Skip to content

feat(core): the animated-layout glide moves the siblings before a change too, and its walk stops near the visible part - #8634

Merged
cixzhang merged 51 commits into
mainfrom
feat/animated-layout-earlier-siblings
Oct 28, 2026
Merged

cixzhang merged 51 commits into
mainfrom
feat/animated-layout-earlier-siblings

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #8568 (the Centered column story uses the adopters). Retarget to main once #8482 and #8568 land.

User need

Two gaps in the glide, both found by measuring:

  1. Earlier siblings jump. In a centered, end-aligned or space-between row, or a list reordered with order, a change also moves the content BEFORE it. That content jumped, because the walk only looked at what came after.
  2. Long lists hitch. The perf page (feat(sandbox): Animated Layout Perf, measuring useAnimatedLayout for an item in a long list #8629) shows the walk was linear in the rows after the change. In a fully rendered list, opening an item in the middle blocked a frame for 136 ms at 5k rows and 275 ms at 10k (1.1 s at 4× CPU) before anything moved, to animate the 9 rows in view.

Problem and solution fit

  • Earlier siblings: at each ancestor level the walk also collects the siblings before the changed element. They move only under the existing rule: their rectangle changed and they are in the visible part. A block-flow list's earlier rows don't change, so they don't move.
  • The bound: each level's walk stops at the first sibling outside the band a change can bring into view. The band is the area's visible part plus one viewport, or the changed element's size if larger, above and below. Using the larger reach closes the tall-collapse case: rows a collapse pulls up from below the fold are measured and glide in. Cost now grows with depth plus the siblings near the visible part, never with a list's length.
  • Record changes: AST-105 drops order from its out-of-coverage list, and FR3 and the coverage table name the earlier siblings. architecture:react-component-runtime INV12 describes the walk's two directions and its bound. Both need an engineering owner's exact-head approval.

Current specification

  • spec:AST-105 FR3 (amended here), Coverage (a new row for siblings before the change; order leaves the out-of-coverage list).
  • architecture:react-component-runtime INV12 (the walk, amended here).

Evidence

Perf page numbers, real Chromium, the middle item of the list, median over 6 toggles. BEFORE is #8629's build; AFTER is this branch plus #8629's page:

rows rendering CPU commit + walk: before → after (no handle, which includes the browser's own layout) rects read: before → after long tasks: before → after (no handle)
1k every row 1× 21.5 → 17.2 ms (2.8) 1,007 → 105 0 → 0 (0)
5k every row 1× 77.1 → 54.0 ms (7.4) 5,007 → 105 6 → 6 (0)
10k every row 1× 150.8 → 109.3 ms (13.0) 10,007 → 105 6 → 6 (0)
1k / 5k / 10k virtualized 1× 7.2–7.5 → 7.4–7.8 ms (1.5–1.6) 43 → 79 0 → 0 (0)
1k every row 4× 90.1 → 71.6 ms (11.0) 1,007 → 105 6 → 6 (0)
5k every row 4× 321.2 → 231.6 ms (29.9) 5,007 → 105 11 → 12 (0)
10k every row 4× 637.9 → 470.0 ms (52.6) 10,007 → 105 21 → 21 (12)
1k / 5k / 10k virtualized 4× 32–33 → 31–32 ms (6.0–6.1) 43 → 79 1 → 1 (0)

The walk's own cost is now flat. In a plain 10,000-row list, outside React, the module measures in 1.5 ms (160 reads) and starts the glide in 6.8 ms (157 reads); 5.2 ms of that is the browser's own layout on the first read, which a bare size change also pays (6.5 ms).

What remains in a fully rendered list is the browser laying out the whole list more than once per toggle. It does so once to read the new places, which can't be avoided, and on a collapse again while the exit measures where its space goes. That second pass is the next thing to bound, in a separate PR. Virtualized lists, which only render what's in view, stay at a few milliseconds.

Real-browser proofs, in the module's browser spec:

  • a centered, a space-between and an end-aligned row, where the earlier sibling glides from its old place;
  • a 2,000-row list that reads fewer than 200 rectangles, where the rows a 1,200 px collapse pulls into view glide in.

The rectangle-count assertion doubles as a CI budget on the walk's work. It's deterministic, unlike a time budget, which would depend on the CI machine.

Scope

  • One capability is changed (the walk's reach and bound).
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the change.
  • Public text and artifacts contain no internal Meta context.

Testing

  • animatedLayout.test.ts: an earlier sibling in a centered row moves, and one whose rectangle didn't change stays; the walk reads no row past the band.
  • animatedLayout.boundary.test.ts: the walk's two directions, and each level ending at the band.
  • animatedLayout.interaction.browser.spec.ts: 36 cases, all pass in real Chromium, two repeats.
  • pnpm -F @astryxdesign/core typecheck, eslint, pnpm check:repo, pnpm check:knowledge. Motion, Collapsible, TreeList and hooks: 1190 pass.

Writing rubric results

Records touched:

  • spec:AST-105: FR3 names the siblings before the change; the coverage table gains their row; order leaves the out-of-coverage list.
  • architecture:react-component-runtime INV12: the walk's two directions and its bound.

Rubric:

  • R1 narration: none; present tense throughout.
  • R2 research: none.
  • R3 evidence: the new coverage row names its test.
  • R4 rejected alternatives: none added.
  • R5 change references: none.
  • R6 open questions: none added.

…t around a size change

When something inside a registered area changes size, the content after it
glides from its old place to its new one with a compositor-only translate
instead of jumping (architecture:react-component-runtime INV12, proposed
in #8463).

- motion/animatedLayout.ts: one batched measurement before and after the
  change; a bounded parentElement/nextElementSibling walk up to the area
  (no TreeWalker), descending only through display: contents; absolute and
  fixed elements skipped; only elements whose rectangle changed and that
  are visible move; scale-corrected; Web Animations translate with
  composite: 'add', feature-tested per call with fallbacks; offscreen
  elements land silently; Reduce Motion cross-fades with no travel; every
  pointer.
- useAnimatedLayout(ref) (public) registers an area; reportLayoutChange
  stays internal. ScrollableArea registers its viewport itself.
- Collapsible and TreeList adopt it: the shown content plays its own
  entrance, and the chevron turns, on the same placeholder layout-change
  role as the glide.
- Storybook: Core/Animated layout, with a nested tree, a Collapsible, a
  field error, load more, wrapping tags, plain divs, a display: contents
  wrapper, a sticky header and an absolute badge.

Draft for trying it out; tests, docs and a changeset follow.
…ollapsible and TreeList take as animatedLayout

The API shape the owner set: the hook takes {containerRef} and returns an
opaque handle; Collapsible and TreeList accept an animatedLayout prop and
report their size changes through it, with no context registration.
Without the prop, a component keeps today's behavior, its chevron
included. ScrollableArea no longer registers itself: an app passes the
ScrollableArea's ref as containerRef. The story uses exactly this shape.
Adds the changeset.
…subpixel rounding

A real-Chromium check showed unscaled followers moving by 79.92 px for an
80 px shift: subpixel rounding made rect.width / offsetWidth read a hair
off 1. A scale within 1% of 1 is now treated as none.
…tion by pointer, visible-part rule for the changed element, top-layer and hidden skipped, chevron on the spring)
- One shared motion role: the glide, the changed element's entrance and the
  disclosure chevron all play springs.settle, resolved through the internal
  motion-role registry (a theme's --motion-springs-settle reaches all three);
  the glide's keyframes are sampled from the spring and played linearly.
- Reduce Motion by pointer (design:reduced-motion DR1-DR3): a cross-fade
  under a coarse primary pointer, an immediate change otherwise.
- The changed element outside the area's visible part lands without
  animation; hidden and top-layer elements are skipped.
- Collapsible and TreeList take the chevron from their Icon's icon target
  and set its transition before the change commits; no handle leaves every
  inline value untouched.
- Tests: the walk, motion and handle in jsdom; Collapsible and TreeList with
  and without a handle; one real-browser case per AST-105 Coverage row, plus
  FR2-FR5.
- Docs: the useAnimatedLayout hook doc (FR7 text), the animatedLayout prop in
  the Collapsible and TreeList docs and records, and a Before and after story.
… registry only

Motion roles are internal (spec:AST-094 DEC-11): the glide, the changed
element and the chevron share the registry's one value, and a scope's
--motion-springs-settle changes nothing. The tests assert that instead of
an override.
… and compare scrolling with and without a handle

Run in real Chromium (all 20 cases, three repeats): the page helper's
microtask wait is plain JavaScript; the out-of-view and nested-area cases
wait for the first change's glide to finish before the second; and the FR3
scroll case compares the area's scroll frame by frame with and without a
handle, since the browser's own scroll anchoring moves it either way.
…ed ones and retargets on the compositor

- A single-mode CollapsibleGroup measures every item a toggle changes (the
  one that closes as well as the one that opens), so the items between
  glide with the content after the group. Items holding a handle register
  their measurement with the group.
- A measurement no commit claims by the next frame (a controlled owner
  refused the toggle) is dropped, so it never moves unchanged rows later.
  A change nothing measured (an owner set isOpen directly) lands at once,
  entrance included.
- The follower glide is one replace-composited translate, which the
  compositor runs (architecture:react-component-runtime INV12). An element
  already gliding is retargeted: its glide stops and the new one starts from
  where it is on screen, so it keeps moving without a jump.
- INV12's source boundary check (no layout property animated, the walk only
  through parentElement / nextElementSibling, nothing public, only the hook
  and the adopters use it) and a real-browser case on a throttled phone
  over a 10-level by 50-sibling tree with two changes in one frame.
spec:AST-105's Coverage row: an element already moving keeps its running
motion and adds the new move. The new glide now samples the sum of the
running glide's remaining curve and the new move into one replace-composited
translate, so the element keeps its speed through the change instead of
restarting from rest. Every animation of a change starts on the document
timeline's current time, so a replaced glide moves in the very frame it is
replaced and the changed element and its followers share one start. Tests:
the carried speed (jsdom) and, in a real browser, no jump and no lost speed
across the change.
…e morph once Core has it, its own compositor translate until then
…st adopt it in their own pull request

The module, the hook, their tests and docs, and the INV12 record line stay
here. Collapsible's and TreeList's adoption (their animatedLayout prop,
docs and records), the CollapsibleGroup measurement, the adopter tests and
the Storybook story move to a stacked pull request, so each lands and
reverts on its own. The changeset now describes the hook alone.
Restores the adoption from the module pull request onto it: the
animatedLayout prop on Collapsible and TreeList (docs and records), the
CollapsibleGroup measuring every item a toggle changes, the adopter tests,
and the Storybook story. The story keeps only adopting components and plain
following content; its demos that drove the internal reporting functions
directly are gone.
…motion

spec:AST-105 FR4: the changed element plays its own animation when it exits
too. holdLayoutExit keeps a closing element on screen but out of flow
(absolutely positioned where it is), so the content after it glides up at
once, and conceals it upward from its bottom edge on springs.settle (a fade
under Reduce Motion with a coarse pointer, nothing with a fine one); the
component hides it when the exit finishes, and releaseLayoutExit puts it
back, including when it opens again mid-exit. The throttled-tree browser
case now reads its animations in the same task, so a slow round trip can't
outlast the glide.
…'s exit

spec:AST-105 FR4: the changed element animates when it exits too. A measured
close keeps the Collapsible's content (or the TreeList row's child group)
rendered, inert and hidden from assistive technology, while holdLayoutExit
holds it out of flow and conceals it on springs.settle; the rows after it
glide up at once, and the content hides (or the group unmounts) when the
exit ends. Opening again mid-exit ends it. Rows of an exiting group leave
the tree's keyboard order at once. An unmeasured close, no handle, or an
exit that lands at once hides it in the same commit.
…es with its row; the exit stays in flow

- This pull request is now the internal module only: the public hook, its
  docs and the chevron's transition move to the adoption pull request, so
  nothing public appears before a component can take the handle.
- A changed element that an earlier change in the same frame displaced moves
  with its row, by its total change (AST-105 FR3), while it plays its own
  entrance or exit; an element not rendered before the change has no old
  place and does not move.
- The exit keeps the element in flow where it is and gives up its space
  with a negative end margin as large as its box, so it scrolls and clips
  with its container and keeps its width (no offset-parent positioning).
- Browser proofs: the sticky row uses a stuck header after the change, the
  flex row a wrapped item and a row-direction neighbor whose rectangle does
  not change; new cases for the displaced changed element and the exit in a
  scrolled area.
… chevron move here

The module pull request is now internal only, so this one carries the
public face with the components that take it: useAnimatedLayout, its doc
page and export (and its docsite route), applyLayoutChangeTransition and
its tests, and the boundary test's list of users (the hook, Collapsible and
TreeList). The adopters follow the module's in-flow exit: their tests check
the exit's end margin instead of absolute positioning. The changeset
describes the hook and the two adopters together.
…e app moves are left alone

- holdLayoutExit measures the layout after the element with it hidden and
  sets the end margin that puts that layout there, so a gap or collapsing
  margins come out exact and nothing jumps when the element hides; where no
  margin can free the space (a grid track and its gap) the exit lands at
  once instead.
- The held element is inert while it conceals (no focus, no pointer,
  nothing read); the module sets and releases it.
- A follower with its own transform, translate, rotate or scale, or with a
  running transform animation the module did not start, takes its final
  place at once, as AST-105's Coverage lists for elements the app moves.
- Tests: the exact space with a gap (jsdom), the grid fallback, app-moved
  followers; real-browser cases for a flex gap and collapsing margins, and
  for app-moved followers.
… left alone) into the adoption

The module now makes a held element inert, so Collapsible's content and
TreeList's child group drop their own inert and aria-hidden while exiting
(TreeList keeps the marker that takes the group's rows out of the keyboard
order).
…red close, and restores what it replaced

- giveUpSpace measures the first element after the closing one in the area
  (its next sibling, or an ancestor's below the area), not the parent's
  edge, which a collapsed negative margin never moves: a Collapsible-shaped
  close (the last child of a plain wrapper) now plays its exit.
- holdLayoutExit holds only a close a change in the area measured, and its
  documentation asks for the call after animate() reports it measured.
- releaseLayoutExit puts back the element's own inert and inline values.
- AST-105's out-of-coverage list names a closing grid item whose track or
  gap changes when it hides (owner-approved): it hides at once, without its
  exit.
- Tests: an unmeasured close holds nothing; prior values restored; a real-
  browser case for the plain-wrapper shape.
…d holds only a measured close) into the adoption
…al next box and checks what follows its container

- The flush stops every running glide and reads every new place before
  starting any glide, and an element inside another moving element moves
  only by its own change: nested changes compose whichever is reported
  first (AST-105 FR3).
- The exit's search for the next box enters a display: contents wrapper,
  as the follower walk does.
- The exit checks the content after its container on both axes, so a
  flex-row item that sets its row's height is refused instead of leaving
  the content below to jump.
- A refused exit puts back the element's own end margin and drops its
  change, so the content after it takes its final place at once.
- AST-105's out-of-coverage line names both layouts no margin frees (owner-
  approved).
- Tests: a refused hold keeps its own margin and glides nothing; real-
  browser cases for nested changes reported inner-first, a display:
  contents next box, and a tall flex-row item.
…ext box and container checks) into the adoption
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 60d3e00

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 28, 2026

@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.

Changes requested: the amended spec:AST-105 FR3/Coverage and architecture:react-component-runtime INV12 are owner-approved for this head, and the earlier wrapped-rows and unstuck-sticky cases are fixed when the area is its own scroller, but two layouts now regress against the base. Please fix both and add a browser case for each:

  • When the area is a panel in a page or outer scroller that scrolls (the hook's documented <div ref={containerRef}>), a change above the view makes the ancestors glide by the page's scroll-anchoring adjustment (rows in view drawn about 51 px off at 100 ms) and a stuck position: sticky; bottom: 0 footer around the change slide 51 px below the viewport, because flush() nets out only the area's own scrollTop and isStuck() measures against the area's edges rather than the element's scrollport.
  • In a CSS multi-column list, the band stop ends the forward walk down the first column, so the shifted rows of later columns jump (13 of 13 glide on the base, 0 on the head); keep walking in such containers, or name them beside order in the out-of-coverage list with a fresh /approve-spec.

[Automated review]

Comment on lines +682 to +683
const scrolledX = (batch.scrollBefore?.x ?? 0) - batch.area.scrollLeft;
const scrolledY = (batch.scrollBefore?.y ?? 0) - batch.area.scrollTop;

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 nets out only the area's own scrollLeft/scrollTop. When the area is a panel in a page that scrolls (the hook's documented <div ref={containerRef}>), a change far above the view makes the browser's scroll anchoring scroll the document instead. Every recorded ancestor's viewport position then shifts by the change's size, and it glides back, carrying rows that never moved: an 80 px change draws the rows in view 51 px off at 100 ms in Chromium and WebKit, while the base keeps them still. Netting out the scroll of whatever scrolls the area's content (the area, an ancestor scroller or the document), for example by also subtracting the area's own change of position, would cover it, together with a page-scrolled variant of the wrapped scroll-anchoring browser case.

Comment on lines +380 to +384
return (
(Number.isFinite(top) && Math.abs(rect.top - (edge.top + top)) < 1) ||
(Number.isFinite(bottom) &&
Math.abs(rect.bottom - (edge.bottom - bottom)) < 1)
);

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 compares the sticky element with the area's edges, but a sticky element sticks to its scrollport, which is the viewport when the area is a panel in a scrolling page. There, a position: sticky; bottom: 0 footer stuck to the viewport bottom and holding an opening Collapsible reads as unstuck, is recorded as an in-flow ancestor, and glides from 0px 80px: its bottom edge is drawn 51 px below the viewport at 100 ms, while the base keeps it at the edge (FR7: stuck sticky elements stay put).

Comment on lines +442 to +443
if (rect.top > band.end) {
return 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.

Ending the forward walk at the first sibling past the band assumes DOM order runs downward. In a CSS multi-column list (columns: 2), opening an item near the top of column 1 walks down column 1 and stops past the band, so column 2's rows (later in the DOM, back at the top of the view) are never measured. All 13 of them shift one slot and jump: the base glides 13, the head 0, in Chromium and WebKit. The Coverage rows this pull request keeps say siblings after the change move when their rectangle changes, and the out-of-coverage list names only order. Either keep walking in a container whose DOM order does not run in the block direction (multi-column, flex-flow: column wrap, grid-auto-flow: column), or name those layouts in the out-of-coverage list with a fresh /approve-spec.

…ck reads the scrollport; a column-flow level walks through

CodeBunny on #8634:
- Only the area's own scroll was netted out of an ancestor's move, so in a
  panel the page scrolls, the page's scroll anchoring made the ancestors
  glide and drew the rows in view 51 px off. An ancestor now moves by its
  place against the area's scrolled content, which nets out a scroll of the
  area, an outer scroller or the page.
- A sticky element was judged stuck against the area's edges; it sticks to
  its scrollport (the nearest scrolling ancestor, or the viewport), so a
  footer stuck to the viewport bottom glided 51 px below it. It is judged
  against its scrollport now.
- In a multi-column list (or a wrapping column flex box, or a column-flow
  grid), DOM order does not run down the page, so the band stop left a later
  column's rows to jump. A level laid out across the block direction now
  runs through its siblings; INV12 states that cost.

Chromium cases: a page-scrolled panel with a wrapper, a viewport-stuck
footer in a page-scrolled panel, and a 120-row two-column list. All three
fail on the previous head.
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 71abedd

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 28, 2026

@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.

Changes requested: the amended spec:AST-105 FR3/Coverage and architecture:react-component-runtime INV12 are owner-approved for this head, and the page-scrolled panel, viewport-stuck footer and multi-column cases from the last review are fixed, but two layouts now regress against the base. Please fix both and add a browser case for each:

  • When the area itself moves as its content grows (a modal dialog the browser re-centers, a panel the page centers, a panel docked to the viewport bottom), the siblings before the change glide by the area's move while the card around them takes its final place, so 100 ms into opening a Collapsible its trigger is drawn about 23 px over the opening content; netting the same correction flush() applies to ancestors out of the siblings before the change keeps them with their card.
  • In a page whose body sets a non-visible overflow (such as overflow-x: hidden), scrollportOf() treats body as the sticky element's scroller, so a footer stuck to the viewport glides from 80 px below and is drawn about 46 px past the edge; treat body as the viewport when its overflow applies to the viewport.

[Automated review]

entry.rawX =
entry.before.left - entry.after.left + (isAncestor ? scrolledX : 0);
entry.rawY =
entry.before.top - entry.after.top + (isAncestor ? scrolledY : 0);

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 nets the on-screen move of the area's content (any scroll, and the area's own move) out of the ancestors only. When the area itself moves as its content grows (a modal dialog the browser re-centers, a panel the page centers, a panel docked to the viewport bottom), the card around the change now takes its final place while the siblings before it, collected at lines 555-561, still glide by the area's move: 100 ms into opening Shipping details in a modal dialog, the Collapsible's trigger is drawn 22.8 px over the opening content and the header 14.8 px over the card, where the base keeps them in place. Netting the same scrolledX/scrolledY out of the siblings before the change keeps them with their card.

const view = element.ownerDocument.defaultView;
for (
let node = element.parentElement;
node != null && node !== element.ownerDocument.documentElement;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only the root element is skipped here. When a page sets a non-visible overflow on body (such as overflow-x: hidden), body computes overflow-y: auto although its overflow applies to the viewport, so a footer stuck to the viewport bottom is measured against the body's box, reads as unstuck and glides from 0px 80px, drawn 45.6 px past the viewport edge at 100 ms (FR7: stuck sticky elements stay put). Treating body as the viewport when the root's overflow is visible fixes it.

…rea moves; the body is no scroller of its own

CodeBunny on #8634:
- When the area itself moves as its content grows (a dialog re-centered,
  a panel the page centers or docks), the card around the change took its
  final place while the siblings before the change glided by the area's
  move, drawing a trigger over the opening content. The siblings before
  the change now net out the same move as the ancestors around them.
- With a non-visible overflow on body (overflow-x: hidden), the body read
  as a sticky footer's scroller, so a footer stuck to the viewport glided
  past the edge. The body's overflow applies to the viewport while the
  root's is visible, so the viewport is its scrollport then.

Chromium cases for both; both fail on the previous head.
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec d2dfc6f

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 28, 2026

@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.

Changes requested: the amended spec:AST-105 FR3/Coverage and architecture:react-component-runtime INV12 are owner-approved for this head, and the moving-area and body-overflow cases from the last review are fixed. Two layouts now regress against the base, though. Please fix both and add a browser case for each:

  • With two changes in one frame, flush() adds the scroll-anchoring shift that one change causes to the siblings before the other change and to that change's own row. In a single-mode CollapsibleGroup whose open item sits above the visible part, content the browser kept in place therefore starts 344 px lower and glides back.
  • A grid item that grid-row places into view after a DOM sibling lying past the band is no longer measured, so it jumps 80 px where the base glides it.

[Automated review]

Comment on lines +771 to +776
const isAncestor =
batch.ancestors.has(entry.element) || batch.befores.has(entry.element);
entry.rawX =
entry.before.left - entry.after.left + (isAncestor ? scrolledX : 0);
entry.rawY =
entry.before.top - entry.after.top + (isAncestor ? scrolledY : 0);

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 adds the area's content-origin shift to every ancestor and earlier sibling, whichever change caused it. Take a single-mode CollapsibleGroup that closes an item above the view while opening one in view, in one frame. Scroll anchoring keeps Questions 2–6 still (scrollTop 395 to 51), yet in Chromium they and the opened row glide from 0px 344px, while the base keeps them still. Netting the shift only out of elements whose place in view moved with it (or netting only the area's own box move out of earlier siblings) would keep them still, and a browser case for that group would pin it.

Comment on lines +509 to +511
if (rect.top > band.end) {
return 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.

Ending the level at the first DOM sibling past the band drops any later DOM sibling that grid placement puts inside the band. In the module's browser harness, a grid child on grid-row: 2, behind a DOM sibling on row 3 with 2,000 px above it, jumps 80 px on the head and glides from 0px -80px on the base. That goes against the unchanged Coverage row for flex and grid items. Walking an explicitly placed or dense grid level all the way through, as flowsAcross() does for column flow, would cover it.

…maller; grid placement from past the band is out of coverage

CodeBunny on #8634:
- With two changes in one frame (a group closing an item above the view
  and opening one in view), the scroll anchoring one change caused was
  added to the siblings before the other change, so rows the browser kept
  in place glided from 344 px. An ancestor or an earlier sibling now takes
  the scroll and area correction only where it makes its move smaller.
  Chromium case; it fails on the previous head.
- A grid item that grid placement brings into view from past the band in
  DOM order is not reached, as with order. AST-105's out-of-coverage list
  names grid placement beside order (owner approval needed).
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 51d65e7

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 28, 2026

@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 amended spec:AST-105 FR3, Coverage and out-of-coverage list and architecture:react-component-runtime INV12 carry an engineering-owner approval for this exact revision, and both issues from the last review are resolved. Rows that scroll anchoring holds in place during a second change in the same frame now stay put, with a browser case that fails on the previous revision, and grid placement from past the band is now named out of coverage. The module's unit and browser suites, the Core typecheck and the knowledge check pass on this revision.

[Automated review]

@cixzhang
cixzhang merged commit 0418c47 into main Oct 28, 2026
76 checks passed
cixzhang added a commit that referenced this pull request Oct 28, 2026
cixzhang added a commit that referenced this pull request Oct 28, 2026
cixzhang added a commit that referenced this pull request Oct 28, 2026
# Conflicts:
#	docs/architecture/react-component-runtime.md
#	docs/specs/AST-105-animated-layout/spec.md
#	packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts
#	packages/core/src/motion/animatedLayout.boundary.test.ts
#	packages/core/src/motion/animatedLayout.test.ts
#	packages/core/src/motion/animatedLayout.ts
@github-actions
github-actions Bot deleted the feat/animated-layout-earlier-siblings branch October 28, 2026 06:17

This branch was successfully deployed

1 active deployment
Preview — 51d65e7b Deployed Oct 28, 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.

2 participants