import React, { Suspense, act } from "react" import { AutomergeUrl } from "@automerge/automerge-repo" import { render, waitFor } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" import { useDocuments } from "../src/useDocuments" import { ErrorBoundary } from "react-error-boundary" import { ExampleDoc, setup, setupPairedRepos } from "./testSetup" describe("useDocuments", () => { const DocumentsComponent = ({ urls, onState, }: { urls: AutomergeUrl[] onState: (docs: Map, change: any) => void }) => { const [docs, change] = useDocuments(urls) onState(docs, change) return null } it("should sync documents and handle changes", async () => { const { handleA, wrapper } = setup() const onState = vi.fn() const Wrapped = () => ( Error!}> Loading...}> ) await act(async () => { render(, { wrapper }) }) await act(async () => { await Promise.resolve() }) // Initial state expect(onState).toHaveBeenCalled() const [docs] = onState.mock.lastCall || [] expect(docs.get(handleA.url)?.foo).toBe("A") // Make a change const [, change] = onState.mock.lastCall || [] await act(async () => { change(handleA.url, doc => (doc.foo = "Changed")) await Promise.resolve() }) // Verify change was synced const [finalDocs] = onState.mock.lastCall || [] expect(finalDocs.get(handleA.url)?.foo).toBe("Changed") }) it("should handle multiple documents and parallel changes", async () => { const { handleA, handleB, wrapper } = setup() const onState = vi.fn() const Wrapped = () => ( Error!}> Loading...}> ) await act(async () => { render(, { wrapper }) }) await act(async () => { await Promise.resolve() }) // Check initial state const [docs, change] = onState.mock.lastCall || [] expect(docs.get(handleA.url)?.foo).toBe("A") expect(docs.get(handleB.url)?.foo).toBe("B") // Make parallel changes await act(async () => { change(handleA.url, doc => { doc.counter = 1 doc.nested = { value: "A1" } }) change(handleB.url, doc => { doc.counter = 2 doc.nested = { value: "B1" } }) await Promise.resolve() }) // Verify both changes were synced const [finalDocs] = onState.mock.lastCall || [] expect(finalDocs.get(handleA.url)).toEqual({ foo: "A", counter: 1, nested: { value: "A1" }, }) expect(finalDocs.get(handleB.url)).toEqual({ foo: "B", counter: 2, nested: { value: "B1" }, }) }) it("should handle document removal and cleanup listeners", async () => { const { handleA, handleB, wrapper } = setup() const onState = vi.fn() const Wrapped = ({ urls }: { urls: AutomergeUrl[] }) => ( Error!}> Loading...}> ) const { rerender, unmount } = render( , { wrapper } ) await act(async () => { await Promise.resolve() }) // Initial state let [docs] = onState.mock.lastCall || [] expect(docs.size).toBe(2) // Remove one document rerender() await act(async () => { await Promise.resolve() }) // Check document was removed docs = onState.mock.lastCall?.[0] expect(docs.size).toBe(1) expect(docs.has(handleA.url)).toBe(true) expect(docs.has(handleB.url)).toBe(false) // Test cleanup unmount() // Make a change - should not trigger update const callCount = onState.mock.calls.length handleA.change(doc => (doc.foo = "Changed after unmount")) expect(onState.mock.calls.length).toBe(callCount) }) it("should handle rapid successive changes", async () => { const { handleA, wrapper } = setup() const onState = vi.fn() const Wrapped = () => ( Error!}> Loading...}> ) await act(async () => { render(, { wrapper }) }) await act(async () => { await Promise.resolve() }) const [, change] = onState.mock.lastCall || [] // Make rapid changes await act(async () => { for (let i = 0; i < 5; i++) { change(handleA.url, doc => { doc.counter = i }) } await Promise.resolve() }) // Should have final value const [finalDocs] = onState.mock.lastCall || [] expect(finalDocs.get(handleA.url)?.counter).toBe(4) }) describe("useDocuments with suspense: false", () => { const NonSuspendingDocumentsComponent = ({ urls, onState, }: { urls: AutomergeUrl[] onState: (docs: Map, change: any) => void }) => { const [docs, change] = useDocuments(urls, { suspense: false }) onState(docs, change) return null } it("should start with already-loaded documents and load other documents asynchronously", async () => { const { repoCreator, repoFinder, wrapper } = setupPairedRepos() const handleA = repoFinder.create({ foo: "A" }) const handleB = repoCreator.create({ foo: "B" }) const onState = vi.fn() const Wrapped = () => ( Error!}> ) render(, { wrapper }) // Initial state should include local document expect(onState).toHaveBeenCalled() let docs = onState.mock.lastCall?.[0] expect(docs.size).toBe(1) expect(docs.get(handleA.url)?.foo).toBe("A") // Wait for remote document to load await act(async () => { await repoFinder.find(handleB.url) }) // Both should now be loaded docs = onState.mock.lastCall?.[0] expect(docs.size).toBe(2) expect(docs.get(handleA.url)?.foo).toBe("A") expect(docs.get(handleB.url)?.foo).toBe("B") }) it("should handle loading multiple documents asynchronously", async () => { const { repoCreator, repoFinder, wrapper } = setupPairedRepos() const handleA = repoCreator.create({ foo: "A" }) const handleB = repoCreator.create({ foo: "B" }) const onState = vi.fn() const Wrapped = () => ( Error!}> ) render(, { wrapper }) // Initial state should be empty let docs = onState.mock.lastCall?.[0] expect(docs.size).toBe(0) // Wait for documents to load await act(async () => { await Promise.all([ repoFinder.find(handleA.url), repoFinder.find(handleB.url), ]) }) // Check loaded state docs = onState.mock.lastCall?.[0] expect(docs.size).toBe(2) expect(docs.get(handleA.url)?.foo).toBe("A") expect(docs.get(handleB.url)?.foo).toBe("B") // Make changes after loading const [, change] = onState.mock.lastCall || [] await act(async () => { change(handleA.url, doc => { doc.counter = 1 doc.nested = { value: "A1" } }) change(handleB.url, doc => { doc.counter = 2 doc.nested = { value: "B1" } }) }) // Verify changes const [finalDocs] = onState.mock.lastCall || [] expect(finalDocs.get(handleA.url)).toEqual({ foo: "A", counter: 1, nested: { value: "A1" }, }) expect(finalDocs.get(handleB.url)).toEqual({ foo: "B", counter: 2, nested: { value: "B1" }, }) }) it("should handle document removal with pending loads", async () => { const { repoCreator, repoFinder, wrapper } = setupPairedRepos() const handleA = repoCreator.create({ foo: "A" }) const handleB = repoCreator.create({ foo: "B" }) const onState = vi.fn() const Wrapped = ({ urls }: { urls: AutomergeUrl[] }) => ( Error!}> ) const { rerender } = render( , { wrapper } ) // Initial state should be empty let docs = onState.mock.lastCall?.[0] expect(docs).toBeDefined() expect(docs.size).toBe(0) // Remove one document before load completes rerender() // Wait for remaining document to load await act(async () => { await Promise.all([ repoFinder.find(handleA.url), repoFinder.find(handleB.url), ]) }) // Should only have loaded the remaining document waitFor(() => { docs = onState.mock.lastCall?.[0] expect(docs.size).toBe(1) expect(docs.has(handleA.url)).toBe(true) expect(docs.has(handleB.url)).toBe(false) }) }) it("should clean up listeners when unmounting with pending loads", async () => { const { repoCreator, repoFinder, wrapper } = setupPairedRepos() const onState = vi.fn() const handleA = repoCreator.create({ foo: "bar" }) const Wrapped = () => ( Error!}> ) const { unmount } = render(, { wrapper }) // Initial state empty expect(onState.mock.lastCall?.[0].size).toBe(0) // Unmount before load completes unmount() // Wait for what would have been load completion let finderHandleA await act(async () => { finderHandleA = await repoFinder.find(handleA.url) }) // Should not have received any updates after unmount const callCount = onState.mock.calls.length finderHandleA.change(doc => (doc.foo = "Changed after unmount")) expect(onState.mock.calls.length).toBe(callCount) }) it("should handle document changes during loading", async () => { const { handleA, wrapper } = setup() const onState = vi.fn() const Wrapped = () => ( Error!}> ) render(, { wrapper }) // Make a change while document is loading handleA.change(doc => (doc.counter = 1)) // Wait for load await act(async () => { await Promise.resolve() }) // Should have latest state const [docs] = onState.mock.lastCall || [] expect(docs.get(handleA.url)).toEqual({ foo: "A", counter: 1, }) }) it("should handle invalid urls with empty map", async () => { const { wrapper } = setup() const onState = vi.fn() const invalidUrl = "invalid-url" as AutomergeUrl const Wrapped = () => ( Error!}> ) render(, { wrapper }) // Initial state empty let docs = onState.mock.lastCall?.[0] expect(docs).toBeDefined() expect(docs.size).toBe(0) // Should remain empty after attempted load await act(async () => { await Promise.resolve() }) docs = onState.mock.lastCall?.[0] expect(docs).toBeDefined() expect(docs.size).toBe(0) }) }) })