Skip to content

Commit

Permalink
useDocuments listen to delete events, tests for all
Browse files Browse the repository at this point in the history
  • Loading branch information
joshuahhh committed Mar 10, 2024
1 parent ef122f7 commit 38f1c44
Show file tree
Hide file tree
Showing 3 changed files with 81 additions and 24 deletions.
33 changes: 25 additions & 8 deletions packages/automerge-repo-react-hooks/src/useDocuments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AutomergeUrl,
DocHandle,
DocHandleChangePayload,
DocHandleDeletePayload,
DocumentId,
} from "@automerge/automerge-repo"
import { useEffect, useState } from "react"
Expand All @@ -13,30 +14,43 @@ import { useRepo } from "./useRepo.js"
*/
export const useDocuments = <T>(ids?: DocId[]) => {
const [documents, setDocuments] = useState({} as Record<DocId, T>)
const [listeners, setListeners] = useState({} as Record<DocId, Listener<T>>)
const [listeners, setListeners] = useState({} as Record<DocId, Listeners<T>>)
const repo = useRepo()

useEffect(
() => {
const updateDocument = (id: DocId, doc?: T) => {
if (doc) setDocuments(docs => ({ ...docs, [id]: doc }))
}
const updateDocumentDeleted = (id: DocId) => {
// (don't remove listeners)
// remove the document from the document map
setDocuments(docs => {
const { [id]: _removedDoc, ...remainingDocs } = docs
return remainingDocs
})
}

const addListener = (handle: DocHandle<T>) => {
const id = handle.documentId

// whenever a document changes, update our map
const listener: Listener<T> = ({ doc }) => updateDocument(id, doc)
handle.on("change", listener)
const listeners: Listeners<T> = {
change: ({ doc }) => updateDocument(id, doc),
delete: () => updateDocumentDeleted(id),
}
handle.on("change", listeners.change)
handle.on("delete", listeners.delete)

// store the listener so we can remove it later
setListeners(listeners => ({ ...listeners, [id]: listener }))
setListeners(listeners => ({ ...listeners, [id]: listeners }))
}

const removeDocument = (id: DocId) => {
// remove the listener
const handle = repo.find<T>(id)
handle.off("change", listeners[id])
handle.off("change", listeners[id].change)
handle.off("delete", listeners[id].delete)

// remove the document from the document map
setDocuments(docs => {
Expand Down Expand Up @@ -68,9 +82,10 @@ export const useDocuments = <T>(ids?: DocId[]) => {

// on unmount, remove all listeners
const teardown = () => {
Object.entries(listeners).forEach(([id, listener]) => {
Object.entries(listeners).forEach(([id, listeners]) => {
const handle = repo.find<T>(id as DocId)
handle.off("change", listener)
handle.off("change", listeners.change)
handle.off("delete", listeners.delete)
})
}

Expand All @@ -83,4 +98,6 @@ export const useDocuments = <T>(ids?: DocId[]) => {
}

type DocId = DocumentId | AutomergeUrl
type Listener<T> = (p: DocHandleChangePayload<T>) => void
type ChangeListener<T> = (p: DocHandleChangePayload<T>) => void
type DeleteListener<T> = (p: DocHandleDeletePayload<T>) => void
type Listeners<T> = { change: ChangeListener<T>, delete: DeleteListener<T> }
23 changes: 23 additions & 0 deletions packages/automerge-repo-react-hooks/test/useDocument.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AutomergeUrl, PeerId, Repo } from "@automerge/automerge-repo"
import { DummyStorageAdapter } from "@automerge/automerge-repo/test/helpers/DummyStorageAdapter"
import { render, waitFor } from "@testing-library/react"
import React from "react"
import { act } from "react-dom/test-utils"
import { describe, expect, it, vi } from "vitest"
import { useDocument } from "../src/useDocument"
import { RepoContext } from "../src/useRepo"
Expand Down Expand Up @@ -65,6 +66,28 @@ describe("useDocument", () => {
await waitFor(() => expect(onDoc).toHaveBeenLastCalledWith({ foo: "A" }))
})

it("should update if the doc changes", async () => {
const { wrapper, handleA } = setup()
const onDoc = vi.fn()

render(<Component url={handleA.url} onDoc={onDoc} />, {wrapper})
await waitFor(() => expect(onDoc).toHaveBeenLastCalledWith({ foo: "A" }))

act(() => handleA.change(doc => (doc.foo = "new value")))
await waitFor(() => expect(onDoc).toHaveBeenLastCalledWith({ foo: "new value" }))
});

it("should update if the doc is deleted", async () => {
const { wrapper, handleA } = setup()
const onDoc = vi.fn()

render(<Component url={handleA.url} onDoc={onDoc} />, {wrapper})
await waitFor(() => expect(onDoc).toHaveBeenLastCalledWith({ foo: "A" }))

act(() => handleA.delete())
await waitFor(() => expect(onDoc).toHaveBeenLastCalledWith(undefined))
});

it("should update if the url changes", async () => {
const { handleA, handleB, wrapper } = setup()
const onDoc = vi.fn()
Expand Down
49 changes: 33 additions & 16 deletions packages/automerge-repo-react-hooks/test/useDocuments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@ describe("useDocuments", () => {
return <RepoContext.Provider value={repo}>{children}</RepoContext.Provider>
}

let documentValues: Record<string, any> = {}

const documentIds = range(10).map(i => {
const handle = repo.create({ foo: i })
const value = { foo: i }
const handle = repo.create(value)
documentValues[handle.documentId] = value
return handle.documentId
})

return { repo, wrapper, documentIds }
return { repo, wrapper, documentIds, documentValues }
}

const Component = ({ ids, onDocs }: {
Expand All @@ -36,23 +40,19 @@ describe("useDocuments", () => {
}

it("returns a collection of documents, given a list of ids", async () => {
const { documentIds, wrapper } = setup()
const { documentIds, documentValues, wrapper } = setup()
const onDocs = vi.fn()

render(<Component ids={documentIds} onDocs={onDocs} />, { wrapper })
await waitFor(() => expect(onDocs).toHaveBeenCalledWith(
Object.fromEntries(documentIds.map((id, i) => [id, { foo: i }]))
))
await waitFor(() => expect(onDocs).toHaveBeenCalledWith(documentValues))
})

it("updates documents when they change", async () => {
const { repo, documentIds, wrapper } = setup()
const { repo, documentIds, documentValues, wrapper } = setup()
const onDocs = vi.fn()

render(<Component ids={documentIds} onDocs={onDocs} />, { wrapper })
await waitFor(() => expect(onDocs).toHaveBeenCalledWith(
Object.fromEntries(documentIds.map((id, i) => [id, { foo: i }]))
))
await waitFor(() => expect(onDocs).toHaveBeenCalledWith(documentValues))

act(() => {
// multiply the value of foo in each document by 10
Expand All @@ -62,26 +62,43 @@ describe("useDocuments", () => {
})
})
await waitFor(() => expect(onDocs).toHaveBeenCalledWith(
Object.fromEntries(documentIds.map((id, i) => [id, { foo: i * 10 }]))
Object.fromEntries(Object.entries(documentValues).map(
([k, { foo }]) => [k, { foo: foo * 10 }]
))
))
})

it(`removes documents when they're removed from the list of ids`, async () => {
const { documentIds, wrapper } = setup()
it("updates documents when one is deleted", async () => {
const { repo, documentIds, documentValues, wrapper } = setup()
const onDocs = vi.fn()

const { rerender } = render(<Component ids={documentIds} onDocs={onDocs} />, { wrapper })
render(<Component ids={documentIds} onDocs={onDocs} />, { wrapper })

// delete the first document
act(() => {
const handle = repo.find(documentIds[0])
handle.delete()
})

await waitFor(() => expect(onDocs).toHaveBeenCalledWith(
Object.fromEntries(documentIds.map((id, i) => [id, { foo: i }]))
{ ...documentValues, [documentIds[0]]: undefined }
))
})

it(`removes documents when they're removed from the list of ids`, async () => {
const { documentIds, documentValues, wrapper } = setup()
const onDocs = vi.fn()

const { rerender } = render(<Component ids={documentIds} onDocs={onDocs} />, { wrapper })
await waitFor(() => expect(onDocs).toHaveBeenCalledWith(documentValues))

// remove the first document
rerender(<Component ids={documentIds.slice(1)} onDocs={onDocs} />)
// 👆 Note that this only works because documentIds.slice(1) is a different
// object from documentIds. If we modified documentIds directly, the hook
// wouldn't re-run.
await waitFor(() => expect(onDocs).toHaveBeenCalledWith(
Object.fromEntries(documentIds.map((id, i) => [id, { foo: i }]).slice(1))
{ ...documentValues, [documentIds[0]]: undefined }
))
})
})
Expand Down

0 comments on commit 38f1c44

Please sign in to comment.