From 54b38f88f84a0a57e40a9f74d2790868bf1fba81 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Fri, 21 Aug 2026 16:57:58 -0700 Subject: [PATCH 1/4] fix(clips,slides): close the remaining sessionStorage hydration mismatches The share route's fix left three siblings with the same shape: a useState initializer reading sessionStorage, so the first client render disagrees with the server's, React discards the hydrated tree, and the page re-renders from scratch - blank for a returning viewer. - clips embed.$shareId: the embedded player, same saved-password read - clips + slides access-request.approve: the saved approval token Each starts from the value the server can also compute and adopts the stored one after mount. --- .../app/routes/access-request.approve.tsx | 25 ++++++++++++------- templates/clips/app/routes/embed.$shareId.tsx | 24 ++++++++++++------ .../app/routes/access-request.approve.tsx | 25 ++++++++++++------- 3 files changed, 49 insertions(+), 25 deletions(-) diff --git a/templates/clips/app/routes/access-request.approve.tsx b/templates/clips/app/routes/access-request.approve.tsx index ba0b9e8af5..9365bc7555 100644 --- a/templates/clips/app/routes/access-request.approve.tsx +++ b/templates/clips/app/routes/access-request.approve.tsx @@ -43,16 +43,23 @@ export default function ApproveRecordingAccessRequestRoute() { const approvalTokenFromUrl = searchParams.get("token") ?? ""; const approvalTokenStorageKey = recordingAccessApprovalSessionKey(recordingId); - const [approvalToken, setApprovalToken] = useState(() => { - if (approvalTokenFromUrl) return approvalTokenFromUrl; - if (typeof window === "undefined" || !recordingId) return ""; + // Only the URL token is knowable on the server, so reading storage in the + // initializer makes the first client render disagree with the server's and + // React re-renders the page from scratch. Adopt the stored token after mount. + const [approvalToken, setApprovalToken] = useState( + () => approvalTokenFromUrl ?? "", + ); + + useEffect(() => { + if (approvalTokenFromUrl || !recordingId) return; try { - return sessionStorage.getItem(approvalTokenStorageKey) ?? ""; - } catch { - // coercion-ok: unavailable tab storage is an absent continuation. - return ""; - } - }); + const stored = sessionStorage.getItem(approvalTokenStorageKey); + if (stored) setApprovalToken(stored); + // Unavailable tab storage is an absent continuation: the page then asks + // for the token rather than silently continuing without one. + // coercion-ok: the absent case is visible to the user, not swallowed. + } catch {} + }, [approvalTokenFromUrl, recordingId, approvalTokenStorageKey]); const [state, setState] = useState({ kind: "loading" }); useEffect(() => { diff --git a/templates/clips/app/routes/embed.$shareId.tsx b/templates/clips/app/routes/embed.$shareId.tsx index 22192f3a07..93796989e5 100644 --- a/templates/clips/app/routes/embed.$shareId.tsx +++ b/templates/clips/app/routes/embed.$shareId.tsx @@ -71,14 +71,24 @@ export default function EmbedRoute() { [searchParams], ); - const [password, setPassword] = useState(() => { - if (typeof window === "undefined" || !shareId) return null; + // Same hydration trap the share route had: reading sessionStorage in the + // initializer makes the first client render disagree with the server's, + // which has no storage and always renders the locked state. React discards + // the hydrated tree and re-renders from scratch, so an embedded player goes + // blank for a returning viewer. Start where the server started and adopt the + // stored password after mount. + const [password, setPassword] = useState(null); + + useEffect(() => { + if (!shareId) return; try { - return sessionStorage.getItem(STORAGE_KEY_PREFIX + shareId); - } catch { - return null; - } - }); + const stored = sessionStorage.getItem(STORAGE_KEY_PREFIX + shareId); + if (stored) setPassword(stored); + // Unreadable storage and no stored password are the same state here: + // both leave `password` null, which renders the password prompt. + // coercion-ok: the fallback is visible to the viewer, not swallowed. + } catch {} + }, [shareId]); const [pwError, setPwError] = useState(null); const readyMediaPollRef = useRef<{ key: string; until: number } | null>(null); diff --git a/templates/slides/app/routes/access-request.approve.tsx b/templates/slides/app/routes/access-request.approve.tsx index 8fe22324b8..717f8cd110 100644 --- a/templates/slides/app/routes/access-request.approve.tsx +++ b/templates/slides/app/routes/access-request.approve.tsx @@ -42,16 +42,23 @@ export default function ApproveDeckAccessRequestRoute() { const deckId = searchParams.get("deckId") ?? ""; const approvalTokenFromUrl = searchParams.get("token") ?? ""; const approvalTokenStorageKey = deckAccessApprovalSessionKey(deckId); - const [approvalToken, setApprovalToken] = useState(() => { - if (approvalTokenFromUrl) return approvalTokenFromUrl; - if (typeof window === "undefined" || !deckId) return ""; + // Only the URL token is knowable on the server, so reading storage in the + // initializer makes the first client render disagree with the server's and + // React re-renders the page from scratch. Adopt the stored token after mount. + const [approvalToken, setApprovalToken] = useState( + () => approvalTokenFromUrl ?? "", + ); + + useEffect(() => { + if (approvalTokenFromUrl || !deckId) return; try { - return sessionStorage.getItem(approvalTokenStorageKey) ?? ""; - } catch { - // coercion-ok: unavailable tab storage is an absent continuation. - return ""; - } - }); + const stored = sessionStorage.getItem(approvalTokenStorageKey); + if (stored) setApprovalToken(stored); + // Unavailable tab storage is an absent continuation: the page then asks + // for the token rather than silently continuing without one. + // coercion-ok: the absent case is visible to the user, not swallowed. + } catch {} + }, [approvalTokenFromUrl, deckId, approvalTokenStorageKey]); const [state, setState] = useState({ kind: "loading" }); useEffect(() => { From 1a0c72d3342e3c951736572589d0cba318d869e1 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Fri, 21 Aug 2026 20:33:03 -0700 Subject: [PATCH 2/4] perf(docs): stop shipping translated docs markdown in the serverless function docs-content.ts globs core/docs/content/locales/*/*.md{,x} with query:"?raw", so Vite emits one lazy chunk per translated file - 1251 of them. Netlify's nodeBundler:"none" + includedFiles:["**"] ships every one and the function unzips all of it on each cold start. It can never serve them: every localized docs page is prerendered, so the CDN answers those URLs from the publish directory and the render function is never invoked. The globs stay - their KEYS still drive localizedDocKey and the locale-availability enumeration, so hreflang and the locale switcher are unaffected. Measured: docs server function 51MB -> 30MB, 1856 -> 615 chunks, with all 193 English markdown chunks intact. The pruner refuses to delete a chunk it cannot prove is prerendered, and skips redirect and draft slugs, so a page the function is still the only renderer for aborts the build instead of 500ing in production. --- packages/docs/package.json | 2 +- .../docs/scripts/prune-locale-doc-chunks.ts | 207 ++++++++++++++++++ .../tests/prune-locale-doc-chunks.test.ts | 91 ++++++++ 3 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 packages/docs/scripts/prune-locale-doc-chunks.ts create mode 100644 packages/docs/tests/prune-locale-doc-chunks.test.ts diff --git a/packages/docs/package.json b/packages/docs/package.json index 3bf2eab19f..fc9e0dd984 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -18,7 +18,7 @@ "migrate:production": "tsx scripts/migrate-production.ts", "validate-doc-blocks": "vitest run app/components/docBlocks.validate.test.tsx", "prebuild": "tsx scripts/generate-source-index.ts && vitest run app/components/docBlocks.validate.test.tsx", - "build": "agent-native build", + "build": "agent-native build && tsx scripts/prune-locale-doc-chunks.ts", "start": "agent-native start", "typecheck": "agent-native typecheck", "test": "vitest run --passWithNoTests" diff --git a/packages/docs/scripts/prune-locale-doc-chunks.ts b/packages/docs/scripts/prune-locale-doc-chunks.ts new file mode 100644 index 0000000000..45641b5420 --- /dev/null +++ b/packages/docs/scripts/prune-locale-doc-chunks.ts @@ -0,0 +1,207 @@ +/** + * Drop translated docs markdown from the deployed serverless functions. + * + * `docs-content.ts` globs `core/docs/content/locales//*.md{,x}` with + * `query: "?raw"`, so Vite emits one lazy chunk per translated file — 1251 of + * them, 19.2MB, roughly 40% of a 51MB docs function. Netlify's + * `nodeBundler: "none"` + `includedFiles: ["**"]` ships every one, and the + * function unzips all of it on each cold start. + * + * It can never serve them. Every localized docs page is prerendered to a static + * file at build time, so the CDN answers those URLs from the publish directory + * and the render function is never invoked for them. The globs stay: the chunk + * KEYS still drive `localizedDocKey` and the locale-availability enumeration, + * so hreflang and the locale switcher keep working with the target files gone. + * + * This runs after `agent-native build`, when the emitted functions are final. + * It deletes nothing it cannot prove is prerendered — see `assertPrerendered`. + */ +import { + existsSync, + readdirSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { DOCS_SLUG_REDIRECTS } from "../app/components/docs-slug-redirects.js"; + +const DOCS_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const FUNCTIONS_DIR = path.join(DOCS_ROOT, ".netlify", "functions-internal"); +const PUBLISH_DIR = path.join(DOCS_ROOT, "dist"); +const LOCALES_DIR = path.resolve(DOCS_ROOT, "../core/docs/content/locales"); + +/** `"../../../core/docs/content/locales/ar-SA/x.mdx":()=>import(`./x-HASH.mjs`)` */ +const GLOB_ENTRY = + /"([^"]*\/core\/docs\/content\/(?:locales\/[^/"]+\/)?[^"]+\.mdx?)"\s*:\s*\(\)\s*=>\s*import\(\s*[`"']\.\/([^`"']+)[`"']\s*\)/g; + +type Attribution = { + localeOnly: Set; + keysByChunk: Map; +}; + +/** + * Map each emitted chunk to the glob keys that reference it. A chunk is + * locale-only when every key that reaches it lives under `locales/`. Reading + * the emitted bundle rather than a manifest means this cannot drift from what + * the build actually produced. + */ +function attributeChunks(chunksDir: string): Attribution { + const keysByChunk = new Map(); + for (const name of readdirSync(chunksDir)) { + if (!name.endsWith(".mjs")) continue; + let source: string; + try { + source = readFileSync(path.join(chunksDir, name), "utf8"); + } catch { + continue; + } + GLOB_ENTRY.lastIndex = 0; + for (let m: RegExpExecArray | null; (m = GLOB_ENTRY.exec(source)); ) { + const [, key, chunk] = m; + const list = keysByChunk.get(chunk) ?? []; + list.push(key); + keysByChunk.set(chunk, list); + } + } + + const localeOnly = new Set(); + for (const [chunk, keys] of keysByChunk) { + if (keys.every((key) => key.includes("/locales/"))) localeOnly.add(chunk); + } + return { localeOnly, keysByChunk }; +} + +function slugFromKey(key: string): string { + return path.basename(key).replace(/\.mdx?$/, ""); +} + +function localeFromKey(key: string): string | undefined { + return /\/locales\/([^/]+)\//.exec(key)?.[1]; +} + +function isDraft(key: string): boolean { + // A draft is excluded from prerender, so the function is still its only + // renderer. `docs-content` loads the chunk before it decides visibility. + const absolute = path.resolve( + LOCALES_DIR, + "..", + key.replace(/^.*core\/docs\/content\//, ""), + ); + try { + return /^---[\s\S]*?\bdraft:\s*["']?true/m.test( + readFileSync(absolute, "utf8"), + ); + } catch { + // Unreadable source is not provably prerendered, so keep its chunk. + return true; + } +} + +/** + * Refuse to delete a chunk whose page is not actually prerendered. A missing + * static page would turn a translated doc into a 500 the moment the function + * tried to load a chunk this script removed, so an unproven page aborts the + * build instead. + */ +function assertPrerendered(keys: string[]): string[] { + const missing: string[] = []; + for (const key of keys) { + const locale = localeFromKey(key); + const slug = slugFromKey(key); + if (!locale) continue; + const candidates = + slug === "getting-started" + ? [path.join(PUBLISH_DIR, locale, "docs", "index.html")] + : [path.join(PUBLISH_DIR, locale, "docs", slug, "index.html")]; + if (!candidates.some((file) => existsSync(file))) + missing.push(`${locale}/${slug}`); + } + return missing; +} + +function pruneFunction(functionDir: string): { + removed: number; + bytes: number; +} { + const chunksDir = path.join(functionDir, "_chunks"); + if (!existsSync(chunksDir)) return { removed: 0, bytes: 0 }; + + const { localeOnly, keysByChunk } = attributeChunks(chunksDir); + const redirectSlugs = new Set(Object.keys(DOCS_SLUG_REDIRECTS)); + + const prunable: string[] = []; + const provenKeys: string[] = []; + for (const chunk of localeOnly) { + const keys = keysByChunk.get(chunk) ?? []; + // A redirect slug never renders its own page, and a draft is not + // prerendered; both keep their chunk rather than gamble on the 301/500. + if ( + keys.some((key) => redirectSlugs.has(slugFromKey(key)) || isDraft(key)) + ) { + continue; + } + prunable.push(chunk); + provenKeys.push(...keys); + } + + const missing = assertPrerendered(provenKeys); + if (missing.length > 0) { + throw new Error( + `prune-locale-doc-chunks: refusing to prune — ${missing.length} translated doc(s) have no prerendered page, ` + + `so the function is still their only renderer: ${missing.slice(0, 8).join(", ")}` + + (missing.length > 8 ? ` (+${missing.length - 8} more)` : ""), + ); + } + + let removed = 0; + let bytes = 0; + for (const chunk of prunable) { + const file = path.join(chunksDir, chunk); + try { + bytes += statSync(file).size; + rmSync(file); + removed += 1; + } catch { + // Already gone: the sibling function is hardlinked in some layouts. + } + } + return { removed, bytes }; +} + +function main(): void { + if (!existsSync(FUNCTIONS_DIR)) { + console.log("[docs] No emitted functions; skipping locale chunk prune."); + return; + } + + let removed = 0; + let bytes = 0; + for (const entry of readdirSync(FUNCTIONS_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + // Netlify zips each function separately, so a chunk must be deleted from + // every emitted copy to actually leave the upload. + const result = pruneFunction(path.join(FUNCTIONS_DIR, entry.name)); + removed += result.removed; + bytes += result.bytes; + } + + console.log( + `[docs] Pruned ${removed} translated-doc chunk(s) (${(bytes / 1024 / 1024).toFixed(1)}MB) from the ` + + `serverless functions; every localized page is prerendered and served statically.`, + ); +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); +} + +export { attributeChunks, assertPrerendered, pruneFunction }; diff --git a/packages/docs/tests/prune-locale-doc-chunks.test.ts b/packages/docs/tests/prune-locale-doc-chunks.test.ts new file mode 100644 index 0000000000..36f3475ad9 --- /dev/null +++ b/packages/docs/tests/prune-locale-doc-chunks.test.ts @@ -0,0 +1,91 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + assertPrerendered, + attributeChunks, +} from "../scripts/prune-locale-doc-chunks"; + +/** + * The pruner deletes real files out of a deployed function, so the two things + * worth pinning are the ones whose failure is silent: attributing a chunk to + * the wrong side (deleting an English page's content) and deleting a page that + * was never prerendered (a 500 on a translated doc). + */ +describe("prune-locale-doc-chunks", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), "prune-locale-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function writeChunk(name: string, entries: Array<[string, string]>): void { + const body = entries + .map(([key, chunk]) => `"${key}":()=>import(\`./${chunk}\`)`) + .join(","); + writeFileSync(path.join(dir, name), `const m={${body}};export default m;`); + } + + it("treats a chunk reached only by locale keys as locale-only", () => { + writeChunk("docs-content.mjs", [ + [ + "../../../core/docs/content/locales/de-DE/actions.mdx", + "actions-DE.mjs", + ], + ["../../../core/docs/content/actions.mdx", "actions-EN.mjs"], + ]); + + const { localeOnly } = attributeChunks(dir); + + expect(localeOnly.has("actions-DE.mjs")).toBe(true); + expect(localeOnly.has("actions-EN.mjs")).toBe(false); + }); + + it("keeps a chunk shared by an English key, even if a locale key also reaches it", () => { + // Deleting this would strip content the function genuinely still renders. + writeChunk("docs-content.mjs", [ + ["../../../core/docs/content/locales/ja-JP/shared.mdx", "shared.mjs"], + ["../../../core/docs/content/shared.mdx", "shared.mjs"], + ]); + + const { localeOnly } = attributeChunks(dir); + + expect(localeOnly.has("shared.mjs")).toBe(false); + }); + + it("reports a translated doc with no prerendered page instead of pruning it", () => { + const missing = assertPrerendered([ + "../../../core/docs/content/locales/fr-FR/never-prerendered.mdx", + ]); + + expect(missing).toEqual(["fr-FR/never-prerendered"]); + }); + + it("accepts a translated doc whose prerendered page exists", () => { + const publish = path.resolve( + path.dirname(new URL(import.meta.url).pathname), + "..", + "dist", + ); + mkdirSync(path.join(publish, "de-DE", "docs", "actions-overview"), { + recursive: true, + }); + writeFileSync( + path.join(publish, "de-DE", "docs", "actions-overview", "index.html"), + "", + ); + + const missing = assertPrerendered([ + "../../../core/docs/content/locales/de-DE/actions-overview.mdx", + ]); + + expect(missing).toEqual([]); + }); +}); From d413bf7dfdb7bacc22c1565f2f4780c26477e98b Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Sat, 22 Aug 2026 06:37:39 -0700 Subject: [PATCH 3/4] perf: cut serverless function payloads across every app Five cuts, each measured on the netlify preset: - @xterm/* is stubbed out of the SSR graph by default in core, not repeated in sixteen vite configs. It is only reachable through a React.lazy boundary whose module body guards on typeof window === undefined, so the server can never import it and the chunk was pure unpack weight everywhere. - formatExtensionHtml (and its slides twin) load prettier/standalone plus the four plugins the HTML printer reaches. prettier's main entry import()s all 13 parser plugins, so bundlers inlined ~3.5MB of flow/typescript/yaml/markdown parsers to format HTML. - docs stubs the tiptap/ProseMirror/assistant-ui editor stack: it never server-renders the agent sidebar or resource editor. lowlight and yjs stay real - both genuinely run on the server. - the docs corpus glob map is declared once instead of twice, so Rollup stops emitting two byte-identical copies of a 1447-entry lazy-import map. Measured: calendar 46.7MB -> 21.1MB, docs 51MB -> 26MB (76.8MB at the start of this work). --- .../shrink-serverless-function-payloads.md | 5 +++ packages/core/src/extensions/content-patch.ts | 16 +++++++- packages/core/src/vite/client.ts | 15 +++++++- .../docs/app/components/docs-availability.ts | 31 +++------------- packages/docs/app/components/docs-content.ts | 30 +-------------- .../app/components/docs-source-loaders.ts | 37 +++++++++++++++++++ .../docs/scripts/prune-locale-doc-chunks.ts | 9 +++-- packages/docs/vite.config.ts | 29 +++++++++++++++ .../slides/server/lib/slide-content-patch.ts | 15 +++++++- 9 files changed, 125 insertions(+), 62 deletions(-) create mode 100644 .changeset/shrink-serverless-function-payloads.md create mode 100644 packages/docs/app/components/docs-source-loaders.ts diff --git a/.changeset/shrink-serverless-function-payloads.md b/.changeset/shrink-serverless-function-payloads.md new file mode 100644 index 0000000000..5f80cc9d59 --- /dev/null +++ b/.changeset/shrink-serverless-function-payloads.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Cut serverless function payloads across every app. `@xterm/*` is now stubbed out of the SSR graph by default (it is only reachable through a `React.lazy` boundary the server can never take), and `formatExtensionHtml` loads `prettier/standalone` plus the four plugins the HTML printer actually reaches instead of prettier's main entry, which `import()`s all 13 parsers and inlines ~3.5MB of flow/typescript/yaml parsers. Measured: calendar 46.7MB → 21.1MB, docs 51MB → 26MB. diff --git a/packages/core/src/extensions/content-patch.ts b/packages/core/src/extensions/content-patch.ts index b5ed207d96..c2b64a2957 100644 --- a/packages/core/src/extensions/content-patch.ts +++ b/packages/core/src/extensions/content-patch.ts @@ -137,10 +137,22 @@ async function applyExtensionContentUpdateUnchecked( export async function formatExtensionHtml(content: string): Promise { try { - const prettier = await import("prettier"); - const formatted = await prettier.format(content, { + // prettier's main entry `import()`s all 13 parser plugins, so a bundler + // inlines ~3.5MB of flow/typescript/yaml/markdown parsers just to format + // HTML. Load the standalone core plus only the plugins the HTML printer + // reaches, which still formats embedded