Skip to content

Commit e6badd7

Browse files
Jing-yilinclaude
andcommitted
Tell other windows about a canvas save, save on leaving, and define before drawing
From review. A canvas save is broadcast to every other page on the project, which reloads onto the file, since the watcher takes the write for the saving page's own. A save still waiting when the page goes away is sent at once with keepalive. Build with define-product asks the agent for an empty canvas, and no board until PRD.md says what the product is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e8eab95 commit e6badd7

4 files changed

Lines changed: 32 additions & 7 deletions

File tree

‎canvas/server/sp.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export function createSpServer(options: {
212212
// change one word would throw away the tldraw viewport, the open panel and a document being
213213
// edited. canvasIndex.ts listens.
214214
const pages = new Set<ServerResponse>();
215-
const broadcast = (event: "reload" | "layout" | "docs", data: unknown) => {
215+
const broadcast = (event: "reload" | "layout" | "docs" | "content", data: unknown) => {
216216
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
217217
for (const page of pages) page.write(frame);
218218
};
@@ -720,7 +720,7 @@ export function createSpServer(options: {
720720
res.end(message);
721721
};
722722
try {
723-
const { slug, file } = JSON.parse(body || "{}");
723+
const { slug, file, by } = JSON.parse(body || "{}");
724724
if (!SAFE_NAME.test(slug ?? "")) return send(400, "bad canvas name");
725725
if (isExample(slug)) return send(403, READ_ONLY);
726726
const folder = path.join(canvasesDir, slug);
@@ -733,6 +733,9 @@ export function createSpServer(options: {
733733
wroteContent.set(target, text);
734734
if (text) fs.writeFileSync(target, text);
735735
else fs.rmSync(target, { force: true });
736+
// Every other page on the project reloads onto it (canvasIndex.ts), since the watcher
737+
// below takes this write for the saving page's own.
738+
broadcast("content", { slug, by });
736739
send(200, "ok");
737740
} catch (error) {
738741
send(500, String(error));

‎canvas/src/AppShell.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,10 @@ export function AppShell() {
213213
const first = [
214214
name.trim() === "" &&
215215
`name this project: write a short name for it as {"name": "…"} in project.json at the project's root (if you cannot tell yet what it is, make that your first question to me)`,
216-
`make the canvas the work goes in and name it: a folder under canvases/ with its "name" in layout.json and a first board, so it opens on my screen`,
216+
// With define-product, no board before the product is worked out, as that skill says.
217+
start.mode === "build" && start.define
218+
? `make the canvas the work goes in and name it: a folder under canvases/ with its "name" in layout.json, so it opens on my screen, and no board in it until PRD.md says what the product is`
219+
: `make the canvas the work goes in and name it: a folder under canvases/ with its "name" in layout.json and a first board, so it opens on my screen`,
217220
].filter(Boolean);
218221
const [skill, ask] =
219222
start.mode === "clone"

‎canvas/src/canvasContent.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
type TLShapeId,
1919
type VecLike,
2020
} from "tldraw";
21-
import { canvasIndex } from "./canvasIndex";
21+
import { canvasIndex, PAGE_ID } from "./canvasIndex";
2222
import {
2323
boardFileUrl,
2424
canvasImageKey,
@@ -176,7 +176,11 @@ export function installCanvasContent(editor: Editor) {
176176
// One write at a time per canvas, so an older body can never land after a newer one. A write
177177
// that fails forgets what it sent, and the next change sends the page again.
178178
const saving = new Map<string, Promise<void>>();
179-
const flush = () => {
179+
// `leaving` is the page going away with a save still waiting: the write is sent at once, and
180+
// keepalive lets it outlive the page.
181+
// ponytail: keepalive caps a body at 64 KB, so a bigger page's last half second is still lost.
182+
const flush = (leaving = false) => {
183+
clearTimeout(pending);
180184
pending = undefined;
181185
for (const [slug, pageId] of pages) {
182186
const file = pageFile(editor, pageId, slug);
@@ -187,10 +191,15 @@ export function installCanvasContent(editor: Editor) {
187191
fetch(`${import.meta.env.BASE_URL}__sp/canvas-content`, {
188192
method: "POST",
189193
headers: { "content-type": "application/json" },
190-
body: JSON.stringify({ slug, file: body ? file : null }),
194+
body: JSON.stringify({ slug, file: body ? file : null, by: PAGE_ID }),
195+
keepalive: leaving,
191196
}).then(async (res) => {
192197
if (!res.ok) throw new Error(await res.text());
193198
});
199+
if (leaving) {
200+
void post();
201+
continue;
202+
}
194203
saving.set(
195204
slug,
196205
(saving.get(slug) ?? Promise.resolve()).then(post).catch((error) => {
@@ -207,12 +216,15 @@ export function installCanvasContent(editor: Editor) {
207216
const dispose = editor.store.listen(
208217
() => {
209218
clearTimeout(pending);
210-
pending = setTimeout(flush, 500);
219+
pending = setTimeout(() => flush(), 500);
211220
},
212221
{ source: "user", scope: "document" },
213222
);
223+
const leave = () => pending && flush(true);
224+
window.addEventListener("pagehide", leave);
214225
return () => {
215226
dispose();
227+
window.removeEventListener("pagehide", leave);
216228
clearTimeout(pending);
217229
mounted = undefined;
218230
};

‎canvas/src/canvasIndex.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ export interface CanvasIndex {
6565

6666
let index: CanvasIndex | undefined;
6767

68+
/** This page, to the server, so a save it hears about can be told from its own (canvasContent.ts). */
69+
export const PAGE_ID = crypto.randomUUID();
70+
6871
/** Hands the page its index. The entries call `loadCanvasIndex`; the tests call this directly. */
6972
export function installCanvasIndex(next: CanvasIndex) {
7073
index = next;
@@ -106,6 +109,10 @@ export async function loadCanvasIndex(live = true) {
106109
if (!live || !canvasIndex().served) return;
107110
const events = new EventSource(`${import.meta.env.BASE_URL}__sp/events`);
108111
events.addEventListener("reload", () => window.location.reload());
112+
// A canvas saved by another window: the file wins on load, as for an edit made outside.
113+
events.addEventListener("content", (event) => {
114+
if (JSON.parse(event.data).by !== PAGE_ID) window.location.reload();
115+
});
109116
events.addEventListener("docs", (event) => {
110117
canvasIndex().docs = JSON.parse(event.data);
111118
window.dispatchEvent(new Event(DOCS_CHANGED));

0 commit comments

Comments
 (0)