diff --git a/.changeset/sync-external-store-default.md b/.changeset/sync-external-store-default.md new file mode 100644 index 0000000..5c92d6a --- /dev/null +++ b/.changeset/sync-external-store-default.md @@ -0,0 +1,5 @@ +--- +'@tanstack/redact': patch +--- + +Put `useSyncExternalStoreWithSelector` on the default export. The Vite plugin aliases `use-sync-external-store/shim/with-selector` here, and zustand reads it as a CJS default import (`import shim from '...'; const { useSyncExternalStoreWithSelector } = shim`), so anything built on zustand's `useStoreWithEqualityFn` — React Flow, for one — threw `useSyncExternalStoreWithSelector is not a function`. diff --git a/packages/redact/src/react/index.ts b/packages/redact/src/react/index.ts index 9c07bbb..945d70e 100644 --- a/packages/redact/src/react/index.ts +++ b/packages/redact/src/react/index.ts @@ -104,6 +104,7 @@ import { useTransition, useDeferredValue, useSyncExternalStore, + useSyncExternalStoreWithSelector, use, useActionState, useFormStatus, @@ -141,6 +142,9 @@ export default { useTransition, useDeferredValue, useSyncExternalStore, + // `use-sync-external-store/shim/with-selector` aliases here, and zustand + // reads it as a CJS default import. + useSyncExternalStoreWithSelector, use, useActionState, useFormStatus, diff --git a/tests/default-export-shim.test.tsx b/tests/default-export-shim.test.tsx new file mode 100644 index 0000000..047eb07 --- /dev/null +++ b/tests/default-export-shim.test.tsx @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from 'vitest' +import React from 'react' +import { createRoot } from 'react-dom/client' +import { flushSync } from 'react-dom' + +// zustand reads the aliased `use-sync-external-store` shims as CJS default +// imports, so the helpers have to be on the default export too. +const { useSyncExternalStore, useSyncExternalStoreWithSelector } = React + +const cleanups: Array<() => void> = [] +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup() +}) + +function createStore(initial: T) { + let state = initial + const listeners = new Set<() => void>() + return { + getState: () => state, + setState(next: T) { + state = next + for (const listener of listeners) listener() + }, + subscribe(listener: () => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } +} + +describe('external store helpers on the default export', () => { + it('exposes both shim entry points', () => { + expect(typeof useSyncExternalStore).toBe('function') + expect(typeof useSyncExternalStoreWithSelector).toBe('function') + }) + + it('renders and updates through the selector helper', () => { + const store = createStore({ count: 0, other: 'x' }) + const container = document.createElement('div') + const root = createRoot(container) + cleanups.push(() => { + flushSync(() => root.unmount()) + }) + + let renders = 0 + function Counter() { + renders += 1 + const count = useSyncExternalStoreWithSelector( + store.subscribe, + store.getState, + store.getState, + (state) => state.count, + ) + return {count} + } + + flushSync(() => root.render()) + expect(container.textContent).toBe('0') + + flushSync(() => store.setState({ count: 1, other: 'x' })) + expect(container.textContent).toBe('1') + + const before = renders + flushSync(() => store.setState({ count: 1, other: 'y' })) + expect(container.textContent).toBe('1') + expect(renders).toBe(before) + }) +})