Skip to content

Commit fee13f8

Browse files
committed
Fix version history seeding retries and preview state
1 parent 4eaff43 commit fee13f8

8 files changed

Lines changed: 228 additions & 40 deletions

File tree

‎examples/07-collaboration/12-multi-doc-versioning/src/App.tsx‎

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { generateRandomId } from "./utils.js";
99
import { LoginScreen } from "./LoginScreen.js";
1010
import { DocumentList } from "./DocumentList.js";
1111
import { DocumentEditor } from "./DocumentEditor.js";
12-
import { SAMPLE_DOCUMENT_TITLE, seedSampleDocument } from "./sampleDocument.js";
12+
import {
13+
SAMPLE_DOCUMENT_TITLE,
14+
seedSampleDocument,
15+
hasPendingSampleDocument,
16+
} from "./sampleDocument.js";
1317
import { YHUB_API_URL } from "./yhub.js";
1418

1519
// Set once the sample document has been created, so deleting every document
@@ -62,36 +66,41 @@ function Workspace({
6266

6367
// A first visit gets a sample document with a few versions in its history,
6468
// so the history sidebar has something to show before anyone has edited.
65-
const [seeding, setSeeding] = useState(false);
69+
const [seedStatus, setSeedStatus] = useState<"idle" | "seeding" | "failed">(
70+
"idle",
71+
);
72+
const [seedAttempt, setSeedAttempt] = useState(0);
6673
const seedStartedRef = useRef(false);
6774
useEffect(() => {
6875
if (
6976
docId ||
70-
index.docs.length > 0 ||
77+
(index.docs.length > 0 &&
78+
!hasPendingSampleDocument({
79+
baseUrl: YHUB_API_URL,
80+
org: workspaceId,
81+
})) ||
7182
localStorage.getItem(SEEDED_KEY) ||
7283
seedStartedRef.current
7384
) {
7485
return;
7586
}
7687
seedStartedRef.current = true;
77-
setSeeding(true);
78-
const id = index.create(SAMPLE_DOCUMENT_TITLE);
88+
setSeedStatus("seeding");
7989
void seedSampleDocument({
8090
baseUrl: YHUB_API_URL,
8191
org: workspaceId,
82-
docId: id,
8392
})
84-
.then(() => {
93+
.then((id) => {
94+
index.ensure(id, SAMPLE_DOCUMENT_TITLE);
8595
localStorage.setItem(SEEDED_KEY, "1");
96+
setSeedStatus("idle");
8697
navigate(`/w/${workspaceId}/${id}`);
8798
})
8899
.catch((error: unknown) => {
89-
// The document still exists, just without history. Say so in the
90-
// console; the demo carries on with an empty document.
91100
console.error("Could not seed the sample document", error);
92-
})
93-
.finally(() => setSeeding(false));
94-
}, [docId, index, workspaceId]);
101+
setSeedStatus("failed");
102+
});
103+
}, [docId, index, workspaceId, seedAttempt]);
95104

96105
// A shared doc URL can reference a doc this browser has never seen (the
97106
// index is localStorage-only). Register it so the editor mounts and syncs
@@ -173,8 +182,21 @@ function Workspace({
173182
workspaceId={workspaceId}
174183
activeDocId={docId}
175184
/>
176-
{seeding ? (
185+
{seedStatus === "seeding" ? (
177186
<div className="page-loading">Preparing a sample document…</div>
187+
) : seedStatus === "failed" ? (
188+
<div className="page-loading">
189+
<p>Could not prepare the sample document.</p>
190+
<button
191+
className="btn btn-primary"
192+
onClick={() => {
193+
seedStartedRef.current = false;
194+
setSeedAttempt((attempt) => attempt + 1);
195+
}}
196+
>
197+
Retry
198+
</button>
199+
</div>
178200
) : activeDoc ? (
179201
<DocumentEditor
180202
key={activeDoc.id + user.id}

‎examples/07-collaboration/12-multi-doc-versioning/src/DocumentEditor.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,11 @@ export function DocumentEditor({
166166
// versions without their names.
167167
const [synced, setSynced] = useState(provider.synced);
168168
useEffect(() => {
169-
const onSync = (isSynced: boolean) => setSynced(isSynced);
169+
const onSync = (isSynced: boolean) => {
170+
if (isSynced) {
171+
setSynced(true);
172+
}
173+
};
170174
provider.on("sync", onSync);
171175
if (provider.synced) {
172176
setSynced(true);

‎examples/07-collaboration/12-multi-doc-versioning/src/docIndex.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,15 @@ export function useDocIndex() {
7777
// browser's index yet. The index is local-only, while doc contents live on
7878
// the collaboration server — so a placeholder entry is enough to open it.
7979
const ensure = useCallback(
80-
(id: string) => {
80+
(id: string, title = "Shared document") => {
8181
const current = readDocs();
8282
if (current.some((d) => d.id === id)) {
8383
return;
8484
}
8585
const now = Date.now();
8686
current.push({
8787
id,
88-
title: "Shared document",
88+
title,
8989
createdAt: now,
9090
updatedAt: now,
9191
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
2+
import * as Y from "@y/y";
3+
import { decodeAny } from "lib0/buffer";
4+
import { seedSampleDocument } from "./sampleDocument.js";
5+
6+
beforeEach(() => localStorage.clear());
7+
afterEach(() => {
8+
localStorage.clear();
9+
vi.unstubAllGlobals();
10+
});
11+
12+
it.each([false, true])(
13+
"replays partial seeding without duplicate content or versions (lost response: %s)",
14+
async (lostResponse) => {
15+
const remote = new Y.Doc({ gc: false });
16+
const requests: Uint8Array[] = [];
17+
const urls: string[] = [];
18+
let failed = false;
19+
vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
20+
if (!(init.body instanceof Uint8Array)) {
21+
throw new Error("Expected a binary seed update");
22+
}
23+
urls.push(url);
24+
requests.push(init.body);
25+
const payload: unknown = decodeAny(init.body);
26+
if (
27+
typeof payload !== "object" ||
28+
payload === null ||
29+
!("update" in payload) ||
30+
!(payload.update instanceof Uint8Array)
31+
) {
32+
throw new Error("Expected an encoded Yjs update");
33+
}
34+
const fail = !failed && requests.length === 2;
35+
if (!fail || lostResponse) {
36+
Y.applyUpdate(remote, payload.update);
37+
}
38+
if (fail) {
39+
failed = true;
40+
return new Response(null, { status: 503 });
41+
}
42+
return new Response(null, { status: 200 });
43+
});
44+
const options = { baseUrl: "https://example.test/api", org: "retry-test" };
45+
await expect(seedSampleDocument(options)).rejects.toThrow("503");
46+
// Index removal does not remove the durable seed updates or change their IDs.
47+
localStorage.removeItem("bn-multi-doc-index");
48+
const id = await seedSampleDocument(options);
49+
expect(new Set(urls)).toEqual(
50+
new Set([`${options.baseUrl}/ydoc/v1/${options.org}/${id}`]),
51+
);
52+
expect(requests[2]).toEqual(requests[0]);
53+
expect(requests[3]).toEqual(requests[1]);
54+
expect(remote.get("__bn_versions").toArray()).toHaveLength(3);
55+
const contents = remote.get().toJSON();
56+
const state = Y.encodeStateVector(remote);
57+
await seedSampleDocument(options);
58+
expect(remote.get().toJSON()).toEqual(contents);
59+
expect(Y.encodeStateVector(remote)).toEqual(state);
60+
expect(remote.get("__bn_versions").toArray()).toHaveLength(3);
61+
remote.destroy();
62+
},
63+
);

‎examples/07-collaboration/12-multi-doc-versioning/src/sampleDocument.ts‎

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { docDiffToDelta } from "@blocknote/core/y";
33
import { docToDelta } from "@y/prosemirror";
44
import * as Y from "@y/y";
55
import { encodeAny } from "lib0/buffer";
6+
import { generateRandomId } from "./utils.js";
67

78
export const SAMPLE_DOCUMENT_TITLE = "Launch plan";
89

@@ -116,17 +117,75 @@ const SAMPLE_VERSIONS: Array<{
116117
{ daysAgo: 0.1, by: "4", blocks: liveDocument },
117118
];
118119

120+
type SeedOptions = { baseUrl: string; org: string };
121+
type SeedPlan = { docId: string; patches: number[][] };
122+
123+
function seedKey(options: SeedOptions) {
124+
return `bn-multi-doc-seed:${options.baseUrl}/${options.org}`;
125+
}
126+
127+
export function hasPendingSampleDocument(options: SeedOptions) {
128+
return localStorage.getItem(seedKey(options)) !== null;
129+
}
130+
131+
function readSeedPlan(raw: string): SeedPlan {
132+
const plan: unknown = JSON.parse(raw);
133+
if (
134+
typeof plan !== "object" ||
135+
plan === null ||
136+
!("docId" in plan) ||
137+
typeof plan.docId !== "string" ||
138+
!("patches" in plan) ||
139+
!Array.isArray(plan.patches) ||
140+
!plan.patches.every(
141+
(patch: unknown): patch is number[] =>
142+
Array.isArray(patch) &&
143+
patch.every(
144+
(byte: unknown) =>
145+
typeof byte === "number" &&
146+
Number.isInteger(byte) &&
147+
byte >= 0 &&
148+
byte <= 255,
149+
),
150+
)
151+
) {
152+
throw new Error("Invalid saved sample seed plan");
153+
}
154+
return { docId: plan.docId, patches: plan.patches };
155+
}
156+
119157
/**
120-
* Seed history as back-dated edits, each with its author and version name.
121-
* Names use the same timestamp keys as createYHubVersioningEndpoints.
158+
* Replay the same back-dated Yjs updates on every attempt. Replaying the prefix
159+
* reconciles partial remote success (including a lost PATCH response) before
160+
* sending the remaining updates: Yjs update identities make this idempotent.
161+
* Persist before the first request, independently of the local document index.
122162
*/
123-
export async function seedSampleDocument(options: {
124-
baseUrl: string;
125-
org: string;
126-
docId: string;
127-
}): Promise<void> {
128-
const url = `${options.baseUrl}/ydoc/v1/${options.org}/${options.docId}`;
163+
export async function seedSampleDocument(
164+
options: SeedOptions,
165+
): Promise<string> {
166+
const key = seedKey(options);
167+
const saved = localStorage.getItem(key);
168+
const plan = saved ? readSeedPlan(saved) : createSeedPlan();
169+
if (!saved) {
170+
localStorage.setItem(key, JSON.stringify(plan));
171+
}
172+
const url = `${options.baseUrl}/ydoc/v1/${options.org}/${plan.docId}`;
173+
for (const patch of plan.patches) {
174+
const res = await fetch(url, {
175+
method: "PATCH",
176+
body: new Uint8Array(patch),
177+
});
178+
if (!res.ok) {
179+
throw new Error(
180+
`YHub seed request failed: ${res.status} ${res.statusText} (${url})`,
181+
);
182+
}
183+
}
184+
return plan.docId;
185+
}
129186

187+
function createSeedPlan(): SeedPlan {
188+
const patches: number[][] = [];
130189
const ydoc = new Y.Doc({ gc: false });
131190
// The same root type the editor syncs (`doc.get()` in `DocumentEditor`).
132191
const fragment = ydoc.get();
@@ -158,19 +217,17 @@ export async function seedSampleDocument(options: {
158217
);
159218
sent = Y.encodeStateVector(ydoc);
160219

161-
const res = await fetch(url, {
162-
method: "PATCH",
163-
body: encodeAny({
164-
update,
165-
by: version.by,
166-
at,
167-
customAttributions: [],
168-
}) as BufferSource,
169-
});
170-
if (!res.ok) {
171-
throw new Error(
172-
`YHub seed request failed: ${res.status} ${res.statusText} (${url})`,
173-
);
174-
}
220+
patches.push(
221+
Array.from(
222+
encodeAny({
223+
update,
224+
by: version.by,
225+
at,
226+
customAttributions: [],
227+
}),
228+
),
229+
);
175230
}
231+
ydoc.destroy();
232+
return { docId: generateRandomId(6), patches };
176233
}

‎packages/react/src/components/Versioning/usePreviewRow.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,12 @@ export function usePreviewRow(): (
5454
(snapshot) =>
5555
!(options?.namedOnly ?? namedOnly) || snapshot.name !== undefined,
5656
);
57+
const rowIndex = snapshots.findIndex((s) => s.id === row.id);
5758
compareToId = isCurrent
5859
? snapshots[0]?.id
59-
: snapshots[snapshots.findIndex((s) => s.id === row.id) + 1]?.id;
60+
: rowIndex === -1
61+
? undefined
62+
: snapshots[rowIndex + 1]?.id;
6063
break;
6164
}
6265
case "snapshot":

‎tests/src/unit/react/versioning/VersioningSidebar.test.tsx‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
DefaultVersionMenuItems,
1414
RestoreVersionItem,
1515
useRestoreVersionAction,
16+
usePreviewRow,
1617
useVersionSnapshot,
1718
VersioningSidebar,
1819
VersionMenu,
@@ -372,6 +373,40 @@ describe("VersioningSidebar", () => {
372373
});
373374
});
374375

376+
it("does not compare a row excluded from the requested filter against the newest snapshot", async () => {
377+
function PreviewNamedHistoryItem() {
378+
const { snapshot } = useVersionSnapshot();
379+
const previewRow = usePreviewRow();
380+
return (
381+
<VersionMenuItem
382+
onClick={() =>
383+
previewRow(snapshot, {
384+
namedOnly: true,
385+
compareTo: { type: "previous" },
386+
})
387+
}
388+
>
389+
Preview named history
390+
</VersionMenuItem>
391+
);
392+
}
393+
const { editor } = await setup({
394+
snapshotMenu: (
395+
<VersionMenu>
396+
<PreviewNamedHistoryItem />
397+
</VersionMenu>
398+
),
399+
});
400+
await click(await openMenuItem(rows()[2]!, /^Preview named history$/));
401+
expect(
402+
editor.getExtension(VersioningExtension)!.store.state.view,
403+
).toMatchObject({
404+
mode: "snapshot",
405+
snapshotId: AUTOMATIC.id,
406+
compareToId: undefined,
407+
});
408+
});
409+
375410
it.each([false, true])(
376411
"compares visible named versions (initial comparison: %s)",
377412
async (initialComparison) => {

‎tests/vite.config.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ export default defineConfig(
3030
test: {
3131
environment: "jsdom",
3232
setupFiles: ["./vitestSetup.ts"],
33-
include: ["./src/unit/**/*.test.ts", "./src/unit/**/*.test.tsx"],
33+
include: [
34+
"./src/unit/**/*.test.ts",
35+
"./src/unit/**/*.test.tsx",
36+
"../examples/07-collaboration/12-multi-doc-versioning/src/*.test.ts",
37+
],
3438
},
3539
resolve: {
3640
alias:

0 commit comments

Comments
 (0)