Prepare for performance improvements (#3684)
This PR is just a chore to prepare for future performance optimizations. Essentially I want to improve the performance of the `Menu`, `Listbox` and `Combobox` components but I want to do it in separate PRs such that reverting the improvements can be done if needed. This PR just sets up a `Machine` for state machines, and adds some helpers such as a `useSlice` to calculate parts of the state machine. Component using the `useSlice` will only re-render _if_ the slice changes. So apart from adding a library (`useSyncExternalStoreWithSelector`) and adding some setup code. Nothing in this PR changes the behavior of the components.
This commit is contained in:
Generated
+19
-1
@@ -2874,6 +2874,13 @@
|
|||||||
"@types/jest": "*"
|
"@types/jest": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "15.0.19",
|
"version": "15.0.19",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -11061,6 +11068,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -11508,12 +11524,14 @@
|
|||||||
"@floating-ui/react": "^0.26.16",
|
"@floating-ui/react": "^0.26.16",
|
||||||
"@react-aria/focus": "^3.17.1",
|
"@react-aria/focus": "^3.17.1",
|
||||||
"@react-aria/interactions": "^3.21.3",
|
"@react-aria/interactions": "^3.21.3",
|
||||||
"@tanstack/react-virtual": "^3.11.1"
|
"@tanstack/react-virtual": "^3.11.1",
|
||||||
|
"use-sync-external-store": "^1.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/react": "^15.0.7",
|
"@testing-library/react": "^15.0.7",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/use-sync-external-store": "^1.5.0",
|
||||||
"jsdom-testing-mocks": "^1.13.1",
|
"jsdom-testing-mocks": "^1.13.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -33,8 +33,8 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepublishOnly": "npm run build",
|
"prepublishOnly": "npm run build",
|
||||||
"build": "../../scripts/build.sh --external:react --external:react-dom",
|
"build": "../../scripts/build.sh --external:react --external:react-dom --external:use-sync-external-store",
|
||||||
"watch": "../../scripts/watch.sh --external:react --external:react-dom",
|
"watch": "../../scripts/watch.sh --external:react --external:react-dom --external:use-sync-external-store",
|
||||||
"test": "../../scripts/test.sh",
|
"test": "../../scripts/test.sh",
|
||||||
"lint": "../../scripts/lint.sh",
|
"lint": "../../scripts/lint.sh",
|
||||||
"lint-types": "npm run attw -P --workspaces --if-present",
|
"lint-types": "npm run attw -P --workspaces --if-present",
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
"@testing-library/react": "^15.0.7",
|
"@testing-library/react": "^15.0.7",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/use-sync-external-store": "^1.5.0",
|
||||||
"jsdom-testing-mocks": "^1.13.1",
|
"jsdom-testing-mocks": "^1.13.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
"@floating-ui/react": "^0.26.16",
|
"@floating-ui/react": "^0.26.16",
|
||||||
"@react-aria/focus": "^3.17.1",
|
"@react-aria/focus": "^3.17.1",
|
||||||
"@react-aria/interactions": "^3.21.3",
|
"@react-aria/interactions": "^3.21.3",
|
||||||
"@tanstack/react-virtual": "^3.11.1"
|
"@tanstack/react-virtual": "^3.11.1",
|
||||||
|
"use-sync-external-store": "^1.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { DefaultMap } from './utils/default-map'
|
||||||
|
import { disposables } from './utils/disposables'
|
||||||
|
|
||||||
|
export abstract class Machine<State, Event extends { type: number | string }> {
|
||||||
|
#state: State = {} as State
|
||||||
|
#eventSubscribers = new DefaultMap<Event['type'], Set<(state: State, event: Event) => void>>(
|
||||||
|
() => new Set()
|
||||||
|
)
|
||||||
|
#subscribers: Set<Subscriber<State, any>> = new Set()
|
||||||
|
|
||||||
|
constructor(initialState: State) {
|
||||||
|
this.#state = initialState
|
||||||
|
}
|
||||||
|
|
||||||
|
get state(): Readonly<State> {
|
||||||
|
return this.#state
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract reduce(state: Readonly<State>, event: Event): Readonly<State>
|
||||||
|
|
||||||
|
subscribe<Slice>(
|
||||||
|
selector: (state: Readonly<State>) => Slice,
|
||||||
|
callback: (state: Slice) => void
|
||||||
|
): () => void {
|
||||||
|
let subscriber: Subscriber<State, Slice> = {
|
||||||
|
selector,
|
||||||
|
callback,
|
||||||
|
current: selector(this.#state),
|
||||||
|
}
|
||||||
|
this.#subscribers.add(subscriber)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this.#subscribers.delete(subscriber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on(type: Event['type'], callback: (state: State, event: Event) => void) {
|
||||||
|
this.#eventSubscribers.get(type).add(callback)
|
||||||
|
return () => {
|
||||||
|
this.#eventSubscribers.get(type).delete(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send(event: Event) {
|
||||||
|
this.#state = this.reduce(this.#state, event)
|
||||||
|
|
||||||
|
for (let subscriber of this.#subscribers) {
|
||||||
|
let slice = subscriber.selector(this.#state)
|
||||||
|
if (shallowEqual(subscriber.current, slice)) continue
|
||||||
|
|
||||||
|
subscriber.current = slice
|
||||||
|
subscriber.callback(slice)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let callback of this.#eventSubscribers.get(event.type)) {
|
||||||
|
callback(this.#state, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Subscriber<State, Slice> {
|
||||||
|
selector: (state: Readonly<State>) => Slice
|
||||||
|
callback: (state: Slice) => void
|
||||||
|
current: Slice
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shallowEqual(a: any, b: any): boolean {
|
||||||
|
// Exact same reference
|
||||||
|
if (Object.is(a, b)) return true
|
||||||
|
|
||||||
|
// Must be some type of object
|
||||||
|
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) return false
|
||||||
|
|
||||||
|
// Arrays
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
return compareEntries(a[Symbol.iterator](), b[Symbol.iterator]())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map and Set
|
||||||
|
if ((a instanceof Map && b instanceof Map) || (a instanceof Set && b instanceof Set)) {
|
||||||
|
if (a.size !== b.size) return false
|
||||||
|
return compareEntries(a.entries(), b.entries())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain objects
|
||||||
|
if (isPlainObject(a) && isPlainObject(b)) {
|
||||||
|
return compareEntries(
|
||||||
|
Object.entries(a)[Symbol.iterator](),
|
||||||
|
Object.entries(b)[Symbol.iterator]()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Not sure how to compare other types of objects
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareEntries(a: IterableIterator<any>, b: IterableIterator<any>): boolean {
|
||||||
|
do {
|
||||||
|
let aResult = a.next()
|
||||||
|
let bResult = b.next()
|
||||||
|
|
||||||
|
if (aResult.done && bResult.done) return true
|
||||||
|
if (aResult.done || bResult.done) return false
|
||||||
|
|
||||||
|
if (!Object.is(aResult.value, bResult.value)) return false
|
||||||
|
} while (true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject<T>(value: T): value is T & Record<keyof T, unknown> {
|
||||||
|
if (Object.prototype.toString.call(value) !== '[object Object]') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let prototype = Object.getPrototypeOf(value)
|
||||||
|
return prototype === null || Object.getPrototypeOf(prototype) === null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function batch<F extends (...args: any[]) => void, P extends any[] = Parameters<F>>(
|
||||||
|
setup: () => [callback: F, handle: () => void]
|
||||||
|
) {
|
||||||
|
let [callback, handle] = setup()
|
||||||
|
let d = disposables()
|
||||||
|
return (...args: P) => {
|
||||||
|
callback(...args)
|
||||||
|
d.dispose()
|
||||||
|
d.microTask(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'
|
||||||
|
|
||||||
|
import { useEvent } from './hooks/use-event'
|
||||||
|
import { shallowEqual, type Machine } from './machine'
|
||||||
|
|
||||||
|
export function useSlice<M extends Machine<any, any>, Slice>(
|
||||||
|
machine: M,
|
||||||
|
selector: (state: Readonly<M extends Machine<infer State, any> ? State : never>) => Slice,
|
||||||
|
compare = shallowEqual
|
||||||
|
) {
|
||||||
|
return useSyncExternalStoreWithSelector(
|
||||||
|
useEvent((onStoreChange) => machine.subscribe(identity, onStoreChange)),
|
||||||
|
useEvent(() => machine.state),
|
||||||
|
useEvent(() => machine.state),
|
||||||
|
useEvent(selector),
|
||||||
|
compare
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function identity<T>(value: T) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
+3
-2
@@ -12,6 +12,7 @@ sharedOptions+=("--bundle")
|
|||||||
sharedOptions+=("--platform=browser")
|
sharedOptions+=("--platform=browser")
|
||||||
sharedOptions+=("--target=es2020")
|
sharedOptions+=("--target=es2020")
|
||||||
|
|
||||||
# Generate actual builds
|
|
||||||
npx esbuild $input --format=esm --outfile=$outdir/$name.esm.js --sourcemap ${sharedOptions[@]} $@ --watch
|
# Generate actual builds
|
||||||
|
NODE_ENV=development npx esbuild $input --format=esm --outfile=$outdir/$name.esm.js --sourcemap ${sharedOptions[@]} $@ --watch
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user