Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sideboard/src/sideboard/account-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { makeDocumentProjection } from "solid-automerge";
import { makeDocumentProjection } from "./lib/document-projection.ts";
import { Show } from "solid-js";
import { render } from "solid-js/web";
import type { DocHandle, Repo } from "@automerge/automerge-repo/slim";
Expand Down
2 changes: 1 addition & 1 deletion sideboard/src/sideboard/document-list-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useDocument } from "solid-automerge";
import { useDocument } from "./lib/document-projection.ts";
import type { AutomergeUrl, Repo } from "@automerge/automerge-repo/slim";
import type { PatchworkViewElement } from "@inkandswitch/patchwork-elements";
import type { OpenDocumentEventDetail } from "@inkandswitch/patchwork-elements";
Expand Down
2 changes: 1 addition & 1 deletion sideboard/src/sideboard/document-list/folder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type Repo,
type DocHandle,
} from "@automerge/automerge-repo/slim";
import { useDocument } from "solid-automerge";
import { useDocument } from "../lib/document-projection.ts";
import type {
OpenDocumentEventDetail,
PatchworkViewElement,
Expand Down
135 changes: 135 additions & 0 deletions sideboard/src/sideboard/lib/document-projection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import { createRoot } from "solid-js";
import { unwrap } from "solid-js/store";
import type {
DocHandle,
DocHandleChangePayload,
Patch,
} from "@automerge/automerge-repo/slim";
import { makeDocumentProjection } from "./document-projection.ts";

type FolderDoc = {
title: string;
docs: { url: string; name: string; type: string }[];
};

// The patches automerge emits for `folder.docs.push({name, type, url})`.
const insertPatches: Patch[] = [
{ action: "insert", path: ["docs", 0], values: [{}] },
{ action: "put", path: ["docs", 0, "name"], value: "" },
{ action: "put", path: ["docs", 0, "type"], value: "" },
{ action: "put", path: ["docs", 0, "url"], value: "" },
{ action: "splice", path: ["docs", 0, "name", 0], value: "Untitled" },
{ action: "splice", path: ["docs", 0, "type", 0], value: "markdown" },
{ action: "splice", path: ["docs", 0, "url", 0], value: "automerge:abc" },
];

describe("makeDocumentProjection", () => {
it("applies a change once per projection even when handles share a doc object", () => {
// Several handle objects for the same document all hand out the same
// materialized doc — as overlay handles from different <patchwork-view>s do.
const document = createFakeDocument<FolderDoc>({ title: "f", docs: [] });
const handles = [document.handle(), document.handle(), document.handle()];

createRoot((dispose) => {
const stores = handles.map((h) => makeDocumentProjection(h));

document.change(insertPatches, { title: "f", docs: [{ name: "Untitled", type: "markdown", url: "automerge:abc" }] });

for (const store of stores) {
expect(store.docs.map((d) => d.url)).toEqual(["automerge:abc"]);
}
expect(document.doc().docs).toHaveLength(1);
dispose();
});
});

it("never writes into the automerge document's own object", () => {
const document = createFakeDocument<FolderDoc>({ title: "f", docs: [] });
const raw = document.doc();

createRoot((dispose) => {
const store = makeDocumentProjection(document.handle());
expect(unwrap(store)).not.toBe(raw);

document.change(insertPatches, { title: "f", docs: [{ name: "Untitled", type: "markdown", url: "automerge:abc" }] });

expect(store.docs).toHaveLength(1);
expect(raw.docs).toHaveLength(0);
dispose();
});
});

it("shares one store between projections of the same handle and tears it down last", () => {
const document = createFakeDocument<FolderDoc>({ title: "f", docs: [] });
const handle = document.handle();

const disposeA = createRoot((dispose) => {
makeDocumentProjection(handle);
return dispose;
});
let storeB!: FolderDoc;
const disposeB = createRoot((dispose) => {
storeB = makeDocumentProjection(handle);
return dispose;
});

expect(document.listenerCount()).toBe(1);
disposeA();
expect(document.listenerCount()).toBe(1);

document.change(insertPatches, { title: "f", docs: [{ name: "Untitled", type: "markdown", url: "automerge:abc" }] });
expect(storeB.docs).toHaveLength(1);

disposeB();
expect(document.listenerCount()).toBe(0);
});
});

// A stand-in for an automerge document that several handles point at: every
// handle's `doc()` returns the same object, and a change fans out to every
// handle's listeners with the same patches — the shape of the real thing.
function createFakeDocument<T extends object>(initial: T) {
let current: T = initial;
const listeners = new Set<{ event: string; fn: (p: unknown) => void }>();

function handle(): DocHandle<T> {
const self = {
url: "automerge:fake",
doc: () => current,
on(event: string, fn: (p: unknown) => void) {
listeners.add({ event, fn });
return self;
},
off(event: string, fn: (p: unknown) => void) {
for (const l of listeners) {
if (l.event === event && l.fn === fn) listeners.delete(l);
}
return self;
},
};
return self as unknown as DocHandle<T>;
}

function change(patches: Patch[], after: T) {
const before = current;
current = after;
for (const l of [...listeners]) {
if (l.event !== "change") continue;
l.fn({
handle: undefined,
doc: after,
patches,
patchInfo: { before, after, source: "change" },
} satisfies Partial<DocHandleChangePayload<T>>);
}
}

return {
handle,
change,
doc: () => current,
listenerCount: () =>
[...listeners].filter((l) => l.event === "change").length,
};
}
110 changes: 110 additions & 0 deletions sideboard/src/sideboard/lib/document-projection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type {
AutomergeUrl,
Doc,
DocHandle,
DocHandleChangePayload,
} from "@automerge/automerge-repo/slim";
import { createMemo, onCleanup, type Accessor, type Resource } from "solid-js";
import { createStore, produce, reconcile, type Store } from "solid-js/store";
import { autoproduce, useDocHandle } from "solid-automerge";

/**
* Drop-in replacements for solid-automerge's `useDocument` and
* `makeDocumentProjection`.
*
* solid-automerge (as of 2.0.1) builds its store with
* `createStore(handle.doc())`, i.e. the automerge document's own materialized
* object becomes the store's raw object, and every patch is written into it
* in place. `handle.doc()` returns the same object for every handle of a
* document, so two projections on two handle objects of the same doc — which
* is exactly what happens when several `<patchwork-view>`s each hold their
* own overlay handle for the folder that's open — end up mutating one shared
* array. Each projection applies the same insert patch, and a document added
* to a folder shows up as many times as there are projections until a reload
* rebuilds the store from scratch.
*
* Here the store starts from (and is only ever reconciled against) a copy of
* the document, so a projection owns its state outright.
*/

type UseDocHandleOptions = Parameters<typeof useDocHandle>[1];

export function useDocument<T extends object>(
url: Accessor<AutomergeUrl | undefined>,
options?: UseDocHandleOptions
): [Accessor<Doc<T> | undefined>, Resource<DocHandle<T> | undefined>] {
const handle = useDocHandle<T>(url, options);
const projection = createMemo<Doc<T> | undefined>(() => {
const h = handle();
return h && makeDocumentProjection<T>(h);
});
return [projection, handle];
}

// `scopeReplaced` arrived with sub-handles in newer automerge-repo builds than
// the one this package types against; the shell's runtime does send it.
type ChangePayload<T> = DocHandleChangePayload<T> & { scopeReplaced?: boolean };

type CacheEntry = {
refs: number;
store: Store<Doc<unknown>>;
cleanup(): void;
};

// One store per handle object, shared by everything projecting that handle
// within this bundle and torn down when the last user goes away.
const cache = new WeakMap<DocHandle<unknown>, CacheEntry>();

export function makeDocumentProjection<T extends object>(
handle: DocHandle<T>
): Doc<T> {
onCleanup(() => {
const entry = cache.get(handle);
if (entry && --entry.refs === 0) entry.cleanup();
});

const existing = cache.get(handle);
if (existing) {
existing.refs++;
return existing.store as Doc<T>;
}

const [doc, set] = createStore<Doc<T>>(snapshot(handle.doc()!));

function patch(payload: ChangePayload<T>) {
// `scopeReplaced`: the change landed at or above this handle's scope, so
// there are no in-scope patches; reconcile against the new value instead.
// `doc` is undefined when the scope was removed entirely.
if (payload.scopeReplaced) {
set(reconcile(snapshot((payload.doc ?? {}) as Doc<T>)));
return;
}
set(produce(autoproduce(payload)));
}

function ondelete() {
set(reconcile({} as Doc<T>));
}

handle.on("change", patch);
handle.on("delete", ondelete);

cache.set(handle, {
refs: 1,
store: doc,
cleanup() {
handle.off("change", patch);
handle.off("delete", ondelete);
cache.delete(handle);
},
});

return doc;
}

// A copy the store can own. Automerge's materialized doc is plain data, so a
// structured clone is faithful; it also sheds automerge's symbol-keyed internal
// state, which has no business in a Solid store.
function snapshot<T>(doc: T): T {
return structuredClone(doc);
}
2 changes: 1 addition & 1 deletion sideboard/src/sideboard/sideboard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { makeDocumentProjection } from "solid-automerge";
import { makeDocumentProjection } from "./lib/document-projection.ts";
import { Show } from "solid-js";
import { render } from "solid-js/web";
import type { DocHandle, Repo } from "@automerge/automerge-repo/slim";
Expand Down
1 change: 0 additions & 1 deletion tldraw4/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
},
"dependencies": {
"@automerge/automerge-repo-react-hooks": "^2.6.0-subduction.46",
"@automerge/react": "^2.6.0-subduction.9",
"@inkandswitch/patchwork-bootloader": "^0.7.2",
"@inkandswitch/patchwork-filesystem": "^0.2.9",
"@inkandswitch/patchwork-plugins": "^1.2.5",
Expand Down
Loading
Loading