Skip to content

feat(core): the animated-layout module's in-place swap (spec:AST-105 FR8) - #8734

Merged
cixzhang merged 61 commits into
mainfrom
feat/animated-layout-swap
Oct 28, 2026
Merged

cixzhang merged 61 commits into
mainfrom
feat/animated-layout-swap

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Based on main, which carries useAnimatedLayout and the size-change module (#8568) and the D38 records (#8720). #8723 (SideNav/AppShell rail motion) is its first adopter and stacks on this.

What this adds

The animated-layout module's in-place swap, spec:AST-105 FR8: an area can hold its old content as one transient copy while new content enters in the same place, on FR4's one motion.

  • holdLayoutSwap(handle, element, {isVisiblePart?}), before a change commits: copies the element. With isVisiblePart, the copy is the visible part of a scroll container, with its content scrolled as it was, so it adds no scroll overflow. It returns nothing without a handle, outside the area, under an immediate change (Reduce Motion with a fine pointer), or for content over 1,500 elements.
  • playLayoutSwap(swap, {host, copy, copyText?, isOpaque?, incoming}), after the commit and before paint: lays the copy over its old place in host, wherever its containing block is (no ancestor's position changes). It plays the copy out while the incoming elements enter, every part on the same duration and timeline start time. The caller shapes the frames over the swap's progress. Under Reduce Motion with a coarse pointer, it plays opacity only (DR2). The copy is removed when the swap ends. It returns a function that ends the swap at once.
  • rideWith: {element, travel, isCopyMoved?} plays every part of the swap (the copy, its text, the incoming parts) over that element's actual glide, frame by frame, and hands each frame function how far the element has moved. By default the copy moves with it. With isCopyMoved: false the copy stays put and a caller can follow the glide another way, such as a clip that keeps an edge on it. The parts start after the batch flushes and before paint. When the element lands silently (out of view, or moved by the app), the ride is the whole travel at once.
  • onEnd runs once when the swap ends. playLayoutFrames(handle, parts) plays frames on elements the page renders, on the same motion with no copy, and releases them when the motion ends.
  • The copy takes no part in the page: it's inert and aria-hidden, and it carries no id or data-testid. It also has no name/form (a copied checked radio would otherwise uncheck the live one), and its frames and media load nothing.
  • The copy is faithful to what showed. It keeps the old rect whatever the box sizing and under a scaled host. It keeps the 2D canvas pixels and the scroll offsets (the container's and every scrolled descendant's, landed at once even under scroll-behavior: smooth). SVG labels fade through their text element. Laid in a host outside the element's own ancestors (a copy riding a column), it keeps what it inherited from them: inherited properties and custom properties that differ from the host's are written on it. Canvases are read back at their own size, so thin marks are kept. Limits: content holding a canvas that reads back empty isn't copied, and the new content enters without an old copy. That covers a canvas that can't be read back (a WebGL canvas without preserveDrawingBuffer, or a tainted one) and a blank one. A style rule keyed on a removed id, or matched through an ancestor between the host and the element, doesn't style the copy as it styled the element.
  • The copy mechanism lives in motion/transientCopy.ts, which only the module imports (boundary test). That keeps the module's walk and property checks intact.

Authority: spec:AST-105 FR8 and architecture:react-component-runtime INV12, which excepts the swap's one copy, plus architecture:component-style-authoring INV7 (the one shared transient-copy mechanism), all current on main since #8720 merged (as the round-3 records state them. Nothing public changes, and nothing adopts the swap in this PR. One glide fix rides along because the adopter exposed it: the glide measures a move by the inline-start edge, read from the container's direction. In a right-to-left container, content after a Collapsible or TreeList size change (with a handle) now glides instead of jumping, and the changeset says so.

Tests

Failing first: animatedLayout.swap.test.ts has 25 cases, each failing before its change.

  • The inert copy takes no part in the page: no ids, test ids, labels' for, form or radio group, and no frame or media load.
  • The copy plays out and the incoming elements play in on one duration and start time, then the copy is removed and onEnd runs once.
  • The copy's text fades on its own curve, SVG labels included; style sheets and textareas keep their text.
  • Faithful copies: the old rect under any box sizing, a scaled host and a fractional size; readable canvases (thin marks too), and declining a blank or unreadable one; scroll offsets restored at once; inherited properties, custom properties and a unitless line-height carried when laid outside the element's ancestors.
  • DR2 is opacity only. Nothing is held without a handle, outside the area, or under DR3.
  • Riding: every part plays over the ridden glide and gets its travel, including a glide that carries an earlier one. Without a glide, the copy takes its new place at once. With isCopyMoved: false the copy stays put.
  • playLayoutFrames plays on the same motion (opacity only under DR2) and releases its frames when the motion ends.
  • The glide reads the container's direction in right-to-left content.

The motion, Collapsible and TreeList suites pass (about 800 tests). Real-browser proof is the adopter's: #8723.

Size

Product +642 lines across animatedLayout.ts and transientCopy.ts, about a third of them the doc comments the module's conventions ask for. That's over the size cap because the swap is one feature: the shared copy mechanism and the player that uses it.

…t around a size change

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Approval recommended: every change matches spec:AST-105 FR8, architecture:react-component-runtime INV12 and architecture:component-style-authoring INV7 as they now stand on main, and the right-to-left glide fix restores FR3. The two defects raised on the previous revision are fixed: a handed-off copy keeps its travel, clip and text fade, and copied style sheets and textareas keep their text. The description could also mention playLayoutFrames, handOff and onEnd, which it currently leaves out.

[Automated review]

}
});

it("keeps the swap's copy mechanism private to the module", () => {

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: the public-entry check above names only the four reporting functions. Adding holdLayoutSwap, playLayoutSwap and playLayoutFrames to its list would make spec:AST-105 FR8's "a public export fails" mechanical; none of them is exported today.

…nd is told how far it has moved

With rideWith, the copy, its text and the incoming parts all play over the
ridden element's glide, and their frames receive its travel so far, so a
caller can keep an edge on that glide (a clip that follows a column);
isCopyMoved false keeps the copy in place. handOff is removed: a reversed
swap is drawn with one copy.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested for one fix: the swap's transient copy is appended to host, so it loses whatever it inherited from, or matched through, the ancestors between host and the copied element; for example, a copy riding a column with its own 13 px type opens at 16 px and spills from its box, and an ancestor's new collapsed class hides the old rail copy's labels. Carrying the element's computed inherited properties onto the copy root in inertCopy, or at least listing this beside the id limit in transientCopy.ts, would resolve it. Everything else matches spec:AST-105 FR8, INV12 and INV7 as they stand on main, and riding a glide now keeps every part on its clock. The description could also mention playLayoutFrames, onEnd, isCopyMoved and the ride passed to every part.

[Automated review]

width: `${rect.width}px`,
height: `${rect.height}px`,
});
host.appendChild(element);

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 copy joins host's cascade here, and with rideWith that host sits outside the ridden element, so whatever the element inherited from, or matched through, the ancestors in between stops applying for the swap. In Chromium, a copy laid outside a column with font-size: 13px; line-height: 1.45 opened at 16 px with line-height: normal and spilled from its box, and an ancestor's new collapsed class hid the old rail copy's labels. Writing the element's computed inherited properties that differ from the host's onto the copy root in inertCopy, or at least naming this beside the id limit in the header, would keep the copy faithful, or the limit known.

copy: (_p, ride) => ({clipPath: `inset(0px ${-ride.x}px 0px 0px)`}),
rideWith: {element: column, travel: {x: 212, y: 0}, isCopyMoved: false},
incoming: [
{element: title, frames: (_p, ride) => ({marginLeft: `${ride.x}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.

Optional: this frame plays marginLeft per frame through the module, the per-frame layout architecture:react-component-runtime INV12's verification fails. translate: `${ride.x}px 0px` would prove the same ride without modelling it.

@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 179b3bf

…ited from them

inertCopy reads the element's inherited properties and custom properties;
placeOver writes those that differ from the host's on the copy, so a copy
riding a column keeps that column's type and variables. Rules matched through
an ancestor between the host and the element are named as a limit. The
boundary test also checks that the swap functions are not exported.
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec a234c3c

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested for one follow-up in the copy's new inherited-property carry: readInherited reads line-height from getComputedStyle, which reports a unitless line-height in pixels, so a descendant with another font size loses its factor (a 16.25 px title in a 13px / 1.45 column drops from 23.56 px to 18.85 px lines, and the text under it starts 9.4 px higher during the swap). Carrying the computed number instead, for example from computedStyleMap().get('line-height') where it is available, or naming the limit beside the id one in transientCopy.ts, would resolve it. Everything else matches spec:AST-105 FR8, INV12 and INV7 as they stand on main: the inherited type, colour and custom properties now carry, and the right-to-left glide fix behaves as described. The description's test count (24 cases now) and product size could also be updated.

[Automated review]

const values = new Map<string, string>();
const style = getComputedStyle(element);
for (const name of INHERITED_PROPERTIES) {
values.set(name, style.getPropertyValue(name));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

getComputedStyle reports line-height as its resolved length, so a unitless line-height (Astryx's reset sets 1.5 on html) is written onto the copy root as pixels at :210, and every descendant with another font size inherits those pixels instead of the factor: in Chromium a copied 16.25 px title in a 13px / 1.45 column drops from 23.56 px to 18.85 px lines and the paragraph under it starts 9.4 px higher. Carrying line-height from element.computedStyleMap?.().get('line-height') when it is a number (Chromium reports 1.45 here), or naming the limit beside the id one in the header, would keep the copy as it showed.

}
}

/** Scroll offsets a copy restores once laid out (a clone carries 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.

Optional: this doc comment now sits above INHERITED_PROPERTIES; it describes scrollOffsets at :120.

The computed style reports a unitless line-height in pixels; the copy now
takes the factor from the typed style map where it has one, so descendants
with another font size keep their line spacing. A stray doc comment moves
back to scrollOffsets.
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 9b6ad1c

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested for one Firefox fix: the swap matches spec:AST-105 FR8, architecture:react-component-runtime INV12 and architecture:component-style-authoring INV7 as they now stand on main, and the right-to-left glide change restores FR3's glide. Firefox has no computedStyleMap, so readInherited (transientCopy.ts:110-120) keeps the pixel line-height and a copy laid outside its ancestors (the rideWith path) hands that fixed value to every descendant: in a 16 px area with a 13 px column and line-height: 1.5, the copy's 24 px title drops from 36 px to 19.5 px lines, so the swap opens on squeezed text. Leaving line-height to inherit when the element's line-height-to-font-size ratio equals the host's, with a unit case that has no computedStyleMap, would fix it, and the module header should also list playLayoutFrames.

[Automated review]

}
// A unitless line-height inherits as its factor, but the computed style
// reports it in pixels: carry the factor where the typed style map has it.
const lineHeight = element.computedStyleMap?.().get('line-height');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Firefox has no computedStyleMap (in Firefox 151 it is undefined), so this falls back to getComputedStyle's pixels, and placeOver writes them on a copy laid outside its ancestors. With a 16 px host, a 13 px element and line-height: 1.5, the copy root gets line-height: 19.5px and a 24 px descendant's lines shrink from 36 px to 19.5 px. Without Typed OM, leaving line-height to inherit when the element's line-height-to-font-size ratio equals the host's keeps the shared factor. The case at animatedLayout.swap.test.ts:695 stubs computedStyleMap, so one without it would cover this path.

* handle, `reportLayoutChange`, `playLayoutChange`, `holdLayoutExit` /
* `releaseLayoutExit` and `applyLayoutChangeTransition`: the one module
* `releaseLayoutExit`, the in-place swap (`holdLayoutSwap` /
* `playLayoutSwap`) and `applyLayoutChangeTransition`: the one module

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: this export list, and the How it works list below it, leave out playLayoutFrames, the third new export.

…ight as a factor too

Where computedStyleMap is missing (Firefox), the copy takes the computed
line-height over the font size, and leaves it to inherit when the host passes
the same factor. The module header also names playLayoutFrames.
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 81388fd

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested for one fix. The swap's copy rewrites a length line-height (such as 20px) as a factor of the copied element's font size whenever the typed style map does not report a number (packages/core/src/motion/transientCopy.ts, lines 130-134), so copied text in another font size re-spaces during the swap; in Chromium, a 20px child's lines became 30.76px. Using the factor only where computedStyleMap is missing, comparing a carried length with the host's computed line-height in placeOver, and adding a test whose typed map reports px would fix it. Everything else matches spec:AST-105 FR8, architecture:react-component-runtime INV12 and architecture:component-style-authoring INV7 as they stand on main, and the right-to-left glide fix restores FR3.

[Automated review]

Comment on lines +130 to +134
} else {
const factor = lineHeightFactor(style);
if (factor != null) {
values.set('line-height', String(factor));
}

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 fallback also runs when a typed style map exists and reports a length (20px, 1.25rem, 150%). A length line-height is then carried as a factor of this element's font size, and placeOver writes it whenever the host's factor differs, so descendants in another font size inherit the factor instead of the length. In Chromium, with a 16 px host at line-height: 20px, a 13 px element and a 20 px child, the copy's child lines went from 20px to 30.76px and its content from 60 to 81 px. Taking the factor only when computedStyleMap is missing (keeping a typed length as its computed pixels), and comparing a carried length with the host's computed line-height at line 236, would keep both cases faithful.

The typed style map tells a factor from a length; only without one does the
copy take the computed pixels over the font size. A carried length is compared
with the host's computed line-height, a factor with the host's factor.
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 3e3322e

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested for one remaining fix in the swap copy's line-height carry; the length case from the last round now keeps its spacing. placeOver compares a carried line-height with the host's computed value without checking whether the host passes a factor or a length (packages/core/src/motion/transientCopy.ts, lines 241-244), so when the numbers agree but the kinds differ nothing is written and the copy re-spaces: in Chromium, a 13px copy whose column sets 20px inside a line-height: 1.25 host drops from 20px to 16.25px lines. Reading the host's typed line-height and writing the carried value whenever the kinds differ, with a test for each direction, would fix it. The branch also conflicts with main, where #8634 rewrote the same rawX lines in flush, so the merge needs to keep both its scroll netting and the inline-start edge.

[Automated review]

Comment on lines +241 to +244
const passed =
name === 'line-height' && /^[\d.]+$/.test(value) && hostFactor != null
? String(hostFactor)
: hostStyle.getPropertyValue(name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This compares a carried line-height with the host's computed value without asking whether the host passes a factor or a length. When the numbers agree but the kinds differ, nothing is written and the copy inherits the host's kind. In Chromium, with the host at line-height: 1.25 (20px at 16px) and the column between them at 20px, a 13px copy's lines drop from 20px to 16.25px and its 20px child's grow to 25px; with the host at 24px and the column at 1.5, they go from 19.5px to 24px and from 30px to 24px. Reading the host's typed line-height where computedStyleMap exists, and writing the carried value whenever the kinds differ, would fix both; a test for each direction would pin it.

…writes its carried line-height

The merge keeps main's scroll netting in flush beside the inline-start edge
read from the container's direction. The copy now always writes its carried
line-height: a host can pass the same number as the other kind (a factor
against a length), which descendants inherit differently.
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 1cf5eab

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

Approval recommended: the latest commit fixes the swap copy's line-height carry, so in Chromium the copy now keeps its original's line spacing in both cases from the last round, and the merge from main keeps both the scroll netting and the inline-start edge in flush. Every change matches spec:AST-105 FR8, architecture:react-component-runtime INV12 and architecture:component-style-authoring INV7 on main, so an engineering owner can approve. One optional follow-up: Firefox has no typed style map, so a length line-height reaching the copy is still written as a factor and can re-space descendants of another font size during the swap; naming that limit in the transientCopy.ts header would tell adopters what to expect.

[Automated review]

Comment on lines +238 to +246
// A line-height is always written: the host may pass the same number as
// the other kind (a factor against a length), which descendants inherit
// differently.
if (
value !== '' &&
(name === 'line-height' || hostStyle.getPropertyValue(name) !== value)
) {
element.style.setProperty(name, value);
}

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: without a typed style map (Firefox), readInherited carries the computed pixels over the font size as the factor, so writing it unconditionally re-spaces descendants of another font size whenever the line-height reaching the copy is a length. In Firefox 151 a copy laid in its own parent with line-height: 24px keeps its 16 px text at 24 px but takes its 12 px child from 24 to 18 px lines; the previous comparison left that case alone and misread the opposite one. Naming this beside the header's other limits would tell adopters what to expect.

@cixzhang
cixzhang merged commit 60178b8 into main Oct 28, 2026
72 checks passed
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
@cixzhang
cixzhang deleted the feat/animated-layout-swap branch October 28, 2026 10:28

This branch was successfully deployed

1 active deployment
Preview — 1cf5eab1 Deployed Oct 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant