import { describe, it, expect, afterEach } from "vitest"; import { render, screen, act, cleanup } from "@testing-library/react"; import { createTestResource, renderTest, cleanupAllResources, } from "../test-utils"; import { resource } from "../../core/resource"; import { withKey } from "../../core/withKey"; import { useState } from "react"; import { useState as useResourceState } from "../../react-hooks/useState"; import { useEffect as useResourceEffect } from "../../react-hooks/useEffect"; import { useResource, useResources, useTapRoot, flushTapSync, useContextProvider, } from "../../index"; import { use as useResourceContext } from "../../react-hooks/use"; import { createContext } from "../../react-shim"; describe("@assistant-ui/tap/react resource API", () => { afterEach(() => { cleanupAllResources(); cleanup(); }); describe("useResource", () => { it("routes to useResource inside a tap resource", () => { const useChild = (props: { n: number }) => { return props.n * 2; }; const Child = resource(useChild); const parent = createTestResource(() => useResource(Child({ n: 21 }))); expect(renderTest(parent)).toBe(42); }); it("routes to the React bridge inside a component", () => { const useCounterResource = () => { const [count, setCount] = useResourceState(0); return { count, setCount }; }; const CounterResource = resource(useCounterResource); let api: { count: number; setCount: (n: number) => void } | null = null; function App() { api = useResource(CounterResource()); return
{api.count}
; } render(); expect(screen.getByTestId("count").textContent).toBe("0"); act(() => api!.setCount(3)); expect(screen.getByTestId("count").textContent).toBe("3"); }); }); describe("useResources", () => { it("hosts a keyed list inside a tap resource", () => { const useItem = (p: { n: number }) => { return p.n * 10; }; const Item = resource(useItem); const parent = createTestResource(() => useResources([ withKey("a", Item({ n: 1 })), withKey("b", Item({ n: 2 })), ]), ); expect(renderTest(parent)).toEqual([10, 20]); }); it("hosts a keyed list inside a React component and tracks deps", () => { const useItem = (p: { n: number }) => { const [v] = useResourceState(p.n * 10); return v; }; const Item = resource(useItem); let setCount: (n: number) => void = () => {}; function App() { const [count, setCountState] = useState(2); setCount = setCountState; const items = useResources( Array.from({ length: count }, (_, i) => withKey(i, Item({ n: i + 1 })), ), ); return
{items.join(",")}
; } render(); expect(screen.getByTestId("list").textContent).toBe("10,20"); act(() => setCount(3)); expect(screen.getByTestId("list").textContent).toBe("10,20,30"); }); it("propagates a child's own state update through the list", () => { const setters: Record void> = {}; const useItem = (p: { id: string }) => { const [v, setV] = useResourceState(0); setters[p.id] = setV; return v; }; const Item = resource(useItem); let values: number[] = []; function App() { values = useResources([ withKey("a", Item({ id: "a" })), withKey("b", Item({ id: "b" })), ]); return
{values.join(",")}
; } render(); expect(values).toEqual([0, 0]); act(() => setters.a!(5)); expect(values).toEqual([5, 0]); }); it("skips re-rendering a child whose withKey deps are unchanged", () => { const renders: Record = {}; const useItem = (p: { id: string; text: string }) => { renders[p.id] = (renders[p.id] ?? 0) + 1; return p.text; }; const Item = resource(useItem); let setTick: (n: number) => void = () => {}; function App() { const [tick, set] = useState(0); setTick = set; const items = useResources([ withKey("a", Item({ id: "a", text: "A" }), ["A"]), withKey("b", Item({ id: "b", text: `B${tick}` }), [`B${tick}`]), ]); return
{items.join(",")}
; } render(); expect(renders).toEqual({ a: 1, b: 1 }); expect(screen.getByTestId("list").textContent).toBe("A,B0"); act(() => setTick(1)); // a's deps are unchanged -> reused; b's deps changed -> re-rendered. expect(renders).toEqual({ a: 1, b: 2 }); expect(screen.getByTestId("list").textContent).toBe("A,B1"); }); it("re-renders a child with unchanged deps when it dispatches its own state", () => { const renders: Record = {}; const setters: Record void> = {}; const useItem = (p: { id: string }) => { renders[p.id] = (renders[p.id] ?? 0) + 1; const [v, setV] = useResourceState(0); setters[p.id] = setV; return v; }; const Item = resource(useItem); let values: number[] = []; function App() { values = useResources([ withKey("a", Item({ id: "a" }), ["a"]), withKey("b", Item({ id: "b" }), ["b"]), ]); return null; } render(); expect(renders).toEqual({ a: 1, b: 1 }); act(() => setters.a!(5)); // a is dirty -> re-renders despite unchanged deps; b bails. expect(values).toEqual([5, 0]); expect(renders).toEqual({ a: 2, b: 1 }); }); it("re-renders a child with unchanged deps when its tap context changes", () => { const TestContext = createContext("default"); let renders = 0; const useItem = () => { renders++; return useResourceContext(TestContext); }; const Item = resource(useItem); const parent = createTestResource((value: string) => useContextProvider(TestContext, value, () => useResources([withKey("item", Item(), [])]), ), ); expect(renderTest(parent, "a")).toEqual(["a"]); expect(parent.contextDeps).toBeNull(); expect(renderTest(parent, "b")).toEqual(["b"]); expect(parent.contextDeps).toBeNull(); expect(renders).toBe(2); }); }); describe("useTapRoot", () => { it("exposes a subscribable inside a tap resource", () => { const useRoot = () => { const [n] = useResourceState(7); return n; }; const parent = createTestResource(() => useTapRoot(function Root() { return useRoot(); }).getValue(), ); expect(renderTest(parent)).toBe(7); }); it("rerenders nested tap roots in their last committed tap context", () => { const TestContext = createContext("default"); let increment: (() => void) | null = null; const useRoot = () => { const context = useResourceContext(TestContext) as string; const [count, setCount] = useResourceState(0); increment = () => setCount((c: number) => c + 1); return `${context}:${count}`; }; let store: useTapRoot.Root | null = null; const parent = createTestResource((context: string) => useContextProvider(TestContext, context, () => { store = useTapRoot(function Root() { return useRoot(); }); return store.getValue(); }), ); expect(renderTest(parent, "a")).toBe("a:0"); flushTapSync(() => increment!()); expect(store!.getValue()).toBe("a:1"); renderTest(parent, "b"); expect(store!.getValue()).toBe("b:1"); flushTapSync(() => increment!()); expect(store!.getValue()).toBe("b:2"); }); // A root is push-based: host it in one place and observe it via getValue/ // subscribe elsewhere. (Hosting AND re-rendering off its own value in the same // component self-feeds, since useResourceHost re-renders the root on every host render // and the root notifies on output change — so this test observes the store // directly rather than through a same-component useSyncExternalStore.) it("hosts a subscribable root inside a React component", () => { const useCounterRoot = () => { const [count, setCount] = useResourceState(0); return { count, setCount }; }; let store: ReturnType< typeof useTapRoot<{ count: number; setCount: (n: number) => void; }> > | null = null; function App() { store = useTapRoot(function Root() { return useCounterRoot(); }); return null; } render(); expect(store!.getValue().count).toBe(0); let notified = 0; const unsubscribe = store!.subscribe(() => { notified++; }); // The root drives updates through tap's own (macrotask) scheduler, so flush // synchronously to observe. flushTapSync(() => store!.getValue().setCount(5)); expect(store!.getValue().count).toBe(5); expect(notified).toBeGreaterThan(0); unsubscribe(); }); }); describe("useResource key remount (React bridge)", () => { it("remounts the hosted resource when the element key changes", () => { const mounts: number[] = []; const useKeyed = (p: { id: number }) => { // oxlint-disable-next-line react/exhaustive-deps -- capture the mount id once per fiber to assert remount on key change useResourceEffect(() => void mounts.push(p.id), []); return p.id; }; const Keyed = resource(useKeyed); let setId: (n: number) => void = () => {}; function App() { const [id, setIdState] = useState(1); setId = setIdState; const out = useResource(withKey(id, Keyed({ id }))); return
{out}
; } render(); expect(screen.getByTestId("keyed").textContent).toBe("1"); expect(mounts).toEqual([1]); act(() => setId(2)); expect(screen.getByTestId("keyed").textContent).toBe("2"); expect(mounts).toEqual([1, 2]); }); }); });