Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
5b0abfb
feat(core): useAnimatedLayout, one module that glides the layout shif…
cixzhang Oct 26, 2026
b476220
feat(core): useAnimatedLayout({containerRef}) returns a handle that C…
cixzhang Oct 26, 2026
52c08dc
fix(core): the layout glide corrects only a real ancestor scale, not …
cixzhang Oct 26, 2026
7cfe5e0
chore(storybook): the Animated layout story's tags row is narrow, so …
cixzhang Oct 26, 2026
e2afc61
Merge 8ba53b45a4 (main, AST-105 current) into the animated layout draft
cixzhang Oct 26, 2026
dc023f3
wip(core): animated layout follows AST-105 (springs.settle, Reduce Mo…
cixzhang Oct 26, 2026
b60f171
feat(core): useAnimatedLayout follows AST-105
cixzhang Oct 26, 2026
ac58ce3
chore(storybook): the story's internal-reporting demos say they are s…
cixzhang Oct 26, 2026
427f850
chore(docsite): the ShadCN route lock lists the useAnimatedLayout hoo…
cixzhang Oct 26, 2026
1bccf04
fix(core): the animated layout reads springs.settle from the internal…
cixzhang Oct 26, 2026
b894315
test(core): the animated-layout browser proofs settle between changes…
cixzhang Oct 26, 2026
ea01d0c
fix(core): animated layout measures every grouped change, drops refus…
cixzhang Oct 26, 2026
186526e
fix(core): a retargeted glide keeps its motion and adds the new move
cixzhang Oct 26, 2026
5bcb797
docs(architecture): INV12 plays the glide through the shared rectangl…
cixzhang Oct 26, 2026
7bad709
docs(architecture): rewrap INV12
cixzhang Oct 26, 2026
3a5f9ae
refactor(core): useAnimatedLayout lands alone; Collapsible and TreeLi…
cixzhang Oct 26, 2026
7e13143
feat(core): Collapsible and TreeList adopt useAnimatedLayout
cixzhang Oct 26, 2026
b3826b7
feat(core): the changed element's exit, held out of flow on the same …
cixzhang Oct 26, 2026
f8c2db7
Merge the module's exit (feat/animated-layout) into the adoption
cixzhang Oct 26, 2026
044b865
feat(core): a Collapsible's or TreeList row's close plays the content…
cixzhang Oct 26, 2026
52863fc
test(core): the exiting group's rows leave the tree's keyboard order
cixzhang Oct 26, 2026
1c44863
chore(changeset): Collapsible and TreeList adopt animatedLayout
cixzhang Oct 26, 2026
a09ce74
fix(core): the internal module alone; a displaced changed element mov…
cixzhang Oct 26, 2026
980ff13
Merge feat/animated-layout into the adoption; the public hook and the…
cixzhang Oct 26, 2026
2f7cedb
fix(core): the exit frees exactly its space and is inert; elements th…
cixzhang Oct 26, 2026
f97811a
Merge feat/animated-layout (an exact, inert exit; app-moved followers…
cixzhang Oct 26, 2026
52841d7
fix(core): the exit measures the content after it, holds only a measu…
cixzhang Oct 26, 2026
86a458b
Merge feat/animated-layout (the exit measures the content after it an…
cixzhang Oct 26, 2026
bc32d66
fix(core): nested changes compose in any order; the exit finds the re…
cixzhang Oct 26, 2026
ac812d7
Merge feat/animated-layout (nested changes in any order; the exit's n…
cixzhang Oct 26, 2026
e4b19b0
fix(core): the exit's correction follows a scaled ancestor, and its m…
cixzhang Oct 26, 2026
731af79
Merge feat/animated-layout (scale-corrected exit; measured out of flo…
cixzhang Oct 26, 2026
c08d59b
fix(core): Collapsible and TreeList report every change, whatever cau…
cixzhang Oct 26, 2026
7c96972
test(core): the adopters' browser spec serves Storybook through a11y-…
cixzhang Oct 26, 2026
7ca7a04
fix(core): a TreeList row collapsing with an expanded child moves wha…
cixzhang Oct 26, 2026
5695f69
docs(core): Collapsible FR10 and TreeList FR8 cite AST-108 FR4a for t…
cixzhang Oct 26, 2026
d2a56cd
feat(core): the animated-layout walk moves the siblings before a chan…
cixzhang Oct 27, 2026
e43e328
perf(core): a held exit reads where the content after it goes once, w…
cixzhang Oct 27, 2026
24eaa87
perf(core): the glide reads every follower before it starts any, so a…
cixzhang Oct 27, 2026
8338da9
Merge main (#8482 landed as 7a530cc) into the adopters
cixzhang Oct 27, 2026
3ea5336
fix(core): the animated-layout walk glides a moved ancestor, reaches …
cixzhang Oct 27, 2026
3a9db0d
Merge the adopters (#8568 8338da94: main with #8482 landed)
cixzhang Oct 27, 2026
aaa7e68
Merge #8634 3a9db0dc (a moved ancestor glides; a frame's changes add …
cixzhang Oct 27, 2026
007f414
docs(spec): Collapsible FR10 and TreeList FR8 cite AST-105 for the sh…
cixzhang Oct 27, 2026
36c6fd8
Merge main: TreeList's animatedLayout row is FR9 beside main's FR8; a…
cixzhang Oct 27, 2026
7e54a7d
Merge #8568 (36c6fd87, as it landed)
cixzhang Oct 27, 2026
e45ef26
Merge main (f8e0c928376: #8568 landed as f8e0c92)
cixzhang Oct 27, 2026
6ab36a8
Merge #8634 (e45ef263: main through #8568 merged)
cixzhang Oct 27, 2026
186bd96
fix(core): the walk reads each element once a frame, stops at the ban…
cixzhang Oct 27, 2026
7743999
fix(core): an exit always guesses and checks where the content after …
cixzhang Oct 27, 2026
c128ce2
Merge #8634 (186bd96b: one read per element a frame; the band stops b…
cixzhang Oct 27, 2026
9fadd72
fix(core): without scroll anchoring a change above the band walks to …
cixzhang Oct 28, 2026
0744d42
Merge #8634 (9fadd729: anchoring-off walk to the band; positioned anc…
cixzhang Oct 28, 2026
73d7b43
fix(core): inside a positioned, sticky or top-layer ancestor only the…
cixzhang Oct 28, 2026
60d3e00
fix(core): an ancestor moves by its layout change alone, not the area…
cixzhang Oct 28, 2026
71abedd
fix(core): an ancestor nets out any scroll of the area's content; stu…
cixzhang Oct 28, 2026
d2dfc6f
fix(core): siblings before the change stay with their card when the a…
cixzhang Oct 28, 2026
51d65e7
fix(core): the scroll correction applies only where it makes a move s…
cixzhang Oct 28, 2026
2ae8a54
Merge #8634 (51d65e7b, as it landed)
cixzhang Oct 28, 2026
8f177d5
Merge main (0418c4705b2: #8634 landed as 0418c470)
cixzhang Oct 28, 2026
9012c84
chore(core): a perf Changeset for the flush's read order; the space-b…
cixzhang Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/animated-layout-exit-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[perf] With an `animatedLayout` handle, a Collapsible or TreeList change in a long list reads every row's place before any glide starts, so the page is laid out once rather than once per gliding row.

@cixzhang
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,73 @@ test.describe('spec:AST-105 FR2–FR4', () => {
expect(follower[0].duration).toBeLessThan(1200);
});

for (const layout of [
{
name: 'block flow',
html: `<div style="display: block"><div id="content" style="height: 80px; background: #fd9">Details</div><div id="a" class="row" style="margin: 0"></div></div>`,
},
{
name: 'a column flex box with a gap',
html: `<div style="display: flex; flex-direction: column; gap: 12px"><div id="content" style="height: 80px; background: #fd9">Details</div><div id="a" class="row" style="margin: 0"></div></div>`,
},
{
name: 'a centered column',
html: `<div style="display: flex; flex-direction: column; justify-content: center; min-height: 400px"><div class="row"></div><div id="content" style="height: 80px; background: #fd9">Details</div><div id="a" class="row"></div></div>`,
},
{
name: 'a space-between column',

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 case can't fail as written: in Chromium the hold is refused here, on the base as well, since one correction moves #a to 207.8 px against a 178 px target. So if (result.held) below skips the landing check, and hidden is read with #content at display: none, the same state target was read in. expect(result.held).toBe(false) plus a check that #a has no glide would pin the refusal; otherwise the case could go.

html: `<div style="display: flex; flex-direction: column; justify-content: space-between; min-height: 400px"><div class="row"></div><div id="content" style="height: 80px; background: #fd9">Details</div><div id="a" class="row"></div><div class="row"></div></div>`,
},
{
name: 'a column that ends a flex row',
html: `<div style="display: flex; gap: 8px"><div style="display: flex; flex-direction: column; flex: 1"><div class="row"></div><div id="content" style="height: 80px; background: #fd9">Details</div></div><div style="width: 60px; height: 40px; background: #ddd"></div></div><div id="a" class="row"></div>`,
},
]) {
test(`an exit in ${layout.name} lands the content after it exactly where it goes once hidden (FR4)`, async ({
page,
}) => {
await open(
page,
`<div id="area" class="area">${layout.html}<div class="row"></div></div>`,
);
const result = await page.evaluate(async () => {
const area = document.querySelector('#area');
const content = document.querySelector('#content') as HTMLElement;
const a = document.querySelector('#a') as HTMLElement;
const handle = window.AnimatedLayout.createAnimatedLayout(() => area);
const report = window.AnimatedLayout.reportLayoutChange(
handle,
() => content,
);
report.measureBefore();
report.animate();
// Where the content after it lands once the element is gone.
content.style.display = 'none';
const target = a.getBoundingClientRect().top;
content.style.display = '';
const held = window.AnimatedLayout.holdLayoutExit(
handle,
content,
() => {},
);
await new Promise<void>(resolve => {
queueMicrotask(resolve);
});
a.getAnimations().forEach(animation => animation.finish());
const landed = a.getBoundingClientRect().top;
// When the element hides, nothing after it moves again.
content.style.display = 'none';
window.AnimatedLayout.releaseLayoutExit(content);
const hidden = a.getBoundingClientRect().top;
return {held, landed, hidden, target};
});
if (result.held) {
expect(Math.abs(result.landed - result.target)).toBeLessThan(0.5);
}
expect(Math.abs(result.hidden - result.target)).toBeLessThan(0.5);
});
}

test('a closing element exits on the same motion while the content after it glides up at once (FR4)', async ({
page,
}) => {
Expand Down
54 changes: 52 additions & 2 deletions packages/core/src/motion/animatedLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,43 @@ describe('the walk (spec:AST-105 FR3, Coverage)', () => {
}
});

it('reads every follower (place, transform, scale) before it starts any glide, so a long page is laid out once', async () => {
const {area, changed, phase} = setUp(
'<div id="area"><div id="changed"></div><div id="a"></div><div id="b"></div><div id="c"></div></div>',
);
for (const [id, y] of [
['a', 0],
['b', 40],
['c', 80],
] as const) {
placeAt(
document.getElementById(id) as HTMLElement,
{before: [0, y, 400, 40], after: [0, y + 80, 400, 40]},
phase,
);
}
// A style or layout read after a glide started lays the page out again.
const readsAfterAGlide: string[] = [];
const computed = window.getComputedStyle.bind(window);
vi.spyOn(window, 'getComputedStyle').mockImplementation(

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 spy only sees the transform read. With entry.scale = scaleOf(...) moved back into the glide loop, a change above 10,000 rows in Chromium is back to 15 layouts, yet this suite still passes, because jsdom's offsetWidth is 0 and nothing watches it. Recording offsetWidth and offsetHeight reads after the first glide too, for example with vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get'), would guard both halves of the fix.

(element: Element, pseudo?: string | null) => {
if (played.some(p => p.keyframes[0].translate != null)) {
readsAfterAGlide.push((element as HTMLElement).id);
}
return computed(element, pseudo);
},
);
await runChange(
createAnimatedLayout(() => area),
changed,
phase,
);
expect(played.filter(p => p.keyframes[0].translate != null)).toHaveLength(
3,
);
expect(readsAfterAGlide).toEqual([]);
});

it('takes the children of a display: contents wrapper and skips absolute, fixed, hidden and display: none elements', async () => {
const {area, changed, phase} = setUp(
`<div id="area"><div id="changed"></div>
Expand Down Expand Up @@ -523,6 +560,16 @@ describe('the changed element exits (spec:AST-105 FR4)', () => {
expect(content.style.pointerEvents).toBe('');
});

it('guesses, then corrects, where a negative margin would collapse with its end margin', () => {
const {content, after, layout} = exitSetUp();
after.style.marginTop = '-4px';
const reads = vi.mocked(after.getBoundingClientRect);
reads.mockClear();
expect(holdLayoutExit(layout, content, vi.fn())).toBe(true);
expect(reads.mock.calls.length).toBeGreaterThan(2);
expect(content.style.getPropertyValue('margin-block-end')).toBe('-88px');
});

it('stops an exit when the content opens again', () => {
const {content, layout} = exitSetUp();
const done = vi.fn();
Expand All @@ -545,7 +592,8 @@ describe('the changed element exits (spec:AST-105 FR4)', () => {
});

it('lands at once where no end margin frees its space (a grid track)', () => {
const {content, after, layout} = exitSetUp();
const {area, content, after, layout} = exitSetUp();
area.style.display = 'grid';
// The row after it ignores the margin: it moves only when the content
// is hidden.
vi.spyOn(after, 'getBoundingClientRect').mockImplementation(
Expand All @@ -567,7 +615,9 @@ describe('the changed element exits (spec:AST-105 FR4)', () => {
});

it("a refused hold keeps the element's own end margin and lets the content after it take its place at once", async () => {
const {content, after, layout} = exitSetUp();
const {area, content, after, layout} = exitSetUp();
// A grid track, which no end margin frees.
area.style.display = 'grid';
content.style.setProperty('margin-block-end', '12px');
vi.spyOn(after, 'getBoundingClientRect').mockImplementation(
() =>
Expand Down
27 changes: 21 additions & 6 deletions packages/core/src/motion/animatedLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,8 @@ function flush(batch: Batch): void {
after?: DOMRect;
rawX?: number;
rawY?: number;
isMovedByApp?: boolean;
scale?: {x: number; y: number};
}[] = [];
for (const element of followers) {
const before = batch.before.get(element);
Expand Down Expand Up @@ -756,6 +758,9 @@ function flush(batch: Batch): void {
}
entries.push({element, before, carried, carriedMs});
}
// Every reading (places, transforms, scales) before any glide starts: a
// glide started between two readings would make the next one lay the page
// out again, once per element (on a long page, the whole cost).
const byElement = new Map<Element, (typeof entries)[number]>();
// An ancestor of the change, and a sibling before it, move by their layout
// change alone: when scroll anchoring re-scrolls the area, an outer
Expand All @@ -781,6 +786,13 @@ function flush(batch: Batch): void {
entry.rawY = isAncestor ? nearer(rawY, scrolledY) : rawY;
byElement.set(entry.element, entry);
}
for (const entry of entries) {
const after = entry.after as DOMRect;
if (intersects(entry.before, visible) || intersects(after, visible)) {
entry.isMovedByApp = movedByApp(entry.element);
entry.scale = scaleOf(entry.element, after);
}
}
for (const entry of entries) {
const {element, before, carried, carriedMs} = entry;
const after = entry.after as DOMRect;
Expand Down Expand Up @@ -814,14 +826,14 @@ function flush(batch: Batch): void {
});
continue;
}
if (movedByApp(element)) {
if (entry.isMovedByApp === true) {
// The app moves it itself: it takes its final place at once
// (`spec:AST-105` Coverage).
continue;
}
// Where it shows now, against its new place, in its own px; the part the
// carried glide does not cover is the new move.
const scale = scaleOf(element, after);
const scale = entry.scale ?? {x: 1, y: 1};
const now = carried(0);
const moveX = dx / scale.x - now.x;
const moveY = dy / scale.y - now.y;
Expand Down Expand Up @@ -1093,6 +1105,9 @@ function giveUpSpace(element: HTMLElement, area: Element): boolean {
? null
: nextInFlow(element.parentElement, area);
const checked = afterParent == null ? [next] : [next, afterParent];
// The element's scale, read in the layout the caller's visibility check
// already laid out.
const scale = scaleOf(element, element.getBoundingClientRect());
// Where the content after it goes once it is hidden, read with it taken
// out of flow (not `display: none`, which would restart the CSS animations
// inside it).
Expand All @@ -1106,14 +1121,14 @@ function giveUpSpace(element: HTMLElement, area: Element): boolean {
} else {
element.style.setProperty('position', position, positionPriority);
}
// The reading is in screen px, the margin in the element's own: under a
// scaled ancestor a distance is divided by the scale on this axis.
const perPx = isRow ? scale.x : scale.y;
// A first guess (its box), then one correction from the measured result:
// a gap, a collapsed margin or the element's own margins.
element.style.setProperty(property, `${-size}px`);
const guess = placeOf(next, rtl)[axis];
// The reading is in screen px, the margin in the element's own: under a
// scaled ancestor the correction is divided by the scale on this axis.
const scale = scaleOf(element, element.getBoundingClientRect());
const correction = (target[axis] - guess) / (isRow ? scale.x : scale.y);
const correction = (target[axis] - guess) / perPx;
element.style.setProperty(property, `${-size + correction}px`);
// Everything checked must land on both axes: a flex-row item that sets
// its row's height frees no block space with an inline margin.
Expand Down
Loading