Skip to content

feat(sandbox): DiffViewer performance page for large change sets - #7554

Open
cixzhang wants to merge 14 commits into
feat/lab-diff-create-filefrom
feat/lab-diff-viewer-perf
Open

cixzhang wants to merge 14 commits into
feat/lab-diff-create-filefrom
feat/lab-diff-viewer-perf

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

The Lab DiffViewer has no way to measure how it behaves on the change sets people actually review: many files and a lot of code. Anyone tuning it (virtualization, lazy file bodies, highlighting) needs one runnable page that reproduces those workloads and reports the same numbers every time.

Intended invariant

Sandbox-only. No package API, component behavior or visual change. Modeled on the existing CodeBlock and Markdown performance pages.

Change

  • apps/sandbox/.../pages/diffviewer-perf: renders a whole change set, one DiffViewer per file, inside a fixed-height scrollable surface.
  • Deterministic workloads (benchmark.ts, seeded, byte-identical every run):
    • many files: 50 / 200 / 500 / 1,000 files at about 10k / 50k / 100k / 150k lines. Mostly small files, some medium ones and a few very large ones, with sparse edits plus a few added, deleted and fully rewritten files;
    • one file: 100 / 1,000 / 5,000 / 20,000 lines, sparse edits or a full rewrite.
  • Controls: unified or split, syntax highlighting on or off.
  • Metrics:
    • render: diff model (createDiffFile) time, first file on screen, full render to the next painted frame, long tasks (count and total), DOM nodes, heap growth (where the browser reports it);
    • scroll: a top-to-bottom test over the whole list (fps, dropped frames, long tasks);
    • interactions: timed collapse all, expand all, one file, view switch and syntax switch.

Stacked on #7565 (createDiffFile); #7568 (the parser) and #7536 (the viewer) are on main. Merge order: #7565, then this.

Evidence

Production Sandbox build of this branch (pnpm build, static export verified), headless Chrome 155, 200 files (50,827 file lines, 16,465 diff rows), unified, highlighted:

Metric Value
Diff model (createDiffFile) 93 ms
First file on screen / full render 4.9 s / 5.7 s
Long tasks 3, 5.8 s total
DOM nodes / heap growth 203,986 / 163 MB
Scroll test (fps / dropped / long tasks) 47 / 49 / 1
Collapse all / expand all / switch to split 0.7 s / 4.0 s / 6.8 s

Every control and button was driven in the browser; no console errors. In a bare page with no app shell, the same Lab build renders 50 / 200 / 500 / 1,000 files in 1.3 / 3.1 / 5.2 / 8.3 s, each as a single long task. Profiling the 200-file case puts most of that time in per-file scroll-area measurement (forced style and layout) inside the one commit. The diff model takes about 80 ms and tokenizing about 70 ms. That is the baseline this page exists to move.

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

  • vitest run apps/sandbox/.../diffviewer-perf: 7 passed (deterministic workloads, size targets within 5%, small/large mix, added/deleted/rewritten files)
  • ESLint and Prettier clean
  • Sandbox production build + static-export verification (adds the route to scripts/next-export-routes.json)
  • Real Chromium against that build (see Evidence)

@vercel

vercel Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 15, 2026 7:14pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 15, 2026
@astracat-bot

astracat-bot Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🔄 In progress (for maintainers only) Oct 15, 2026, 7:15 PM UTC

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Previews ready: Storybook and Sandbox were built with this PR's docsite. CI evidence will be added when available.

View Storybook for this PR · View Sandbox for this PR

@cixzhang
cixzhang changed the base branch from feat/lab-diff-viewer to feat/lab-diff-create-file October 15, 2026 07:38
@cixzhang
cixzhang force-pushed the feat/lab-diff-viewer-perf branch from abb33e6 to bdf03d7 Compare October 15, 2026 07:38

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: the page is well scoped (Sandbox-only, deterministic workloads, the route exports cleanly and the tests pass), but three readings can mislead the tuning work it exists for. Please address these before merging:

  • “First file on screen” is read in a frame callback before the paint, while “Full render” is read after it, so with the current DiffViewer both describe the same paint; take the first-file reading after the paint too, and record a late detection instead of falling back to the render time.
  • The Run metrics badges follow the live selection, so picking another workload after a run relabels its numbers; store the label, line total, view and syntax with the run, and keep Run benchmark and the other controls disabled during the scroll test.
  • Unavailable readings show as zero: long tasks read “0 / 0.0 ms” in Firefox, which has no Long Tasks API, and heap growth reads “0.0 MB” where the heap reading is coarse (headless Chromium); show “—” in both cases.

[Automated review]

observer = new PerformanceObserver(list => {
entries.push(...list.getEntries());
});
observer.observe({type: 'longtask', buffered: false});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observing an unsupported entry type does not throw (Firefox only logs “Ignoring unsupported entryTypes: longtask.”), so the catch never runs and the card reports “0 / 0.0 ms” for a multi-second synchronous render. Check PerformanceObserver.supportedEntryTypes?.includes('longtask') and return null so the card shows “—”.

}
requestAnimationFrame(watchFirstFile);
};
requestAnimationFrame(watchFirstFile);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This frame callback reads the first file before that frame’s style, layout and paint, while Full render (L236) reads after the same paint; because the whole list commits in one flushSync, both describe one paint. Take this reading at the after-paint point too (a setTimeout(0) from the callback that finds the table), and update run.firstFileMs when detection lands late instead of falling back to renderMs (L241).

heapBytes:
heapBefore == null || heapAfter == null
? null
: heapAfter - heapBefore,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where the heap reading is coarse (headless Chromium gave identical readings before and after a ~200 MB allocation), this subtraction yields 0 and the card shows “0.0 MB”; treat an unchanged reading as unavailable and say so in the caption.

const changeSyntax = (next: string) =>
timeInteraction(`Syntax ${next}`, () => setSyntax(next as Syntax));

const runScrollTest = () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scroll test never sets isBusy, so Run benchmark, the selectors and Collapse all / Expand all stay enabled for its four seconds, and a run started meanwhile receives this loop’s stale result from setScroll (L321). Set isBusy for the duration of the test.

<VStack gap={3}>
<Heading level={3}>Run metrics</Heading>
<HStack gap={2} wrap="wrap">
<Badge label={workload.label} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These badges follow the live selection while the metrics below belong to the last run: choosing “1,000” after a 200-file run labels that run “1,000 files / 152,827 file lines”. Store the label, line total, view and syntax with the run (or reset run when the selection changes).

Renders a whole change set, one Lab DiffViewer per file, from deterministic
workloads (50/200/500/1,000 files at 10k-150k lines, mostly small files plus
a few very large ones; single files of 100-20k lines, sparse or rewritten)
and measures the diff model, first file on screen, full render, long tasks,
DOM size, heap growth, scroll frames, and collapse/expand/view switches.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: this revision only restacks the branch, so the three measurement problems raised on the previous revision still apply; otherwise the page is well scoped, and its route and tests are in good shape.

  • Take "First file on screen" after the paint, as "Full render" does, and keep a late detection instead of falling back to the render time.
  • Store the workload label, line total, view and syntax with each run, and keep the controls disabled while the scroll test runs.
  • Show "—" instead of zero when the browser has no Long Tasks API (Firefox) or the heap reading does not change (for example, headless Chromium).

[Automated review]

…ch run's labels, and shows no reading as a dash

- First file on screen is now read after the paint that shows it, like full
  render, and the run waits for it rather than falling back to the render
  time when it lands late.
- Each run stores its workload label, line total, view and syntax, so
  changing a control afterwards no longer relabels its numbers; every control
  stays disabled while the scroll test runs.
- Long tasks show a dash where the browser has no Long Tasks API, and heap
  growth shows a dash when the heap reading does not move.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: this revision fixes all three measurement problems raised earlier (first file read after the paint, per-run labels with the controls locked during the scroll test, and dashes for unavailable readings), and the route, build and tests are in good shape. One labeling issue remains: the badges keep the run's view and syntax, but switching either re-renders the list without a new run, so a later scroll test or collapse/expand timing appears under the old labels (a split scroll result shows as "unified"). Store the view and syntax with each scroll reading and timed interaction, or clear the scroll reading on a switch, so every number names what it measured.

[Automated review]

return;
}
const elapsed = now - start;
setScroll({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reading measures whatever view and syntax are on screen, but the card labels it with the run's (shown, L393–398). After a unified run, switching to split and running this test showed 17 / 53 / 28 (unified: 40 / 17 / 1) under the "unified" badge, and Collapse all / Expand all timings after the switch have the same problem. Capture viewMode and syntax when the test (or a timed interaction) starts and show them with the reading, or clear scroll when the view or syntax changes.

… reading with the view it measured

A view or syntax switch re-renders the list without a new run, so a later
scroll test or collapse/expand timing appeared under the run's view. Each
scroll reading and timed interaction now stores the view and syntax it was
taken in and shows them in its label (a switch is labelled with the view it
switched to).

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: this revision fixes the earlier labeling issue (every scroll and interaction reading now names the view and syntax it measured), and the route, build and tests are in good shape. One small labeling problem is left: the "diff rows rendered" badge sums diff lines, but split view puts a removed line and its added line in one row, so a split run of the 5,000-line full rewrite shows 10,000 rows while 5,002 are rendered. Label the badge with what it counts (for example "diff lines") or count rows for the run's view.

[Automated review]

label={`${shown.totalLines.toLocaleString()} file lines`}
/>
<Badge
label={`${diffLines.toLocaleString()} diff rows rendered`}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sums hunk lines (renderedLineCount), but DiffViewer adds a header row per hunk and, in split view, puts a removed line and its added line in one row: a split run of the one-file 5,000-line full rewrite shows "10,000 diff rows rendered" while 5,002 rows are rendered. Label it with what it counts, or count rows for the run's view.

<Badge label={`${diffLines.toLocaleString()} diff lines`} />

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: this revision only merges the updated base, so the one labeling problem raised on the previous revision is still open; the route, static export, tests and lint are otherwise in good shape. The "diff rows rendered" badge sums diff lines, but split view puts a removed line and its added line in one row, so a split run of the 5,000-line full rewrite still shows "10,000 diff rows rendered" while its table renders 5,000 line rows. Label the badge with what it counts (for example "diff lines") or count rows for the run's view.

[Automated review]

The badge summed hunk lines but called them rendered rows; split view puts a
removed line and its added line in one row and adds hunk header rows, so a
split full rewrite of 5,000 lines read "10,000 diff rows rendered". It now
says "diff lines", which is what it counts.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommending approval: the badge now reads "diff lines", which is what it counts, and the route, static export, workload tests and lint are in good shape on this revision. Optionally, refresh the description's first-file / full-render row (4.9 s / 5.7 s), which predates reading first file after the paint; both readings now come from one paint. This can merge once #7568, #7536 and #7565 have landed.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommending approval: this revision only merges the updated base, and the page, its route and static export, the workload tests and lint all check out on it. Optionally, refresh the description's evidence table, which still shows the earlier first-file / full-render split (4.9 s / 5.7 s) and "diff rows" where the page now reports "diff lines". This can merge once #7536 and #7565 have landed.

[Automated review]

The page had a route but no entry in the sandbox page registry, so it did
not appear on the tools page. Adds it next to CodeBlock Perf and Markdown
Perf.

This branch was successfully deployed

1 active deployment
Preview — 08e4f4bc Deployed Oct 15, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant