Skip to content

fix(core): an element whose transform leaves it in place still glides - #8780

Merged
cixzhang merged 7 commits into
meta:mainfrom
vjeux:uikit/glide-transform-in-place
Oct 28, 2026
Merged

cixzhang merged 7 commits into
meta:mainfrom
vjeux:uikit/glide-transform-in-place

Conversation

@vjeux

@vjeux vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

User impact

With an animatedLayout handle (#8568), the content after an opening Collapsible, an expanding TreeList row or a changing List glides to its new place. Buttons in that content jumped instead, and so did every row with swipe actions. The button you just pressed jumped onto the one gliding beside it.

Problem and solution fit

What led to it: this is part of a program to make Astryx's list components behave like iOS tables on phones. Building List's row updates on the handle (component:List FR12, #8106), the browser proofs showed the rows and a caption gliding while the Buttons after the list jumped, and a swipe list's rows didn't glide at all. I first offered this fix as a branch on #8634 (comment); #8634 has landed without it, so it comes here on its own.

Why: the module leaves an element the app moves itself to take its new place at once, judged by the element's own transform, translate, rotate and scale and their running animations. It counted values that leave the element in place as moves:

  • a resting Button keeps scale(1) for its press, computed as matrix(1, 0, 0, 1, 0, 0);
  • a pressed one is at scale(0.98);
  • a row with swipe actions rests at translate3d(0, 0, 0).

Options:

  • Count only exact identity values as no move. That's not enough: on the iOS 26.2 simulator, the button you just tapped (scale(0.98)) then jumps onto the one gliding beside it (identity values only, this change).
  • Have each component drop its resting transform. That's every pressable and swipeable component, now and later.
  • Treat a value that changes nothing, or only scales the element in place, as no move (chosen). A glide's translate composes with it.

Expected behavior and authority

spec:AST-105 FR3: the content after a change glides. Its Coverage leaves out only elements the app moves itself.

Smallest restoration

movedByApp ignores a value that leaves the element in place. Anything translated, rotated or skewed, a swiped-open row included, stays the app's. No API change.

It merges cleanly today with the open #8723, #8734, #8639 and #8656; if one of them lands first and conflicts, I'll rebase this, never the other way around.

Evidence

  • Reproduction before the change: the new unit test fails on main; none of the six followers that keep a transform leaving them in place glides. In List's "Animated changes" story on feat(List): animate row inserts, removals and moves through the animatedLayout handle #8106's branch, the Buttons after the list get no glide.
  • Result after the change: the six followers glide; in the List story every button glides with the rows, the pressed one too, and a swipe list's rows glide.
  • Representative unchanged path: the existing test (rotate(2deg) and translate: 4px 0 take their place at once), and a swiped row and a pressed-and-moved one in the new test.
  • Regression test: animatedLayout.test.ts, "glides a follower whose transform leaves it in place".

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

What was run: the module's unit tests, the motion, Collapsible and TreeList unit tests, typecheck, and the module's browser specs in Chromium (56) and WebKit (56). On #8106's branch, the List browser proofs with every button and swipe rows fail without this change (3 of 9) and pass with it. All pass here; the new test failed first.

The animated layout leaves an element the app moves itself to take its
place at once: its own transform, translate, rotate or scale, or a running
animation of one. It counted values that leave the element in place as
such a move, so a component that keeps a transform for a gesture or a
press jumped instead of gliding: a resting Button (scale(1), computed as
matrix(1, 0, 0, 1, 0, 0)), a pressed one (scale(0.98), which then jumped
onto the button gliding beside it), and a row with swipe actions at rest
(translate3d(0, 0, 0)). A value that changes nothing, or only scales the
element in place, is no move now; a glide's translate composes with it.
A row a swipe holds open, or anything translated, rotated or skewed, is
still the app's.

Red first: the followers that keep a transform leaving them in place
glide; the module before it left them all where they landed.
@vercel

vercel Bot commented Oct 28, 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 28, 2026 7:55am UTC

Request Review

@vjeux
vjeux requested a review from josephfarina as a code owner October 28, 2026 05:05
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 28, 2026
@astracat-bot

astracat-bot Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 28, 2026, 8:18 AM UTC

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/Collapsible 18.2KB 18.7KB +455B 4.4KB 4.4KB 0B
core/PowerSearch 166.1KB 166.6KB +452B 20.1KB 20.1KB 0B
core/TreeList 39.8KB 40.3KB +461B 4.8KB 4.8KB 0B

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 35.1KB 35.1KB 0B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

Status: No visual change across 6 compared shot(s).


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@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: counting identity transforms as in place fixes the resting Buttons and swipe rows, but a follower with its own scale now glides by its shift divided by that scale, because the glide's scale correction (scaleOf, applied at packages/core/src/motion/animatedLayout.ts:896-899) reads the element's own scale as if an ancestor applied it. In Chromium, on an 80 px downward shift, a scale(0.98) follower starts 1.63 px above its old place and a scale(0.5) follower starts 80 px above it, which contradicts spec:AST-105 FR3. Please take that correction from the follower's containing box (or divide its own scale out) and add a browser proof with a self-scaled follower, since the jsdom unit test always sees a scale of 1. A smaller gap: staysInPlace treats a none keyframe as a move, so a follower whose own transition runs from transform: none to a scale still jumps.

[Automated review]

Comment on lines +232 to +234
if (property === 'scale') {
return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Admitting every scale sends self-scaled followers to the glide, where the unchanged scaleOf (line 896) divides their travel by their own scale as if an ancestor applied it. In Chromium, on an 80 px downward shift, a scale(0.5) follower starts 80 px above its old place and a pressed Button (scale(0.98)) 1.63 px above it. Taking the correction from the follower's containing box, or dividing its own scale out, keeps the glide starting at its old place.

// or as written.
const functions = [...value.matchAll(/([a-z0-9]+)\(([^)]*)\)/gi)];
return (
functions.length > 0 &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

none reaches here from an app animation's keyframes (lines 313-316) and returns false, so a follower whose own transition runs from transform: none to a scale still counts as moved; rotate: none does the same at lines 240-241. Treating none as in place for every property would match the static check at line 294.

…ection

A follower whose own scale or transform leaves it in place now glides,
but the glide's scale correction read that scale as an ancestor's: an
element's measured size against its layout size includes its own
`scale` and `transform`, while a glide's `translate` applies first, in
the parent's space, and does not go through them. On an 80 px shift a
`scale(0.98)` follower started 1.63 px above its old place and a
`scale: 0.5` one 80 px above it. The correction now divides the
follower's own scale out, so only its ancestors' scale is corrected.

A keyframe with no transform (`none`) now counts as in place too, so a
follower whose own transition runs from `transform: none` to a scale (a
press starting) glides.

Red first: the browser case (a `scale(0.98)` follower, a `scale: 0.5`
follower and one mid-press start at their old places) fails on the change
before this one in Chromium and WebKit, each half on its own; the unit
case for the `none` keyframe fails too.
@vjeux

vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, both fixed in c0cadbe:

  • Self-scaled followers: the glide's correction now divides the follower's own scale and transform out of its measured scale, so only its ancestors' scale is corrected; a translate doesn't go through the element's own scale. A new browser case checks that on an 80 px shift, a scale(0.98) follower, a scale: 0.5 follower and one mid-press all start at their old places, in Chromium and WebKit. It failed first: the scale(0.98) follower started 1.63 px above.
  • none keyframes: a keyframe with no transform counts as in place, so a follower whose own transition runs from transform: none to a scale glides. The same browser case and a unit case cover it; both failed first.

…e fix: a real conflict in animatedLayout.ts

# Conflicts:
#	packages/core/src/motion/animatedLayout.ts
@cixzhang cixzhang closed this Oct 28, 2026
@cixzhang cixzhang reopened this Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf: closed and reopened only to restart this PR's CI on the current workflow after tonight's queue-wide cancel; nothing in the PR changed.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Thanks, the fix looks right. #8734 (the in-place swap) just merged and touches the same lines in motion/animatedLayout.ts, so this needs a merge of main to resolve the conflict, which is the real-conflict case for merging main. If it stays idle for about 30 minutes, we'll resolve it on your branch, keeping your authorship.

@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec b44dc18

@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: counting identity and scale-only transforms as in place now carries resting and pressed Buttons and resting swipe rows with the glide, but ownScaleOf keeps the sign of a negative scale property (packages/core/src/motion/animatedLayout.ts:816-821), so a follower at scale: 1 -1 or scale: -1 starts its glide on the far side of its new place (in Chromium, at 0px 80px instead of 0px -80px on an 80 px downward shift), which contradicts spec:AST-105 FR3. Taking the magnitude there (x *= Math.abs(sx); y *= Math.abs(sy);, as the matrix path already does) fixes it, and a scale: -1 follower in the browser case at packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts:372 would pin it.

[Automated review]

Comment on lines +818 to +820
const [sx = 1, sy = sx] = numbersIn(scale);
x *= sx;
y *= sy;

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 negative scale keeps its sign here, unlike the Math.hypot path below, so scaleOf returns -1 on a flipped axis and the glide's start offset (dy / scale.y, line 986) changes sign: in Chromium a scale: 1 -1 follower starts at 0px 80px on an 80 px downward shift instead of 0px -80px. x *= Math.abs(sx); y *= Math.abs(sy); fixes it; with that change the flipped followers start at their old place and the module's browser spec still passes.

The glide's own-scale correction kept the sign of a negative `scale`
property, so a follower at `scale: -1` or `scale: 1 -1` started its
glide on the far side of its new place (at 0px 80px instead of 0px -80px
on an 80 px downward shift). A negative scale flips the element in place
and its measured box stays positive, so the correction now takes the
scale's magnitude, as the transform-matrix path already does.

Red first: the browser case's `scale: -1` follower started at +80 px on
the change before this one, in Chromium and WebKit.
@vjeux

vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, fixed in ad3f5e1: the own-scale correction now takes the scale's magnitude, as the matrix path already did, so a flipped follower glides the right way. I added scale: -1 and scale: 1 -1 followers to the browser case; the first started at +80 px before the fix, in Chromium and WebKit.

@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec ad3f5e1

@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: counting identity and scale-only transforms as in place now carries resting and pressed Buttons, resting swipe rows and flipped followers with the glide from their old place, but staysInPlace reads an unresolved var() in an app's own Web Animation keyframes as zero (packages/core/src/motion/animatedLayout.ts:260 and :300). A follower running translate: var(--from), translateY(var(--from)) or rotate(var(--angle)) keyframes is therefore glided, and the glide's translate masks the app's own animation (in Chromium a 120 px slide-in shows the glide's -35px at 150 ms instead of the app's +105px), contrary to spec:AST-105's rule that elements the app moves itself take their final place at once. Counting a value that still holds var() as the app's (for example if (value.includes('var(')) return false; after the scale branch) and adding a unit case with a var() keyframe would fix it.

[Automated review]

return true;
}
if (property === 'translate') {
return numbersIn(value).every(n => n === 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A Web Animation reports its keyframes as written, so an app's translate: var(--from) keyframe reaches this branch with no digits, [].every(...) is true, and the keyframe reads as zero. Line 300 does the same for translateY(var(--from)), rotate(var(--angle)) and translate3d(var(--x, 0px), 0, 0) (only the fallback's 0 is read). In Chromium such a follower now gets the glide, whose translate masks the app's own slide: at 150 ms it sits at -35px instead of the app's +105px. Counting a value that still holds var() as the app's, for example if (value.includes('var(')) return false; after the scale branch, fixes both.

A follower running its own Web Animation whose keyframes hold an
unresolved custom property (`translate: var(--from)`,
`translateY(var(--from))`, `rotate(var(--angle))`) read as moving
nothing, since its value had no numbers, so the glide's `translate` took
over from the app's own animation (in Chromium a 120 px slide-in showed
the glide's -35px at 150 ms instead of the app's +105px). A value that
still holds `var()` is unknown here, so it now counts as the app moving
the element, which takes its final place at once (spec:AST-105). A scale
stays in place whatever its value.

Red first: the unit case (three var() followers left to the app, a
var() scale gliding) fails on the change before this one.
@vjeux

vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, fixed in 415fe17: a keyframe value that still holds var() is unknown, so it now counts as the app moving the element, and the element takes its final place at once. A scale stays in place whatever its value. A new unit case runs followers with translate: var(--from), translateY(var(--from)) and rotate(var(--angle)) keyframes, which are left to the app, and a scale: var(--from) one, which still glides. All four glided before the fix.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Recommending approval: resting and pressed Buttons and resting swipe rows now glide from their old place with the content after a Collapsible or TreeList change, and a follower whose own Web Animation uses var() keyframes is again left to the app, which settles the last requested change. In Chromium, Back, a pressed Continue and a resting swipe row start at the plain rows' 0px -80px offset, app animations with var() keyframes play unmasked, and the new unit and browser cases fail without the change. Nothing more is needed from you before a maintainer's decision.

[Automated review]

@cixzhang
cixzhang merged commit f0c6982 into meta:main Oct 28, 2026
66 checks passed
@vjeux
vjeux deleted the uikit/glide-transform-in-place branch October 28, 2026 08:20
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
…dates base: a real conflict in the animated layout module

# Conflicts:
#	packages/core/src/motion/animatedLayout.test.ts
#	packages/core/src/motion/animatedLayout.ts
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
…8723) into the List updates change

# Conflicts:
#	packages/core/src/motion/animatedLayout.boundary.test.ts

This branch was successfully deployed

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

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants