diff --git a/package-lock.json b/package-lock.json index 4e1d96b..32c841a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json index b56139b..9e02672 100644 --- a/packages/@headlessui-react/package.json +++ b/packages/@headlessui-react/package.json @@ -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" } } diff --git a/packages/@headlessui-react/src/machine.ts b/packages/@headlessui-react/src/machine.ts new file mode 100644 index 0000000..7040125 --- /dev/null +++ b/packages/@headlessui-react/src/machine.ts @@ -0,0 +1,129 @@ +import { DefaultMap } from './utils/default-map' +import { disposables } from './utils/disposables' + +export abstract class Machine { + #state: State = {} as State + #eventSubscribers = new DefaultMap void>>( + () => new Set() + ) + #subscribers: Set> = new Set() + + constructor(initialState: State) { + this.#state = initialState + } + + get state(): Readonly { + return this.#state + } + + abstract reduce(state: Readonly, event: Event): Readonly + + subscribe( + selector: (state: Readonly) => Slice, + callback: (state: Slice) => void + ): () => void { + let subscriber: Subscriber = { + 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 { + selector: (state: Readonly) => 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, b: IterableIterator): 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(value: T): value is T & Record { + 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 void, P extends any[] = Parameters>( + setup: () => [callback: F, handle: () => void] +) { + let [callback, handle] = setup() + let d = disposables() + return (...args: P) => { + callback(...args) + d.dispose() + d.microTask(handle) + } +} diff --git a/packages/@headlessui-react/src/react-glue.tsx b/packages/@headlessui-react/src/react-glue.tsx new file mode 100644 index 0000000..ccd08f8 --- /dev/null +++ b/packages/@headlessui-react/src/react-glue.tsx @@ -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, Slice>( + machine: M, + selector: (state: Readonly ? State : never>) => Slice, + compare = shallowEqual +) { + return useSyncExternalStoreWithSelector( + useEvent((onStoreChange) => machine.subscribe(identity, onStoreChange)), + useEvent(() => machine.state), + useEvent(() => machine.state), + useEvent(selector), + compare + ) +} + +function identity(value: T) { + return value +} diff --git a/scripts/watch.sh b/scripts/watch.sh index 4680ea9..db2a0d2 100755 --- a/scripts/watch.sh +++ b/scripts/watch.sh @@ -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