Skip to content

Restyle fewer elements for :has() and cache the box model and marker style - #153

Merged
brainkim merged 99 commits into
mainfrom
perf-pass
Oct 15, 2026
Merged

brainkim merged 99 commits into
mainfrom
perf-pass

Conversation

@brainkim

@brainkim brainkim commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Makes TermDOM faster and keeps it responsive when the machine is busy, makes its caches provably follow every change, lines atomic inlines up on the baseline, cleans up the internals, and fixes the bugs found along the way.

Performance

  • Repaint only the rows that changed. A frame now starts from the previous frame and repaints only the rows that changed. Layout reports which boxes moved, and a style change or selection move reports its own rows. Anything that doesn't report rows still repaints the whole screen. Typing in CodeMirror repaints about 3 rows per frame instead of 40. The tests now check every partial frame against a full repaint, so a missed change fails a test.
  • Root laid out as a block. The root of the layout tree was a flex column. That made <html> a flex item, so every layout ran an extra min-content pass over the whole page.
  • No timer sleep before a frame. The render loop used setTimeout(0), which puts the process to sleep and adds delay when the machine is busy. It now uses setImmediate.
  • Skip work for parts of the page that didn't change:
    • Grid rounding and paint extents are skipped for subtrees layout didn't touch.
    • A partial frame joins borders and compares cells only in the rows it changed.
    • The painter binary-searches the children of any box whose children are in row order, not only block containers.
  • Cheaper geometry reads, which editors make on every frame:
    • getBoundingClientRect no longer re-clamps every scroll offset, or searches for an enclosing <math>, when nothing calls for it.
    • It builds one DOMRect instead of three.
    • It checks for fixed positioning from the layout tree instead of every ancestor's computed style.
    • getComputedStyle keeps one view per element, so a restyle no longer redefines some 400 index properties.
  • Less restyling:
    • A :has() change restyles earlier siblings only when a sheet uses :has(+ x) or :has(~ x).
    • Adding or removing a child restyles only the siblings a rule could select.
    • Rules are tried only when the element's tag name allows them.
  • Caching: selectors are parsed once, not once per document. The box model and list-marker style are cached by style key.

Responsiveness under load

bench/scenarios/latency.ts and editor-latency.ts time a key from input to frame in three conditions: idle, every core busy, and two busy processes per core.

  • Plain <input>: median key-to-frame went from 5.3 ms to 2.3 ms idle, and from 4.8 ms to 1.9 ms with every core busy.
  • CodeMirror under load: median key-to-frame dropped 26–29% compared with the branch before the repaint and line-breaking changes.

Benchmarks

The branch adds a benchmark harness (bench/run.ts), and the Benchmarks workflow posts this branch's numbers against its base. Medians against main, measured locally on a busy machine. Key to frame is wall time; the rest is CPU time.

Benchmark Change
toggle (li:has(), 500 items) −85%
react chat append −63%
DOM ops (20,000 spans) −58%
<input> key to frame −38 to −44%
CodeMirror key to frame −17% idle, −32 to −36% under load
startup −29%
scroll −3 to −9%
emails −4%
table about the same

The last round of changes, made after that run, also cut CodeMirror's CPU per key by 18% and emails by 9%.

Staying correct through changes

Every cache layout, style and paint keep between frames is a way for a page that has changed to drift from the same page rendered fresh. Rather than chase those one at a time, the engine now keeps four rules:

  1. Nothing reads a value that was overwritten by one derived from it. Layout used to round box positions to cells in place, then read the rounded values back on the next solve. Rounded positions now live in a record of their own (grid) that only rounding writes and layout never reads.
  2. Invalidation follows what a computation reads, not the shape of the tree. A measure records every style it read, and a change to any of them measures it again.
  3. A cache key covers everything its computation reads. For example, a size measured with no width was reused for a request of the width it came out at. That holds for text but not for content with a percentage width, so such sizes are now reused only for the request they answered.
  4. Each value has one writer. The layout tree is a tree: every child names the node that holds it, no node is held twice, held by nothing, or out of reach, only an element that generates a box has one, and every absolutely positioned box hangs where it can reach its containing block.

Some bug classes are now impossible to write rather than only tested for:

  • Coordinate frames: a box inside an inline-block counts its rows from that inline-block's own root. Every extent records which root it counts from, and an extent read in another frame reads as none, which only costs a shortcut.
  • Form controls: each frame redraws every form control from its state, so a state change that forgot to redraw cannot reach the screen.
  • Counters: the key that decides when to count again is made from the same inputs counting applies.

Each rule is checked in every test, on every frame:

  • Layout: the frame's layout is compared with a solve that forgot every cache, and the tree's shape is checked.
  • Style: every computed value served is compared with one resolved with every cache dropped.
  • Paint: every partial frame is compared with a full repaint.
  • Form controls: a frame that would redraw a control fails the test, since the redraw was forgotten earlier.

The checks run only under libuild test. They sit behind import.meta.litest, which libuild 0.2.27 defines in every test bundle, and libuild build removes them, so dist carries none of them. tests/incremental.test.ts drives this: it applies seeded random changes to three pages built from the features those caches cover (tests/fuzz-pages.ts), and after each change compares the screen and every element's rect with a fresh render.

Baseline alignment

Inline-blocks, inline-tables, inline-grids and images used to sit at the top of their line and fill it, with the text beside them on the line's top row. Each now sits with its baseline on the line's (CSS 2.1 §10.8.1):

Box The text beside it lines up with
inline-block its last line of text
inline-table, inline-grid, inline-flex its first row
image, or a box that clips, such as a <textarea> its bottom edge

vertical-align: top, middle and bottom place a box against the line instead. A line of text alone is placed as before, and no email snapshot changed. display: inline-table is also new: it used to lay out as a block.

Bug fixes

  • Non-breaking spaces: a block holding only &nbsp; now takes a line. Outlook writes every blank line this way, so those blank lines were dropped.
  • Half-cell boxes: text in a box whose edges fall on half cells now breaks to the columns the box is drawn across. Before, it could cross the box's border.
  • Image position: an image's x and y report its position.
  • Fullscreen: asking for fullscreen on a detached element now fails with a TypeError.
  • @import and @namespace in the wrong place in a sheet are dropped, as the spec requires.
  • Solitaire's height breakpoints now have units. Without them, the largest card size never showed.

Found by the checks and the fuzz tests:

  • Layout:
    • An inline-block is measured again when a child's size or a style it read changes.
    • Positioned rows, row groups and cells keep their absolutely positioned boxes laid out.
    • A box that becomes sticky no longer crashes a solve that places a fixed box.
    • A block inside an inline that becomes display: contents no longer keeps its old box.
  • Repaint:
    • A restyle inside an inline-block repaints the rows the inline-block is on.
    • A box that loses its background as it moves clears its old rows.
    • A sticky box whose shift changes repaints where it was and where it is.
    • An outline on a box with no height no longer draws on the row above.
  • Style:
    • A <style> added inside a subtree applies.
    • A class change restyles a form control's internal elements.
    • An <input> shows its new value when its value or type attribute changes.
    • A <textarea> shows its new text children until edited.
  • Inline-blocks:
    • A sticky box inside one now sticks to the scroller around it. Its shift used to be counted against the viewport from the inline-block's own rows.
    • A positioned cell inside one paints, and the pointer finds boxes inside one, including content that overflows it.
    • Changing an inline-block or inline-table into another kind of box re-derives what it holds, its own root's display, and the absolutely positioned boxes under it.
  • Percentages: an inline-block's percentage width stays exact until rounding to cells, so three boxes at a third each fit their line.
  • Form controls: a canceled click on a checkbox redraws it as it puts its state back.
  • Counters follow CSS Lists 3:
    • an element inherits its previous sibling's counters as well as its parent's;
    • counter-set works, and a box that isn't shown doesn't count;
    • ::before and ::after count as an element's first and last child, so h2::before { counter-increment: section } numbers headings;
    • counters() works, and a string that spells counter() stays text;
    • counters follow the flat tree, through shadow trees and slots.
  • Tables follow CSS 2.1 §17.2.1:
    • table parts outside a table share an anonymous table, so cells sit side by side and rows share columns;
    • text a script puts in a table gets a row, and anything but a cell in a row gets a cell;
    • each row group spans only its own rows;
    • an absolute box after an anonymous table starts below it.

Cleanup

  • No import cycles. The old 10-module runtime cycle is gone, and a test fails if one comes back. New modules: tree.ts (tree reads), units.ts (cell size) and window.ts (window assembly).
  • Internal steps renamed. Internal methods take a leading underscore instead of symbol keys, and a test checks that none shows on a public interface.
  • Plain functions instead of methods for the painter, cascade, framebuffer, layout, exchange, layout node, cell grid and input.
  • Dead code and comments: dead branches, unused parameters and unneeded fallbacks are gone, and the typechecker now rejects unused locals and parameters. Stray comments are back on the code they describe, and comments that described the wrong thing are fixed.
  • CodeMirror example: it runs at the terminal's own cell size, so its cell-size workarounds are gone.
  • No test hooks in the library: setDecodeTimeout and getWindowInterfaceNames existed only for a test and a script, and are gone.

Tests

CI passes on node 20.19 and 26, bun and deno. The branch requires libuild 0.2.27. New or changed tests:

  • tests/import-graph.test.ts: fails on any runtime import cycle.
  • tests/style-caches.test.ts: the cached box model and marker style follow every kind of style change.
  • tests/stylesheet.test.ts: :has() invalidation.
  • tests/whitespace.test.ts: &nbsp; paragraphs.
  • tests/cell-size.test.ts: the half-cell case.
  • tests/test-utils.ts: turns on the layout, style and partial-frame checks for every test.
  • tests/incremental.test.ts and tests/fuzz-pages.ts: seeded changes against fresh renders, plus every seed that once found a bug.
  • tests/partial-repaint.test.ts: each way a page can change names the rows it repaints.
  • tests/counters.test.ts: CSS counters, pseudo-element counters, counters() and the flat tree.
  • tests/tables.test.ts: anonymous tables, row groups and inline-tables.
  • tests/inline-block.test.ts: baseline alignment.
  • tests/sticky-position.test.ts: a sticky box inside an inline-block.
  • tests/element-from-point-shadow.test.ts: hit-testing inside inline-blocks.
  • The email fixture snapshots changed where &nbsp; spacer rows now appear, where lines that crossed their box now wrap inside it, and where a Ghost newsletter's feedback buttons, which were drawn at no size, now show. Centred text in two of them moved one column, from widths kept exact before rounding.

🤖 Generated with Claude Code

brainkim and others added 30 commits October 14, 2026 15:01
…as(~ x)

A change under an element restyled every element a :has() could be
tested on: its ancestors, and every earlier sibling of each. Only a
:has() whose argument starts with + or ~ can match from a sibling, so
the siblings are walked only when a rule has one. In a list styled with
li:has(.on), toggling a class in one item restyled every item before it.

The box model and a list marker's text style are kept by the style
object a resolved style keeps until it changes, as the paint style is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…style change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A list marker is not ASCII, so every frame measured and drew each
item's bullet through Intl.Segmenter. A short text's graphemes are now
kept. The frame writer appended every cell to an array it then joined,
and tested each one-character cell with a regex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry rule

Each list item's marker filtered the whole rule list for ::marker rules
every frame. The rules are already filed by pseudo-element, and are now
filed again in cascade order whenever the rule list is sorted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rom the top of a sheet

A sheet kept an @import or @namespace after a style rule, after a later
kind of prelude rule, or nested in another rule. Browsers drop each of
these, as css-cascade-5 and css-namespaces-3 require. With namespaces
only at the top, each style rule's parse reads the sheet's namespaces
from the top instead of walking every rule in the sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every document parsed the UA sheet again, and reading its selectors
with css-tree was most of what a new document's stylesheet parse cost.
A reading depends only on the selector's text and nothing changes it,
so readings are kept by that text.

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

Each new window spread every platform class into a fresh object two
times, and parsed the Web IDL text of each Window operation after
building a map of every interface class. Both depend on nothing a
window has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rules outside shadow trees

The rule index filed rules by id and class, so a plain span tried 152
rules, among them the UA's rules for inputs, dialogs and table cells. A
rule whose subject names an element is now filed by that name too.
Pseudo-element rules match through a shadow host, so outside a shadow
tree they are rejected before the ::part() test. Checking a name for
uppercase is a loop rather than a regex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A child added to a tree that was then removed before the frame still
had its boxes derived, and asking whether each was display: contents
resolved a style for every node of it. Building 20,000 spans under a
div and removing the div resolved 20,000 styles. A detached tree holds
no boxes, and the record that brings it back derives it whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…without a NodeList

Each added element's descendants came from querySelectorAll, which
built a NodeList and defined an index property per element. They are
now walked directly. A detached tree's pseudo-elements and shadow roots
wait for the record that inserts it, which visits the same elements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s or goes

The UA sheet's details > summary:first-of-type sets display and list
styles, so in every document a sibling rule reached descendants, and a
parent gaining a child restyled everything under it. Appending a message
to a chat log restyled every earlier message. Each child is now checked
against the sibling rules' keys, as it already was when no rule reached
descendants, and only one they could name restyles with its subtree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measuring a box walked its ancestors and asked each whether it was the
html or body element, reading both through the window's bindings, before
reading a scroll offset that was nearly always zero. CodeMirror measures
on every keystroke.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bench/run.ts runs each scenario in bench/scenarios in a process of its
own and prints the median CPU time of each measurement. With --against
it checks a ref out into a temporary worktree and alternates runs
between the two checkouts. Four scenarios are shaped like apps: startup
to a first frame, scrolling a long document, appending to a React chat
log, and typing in CodeMirror. Four are synthetic: the email fixtures, a
1000-row table, a :has() toggle and building 20,000 spans.

A workflow runs the comparison on every pull request and writes the
table to the job summary. Shared runners are too noisy to fail on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The symbol-keyed methods were moved off the public prototypes onto the
records at startup, and called through those symbols. Each is now a
module function that takes the record, as the engine's other reads do.
The gradient and pattern lookups, the one pair a call chose between, are
chosen by the paint's class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d drop fallbacks no runtime takes

A class's internal steps were symbol-keyed methods that slots.ts moved
off the public prototype onto the record. They are now methods whose
names start with "_", which no Web IDL member's does, and slots.ts moves
those instead, so a step reads slots._insertionSteps() rather than
slots[kInsertionSteps](). Overrides and super calls work as before. A
test holds that no interface a page can reach shows such a member, and
that no member the specs name starts with one.

Node 20.19, Bun and Deno all have DOMException, FormData,
AbortController and AbortSignal, so the stand-ins for a runtime without
them, the sealed DOMException and a FormData of our own among them, and
the checks for their absence are gone. FormData extends the runtime's
directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…flags without a step

TreeWalker's step built a fresh object of three of its own slots for
every node the filter looked at, and NodeIterator built the same; both
now hand filterNode their record, which holds those slots. The token
list's step only called ensureList, and the validity state's only called
its own flags(). A computed style's custom property names come from a
plain function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… by symbols that are gone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… describe nothing

Several doc comments had been left above the wrong declaration when code
moved around them: flushLayout's above revealEditingCaret, Window's above
two runtime constants, createWindow's above the class table, the hover
note above the :active map. Others headed nothing, said the same thing
twice, told how a bug once looked, or named writeTokenList() for the
clipboard's write().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hed element with a TypeError, and fix comments that misdescribe their code

img.x and img.y returned 0 while their comment said they read the
geometry; they are now the border box's edges from the document's
origin, as CSSOM View has them. requestFullscreen() on an element
outside the document rejected with an Error named InvalidStateError
through a try/catch that could not otherwise fire; it now fires
fullscreenerror and rejects with the TypeError the Fullscreen Standard
names. The document kept a children field nothing read.

Comments that told how a bug once looked now state the rule. Others
moved to the code they describe, named a function that exists instead
of one that does not, or claimed innerText did not read the rendered
text, which it does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d parameters and dead branches, and have the typechecker refuse new ones

Every supported runtime has Event, EventTarget and Blob, so the guards
for their absence go. insertAdjacentHTML tested whether its context,
always an element, was one. hasRuntimeGlobal tested for names its one
caller never passes. blur() looked the focused element up twice with
nothing between. isDisabled forwarded to isActuallyDisabled with a match
state no one read, and isMutable took that state only to pass it on.
paintOutline and matchingChangedMembers took arguments they never used.

tsconfig now sets noUnusedLocals and noUnusedParameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er module reads

canvas.ts's domException(name, message) only built a DOMException with
its arguments the other way round, so the twelve calls build it
themselves. resetCanvasContext, getCaretColumn, createBeforeUnloadEvent
and hasPaintContainment each forwarded to one call at one or two sites.
getValidityState set the internal construction flag by hand where
constructInternal does it. text.ts tested for a control byte by hand
beside its own isControlByte, and the inspector wrote a closing angle
bracket on two paths that could share it.

Eleven exports nothing outside their module imports are module-local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roll container test, collapsing a selection to an edge

The url reflection, a hyperlink's href and its toString resolved the
attribute against the base URL in three copies. Layout asked whether
either axis scrolls in three places. collapseToStart and collapseToEnd
differed in which boundary point they took. getChildElementsNamed
repeated isHTMLElementNamed, and the document's named collections walked
the tree by hand beside getDescendantElements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…over forwarder and a hand-written focus walk

lookUpCustomElementDefinition only checked for a null registry before
calling lookUpDefinition, which nothing else called. hidePopoversUntil
forwarded to hidePopoverStackUntil for one caller. dropUnfocusableFocus
walked through shadow roots to the focused element as getFocusedElement
does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PopoverState.trigger was set on show and cleared on hide but never read:
HTML reads a popover's invoker only for CSS anchoring, which this engine
does not do. flushStyle and flushLayout each worked the document out
from the node by hand, though the attached document already is it, and
flushLayout repeated flushStyle line for line before its layout pass.
getInnerText settles layout through getSettledLayout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rest stay for now: they are called through an optional chain, or
their names are taken by a function already.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
renderStatic stays a method, since index.ts has a function of that name.
The test helper that drew a frame through any object with beginFrame and
endFrame takes a Framebuffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in functions

Those that read the layout take it first; those that never did take no
layout, and their calls drop a receiver they did not need. Methods that
dom.ts or cssom.ts call stay: layout.ts loads both, and importing a
function back from it would close a cycle and change which module
finishes loading first. So do the ones called through an optional chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n functions

start and flush become startExchange and flushExchange, since a bare
start() or flush() says nothing at the call. Methods that dom.ts or
framebuffer.ts call stay, as importing them back from exchange.ts would
close a cycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n functions

CellGrid's clear, clone and copyFrom become clearGrid, cloneGrid and
copyGridFrom, and Input's dispatch becomes dispatchInput, since the bare
names say nothing at the call. FrameWriter stays a class whose methods
chain, which reads better than the nested calls functions would need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brainkim and others added 28 commits October 15, 2026 08:20
A frame repaints the rows a box painted before and the rows it paints
now. Two things moved those rows without the damage pass seeing it. A
box whose style changed as it moved was judged by its new style alone,
so a background it lost left its old rows unrepainted. And a sticky
box's shift moves its rows with no change to its rect, so each solve
now compares every sticky box's rows, shift included, with the last.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Counters follow css-lists-3: an element inherits its parent's and its
previous sibling's counters at the values the element before it left,
then applies counter-reset, counter-increment and counter-set. An
element's counters begin with every ancestor's, so inheriting is
cutting the list of the element before to the length of the previous
sibling's or the parent's. An element that changes no counter is
transparent: inserting or removing one renumbers nothing, so the
counts are dropped only when an element's counter inputs change or a
subtree holding one comes or goes.

A derivation retires the boxes of elements it walks through, and now
walks into every inline box, as the enumeration does. Asking whether
an inline was broken around a block read the state after the change,
so a block that dissolved inside an inline kept its old box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y read

Table parts a container that is no table holds share an anonymous
table, one per unbroken sequence (CSS2 §17.2.1), with a layout node of
its own that the parts hang from, so cells outside a row sit side by
side and stray rows share columns. White space in a table, a row group
or a row is no box, so any run left there is content, which the table
puts in an anonymous cell. A row group spans its own rows.

A size measured with no width was reused for a request of the width it
came out at, which holds for text but not for content with a
percentage, which an unbounded probe takes as auto. A node whose
content resolves a percentage against the width it is offered now
reuses a size only for the request it answered.

An outline of a box with no height drew on the row above, which no
extent covered, so a frame repainting that row skipped it. The layout
check also asserts the tree's shape: every child names its parent, no
node is held twice or hangs out of reach, and only an element that
generates a box has one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The from-scratch style, layout and partial-frame checks run in the
frame loop behind import.meta.litest, which libuild 0.2.27 defines in
every test bundle and the build replaces with undefined, so the checks
and the functions only they call are dropped from dist.

setDecodeTimeout and getWindowInterfaceNames existed only for a test
and a script. The tests wait out the real timeout, or pass an expired
deadline to decodeImageForPage, and the script reads the names off
getInterfaceObjects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getInterfaceObjects keeps only the interfaces the generated IDL already
names, which is the file the generator writes, so the names came out
short and the freshness check failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…idths exact

A table part takes its place in the anonymous table around it, so an
absolute box after one starts below the table rather than above it.

An inline-block's percentage width was rounded to whole cells as it
resolved, so three at a third each asked for more cells than the line
had. Layout keeps the exact width and the grid rounds it, as it does
every other.

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

A pseudo-element's counter-reset, counter-increment and counter-set
were ignored, so the common h2::before { counter-increment: section }
numbered every heading 0. An element's counters are now counted where
its subtree starts, after its ::before, which its first child takes,
and where it ends, after its ::after, which its next sibling takes its
values from.

counters() joins every counter of the name in scope. Both functions are
read as a content value's tokens, so a string that spells counter() is
only text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
inline-table was not a display layout knew, so it laid out as a block.
It is now an atomic inline whose own formatting context is a table,
as an inline-grid's is a grid.

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

A box an inline-block lays out has its extents counted from the
inline-block's own root. Painted through its stacking layer, such a
box was culled against the band counted in document rows, so a
positioned cell in a table made an inline-block went unpainted when the
frame's rows were not its root-relative ones. The band is now counted
from the root there, as it is when the inline-block paints its content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s in another frame

A box an inline-block lays out has its extents counted from the
inline-block's own root. Hit-testing compared the pointer's document
row with them, to rule a box out and to search a list's children, so
a pointer over such a list landed on the list rather than the child.
Both shortcuts now apply only to boxes counted in document rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An inline-block paints its content, which can reach past its own box
and the line it sits on, and the line's extent left that out. A box is
ruled out of a hit test only by an extent that covers it, so content
overflowing an inline-block could not be hit. The line's extent now
reaches as far as each inline-block's content does, and a box measured
on a line is ruled out by the line's extent, or by none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ly in it

An extent's rows count from the document's root or from the root an
inline-block lays its content out under. Readers compared them with
rows of the document wherever they came from, and three fixes each put
a check in front of one reader. The extent now records its frame, and
paintExtent answers only a caller counting in that frame: the painter,
which knows the frame it is painting, and hit-testing and damage,
which count in the document's. An extent of another frame reads as
none, which every reader already takes to mean "do not cull".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re percentages resolve

A run's measured size is reused only for the request it answered when
its content's size depends on the width it is given. That was flagged
where an inline-block resolved a percentage width or max-width, and a
replaced box resolving its own percentage width was never flagged. An
atomic inline with a percentage in any horizontal sizing property now
marks the run as it is collected, however it is later resolved, and so
does a percentage text-indent on the container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The key that says when to count again and the counting itself each read
an element's counter properties, in two places that could disagree: the
key read them only where counter rules exist, and counting always. Both
now take one record of what the element, its ::before and its ::after
reset, step and set, and whether it shows, and the key is made from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t unredrawn

A form control's shadow tree is a function of its state, redrawn at
each of some fifty places that change the state, and a place that
forgot left the control showing the old state. A frame now brings every
connected control's tree up to its state before it applies mutations,
so a forgotten place cannot reach the screen. Under test, a control the
frame would change first throws, so a forgotten place is still found,
as it matters to a read before the frame: a canceled click put a
checkbox's checkedness back without redrawing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-block

A sticky box's shift walked its layout parents to the scroller it holds
against, and the walk ends at an inline-block's own root, so a sticky
box inside one held against the viewport with its position counted
from that root. The walk now goes on from the inline-block, reading it
and the scroller where they are drawn, and the frame's repaint compares
each sticky box's shift too, repainting the inline-block's rows, which
cover any shift inside it.

A third fuzz page holds what boxes that lay out their own content hold:
a sticky box against a scroller's bottom in an inline-block, content past
an inline-block's height, an inline-table, ::before counters and cells
with no table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Counters were counted along the light tree, so a shadow tree's content
and a slot's assigned nodes did not take the counters around their host
or slot. Counting now walks flat-tree parents and siblings, a removed
subtree is searched through its shadow trees for counters it changed,
and a slot taking other nodes or a host taking a shadow root counts
again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rap any non-cell in a row

An inline-block's own root takes its display from the element, a table
for an inline-table and a block for an inline-block, but it was styled
only when the root was made, so an inline-table made an inline-block
went on laying its content out as a table. The root is now styled
wherever its element is, and a run syncs each member's root as it
syncs the member.

A row wraps whatever in it is no cell in an anonymous cell (CSS2
§17.2.1), a row or a group included, which the cell then lays out as a
table of its own. A row held in a row was taken for a cell.

A node's kept sizes are dropped when it moves under another parent, or
its parent's display changes, since what it was sized as depends on
what holds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Whether an element lays its content out under a root of its own follows
its display, but the root was dropped only when its node was made
again. A node kept through a change, as one moved into an anonymous
table is, kept the root, and its content went on being placed under a
root nothing laid out. A restyle that changes the kind of box now syncs
the root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… count from different roots

A static position is the in-flow position less the containing block's,
and the two were subtracted though one counted from an inline-block's
own root and the other from the document's. An absolute box in an
inline-table placed against a positioned ancestor outside it took a
position from rows that meant nothing there, as a fresh render, which
reached it before the root was laid out, did not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An atomic inline that lays its content out under a root of its own
keeps its node while it sits on a line, and the node kept the style it
had when made. A restyle compares the element with that style to tell
whether its box changed kind, so an inline-table made a block again,
compared with the block it was made as, left the line that measured it
in place. A run now styles each member's kept node as it syncs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ode is held

A member of a run is measured by the run and has no box of its own, but
one that lays its content out under a root of its own kept the node it
had before it joined the line. Its rect and its paint read that node, a
block's, so a sticky footer made an inline-table painted the width of
the line. A run now drops every member's node, as a fresh render never
makes one, and the layout check fails on a node no box holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… goes, and check they reach their containing blocks

An out-of-flow box hangs where its containing block can be reached
from its parent, which was checked only as it was placed. An element
that took a root of its own moved what it holds under that root,
taking an absolute box with it out of its containing block's reach, so
the box was laid out against a parent that was not its containing
block. Making or dropping a root now places its out-of-flow boxes
again, and the layout check fails on one out of its containing block's
reach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An atomic inline lays its content out under a root of its own that its
run's measure lays out. One taken out of flow is measured by no run, and
a root made for it took its content from its own node and was laid out
by nothing, so an absolute inline-table showed nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A box measured as a run of its own, as a text node's is, has no parent
box, and the run's measure asked that missing parent for its text-indent.
The block the lines are indented in is the run's container, or the
box's own element.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An inline-block, inline-table, inline-grid or image sat at the top of
its line, which it filled, and the text beside it sat on the line's top
row. Each now sits with its baseline on the line's (css2 §10.8.1): an
inline-block's last line, an inline-table's, inline-grid's or
inline-flex's first, and the bottom edge of an image or of a box that
clips. vertical-align: top, middle and bottom place one against the
line instead. A line is as tall as what reaches furthest above the
baseline and below it, and its text sits on the baseline's row; a line
of text alone is placed as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An inline-block flex item's measure counts its cross-axis border and
padding in its size, so its layout node holds none on those edges. The
content rect read the node's edges as the box's, so a focused, empty
textarea in a flex row parked its caret on its top border. Which edges
are masked is now one function, which the masking and the content rect
both read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brainkim
brainkim merged commit 3dd32d3 into main Oct 15, 2026
8 checks passed
@brainkim
brainkim deleted the perf-pass branch October 15, 2026 20:54
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