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": "*"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "15.0.19",
|
||||
"dev": true,
|
||||
@@ -11061,6 +11068,15 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"license": "MIT"
|
||||
@@ -11508,12 +11524,14 @@
|
||||
"@floating-ui/react": "^0.26.16",
|
||||
"@react-aria/focus": "^3.17.1",
|
||||
"@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": {
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/use-sync-external-store": "^1.5.0",
|
||||
"jsdom-testing-mocks": "^1.13.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "npm run build",
|
||||
"build": "../../scripts/build.sh --external:react --external:react-dom",
|
||||
"watch": "../../scripts/watch.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 --external:use-sync-external-store",
|
||||
"test": "../../scripts/test.sh",
|
||||
"lint": "../../scripts/lint.sh",
|
||||
"lint-types": "npm run attw -P --workspaces --if-present",
|
||||
@@ -49,6 +49,7 @@
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/use-sync-external-store": "^1.5.0",
|
||||
"jsdom-testing-mocks": "^1.13.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -58,6 +59,7 @@
|
||||
"@floating-ui/react": "^0.26.16",
|
||||
"@react-aria/focus": "^3.17.1",
|
||||
"@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+=("--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