Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions web_ui/scripts/check-bundle-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
44 changes: 27 additions & 17 deletions web_ui/src/app/canvas/SceneCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
112 changes: 112 additions & 0 deletions web_ui/src/app/canvas/lazyNodeViews.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="scene-node scene-node-loading" aria-busy="true">
<div className="scene-node-title">Loading…</div>
</div>
);
}

/**
* 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<P extends object>(Component: ComponentType<P>): ComponentType<P> {
function LazyNodeView(props: P) {
return (
<Suspense fallback={<NodeChunkFallback />}>
<Component {...props} />
</Suspense>
);
}
// 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 }))),
);
57 changes: 51 additions & 6 deletions web_ui/src/app/canvas/renderCountGate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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(
<ReactFlowProvider>
Expand All @@ -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<string, unknown>);
});
// 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(
<ReactFlowProvider>
Expand All @@ -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<string, unknown>);
});
// 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;
Expand Down Expand Up @@ -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(
<ReactFlowProvider>
Expand All @@ -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<string, unknown>);
});
await flushLazyNodeChunks();

chatNodeRenders.count = 0;
act(() => {
Expand Down
Loading