Repository navigation
feat(core): the animated-layout glide moves the siblings before a change too, and its walk stops near the visible part - #8634
Conversation
…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.
…+ Add tag wraps and its followers move
…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
|
/approve-spec 60d3e00 |
There was a problem hiding this comment.
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 stuckposition: sticky; bottom: 0footer around the change slide 51 px below the viewport, becauseflush()nets out only the area's ownscrollTopandisStuck()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
orderin the out-of-coverage list with a fresh/approve-spec.
[Automated review]
| const scrolledX = (batch.scrollBefore?.x ?? 0) - batch.area.scrollLeft; | ||
| const scrolledY = (batch.scrollBefore?.y ?? 0) - batch.area.scrollTop; |
There was a problem hiding this comment.
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.
| return ( | ||
| (Number.isFinite(top) && Math.abs(rect.top - (edge.top + top)) < 1) || | ||
| (Number.isFinite(bottom) && | ||
| Math.abs(rect.bottom - (edge.bottom - bottom)) < 1) | ||
| ); |
There was a problem hiding this comment.
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).
| if (rect.top > band.end) { | ||
| return false; |
There was a problem hiding this comment.
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.
|
/approve-spec 71abedd |
There was a problem hiding this comment.
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
bodysets a non-visible overflow (such asoverflow-x: hidden),scrollportOf()treatsbodyas 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; treatbodyas 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); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
|
/approve-spec d2dfc6f |
There was a problem hiding this comment.
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-rowplaces 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]
| 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); |
There was a problem hiding this comment.
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.
| if (rect.top > band.end) { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
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).
|
/approve-spec 51d65e7 |
There was a problem hiding this comment.
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]
# 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
Stacked on #8568 (the Centered column story uses the adopters). Retarget to
mainonce #8482 and #8568 land.User need
Two gaps in the glide, both found by measuring:
order, a change also moves the content BEFORE it. That content jumped, because the walk only looked at what came after.Problem and solution fit
orderfrom its out-of-coverage list, and FR3 and the coverage table name the earlier siblings.architecture:react-component-runtimeINV12 describes the walk's two directions and its bound. Both need an engineering owner's exact-head approval.Current specification
spec:AST-105FR3 (amended here), Coverage (a new row for siblings before the change;orderleaves the out-of-coverage list).architecture:react-component-runtimeINV12 (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:
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:
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
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;orderleaves the out-of-coverage list.architecture:react-component-runtimeINV12: the walk's two directions and its bound.Rubric: