Skip to content

chore(core): the internal module for size-change glides (spec:AST-105, INV12) - #8482

Merged
cixzhang merged 22 commits into
mainfrom
feat/animated-layout
Oct 27, 2026
Merged

cixzhang merged 22 commits into
mainfrom
feat/animated-layout

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

User need

When something inside a scroll container or app panel changes size (a folder in a tree opens, a settings section expands), the content after it jumps to its new place. People lose their place in a list, on a phone and with a mouse alike. An app can't fix this by composition: the content after the change belongs to other components and the app's own elements, and none of them know a size change happened above them.

Problem and solution fit

  • useAnimatedLayout({containerRef}) returns one opaque handle per area. A component whose size changes takes it and reports each change through it.
  • The content after the change, up to the area, glides from its old place to its new one instead of jumping. The changed element plays its own entrance or exit on the same motion.
  • This pull request is the internal module only: nothing public. The public useAnimatedLayout hook and Collapsible's and TreeList's animatedLayout prop arrive together in feat(core): useAnimatedLayout, and Collapsible and TreeList glide the content after them (spec:AST-105) #8568, which is stacked on this one, so no public API ships before a component can take the handle.

Current specification

  • spec:AST-105 (current): FR1–FR7, Coverage and Verification.
  • architecture:react-component-runtime INV12: one internal module, a compositor-only translate (through the shared rectangle morph once Core has it; this module's own compositor translate until then, as amended here), a walk through parentElement / nextElementSibling, and changes batched per area per frame.
  • One shared internal motion role, springs.settle (spec:AST-094 FR4), read from the internal role registry. Roles are internal (spec:AST-094 DEC-11): no theme, scope or custom property overrides it.
  • Reduce Motion follows design:reduced-motion DR1–DR3. This is a named fine-pointer change under spec:AST-017 FR59 (spec:AST-105 FR5).

Public delta and caller burden

None: the module is internal and unexported, and no component uses it yet. The public hook and prop are in #8568.

Design and alternatives

  • Measuring: the area is measured before the change (in the handler that causes it) and after it commits. All changes in one area in one frame are measured together, so each element moves once, by its total change. A measurement no commit claims by the next frame (a refused change) is dropped, and animate() reports whether the change was measured, so a component lands an unmeasured change at once.
  • The changed element: its entrance reveals downward from its top edge (playLayoutChange). Its exit (holdLayoutExit) keeps it where it is, in flow and inert, while it gives up exactly its space: the end margin is measured against the layout with the element taken out of flow (so CSS animations inside it keep running) and corrected for a scaled ancestor, so gaps and collapsing margins come out exact, and the content after it glides up at once while it still scrolls and clips with its container. It conceals upward on the same motion before the component hides it. Only a measured close is held. Where no margin can free the space (a grid item whose track or gap changes, or a flex-row item that sets its row's height), the element hides at once without its exit and the content after it takes its final place at once, as AST-105's out-of-coverage list now names. A changed element that an earlier change in the same frame displaced moves with its row, by its total change, and nested changes compose whichever is reported first.
  • The walk: the following element siblings at each ancestor level up to the area. It descends only into display: contents.
  • What moves: an element moves only when its rectangle changed and it is in the area's visible part. Absolute, fixed, top-layer, hidden and zero-size elements are skipped; an absolute child rides along with its moving parent. An element the app moves itself (its own transform, translate, rotate or scale, or a transform animation the module did not start) takes its final place at once.
  • How it moves: one replace-composited translate, which the compositor runs (INV12), sampled from springs.settle into linear keyframes and corrected for a scaled ancestor. An element already gliding keeps that motion and adds the new move: the rest of its running curve and the new move are summed into one new glide, so it keeps its speed with no jump. Every animation of a change starts on the document timeline's current time. The glide, the entrance and the exit all read the one internal springs.settle value. Height is never animated, and the area is never scrolled.
  • WebKit safety: the glide uses only linear easing over sampled keyframes and replace composition, which every Web Animations engine takes.
  • Rejected (spec:AST-105 DEC-1): wrapping the following content in an element, or opting in each following element.

Evidence

The module's proof is its real-browser spec (below): one case per spec:AST-105 Coverage row plus INV12's throttled-phone case, run in real Chromium. The fine-pointer before-and-after images spec:AST-105 FR5 asks of adopting pull requests are on #8568 (Collapsible and TreeList, open and close).

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

This introduces a new behavior module (motion/animatedLayout.ts). The adoption is split into #8568.

Testing

  • animatedLayout.test.ts (jsdom, 29 tests): the handle has no members; nothing runs without a handle; the walk at every level and nothing past the area; display: contents; absolute, fixed, hidden and display: none skipped; unchanged and out-of-view elements left alone; two changes in one frame move once by the total; the area is never scrolled; one duration from springs.settle, unchanged by a scope's --motion-springs-settle; Reduce Motion (a cross-fade on a coarse pointer, at once on a fine one); a running glide continued with its speed plus the new move; a refused measurement dropped by the next frame; an unmeasured change moving nothing; the exit kept in place, inert, giving up exactly its space (a gap included), landing at once where a grid track can't free it, refusing an unmeasured close, putting back the element's own inert and inline values, a refused hold keeping its own margin and gliding nothing, concealed, handed back, stopped by a reopen, and landing at once or fading under Reduce Motion; a changed element an earlier change displaced moving by its total change; an element not rendered before a change not moving; app-moved followers left alone.
  • animatedLayout.boundary.test.ts: INV12's source boundary check (no layout property animated; the walk only through parentElement / nextElementSibling, entering display: contents alone; nothing public; nothing uses the module yet).
  • animatedLayout.interaction.browser.spec.ts (the interaction proofs, Chromium and WebKit; run locally in real Chromium: 32 of 32 pass, three repeats): one case per spec:AST-105 Coverage row (the sticky row with a stuck header after the change, the flex row with an item that wraps and a row-direction neighbor whose rectangle does not change); INV12's throttled-phone case (a 10-level by 50-sibling tree, two changes in one frame: one move per follower, none inside a subtree, none past the area, no height); FR2; FR3 (two changes in one frame, the displaced changed element included; scrolling identical with and without a handle); FR4 (one start frame and duration, the exit while the content after glides up, the exit in a scrolled area staying in place, scrolling and keeping its width, the exit freeing exactly its space in a flex gap, with collapsing margins, before a display: contents wrapper and as the last child of a plain wrapper (Collapsible's shape), a tall flex-row item refused with nothing gliding, nested changes reported inner-first, a gap stack under a scaled ancestor, a spinner inside the closing element keeping its time, app-moved followers left alone, a scope's --motion-springs-settle changing nothing, Reduce Motion on each pointer); FR5.
  • pnpm -F @astryxdesign/core typecheck, eslint, pnpm check:repo, pnpm check:knowledge; motion and hooks tests: 956 pass.

Writing rubric results

Records touched:

  • architecture:react-component-runtime INV12: the glide plays through the shared rectangle morph once Core has it, and its own compositor translate until then.
  • spec:AST-105: one out-of-coverage line for a closing element whose space no end margin frees (a grid item whose track or gap changes, or a flex-row item that sets its row's height).

Rubric:

  • R1 narration: none. Both state what the system does in the present tense, with no change history.
  • R2 research: none.
  • R3 evidence: no evidence cell changed. The new line's behavior is covered by animatedLayout.test.ts (the grid fallback and refused-hold cases) and the tall flex-row browser case.
  • 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.
@vercel

vercel Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 26, 2026 10:19pm UTC

Request Review

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 26, 2026
@astracat-bot

astracat-bot Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 26, 2026, 10:42 PM UTC

…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.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 26, 2026
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Previews ready: Storybook and Sandbox were built with this PR's docsite. CI evidence will be added when available.

View Storybook for this PR · View Sandbox for this PR

…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.
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 26, 2026
@cixzhang cixzhang changed the title feat(core): useAnimatedLayout glides the content after a size change (draft) feat(core): useAnimatedLayout glides the content after a size change (spec:AST-105) Oct 26, 2026
… 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.
@cixzhang cixzhang changed the title feat(core): useAnimatedLayout, the one module for size-change glides (spec:AST-105) chore(core): the internal module for size-change glides (spec:AST-105, INV12) Oct 26, 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.

Thanks for narrowing this to the internal module: without a handle nothing changes, and the follower glide, the entrance and Reduce Motion check out against AST-105 in the browser tests. Approval waits on a spec decision: the INV12 change (the module plays its own compositor translate until Core has the shared rectangle morph) amends a current invariant, so an engineering owner needs to record the decision by commenting /approve-spec a09ce7412eb240ebb1e5f335c60f70df4c985d67 on this pull request (the author may do so as an engineering owner), or the module waits for the shared morph. Independently of that decision, these need changes:

  • holdLayoutExit gives back the border box plus margin-top, so in a gap container or under collapsed margins the content after a closing element ends 8–10 px off and jumps when the element is hidden (animatedLayout.ts:662-691, AST-105 FR3).
  • Elements the app moves with its own transform, translate or animation are pulled into the glide, where AST-105's Coverage says they take their final place at once.
  • A held element stays focusable and readable by screen readers while it conceals, and nothing says who makes it inert.
  • At 2,241 authored lines, the size is covered by the verification AST-105 and INV12 require only once the INV12 change is approved; otherwise it needs to come under 2,000.

[Automated review]

through the shared rectangle morph (`architecture:layer-runtime` INV15;
`spec:AST-094` FR5); it never animates height. Its walk visits only the
following element siblings at each ancestor level up to the area, through
`spec:AST-094` FR5) once Core has that morph; until then, the module plays

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 rewords INV12, which on main requires the glide to play through the shared rectangle morph (architecture:layer-runtime INV15), and Core has no such morph yet. The module's own translate depends on this interim, so it needs an engineering owner's /approve-spec with the full head SHA, or the module waits for the morph.

Comment on lines +675 to +691
if (isRow) {
const inlineStart =
style?.direction === 'rtl' ? 'margin-right' : 'margin-left';
const start =
Number.parseFloat(style?.getPropertyValue(inlineStart) ?? '') || 0;
element.style.setProperty(
'margin-inline-end',
`${-(element.offsetWidth + start)}px`,
);
} else {
const start =
Number.parseFloat(style?.getPropertyValue('margin-top') ?? '') || 0;
element.style.setProperty(
'margin-block-end',
`${-(element.offsetHeight + start)}px`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The border box plus margin-top is the space the element frees only in plain block flow. With gap: 8px (flex or grid) the row after a closing element glides to 8 px short of its place and jumps up when the element is hidden; with a 20 px margin above and a 10 px margin-top here it overshoots by 10 px and jumps down (AST-105 FR3). Measuring the first follower's place with the element hidden, in the same layout effect before paint, gives the real amount; a grid track's gap needs another route, since a margin cannot free it.

Comment on lines +470 to +477
// The element's own `translate`, which the glide plays on top of; a
// value in other units cannot be offset in px, so it lands at once.
const own = parseTranslate(
view?.getComputedStyle(element).getPropertyValue('translate') ?? '',
);
if (own == null) {
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AST-105's out-of-coverage list says "elements the app moves itself with its own transforms" take their final place at once, but here the glide plays on top of the element's own translate, and nothing checks transform or animations the app runs: a row with its own running transform animation, a static transform and a static translate each get a glide. Skipping followers whose computed transform or translate is not none, or that run animations this module did not start, would match the record.

`${-(element.offsetHeight + start)}px`,
);
}
element.style.setProperty('pointer-events', 'none');

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 pointer events are blocked, so for about 735 ms a keyboard user can Tab into content that has visually closed and a screen reader still reads it; focus inside it is lost when the component hides it. Setting inert while the exit holds the element (and removing it in releaseLayoutExit), or stating that the adopting component does, would define it.

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

Copy link
Copy Markdown
Contributor Author

/approve-spec 2f7cedb

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 26, 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.

Thanks for the fixes: the exit now frees exactly its space with gaps and collapsing margins, followers the app moves are left alone, and the INV12 interim has an engineering owner's approval on this head. One change is still needed before approval: when the closing element is the last child of a plain block wrapper, as Collapsible's content is, holdLayoutExit measures the wrapper's edge, which the collapsed negative margin never moves, so the exit lands at once and the content vanishes while the rows glide (animatedLayout.ts:659-663, AST-105 FR4).

  • Measure where the content after the element actually sits instead of the parent's edge, and add that layout to the exit browser cases.
  • The grid-gap fallback also skips FR4's exit: hold the element another way there, or have an engineering owner name it in AST-105's out-of-coverage list.
  • holdLayoutExit's comment asks for the call before animate(), which holds a close nothing measured, and releaseLayoutExit strips inert and inline values the exit did not set.

[Automated review]

Comment on lines +659 to +663
const parent = element.parentElement?.getBoundingClientRect();
if (parent == null) {
return 0;
}
return isRow ? (rtl ? -parent.left : parent.right) : parent.bottom;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For a last child this reads the parent's end edge. In block flow, when the parent has no bottom border, padding or formatting context of its own (Collapsible's root), the negative end margin collapses through it: in a Chromium probe the wrapper's bottom stayed at 124 px while the paragraph after it moved from 124 px to 40 px, exactly where it sits once the content is hidden. The check at line 698 then refuses the hold, so the exit lands at once, against AST-105 FR4. Measuring the first element the walk reaches after it would see the real target.

Comment on lines +716 to +717
* fades instead. Call it in the layout effect of the commit that closes the
* element, before the change's `animate()`.

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 animate() knows whether the change was measured, so holding first can hold a close nothing measured: in a probe the row after it moved up at once into the held element's place and overlapped it by 81 px for the whole exit. #8568 calls animate() first and holds only a measured close, which works because the flush is a microtask; documenting that order would keep the next adopter from tripping on it.

}
exits.get(element)?.cancel();
exits.delete(element);
element.removeAttribute('inert');

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 removes inert (and the loop below removes pointer-events and both end margins) even when the element had them before the exit, so content an app or component made inert is reachable again after a release. Recording the prior values in holdLayoutExit and restoring them here would keep the promise in the comment above: remove only what the exit set.

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

Copy link
Copy Markdown
Contributor Author

/approve-spec 52841d7

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

Thanks for the fixes: the exit now measures the content after it, holds only a measured close and puts back what it replaced, and the INV12 interim and the new AST-105 line carry an engineering owner's approval on this head. A few changes are still needed before approval, and because this pull request edits both records, the fix commit will need a fresh /approve-spec from an engineering owner:

  • Nested changes in one frame compose only when the outer one is measured first; measured inner first, rows inside the outer change's follower start 80 px too high (animatedLayout.ts:451-512, AST-105 FR3).
  • The exit is held where its margin can't free the space, a display: contents next element (as Tooltip and Theme render) or a flex-row item that sets its row's height, so the content after it jumps 8 px or 80 px when the element hides.
  • The grid fallback still glides the content after the item, which the new AST-105 line says takes its final place at once, and a refused hold removes the element's own inline end margin.
  • Please add a nested inner-first case and a Core display: contents follower to the browser spec.

[Automated review]

running.animation.cancel();
glides.delete(element);
}
const after = element.getBoundingClientRect();

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 reads the follower's new place after the glides of followers processed before it have started, so a follower inside another follower gets the right move only when its moving ancestor came first. With folder A (80 px) and folder B1 inside the following folder B (40 px) opening in one frame, measuring B1 first gave row B2 a 120 px glide inside B's 80 px glide: it started 80 px above where it was and travelled 200 px (Chromium). AST-105 FR3 says nested changes compose. Reading every follower's rectangle before starting any glide, then subtracting its nearest moving ancestor's move, would make this independent of the order.

let sibling = node.nextElementSibling;
while (sibling != null) {
const style = view?.getComputedStyle(sibling);
if (style == null || takesPart(sibling, style)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A display: contents sibling passes takesPart and is returned here, but it has no box, so every startEdge reading is 0 and the margin falls back to the border box. In a flex column with gap: 8px before a display: contents wrapper (Tooltip and Theme render one), the row after it ended 8 px short and jumped when the element hid. Descending into it, as collectFollowers does, would measure the real next box.

}

/** An element's start edge along the main axis (RTL rows read right to left). */
function startEdge(element: Element, isRow: boolean, rtl: boolean): number {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For a closing flex-row item this reads only the inline axis, even when next is the element after the row. With the closing item 120 px tall beside a 40 px item, the hold was accepted, nothing below glided, and the row below jumped 80 px when the item hid. Measuring the block axis when next sits outside the row, and refusing the hold when either axis misses, would let the follower glide instead.

const guess = startEdge(next, isRow, rtl);
element.style.setProperty(property, `${-size + (target - guess)}px`);
if (Math.abs(startEdge(next, isRow, rtl) - target) > 1 && before !== target) {
element.style.removeProperty(property);

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 removes the end margin outright, so an element that had its own inline margin-block-end: 12px comes back with none after a refused exit, against “Returns false, setting nothing” above. Putting back the prior value, which holdLayoutExit already records, would keep that promise.

] as const,
),
};
if (!giveUpSpace(element, area)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When the hold is refused here, the change stays measured in the batch, so once the adopter hides the item the flush glides the content after it (88 px in a grid with row-gap: 8px). The new AST-105 out-of-coverage line says that content takes its final place at once. Either drop this change's followers when the exit is refused, or have the line say the content glides.

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

Copy link
Copy Markdown
Contributor Author

/approve-spec bc32d66

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

Thanks for the fixes: nested changes now compose in any order, the exit finds the real next box and checks what follows its container, a refused hold keeps the element's own margin, and the INV12 interim and the new AST-105 line carry an engineering owner's approval on this head. Two changes are still needed before approval, and because this pull request edits both records, the fix commit will need a fresh /approve-spec <full head SHA> from an engineering owner (the author may do so as one):

  • Under a scaled ancestor the exit adds a screen-pixel correction to a layout-pixel margin (animatedLayout.ts:784-786), so in a gap stack or with collapsing margins the hold is refused and the content after it jumps; dividing the correction by the element's scale and adding a scaled exit case to the browser spec would fix it.
  • The display: none measurement (animatedLayout.ts:776-781) restarts CSS animations inside the closing section and fires animationstart again; measuring with the element taken out of flow another way, such as a temporary position: absolute, would keep them running.

[Automated review]

Comment on lines +784 to +786
element.style.setProperty(property, `${-size}px`);
const guess = placeOf(next, rtl)[axis];
element.style.setProperty(property, `${-size + (target[axis] - guess)}px`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

size is layout pixels (offsetHeight), but target[axis] - guess comes from getBoundingClientRect(), which is screen pixels. Under a scale(0.5) ancestor an 8 px gap reads as 4 px, the margin lands 4 px of layout short, the check below refuses the hold and the content after the element jumps (Chromium probe: 44 px on screen; the same layout unscaled glides 88 px). AST-105 Coverage says elements under a scaled ancestor move corrected for the scale. Dividing the correction by the element's scale on this axis (scaleOf, as the glide does) would fix it.

Comment on lines +776 to +781
const display = element.style.getPropertyValue('display');
const priority = element.style.getPropertyPriority('display');
element.style.setProperty('display', 'none', 'important');
const targets = checked.map(box => placeOf(box, rtl));
const target = targets[0];
element.style.setProperty('display', display, priority);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Toggling display: none here, even for one forced layout, cancels the CSS animations inside the element and restarts them when the display comes back: a spinner moved 1 s in came back at 0 ms with animationcancel and animationstart fired (Chromium probe). Taking the element out of flow for the measurement without changing its display, such as a temporary position: absolute, would keep them running.

…easurement 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.
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 26, 2026
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec e4b19b0

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 26, 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.

Thanks for the fixes: the exit now lands exactly under a scaled ancestor and keeps CSS animations inside a closing section running, and the new browser cases for both pass here and fail against the previous revision. Every change matches spec:AST-105 and INV12, the INV12 interim and the new AST-105 out-of-coverage line carry an engineering owner's approval on this head, and the module stays internal until #8568 adopts it, so nothing further is needed from this review. Optionally, asserting the exit's start time and duration against its follower in the closing browser case would guard AST-105 FR4's same-motion exit.

[Automated review]

Comment on lines +715 to +716
exitDuration: Number(timing?.duration),
followerDuration: window.motionOf('#a')[0]?.duration ?? null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: exitDuration and followerDuration are read here but never asserted. Adding expect(result.exitDuration).toBeCloseTo(result.followerDuration, 3) (and comparing the exit's start time with the follower's) would guard AST-105 FR4's same-motion exit; a probe shows both start together over 734.8 ms today.

@cixzhang

Copy link
Copy Markdown
Contributor Author

Posted by Cindy's agent on her behalf.

Cindy's decision on content before a changed element: the animated layout also moves earlier siblings. At each level, the bounded walk collects earlier siblings as candidates too, and the same rule applies: only an element whose rect changed and that is visible moves. This covers centered rows, space-between and end-aligned layouts, where content before the changed element shifts. order comes off AST-105's out-of-coverage list. A sandbox performance page (an item in the middle of a long list) will measure the walk's cost before and after.

@cixzhang
cixzhang merged commit 7a530cc into main Oct 27, 2026
68 checks passed
cixzhang added a commit that referenced this pull request Oct 27, 2026
# Conflicts:
#	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 branch October 27, 2026 06:22

This branch was successfully deployed

1 active deployment
Preview — e4b19b06 Deployed Oct 26, 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. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant