Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

The end of undefined docSync() / await doc() #402

Merged
merged 60 commits into from
Jan 31, 2025
Merged

The end of undefined docSync() / await doc() #402

merged 60 commits into from
Jan 31, 2025

Conversation

pvh
Copy link
Member

@pvh pvh commented Dec 30, 2024

After many long conversations with automerge-repo users, I have finally seen the error of my ways. I see now that repo.find() should not be synchronous and should instead return a promise to a handle that resolves when the handle is actually loaded.

This change should integrate better with suspense and radically reduce the amount of annoying awaits, undefined checks, and other irritations when consuming documents in automerge-repo.

Here's an initial draft that passes most tests. I as I continue to work through the consequences and simplify the resulting library, I'll update it here.

Comments & questions welcome.

(Note that this PR probably currently breaks a few features such as unload() but we'll work through that stuff before landing.)

@kraenhansen
Copy link
Contributor

kraenhansen commented Dec 30, 2024

I see you're not awaiting the expect calls wrapping callbacks returning promises, some places. It seems the pattern is when you chain .rejects.toThrow. If there's a reason for this, feel free to disregard my comments, but it looks like an oversight to me. If the promise resolves "slowly" I suspect the tests won't fail.

@pvh pvh marked this pull request as draft December 30, 2024 21:10
@msakrejda
Copy link
Contributor

Nice, excited to see this revisited. Can you talk about how this affects this diagram?

@pvh
Copy link
Member Author

pvh commented Dec 31, 2024

Nice, excited to see this revisited. Can you talk about how this affects this diagram?

Ideally, all of those states except READY and DELETED will go away from a public consumer point of view. If you call find() and it doesn't reach a useful state, you'll get an exception.

That might happen because you're offline and trying to open a document you have heard of but didn't fetch yet for whatever reason, or because the peers you're connected to don't have the data.

That said, we won't give you the handle back from the promise unless and until you can actually use it! Long-term, I think some kind of signal-based model here would be preferable: you might want to be able to see incremental loading details to show some kind of progress bar. That said, we don't have that yet, so it feels premature to worry too much about it.

Copy link
Contributor

@msakrejda msakrejda left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, I'm excited to see this direction. I left a bunch of comments and questions, but overall it looks good. You had asked me privately about testing, but I don't see anything missing: test coverage looks great to me.

My main concern: in the React hooks, is it possible to load local documents without suspending? That would be really nice. Is Suspense always triggered because find returns a promise? If that's the case, would a findSync work, that returns undefined if the handle's document is not loaded? I think that could be used to build hooks that don't suspend unless necessary.

export const wrapperCache = new Map<
AnyDocumentId,
ReturnType<typeof wrapPromise>
>()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really want this global, and not, e.g., living in something that also wraps a RepoContext.Provider?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oooooh. that might be better. it would also be a bit more complicated... my immediate instinct is to defer this until someone asks for it. the failure mode would be that if you had the same document in two different repos in your react app they'd share a cache of their state.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that would be hard to debug and understand for sure. should this be part of the repo context? i seem to remember in one version of this patch trying to rely on the consistency of the promises from repo.find() ... but then you can't resolve it in two places so i guess actually we need the cache here. hmmm.

i think i'd prefer to leave this as-is because the failure mode seems pretty remote and a robust solution seems complicated but i could be persuaded this is the wrong idea.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my weak compromise was to add the comment below. if you find this too odious, i'm open to improving on it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, okay. Though for what it's worth, I use context for stuff like this all the time, though, and I've never run into problems. The odds of anyone running into a problem here are pretty remote, but if they do, it's going to be a fun debugging time for sure. Plus, I think introducing an AutomergeContext with this breaking change lets us put other per-repo state (that does not belong in a repo) in this context in the future without breaking existing APIs at that point. But I don't think it's a huge deal; it'd be a pretty easy migration if necessary.

if (suspense) {
return wrapper.read() as DocHandle<T>
} else {
return handle || undefined
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does || undefined give you here? Isn't the handle already a DocHandle or undefined?


useEffect(() => {
if (suspense === false) {
void wrapper.promise
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why void here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, this is to deal with floating promises? But you have a then and catch here, which should take care of that, no?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eslint was likely complaining. i'll see if i can remove it

}

useEffect(() => {
if (suspense === false) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would be simpler as

if (suspense) {
  return
}

and then handle the non-suspense case unconditionally.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i can fix that, yeah


// Check if we need any new wrappers
for (const id of ids) {
let wrapper = wrapperCache.get(id)!
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why !? Aren't you testing if (!wrapper) right below?

packages/automerge-repo/src/Repo.ts Outdated Show resolved Hide resolved
const { handleA, wrapper } = setup()
const onDoc = vi.fn()
// First we should see the loading state
expect(screen.getByTestId("loading")).toBeInTheDocument()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We expect to see a loading state even if we're dealing with a locally-created document?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump

it("avoids showing stale data", async () => {
const { handleA, handleSlow, wrapper } = setup()
// Test document changes during loading
it("should handle document changes while loading", async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, good test idea.

})

await act(async () => {
await Promise.resolve()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this waiting for useEffect? It might be nice to have a comment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, this is waiting for the doc to load, right?

)
await waitFor(() => unmount())
}
it("should handle multiple documents and parallel changes", async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice.

@msakrejda
Copy link
Contributor

Oh, also, does this approach to hooks work on React 19? I think it should, but given the state of the Suspense APIs, it might be good to check.

@pvh pvh changed the title [draft] The end of undefined docSync() / await doc() The end of undefined docSync() / await doc() Jan 31, 2025
@@ -38,7 +39,11 @@ window.repo = repo
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<RepoContext.Provider value={repo}>
<React.StrictMode>
<App url={docUrl} />
<ErrorBoundary fallback={<div>Something went wrong</div>}>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this officialish error boundary is chatty in the logs. i should silence it and/or replace it with one that is better for tests.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's not much to it—for my project, I ended up just borrowing from it liberally and writing my own. But I think that can wait.

"repository": "https://github.com/automerge/automerge-repo/tree/master/packages/automerge-repo-svelte-store",
"license": "MIT",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"build": "",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, i forgot i need some svelte help

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think it's fine to ship an alpha but i need to either get help or learn some svelte i suppose

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:disappear:

@@ -87,7 +87,7 @@ export function setContextRepo(repo: Repo) {
export function document<T>(documentId: AutomergeUrl, repo?: Repo) {
repo = repo ?? getContextRepo()
const handle = repo.find<T>(documentId)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this line here is the problem for svelte!

Copy link
Contributor

@msakrejda msakrejda left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, I left some questions and comments, but broadly this looks great.

@@ -1,4 +1,5 @@
import typescriptEslint from "@typescript-eslint/eslint-plugin"
import pluginReact from "eslint-plugin-react"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't you need something similar for the hooks plugin?

@@ -38,7 +39,11 @@ window.repo = repo
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<RepoContext.Provider value={repo}>
<React.StrictMode>
<App url={docUrl} />
<ErrorBoundary fallback={<div>Something went wrong</div>}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's not much to it—for my project, I ended up just borrowing from it liberally and writing my own. But I think that can wait.

export const wrapperCache = new Map<
AnyDocumentId,
ReturnType<typeof wrapPromise>
>()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, okay. Though for what it's worth, I use context for stuff like this all the time, though, and I've never run into problems. The odds of anyone running into a problem here are pretty remote, but if they do, it's going to be a fun debugging time for sure. Plus, I think introducing an AutomergeContext with this breaking change lets us put other per-repo state (that does not belong in a repo) in this context in the future without breaking existing APIs at that point. But I don't think it's a huge deal; it'd be a pretty easy migration if necessary.

const promise = repo.find<T>(id)
wrapper = wrapPromise(promise)
wrapperCache.set(id, wrapper)
} catch (e) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is that thrown? I'm probably missing something, but as far as I can tell, neither find nor wrapPromise throw synchronously.

} else {
setHandleMap(nextHandleMap)
}
}, [suspense, ids])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're still omitting pendingPromises and nextHandleMap from the dependency array. I think your hook lint isn't running here. I remember when I was in a spot like this that refactoring to avoid that is not trivial, and the logic seems sound, so maybe throw an eslint-ignore-next-line on it once you get linting working for now.

await eventPromise(bobRepo, "document")
const bobHandle = bobRepo.find<TestDoc>(aliceHandle.url)
// TODO: ... let connections complete. this shouldn't be necessary.
await pause(50)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure this is necessary? Tests pass reliably for me without this (granted, CI may be a different story).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, i needed it. i don't know why... investigating it is beyond my motivation right now.

@@ -97,8 +102,7 @@ export class DocSynchronizer extends Synchronizer {
/// PRIVATE

async #syncWithPeers() {
this.#log(`syncWithPeers`)
const doc = await this.#handle.doc()
const doc = await this.#handle.legacyAsyncDoc() // XXX THIS ONE IS WEIRD
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you have more context to leave here for future you or other future maintainers, that might prove worthwhile.


const docPromise = this.#handle
.doc([READY, REQUESTING, UNAVAILABLE])
const docPromise = this.#handle // TODO THIS IS ALSO WEIRD
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

@@ -260,7 +259,7 @@ describe("DocHandle", () => {
const handle = new DocHandle<TestDoc>(TEST_ID)
assert.equal(handle.isReady(), false)

handle.doc()
handle.legacyAsyncDoc()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here...

// we should only be notified of the head changes of doc A
assert.strictEqual(remoteHeadsChangedMessages.length, 2)
assert.strictEqual(remoteHeadsChangedMessages.length, 1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did this change?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't know why. i decided i don't need to

@pvh pvh merged commit c92830a into main Jan 31, 2025
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants