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 ( +
(Component: ComponentType
): ComponentType
{
+ function LazyNodeView(props: P) {
+ return (
+