diff --git a/web_ui/scripts/check-bundle-size.mjs b/web_ui/scripts/check-bundle-size.mjs index 8f8e9b1e..ea6211d4 100644 --- a/web_ui/scripts/check-bundle-size.mjs +++ b/web_ui/scripts/check-bundle-size.mjs @@ -126,7 +126,32 @@ const ASSETS_DIR = join(HERE, "..", "dist", "app", "assets"); // regression ratchet, not the budget. Closing the real gap still needs the // 17 eagerly-rendered node views in SceneCanvas.tsx code-split - work no // stage owns. -const LARGEST_CHUNK_CEILING_BYTES = 917_000; +// Deliberate, commented amendment - 2026-09-04 (ADR-019 section 4). +// +// The first time either number in this file has moved DOWN, and the reason +// every prior amendment gave for not moving it: the eagerly-rendered node +// views are now code-split. SceneCanvas.tsx registered all 17 *NodeView +// components through static imports, so every one landed in the initial +// chunk whether or not the open canvas held a single node of that kind. +// They now load one chunk per kind, on first render of that kind - see +// web_ui/src/app/canvas/lazyNodeViews.tsx. +// +// Measured: largest chunk 890,541 -> 780,470 bytes (-110,071, -12.4%). +// +// Total JS went the other way, 1,445,539 -> 1,456,011 (+10,472, +0.7%), +// which is expected and is not a regression: splitting redistributes code +// and adds a small per-chunk overhead. The budget ADR-019 actually sets is +// on the INITIAL chunk - what the app must parse before it can paint - and +// that is the number that fell. +// +// Both ceilings are re-anchored to ~3% over measured reality, the posture +// every amendment here has used. Stated plainly, as the raises were: the +// ADR-019 budget for the initial chunk is 500 KiB (512,000 bytes), and at +// 780,470 the initial chunk is still ~52% over it - down from ~74%. React +// and React Flow are the bulk of what remains, and no further split is +// planned; this closes the gap that was attributed to the node views, not +// the whole gap. +const LARGEST_CHUNK_CEILING_BYTES = 804_000; // Post-11.6 reality: six chunks (main + katex + highlight.js + the three // lazy dialogs) total 1,288,075 bytes - essentially unchanged from the // pre-split single-chunk total, as expected: splitting redistributes code @@ -144,7 +169,10 @@ const LARGEST_CHUNK_CEILING_BYTES = 917_000; // 1,423,872, rounded down to a clean number) - this only ever moves the // ceiling down again in a later stage that shrinks the total, per this // file's own ratchet discipline above. -const TOTAL_JS_CEILING_BYTES = 1_489_000; +// 2026-09-04: re-anchored to 1,456,011 with the same ~3% headroom, for the +// per-chunk overhead the node-view split added. Recorded as a raise, small +// as it is - the amendment above is what pays for it. +const TOTAL_JS_CEILING_BYTES = 1_500_000; // Below this much headroom, say so. Both prior amendments record the same // story: the ceiling was set with ~3-5% room, ordinary week-to-week growth diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index 3ff0c64e..a27c0eb8 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -18,24 +18,34 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore import type { SceneEdgeRow, SceneNodeRow, SceneState } from "../../lib/bridge-core/generated/scene-state"; import type { StreamListener } from "../../lib/ws/transport"; import { BridgeErrorState } from "../../lib/ui/BridgeErrorState"; -import { ArtifactNodeView, type ArtifactFlowNode } from "./ArtifactNodeView"; -import { ChartNodeView, type ChartFlowNode } from "./ChartNodeView"; -import { ChatNodeView, type ChatFlowNode } from "./ChatNodeView"; -import { CodeNodeView, type CodeFlowNode } from "./CodeNodeView"; -import { CodeReviewNodeView, type CodeReviewFlowNode } from "./CodeReviewNodeView"; -import { CodeSandboxNodeView, type CodeSandboxFlowNode } from "./CodeSandboxNodeView"; -import { ConversationNodeView, type ConversationFlowNode, type ConversationMessage } from "./ConversationNodeView"; -import { DocumentNodeView, type DocumentFlowNode } from "./DocumentNodeView"; -import { GitlinkNodeView, type GitlinkFlowNode } from "./GitlinkNodeView"; -import { GroupNodeView, type GroupFlowNode } from "./GroupNodeView"; -import { HtmlNodeView, type HtmlFlowNode } from "./HtmlNodeView"; -import { ImageNodeView, type ImageFlowNode } from "./ImageNodeView"; -import { NoteNodeView, type NoteFlowNode } from "./NoteNodeView"; +// ADR-019: the node views are lazily imported, one chunk per kind - see +// lazyNodeViews.tsx for why the Suspense boundary is per node rather than +// one around the whole canvas. Their TYPES stay statically imported below: +// `import type` is erased at build time and pulls nothing into the bundle. +import { + ArtifactNodeView, ChartNodeView, ChatNodeView, CodeNodeView, CodeReviewNodeView, + CodeSandboxNodeView, ConversationNodeView, DocumentNodeView, GitlinkNodeView, + GroupNodeView, HarnessNodeView, HtmlNodeView, ImageNodeView, NoteNodeView, + PlanNodeView, ThinkingNodeView, WebResearchNodeView, +} from "./lazyNodeViews"; +import type { ArtifactFlowNode } from "./ArtifactNodeView"; +import type { ChartFlowNode } from "./ChartNodeView"; +import type { ChatFlowNode } from "./ChatNodeView"; +import type { CodeFlowNode } from "./CodeNodeView"; +import type { CodeReviewFlowNode } from "./CodeReviewNodeView"; +import type { CodeSandboxFlowNode } from "./CodeSandboxNodeView"; +import type { ConversationFlowNode, ConversationMessage } from "./ConversationNodeView"; +import type { DocumentFlowNode } from "./DocumentNodeView"; +import type { GitlinkFlowNode } from "./GitlinkNodeView"; +import type { GroupFlowNode } from "./GroupNodeView"; +import type { HtmlFlowNode } from "./HtmlNodeView"; +import type { ImageFlowNode } from "./ImageNodeView"; +import type { NoteFlowNode } from "./NoteNodeView"; import { OrthogonalEdge } from "./OrthogonalEdge"; -import { ThinkingNodeView, type ThinkingFlowNode } from "./ThinkingNodeView"; -import { WebResearchNodeView, type WebResearchFlowNode } from "./WebResearchNodeView"; -import { PlanNodeView, type PlanFlowNode, type PlanStepData } from "./PlanNodeView"; -import { HarnessNodeView, type HarnessFlowNode } from "./HarnessNodeView"; +import type { ThinkingFlowNode } from "./ThinkingNodeView"; +import type { WebResearchFlowNode } from "./WebResearchNodeView"; +import type { PlanFlowNode, PlanStepData } from "./PlanNodeView"; +import type { HarnessFlowNode } from "./HarnessNodeView"; import { FIT_VIEW_MAX_ZOOM, GROUP_FALLBACK_HEIGHT, diff --git a/web_ui/src/app/canvas/lazyNodeViews.tsx b/web_ui/src/app/canvas/lazyNodeViews.tsx new file mode 100644 index 00000000..f936dcaf --- /dev/null +++ b/web_ui/src/app/canvas/lazyNodeViews.tsx @@ -0,0 +1,112 @@ +// ADR-019: the node views, code-split. +// +// SceneCanvas.tsx used to import all 17 *NodeView components statically, so +// every one of them landed in the initial chunk whether or not the open +// canvas contained a single node of that kind. That is the whole of the +// remaining gap against ADR-019's 500 KiB (512,000-byte) initial-chunk +// budget: React + React Flow are irreducible, the three dialogs and +// katex/highlight.js were split at ADR-011 stage 11.6, and the node views +// are what was left. Every bundle-ratchet amendment since has said so and +// then raised the ceiling instead, because "the eagerly-rendered node views +// code-split" was work no stage owned. +// +// Each kind is now its own chunk, fetched the first time a node of that kind +// is rendered. A canvas of chat and code nodes never parses the Gitlink, +// Review Lens, harness, chart or web-research views at all. +// +// WHY A SUSPENSE BOUNDARY PER NODE, NOT ONE AROUND THE CANVAS: React Flow +// renders node components itself, deep inside its own tree. A single +// boundary high up would suspend the WHOLE canvas - every node, plus the +// pane, controls and minimap - the first time any one kind loaded, which is +// a far worse flicker than the one it would be preventing. Wrapping each +// node component individually keeps a suspension local to the one card that +// is still loading; everything already resolved stays on screen. +// +// The fallback deliberately renders a card-shaped shell rather than nothing: +// React Flow has already positioned and sized the node by the time its +// component renders, so an empty fallback would collapse the card and let +// edges snap to a zero-size box for a frame or two. + +import { lazy, Suspense, type ComponentType } from "react"; + +/** The shell shown while a kind's chunk is still loading. */ +function NodeChunkFallback() { + return ( +
+
Loading…
+
+ ); +} + +/** + * Wrap a lazily-imported node component in its own Suspense boundary. + * + * React Flow's nodeTypes map wants a plain component, and `lazy()` returns + * one that throws a promise - so the boundary has to live between them, + * here, rather than at any call site. + */ +function withNodeSuspense

(Component: ComponentType

): ComponentType

{ + function LazyNodeView(props: P) { + return ( + }> + + + ); + } + // Keeps React DevTools and any test that queries by displayName readable. + LazyNodeView.displayName = `LazyNodeView(${Component.displayName || "chunk"})`; + return LazyNodeView; +} + +// Named exports, so each import() is unwrapped to a default for lazy(). +export const ArtifactNodeView = withNodeSuspense( + lazy(() => import("./ArtifactNodeView").then((m) => ({ default: m.ArtifactNodeView }))), +); +export const ChartNodeView = withNodeSuspense( + lazy(() => import("./ChartNodeView").then((m) => ({ default: m.ChartNodeView }))), +); +export const ChatNodeView = withNodeSuspense( + lazy(() => import("./ChatNodeView").then((m) => ({ default: m.ChatNodeView }))), +); +export const CodeNodeView = withNodeSuspense( + lazy(() => import("./CodeNodeView").then((m) => ({ default: m.CodeNodeView }))), +); +export const CodeReviewNodeView = withNodeSuspense( + lazy(() => import("./CodeReviewNodeView").then((m) => ({ default: m.CodeReviewNodeView }))), +); +export const CodeSandboxNodeView = withNodeSuspense( + lazy(() => import("./CodeSandboxNodeView").then((m) => ({ default: m.CodeSandboxNodeView }))), +); +export const ConversationNodeView = withNodeSuspense( + lazy(() => import("./ConversationNodeView").then((m) => ({ default: m.ConversationNodeView }))), +); +export const DocumentNodeView = withNodeSuspense( + lazy(() => import("./DocumentNodeView").then((m) => ({ default: m.DocumentNodeView }))), +); +export const GitlinkNodeView = withNodeSuspense( + lazy(() => import("./GitlinkNodeView").then((m) => ({ default: m.GitlinkNodeView }))), +); +export const GroupNodeView = withNodeSuspense( + lazy(() => import("./GroupNodeView").then((m) => ({ default: m.GroupNodeView }))), +); +export const HarnessNodeView = withNodeSuspense( + lazy(() => import("./HarnessNodeView").then((m) => ({ default: m.HarnessNodeView }))), +); +export const HtmlNodeView = withNodeSuspense( + lazy(() => import("./HtmlNodeView").then((m) => ({ default: m.HtmlNodeView }))), +); +export const ImageNodeView = withNodeSuspense( + lazy(() => import("./ImageNodeView").then((m) => ({ default: m.ImageNodeView }))), +); +export const NoteNodeView = withNodeSuspense( + lazy(() => import("./NoteNodeView").then((m) => ({ default: m.NoteNodeView }))), +); +export const PlanNodeView = withNodeSuspense( + lazy(() => import("./PlanNodeView").then((m) => ({ default: m.PlanNodeView }))), +); +export const ThinkingNodeView = withNodeSuspense( + lazy(() => import("./ThinkingNodeView").then((m) => ({ default: m.ThinkingNodeView }))), +); +export const WebResearchNodeView = withNodeSuspense( + lazy(() => import("./WebResearchNodeView").then((m) => ({ default: m.WebResearchNodeView }))), +); diff --git a/web_ui/src/app/canvas/renderCountGate.test.tsx b/web_ui/src/app/canvas/renderCountGate.test.tsx index 3a171bea..b5a8d699 100644 --- a/web_ui/src/app/canvas/renderCountGate.test.tsx +++ b/web_ui/src/app/canvas/renderCountGate.test.tsx @@ -66,7 +66,7 @@ */ import { memo, Profiler, type ReactNode } from "react"; import { ReactFlowProvider } from "@xyflow/react"; -import { act, render } from "@testing-library/react"; +import { act, render, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; const chatNodeRenders = { count: 0 }; @@ -95,6 +95,44 @@ vi.mock("./ChatNodeView", async (importOriginal) => { }; }); +/** + * Wait for the lazily-imported node-view chunks to mount. + * + * ADR-019: SceneCanvas registers each node kind through lazyNodeViews.tsx + * (React.lazy + a per-node Suspense boundary), so the first render of a + * canvas produces the fallback shell, not the node component. Without this + * wait the counter below sees zero renders and every assertion here passes + * or fails for the wrong reason - the "guards the guard" case caught exactly + * that when this gate was first run against the split bundle. + * + * Polled rather than a fixed tick count: resolving one kind takes an + * unspecified number of microtask hops (this file's own vi.mock factory + * awaits importOriginal(), adding one more), and a hard-coded flush that + * happens to be one hop short silently reintroduces the zero-measurement + * bug. + * + * The condition is POSITIVE - every expected stub is on screen - not + * "no fallback shells remain". The negative form is also true BEFORE the + * nodes have rendered at all, so it resolved immediately under load and put + * the zero-measurement bug straight back; it passed when this file ran + * alone and failed in the full suite. + */ +async function flushLazyNodeChunks() { + // 5s, not waitFor's 1s default: what is being waited on is vitest + // transforming and evaluating 17 newly-split chunk entry points, not + // anything the app does at runtime. Under a full parallel suite that + // exceeded 1s and the gate failed with "expected 0 to be 10" - the + // zero-measurement symptom again, from a different cause. It passes in + // ~150ms when this file runs alone. + await waitFor( + () => { + expect(document.body.querySelectorAll('[data-testid="chat-node-stub"]').length) + .toBe(NODE_COUNT); + }, + { timeout: 5_000 }, + ); +} + import { SceneCanvas } from "./SceneCanvas"; import { SceneStore, initialSceneState } from "./sceneStore"; import type { WsTransport } from "../../lib/ws/transport"; @@ -249,7 +287,7 @@ function mountWithCommitCounter(children: ReactNode) { } describe("re-renders per single-node update (ADR-019 stage 19.2)", () => { - it("finds real renders at all (guards the guard)", () => { + it("finds real renders at all (guards the guard)", async () => { const { store, stateListeners } = makeWiredStore(); mountWithCommitCounter( @@ -259,12 +297,14 @@ describe("re-renders per single-node update (ADR-019 stage 19.2)", () => { act(() => { stateListeners.get("scene")!(makeScene() as unknown as Record); }); - // The stub actually rendered once per node on mount - a broken mock or a - // React Flow harness change would make the gate below pass vacuously. + await flushLazyNodeChunks(); + // The stub actually rendered once per node on mount - a broken mock, a + // React Flow harness change, or an unresolved lazy chunk would make the + // gate below pass vacuously. expect(chatNodeRenders.count).toBeGreaterThanOrEqual(NODE_COUNT); }); - it("a single-node update stays within the pinned commit and render baselines", () => { + it("a single-node update stays within the pinned commit and render baselines", async () => { const { store, stateListeners } = makeWiredStore(); const commits = mountWithCommitCounter( @@ -274,6 +314,10 @@ describe("re-renders per single-node update (ADR-019 stage 19.2)", () => { act(() => { stateListeners.get("scene")!(makeScene() as unknown as Record); }); + // Resolve the lazy chunks BEFORE zeroing the counters, so the update + // below is measured against fully-mounted node views - the same steady + // state a user is in by the time they drag or edit anything. + await flushLazyNodeChunks(); chatNodeRenders.count = 0; commits.count = 0; @@ -310,7 +354,7 @@ describe("re-renders per single-node update (ADR-019 stage 19.2)", () => { // isolated pieces: chatNodePropsAreEqual's own pure-function unit tests, // and a real-DOM render check keyed off `selected`, never `content`, // never through the store - see ChatNodeView.test.tsx). - it("a single-node CONTENT edit (the ADR's own literal exit criterion) re-renders exactly 1 node view end-to-end through the real store pipeline", () => { + it("a single-node CONTENT edit (the ADR's own literal exit criterion) re-renders exactly 1 node view end-to-end through the real store pipeline", async () => { const { store, stateListeners } = makeWiredStore(); mountWithCommitCounter( @@ -320,6 +364,7 @@ describe("re-renders per single-node update (ADR-019 stage 19.2)", () => { act(() => { stateListeners.get("scene")!(makeScene() as unknown as Record); }); + await flushLazyNodeChunks(); chatNodeRenders.count = 0; act(() => {