Skip to content

Convert codebase to Typescript.#58

Open
aryaemami59 wants to merge 9 commits intoskortchmark9:masterfrom
aryaemami59:master
Open

Convert codebase to Typescript.#58
aryaemami59 wants to merge 9 commits intoskortchmark9:masterfrom
aryaemami59:master

Conversation

@aryaemami59
Copy link

Hi,
First of all I have to say this library is absolutely awesome and I absolutely love it. As you might have heard, RTK 2.0 is currently in the works and with it, reselect is going to get a major version bump, currently it's in alpha. @markerikson has been doing a lot of great work, he recently converted the reselect codebase to TypeScript, and created 2 new alternate memoization methods. And since this library has been an integral part of redux/reselect ecosystem, I was thinking maybe I could help convert the codebase to TypeScript. I have already created all the types and tested the final build with are-the-types-wrong.
It would be absolutely awesome if you would let me contribute. I would love to know what you think.

These are the types:

import type { OutputSelectorFields, Selector, SelectorArray } from 'reselect'
import type { checkSelector, selectorGraph } from './index'

/**
 * Any function with arguments.
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyFunction = (...args: any[]) => unknown
/**
 * A memoized selector created by calling reselect's `createSelector`.
 */
export type ResultSelector = Selector &
  Partial<OutputSelectorFields<AnyFunction, unknown>>
/**
 * A key value pair object where the keys are selector names and the values are the selectors themselves.
 */
export type SelectorsObject = Record<string, RegisteredSelector>
/**
 * A selector that has been registered using `registerSelectors`.
 */
export type RegisteredSelector = ResultSelector & {
  selectorName?: string
}

export interface Extra {
  inputs?: unknown[]
  output?: ReturnType<RegisteredSelector>
  error?: string
}
/**
 * Information about the selector returned by calling `checkSelector`.
 */
export interface CheckSelectorResults extends Extra {
  dependencies: SelectorArray
  recomputations: number | null
  isNamed: boolean
  selectorName: string | null
}
/**
 * A node is a selector in the tree.
 */
export interface Node {
  recomputations: number | null
  isNamed: boolean
  name: string
}
/**
 * An edge goes from a selector to the selectors it depends on.
 */
export interface Edge {
  from: string
  to: string
}
/**
 * A POJO with nodes and edges providing information about the selectors.
 */
export interface Graph {
  nodes: Record<string, Node>
  edges: Edge[]
}

declare global {
  interface Window {
    /**
     * The dev tools bind to your app via this global.
     * Even without the devtools, you can call `__RESELECT_TOOLS__.checkSelector('mySelector$')` from the developer console or `__RESELECT_TOOLS__.selectorGraph()` to see what's going on.
     */
    __RESELECT_TOOLS__: {
      selectorGraph: typeof selectorGraph
      checkSelector: typeof checkSelector
    }
  }
}

This is the new index.ts file:

import type { Selector } from 'reselect'
import { createSelector } from 'reselect'
import type {
  AnyFunction,
  CheckSelectorResults,
  Extra,
  Graph,
  RegisteredSelector,
  SelectorsObject
} from './types'
export type {
  AnyFunction,
  CheckSelectorResults,
  Edge,
  Extra,
  Graph,
  Node,
  RegisteredSelector,
  ResultSelector,
  SelectorsObject
} from './types'

let _getState: (() => unknown) | null = null
let _allSelectors = new Set<RegisteredSelector>()

const _isFunction = (func: unknown): func is AnyFunction =>
  typeof func === 'function'

/**
 * This function is only exported for legacy purposes.
 * It will be removed in future versions.
 */
export function createSelectorWithDependencies(
  ...args: Parameters<typeof createSelector>
) {
  return createSelector(...args)
}

const _isSelector = (selector: unknown): selector is Selector =>
  (!!selector && typeof selector === 'object' && 'resultFunc' in selector) ||
  _isFunction(selector)

const _addSelector = <S extends RegisteredSelector>(selector: S) => {
  _allSelectors.add(selector)

  const dependencies = selector.dependencies ?? []
  dependencies.forEach(_addSelector)
}
/**
 * Adds named selectors to the graph. It sets selector names as keys and selectors as values.
 * @param selectors A key value pair object where the keys are selector names and the values are the selectors themselves.
 */
export function registerSelectors<S extends SelectorsObject>(selectors: S) {
  Object.keys(selectors).forEach(name => {
    const selector = selectors[name]
    if (_isSelector(selector)) {
      selector.selectorName = name
      _addSelector(selector)
    }
  })
}

export function reset() {
  _getState = null
  _allSelectors = new Set()
}
/**
 * Outputs information about the selector at the given time. By default, outputs only the recomputations of the selector.
 * If you use `getStateWith`, it will output the selector's input and output values. If you use `registerSelectors`, you can pass it the string name of a selector.
 * @param selector Either a selector or the string name of a selector.
 * @returns Information about the selector at the given time.
 */
export function checkSelector(selector: RegisteredSelector | string) {
  if (typeof selector === 'string') {
    for (const possibleSelector of _allSelectors) {
      if (possibleSelector.selectorName === selector) {
        selector = possibleSelector
        break
      }
    }
  }

  if (!_isFunction(selector)) {
    throw new Error(
      `Selector ${JSON.stringify(
        selector
      )} is not a function...has it been registered?`
    )
  }

  const { dependencies = [], selectorName = null } = selector

  const isNamed = typeof selectorName === 'string'
  const recomputations = selector.recomputations
    ? selector.recomputations()
    : null

  const ret: CheckSelectorResults = {
    dependencies,
    recomputations,
    isNamed,
    selectorName
  }
  if (_getState) {
    const extra: Extra = {}
    const state = _getState()

    try {
      extra.inputs = dependencies.map(parentSelector => parentSelector(state))

      try {
        extra.output = selector(state)
      } catch (e) {
        extra.error = `checkSelector: error getting output of selector ${selectorName}. The error was:\n${
          e instanceof TypeError ? e.message : JSON.stringify(e)
        }`
      }
    } catch (e) {
      extra.error = `checkSelector: error getting inputs of selector ${selectorName}. The error was:\n${
        e instanceof TypeError ? e.message : JSON.stringify(e)
      }`
    }

    Object.assign(ret, extra)
  }

  return ret
}
/**
 * Accepts a function which returns the current state. This state is then passed into `checkSelector`. In most cases, this will be `store.getState()`
 * @param stateGetter A function which returns the current state.
 */
export function getStateWith<T extends AnyFunction | null>(stateGetter: T) {
  _getState = stateGetter
}

function _sumString(str: AnyFunction) {
  return Array.from(str.toString()).reduce(
    (sum, char) => char.charCodeAt(0) + sum,
    0
  )
}
/**
 * Looks for a function name, then a match in the registry, and finally resorts to calling `toString` on the selector's `resultFunc`.
 * @param selector The selector whose name will be extracted as the key.
 * @returns The name of the function or it's `resultFunc` stringified.
 */
const defaultSelectorKey = (selector: RegisteredSelector) => {
  if (selector.selectorName) {
    return selector.selectorName
  }

  if (selector.name) {
    // if it's a vanilla function, it will have a name.
    return selector.name
  }

  return (selector.dependencies ?? []).reduce(
    (base, dep) => {
      return base + _sumString(dep)
    },
    (selector.resultFunc ? selector.resultFunc : selector).toString()
  )
}
/**
 * Outputs a POJO with nodes and edges. A node is a selector in the tree, and an edge goes from a selector to the selectors it depends on.
 * @param selectorKey An optional callback function that takes a selector and outputs a string which must be unique and consistent for a given selector. @default defaultSelectorKey
 * @returns A graph which is a POJO with nodes and edges. A node is a selector in the tree, and an edge goes from a selector to the selectors it depends on.
 */
export function selectorGraph(selectorKey = defaultSelectorKey) {
  const graph: Graph = { nodes: {}, edges: [] }
  const addToGraph = <S extends RegisteredSelector>(selector: S) => {
    const name = selectorKey(selector)
    if (graph.nodes[name]) return
    const { recomputations, isNamed } = checkSelector(selector)
    graph.nodes[name] = {
      recomputations,
      isNamed,
      name
    }

    const dependencies = selector.dependencies ?? []
    dependencies.forEach(dependency => {
      addToGraph(dependency)
      graph.edges.push({
        from: name,
        to: selectorKey(dependency)
      })
    })
  }

  for (const selector of _allSelectors) {
    addToGraph(selector)
  }
  return graph
}

// hack for devtools
/* istanbul ignore if */
if (typeof window !== 'undefined') {
  window.__RESELECT_TOOLS__ = {
    selectorGraph,
    checkSelector
  }
}

and this is the index.test.ts file:

import { createSelector } from 'reselect'
import {
  checkSelector,
  createSelectorWithDependencies,
  getStateWith,
  registerSelectors,
  reset,
  selectorGraph
} from '../src/index'

import type {
  RegisteredSelector,
  ResultSelector,
  SelectorsObject
} from 'src/types'
import { assert, beforeEach, suite, test } from 'vitest'

beforeEach(reset)

suite('registerSelectors', () => {
  test('allows you to name selectors', () => {
    const foo = (() => 'foo') as unknown as RegisteredSelector
    const bar = createSelector(
      foo,
      () => 'bar'
    ) as unknown as RegisteredSelector
    const baz = createSelector(
      bar,
      foo,
      () => 'baz'
    ) as unknown as RegisteredSelector
    registerSelectors({ foo, bar, bazinga: baz })

    assert.equal(foo.selectorName, 'foo')
    assert.equal(bar.selectorName, 'bar')
    assert.equal(baz.selectorName, 'bazinga')
  })

  test('ignores inputs which are not selectors or functions', () => {
    const foo = () => 'foo'
    const bar = createSelector(foo, () => 'bar')
    const utilities = {
      identity: (x: unknown) => x
    } as unknown as RegisteredSelector
    const selectors = { foo, bar, utilities }
    registerSelectors(selectors)

    assert.isUndefined(utilities.selectorName)
  })

  test('ignores inputs which are null', () => {
    const foo = () => 'foo'
    const bar = createSelector(foo, () => 'bar')
    const selectors = { foo, bar, property: null } as unknown as SelectorsObject
    registerSelectors(selectors)
  })

  test('can be called additively', () => {
    const foo = (() => 'foo') as RegisteredSelector
    const bar = createSelector(foo, () => 'bar') as RegisteredSelector
    const baz = createSelector(bar, foo, () => 'bar') as RegisteredSelector

    registerSelectors({ foo, bar })
    assert.equal(foo.selectorName, 'foo')

    registerSelectors({ baz })
    registerSelectors({ hat: foo })
    assert.equal(foo.selectorName, 'hat')
    assert.equal(bar.selectorName, 'bar')
    assert.equal(baz.selectorName, 'baz')
  })
})

suite('createSelectorWithDependencies', () => {
  test('it is just exported for legacy purposes', () => {
    const four = () => 4
    let calls1 = 0
    let calls2 = 0
    const selector1 = createSelector(four, () => calls1++)
    // @ts-expect-error
    const selector2 = createSelectorWithDependencies(four, () => calls2++)
    // @ts-expect-error
    selector1()
    // @ts-expect-error
    selector1()
    // @ts-expect-error
    selector2()
    // @ts-expect-error
    selector2()
    assert.equal(calls1, calls2)
  })
})

suite('checkSelector', () => {
  test("it outputs a selector's dependencies, even if it's a plain function", () => {
    const foo = () => 'foo'
    const bar = createSelector(foo, () => 'bar')

    assert.equal(checkSelector(foo).dependencies.length, 0)

    assert.equal(checkSelector(bar).dependencies.length, 1)
    assert.equal(checkSelector(bar).dependencies[0], foo)
  })

  test('if you give it a way of getting state, it also gets inputs and outputs', () => {
    interface State {
      foo: {
        baz: number
      }
    }
    const state: State = {
      foo: {
        baz: 1
      }
    }

    getStateWith(() => state)

    const foo = (state: State) => state.foo
    const bar = createSelector(foo, foo => foo.baz)

    const checkedFoo = checkSelector(foo)
    assert.equal(checkedFoo.inputs?.length, 0)
    assert.deepEqual(checkedFoo.output, { baz: 1 })
    assert.deepEqual(checkedFoo.output, foo(state))

    const checkedBar = checkSelector(bar)
    assert.deepEqual(checkedBar.inputs, [{ baz: 1 }])
    assert.equal(checkedBar.output, 1)
    assert.deepEqual(checkedBar.output, bar(state))

    getStateWith(null)
  })

  test('it returns the number of recomputations for a given selector', () => {
    interface State {
      foo: {
        baz: number
      }
    }
    const foo = (state: State) => state.foo
    const bar = createSelector(foo, foo => foo.baz)
    assert.equal(bar.recomputations(), 0)

    const state: State = {
      foo: {
        baz: 1
      }
    }
    getStateWith(() => state)

    bar(state)
    assert.equal(bar.recomputations(), 1)
    bar(state)

    assert.deepEqual(checkSelector(bar), {
      dependencies: [foo],
      inputs: [{ baz: 1 }],
      output: 1,
      recomputations: 1,
      isNamed: false,
      selectorName: null
    })

    const newState: State = {
      foo: {
        baz: 2
      }
    }
    getStateWith(() => newState)

    bar(newState)
    assert.equal(bar.recomputations(), 2)

    bar(newState)
    assert.deepEqual(checkSelector(bar), {
      dependencies: [foo],
      inputs: [{ baz: 2 }],
      output: 2,
      recomputations: 2,
      isNamed: false,
      selectorName: null
    })
  })

  test("it allows you to pass in a string name of a selector if you've registered", () => {
    interface State {
      foo: number
    }
    const foo = (state: State) => state.foo
    const bar = createSelector(foo, foo => foo + 1)
    registerSelectors({ bar })
    getStateWith(() => ({ foo: 1 }))
    const checked = checkSelector('bar')
    assert.deepEqual(checked, {
      dependencies: [foo],
      inputs: [1],
      output: 2,
      recomputations: 0,
      isNamed: true,
      selectorName: 'bar'
    })
  })

  test('it throws if you try to check a non-existent selector', () => {
    interface State {
      foo: number
    }
    const foo = (state: State) => state.foo
    const bar = createSelector(foo, foo => foo + 1)
    registerSelectors({ bar })
    assert.throws(() => checkSelector('baz'))
  })

  test('it throws if you try to check a non-function', () => {
    // @ts-expect-error
    assert.throws(() => checkSelector(1))
  })

  test('it tells you whether or not a selector has been registered', () => {
    const one$ = () => 1
    const two$ = createSelector(one$, one => one + 1)
    registerSelectors({ one$ })

    assert.equal(checkSelector(() => 1).isNamed, false)

    assert.equal(checkSelector(two$).isNamed, false)
    registerSelectors({ two$ })
    assert.equal(checkSelector(two$).isNamed, true)
  })

  test('it catches errors inside parent selector functions and exposes them', () => {
    interface State {
      foo: {
        bar: number
      }
    }
    const badParentSelector$ = (state: State) => state.foo.bar
    const badSelector$ = createSelector(badParentSelector$, foo => foo)
    getStateWith(() => [])
    registerSelectors({ badSelector$ })

    const checked = checkSelector('badSelector$')
    assert.equal(
      checked.error,
      "checkSelector: error getting inputs of selector badSelector$. The error was:\nCannot read properties of undefined (reading 'bar')"
    )
  })

  test('it catches errors inside selector functions and exposes them', () => {
    interface State {
      foo: {
        bar: number
      }
    }
    const badSelector$ = (state: State) => state.foo.bar
    getStateWith(() => [])
    registerSelectors({ badSelector$ })

    const checked = checkSelector('badSelector$')
    assert.equal(
      checked.error,
      'checkSelector: error getting output of selector badSelector$. The error was:\n' +
        "Cannot read properties of undefined (reading 'bar')"
    )
  })
})

suite('selectorGraph', () => {
  interface State {
    data: {
      users: [{ pets: number[] }]
      pets: number[]
    }
    ui: {
      currentUser: number
    }
  }
  function createMockSelectors() {
    const data$ = (state: State) => state.data
    const ui$ = (state: State) => state.ui
    const users$ = createSelector(data$, data => data.users)
    const pets$ = createSelector(data$, ({ pets }) => pets)
    const currentUser$ = createSelector(
      ui$,
      users$,
      (ui, users) => users[ui.currentUser]
    )
    const currentUserPets$ = createSelector(
      currentUser$,
      pets$,
      (currentUser, pets) => currentUser!.pets.map(petId => pets[petId]!)
    )
    const random$ = () => 1
    const thingy$ = createSelector(random$, number => number + 1)
    const booya$ = createSelector(thingy$, currentUser$, () => 'booya!')
    const selectors = {
      data$,
      ui$,
      users$,
      pets$,
      currentUser$,
      currentUserPets$,
      random$,
      thingy$,
      booya$
    }
    // @ts-expect-error
    registerSelectors(selectors)
    return selectors
  }

  test('returns an empty graph if no selectors are registered', () => {
    const { edges, nodes } = selectorGraph()
    assert.equal(Object.keys(nodes).length, 0)
    assert.equal(edges.length, 0)
  })

  test('walks up the tree if you register a single selector', () => {
    function parent() {
      return 'parent'
    }
    const child$ = createSelector(parent, string => string)
    registerSelectors({ child$ })
    const { edges, nodes } = selectorGraph()
    assert.equal(Object.keys(nodes).length, 2)
    assert.equal(edges.length, 1)
  })

  test('it outputs a selector graph', () => {
    const selectors = createMockSelectors()

    const { edges, nodes } = selectorGraph()
    assert.equal(Object.keys(nodes).length, Object.keys(selectors).length)
    assert.equal(edges.length, 9)
  })

  test('allows you to pass in a different selector key function', () => {
    type DummySelector = ResultSelector & { idx: number }
    function idxSelectorKey(selector: DummySelector) {
      return selector.idx
    }

    const selectors = createMockSelectors()
    type Keys = (keyof typeof selectors)[]
    ;(Object.keys(selectors) as Keys).sort().forEach((key, i) => {
      const selector = selectors[key]
      // @ts-expect-error
      selector.idx = i
    })

    // @ts-expect-error
    const { nodes } = selectorGraph(idxSelectorKey)
    assert.equal(Object.keys(nodes).length, 9)
  })

  suite('defaultSelectorKey', () => {
    test('it names the nodes based on their string name by default', () => {
      createMockSelectors()
      const { nodes } = selectorGraph()

      // comes from func.name for top-level vanilla selector functions.
      assert.equal(nodes['data$']?.recomputations, null)
    })

    test('it falls back to toString on anonymous functions', () => {
      const selector1 = createSelector(
        () => 1,
        one => one + 1
      )
      registerSelectors({ selector1 })
      const { nodes } = selectorGraph()
      const keys = Object.keys(nodes)
      assert.equal(keys.length, 2)

      // [ 'selector1', 'function () {\n        return 1;\n      }' ]
      for (const key of keys) {
        assert.include(key, '1')
      }
    })

    test('it creates numeric names for unregistered selectors', () => {
      const foo$ = createSelector(() => 'foo')
      // @ts-expect-error
      const unregistered$ = createSelector(foo$, () => 1)
      const registered$ = createSelector(unregistered$, () => 3)

      // @ts-expect-error
      registerSelectors({ registered$, foo$ })
      const { nodes } = selectorGraph()
      const keys = Object.keys(nodes)
      assert.equal(keys.length, 3)

      // please let's do better!
      // assert.isDefined(nodes['function () {\n        return 1;\n      }22074'])
      // Replace with:
      assert.isDefined(nodes['memoized']) // In reselect v5.0.0-alpha.2 the anonymous function has been given the name `memoized`.
    })

    test("doesn't duplicate nodes if they are different", () => {
      interface State {
        foo: number
      }
      const foo$ = (state: State) => state.foo // node1
      const select = () => 1 // node 2
      createSelector(foo$, select)
      createSelector(select) // node 3
      registerSelectors({ foo$, baz: select })
      const { nodes } = selectorGraph()
      assert.equal(Object.keys(nodes).length, 2)
    })

    test('it names the nodes based on entries in the registry if they are there', () => {
      createMockSelectors()
      const { edges } = selectorGraph()

      const expectedEdges = [
        { from: 'users$', to: 'data$' },
        { from: 'pets$', to: 'data$' },
        { from: 'currentUser$', to: 'ui$' },
        { from: 'currentUser$', to: 'users$' },
        { from: 'currentUserPets$', to: 'currentUser$' },
        { from: 'currentUserPets$', to: 'pets$' },
        { from: 'thingy$', to: 'random$' },
        { from: 'booya$', to: 'thingy$' },
        { from: 'booya$', to: 'currentUser$' }
      ]
      assert.sameDeepMembers(edges, expectedEdges)
    })
  })
})

Please let me know what you think and if there is anything else I can do, I would be more than happy to help.

@aryaemami59 aryaemami59 marked this pull request as ready for review September 27, 2023 20:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant