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
44 changes: 43 additions & 1 deletion web_ui/scripts/check-bundle-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,49 @@ const ASSETS_DIR = join(HERE, "..", "dist", "app", "assets");
// 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;
// Deliberate, commented amendment - 2026-09-04 (ADR-019 section 4).
//
// The second time this number has moved DOWN, and the first time the ADR-019
// budget has been in sight rather than a footnote.
//
// A sourcemap attribution of the initial chunk - every output byte charged
// back to the module it came from - found that two things reachable ONLY
// through a click were sitting in it:
//
// * The markdown machinery behind the Document View. DocumentViewPanel was
// statically imported by App.tsx, and it pulled DocumentViewMarkdown
// (react-markdown plus six remark/rehype plugins and
// react-medium-image-zoom) and documentViewHeadings (its own
// unified/remark-parse pass for the table of contents). The 11 node views
// that also use markdown were already lazy; this one eager importer was
// holding the whole unified/micromark/mdast stack in the initial chunk
// for every session, including the many that never open the panel.
// * html-to-image, reached from AppBar's Export PNG button and the command
// palette's export command.
//
// Both now load on demand. The Document View's SHELL deliberately stays
// eager: it is an <aside> that animates `width 220ms ease` from 0, and a CSS
// transition does not fire on a freshly mounted element, so lazy-mounting the
// shell would have cost the first open its slide-in. Only its contents moved.
//
// Measured: largest chunk 781,060 -> 595,517 bytes (-185,543, -23.8%). Total
// JS 1,456,011 -> 1,458,443 (+2,432, +0.2%) - the usual per-chunk overhead of
// splitting, and the budget ADR-019 sets is on the initial chunk anyway.
//
// Both ceilings re-anchored to ~3% over measured reality, the posture every
// amendment here has used. Stated plainly, as every raise was: the ADR-019
// budget for the initial chunk is 500 KiB (512,000 bytes), and at 595,517 the
// initial chunk is ~16% over it - down from ~52%, and before this pass ~74%.
//
// What is left is no longer a mystery, and is recorded here so the next
// person does not have to re-measure it. Of the 595,517 bytes: ~328,600 is
// dependencies (React and React-DOM ~140,000; React Flow and its d3
// dependencies ~175,000 - both load-bearing for first paint and neither
// splittable), and ~262,300 is this app's own source, concentrated in
// lib/bridge-core (~84,000), app/canvas (~82,000) and app/chrome (~78,000).
// Closing the last ~83,500 bytes means splitting app source, not removing a
// dependency - which is a real option now that it is the only one left.
const LARGEST_CHUNK_CEILING_BYTES = 613_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 Down
38 changes: 30 additions & 8 deletions web_ui/src/app/canvas/DocumentViewPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@ function renderPanel(overrides: Partial<React.ComponentProps<typeof DocumentView
}

describe("DocumentViewPanel", () => {
it("renders the fixed title and the passed markdown content", () => {
it("renders the fixed title and the passed markdown content", async () => {
renderPanel({ content: "# Heading\n\nA paragraph of body text." });

// The title is part of the eager shell; the body arrives with the lazy
// markdown chunk, hence findBy rather than getBy.
expect(screen.getByText("Document View")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Heading" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Heading" })).toBeInTheDocument();
expect(screen.getByText("A paragraph of body text.")).toBeInTheDocument();
});

Expand Down Expand Up @@ -71,14 +73,32 @@ describe("DocumentViewPanel", () => {
expect(screen.getByLabelText("Document View")).toBeInTheDocument();
});

it("collapses to zero width and is aria-hidden when closed, without unmounting", () => {
it("collapses to zero width and is aria-hidden when closed", () => {
const { container } = renderPanel({ isOpen: false, content: "still here" });

const panel = container.querySelector(".document-view-panel") as HTMLElement;
expect(panel).toHaveAttribute("aria-hidden", "true");
expect(panel.style.width).toBe("0px");
// Content stays mounted (no unmount/remount flicker on next open) -
// just clipped by the closed panel's own overflow:hidden.
});

it("does not load its markdown chunk for a panel that has never been opened", () => {
// The deferral this panel's lazy split exists for: the shell mounts with
// the app, the ~130 KB of markdown machinery behind it does not, until
// somebody actually opens the Document View.
renderPanel({ isOpen: false, content: "still here" });
expect(screen.queryByText("still here")).toBeNull();
});

it("keeps its content mounted after being closed again, so reopening does not flicker", async () => {
const { rerender } = renderPanel({ isOpen: true, content: "still here" });
expect(await screen.findByText("still here")).toBeInTheDocument();

rerender(
<DocumentViewPanel isOpen={false} content="still here" sourceLabel={null} onClose={vi.fn()} />,
);

// Closed, but never unmounted - the property the eager version had, kept
// by latching "has been opened" rather than tracking `isOpen`.
expect(screen.getByText("still here")).toBeInTheDocument();
});

Expand Down Expand Up @@ -171,9 +191,11 @@ describe("DocumentViewPanel", () => {
// shape, the reading-progress bar's scroll-driven width, and the
// reset-on-new-content behavior.
describe("table of contents + reading progress (stage 2)", () => {
it("shows the Outline toggle when the content has 2+ headings", () => {
it("shows the Outline toggle when the content has 2+ headings", async () => {
renderPanel({ content: "# One\n\n## Two" });
expect(screen.getByRole("button", { name: "Outline" })).toBeInTheDocument();
// Headings come from a dynamically imported parser, so they land a
// microtask after mount rather than during it.
expect(await screen.findByRole("button", { name: "Outline" })).toBeInTheDocument();
});

it("shows no Outline toggle when the content has fewer than 2 headings", () => {
Expand Down Expand Up @@ -437,7 +459,7 @@ describe("DocumentViewPanel", () => {
const onClose = vi.fn();
renderPanel({ onClose, content: "# One\n\n## Two" });

await user.click(screen.getByRole("button", { name: "Outline" }));
await user.click(await screen.findByRole("button", { name: "Outline" }));
expect(screen.getByRole("menu", { name: "Table of contents" })).toBeInTheDocument();

await user.keyboard("{Escape}");
Expand Down
61 changes: 56 additions & 5 deletions web_ui/src/app/canvas/DocumentViewPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DocumentViewMarkdown } from "./DocumentViewMarkdown";
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
import { DocumentViewToc } from "./DocumentViewToc";
import { DocumentViewSearch } from "./DocumentViewSearch";
import { extractHeadings } from "./documentViewHeadings";
import type { DocumentHeading } from "./documentViewHeadings";

// Everything that parses or renders markdown loads on first open, not at
// module scope. Between them, DocumentViewMarkdown (react-markdown plus six
// remark/rehype plugins and react-medium-image-zoom) and
// documentViewHeadings (its own unified/remark-parse pass) were ~130 KB of
// the initial chunk - reachable only through this panel, which is behind a
// click and which many sessions never open at all.
//
// The panel SHELL deliberately stays eager. It is an <aside> that animates
// `width 220ms ease` from 0, and a CSS transition does not fire on a freshly
// mounted element: lazy-mounting the shell would cost the first open its
// slide-in. Splitting the contents instead keeps the animation exactly as it
// was and still leaves the weight behind the click.
const DocumentViewMarkdown = lazy(() =>
import("./DocumentViewMarkdown").then((m) => ({ default: m.DocumentViewMarkdown })),
);

const SEARCH_MATCH_SELECTOR = ".document-view-search-match";
const SEARCH_MATCH_CURRENT_CLASS = "document-view-search-match-current";
Expand Down Expand Up @@ -261,11 +276,40 @@ export function DocumentViewPanel({
});
}, [content]);

// Latches true the first time the panel opens and never resets, so the two
// dynamic imports below fire on that first open rather than on App's own
// mount. React.lazy inside an always-rendered subtree would otherwise
// request its chunk moments after page load - splitting the bundle without
// deferring the fetch, which is the whole point. (Same reasoning, and the
// same latch, as App.tsx's LazySurface.) Closing the panel does not unmount
// anything, so scroll position, search text and width survive exactly as
// they did when this was all eager.
const [hasOpened, setHasOpened] = useState(isOpen);
if (isOpen && !hasOpened) {
setHasOpened(true);
}

// Stage 2: table of contents + reading progress. Extracted from the raw
// markdown source (not queried from the rendered DOM) - see
// documentViewHeadings.ts's own doc comment for why this is both simpler
// and available before the very first paint.
const headings = useMemo(() => extractHeadings(content ?? ""), [content]);
//
// Now resolved through a dynamic import, so this is state fed by an effect
// rather than a useMemo. The observable difference is one microtask: the
// table of contents is empty for a tick after content changes, then fills
// in. DocumentViewToc already renders nothing below two headings, so that
// tick looks the same as a document that simply has no outline.
const [headings, setHeadings] = useState<DocumentHeading[]>([]);
useEffect(() => {
if (!hasOpened) return;
let cancelled = false;
void import("./documentViewHeadings").then(({ extractHeadings }) => {
if (!cancelled) setHeadings(extractHeadings(content ?? ""));
});
return () => {
cancelled = true;
};
}, [content, hasOpened]);
const scrollRef = useRef<HTMLDivElement>(null);
const [readingProgress, setReadingProgress] = useState(0);

Expand Down Expand Up @@ -529,7 +573,14 @@ export function DocumentViewPanel({
onScroll={onScroll}
style={{ fontSize: fontSizeMultiplier === 1 ? undefined : `${fontSizeMultiplier}em` }}
>
<DocumentViewMarkdown content={content ?? ""} searchQuery={searchQuery} />
{/* fallback={null} rather than a spinner: the panel is mid
slide-in on the one render where this can be pending, and an
empty body for that moment is what the animation already shows. */}
{hasOpened && (
<Suspense fallback={null}>
<DocumentViewMarkdown content={content ?? ""} searchQuery={searchQuery} />
</Suspense>
)}
</div>
</div>
{/* This is the ARIA APG "window splitter" pattern: a focusable,
Expand Down
12 changes: 9 additions & 3 deletions web_ui/src/app/chrome/AppBar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useReactFlow, useStore } from "@xyflow/react";
import { useSyncExternalStore } from "react";
import { FIT_VIEW_MAX_ZOOM } from "../canvas/canvasConstants";
import { exportCanvasAsPng } from "../canvas/exportCanvasPng";
import { motionDuration } from "../reducedMotion";
import type { SceneStore } from "../canvas/sceneStore";
import { Popover, useOverlays } from "../overlays/overlays";
Expand Down Expand Up @@ -294,12 +293,19 @@ export function AppBar({ store }: { store: SceneStore }) {
const viewport = getViewport();
setViewport({ ...viewport, zoom: 1 }, { duration: motionDuration(200) });
};
const exportPng = () =>
void exportCanvasAsPng(
// Imported on click, not at module scope: exportCanvasAsPng pulls in
// html-to-image (13 KB of the initial chunk) to rasterize the canvas, and
// a session that never exports a PNG never needs it. The await lands
// inside the handler, so the "export in progress" flag the callback below
// sets still brackets the real work exactly as before.
const exportPng = async () => {
const { exportCanvasAsPng } = await import("../canvas/exportCanvasPng");
await exportCanvasAsPng(
{ getNodes, getViewport, setViewport },
"--gl-surface-window",
(value) => store.setExportInProgress(value),
);
};

// Overlay-opening actions (Pins/View/Plugins/About/Help) close the open
// menu for free via OverlayProvider's own single-open policy - opening any
Expand Down
8 changes: 6 additions & 2 deletions web_ui/src/app/chrome/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ describe("buildCommands", () => {
expect(exportWithNodes.enabled()).toBe(true);
});

it("export-canvas-png's run() actually invokes exportCanvasAsPng with the instance and background token", () => {
it("export-canvas-png's run() actually invokes exportCanvasAsPng with the instance and background token", async () => {
// Audit finding: this used to assert `expect(() => run()).not.toThrow()`,
// which is vacuous - run() `void`s a call to an async function, and an
// async function never throws synchronously, so it held for EVERY
Expand All @@ -268,7 +268,11 @@ describe("buildCommands", () => {

commands.find((c) => c.id === "export-canvas-png")!.run();

expect(exportCanvasAsPngMock).toHaveBeenCalledOnce();
// run() now reaches exportCanvasAsPng through a dynamic import, so the
// call lands a microtask later. waitFor keeps the assertion real - it
// still fails if the command is wired to nothing, which is the whole
// point of the note above.
await vi.waitFor(() => expect(exportCanvasAsPngMock).toHaveBeenCalledOnce());
// ADR-011 stage 11.2: a 3rd arg now threads store.setExportInProgress
// through so exportCanvasAsPng can suspend onlyRenderVisibleElements for
// the capture's duration - see that module's own doc. Asserted as
Expand Down
7 changes: 5 additions & 2 deletions web_ui/src/app/chrome/commands.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { ReactFlowInstance } from "@xyflow/react";
import { applyCompareBranches, applySynthesizeBranches } from "../canvas/branchActions";
import { FIT_VIEW_MAX_ZOOM } from "../canvas/canvasConstants";
import { exportCanvasAsPng } from "../canvas/exportCanvasPng";
import type { SceneStore } from "../canvas/sceneStore";
import type { OverlayContextValue } from "../overlays/overlays";
import { motionDuration } from "../reducedMotion";
Expand Down Expand Up @@ -100,7 +99,11 @@ export function buildCommands(
id: "export-canvas-png",
name: "Export Canvas as PNG",
aliases: ["export png", "download image", "save canvas image"],
run: () => void exportCanvasAsPng(rf, "--gl-surface-window", (value) => store.setExportInProgress(value)),
// Imported on run, not at module scope - see AppBar.tsx's exportPng.
run: () =>
void import("../canvas/exportCanvasPng").then(({ exportCanvasAsPng }) =>
exportCanvasAsPng(rf, "--gl-surface-window", (value) => store.setExportInProgress(value)),
),
enabled: hasNodes,
},
{
Expand Down
Loading