@assistant-ui/store
Advanced tools
| // @vitest-environment jsdom | ||
| import type { ReactNode } from "react"; | ||
| import { act, render } from "@testing-library/react"; | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import { AuiProvider } from "../utils/react-assistant-context"; | ||
| import { RenderChildrenWithAccessor } from "../RenderChildrenWithAccessor"; | ||
| import { PROXIED_ASSISTANT_STATE_SYMBOL } from "../utils/proxied-assistant-state"; | ||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
| type Listener = () => void; | ||
| const createTestAuiClient = () => { | ||
| const listeners = new Set<Listener>(); | ||
| let itemState: { value: number; isEditing: boolean } = { | ||
| value: 1, | ||
| isEditing: false, | ||
| }; | ||
| const proxiedState = { | ||
| item: itemState, | ||
| }; | ||
| const client = { | ||
| subscribe: (listener: Listener) => { | ||
| listeners.add(listener); | ||
| return () => listeners.delete(listener); | ||
| }, | ||
| on: () => () => {}, | ||
| [PROXIED_ASSISTANT_STATE_SYMBOL]: proxiedState, | ||
| } as const; | ||
| return { | ||
| client, | ||
| getItemState: () => itemState, | ||
| update: (next: Partial<typeof itemState>) => { | ||
| itemState = { ...itemState, ...next }; | ||
| proxiedState.item = itemState; | ||
| // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach callback intentionally has no return | ||
| listeners.forEach((listener) => listener()); | ||
| }, | ||
| }; | ||
| }; | ||
| describe("RenderChildrenWithAccessor", () => { | ||
| it("re-renders when accessed state updates (regression: issue #3838)", () => { | ||
| const testClient = createTestAuiClient(); | ||
| const wrapper = ({ children }: { children: ReactNode }) => ( | ||
| <AuiProvider value={testClient.client as never}>{children}</AuiProvider> | ||
| ); | ||
| const { container } = render( | ||
| <RenderChildrenWithAccessor | ||
| getItemState={() => testClient.getItemState()} | ||
| > | ||
| {(getItem) => { | ||
| const item = getItem(); | ||
| return <div>{item.isEditing ? "editing" : "viewing"}</div>; | ||
| }} | ||
| </RenderChildrenWithAccessor>, | ||
| { wrapper }, | ||
| ); | ||
| expect(container.textContent).toBe("viewing"); | ||
| act(() => { | ||
| testClient.update({ isEditing: true }); | ||
| }); | ||
| expect(container.textContent).toBe("editing"); | ||
| act(() => { | ||
| testClient.update({ isEditing: false }); | ||
| }); | ||
| expect(container.textContent).toBe("viewing"); | ||
| }); | ||
| it("does not schedule an extra render on first access (initial snapshot matches getItemState)", () => { | ||
| const testClient = createTestAuiClient(); | ||
| const wrapper = ({ children }: { children: ReactNode }) => ( | ||
| <AuiProvider value={testClient.client as never}>{children}</AuiProvider> | ||
| ); | ||
| const renderSpy = vi.fn(); | ||
| render( | ||
| <RenderChildrenWithAccessor | ||
| getItemState={() => testClient.getItemState()} | ||
| > | ||
| {(getItem) => { | ||
| renderSpy(); | ||
| const item = getItem(); | ||
| return <div>{item.value}</div>; | ||
| }} | ||
| </RenderChildrenWithAccessor>, | ||
| { wrapper }, | ||
| ); | ||
| // first mount accesses the item; useSyncExternalStore's post-commit | ||
| // tearing check should see a stable snapshot and not force a re-render | ||
| expect(renderSpy).toHaveBeenCalledTimes(1); | ||
| }); | ||
| it("does not re-render when item is never accessed", () => { | ||
| const testClient = createTestAuiClient(); | ||
| const wrapper = ({ children }: { children: ReactNode }) => ( | ||
| <AuiProvider value={testClient.client as never}>{children}</AuiProvider> | ||
| ); | ||
| const renderSpy = vi.fn(); | ||
| render( | ||
| <RenderChildrenWithAccessor | ||
| getItemState={() => testClient.getItemState()} | ||
| > | ||
| {() => { | ||
| renderSpy(); | ||
| return <div>static</div>; | ||
| }} | ||
| </RenderChildrenWithAccessor>, | ||
| { wrapper }, | ||
| ); | ||
| const initialRenderCount = renderSpy.mock.calls.length; | ||
| act(() => { | ||
| testClient.update({ value: 99 }); | ||
| }); | ||
| expect(renderSpy.mock.calls.length).toBe(initialRenderCount); | ||
| }); | ||
| }); |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"RenderChildrenWithAccessor.d.ts","sourceRoot":"","sources":["../src/RenderChildrenWithAccessor.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,SAAS,EAAmB,MAAM,OAAO,CAAC;AACxD,OAAO,KAAK,EAAE,eAAe,EAAE,0BAAuB;AAItD,eAAO,MAAM,kBAAkB,GAAI,CAAC,EAClC,cAAc,CAAC,GAAG,EAAE,eAAe,KAAK,CAAC,YAkB1C,CAAC;AAIF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,0BAA0B,CAAC,CAAC,EAAE,EAC5C,YAAY,EACZ,QAAQ,GACT,EAAE;IACD,YAAY,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,CAAC,CAAC;IAC1C,QAAQ,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,KAAK,SAAS,CAAC;CAC3C,GAAG,SAAS,CAGZ"} | ||
| {"version":3,"file":"RenderChildrenWithAccessor.d.ts","sourceRoot":"","sources":["../src/RenderChildrenWithAccessor.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,SAAS,EAAmB,MAAM,OAAO,CAAC;AACxD,OAAO,KAAK,EAAE,eAAe,EAAE,0BAAuB;AAItD,eAAO,MAAM,kBAAkB,GAAI,CAAC,EAClC,cAAc,CAAC,GAAG,EAAE,eAAe,KAAK,CAAC,YAoB1C,CAAC;AAIF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,0BAA0B,CAAC,CAAC,EAAE,EAC5C,YAAY,EACZ,QAAQ,GACT,EAAE;IACD,YAAY,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,CAAC,CAAC;IAC1C,QAAQ,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,KAAK,SAAS,CAAC;CAC3C,GAAG,SAAS,CAGZ"} |
@@ -7,12 +7,16 @@ "use client"; | ||
| const aui = useAui(); | ||
| // if the consumer never accesses the item, do not trigger rerenders | ||
| const cacheRef = useRef(undefined); | ||
| // Track access with a dedicated flag: | ||
| // useSyncExternalStore may call getSnapshot() after commit (tearing checks), | ||
| // which would re-cache the current state and mask later real updates. | ||
| // Use the current state as the pre-access snapshot so the post-commit check | ||
| // matches getItemState(aui) and doesn't schedule an unnecessary re-render. | ||
| const accessedRef = useRef(false); | ||
| const currentValue = accessedRef.current ? null : getItemState(aui); | ||
| useAuiState(() => { | ||
| if (cacheRef.current === undefined) { | ||
| cacheRef.current = getItemState(aui); | ||
| } | ||
| return cacheRef.current; | ||
| if (!accessedRef.current) | ||
| return currentValue; | ||
| return getItemState(aui); | ||
| }); | ||
| return () => { | ||
| cacheRef.current = undefined; // clear the cache (rerender on next state change) | ||
| accessedRef.current = true; | ||
| return getItemState(aui); | ||
@@ -19,0 +23,0 @@ }; |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"RenderChildrenWithAccessor.js","sourceRoot":"","sources":["../src/RenderChildrenWithAccessor.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAkB,OAAO,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AAExD,OAAO,EAAE,WAAW,EAAE,yBAAsB;AAC5C,OAAO,EAAE,MAAM,EAAE,oBAAiB;AAElC,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAChC,YAAyC,EACzC,EAAE;IACF,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC;IAErB,oEAAoE;IACpE,MAAM,QAAQ,GAAG,MAAM,CAAgB,SAAS,CAAC,CAAC;IAClD,WAAW,CAAC,GAAG,EAAE;QACf,IAAI,QAAQ,CAAC,OAAO,KAAK,SAAS,EAAE,CAAC;YACnC,QAAQ,CAAC,OAAO,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;QACvC,CAAC;QACD,OAAO,QAAQ,CAAC,OAAO,CAAC;IAC1B,CAAC,CAAC,CAAC;IAEH,OAAO,GAAG,EAAE;QACV,QAAQ,CAAC,OAAO,GAAG,SAAS,CAAC,CAAC,kDAAkD;QAEhF,OAAO,YAAY,CAAC,GAAG,CAAC,CAAC;IAC3B,CAAC,CAAC;AACJ,CAAC,CAAC;AAEF,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;AAEvC;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,0BAA0B,CAAI,EAC5C,YAAY,EACZ,QAAQ,GAIT;IACC,MAAM,OAAO,GAAG,kBAAkB,CAAC,YAAY,CAAC,CAAC;IACjD,OAAO,4BAA4B,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;AACzD,CAAC;AAED,MAAM,4BAA4B,GAAG,CAAC,IAAe,EAAE,EAAE;IACvD,MAAM,EAAE,GACN,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,IAAI,IAAI,IAAI,MAAM,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;IAC3E,MAAM,UAAU,GAAG,EAAE,EAAE,IAAI,CAAC;IAC5B,MAAM,SAAS,GAAG,EAAE,EAAE,GAAG,CAAC;IAC1B,MAAM,WAAW,GACf,OAAO,EAAE,EAAE,KAAK,KAAK,QAAQ;QAC7B,EAAE,CAAC,KAAK,IAAI,IAAI;QAChB,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,MAAM,KAAK,CAAC;QACnC,CAAC,CAAC,YAAY;QACd,CAAC,CAAC,EAAE,EAAE,KAAK,CAAC;IAEhB,OAAO;IACL,wEAAwE;IACxE,OAAO,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,UAAU,EAAE,SAAS,EAAE,WAAW,CAAC,CAAC,IAAI,IAAI,CAChE,CAAC;AACJ,CAAC,CAAC"} | ||
| {"version":3,"file":"RenderChildrenWithAccessor.js","sourceRoot":"","sources":["../src/RenderChildrenWithAccessor.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAkB,OAAO,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AAExD,OAAO,EAAE,WAAW,EAAE,yBAAsB;AAC5C,OAAO,EAAE,MAAM,EAAE,oBAAiB;AAElC,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAChC,YAAyC,EACzC,EAAE;IACF,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC;IAErB,sCAAsC;IACtC,6EAA6E;IAC7E,sEAAsE;IACtE,4EAA4E;IAC5E,2EAA2E;IAC3E,MAAM,WAAW,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAClC,MAAM,YAAY,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC;IACpE,WAAW,CAAC,GAAG,EAAE;QACf,IAAI,CAAC,WAAW,CAAC,OAAO;YAAE,OAAO,YAAY,CAAC;QAC9C,OAAO,YAAY,CAAC,GAAG,CAAC,CAAC;IAC3B,CAAC,CAAC,CAAC;IAEH,OAAO,GAAG,EAAE;QACV,WAAW,CAAC,OAAO,GAAG,IAAI,CAAC;QAC3B,OAAO,YAAY,CAAC,GAAG,CAAC,CAAC;IAC3B,CAAC,CAAC;AACJ,CAAC,CAAC;AAEF,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;AAEvC;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,0BAA0B,CAAI,EAC5C,YAAY,EACZ,QAAQ,GAIT;IACC,MAAM,OAAO,GAAG,kBAAkB,CAAC,YAAY,CAAC,CAAC;IACjD,OAAO,4BAA4B,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;AACzD,CAAC;AAED,MAAM,4BAA4B,GAAG,CAAC,IAAe,EAAE,EAAE;IACvD,MAAM,EAAE,GACN,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,IAAI,IAAI,IAAI,MAAM,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;IAC3E,MAAM,UAAU,GAAG,EAAE,EAAE,IAAI,CAAC;IAC5B,MAAM,SAAS,GAAG,EAAE,EAAE,GAAG,CAAC;IAC1B,MAAM,WAAW,GACf,OAAO,EAAE,EAAE,KAAK,KAAK,QAAQ;QAC7B,EAAE,CAAC,KAAK,IAAI,IAAI;QAChB,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,MAAM,KAAK,CAAC;QACnC,CAAC,CAAC,YAAY;QACd,CAAC,CAAC,EAAE,EAAE,KAAK,CAAC;IAEhB,OAAO;IACL,wEAAwE;IACxE,OAAO,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,UAAU,EAAE,SAAS,EAAE,WAAW,CAAC,CAAC,IAAI,IAAI,CAChE,CAAC;AACJ,CAAC,CAAC"} |
+5
-5
| { | ||
| "name": "@assistant-ui/store", | ||
| "version": "0.2.9", | ||
| "version": "0.2.10", | ||
| "description": "Tap-based state management for @assistant-ui", | ||
@@ -33,3 +33,3 @@ "keywords": [ | ||
| "peerDependencies": { | ||
| "@assistant-ui/tap": "^0.5.10", | ||
| "@assistant-ui/tap": "^0.5.11", | ||
| "@types/react": "*", | ||
@@ -47,7 +47,7 @@ "react": "^18 || ^19" | ||
| "@types/react-dom": "^19.2.3", | ||
| "jsdom": "^29.1.0", | ||
| "jsdom": "^29.1.1", | ||
| "react": "^19.2.5", | ||
| "vitest": "^4.1.5", | ||
| "@assistant-ui/tap": "0.5.10", | ||
| "@assistant-ui/x-buildutils": "0.0.6" | ||
| "@assistant-ui/tap": "0.5.11", | ||
| "@assistant-ui/x-buildutils": "0.0.7" | ||
| }, | ||
@@ -54,0 +54,0 @@ "publishConfig": { |
@@ -13,14 +13,16 @@ "use client"; | ||
| // if the consumer never accesses the item, do not trigger rerenders | ||
| const cacheRef = useRef<T | undefined>(undefined); | ||
| // Track access with a dedicated flag: | ||
| // useSyncExternalStore may call getSnapshot() after commit (tearing checks), | ||
| // which would re-cache the current state and mask later real updates. | ||
| // Use the current state as the pre-access snapshot so the post-commit check | ||
| // matches getItemState(aui) and doesn't schedule an unnecessary re-render. | ||
| const accessedRef = useRef(false); | ||
| const currentValue = accessedRef.current ? null : getItemState(aui); | ||
| useAuiState(() => { | ||
| if (cacheRef.current === undefined) { | ||
| cacheRef.current = getItemState(aui); | ||
| } | ||
| return cacheRef.current; | ||
| if (!accessedRef.current) return currentValue; | ||
| return getItemState(aui); | ||
| }); | ||
| return () => { | ||
| cacheRef.current = undefined; // clear the cache (rerender on next state change) | ||
| accessedRef.current = true; | ||
| return getItemState(aui); | ||
@@ -27,0 +29,0 @@ }; |
170392
2.7%110
0.92%3261
3.79%