Skip to content

perf(latex): stop scroll re-layout and sibling :has() restyles in Visual LaTeX - #810

Merged
yaacovcorcos merged 9 commits into
mainfrom
claude/scroll-perf-20261109
Nov 9, 2026
Merged

yaacovcorcos merged 9 commits into
mainfrom
claude/scroll-perf-20261109

Conversation

@yaacovcorcos

Copy link
Copy Markdown
Contributor

Platforms: measured and tested in headless Chromium on macOS (Apple Silicon). Electron 44 uses the same engine on Windows and Linux, but neither was run.

What Changed

Follow-up to #801 and #806. Scrolling a Visual LaTeX document no longer re-lays out the paged document, and showing the LaTeX tab costs about half as much. All changes are in Scient files; no T3 file changes.

Scrolling (latexPagedPresentation.ts):

  • Reveals restarted layout passes that changed nothing. A content-visibility reveal now schedules a pass only when the block's size actually changed (reported by the shared observeResize). Unknown blocks keep the old path.
  • Passes never settled. Spacers recomputed from getBoundingClientRect drifted by up to 0.55 px from the published values, so about 400 of 768 decorations were replaced on every pass. A gap or offset within 1 px of the published one now keeps it.
  • Every pass rewrote every block's intrinsic size, because a string comparison could never match. Sizes are now compared as numbers.
  • Bug fix, also on main: a pass read block positions across frames, so a reveal between slices moved the blocks read after it. At 4× pages of text jumped by about 380 px. Positions are now read in one layout.

Showing the tab (new latexBlockNeighbours.ts):

  • Five sibling :has(+ …) selectors made Chromium restyle every top-level block on each block the loader inserted.
  • A ProseMirror plugin now marks a paragraph before a display (data-latex-before-display, "short" when the paged layout set the display short) and a run-in heading before its paragraph (data-latex-run-in). The CSS uses those attributes.
  • After an edit, it rebuilds only the blocks around the change: 0.2–0.3 ms per key on a 783-block document.
  • Guard: universalSiblingSelectors.test.ts now also rejects sibling :has() under src/scient.

One visible difference: a paragraph holding a footnote, followed by a short display, is now raised above the display like every other such line. Before, the note's band between them in the DOM hid the match, and the formula took clicks on that line's lower half. Computed styles of every paragraph, heading and display were otherwise identical across 11 editor states.

Why

61-page Visual LaTeX document, production build:

main (with #806) this PR
Scrolling at 4× CPU throttle, long frames per ~15 s 12–13 s about 1 s
Typing while scrolling, unthrottled, p50/p90 per key 64/80 ms 16/16 ms
Showing the LaTeX tab at 4×, long frames (median) 6.1 s 2.9 s
:has() invalidation records per tab show 44,229 1,735

PDF scrolling needed nothing more: #806 already removed its cost.

What remains of tab show is mostly the real style and layout of the content. Keeping the editor mounted would remove it, but it needs T3 panel changes and 0.3–0.4 GB per thesis-sized document, so it is not done here.

Verification

  • vp run check:changed origin/main passes: format, lint, knip, typecheck, 7 affected test files and the React Compiler check.
  • All LaTeX browser suites pass: 53 files, 560 tests (run before the last review fix; the affected suites ran again after it).
  • New latexBlockNeighbours.test.ts checks the plugin against a fresh one after an explicit sequence and 400 seeded random edits, undo/redo and layout publications. Deliberate breaks to the plugin make it fail, including the attribute-step case below.
  • New latexPagedPresentation.browser.test.tsx cases cover the reveal gate and the single-layout read.
  • Codex review (gpt-6.1-sol, high): approve with fixes. Fixed in the last commit:
    • a heading level change made through setNodeAttribute maps no positions, so the plugin missed it;
    • the guard missed a sibling selector later in a :has() list.
  • Not run locally: the full test suites, the full build and the desktop smoke test. CI covers the suites and build.

Documentation Impact

Documentation impact: Updated — docs/internals/scient-latex-visual-performance.md (scroll causes and the remaining tab-show cost).

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • Visible change described above (footnote line above a short display)

Implementation: Claude Code (Claude Opus 5.5). Review: Codex (gpt-6.1-sol).

🤖 Generated with Claude Code

yaacovcorcos and others added 9 commits November 9, 2026 16:28
Scrolling Visual LaTeX kept the main thread ~85% busy at 4x CPU. Every
content-visibility reveal started a paged-layout pass, and every pass
republished hundreds of decorations and rewrote every block's intrinsic
size, so the reading anchor's next read forced a full style and layout.

- A reveal starts a pass only when the block's rendered size differs from
  its skipped size. Chromium reports that resize before the reveal event.
- A recomputed spacer or flow offset within 1 px of the published one keeps
  the published value. Read-back positions drift by fractions of a pixel,
  so passes never settled and replaced ~400 decorations each time.
- Intrinsic sizes are written in layout units and compared numerically. The
  string comparison never matched the six-digit serialization, so each pass
  wrote ~1,000 inline styles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pass that turns the page plan into spacers read each block's position
in work slices across frames. A block revealed between slices moved the
blocks read after it, so the pass published page gaps and flow offsets
hundreds of pixels off. The next reveal flipped them back, moving visible
text and forcing another full layout. Reading positions is cheap once
layout is clean, so the reading loop no longer yields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…() fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five sibling :has(+ …) rules made Chromium restyle every top-level paragraph,
heading and display on each block insertion. A ProseMirror plugin now marks a
paragraph before a display (and whether the paged layout set that display
short) and a run-in heading before its paragraph. The marks map through edits
and are rebuilt only around the change.

Showing the 61-page manuscript's tab at 4x: long frames 5.7-6.3 s -> 2.7-3.9 s.
A paragraph holding a footnote is now raised above a short display like any
other line; the note band between them in the DOM used to hide the display.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A heading level change through setNodeAttribute maps no positions, so the
plugin missed it and an h3 turned h4 kept no run-in mark. Attribute steps now
widen the changed range. The sibling :has() guard also catches a relative
selector later in a :has() list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yaacovcorcos
yaacovcorcos added this pull request to the merge queue Nov 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful main measurement shown below.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 5.5 KiB 5.5 KiB 0 B (0.0%) 6.8 KiB ✅
Codex Thread snapshot wire 4.2 KiB 4.2 KiB 0 B (0.0%) 4.9 KiB ✅
Codex Live turn WebSocket wire 1.3 KiB 1.3 KiB 0 B (0.0%) 2.0 KiB ✅
Codex Live turn WebSocket decoded 22.3 KiB 22.3 KiB 0 B (0.0%) 24.4 KiB ✅
Codex Live turn messages 1 1 0 (0.0%) 8 ✅
Claude Total thread wire 5.5 KiB 5.5 KiB 0 B (0.0%) 6.8 KiB ✅
Claude Thread snapshot wire 4.2 KiB 4.2 KiB 0 B (0.0%) 4.9 KiB ✅
Claude Live turn WebSocket wire 1.3 KiB 1.3 KiB 0 B (0.0%) 2.0 KiB ✅
Claude Live turn WebSocket decoded 22.7 KiB 22.7 KiB 0 B (0.0%) 24.4 KiB ✅
Claude Live turn messages 1 1 0 (0.0%) 8 ✅

Baseline: bfa08d9 · PR result: 829ad36 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Bounded compact HTTP snapshot with live cursor.

  • Codex decoded thread snapshot: 88.2 KiB
  • Claude decoded thread snapshot: 88.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 9, 2026
@yaacovcorcos
yaacovcorcos added this pull request to the merge queue Nov 9, 2026
Merged via the queue into main with commit 49670c6 Nov 9, 2026
48 checks passed
@yaacovcorcos
yaacovcorcos deleted the claude/scroll-perf-20261109 branch November 9, 2026 22:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant