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:
Robin Malfait
2025-04-10 22:26:12 +02:00
committed by GitHub
parent ef9c17217e
commit e2a63760aa
5 changed files with 178 additions and 6 deletions
+19 -1
View File
@@ -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",
+5 -3
View File
@@ -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"
} }
} }
+129
View File
@@ -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
View File
@@ -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