diff --git a/apps/writer/scripts/browser-reliability-test.mjs b/apps/writer/scripts/browser-reliability-test.mjs index fec742f..1cf6d0d 100644 --- a/apps/writer/scripts/browser-reliability-test.mjs +++ b/apps/writer/scripts/browser-reliability-test.mjs @@ -12,6 +12,13 @@ const main = "manuscripts/patient-observation.md"; async function scenario(name, run) { const context = await browser.newContext({ viewport: { width: 1500, height: 950 }, acceptDownloads: true }); const page = await context.newPage(); + if (process.env.COLD_CACHE) { + await context.route("**/*", (route) => route.continue()); + const cdp = await context.newCDPSession(page); + await cdp.send("Network.enable"); + await cdp.send("Network.setCacheDisabled", { cacheDisabled: true }); + await cdp.send("Network.setBypassServiceWorker", { bypass: true }); + } try { await run(page); console.log(`ok ${name}`); } catch (error) { failures++; @@ -113,6 +120,29 @@ try { await page.waitForFunction(() => window.writer.workspace.getSnapshot().records.has("chapters/worms.md")); }); + await scenario("self-contained preview does not wait for the collection index; exports do", async (page) => { + await page.goto(`${base}?demo`); + await page.locator(".manuscript-row").first().waitFor(); + await page.evaluate(async (path) => { + const backend = window.writer.backend; + const index = await backend.index(); + backend.authority.editElsewhere(path, { body: "# Self-contained\n\nA plain paragraph.\n" }); + backend.index = () => new Promise((resolve) => { window.finishIndex = () => resolve(index); }); + }, main); + await page.locator(".manuscript-row", { hasText: "Patient Observation" }).click(); + await painted(page); + assert.equal(await page.evaluate(() => window.writer.workspace.getSnapshot().indexLoad.phase), "loading"); + await page.getByText("Dependencies not checked").waitFor(); + await page.evaluate(() => { + window.exportFinished = false; + window.pendingBundle = window.writer.workspace.exportBundle().then(() => { window.exportFinished = true; }); + }); + await page.waitForTimeout(100); + assert.equal(await page.evaluate(() => window.exportFinished), false); + await page.evaluate(async () => { window.finishIndex(); await window.pendingBundle; }); + assert.equal(await page.evaluate(() => window.writer.workspace.getSnapshot().indexLoad.phase), "ready"); + }); + await scenario("preview starts while annotation discovery is held", async (page) => { await page.goto(`${base}?demo`); await page.locator(".manuscript-row").first().waitFor(); diff --git a/apps/writer/scripts/browser-test.mjs b/apps/writer/scripts/browser-test.mjs index e078bfb..29c452f 100644 --- a/apps/writer/scripts/browser-test.mjs +++ b/apps/writer/scripts/browser-test.mjs @@ -12,6 +12,14 @@ mkdirSync("out", { recursive: true }); const browser = await chromium.launch({ executablePath }); const context = await browser.newContext({ viewport: { width: 1500, height: 950 }, acceptDownloads: true }); const page = await context.newPage(); +if (process.env.COLD_CACHE) { + // Context routing also disables HTTP caching for dedicated-worker requests. + await context.route("**/*", (route) => route.continue()); + const cdp = await context.newCDPSession(page); + await cdp.send("Network.enable"); + await cdp.send("Network.setCacheDisabled", { cacheDisabled: true }); + await cdp.send("Network.setBypassServiceWorker", { bypass: true }); +} const errors = []; page.on("pageerror", (e) => errors.push(String(e))); page.on("console", (m) => { diff --git a/apps/writer/scripts/perf/README.md b/apps/writer/scripts/perf/README.md index 365e23a..4ee3ce2 100644 --- a/apps/writer/scripts/perf/README.md +++ b/apps/writer/scripts/perf/README.md @@ -79,4 +79,23 @@ PROFILE_NAME=after PROFILE_OUTPUT=out/perf/browser-after.json pnpm -C apps/write pnpm -C apps/writer test:browser ``` -`BASE`, `CHROME`, `PROFILE_NAME`, `PROFILE_OUTPUT` are configurable. The script captures editor/preview visible geometry at animation frames, CDP main-isolate JS heap, >50 ms main-thread long tasks during two ~10 s typing phases, source paging (historically Show all), 300-highlight expansion, citation/embed completion, page errors and `out/large-{sources,comments}-{before,after}.png`. One run is observational, not a statistical timing assertion; heap excludes workers/WASM and is not GC-controlled, and visible canvas is not pixel verification. The default JSON output is `out/perf/browser-profile.json`. Historical before was captured before Round 4 UI changes; after uses a corrected reply counter. Interpretation and deferred work are in `docs/perf-review.md`. +`BASE`, `CHROME`, `PROFILE_NAME`, `PROFILE_OUTPUT` are configurable. +HTTP cache is explicitly disabled and service workers bypassed on every profile. +The profiler now also captures startup tasks, first drawn-revision time, worker +messages/resource timings, main heap after forced GC/full discovery and worker +heap usage. `PROFILE_CPU=out/perf/main.cpuprofile`, +`PROFILE_HEAP=out/perf/main.heapsnapshot`, and +`PROFILE_WORKER_HEAP=out/perf/worker` optionally save detailed evidence. +Summarise a snapshot with: + +```sh +pnpm -C apps/writer exec node scripts/perf/heap-summary.mjs out/perf/main.heapsnapshot > apps/writer/out/perf/heap-summary.json +COLD_CACHE=1 pnpm -C apps/writer test:browser +``` + +On the fixture-corrected product comparison, cold preview went from 3,426 to +2,134 ms and the GC-controlled main heap from 60.91 to 58.28 MiB. +Uncollected heap is still recorded for historical comparisons, not treated as a +retention measurement. + +The script also captures editor/preview visible geometry at animation frames, CDP main-isolate JS heap, >50 ms main-thread long tasks during two ~10 s typing phases, source paging (historically Show all), 300-highlight expansion, citation/embed completion, page errors and `out/large-{sources,comments}-{before,after}.png`. One run is observational, not a statistical timing assertion; the main heap field excludes workers/native backing storage, and visible canvas is not pixel verification. The original Round 4 heap field was not GC-controlled. The default JSON output is `out/perf/browser-profile.json`. Historical before was captured before Round 4 UI changes; after uses a corrected reply counter. Interpretation and deferred work are in `docs/perf-review.md`. diff --git a/apps/writer/scripts/perf/browser-profile.mjs b/apps/writer/scripts/perf/browser-profile.mjs index 35ad0bd..e6031bc 100644 --- a/apps/writer/scripts/perf/browser-profile.mjs +++ b/apps/writer/scripts/perf/browser-profile.mjs @@ -5,17 +5,42 @@ import { dirname } from "node:path"; const browser = await chromium.launch({ executablePath: process.env.CHROME }); const page = await browser.newPage({ viewport: { width: 1500, height: 950 } }); const errors = []; page.on("pageerror", (e) => errors.push(e.message)); +const workers = []; page.on("worker", (worker) => workers.push(worker)); +const cdp = await page.context().newCDPSession(page); +await cdp.send("Network.enable"); await cdp.send("Network.setCacheDisabled", { cacheDisabled: true }); +await cdp.send("Network.setBypassServiceWorker", { bypass: true }); +await cdp.send("Performance.enable"); +if (process.env.PROFILE_CPU) { await cdp.send("Profiler.enable"); await cdp.send("Profiler.start"); } await page.addInitScript(() => { window.uiTasks = []; + window.workerProfile = { messages: [], events: [] }; + const NativeWorker = window.Worker; + window.Worker = class extends NativeWorker { + constructor(...args) { + super(...args); + window.workerProfile.events.push({ type: "created", url: String(args[0]), at: performance.now() }); + this.addEventListener("message", ({ data }) => { + window.workerProfile.events.push({ type: data.type, at: performance.now(), initMs: data.initMs, timings: data.timings }); + }); + } + postMessage(message, ...args) { + const start = performance.now(); super.postMessage(message, ...args); + window.workerProfile.messages.push({ type: message.type, at: start, sendMs: performance.now() - start, + items: message.library?.length, records: message.recordPaths?.length, + // Only in the opt-in profiler: logical size, not wire bytes. + bytes: message.type === "init" || message.type === "collection" ? new TextEncoder().encode(JSON.stringify(message)).length : undefined }); + } + }; new PerformanceObserver((list) => window.uiTasks.push(...list.getEntries().map((e) => ({ start: e.startTime, duration: e.duration })))).observe({ type: "longtask", buffered: true }); const watch = () => { if (!window.editorPaint && document.querySelector(".cm-content")?.getBoundingClientRect().height) window.editorPaint = performance.now(); if (!window.previewPaint && document.querySelector(".preview-pages canvas.page")?.getBoundingClientRect().height) window.previewPaint = performance.now(); - if (!window.previewPaint) requestAnimationFrame(watch); + if (!window.previewRendered && Number(document.querySelector(".preview-pages")?.dataset.renderedRevision) > 0) window.previewRendered = performance.now(); + if (!window.previewRendered) requestAnimationFrame(watch); }; requestAnimationFrame(watch); }); const base = process.env.BASE ?? "http://127.0.0.1:5320/"; -const report = { boundary: "Chromium main-thread long tasks; visible editor/canvas at animation frame (not pixel verification); CDP JS heap excludes workers/WASM; synthetic transport 20ms pages", phases: {} }; +const report = { boundary: "Cold browser cache; Chromium main-thread long tasks; editor/canvas geometry and rendered-revision marker at animation frame (not pixel verification); main CDP JS heap excludes worker heaps and native/WASM backing storage; heap sampled after complete metadata; synthetic transport 20ms pages", phases: {} }; const measure = async (name, run) => { const start = await page.evaluate(() => performance.now()); await run(); await page.waitForTimeout(100); report.phases[name] = await page.evaluate((start) => { @@ -25,10 +50,73 @@ const measure = async (name, run) => { }; try { await page.goto(`${base}?demo=large&manuscript=manuscripts/main.md`); - await page.waitForFunction(() => window.previewPaint && window.writer.workspace.getSnapshot().commentsLoad.phase === "ready", null, { timeout: 120000 }); - report.paint = await page.evaluate(() => ({ editorMs: window.editorPaint, previewMs: window.previewPaint })); - const cdp = await page.context().newCDPSession(page); await cdp.send("Performance.enable"); + await page.waitForFunction(() => window.previewRendered && window.writer.workspace.getSnapshot().commentsLoad.phase === "ready", null, { timeout: 120000 }); + report.paint = await page.evaluate(() => ({ editorMs: window.editorPaint, previewMs: window.previewPaint, renderedMs: window.previewRendered })); + // An early self-contained preview can precede discovery. Compare retained heap + // only after the same complete metadata workload, never a partial collection. + await page.waitForFunction(() => ["indexLoad", "libraryLoad", "annotationsLoad", "commentsLoad"].every((key) => window.writer.workspace.getSnapshot()[key].phase === "ready"), null, { timeout: 120000 }); + report.metadataReadyMs = await page.evaluate(() => performance.now()); + await page.waitForTimeout(100); report.heapAfterLoadBytes = (await cdp.send("Performance.getMetrics")).metrics.find((m) => m.name === "JSHeapUsedSize").value; + report.startup = await page.evaluate(() => ({ timeOrigin: performance.timeOrigin, tasks: window.uiTasks, worker: window.workerProfile, + resources: performance.getEntriesByType("resource").map(({ name, startTime, duration, transferSize }) => ({ name, startTime, duration, transferSize })) })); + report.workers = await Promise.all(workers.map(async (worker) => ({ url: worker.url(), ...await worker.evaluate(() => ({ + timeOrigin: performance.timeOrigin, now: performance.now(), + marks: performance.getEntriesByType("mark").map(({ name, startTime }) => ({ name, startTime })), + resources: performance.getEntriesByType("resource").map(({ name, startTime, duration, transferSize }) => ({ name, startTime, duration, transferSize })), + })) }))); + if (process.env.PROFILE_CPU) { + const { profile } = await cdp.send("Profiler.stop"); + mkdirSync(dirname(process.env.PROFILE_CPU), { recursive: true }); + writeFileSync(process.env.PROFILE_CPU, JSON.stringify(profile)); + } + await cdp.send("HeapProfiler.collectGarbage"); + report.heapAfterGCBytes = (await cdp.send("Performance.getMetrics")).metrics.find((m) => m.name === "JSHeapUsedSize").value; + // Dedicated workers aren't included in the page's Performance metrics. + const browserCdp = await browser.newBrowserCDPSession(); + report.workerHeaps = []; + for (const target of (await browserCdp.send("Target.getTargets")).targetInfos.filter((t) => t.type === "worker")) { + const { sessionId } = await browserCdp.send("Target.attachToTarget", { targetId: target.targetId }); + let id = 0; + const request = (method) => new Promise((resolve, reject) => { + const requestId = ++id; + const timeout = setTimeout(() => { browserCdp.off("Target.receivedMessageFromTarget", receive); reject(new Error(`Worker ${method} timed out`)); }, 60000); + const receive = (event) => { + if (event.sessionId !== sessionId) return; + const response = JSON.parse(event.message); + if (response.id !== requestId) return; + clearTimeout(timeout); + browserCdp.off("Target.receivedMessageFromTarget", receive); + response.error ? reject(new Error(response.error.message)) : resolve(response.result); + }; + browserCdp.on("Target.receivedMessageFromTarget", receive); + void browserCdp.send("Target.sendMessageToTarget", { sessionId, message: JSON.stringify({ id: requestId, method }) }).catch(reject); + }); + await request("HeapProfiler.collectGarbage"); + report.workerHeaps.push({ url: target.url, ...await request("Runtime.getHeapUsage") }); + if (process.env.PROFILE_WORKER_HEAP) { + const chunks = []; + const receive = (event) => { + if (event.sessionId !== sessionId) return; + const message = JSON.parse(event.message); + if (message.method === "HeapProfiler.addHeapSnapshotChunk") chunks.push(message.params.chunk); + }; + browserCdp.on("Target.receivedMessageFromTarget", receive); + await request("HeapProfiler.takeHeapSnapshot"); + browserCdp.off("Target.receivedMessageFromTarget", receive); + const output = `${process.env.PROFILE_WORKER_HEAP}-${target.targetId}.heapsnapshot`; + mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, chunks.join("")); + report.workerHeaps.at(-1).snapshot = output; + } + await browserCdp.send("Target.detachFromTarget", { sessionId }); + } + await browserCdp.detach(); + if (process.env.PROFILE_HEAP) { + const chunks = []; cdp.on("HeapProfiler.addHeapSnapshotChunk", ({ chunk }) => chunks.push(chunk)); + await cdp.send("HeapProfiler.takeHeapSnapshot", { reportProgress: false }); + mkdirSync(dirname(process.env.PROFILE_HEAP), { recursive: true }); + writeFileSync(process.env.PROFILE_HEAP, chunks.join("")); + } await page.getByRole("tab", { name: /Sources/ }).click(); const type = async () => { await page.locator(".cm-content").click(); await page.keyboard.press("ControlOrMeta+End"); diff --git a/apps/writer/scripts/perf/generator.ts b/apps/writer/scripts/perf/generator.ts index d22899c..d6a8abb 100644 --- a/apps/writer/scripts/perf/generator.ts +++ b/apps/writer/scripts/perf/generator.ts @@ -5,16 +5,24 @@ export const main = "manuscripts/main.md"; const pad = (n: number) => String(n).padStart(5, "0"); export function generateCollection(seed: (path: string, type: string, fm: JsonObject, body: string) => void, highlightsPerBook?: number, browser = false): string { const body = "# Main {#sec-main}\n\n" + Array.from({ length: 100 }, (_, i) => `${browser ? `## Section ${i} {#sec-${i}}\n\n` : ""}Paragraph ${i}: quoted passage ${i}. A sustained cited argument [@source${pad(i)}].\n\n`).join(""); + // Share the fixture's intentionally identical text instead of retaining one + // flattened copy per seeded record. Cardinality/logical payloads are unchanged. + // This is a fixture correction, not a real-collection memory optimisation. + const sourceBody = "Source body not needed for citation.\n".repeat(60); + const annotationBody = `> ${"A quotation with evidence from the source. ".repeat(8)}\n\n${"Private reading note. ".repeat(32)}\n`; + const noteBody = "Ordinary collection note.\n".repeat(80); + const commentBody = "A comment discussing the passage. ".repeat(16); + const abstract = "Source metadata. ".repeat(40); for (let i = 0; i < counts.manuscripts; i++) seed(i === 0 ? main : `manuscripts/paper-${pad(i)}.md`, "writer-manuscript", { title: `Paper ${pad(i)}`, csl: "apa", template: "article" }, body); for (let i = 0; i < counts.sources; i++) seed(`sources/source${pad(i)}.md`, "reader-source", { title: `Synthetic source ${pad(i)}`, - csl: { id: `source${pad(i)}`, type: "article-journal", title: `Synthetic research ${pad(i)}: evidence and collection performance`, author: [{ family: `Author${pad(i % 997)}`, given: "Alex" }], issued: { "date-parts": [[2000 + i % 25]] }, "container-title": "Journal of Synthetic Data", DOI: `10.0000/${i}`, abstract: "Source metadata. ".repeat(40) }, - }, "Source body not needed for citation.\n".repeat(60)); - for (let i = 0; i < counts.annotations; i++) seed(`annotations/highlight-${pad(i)}.md`, "reader-annotation", { source: `[[sources/source${pad(highlightsPerBook === undefined ? i % counts.sources : i < highlightsPerBook ? 0 : 1 + i % (counts.sources - 1))}]]`, locator: { label: `p. ${i % 300 + 1}` } }, `> ${"A quotation with evidence from the source. ".repeat(8)}\n\n${"Private reading note. ".repeat(32)}\n`); - for (let i = 0; i < counts.notes; i++) seed(`notes/note-${pad(i)}.md`, "note", { title: `Note ${pad(i)}`, tags: ["synthetic", "research"] }, "Ordinary collection note.\n".repeat(80)); + csl: { id: `source${pad(i)}`, type: "article-journal", title: `Synthetic research ${pad(i)}: evidence and collection performance`, author: [{ family: `Author${pad(i % 997)}`, given: "Alex" }], issued: { "date-parts": [[2000 + i % 25]] }, "container-title": "Journal of Synthetic Data", DOI: `10.0000/${i}`, abstract }, + }, sourceBody); + for (let i = 0; i < counts.annotations; i++) seed(`annotations/highlight-${pad(i)}.md`, "reader-annotation", { source: `[[sources/source${pad(highlightsPerBook === undefined ? i % counts.sources : i < highlightsPerBook ? 0 : 1 + i % (counts.sources - 1))}]]`, locator: { label: `p. ${i % 300 + 1}` } }, annotationBody); + for (let i = 0; i < counts.notes; i++) seed(`notes/note-${pad(i)}.md`, "note", { title: `Note ${pad(i)}`, tags: ["synthetic", "research"] }, noteBody); for (let i = 0; i < counts.comments; i++) seed(`comments/comment-${pad(i)}.md`, "comment", { document: i % 10 === 0 ? "[[manuscripts/main]]" : `[[notes/note-${pad(i % counts.notes)}]]`, created_at: "2026-01-01T00:00:00Z", status: browser ? "open" : i % 3 === 0 ? "resolved" : "open", motivation: "commenting", target: { quote: { exact: `quoted passage ${i % 100}` } }, ...(browser && i > 0 && i % 10 === 0 && i < 1500 ? { in_reply_to: "[[comments/comment-00000]]" } : {}), - }, "A comment discussing the passage. ".repeat(16)); + }, commentBody); return body; } diff --git a/apps/writer/scripts/perf/heap-summary.mjs b/apps/writer/scripts/perf/heap-summary.mjs new file mode 100644 index 0000000..8563ac8 --- /dev/null +++ b/apps/writer/scripts/perf/heap-summary.mjs @@ -0,0 +1,60 @@ +// Opt-in V8 snapshot summary. Strong-edge dominators (not a process/RSS measurement). +import { readFileSync } from "node:fs"; +const snapshot = JSON.parse(readFileSync(process.argv[2], "utf8")); +const { nodes, edges, strings } = snapshot; +const nf = snapshot.snapshot.meta.node_fields, ef = snapshot.snapshot.meta.edge_fields; +const width = nf.length, ew = ef.length, count = nodes.length / width; +const nt = snapshot.snapshot.meta.node_types[0], et = snapshot.snapshot.meta.edge_types[0]; +const name = (i) => strings[nodes[i * width + nf.indexOf("name")]]; +const type = (i) => nt[nodes[i * width]]; +const size = (i) => nodes[i * width + nf.indexOf("self_size")]; +const first = new Uint32Array(count + 1), predCount = new Uint32Array(count); +for (let i = 0; i < count; i++) first[i + 1] = first[i] + nodes[i * width + nf.indexOf("edge_count")] * ew; +const strong = (e) => et[edges[e]] !== "weak"; +const target = (e) => edges[e + ef.indexOf("to_node")] / width; +for (let e = 0; e < edges.length; e += ew) if (strong(e)) predCount[target(e)]++; +const pf = new Uint32Array(count + 1); +for (let i = 0; i < count; i++) pf[i + 1] = pf[i] + predCount[i]; +const predecessors = new Uint32Array(pf[count]), cursor = pf.slice(); +for (let i = 0; i < count; i++) for (let e = first[i]; e < first[i + 1]; e += ew) if (strong(e)) predecessors[cursor[target(e)]++] = i; +const seen = new Uint8Array(count), stack = [0], pending = [first[0]], postorder = []; +seen[0] = 1; +while (stack.length) { + const i = stack.at(-1); let e = pending.at(-1); + while (e < first[i + 1] && (!strong(e) || seen[target(e)])) e += ew; + if (e === first[i + 1]) { postorder.push(i); stack.pop(); pending.pop(); } + else { pending[pending.length - 1] = e + ew; const child = target(e); seen[child] = 1; stack.push(child); pending.push(first[child]); } +} +const order = postorder.reverse(), rank = new Int32Array(count).fill(-1), dom = new Int32Array(count).fill(-1); +order.forEach((i, r) => rank[i] = r); dom[0] = 0; +const intersect = (a, b) => { while (a !== b) { while (rank[a] > rank[b]) a = dom[a]; while (rank[b] > rank[a]) b = dom[b]; } return a; }; +let changed = true, passes = 0; +while (changed) { + changed = false; passes++; + for (let r = 1; r < order.length; r++) { + const i = order[r]; let d = -1; + for (let p = pf[i]; p < pf[i + 1]; p++) { const parent = predecessors[p]; if (dom[parent] !== -1) d = d === -1 ? parent : intersect(d, parent); } + if (dom[i] !== d) { dom[i] = d; changed = true; } + } +} +const retained = new Float64Array(count); +for (let i = 0; i < count; i++) retained[i] = size(i); +for (let r = order.length - 1; r > 0; r--) { const i = order[r]; retained[dom[i]] += retained[i]; } +const self = new Map(); +for (let i = 0; i < count; i++) { const key = type(i) === "object" ? `${type(i)}:${name(i)}` : type(i); const g = self.get(key) ?? { bytes: 0, count: 0 }; g.bytes += size(i); g.count++; self.set(key, g); } +const fixtureStrings = new Map(), objects = new Map(); +for (let i = 0; i < count; i++) { + const n = name(i), t = type(i); + if (t === "string") { + const category = n.startsWith("> A quotation") ? "annotation bodies" : n.startsWith("Ordinary collection") ? "note bodies" + : n.startsWith("Source body") ? "source bodies" : n.startsWith("Source metadata.") ? "abstracts" + : n.startsWith("A comment discussing") ? "comment bodies" : undefined; + if (category) { const g = fixtureStrings.get(category) ?? { bytes: 0, count: 0 }; g.bytes += size(i); g.count++; fixtureStrings.set(category, g); } + } + if (t === "object" && /^(EditorState|ManuscriptAssembler|Citeproc|PathIndex|CollectionStore|ManuscriptWorkspace)$/.test(n)) { + const g = objects.get(n) ?? { count: 0, maxRetainedBytes: 0 }; g.count++; g.maxRetainedBytes = Math.max(g.maxRetainedBytes, retained[i]); objects.set(n, g); + } +} +const largest = order.filter((i) => type(i) === "object" || type(i) === "closure").sort((a, b) => retained[b] - retained[a]).slice(0, 50); +console.log(JSON.stringify({ nodes: count, passes, totalBytes: retained[0], fixtureStrings: Object.fromEntries(fixtureStrings), objects: Object.fromEntries(objects), shallow: [...self].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 30), + dominators: largest.map((i) => ({ id: nodes[i * width + nf.indexOf("id")], name: name(i), type: type(i), retained: retained[i], parent: name(dom[i]), properties: Array.from({ length: (first[i + 1] - first[i]) / ew }, (_, k) => { const e = first[i] + k * ew; return { edge: et[edges[e]] === "element" ? edges[e + 1] : strings[edges[e + 1]], name: name(target(e)), retained: retained[target(e)] }; }).filter((e) => e.retained > 10000).sort((a, b) => b.retained - a.retained).slice(0, 12) })) }, null, 2)); diff --git a/apps/writer/src/App.tsx b/apps/writer/src/App.tsx index 9b9bc07..1cf1388 100644 --- a/apps/writer/src/App.tsx +++ b/apps/writer/src/App.tsx @@ -159,6 +159,8 @@ function DemoRoot() { let live = true; let owned: WriterBackend | undefined; void import("./backend/demo.js").then(async ({ createDemoBackend }) => { + // StrictMode may have disposed this effect while the module was loading. + if (!live) return; const b = import.meta.env.DEV && params.get("demo") === "large" ? await (await import("./backend/large-demo.js")).createLargeDemoBackend() : await createDemoBackend(); diff --git a/apps/writer/src/backend/collection.ts b/apps/writer/src/backend/collection.ts index f71e93a..5bfedad 100644 --- a/apps/writer/src/backend/collection.ts +++ b/apps/writer/src/backend/collection.ts @@ -2,7 +2,7 @@ import type { CollectionDescription, JsonObject, QuerySelectionExpression } from "@mdbase-dev/connect"; import { annotationSourceLink } from "@mdbase-writer/core/annotations"; import { commentFromRecord, linkPath, type CommentRecord } from "@mdbase-writer/core/comments"; -import { PathIndex, resolveLinkTarget } from "@mdbase-writer/core/records"; +import { PathIndex, pathIndex, resolveLinkTarget } from "@mdbase-writer/core/records"; import { personKey, toContract } from "./comments.js"; import { fail, libraryEntry, ok, type CollectionDelta, type CollectionIndex, type LibraryEntry, type ManuscriptSummary } from "./types.js"; @@ -157,7 +157,7 @@ export class CollectionStore { get library(): LibraryEntry[] { return this.libraryValue ??= [...this.sources.values()].sort((a, b) => a.path.localeCompare(b.path)); } get manuscripts(): ManuscriptSummary[] { return this.manuscriptValue ??= [...this.papers.values()].sort((a, b) => a.title.localeCompare(b.title) || a.path.localeCompare(b.path)); } get sourceIndex(): PathIndex { return this.sourceIndexValue ??= new PathIndex(this.sources.keys()); } - get recordIndex(): PathIndex { return this.recordIndexValue ??= new PathIndex(this.records.keys()); } + get recordIndex(): PathIndex { return this.recordIndexValue ??= pathIndex(this.index.recordPaths); } private classify(row: CollectionRow, previous: Classified | undefined, discovery: boolean): Classified { const fm = row.values ?? row.frontmatter ?? row.effectiveFrontmatter ?? {}; diff --git a/apps/writer/src/compile/client.test.ts b/apps/writer/src/compile/client.test.ts index 1dedc19..c53abf3 100644 --- a/apps/writer/src/compile/client.test.ts +++ b/apps/writer/src/compile/client.test.ts @@ -15,6 +15,16 @@ class FakeWorker { } beforeEach(() => { vi.useFakeTimers(); vi.stubGlobal("Worker", FakeWorker); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); +it("prepares independently without starting a compile timeout", async () => { + const client = new CompileClient(); + client.send({ type: "prepare", baseUrl: "/" }); + FakeWorker.latest.reply({ type: "ready", initMs: 1 }); + await client.ready; + const failure = vi.fn(); client.onFailure(failure); + await vi.advanceTimersByTimeAsync(60_000); + expect(failure).not.toHaveBeenCalled(); + client.terminate(); +}); it("settles readiness and all outstanding exports when the worker fails", async () => { const client = new CompileClient(); const failed = vi.fn(); client.onFailure(failed); diff --git a/apps/writer/src/compile/client.ts b/apps/writer/src/compile/client.ts index a365975..6f023dd 100644 --- a/apps/writer/src/compile/client.ts +++ b/apps/writer/src/compile/client.ts @@ -47,7 +47,7 @@ export class CompileClient { } send(message: ToWorker, transfer: Transferable[] = []): void { if (this.stopped) return; - if (message.type !== "init" && message.type !== "export-pdf" && this.compileTimer === undefined) { + if (message.type !== "prepare" && message.type !== "init" && message.type !== "export-pdf" && this.compileTimer === undefined) { this.compileTimer = setTimeout(() => this.fail("Typesetting took too long. Your editor is still available; retry the preview."), 60_000); } try { this.worker.postMessage(message, transfer); } diff --git a/apps/writer/src/compile/protocol.ts b/apps/writer/src/compile/protocol.ts index 4b6d159..eeaa7cd 100644 --- a/apps/writer/src/compile/protocol.ts +++ b/apps/writer/src/compile/protocol.ts @@ -39,6 +39,8 @@ export interface SourceMark { } export type ToWorker = + /** Start font/WASM loading independently of collection discovery. No compile yet. */ + | { readonly type: "prepare"; readonly baseUrl: string } | { readonly type: "init"; readonly library: readonly CslItem[]; readonly styles: readonly [string, string][]; readonly locales: readonly [string, string][]; readonly baseUrl: string } | { readonly type: "library"; readonly library: readonly CslItem[] } | { readonly type: "library-delta"; readonly upsert: readonly CslItem[]; readonly remove: readonly string[] } @@ -69,7 +71,7 @@ export interface CompileResult { readonly labels: readonly string[]; /** The bibliography entry for each cited source, as Typst markup (for hovers). */ readonly references: readonly { readonly key: string; readonly text: string }[]; - readonly timings: { readonly assembleMs: number; readonly compileMs: number; readonly citations: string; readonly clusters: number }; + readonly timings: { readonly assembleMs: number; readonly compileMs: number; readonly citeprocMs?: number; readonly citations: string; readonly clusters: number }; } export type FromWorker = diff --git a/apps/writer/src/compile/worker.test.ts b/apps/writer/src/compile/worker.test.ts new file mode 100644 index 0000000..1c66019 --- /dev/null +++ b/apps/writer/src/compile/worker.test.ts @@ -0,0 +1,31 @@ +import { afterEach, expect, it, vi } from "vitest"; + +const mock = vi.hoisted(() => ({ + init: vi.fn(async () => {}), addSource: vi.fn(), mapShadow: vi.fn(), +})); +vi.mock("@myriaddreamin/typst.ts/compiler", () => ({ createTypstCompiler: () => mock })); +vi.mock("@myriaddreamin/typst.ts/options.init", () => ({ disableDefaultFontAssets: () => {}, loadFonts: () => {} })); +vi.mock("../async.js", () => ({ fetchChecked: vi.fn(async () => new Response(new Uint8Array([1]))) })); + +afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules(); }); +it("prepares once without compiling or announcing readiness before the input snapshot", async () => { + const worker = { onmessage: undefined as ((event: { data: unknown }) => void) | undefined, postMessage: vi.fn() }; + vi.stubGlobal("self", worker); + await import("./worker.js"); + worker.onmessage!({ data: { type: "prepare", baseUrl: "/" } }); + worker.onmessage!({ data: { type: "prepare", baseUrl: "/" } }); + await vi.waitFor(() => expect(mock.addSource).toHaveBeenCalled()); + expect(mock.init).toHaveBeenCalledTimes(1); + expect(worker.postMessage).not.toHaveBeenCalled(); + worker.onmessage!({ data: { type: "init", baseUrl: "/", library: [], styles: [], locales: [] } }); + await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "ready" }), [])); + expect(mock.init).toHaveBeenCalledTimes(1); +}); +it("reports preparation failures rather than leaving readiness pending", async () => { + const worker = { onmessage: undefined as ((event: { data: unknown }) => void) | undefined, postMessage: vi.fn() }; + mock.init.mockRejectedValueOnce(new Error("compiler unavailable")); + vi.stubGlobal("self", worker); + await import("./worker.js"); + worker.onmessage!({ data: { type: "prepare", baseUrl: "/" } }); + await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalledWith({ type: "failure", message: "compiler unavailable" }, [])); +}); diff --git a/apps/writer/src/compile/worker.ts b/apps/writer/src/compile/worker.ts index 6fc7ff2..e42b4a2 100644 --- a/apps/writer/src/compile/worker.ts +++ b/apps/writer/src/compile/worker.ts @@ -67,23 +67,39 @@ const pdfRequests: Extract[] = []; const post = (message: FromWorker, transfer: Transferable[] = []) => self.postMessage(message, transfer); const bytes = async (url: string) => new Uint8Array(await (await fetchChecked(url)).arrayBuffer()); +const mark = (name: string) => { if (import.meta.env.DEV) performance.mark(`writer:${name}`); }; +let preparation: Promise<{ compiler: TypstCompiler; initMs: number }> | undefined; +function prepare(baseUrl: string) { + return preparation ??= (async () => { + const started = performance.now(); + mark("compiler-start"); + // Fetch WASM concurrently with fonts, not after all font requests settle. + const module = compilerUrl(baseUrl).then(fetchChecked); + void module.catch(() => {}); + const [fonts, mitex] = await Promise.all([ + Promise.all(FONTS.map((f) => bytes(`${baseUrl}fonts/${f}`))), + Promise.all(MITEX.map((f) => bytes(`${baseUrl}typst/mitex/${f}`))), + ]); + mark("fonts-ready"); + const c = createTypstCompiler(); + await c.init({ getModule: () => module, beforeBuild: [disableDefaultFontAssets(), loadFonts(fonts)] }); + mark("wasm-ready"); + MITEX.forEach((f, i) => c.mapShadow(`/vendor/mitex/${f}`, mitex[i] ?? new Uint8Array())); + for (const [path, source] of Object.entries(runtime)) c.addSource(path.replace(/^\.\.\/\.\.\/typst/, ""), source); + mark("compiler-prepared"); + // Do not expose the compiler to drain until the init snapshot has arrived. + return { compiler: c, initMs: performance.now() - started }; + })(); +} + async function init(message: Extract) { - const started = performance.now(); library = new Map(message.library.map((i) => [i.id, i])); styles = new Map(message.styles); locales = new Map(message.locales); - const [fonts, mitex] = await Promise.all([ - Promise.all(FONTS.map((f) => bytes(`${message.baseUrl}fonts/${f}`))), - Promise.all(MITEX.map((f) => bytes(`${message.baseUrl}typst/mitex/${f}`))), - ]); - const c = createTypstCompiler(); - const wasmUrl = await compilerUrl(message.baseUrl); - await c.init({ getModule: () => fetchChecked(wasmUrl), beforeBuild: [disableDefaultFontAssets(), loadFonts(fonts)] }); - MITEX.forEach((f, i) => c.mapShadow(`/vendor/mitex/${f}`, mitex[i] ?? new Uint8Array())); - for (const [path, source] of Object.entries(runtime)) c.addSource(path.replace(/^\.\.\/\.\.\/typst/, ""), source); - for (const [path, data] of assetData) c.mapShadow(`/${path}`, data); - compiler = c; - post({ type: "ready", initMs: performance.now() - started }); + const prepared = await prepare(message.baseUrl); + for (const [path, data] of assetData) prepared.compiler.mapShadow(`/${path}`, data); + compiler = prepared.compiler; + post({ type: "ready", initMs: prepared.initMs }); schedule(); } @@ -182,6 +198,7 @@ async function compile(c: TypstCompiler): Promise { timings: { assembleMs: assembled - started, compileMs: performance.now() - assembled, + ...(import.meta.env.DEV ? { citeprocMs: assembly.citations.ms } : {}), citations: assembly.citations.mode, clusters: assembly.citations.clusters, }, @@ -284,6 +301,9 @@ async function exportPdf(c: TypstCompiler, id: number) { self.onmessage = (event: MessageEvent) => { const message = event.data; switch (message.type) { + case "prepare": + void prepare(message.baseUrl).catch((error: unknown) => post({ type: "failure", message: error instanceof Error ? error.message : String(error) })); + return; case "init": void init(message).catch((error: unknown) => post({ type: "failure", message: error instanceof Error ? error.message : String(error) })); return; diff --git a/apps/writer/src/ui/Home.tsx b/apps/writer/src/ui/Home.tsx index 00a0311..4e843c3 100644 --- a/apps/writer/src/ui/Home.tsx +++ b/apps/writer/src/ui/Home.tsx @@ -1,6 +1,6 @@ // The collection's manuscripts, and creating a new one. import { TEMPLATES, type TemplateName } from "@mdbase-writer/core/meta"; -import { PathIndex, resolveLinkTarget } from "@mdbase-writer/core/records"; +import { pathIndex, resolveLinkTarget } from "@mdbase-writer/core/records"; import { STYLES, type StyleId } from "@mdbase-writer/core/styles"; import { signalMdbaseMark } from "@mdbase-dev/ui/mark-activity"; import { Select } from "@mdbase-dev/ui/select"; @@ -84,7 +84,7 @@ export function Home({ backend, onOpen, collectionPicker }: { backend: WriterBac return () => { stop(); clearTimeout(timer); window.removeEventListener("focus", focus); }; }, [backend]); - const recordIndex = useMemo(() => new PathIndex(recordPaths ?? []), [recordPaths]); + const recordIndex = useMemo(() => pathIndex(recordPaths ?? []), [recordPaths]); // Bodies are only read for visible rows, at most four concurrent manuscript traversals. useEffect(() => { diff --git a/apps/writer/src/workspace/workspace.test.ts b/apps/writer/src/workspace/workspace.test.ts index 0ac0bed..06ce297 100644 --- a/apps/writer/src/workspace/workspace.test.ts +++ b/apps/writer/src/workspace/workspace.test.ts @@ -142,7 +142,7 @@ describe("workspace reliability", () => { expect(workspace.getSnapshot().libraryLoad.phase).toBe("loading"); expect(workspace.getSnapshot().records.has("chapters/one.md")).toBe(false); expect(workspace.dependencyDiagnostics()).toEqual([]); - expect(workspace["compile"].send).not.toHaveBeenCalled(); + expect(workspace["compile"].send).toHaveBeenCalledExactlyOnceWith({ type: "prepare", baseUrl: "/" }); let exported = false; const exportJob = workspace.exportBundle().then(() => { exported = true; }); await new Promise((resolve) => setTimeout(resolve, 10)); @@ -153,6 +153,57 @@ describe("workspace reliability", () => { finishLibrary(); await exportJob; }); + it("previews self-contained text before the collection index, but exports still wait", async () => { + const { backend, authority, open } = fixture(); + authority.editElsewhere("main.md", { body: "# Self-contained\n\nPlain text.\n" }); + const index = backend.index; + let finishIndex!: () => void; + backend.index = () => new Promise((resolve) => { finishIndex = () => { void index().then(resolve); }; }); + const workspace = open(); await ready(workspace); + await vi.waitFor(() => expect(workspace["compile"].send).toHaveBeenCalledWith(expect.objectContaining({ type: "init" }))); + expect(workspace.getSnapshot().indexLoad.phase).toBe("loading"); + expect(workspace["compile"].send).toHaveBeenCalledWith({ type: "collection", recordPaths: [], filePaths: [] }); + let exported = false; + const job = workspace.exportBundle().then(() => { exported = true; }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(exported).toBe(false); + finishIndex(); await job; + expect(workspace["compile"].send).toHaveBeenCalledWith(expect.objectContaining({ type: "collection-delta", recordUpsert: ["main.md", "chapters/one.md", "chapters/two.md"] })); + }); + + it.each([ + { body: "![[same]]", patch: {} }, + { body: "![Figure](figure.svg)", patch: {} }, + { body: "Plain text", patch: { template: "custom.typ" } }, + { body: "Plain text", patch: { local_style: "custom.csl" } }, + ])("still waits for a complete index for dependencies: $body $patch", async ({ body, patch }) => { + const { backend, authority, open } = fixture(); + authority.editElsewhere("main.md", { body, patch }); + let finishIndex!: () => void; + backend.index = () => new Promise((resolve) => { finishIndex = () => resolve(ok({ recordPaths: ["main.md", "one/same.md", "two/same.md"], notePaths: [], filePaths: [] })); }); + const workspace = open(); await ready(workspace); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(workspace["compile"].send).not.toHaveBeenCalledWith(expect.objectContaining({ type: "init" })); + finishIndex(); + await vi.waitFor(() => expect(workspace["compile"].send).toHaveBeenCalledWith(expect.objectContaining({ type: "init" }))); + expect(workspace.getSnapshot().records.size).toBe(1); // ambiguous "same" opens neither candidate + }); + + it("rechecks dependencies added while waiting for library metadata", async () => { + const { backend, authority, open } = fixture(); + authority.editElsewhere("main.md", { body: "Plain text" }); + const index = backend.index; + let finishIndex!: () => void, finishLibrary!: () => void; + backend.index = () => new Promise((resolve) => { finishIndex = () => { void index().then(resolve); }; }); + backend.library = () => new Promise((resolve) => { finishLibrary = () => resolve(ok([])); }); + const workspace = open(); await ready(workspace); + workspace.setBody("main.md", "![[chapters/one]]"); + finishLibrary(); await new Promise((resolve) => setTimeout(resolve, 30)); + expect(workspace["compile"].send).not.toHaveBeenCalledWith(expect.objectContaining({ type: "init" })); + finishIndex(); + await vi.waitFor(() => expect(workspace["compile"].send).toHaveBeenCalledWith(expect.objectContaining({ type: "init" }))); + }); + it("initializes preview before annotation discovery, then sends late quotation paths", async () => { const { backend, open } = fixture(); let finish!: () => void; diff --git a/apps/writer/src/workspace/workspace.ts b/apps/writer/src/workspace/workspace.ts index a26ebdc..72e5105 100644 --- a/apps/writer/src/workspace/workspace.ts +++ b/apps/writer/src/workspace/workspace.ts @@ -10,7 +10,7 @@ import { ManuscriptAssembler, translateRecord, type WriterRecord, type AssemblyI import { applySuggestion, bodyHash, linkPath, targetFor, type CommentRecord, type CommentThread } from "@mdbase-writer/core/comments"; import { BUNDLE_README, materialize } from "@mdbase-writer/core/materialize"; import { isAnnotation } from "@mdbase-writer/core/annotations"; -import { PathIndex, resolveLinkTarget } from "@mdbase-writer/core/records"; +import { PathIndex, pathIndex, resolveLinkTarget } from "@mdbase-writer/core/records"; import { LOCALES, STYLES } from "@mdbase-writer/core/styles"; import { NO_PEOPLE, type CommentChange, type People } from "../backend/comments.js"; @@ -135,10 +135,10 @@ export class ManuscriptWorkspace { private commentsTimer: ReturnType | undefined; private current: WorkspaceSnapshot; private workerLibrary = new Map(); - private workerRecordPaths = new Set(); - private workerFilePaths = new Set(); - private workerAnnotationPaths = new Set(); - private workerSourceKeys = new Map(); + private workerRecordPaths: ReadonlySet = new Set(); + private workerFilePaths: ReadonlySet = new Set(); + private workerAnnotationPaths: ReadonlySet = new Set(); + private workerSourceKeys: ReadonlyMap = new Map(); private commentsScope = ""; private metadataJobs: Partial>> = {}; private metadataGeneration = 0; @@ -195,8 +195,8 @@ export class ManuscriptWorkspace { private update(patch: Partial): void { if (this.disposed) return; - if (patch.recordPaths) patch = { ...patch, recordIndex: new PathIndex(patch.recordPaths) }; - if (patch.filePaths) patch = { ...patch, fileIndex: new PathIndex(patch.filePaths) }; + if (patch.recordPaths) patch = { ...patch, recordIndex: pathIndex(patch.recordPaths) }; + if (patch.filePaths) patch = { ...patch, fileIndex: pathIndex(patch.filePaths) }; this.current = { ...this.current, ...patch }; for (const l of this.listeners) l(); } @@ -274,16 +274,16 @@ export class ManuscriptWorkspace { } catch (error) { if (generation === this.metadataGeneration) this.update({ libraryLoad: { phase: "failed", problem: errorMessage(error) } }); } } - private async metadata(annotations = true): Promise { + private async metadata(annotations = true, index = true): Promise { this.loadMetadata(); for (;;) { const generation = this.metadataGeneration; - await Promise.all(annotations ? Object.values(this.metadataJobs) : [this.metadataJobs.indexLoad, this.metadataJobs.libraryLoad]); + await Promise.all(annotations ? Object.values(this.metadataJobs) : [index ? this.metadataJobs.indexLoad : undefined, this.metadataJobs.libraryLoad]); if (this.disposed || generation === this.metadataGeneration) break; } if (this.disposed) throw new Error("The manuscript was closed."); // Annotation discovery is optional: a real failure is visible, not a preview/export gate. - for (const domain of ["indexLoad", "libraryLoad"] as const) { + for (const domain of (index ? ["indexLoad", "libraryLoad"] : ["libraryLoad"]) as MetadataDomain[]) { const state = this.current[domain]; if (state.phase !== "ready") throw new Error(state.problem ?? "Collection metadata is still loading."); } @@ -299,16 +299,30 @@ export class ManuscriptWorkspace { private async initializePreview(): Promise { const client = this.compile; try { - // Annotation identities follow through sendQuotations; their diagnostics remain gated. - await this.metadata(false); - const csl = await loadStyles(); + // Compiler assets and CSL can load while the collection is enumerated. + client.send({ type: "prepare", baseUrl: import.meta.env.BASE_URL }); + const styles = loadStyles(); + // Handle an early fetch failure even while metadata is still pending. + void styles.catch(() => {}); + await this.metadata(false, false); + const csl = await styles; + // A self-contained manuscript needs CSL, not every collection path. Never + // resolve an embed/image/custom asset against a partial basename index. + // Recheck after the awaits: the user may have added a dependency meanwhile. + const record = this.current.records.get(this.main)?.snapshot; + const translated = record && this.translated(this.main, record.body); + const template = record?.frontmatter["template"], style = record?.frontmatter["csl"]; + if (!translated || translated.includes.length || translated.images.length + || template !== undefined && template !== "article" && template !== "thesis" + || style !== undefined && !STYLES.some((s) => s.id === style)) await this.metadata(false); + // Annotation identities follow through sendQuotations; diagnostics remain gated. if (this.disposed || client !== this.compile) return; client.send({ type: "init", library: this.current.library.map((e) => e.item), styles: [...csl.styles], locales: [...csl.locales], baseUrl: import.meta.env.BASE_URL }); this.previewInitialized = true; this.workerLibrary = new Map(this.current.library.map((e) => [e.key, e.item])); - this.workerRecordPaths = new Set(this.current.recordPaths); - this.workerFilePaths = new Set(this.current.filePaths); - this.workerAnnotationPaths.clear(); this.workerSourceKeys.clear(); + this.workerRecordPaths = this.current.recordIndex; + this.workerFilePaths = this.current.fileIndex; + this.workerAnnotationPaths = new Set(); this.workerSourceKeys = new Map(); client.send({ type: "collection", recordPaths: this.current.recordPaths, filePaths: this.current.filePaths }); client.send({ type: "main", path: this.main }); client.send({ type: "records", upsert: this.writerRecords() }); @@ -379,7 +393,7 @@ export class ManuscriptWorkspace { private sendCollection(): void { if (!this.previewInitialized) return; - const records = new Set(this.current.recordPaths), files = new Set(this.current.filePaths); + const records = this.current.recordIndex, files = this.current.fileIndex; const recordUpsert = [...records].filter((p) => !this.workerRecordPaths.has(p)), recordRemove = [...this.workerRecordPaths].filter((p) => !records.has(p)); const fileUpsert = [...files].filter((p) => !this.workerFilePaths.has(p)), fileRemove = [...this.workerFilePaths].filter((p) => !files.has(p)); if (recordUpsert.length || recordRemove.length || fileUpsert.length || fileRemove.length) this.compile.send({ type: "collection-delta", recordUpsert, recordRemove, fileUpsert, fileRemove }); @@ -401,7 +415,7 @@ export class ManuscriptWorkspace { const annotationUpsert = [...annotations].filter((p) => !this.workerAnnotationPaths.has(p)), annotationRemove = [...this.workerAnnotationPaths].filter((p) => !annotations.has(p)); const sourceUpsert = [...keys].filter(([path, key]) => this.workerSourceKeys.get(path) !== key), sourceRemove = [...this.workerSourceKeys.keys()].filter((p) => !keys.has(p)); if (annotationUpsert.length || annotationRemove.length || sourceUpsert.length || sourceRemove.length) this.compile.send({ type: "quotations-delta", annotationUpsert, annotationRemove, sourceUpsert, sourceRemove }); - this.workerAnnotationPaths = new Set(annotations); this.workerSourceKeys = new Map(keys); + this.workerAnnotationPaths = annotations; this.workerSourceKeys = keys; } async loadComments(): Promise { diff --git a/docs/architecture.md b/docs/architecture.md index bc267b2..919d000 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,8 +111,11 @@ canvas preview ◀──── artifact ──────── raw-block iso - **Collection discovery** runs independently of the main record: writing waits only for manuscript bindings and its session. Index, library, annotation identities and comments have separate loading/error states. Index arrival - retries embeds. Preview initialization waits for index/library, with annotation - identities supplied later. Export also awaits annotation discovery settling; + retries embeds. Compiler/font and CSL loading overlap discovery. Self-contained + manuscripts with bundled layouts/styles preview after library discovery, without + the full index. Embeds, images and custom styles/layouts still wait for the complete + index; dependency checks remain provisional until discovery settles. Annotation + identities arrive later. Export still awaits the index and annotation discovery; real annotation failures are non-blocking problems/warnings, not preview/export gates. Missing-dependency diagnostics remain gated by their discovery domain. - **Collection ownership** lives in `backend/collection.ts`. `CollectionSchema` @@ -126,7 +129,7 @@ canvas preview ◀──── artifact ──────── raw-block iso demo uses the same store; the large demo already uses ConnectBackend. Native CEL link queries remain deferred: installed canonical CEL lacks `asFile()`, and native basename resolution is not Writer's unique-basename rule. -- **Path resolution** uses immutable collection-generation indexes: exact paths, +- **Path resolution** uses shared immutable collection-generation indexes: exact paths, then relative paths, then a unique case-insensitive basename. Comment membership is cached by comments/index identity; only changed manuscript bodies are re-anchored. Comment metadata resolves manuscript targets against the complete @@ -177,6 +180,9 @@ keystroke (from the spike). Download: about 9.4 MB compressed, mostly the Typst compiler, cached after the first visit. The Word export adds about 16 MB (Pandoc) on its first use; the demo paper converts in about a second. +Cold large-collection preview and GC-controlled heap measurements come from the +perf harness in `apps/writer/scripts/perf/`. + ## Known limits - Not yet run against a real Connect collection (see the README). diff --git a/docs/development.md b/docs/development.md index 1f65c23..981fba2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -28,6 +28,8 @@ Run the end-to-end Chromium suite with `pnpm dev` running: ```sh pnpm --filter @mdbase-writer/app test:browser +# Explicitly disable the HTTP cache and bypass service workers in both Chromium suites: +COLD_CACHE=1 pnpm --filter @mdbase-writer/app test:browser ``` If port 5320 is occupied, use `pnpm dev --port 5321` and run both suites with diff --git a/packages/core/src/records.ts b/packages/core/src/records.ts index c470c37..1e17b8c 100644 --- a/packages/core/src/records.ts +++ b/packages/core/src/records.ts @@ -69,6 +69,14 @@ export class PathIndex implements ReadonlySet { } } +const pathIndexes = new WeakMap(); +/** Share an index only for immutable collection-generation arrays. Never mutate the input. */ +export function pathIndex(paths: readonly string[]): PathIndex { + let index = pathIndexes.get(paths); + if (!index) { index = new PathIndex(paths); pathIndexes.set(paths, index); } + return index; +} + /** * Resolves an embed or link target the way Obsidian-style collections expect: * exact collection path, then relative to the linking record, then a unique diff --git a/packages/core/test/records.test.ts b/packages/core/test/records.test.ts index f60ffbe..9914753 100644 --- a/packages/core/test/records.test.ts +++ b/packages/core/test/records.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { IMAGE_EXTENSION, joinPath, dirname, PathIndex, resolveLinkTarget } from "../src/records.js"; +import { IMAGE_EXTENSION, joinPath, dirname, PathIndex, pathIndex, resolveLinkTarget } from "../src/records.js"; // Frozen reference: the pre-index resolver, including exact case-sensitive precedence. function oldResolve(target: string, fromPath: string, candidates: ReadonlySet, extension = ".md"): string | null { @@ -50,6 +50,16 @@ describe("indexed path resolution", () => { } }); + it("shares only immutable array generations, preserving ambiguity after replacement", () => { + const paths = Object.freeze(["one/a.md"]); + const before = pathIndex(paths); + expect(pathIndex(paths)).toBe(before); + const after = pathIndex(Object.freeze([...paths, "two/a.md"])); + expect(after).not.toBe(before); + expect(resolveLinkTarget("a", "main.md", before)).toBe("one/a.md"); + expect(resolveLinkTarget("a", "main.md", after)).toBeNull(); + }); + it("keeps collection generations independent", () => { const before = new PathIndex(["one/a.md"]); const after = new PathIndex(["one/a.md", "two/a.md"]);