Repository navigation
spec(AST-105): animated layout; architecture(react-component-runtime): its mechanism (INV12) - #8463
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report
No new or modified components detected. Bundle Size SummaryNo component packages changed. Accessibility AuditStatus: No accessibility violations detected. Visual Regression55 of 502 shot(s) changed. A change here is a question, not a failure: check whether the after is the
and 35 more. Generated by PR Enrichment workflow | View full report |
There was a problem hiding this comment.
This proposal waits on an engineering owner's decision on INV12: whether to adopt handle (c) from OQ1, and whether the travel applies on every pointer or only on a coarse primary pointer. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Because a new commit invalidates approval, please fix these in the record first so the approved head is final:
- The Reduce Motion sentence cross-fades on every pointer, but
design:reduced-motionDR3 requires an immediate change when the primary pointer is not coarse; defer to DR1–DR3 or scope INV12 to touch. ScrollAreais not a Core component; the scroll container isScrollableArea.architecture:public-component-apiINV21 is not onmainyet, so cite onlyspec:AST-002admission or land that change first.- Remove the "Proposed; pending owner decision" and "(proposed)" markers and collapse OQ1 into INV12 in the head that gets approved.
[Automated review]
| its own elements, and follows INV3 and INV5. The hook follows INV3 and | ||
| INV5. | ||
| - **INV12 — Disclosure height changes have one module and one handle.** | ||
| **Proposed; pending owner decision (OQ1).** In `@astryxdesign/core`, one |
There was a problem hiding this comment.
This marker, the INV12 (proposed) verification row and OQ1's options should come out in the head an owner approves; otherwise a current record merges a claim that calls itself pending.
| change and moves following content with a compositor-only translate from | ||
| its old place to its new one (`spec:AST-094` FR5); it never animates height, | ||
| which lays out every frame. What moves is designated, never found by walking | ||
| siblings: a scroll-area container, such as ScrollArea, is the boundary that |
There was a problem hiding this comment.
Core has no ScrollArea; its scroll container is ScrollableArea (component:ScrollableArea). The same name appears in the example at lines 186, 201, 202 and 205.
| around it. Under Reduce Motion nothing travels, and following content | ||
| cross-fades into place (`design:reduced-motion` DR2). The module and its hook |
There was a problem hiding this comment.
design:reduced-motion DR2 applies only on a coarse primary pointer or a touch release; DR3 requires an immediate change, with no animated property, otherwise. As written this cross-fades on every pointer. Suggest deferring to DR1–DR3 here and adding "an animated property under reduced motion with a fine pointer" to the verification row's failure signal.
| cross-fades into place (`design:reduced-motion` DR2). The module and its hook | ||
| are not exported; a scroll container an app owns is not a boundary, so | ||
| content in it keeps today's flow, and making a boundary public needs | ||
| `spec:AST-002` admission and `architecture:public-component-api` INV21. |
There was a problem hiding this comment.
architecture:public-component-api INV21 is not on main (that record ends at INV17); it exists only in an open proposal. Cite only spec:AST-002 admission here, or land INV21 first.
| // A disclosing element measures before its commit and animates after it. | ||
| const change = useDisclosureChange(groupRef); | ||
| const toggle = () => { | ||
| change.measureBefore(); |
There was a problem hiding this comment.
This takes the before-measurement only in the disclosure's own toggle. A CollapsibleGroup in its default single mode closes another item whose toggle never runs, and a controlled isOpen changes without this handler; a boundary-level snapshot would cover both.
|
Posted by Cindy's agent on her behalf. Cindy's decision on how following content is designated (OQ1): (c), with a generic boundary.
Since apps can provide a ref, the record should state the minimal public shape for that integration point and check it against the API principles. |
There was a problem hiding this comment.
This proposal waits on an engineering owner's decision on INV12: whether to adopt it with OQ2 settled as (ii), which makes useDisclosureParticipant public beside useDisclosureBoundary, and whether the travel applies on every pointer or only on a coarse primary pointer. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Because a new commit invalidates approval, please fix these in the record first so the approved head is final:
- The Reduce Motion sentence cross-fades on every pointer, but
design:reduced-motionDR3 requires an immediate change when the primary pointer is not coarse; defer to DR1–DR3 or scope INV12 to touch. ScrollAreais not a Core component; the scroll container isScrollableArea.architecture:public-component-apiINV21 is not onmainyet, so cite only current claims or land that change first.- Remove the "Proposed; pending owner approval", "INV12 proposes (ii)" and "is decided by … 2026-10-25" wording and the "(proposed)" row label, and fold the ruled OQ2 into INV12.
[Automated review]
| as one participant of the boundary around it. Under Reduce Motion nothing | ||
| travels, and following content cross-fades into place | ||
| (`design:reduced-motion` DR2). The handle (a container boundary with opt-in |
There was a problem hiding this comment.
design:reduced-motion DR2 cross-fades only on a coarse primary pointer or a touch release; DR3 requires an immediate change, with no animated property, otherwise. As written this cross-fades on every pointer. Deferring to DR1–DR3 here, and adding "any animated property under reduced motion with a fine pointer" to the INV12 row's failure signal, would fix it; so would scoping INV12 to a coarse primary pointer.
| translate from its old place to its new one (`spec:AST-094` FR5); it never | ||
| animates height, which lays out every frame. What moves is designated, never | ||
| found by walking siblings. A boundary is any container element registered by | ||
| ref: ScrollArea registers its scrolling element itself, and an app registers |
There was a problem hiding this comment.
Core has no ScrollArea; its scroll container is ScrollableArea (component:ScrollableArea). The usage comment at line 194 repeats the name.
| (ii), `useDisclosureParticipant(elementRef)`: each takes a ref and returns | ||
| nothing, because which container scopes the motion and which app content | ||
| moves with it are the caller's intent (`spec:AST-002` FR1), and no other API | ||
| does either job (`architecture:public-component-api` INV21). The change hook |
There was a problem hiding this comment.
architecture:public-component-api INV21 is not on main (that record ends at INV17); it exists only in an open proposal. Cite only current claims here, such as spec:AST-002 FR1 and FR5, or land INV21 first.
| its own elements, and follows INV3 and INV5. The hook follows INV3 and | ||
| INV5. | ||
| - **INV12 — Disclosure height changes have one module and one handle.** | ||
| **Proposed; pending owner approval of the public hook.** In |
There was a problem hiding this comment.
This marker, "INV12 proposes (ii)" (line 172), "is decided by cixzhang, 2026-10-25" (lines 182–183) and the INV12 (proposed) row label (line 358) should come out of the head an owner approves, with the ruled OQ2 folded into INV12; otherwise a current record merges proposal and decision history (architecture:knowledge-contracts INV10).
There was a problem hiding this comment.
This proposal waits on an engineering owner's decision on INV12: whether to adopt it with OQ2 settled as (ii), which makes useDisclosureParticipant public beside useDisclosureBoundary, and whether the travel applies on every pointer or only on a coarse primary pointer (OQ3). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Because a new commit invalidates approval, please make these changes first so the approved head is final:
- Remove "Proposed; pending owner approval", "INV12 proposes (ii)", "is decided by … 2026-10-25" and the "(proposed)" row label, and fold the OQ2 and OQ3 rulings into INV12 (
architecture:knowledge-contractsINV10); the description's "R1 narration: none" does not match the record. - Add a fine-pointer Reduce Motion case to the INV12 verification row, since INV12 now requires an immediate change there (
design:reduced-motionDR3). - State the
spec:AST-002FR5 admission argument for each hook: the two otherwise identical cases and why Core cannot derive the outcome. - For OQ2, note that under (ii) unregistered content after the participants takes its final place at once, so it overlaps them while they slide back on a collapse.
[Automated review]
| | INV9 | Provider/external-store identity and render-count tests | Unchanged semantic payload changes identity, or snapshot identity creates a render loop | | ||
| | INV10 | Shared test over every adopter of the announcement module | An adopter holding its own Effect, observer, timer, or listener for its own-content announcement | | ||
| | INV11 | Source boundary check plus hook tests across StrictMode replay and unmount | A second keyboard listener or measurement in Core; a consumer reading a CSS custom property on the root or a shared ancestor for the height; an export of the hook; a component outside Core measuring by a rule other than AST-099 FR2, or riding the keyboard; a listener left after unmount; a Core rider driven by JavaScript per frame or by its own transition; or more than one layout read per keyboard change | | ||
| | INV12 (proposed) | Real-browser tests on a throttled phone profile plus a source boundary check, over a disclosure inside a boundary with participants, non-participants, a sticky header, a nested boundary, content outside, and Reduce Motion | A per-frame height or layout, a sibling walk, a moved non-participant, an added element, or travel under Reduce Motion fails | |
There was a problem hiding this comment.
This row tests only a throttled phone profile and fails only on "travel under Reduce Motion". INV12 now also requires an immediate change with a fine pointer (design:reduced-motion DR3: "MUST complete immediately, with no animated property"), and OQ3 recommends travel under every pointer, so a cross-fade or a travel with a mouse would pass. Suggest adding a fine primary pointer state and "any animated property under Reduce Motion with a fine pointer" to the failure signal, plus a fine-pointer travel case if OQ3 lands on (a).
| The public pieces are `useDisclosureBoundary(containerRef)` and, under OQ2's | ||
| (ii), `useDisclosureParticipant(elementRef)`: each takes a ref and returns | ||
| nothing, because which container scopes the motion and which app content | ||
| moves with it are the caller's intent (`spec:AST-002` FR1), and no other API | ||
| does either job. The change hook |
There was a problem hiding this comment.
spec:AST-002 FR5 asks a public-API proposal to name "the two otherwise identical cases, the different required outcomes, why the caller knows the difference, and why the component cannot derive it". This gives caller intent under FR1 (written for props) and "no other API does either job", but not the two cases or why Core cannot derive the outcome, and OQ2's own option (i) derives what moves from layout (FR2). Stating that argument for each hook would let the owner rule OQ2 on it.
|
Posted by Cindy's agent on her behalf. Cindy's decision on mixed following content (OQ2): (i), a sibling walk bounded by the container ref.
Still open: whether the glide applies under every pointer or only under touch. |
b651643 to
f1747db
Compare
|
Posted by Cindy's agent on her behalf. Cindy's decision on the name (OQ4):
The internal trigger is |
|
Posted by Cindy's agent on her behalf. Cindy's decision on pointer scope (OQ3): every pointer, mouse included. "Yes, a layout fix." This isn't a touch gesture: mouse users saw the same jump. It's recorded as a named desktop change under The changed element animates itself, and the content after it follows with matching timing and easing. |
|
Posted by Cindy's agent on her behalf. Cindy's decision on which elements move (OQ5): only elements whose position actually changed and that are visible in the area. The walk collects candidates; offscreen ones take their final place without animating. It descends into With this, every open question on
|
There was a problem hiding this comment.
This record change waits on an engineering owner's decision on INV12, including what happens when a changed element has no registered area above it, such as a Collapsible on a page that scrolls the document; an engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Because a new commit invalidates approval, please make these record fixes first so the approved head is final:
- Lines 190–192 cite a layout-change role from
spec:AST-094FR4, which names no such role and covers touch only; add the role through its owner first or cite the roles and tokens that exist. - Remove "are decided by
cixzhang, 2026-10-25" and "TreeList and Collapsible adopt the module first" (lines 206–210), perarchitecture:knowledge-contractsINV10. - Make the hook documentation at line 217 say "Fixed and stuck sticky elements stay put" and "motion role", matching the coverage table and the authoring guidance.
- Give rows 422–425 a fine-pointer Reduce Motion case and a theme-override case and scope the jump signal to visible followers; also carve
display: contentsout of line 182, state thespec:AST-002FR5 argument foruseAnimatedLayout, and add INV12 to the review-applicability block.
[Automated review]
| An area is any container element registered by ref: ScrollableArea | ||
| registers its scrolling element itself, and an app registers its own scroll | ||
| container or panel with `useAnimatedLayout(containerRef)`. An element whose | ||
| size changes reports the change, around its commit, to its nearest |
There was a problem hiding this comment.
What happens when the changed element has no registered area above it, such as a Collapsible on a page that scrolls the document outside any ScrollableArea? Lines 189–190 have the element play its own resize unconditionally, so if its followers then take their final place at once they overlap it on a collapse and leave a gap under it on an expand. The record should state the no-area behavior: the document as the area, or the element taking its final size at once.
| ancestor to the area: at every level, each following in-flow sibling moves | ||
| by the distance layout moved it, measured before and after. The walk visits | ||
| only the following element siblings at each ancestor level, through | ||
| `parentElement` and `nextElementSibling`, never descending into a subtree, |
There was a problem hiding this comment.
"never descending into a subtree" conflicts with the coverage row at line 249, which takes a display: contents element's children in its place. Carving that case out here keeps both sentences true.
| area's visible part, so the setup cost is bounded by what the person can | ||
| see. Which elements move is listed under Size-change coverage below. The changed element plays its own animation, entering, exiting, or | ||
| resizing, and the module moves only the content after it. Both read the | ||
| same generic theme motion role, the layout-change role of the motion scheme |
There was a problem hiding this comment.
spec:AST-094 FR4 lists no layout-change role (its table names only springs.* and timing.* roles), FR4 covers touch presentations only, and AST-094's Non-goals keep pointer timing on the theme's duration and easing tokens. Add the role through its owner first, with a measured default and source, or cite the role and token that time each pointer; the INV12 timing row (line 424) depends on it.
| a named fine-pointer change under `spec:AST-017` FR59, and its adopting pull | ||
| requests show fine-pointer evidence before and after. The container area, | ||
| the bounded walk, the changed-and-visible rule, every-pointer scope, the | ||
| shared motion role, and the name `useAnimatedLayout` are decided by |
There was a problem hiding this comment.
This attribution, with "TreeList and Collapsible adopt the module first" on lines 209–210, is decision and project history in a current record (architecture:knowledge-contracts INV10: "a record states the fact a change established, not the change"; rubric R1). Keeping only "any Core component whose size changes in place uses it rather than its own layout animation" states the durable rule.
| changes in place uses it rather than its own layout animation. | ||
| `useAnimatedLayout` is the one public piece: it takes the container's ref | ||
| and returns nothing, because which container scopes the motion is the | ||
| caller's intent (`spec:AST-002` FR1), and no other API does that job. |
There was a problem hiding this comment.
spec:AST-002 FR5 asks a public-API proposal to name "the two otherwise identical cases, the different required outcomes, why the caller knows the difference, and why the component cannot derive it", and FR2 keeps a difference internal when Core can derive it from measured or CSS layout, such as the nearest scroll container. Stating that argument here lets the owner admit useAnimatedLayout on it.
| caller's intent (`spec:AST-002` FR1), and no other API does that job. | ||
| `reportLayoutChange` stays internal. Its documentation reads: "When | ||
| something inside this area changes size, the changed element and the content | ||
| after it animate together on one shared motion token. Fixed and sticky elements stay put, Reduce Motion is |
There was a problem hiding this comment.
"Fixed and sticky elements stay put" contradicts the coverage row at line 251 (an unstuck sticky element moves) and the guidance at line 279 ("stuck sticky elements stay put"); this text also says "token" where INV12 says role, and omits the nesting and scroll-position points the guidance at lines 277–282 says the documentation states.
| | INV9 | Provider/external-store identity and render-count tests | Unchanged semantic payload changes identity, or snapshot identity creates a render loop | | ||
| | INV10 | Shared test over every adopter of the announcement module | An adopter holding its own Effect, observer, timer, or listener for its own-content announcement | | ||
| | INV11 | Source boundary check plus hook tests across StrictMode replay and unmount | A second keyboard listener or measurement in Core; a consumer reading a CSS custom property on the root or a shared ancestor for the height; an export of the hook; a component outside Core measuring by a rule other than AST-099 FR2, or riding the keyboard; a listener left after unmount; a Core rider driven by JavaScript per frame or by its own transition; or more than one layout read per keyboard change | | ||
| | INV12 | Real-browser tests on a throttled phone profile plus a source boundary check, over a disclosure and an inserted item nested several levels inside a parent with following siblings at each level, mixed Astryx and plain elements, a fixed element, an unstuck sticky header, a nested boundary, content outside the boundary, two disclosures toggled in one frame, a 10-level by 50-sibling tree for cost, and Reduce Motion | A per-frame height or layout, a walk past the boundary, a moved out-of-flow element, a following sibling at any level that jumps, an element moved twice for one frame, an added element, or travel under Reduce Motion fails | |
There was a problem hiding this comment.
This row tests only a throttled phone profile and fails only on "travel under Reduce Motion", so a cross-fade with a mouse (design:reduced-motion DR3: "MUST complete immediately, with no animated property") or a theme override of the role that restores the glide (DR4) passes every INV12 row. "A following sibling at any level that jumps" also fails the offscreen and inline followers that lines 186–189 and 264–265 send to their final place at once; scoping it to visible, in-coverage followers fixes that.
|
Posted by Cindy's agent on her behalf. Cindy's decision on the API shape: const animatedLayout = useAnimatedLayout({containerRef});
<TreeList animatedLayout={animatedLayout}>…</TreeList>
|
…s; move by measured change (OQ5)
…s; move by measured change (OQ5)
…ements in the visible area
…-of-coverage list
…x order candidates, and scaled ancestors
…; additive retarget; scale correction; plain out-of-coverage list
…documentation points for the hook
…(decided), with its documentation; restore INV12's batching, INV7, Reduce Motion, and adoption text
…tself; followers match its timing via reportLayoutChange(ref, motion)
…s an AST-017 FR59 named fine-pointer change
…g; reportLayoutChange returns the motion the element plays
… element and its followers; reportLayoutChange only triggers
…f}) returns a handle passed as animatedLayout; ScrollableArea gets the ref; CodeBunny consistency
…ll position kept; no unmerged citations; OQ6 motion role, OQ7 ownership
…e names a layout role; this record owns the hook; scroll position is tested
… the module never scrolls; scope to reported changes; drop the TreeWalker note
…m spec; INV12 keeps the mechanism
…s; runtime lists AST-105/DEC-1
a96e822 to
0c0c8f4
Compare
|
/approve-spec 0c0c8f4 |
There was a problem hiding this comment.
Both records carry an engineering owner's approval of this revision, and the four record fixes from the last review are in, so this looks ready to land. When TreeList, Collapsible or another component adopts animatedLayout, that pull request owes the fine-pointer before-and-after evidence FR5 names. If you touch AST-105 again, ending FR4's Reduce Motion sentence at the design:reduced-motion DR1–DR3 reference and adding a fine-pointer Reduce Motion failure signal to its verification row would tighten it.
[Automated review]
…an item in a long list (#8629) * feat(core): useAnimatedLayout, one module that glides the layout shift 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. * feat(core): useAnimatedLayout({containerRef}) returns a handle that Collapsible 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. * fix(core): the layout glide corrects only a real ancestor scale, not 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. * chore(storybook): the Animated layout story's tags row is narrow, so + Add tag wraps and its followers move * Merge 8ba53b4 (main, AST-105 current) into the animated layout draft * wip(core): animated layout follows AST-105 (springs.settle, Reduce Motion by pointer, visible-part rule for the changed element, top-layer and hidden skipped, chevron on the spring) * feat(core): useAnimatedLayout follows AST-105 - 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. * chore(storybook): the story's internal-reporting demos say they are story-only * chore(docsite): the ShadCN route lock lists the useAnimatedLayout hook page * fix(core): the animated layout reads springs.settle from the internal 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. * test(core): the animated-layout browser proofs settle between changes 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. * fix(core): animated layout measures every grouped change, drops refused 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. * fix(core): a retargeted glide keeps its motion and adds the new move 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. * docs(architecture): INV12 plays the glide through the shared rectangle morph once Core has it, its own compositor translate until then * docs(architecture): rewrap INV12 * refactor(core): useAnimatedLayout lands alone; Collapsible and TreeList 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. * feat(core): Collapsible and TreeList adopt useAnimatedLayout 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. * feat(core): the changed element's exit, held out of flow on the same 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. * Merge the module's exit (feat/animated-layout) into the adoption * feat(core): a Collapsible's or TreeList row's close plays the content'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. * test(core): the exiting group's rows leave the tree's keyboard order * chore(changeset): Collapsible and TreeList adopt animatedLayout * fix(core): the internal module alone; a displaced changed element moves 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. * Merge feat/animated-layout into the adoption; the public hook and the 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. * fix(core): the exit frees exactly its space and is inert; elements the 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. * Merge feat/animated-layout (an exact, inert exit; 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). * fix(core): the exit measures the content after it, holds only a measured 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. * Merge feat/animated-layout (the exit measures the content after it and holds only a measured close) into the adoption * fix(core): nested changes compose in any order; the exit finds the real 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. * Merge feat/animated-layout (nested changes in any order; the exit's next box and container checks) into the adoption * fix(core): the exit's correction follows a scaled ancestor, and its measurement keeps CSS animations running - The space correction is read in screen px and the margin is in the element's own: it is now divided by the element's scale on that axis, so an exit under a scaled ancestor frees exactly its space (AST-105 Coverage). - The layout without the element is read with it taken out of flow by a temporary position: absolute, not display: none, which restarted the CSS animations inside it. - Real-browser cases: a gap stack under a scaled ancestor, and a spinner inside the closing element that keeps its time with no animationstart. * Merge feat/animated-layout (scale-corrected exit; measured out of flow) into the adoption * fix(core): Collapsible and TreeList report every change, whatever caused it spec:AST-105 FR1: the component reports each change. A click was the only path measured; an owner-set isOpen, a group's controlled value or isExpanded changed in items landed at once. Both components now measure in the commit itself, before React changes the DOM, through a small internal LayoutChangeSnapshot (getSnapshotBeforeUpdate): it runs only for a render that commits, once under StrictMode, and keeps render pure. The exit state is derived from the open state as it changes, during render, so any close keeps the content for its exit; the chevron takes the spring on every change. CollapsibleGroup no longer needs its measurement registry: each item measures its own change. Also: the set-state-in-effect reasons name the layout reads holdLayoutExit decides from, the Verification rows fill every column, and a browser spec renders the Core/Animated layout story (a ScrollableArea ref, Collapsible and TreeList opening and closing). Tests: an owner-set isOpen, a group's controlled value and isExpanded in items glide; one measurement in StrictMode and none for a refused change. * test(core): the adopters' browser spec serves Storybook through a11y-spec, a dependency core already has The interaction harness is not one of core's dependencies, so importing it broke the package typecheck. * fix(core): a TreeList row collapsing with an expanded child moves what follows once; the chevron drops its spring without a handle - A changed row inside another changed row (a child left expanded when its parent collapses) is not reported as its own change: only the outer group exits and gives up its space, the child's group leaves with it when that exit ends, and the content after them moves once, with no jump back (AST-105 FR3). - Without a handle, Collapsible's and TreeList's chevrons drop the spring a handle set, so they keep the theme's own turn (AST-105 FR2). - Tests: a nested collapse holds only the outer group (jsdom) and moves the rows after it once with no jump (real browser, the story's Photos with Trips open); removing the handle clears the chevron's spring. * docs(core): Collapsible FR10 and TreeList FR8 cite AST-108 FR4a for the chevron The chevron turning while the content glides is a coordinated pair: it derives its transition from the same springs.settle motion, as AST-108 FR4a (proposed) asks of coordinated animations. * feat(sandbox): Animated Layout Perf, a fullscreen page measuring useAnimatedLayout in a long list Cindy asked for a perf test of useAnimatedLayout for an item in the middle of a long list. The page follows Markdown Perf's layout: heading and description, a muted toolbar, a Run metrics card beside the output. A list of 1k, 5k or 10k rows (every row rendered, or a virtualized window) has a Collapsible in the middle that takes the list's handle; a run expands and collapses it six times and reports the commit and walk until the glide starts, rectangles read, elements animated, time to the first frame, long tasks, frames and drops, each against a saved baseline. Sandbox only: no package change. * fix(sandbox): Animated Layout Perf toggles only the middle item, so a run measures its commit and the walk, not a list re-render The open state lived on the page, so each toggle re-rendered every row (10k rows: most of the 275 ms). The middle item now owns it, and the rows are built once per list and window. * feat(sandbox): Animated Layout Perf compares a run without the handle, the browser's own layout cost A Glide control (With handle / No handle) runs the same list without useAnimatedLayout, so the walk's share of a toggle is the difference. * fix(sandbox): Animated Layout Perf keeps baselines across reloads, reports nothing for a stopped run, and takes a true median - Saved baselines go to localStorage per rows, rendering and glide, so the reload a build change needs keeps them (read after hydration). - A stopped run publishes no metrics, so a partial run can neither read as a full one nor be saved as a baseline. - The median of an even count is the mean of its two middle values: a run's three opens and three closes both count. - The middle item's copy says what moves (the rows after it that are in view) and what the page measures. * fix(sandbox): Animated Layout Perf charges the browser's layout to both modes, and a saved baseline reads as saved after a reload - An uncounted layout read closes each toggle's clock, so No handle times the browser's own style and layout for the change too, and with the handle minus without is the walk alone. - A metric reads no baseline only when none is saved for the configuration; before a run it reads baseline saved. * Merge origin/main into feat/animated-layout-perf The base branches (#8482, #8568) merged to main as squashes, so every file they touched takes main's version, and their already-released changesets go. This PR's own three sandbox files are unchanged; the sandbox route list keeps main's new entry beside this page's. * sandbox(animated-layout-perf): show no long-task reading without the Long Tasks API Firefox has no Long Tasks API, so the page reported 'Long tasks 0'. It now shows no reading, as DiffViewer Perf does. * sandbox(animated-layout-perf): use the perf pages' existing format and long-task helpers The page imports formatDuration and formatCount from DiffViewer Perf, formatBenchmarkChange from Markdown Perf, and watchLongTasks, which moves from DiffViewer Perf's page into its benchmark module because a Next.js page exports only page members. formatBenchmarkChange gains optional empty-state labels (default '—', so Markdown Perf is unchanged), which keep this page's 'no baseline' and 'baseline saved'. The Virtualized comment now says the middle item renders only inside the window.
Decided (Cindy, 2026-10-25): OQ1–OQ5 all resolved.
Exact decision needed
How does Core designate the content that moves when a disclosure changes height (OQ1)? Strategy is fixed by Cindy (16:12): measure before and after, translate on the compositor.
Existing authority searched
architecture:react-component-runtimeINV10 and INV11 (one internal module per cross-component behavior), INV3 and INV5.spec:AST-094FR5 (compositor-only motion),design:reduced-motionDR2 (cross-fade),architecture:component-style-authoringINV7 (no added elements).architecture:public-component-apiINV18–INV21 (architecture(public-component-api): four API-shape principles (INV18–INV21) #8459, approved): INV21 for any public boundary.architecture:layer-runtimeis for layers, so it is not the home; this is a runtime module like INV10/INV11.Decision
Split per knowledge-contracts INV18 (chief OK, 2026-10-25): the public contract (hook,
animatedLayoutprop, glide behavior, coverage table) is a new system spec,spec:AST-105;architecture:react-component-runtimeINV12 keeps only the mechanism (measure, walk, translate, internal reporting function), as INV10/INV11 project AST-086/AST-099. The text is unchanged apart from the move.architecture:react-component-runtimeINV12:useAnimatedLayout(containerRef). A disclosure reports to its nearest registered ancestor boundary, which moves only participants after it.Reframe: any size change (Cindy 19:14)
INV12 is a general size-change (layout-shift) module: disclosures, async content loading in, messages appearing, items inserted or removed, text expanding. Names are generic: public
useAnimatedLayout(containerRef: RefObject<HTMLElement>): void(ScrollableArea registers itself); internalreportLayoutChange(ref)(not exported).hasAnimatedChanges, feat(List): animate row inserts, removals and moves through the animatedLayout handle #8106), Textarea autosize, Skeleton → loaded content.hasAnimatedChangesFLIP (feat(List): animate row inserts, removals and moves through the animatedLayout handle #8106, not on main) shares the job and should adopt this module instead of its own.motion/rectMorph.tsis the morph INV15 names; the module reuses it.parentElementandnextElementSibling, visiting only following element siblings at each ancestor level, O(depth + following siblings), never descending into subtrees. A TreeWalker visits subtrees and text nodes unless filtered, and text nodes don't move. Lane 1 measured the manual walk 3–5× cheaper than a TreeWalker.API shape (Cindy 20:18)
useAnimatedLayout({containerRef})returns an opaque handle (no public methods or fields); size-changing components take ananimatedLayoutprop and report through it. Explicit composition replaces implicit registration; reporting is an internal method on the handle.architecture:theme-tokensINV3 (the layout role name waits for the motion scheme); decision and rollout notes removed per knowledge-contracts INV10; JSDoc says "stuck sticky" and "motion role"; fine-pointer Reduce Motion and visible-follower checks;display: contentscarve-out; INV12 in the review-applicability block.What the hook does (Cindy 19:15); name decided 19:28:
useAnimatedLayoutAll the logic (measure, walk, translate) lives inside one internal module. The public hook only marks the area where the walk stops; the internal
reportLayoutChange(ref)only triggers it. Name (OQ4), each for what it does:useSmoothLayoutShift(ref)useGlideOnResize(ref)(runner-up)useAnimatedLayout(ref)(DECIDED, Cindy 19:28: "pick the one that has the least specialized words")useAnimatedReflow(ref)useDisclosureAnimatedShift(ref)Authoring guidance now lists the points every agent was unsure about: only content after the change moves; fixed and stuck sticky stay put; Reduce Motion handled; List rows use the same module; nesting stops at the nearest area; scroll position kept.
Timing and pointer scope (decided)
reportLayoutChange(ref)only triggers the walk and lines up the start frame; Reduce Motion reaches both through the role.Element selection (Cindy 19:16)
The walk collects candidates; recommended (OQ5 a): only candidates whose layout rectangle changed between the two measurements move.
display: contentsdisplay: none, hidden, zero sizeorder, wrap, floatsMixed following content (OQ2, decided: (i))
Cindy (19:11): "I think a sibling walk on the container ref would be how we do it. It needs to account for the disclosure existing within a parent that has siblings in the container, and arbitrary nesting between the disclosure and the container."
useAnimatedLayout(containerRef: RefObject<HTMLElement>): void; ScrollableArea registers itself.useDisclosureParticipantis removed.Walk cost (lane 1, final; 4× CPU Chromium, mobile viewport, software GPU):
Fixture findings folded into the coverage table:
display: contentschildren need explicit handling; inline elements cannot take a transform (they take their final place, their block moves); flex items placed later byordermust be candidates; a scaled ancestor needs a scale correction; elements mid-animation retarget (additive composite in the implementation).Cost rule (in INV12): animate only elements whose rectangle changed and that intersect the boundary's visible area; offscreen elements take their final place at once. Setup is about 0.05 ms per animated element, so the cost is bounded by what is on screen. No per-level wrappers (rejected 19:09).
Correctness fixtures (pure sibling rule vs move-only-changed) are next from lane 1.
Perf
Headless Chromium on a server CPU; a disclosure with 400 following rows of wrapped text; forced layout timed over 60 frames, median of 15 runs (three runs agree):
On a 4–6× slower phone that is roughly 0.1–0.2 ms per frame either way, so main-thread layout is not where the approaches differ. These numbers do not capture paint and raster: a height animation repaints the moved region every frame, while a translate is composited (AST-094 FR5). That is the reason to measure once and translate.
A demo view exists for owner review.
First adopters
TreeList (groups expand inside its scroll area) and Collapsible.
Rejected alternatives
Writing rubric results
Scope
Validation
pnpm check:knowledge(CI lint)git diff --check