Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Fix Artifacts cold-load dependencies and add navigation recovery

syntaqxcommitted Parent9de2295Browse files
7 files+151−180/7 viewed
+10−0
4646 and pass the viewer in each call's arguments. Nothing waits on a call it
4747 does not need.
4848
49+When an in-app navigation takes more than five seconds, **Reload destination**
50+opens the requested URL as a full page. It uses the pending destination,
51+including its query and fragment, rather than the page still in the address bar.
52+It is not offered while a form submission is still being sent.
53+
54+Artifacts caches completed workspace values across requests for 30 seconds;
55+in-flight lookups are shared only within the request that started them. A
56+document without a saved editor snapshot renders its saved text while its live
57+room warms in the background, instead of holding the page response on that room.
58+
4959 ### Deadlines
5060
5161 Each binding is wrapped by `instrumented()` in `app/lib/perf.server.ts`,
+30−0
1+import { useEffect, useState } from "react";
2+import { useNavigation } from "react-router";
3+
4+import { stuckTarget } from "../lib/stuck";
5+import { Button } from "./ui/button";
6+
7+/** A document load of the destination, while the router still shows the old page. */
8+export function NavigationRecovery() {
9+ const navigation = useNavigation();
10+ const target = stuckTarget(navigation);
11+ const key = navigation.location?.key;
12+ const [slow, setSlow] = useState<string | null>(null);
13+ useEffect(() => {
14+ setSlow(null);
15+ if (!target || !key) return;
16+ const timer = window.setTimeout(() => setSlow(key), 5_000);
17+ return () => window.clearTimeout(timer);
18+ }, [target, key]);
19+ if (!target || !key || slow !== key) return null;
20+ return (
21+ <div className="fixed inset-x-0 bottom-20 z-65 flex justify-center px-4">
22+ <div className="flex max-w-full flex-wrap items-center justify-center gap-3 rounded-xl bg-surface px-4 py-3 text-sm shadow-xl ring-1 ring-line-strong">
23+ <span role="status">This page is taking a while.</span>
24+ <Button asChild variant="outline" size="sm">
25+ <a href={target}>Reload destination</a>
26+ </Button>
27+ </div>
28+ </div>
29+ );
30+}
+2−0
9797 } from "./lib/developer-mode";
9898 import { RouteError } from "./components/route-error";
9999 import { ConnectionToast } from "./components/connection-toast";
100+import { NavigationRecovery } from "./components/navigation-recovery";
100101 import { isNetworkFailure } from "./lib/resilient-fetch";
101102 import { useReconnect } from "./lib/reconnect";
102103
618619 )}
619620 {/* A page that cannot reach g1t says so, and keeps what it shows (lib/resilient-fetch.ts). */}
620621 <ConnectionToast />
622+ <NavigationRecovery />
621623 {/* Live notifications for the signed-in: the feed socket, toasts, the tab's count (components/notifications). */}
622624 {user && !awaitsConfirmation(user) && (
623625 <LiveNotifications
+36−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+import { fileURLToPath } from "node:url";
4+import { build } from "esbuild";
5+
6+const bundle = await build({
7+ entryPoints: [fileURLToPath(new URL("./service.ts", import.meta.url))], bundle: true, write: false, format: "esm", platform: "node",
8+ plugins: [{ name: "worker-host", setup(build) {
9+ build.onResolve({ filter: /^cloudflare:workers$/ }, () => ({ path: "host", namespace: "host" }));
10+ build.onLoad({ filter: /.*/, namespace: "host" }, () => ({ contents: "export class DurableObject {}" }));
11+ } }],
12+});
13+const { Folios } = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}`);
14+
15+test("a cold room never holds up page state; its snapshot is saved in the background", async () => {
16+ let resolve!: (value: Uint8Array) => void;
17+ const pending = new Promise<Uint8Array>((done) => { resolve = done; });
18+ const deferred: Promise<unknown>[] = [];
19+ const writes: unknown[][] = [];
20+ const statement = { bind: (...values: unknown[]) => { writes.push(values); return statement; }, run: async () => ({ success: true }) };
21+ const service = new Folios({ DB: { prepare: () => statement } }, (work: Promise<unknown>) => deferred.push(work));
22+ service.ready = async () => ({ state: () => pending });
23+ assert.equal(service.stateFor({ slug: "cold" }, { id: "folio_cold", state: null }), null);
24+ assert.equal(deferred.length, 1);
25+ assert.deepEqual(writes, []);
26+ const bytes = new Uint8Array([1, 2, 3]);
27+ resolve(bytes);
28+ await Promise.all(deferred);
29+ assert.deepEqual(writes, [[bytes, "folio_cold"]]);
30+});
31+
32+test("saved state and trashed documents never wake the live room", () => {
33+ const service = new Folios({}, () => assert.fail("must not start background work"));
34+ assert.equal(service.stateFor({}, { state: [104, 105] }), btoa("hi"));
35+ assert.equal(service.stateFor({}, { state: null, trashed_at: "2026-10-15" }), null);
36+});
+13−10
575575 ]);
576576 const parents = above.map((id) => aboveRows.get(id)).filter((r): r is FolioRow => !!r);
577577 const others = [...parents, ...childRows.results, ...linkRows.results.filter((r) => r.id !== row.id)];
578− const [{ roles }, state] = await Promise.all([this.roles(ctx, others), this.stateFor(ctx.workspace, row)]);
578+ const state = this.stateFor(ctx.workspace, row);
579+ const { roles } = await this.roles(ctx, others);
579580 const readable = (list: FolioRow[]) => list.filter((r) => roles.get(r.id)).map((r) => this.ref(ctx.workspace.slug, r));
580581 return ok({
581582 folio: folio!,
591592 /**
592593 * The document the page carries, so the editor opens on it before the
593594 * room answers. A folio saved before its state was kept (or whose last
594− * save was too large for a row) has none in D1: its room is asked once,
595− * the state goes out with this page, and it is written back so the next
596− * open reads it from the row like any other. A room that cannot answer
597− * costs the page nothing: the editor waits for the room as before.
595+ * save was too large for a row) has none in D1: warm it in the background
596+ * so the next open can read it from the row. Never hold the HTML response
597+ * on a cold live room; this open shows saved text and syncs via the socket.
598598 */
599− private async stateFor(workspace: Workspace, row: FolioRow): Promise<string | null> {
599+ private stateFor(workspace: Workspace, row: FolioRow): string | null {
600600 const saved = pageState(row.state);
601601 if (saved || row.trashed_at) return saved;
602+ this.defer(this.warmPageState(workspace, row));
603+ return null;
604+ }
605+
606+ private async warmPageState(workspace: Workspace, row: FolioRow): Promise<void> {
602607 try {
603608 const room = await this.ready(workspace, row);
604609 const state = await room.state();
605− if (!state.byteLength || state.byteLength > MAX_PAGE_STATE) return null;
606− this.defer(this.db.prepare("UPDATE folios SET state = ? WHERE id = ? AND state IS NULL").bind(state, row.id).run());
607− return pageState(state);
610+ if (!state.byteLength || state.byteLength > MAX_PAGE_STATE) return;
611+ await this.db.prepare("UPDATE folios SET state = ? WHERE id = ? AND state IS NULL").bind(state, row.id).run();
608612 } catch (error) {
609613 console.warn("artifacts: a page opens without its state", row.id, error instanceof Error ? error.message : String(error));
610− return null;
611614 }
612615 }
613616
+51−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+import { build } from "esbuild";
4+import { fileURLToPath } from "node:url";
5+
6+import type { WhoEnv } from "./who.ts";
7+
8+// Resolve the contracts package's bundler-style imports as production does.
9+const bundle = await build({ entryPoints: [fileURLToPath(new URL("./who.ts", import.meta.url))], bundle: true, write: false, format: "esm", platform: "node" });
10+const { Who, forgetKept }: typeof import("./who.ts") = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}`);
11+
12+function deferred<T>() {
13+ let resolve!: (value: T) => void;
14+ const promise = new Promise<T>((done) => { resolve = done; });
15+ return { promise, resolve };
16+}
17+
18+function environment(read: () => Promise<Response>): WhoEnv {
19+ const statement = { bind: () => statement, run: async () => ({ success: true }) };
20+ return { IDENTITY: { fetch: read }, DB: { prepare: () => statement } as unknown as D1Database };
21+}
22+
23+test("a cold request cannot make another request wait on its workspace I/O", async () => {
24+ forgetKept();
25+ const first = deferred<Response>();
26+ let calls = 0;
27+ const workspace = { id: "ws_cold", slug: "cold" };
28+ const env = environment(async () => ++calls === 1 ? first.promise : Response.json(workspace));
29+ const one = new Who(env);
30+ const pending = one.workspace("cold");
31+ assert.equal(one.workspace("cold"), pending, "deduplicate within one request");
32+ const two = new Who(env).workspace("cold");
33+ assert.equal(calls, 2, "independent requests must start independent I/O on a miss");
34+ assert.deepEqual(await two, workspace, "request two completes while request one is still pending");
35+ first.resolve(Response.json(null));
36+ assert.equal(await pending, null);
37+ assert.deepEqual(await new Who(env).workspace("cold"), workspace);
38+ assert.equal(calls, 2, "a late miss must not evict a successful cached answer");
39+});
40+
41+test("failed workspace reads are retried by the next request", async () => {
42+ forgetKept();
43+ let calls = 0;
44+ const env = environment(async () => {
45+ if (++calls === 1) throw new Error("connection lost");
46+ return Response.json({ id: "ws_retry", slug: "retry" });
47+ });
48+ assert.equal(await new Who(env).workspace("retry"), null);
49+ assert.equal((await new Who(env).workspace("retry"))?.id, "ws_retry");
50+ assert.equal(calls, 2);
51+});
+9−8
7272 * that half minute, which is what the site's own redirect allows.
7373 */
7474 const WORKSPACE_TTL_MS = 30_000;
75−const workspaces = new Map<string, { at: number; value: Promise<Workspace | null> }>();
75+// Only completed values cross requests. In-flight I/O belongs to the
76+// request that started it; another request must never await that promise.
77+const workspaces = new Map<string, { at: number; value: Workspace }>();
7678 const defaultsMade = new Set<string>();
7779
7880 /** For tests: forgets everything kept across requests. */
107109 let found = this.workspaces.get(key);
108110 if (!found) {
109111 const kept = workspaces.get(key);
110− if (kept && Date.now() - kept.at < WORKSPACE_TTL_MS) found = kept.value;
112+ if (kept && Date.now() - kept.at < WORKSPACE_TTL_MS) found = Promise.resolve(kept.value);
111113 else {
112114 found = identityClient(this.env.IDENTITY)
113115 .getWorkspace(key)
114116 .catch(() => null)
115117 // Its slug, for reporting its embeddings to billing (src/embed-billing.ts).
116− .then(async (w) => (await rememberWorkspace(this.env.DB, w), w));
117− workspaces.set(key, { at: Date.now(), value: found });
118− // Only an answer is kept: a miss or a failure is asked again next time.
119− void found.then((w) => {
120− if (!w) workspaces.delete(key);
121− });
118+ .then(async (w) => {
119+ await rememberWorkspace(this.env.DB, w);
120+ if (w) workspaces.set(key, { at: Date.now(), value: w });
121+ return w;
122+ });
122123 }
123124 this.workspaces.set(key, found);
124125 }

Comments

Sign in to comment.