Skip to content

Commit

Permalink
Merge pull request #258 from automerge/fix-use-document-async
Browse files Browse the repository at this point in the history
improve handling of changing URLs in useDocument and useHandle
  • Loading branch information
paulsonnentag authored Dec 14, 2023
2 parents c4adefc + 2d49c44 commit 5c8153b
Show file tree
Hide file tree
Showing 4 changed files with 220 additions and 9 deletions.
23 changes: 17 additions & 6 deletions packages/automerge-repo-react-hooks/src/useDocument.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ChangeFn, ChangeOptions, Doc } from "@automerge/automerge/next"
import { AutomergeUrl, DocHandleChangePayload } from "@automerge/automerge-repo"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { useRepo } from "./useRepo.js"

/** A hook which returns a document identified by a URL and a function to change the document.
Expand All @@ -18,17 +18,28 @@ export function useDocument<T>(
const repo = useRepo()

const handle = documentUrl ? repo.find<T>(documentUrl) : null
const handleRef = useRef(null)

useEffect(() => {
if (!handle) {
if (doc) {
setDoc(undefined)
}
// When the handle has changed, reset the doc to an empty state.
// This ensures that if loading the doc takes a long time, the UI
// shows a loading state during that time rather than a stale doc.
setDoc(undefined)

if (!handle) {
return
}

handle.doc().then(v => setDoc(v))
handleRef.current = handle
handle.doc().then(v => {
// Bail out on updating the doc if the handle has changed since we started loading.
// This avoids problem with out-of-order loads when the handle is changing faster
// than documents are loading.
if (handleRef.current !== handle) {
return
}
setDoc(v)
})

const onChange = (h: DocHandleChangePayload<T>) => setDoc(h.doc)
handle.on("change", onChange)
Expand Down
13 changes: 10 additions & 3 deletions packages/automerge-repo-react-hooks/src/useHandle.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { AutomergeUrl, DocHandle } from "@automerge/automerge-repo"
import { useState } from "react"
import { useEffect, useState } from "react"
import { useRepo } from "./useRepo.js"

/** A hook which returns a {@link DocHandle} identified by a URL.
*
* @remarks
* This requires a {@link RepoContext} to be provided by a parent component.
*/
export function useHandle<T>(automergeUrl: AutomergeUrl): DocHandle<T> {
export function useHandle<T>(docUrl?: AutomergeUrl): DocHandle<T> | undefined {
const repo = useRepo()
const [handle] = useState<DocHandle<T>>(repo.find(automergeUrl))
const [handle, setHandle] = useState<DocHandle<T>>(
docUrl ? repo.find(docUrl) : undefined
)

useEffect(() => {
setHandle(docUrl ? repo.find(docUrl) : undefined)
}, [docUrl])

return handle
}
83 changes: 83 additions & 0 deletions packages/automerge-repo-react-hooks/test/useDocument.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ function getRepoWrapper(repo: Repo) {
)
}

const SLOW_DOC_LOAD_TIME_MS = 10

describe("useDocument", () => {
const repo = new Repo({
peerId: "bob" as PeerId,
Expand All @@ -31,10 +33,24 @@ describe("useDocument", () => {
const handleB = repo.create<ExampleDoc>()
handleB.change(doc => (doc.foo = "B"))

// A doc that takes 10ms to load, to simulate a slow load.
// The time value isn't totally arbitrary; 1ms can cause flaky tests
// presumably because of interations with React's scheduler / batched
// renders, but 10ms seems safe empirically.
const handleSlow = repo.create<ExampleDoc>()
handleSlow.change(doc => (doc.foo = "slow"))
const oldDoc = handleSlow.doc.bind(handleSlow)
handleSlow.doc = async () => {
await new Promise(resolve => setTimeout(resolve, SLOW_DOC_LOAD_TIME_MS))
const result = await oldDoc()
return result
}

return {
repo,
handleA,
handleB,
handleSlow,
wrapper: getRepoWrapper(repo),
}
}
Expand Down Expand Up @@ -88,4 +104,71 @@ describe("useDocument", () => {
await waitForNextUpdate()
assert.deepStrictEqual(result.current.doc, undefined)
})

it("sets the doc to undefined while the initial load is happening", async () => {
const { wrapper, handleA, handleSlow } = setup()

const { result, waitForNextUpdate, waitFor } = renderHook(
() => {
const [url, setUrl] = useState<AutomergeUrl>()
const [doc] = useDocument(url)

return {
setUrl,
doc,
}
},
{ wrapper }
)

// initially doc is undefined
assert.deepStrictEqual(result.current.doc, undefined)

// start by setting url to doc A
result.current.setUrl(handleA.url)
await waitForNextUpdate()
assert.deepStrictEqual(result.current.doc, { foo: "A" })

// Now we set the URL to a handle that's slow to load.
// The doc should be undefined while the load is happening.
result.current.setUrl(handleSlow.url)
await waitForNextUpdate()
assert.deepStrictEqual(result.current.doc, undefined)
await waitForNextUpdate()
assert.deepStrictEqual(result.current.doc, { foo: "slow" })
})

it("avoids showing stale data", async () => {
const { wrapper, handleA, handleSlow } = setup()

const { result, waitForNextUpdate, waitFor } = renderHook(
() => {
const [url, setUrl] = useState<AutomergeUrl>()
const [doc] = useDocument(url)

return {
setUrl,
doc,
}
},
{ wrapper }
)

// initially doc is undefined
assert.deepStrictEqual(result.current.doc, undefined)

// Set the URL to a slow doc and then a fast doc.
// We should see the fast doc forever, even after
// the slow doc has had time to finish loading.
result.current.setUrl(handleSlow.url)
result.current.setUrl(handleA.url)
await waitForNextUpdate()
assert.deepStrictEqual(result.current.doc, { foo: "A" })

// wait for the slow doc to finish loading...
await new Promise(resolve => setTimeout(resolve, SLOW_DOC_LOAD_TIME_MS * 2))

// we didn't update the doc to the slow doc, so it should still be A
assert.deepStrictEqual(result.current.doc, { foo: "A" })
})
})
110 changes: 110 additions & 0 deletions packages/automerge-repo-react-hooks/test/useHandle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { PeerId, Repo, AutomergeUrl } from "@automerge/automerge-repo"
import { DummyStorageAdapter } from "@automerge/automerge-repo/test/helpers/DummyStorageAdapter"
import { describe, it } from "vitest"
import { RepoContext } from "../src/useRepo"
import { useHandle } from "../src/useHandle"
import { renderHook } from "@testing-library/react-hooks"
import React, { useState } from "react"
import assert from "assert"

interface ExampleDoc {
foo: string
}

function getRepoWrapper(repo: Repo) {
return ({ children }) => (
<RepoContext.Provider value={repo}>{children}</RepoContext.Provider>
)
}

describe("useHandle", () => {
const repo = new Repo({
peerId: "bob" as PeerId,
network: [],
storage: new DummyStorageAdapter(),
})

function setup() {
const handleA = repo.create<ExampleDoc>()
handleA.change(doc => (doc.foo = "A"))

const handleB = repo.create<ExampleDoc>()
handleB.change(doc => (doc.foo = "B"))

return {
repo,
handleA,
handleB,
wrapper: getRepoWrapper(repo),
}
}

it("loads a handle", async () => {
const { handleA, wrapper } = setup()

const { result, waitForNextUpdate } = renderHook(
() => {
const handle = useHandle(handleA.url)

return {
handle,
}
},
{ wrapper }
)

assert.deepStrictEqual(result.current.handle, handleA)
})

it("returns undefined when no url given", async () => {
const { handleA, wrapper } = setup()

const { result, waitForNextUpdate } = renderHook(
() => {
const handle = useHandle()

return {
handle,
}
},
{ wrapper }
)

assert.deepStrictEqual(result.current.handle, undefined)
})

it("updates the handle when the url changes", async () => {
const { wrapper, handleA, handleB } = setup()

const { result, waitForNextUpdate } = renderHook(
() => {
const [url, setUrl] = useState<AutomergeUrl>()
const handle = useHandle(url)

return {
setUrl,
handle,
}
},
{ wrapper }
)

// initially doc is undefined
assert.deepStrictEqual(result.current.handle, undefined)

// set url to doc A
result.current.setUrl(handleA.url)
await waitForNextUpdate()
assert.deepStrictEqual(result.current.handle, handleA)

// set url to doc B
result.current.setUrl(handleB.url)
await waitForNextUpdate()
assert.deepStrictEqual(result.current.handle, handleB)

// set url to undefined
result.current.setUrl(undefined)
await waitForNextUpdate()
assert.deepStrictEqual(result.current.handle, undefined)
})
})

0 comments on commit 5c8153b

Please sign in to comment.