Skip to content

Commit e2a6376

Browse files
authored
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.
1 parent ef9c172 commit e2a6376

File tree

5 files changed

+177
-5
lines changed

5 files changed

+177
-5
lines changed

package-lock.json

Lines changed: 19 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/@headlessui-react/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
},
3434
"scripts": {
3535
"prepublishOnly": "npm run build",
36-
"build": "../../scripts/build.sh --external:react --external:react-dom",
37-
"watch": "../../scripts/watch.sh --external:react --external:react-dom",
36+
"build": "../../scripts/build.sh --external:react --external:react-dom --external:use-sync-external-store",
37+
"watch": "../../scripts/watch.sh --external:react --external:react-dom --external:use-sync-external-store",
3838
"test": "../../scripts/test.sh",
3939
"lint": "../../scripts/lint.sh",
4040
"lint-types": "npm run attw -P --workspaces --if-present",
@@ -49,6 +49,7 @@
4949
"@testing-library/react": "^15.0.7",
5050
"@types/react": "^18.3.3",
5151
"@types/react-dom": "^18.3.0",
52+
"@types/use-sync-external-store": "^1.5.0",
5253
"jsdom-testing-mocks": "^1.13.1",
5354
"react": "^18.3.1",
5455
"react-dom": "^18.3.1",
@@ -58,6 +59,7 @@
5859
"@floating-ui/react": "^0.26.16",
5960
"@react-aria/focus": "^3.17.1",
6061
"@react-aria/interactions": "^3.21.3",
61-
"@tanstack/react-virtual": "^3.11.1"
62+
"@tanstack/react-virtual": "^3.11.1",
63+
"use-sync-external-store": "^1.5.0"
6264
}
6365
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { DefaultMap } from './utils/default-map'
2+
import { disposables } from './utils/disposables'
3+
4+
export abstract class Machine<State, Event extends { type: number | string }> {
5+
#state: State = {} as State
6+
#eventSubscribers = new DefaultMap<Event['type'], Set<(state: State, event: Event) => void>>(
7+
() => new Set()
8+
)
9+
#subscribers: Set<Subscriber<State, any>> = new Set()
10+
11+
constructor(initialState: State) {
12+
this.#state = initialState
13+
}
14+
15+
get state(): Readonly<State> {
16+
return this.#state
17+
}
18+
19+
abstract reduce(state: Readonly<State>, event: Event): Readonly<State>
20+
21+
subscribe<Slice>(
22+
selector: (state: Readonly<State>) => Slice,
23+
callback: (state: Slice) => void
24+
): () => void {
25+
let subscriber: Subscriber<State, Slice> = {
26+
selector,
27+
callback,
28+
current: selector(this.#state),
29+
}
30+
this.#subscribers.add(subscriber)
31+
32+
return () => {
33+
this.#subscribers.delete(subscriber)
34+
}
35+
}
36+
37+
on(type: Event['type'], callback: (state: State, event: Event) => void) {
38+
this.#eventSubscribers.get(type).add(callback)
39+
return () => {
40+
this.#eventSubscribers.get(type).delete(callback)
41+
}
42+
}
43+
44+
send(event: Event) {
45+
this.#state = this.reduce(this.#state, event)
46+
47+
for (let subscriber of this.#subscribers) {
48+
let slice = subscriber.selector(this.#state)
49+
if (shallowEqual(subscriber.current, slice)) continue
50+
51+
subscriber.current = slice
52+
subscriber.callback(slice)
53+
}
54+
55+
for (let callback of this.#eventSubscribers.get(event.type)) {
56+
callback(this.#state, event)
57+
}
58+
}
59+
}
60+
61+
interface Subscriber<State, Slice> {
62+
selector: (state: Readonly<State>) => Slice
63+
callback: (state: Slice) => void
64+
current: Slice
65+
}
66+
67+
export function shallowEqual(a: any, b: any): boolean {
68+
// Exact same reference
69+
if (Object.is(a, b)) return true
70+
71+
// Must be some type of object
72+
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) return false
73+
74+
// Arrays
75+
if (Array.isArray(a) && Array.isArray(b)) {
76+
if (a.length !== b.length) return false
77+
return compareEntries(a[Symbol.iterator](), b[Symbol.iterator]())
78+
}
79+
80+
// Map and Set
81+
if ((a instanceof Map && b instanceof Map) || (a instanceof Set && b instanceof Set)) {
82+
if (a.size !== b.size) return false
83+
return compareEntries(a.entries(), b.entries())
84+
}
85+
86+
// Plain objects
87+
if (isPlainObject(a) && isPlainObject(b)) {
88+
return compareEntries(
89+
Object.entries(a)[Symbol.iterator](),
90+
Object.entries(b)[Symbol.iterator]()
91+
)
92+
}
93+
94+
// TODO: Not sure how to compare other types of objects
95+
return false
96+
}
97+
98+
function compareEntries(a: IterableIterator<any>, b: IterableIterator<any>): boolean {
99+
do {
100+
let aResult = a.next()
101+
let bResult = b.next()
102+
103+
if (aResult.done && bResult.done) return true
104+
if (aResult.done || bResult.done) return false
105+
106+
if (!Object.is(aResult.value, bResult.value)) return false
107+
} while (true)
108+
}
109+
110+
function isPlainObject<T>(value: T): value is T & Record<keyof T, unknown> {
111+
if (Object.prototype.toString.call(value) !== '[object Object]') {
112+
return false
113+
}
114+
115+
let prototype = Object.getPrototypeOf(value)
116+
return prototype === null || Object.getPrototypeOf(prototype) === null
117+
}
118+
119+
export function batch<F extends (...args: any[]) => void, P extends any[] = Parameters<F>>(
120+
setup: () => [callback: F, handle: () => void]
121+
) {
122+
let [callback, handle] = setup()
123+
let d = disposables()
124+
return (...args: P) => {
125+
callback(...args)
126+
d.dispose()
127+
d.microTask(handle)
128+
}
129+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'
2+
3+
import { useEvent } from './hooks/use-event'
4+
import { shallowEqual, type Machine } from './machine'
5+
6+
export function useSlice<M extends Machine<any, any>, Slice>(
7+
machine: M,
8+
selector: (state: Readonly<M extends Machine<infer State, any> ? State : never>) => Slice,
9+
compare = shallowEqual
10+
) {
11+
return useSyncExternalStoreWithSelector(
12+
useEvent((onStoreChange) => machine.subscribe(identity, onStoreChange)),
13+
useEvent(() => machine.state),
14+
useEvent(() => machine.state),
15+
useEvent(selector),
16+
compare
17+
)
18+
}
19+
20+
function identity<T>(value: T) {
21+
return value
22+
}

scripts/watch.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ sharedOptions+=("--bundle")
1212
sharedOptions+=("--platform=browser")
1313
sharedOptions+=("--target=es2020")
1414

15+
1516
# Generate actual builds
16-
npx esbuild $input --format=esm --outfile=$outdir/$name.esm.js --sourcemap ${sharedOptions[@]} $@ --watch
17+
NODE_ENV=development npx esbuild $input --format=esm --outfile=$outdir/$name.esm.js --sourcemap ${sharedOptions[@]} $@ --watch
1718

0 commit comments

Comments
 (0)