Fix Artifacts cold-load dependencies and add navigation recovery
7 files+151−180/7 viewed
| 46 | 46 | and pass the viewer in each call's arguments. Nothing waits on a call it | |
| 47 | 47 | does not need. | |
| 48 | 48 | ||
| 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 | + | ||
| 49 | 59 | ### Deadlines | |
| 50 | 60 | ||
| 51 | 61 | Each binding is wrapped by `instrumented()` in `app/lib/perf.server.ts`, |
| 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 | + | } |
| 97 | 97 | } from "./lib/developer-mode"; | |
| 98 | 98 | import { RouteError } from "./components/route-error"; | |
| 99 | 99 | import { ConnectionToast } from "./components/connection-toast"; | |
| 100 | + | import { NavigationRecovery } from "./components/navigation-recovery"; | |
| 100 | 101 | import { isNetworkFailure } from "./lib/resilient-fetch"; | |
| 101 | 102 | import { useReconnect } from "./lib/reconnect"; | |
| 102 | 103 | ||
| ⋯ | |||
| 618 | 619 | )} | |
| 619 | 620 | {/* A page that cannot reach g1t says so, and keeps what it shows (lib/resilient-fetch.ts). */} | |
| 620 | 621 | <ConnectionToast /> | |
| 622 | + | <NavigationRecovery /> | |
| 621 | 623 | {/* Live notifications for the signed-in: the feed socket, toasts, the tab's count (components/notifications). */} | |
| 622 | 624 | {user && !awaitsConfirmation(user) && ( | |
| 623 | 625 | <LiveNotifications | |
| 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 | + | }); |
| 575 | 575 | ]); | |
| 576 | 576 | const parents = above.map((id) => aboveRows.get(id)).filter((r): r is FolioRow => !!r); | |
| 577 | 577 | 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); | |
| 579 | 580 | const readable = (list: FolioRow[]) => list.filter((r) => roles.get(r.id)).map((r) => this.ref(ctx.workspace.slug, r)); | |
| 580 | 581 | return ok({ | |
| 581 | 582 | folio: folio!, | |
| ⋯ | |||
| 591 | 592 | /** | |
| 592 | 593 | * The document the page carries, so the editor opens on it before the | |
| 593 | 594 | * 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. | |
| 598 | 598 | */ | |
| 599 | − | private async stateFor(workspace: Workspace, row: FolioRow): Promise<string | null> { | |
| 599 | + | private stateFor(workspace: Workspace, row: FolioRow): string | null { | |
| 600 | 600 | const saved = pageState(row.state); | |
| 601 | 601 | 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> { | |
| 602 | 607 | try { | |
| 603 | 608 | const room = await this.ready(workspace, row); | |
| 604 | 609 | 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(); | |
| 608 | 612 | } catch (error) { | |
| 609 | 613 | console.warn("artifacts: a page opens without its state", row.id, error instanceof Error ? error.message : String(error)); | |
| 610 | − | return null; | |
| 611 | 614 | } | |
| 612 | 615 | } | |
| 613 | 616 | ||
| 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 | + | }); |
| 72 | 72 | * that half minute, which is what the site's own redirect allows. | |
| 73 | 73 | */ | |
| 74 | 74 | 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 }>(); | |
| 76 | 78 | const defaultsMade = new Set<string>(); | |
| 77 | 79 | ||
| 78 | 80 | /** For tests: forgets everything kept across requests. */ | |
| ⋯ | |||
| 107 | 109 | let found = this.workspaces.get(key); | |
| 108 | 110 | if (!found) { | |
| 109 | 111 | 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); | |
| 111 | 113 | else { | |
| 112 | 114 | found = identityClient(this.env.IDENTITY) | |
| 113 | 115 | .getWorkspace(key) | |
| 114 | 116 | .catch(() => null) | |
| 115 | 117 | // 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 | + | }); | |
| 122 | 123 | } | |
| 123 | 124 | this.workspaces.set(key, found); | |
| 124 | 125 | } | |
Comments
Sign in to comment.