// @vitest-environment happy-dom import { cojsonInternals } from "cojson"; import { Loaded, co, z } from "jazz-tools"; import { assertLoaded } from "jazz-tools/testing"; import { assert, beforeEach, describe, expect, expectTypeOf, it } from "vitest"; import React, { Suspense } from "react"; import { useCoStates, useSuspenseCoStates } from "../hooks.js"; import { createJazzTestAccount, setupJazzTestSync } from "../testing.js"; import { act, renderHook, waitFor } from "./testUtils.js"; const ProjectSchema = co.map({ name: z.string(), }); beforeEach(async () => { cojsonInternals.setCoValueLoadingRetryDelay(20); await setupJazzTestSync({ asyncPeers: true, }); await createJazzTestAccount({ isCurrentActiveAccount: true, }); }); describe("useSuspenseCoStates", () => { it("should return loaded values for all subscriptions", async () => { const project1 = ProjectSchema.create({ name: "My Project 1" }); const project2 = ProjectSchema.create({ name: "My Project 2" }); const wrapper = ({ children }: { children: React.ReactNode }) => ( Loading...}>{children} ); const { result } = await act(async () => { return renderHook( () => useSuspenseCoStates(ProjectSchema, [ project1.$jazz.id, project2.$jazz.id, ]), { wrapper, }, ); }); // Wait for any async operations to complete await waitFor(() => { expect(result.current).toBeDefined(); expect(result.current.length).toBe(2); }); const [loadedProject1, loadedProject2] = result.current; assert(loadedProject1); expect(loadedProject1.name).toBe("My Project 1"); assert(loadedProject2); expect(loadedProject2.name).toBe("My Project 2"); }); it("should have correct return types for each entry", async () => { const project1 = ProjectSchema.create({ name: "Project 1" }); const project2 = ProjectSchema.create({ name: "Project 2" }); const wrapper = ({ children }: { children: React.ReactNode }) => ( Loading...}>{children} ); const ids = [project1.$jazz.id, project2.$jazz.id] as const; const { result } = await act(async () => { return renderHook(() => useSuspenseCoStates(ProjectSchema, ids), { wrapper, }); }); await waitFor(() => { expect(result.current).toBeDefined(); expect(result.current.length).toBe(2); }); expectTypeOf(result.current).toEqualTypeOf< Loaded[] >(); }); it("should re-render when any value changes", async () => { const project1 = ProjectSchema.create({ name: "Project 1" }); const project2 = ProjectSchema.create({ name: "Project 2" }); const wrapper = ({ children }: { children: React.ReactNode }) => ( Loading...}>{children} ); const { result } = await act(async () => { return renderHook( () => useSuspenseCoStates(ProjectSchema, [ project1.$jazz.id, project2.$jazz.id, ]), { wrapper, }, ); }); await waitFor(() => { expect(result.current).toBeDefined(); expect(result.current.length).toBe(2); }); assert(result.current[0]); assert(result.current[0]); expect(result.current[0].name).toBe("Project 1"); // Update one of the values act(() => { project1.$jazz.set("name", "updated1"); }); assert(result.current[0]); expect(result.current[0].name).toBe("updated1"); }); it("should handle empty subscription array", async () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( Loading...}>{children} ); const { result } = await act(async () => { return renderHook(() => useSuspenseCoStates(ProjectSchema, []), { wrapper, }); }); await waitFor(() => { expect(result.current).toBeDefined(); }); expect(result.current).toEqual([]); }); it("supports branching CoValues", async () => { const project = ProjectSchema.create({ name: "My Project" }); const wrapper = ({ children }: { children: React.ReactNode }) => ( Loading...}>{children} ); const branchName = "feature-branch"; const { result } = await act(async () => { return renderHook( () => useSuspenseCoStates(ProjectSchema, [project.$jazz.id], { unstable_branch: { name: branchName }, }), { wrapper, }, ); }); assert(result.current[0]); expect(result.current[0].name).toBe("My Project"); // Updates on changes to the branched CoValue act(() => { result.current[0]!.$jazz.set("name", "My Project Updated"); }); assert(result.current[0]); expect(result.current[0].name).toBe("My Project Updated"); // Does not update when changes are made to another branch act(() => { project.$jazz.set("name", "My Project Updated 2"); }); assert(result.current[0]); expect(result.current[0].name).toBe("My Project Updated"); }); }); describe("useCoStates", () => { it("should return MaybeLoaded values", async () => { const project1 = ProjectSchema.create({ name: "My Project 1" }); const project2 = ProjectSchema.create({ name: "My Project 2" }); const { result } = renderHook(() => useCoStates(ProjectSchema, [project1.$jazz.id, project2.$jazz.id]), ); await waitFor(() => { expect(result.current[0]?.$isLoaded).toBe(true); expect(result.current[1]?.$isLoaded).toBe(true); }); const [loadedProject1, loadedProject2] = result.current; assert(loadedProject1); assert(loadedProject2); assertLoaded(loadedProject1); assertLoaded(loadedProject2); expect(loadedProject1.name).toBe("My Project 1"); expect(loadedProject2.name).toBe("My Project 2"); }); it("should re-render when any value changes", async () => { const project1 = ProjectSchema.create({ name: "Project 1" }); const project2 = ProjectSchema.create({ name: "Project 2" }); const { result } = renderHook(() => useCoStates(ProjectSchema, [project1.$jazz.id, project2.$jazz.id]), ); await waitFor(() => { expect(result.current[0]?.$isLoaded).toBe(true); }); expect(result.current[0]).not.toBeNull(); if (result.current[0]) { assertLoaded(result.current[0]); expect(result.current[0].name).toBe("Project 1"); } // Update one of the values act(() => { project1.$jazz.set("name", "updated1"); }); await waitFor(() => { const val = result.current[0]; return val?.$isLoaded && val.name === "updated1"; }); assert(result.current[0]); expect(result.current[0].name).toBe("updated1"); }); it("should handle empty subscription array", async () => { const { result } = renderHook(() => useCoStates(ProjectSchema, [])); await waitFor(() => { expect(result.current).not.toBeNull(); }); expect(result.current).toEqual([]); }); it("should update when ids change", async () => { const project1 = ProjectSchema.create({ name: "My Project 1" }); const project2 = ProjectSchema.create({ name: "My Project 2" }); let ids: string[] = [project1.$jazz.id, project2.$jazz.id]; const { result, rerender } = renderHook( ({ ids }: { ids: string[] }) => useCoStates(ProjectSchema, ids), { initialProps: { ids }, }, ); await waitFor(() => { expect(result.current[0]?.$isLoaded).toBe(true); expect(result.current[1]?.$isLoaded).toBe(true); }); const project3 = ProjectSchema.create({ name: "My Project 3" }); act(() => { // Create a new array with updated IDs ids = [project2.$jazz.id, project3.$jazz.id]; rerender({ ids }); }); await waitFor(() => { expect(result.current[0]?.$isLoaded).toBe(true); expect(result.current[1]?.$isLoaded).toBe(true); }); assert(result.current[0]); assert(result.current[1]); assertLoaded(result.current[0]); assertLoaded(result.current[1]); expect(result.current[0].name).toBe("My Project 2"); expect(result.current[1].name).toBe("My Project 3"); }); it("should not update when ids are the same", async () => { const project1 = ProjectSchema.create({ name: "My Project 1" }); const project2 = ProjectSchema.create({ name: "My Project 2" }); let ids: string[] = [project1.$jazz.id, project2.$jazz.id]; const { result, rerender } = renderHook( ({ ids }: { ids: string[] }) => useCoStates(ProjectSchema, ids), { initialProps: { ids }, }, ); await waitFor(() => { expect(result.current[0]?.$isLoaded).toBe(true); expect(result.current[1]?.$isLoaded).toBe(true); }); const firstResult = result.current; act(() => { // Create a new array with the same IDs ids = [...ids]; rerender({ ids }); }); // The result should be the same reference when IDs haven't changed expect(result.current).toBe(firstResult); expect(result.current[0]).toBe(firstResult[0]); expect(result.current[1]).toBe(firstResult[1]); }); it("supports branching CoValues", async () => { const project = ProjectSchema.create({ name: "My Project" }); const { result } = renderHook(() => useCoStates(ProjectSchema, [project.$jazz.id], { unstable_branch: { name: "feature-branch" }, }), ); const loadedProject = result.current[0]; assert(loadedProject); assertLoaded(loadedProject); expect(loadedProject.name).toBe("My Project"); // Updates on changes to the branched CoValue act(() => { loadedProject.$jazz.set("name", "My Project Updated"); }); const loadedProject2 = result.current[0]; assert(loadedProject2); assertLoaded(loadedProject2); expect(loadedProject2.name).toBe("My Project Updated"); // Does not update when changes are made to another branch act(() => { project.$jazz.set("name", "My Project Updated 2"); }); const loadedProject3 = result.current[0]; assert(loadedProject3); assertLoaded(loadedProject3); expect(loadedProject3.name).toBe("My Project Updated"); }); it("should remove subscriptions for removed ids", async () => { const project1 = ProjectSchema.create({ name: "My Project 1" }); const project2 = ProjectSchema.create({ name: "My Project 2" }); let ids = [project1.$jazz.id, project2.$jazz.id]; let renderCount = 0; const { result, rerender } = renderHook( ({ ids }: { ids: string[] }) => { renderCount++; return useCoStates(ProjectSchema, ids); }, { initialProps: { ids }, }, ); await waitFor(() => { expect(result.current[0]?.$isLoaded).toBe(true); expect(result.current[1]?.$isLoaded).toBe(true); }); assert(result.current[0]); assertLoaded(result.current[0]); const loadedProject1 = result.current[0]; act(() => { ids.shift(); // Remove project1, keeping only project2 rerender({ ids }); }); await waitFor(() => { expect(result.current.length).toBe(1); expect(result.current[0]?.$isLoaded).toBe(true); }); assert(result.current[0]); assertLoaded(result.current[0]); expect(result.current[0].name).toBe("My Project 2"); expect(renderCount).toBe(2); // Modify project1. The hook should NOT re-render because project1 is no longer subscribed act(() => { project1.$jazz.set("name", "Modified Project 1"); }); // Wait a bit to ensure any potential updates would have occurred await new Promise((resolve) => setTimeout(resolve, 100)); // The hook didn't re-render expect(renderCount).toBe(2); // project2's name is still the same assert(result.current[0]); assertLoaded(result.current[0]); expect(result.current[0].name).toBe("My Project 2"); // project1's subscription scope is no longer subscribed to const project1SubscriptionScope = loadedProject1.$jazz._subscriptionScope; expect(project1SubscriptionScope?.subscribers.size).toBe(0); }); });