fix(chat): prevent persistent gaps after activity collapse

Upgrade LegendList to 3.3.10 and patch temporary padding cleanup to compare CSSOM-serialized values. Settle Activity height when React replays an interrupted layout effect.

Validated with 16 padding regressions, 3 collapse lifecycle tests, the UI suite, workspace type-check and lint, and web and VS Code builds.
This commit is contained in:
Bohdan Triapitsyn
2026-09-08 19:16:55 +03:00
parent 66a1cc2112
commit 87d88d6952
8 changed files with 342 additions and 14 deletions
@@ -0,0 +1,118 @@
import React, { act, StrictMode, Suspense } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import { LiveActivityCollapse } from './LiveActivityCollapse';
describe('live Activity collapse layout lifecycle', () => {
let root: Root;
let container: HTMLDivElement;
let restore: () => void;
beforeEach(() => {
const win = new Window({ url: 'http://localhost' });
const globals = {
window: win, document: win.document, HTMLElement: win.HTMLElement,
Element: win.Element, SVGElement: win.SVGElement, NodeList: win.NodeList,
requestAnimationFrame: win.requestAnimationFrame.bind(win),
cancelAnimationFrame: win.cancelAnimationFrame.bind(win),
getComputedStyle: win.getComputedStyle.bind(win),
IS_REACT_ACT_ENVIRONMENT: true,
};
const previous = Object.keys(globals).map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const);
for (const [name, value] of Object.entries(globals)) {
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
}
restore = () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
};
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
restore();
});
test('settles the target height when React cleans up and replays a collapse layout effect', async () => {
await act(async () => root.render(
<StrictMode>
<LiveActivityCollapse expanded={false} animateOnMount>
<div ref={(node) => {
if (!node?.parentElement) return;
// Happy DOM has no layout engine. Supply the height
// measured before a real historical turn collapses.
Object.defineProperty(node.parentElement, 'scrollHeight', { configurable: true, get: () => 2400 });
}}>Historical activity</div>
</LiveActivityCollapse>
</StrictMode>,
));
const region = container.querySelector<HTMLElement>('[data-live-activity-content]');
expect(region?.style.height).toBe('0px');
expect(region?.childElementCount).toBe(0);
});
test('settles a collapse after a Suspense hide/reveal in production lifecycle', async () => {
let suspended = false;
let release: () => void = () => undefined;
const pending = new Promise<void>((resolve) => { release = resolve; });
function LoadingSibling() {
if (suspended) throw pending;
return null;
}
const render = () => (
<Suspense fallback={<div>Loading history</div>}>
<LiveActivityCollapse expanded={false} animateOnMount>
<div ref={(node) => {
if (!node?.parentElement) return;
Object.defineProperty(node.parentElement, 'scrollHeight', { configurable: true, get: () => 2400 });
}}>Historical activity</div>
</LiveActivityCollapse>
<LoadingSibling />
</Suspense>
);
await act(async () => root.render(render()));
suspended = true;
await act(async () => root.render(render()));
suspended = false;
await act(async () => { release(); });
const region = container.querySelector<HTMLElement>('[data-live-activity-content]');
expect(region?.style.height).toBe('0px');
expect(region?.childElementCount).toBe(0);
});
test('restores natural height when an expansion is interrupted by Suspense', async () => {
let expanded = false;
let suspended = false;
let release: () => void = () => undefined;
const pending = new Promise<void>((resolve) => { release = resolve; });
function LoadingSibling() {
if (suspended) throw pending;
return null;
}
const render = () => (
<Suspense fallback={<div>Loading history</div>}>
<LiveActivityCollapse expanded={expanded}>
<div>Historical activity</div>
</LiveActivityCollapse>
<LoadingSibling />
</Suspense>
);
await act(async () => root.render(render()));
expanded = true;
await act(async () => root.render(render()));
suspended = true;
await act(async () => root.render(render()));
suspended = false;
await act(async () => { release(); });
const region = container.querySelector<HTMLElement>('[data-live-activity-content]');
expect(region?.style.height).toBe('auto');
expect(region?.style.overflow).toBe('visible');
expect(region?.textContent).toBe('Historical activity');
});
});
@@ -16,13 +16,24 @@ export function LiveActivityCollapse({ expanded, children, id, animateOnMount =
React.useLayoutEffect(() => {
const element = ref.current;
if (!element || previousExpanded.current === expanded) return;
previousExpanded.current = expanded;
if (expanded) setRetained(true);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
if (!element) return;
const settle = () => {
element.style.height = expanded ? 'auto' : '0px';
element.style.overflow = expanded ? 'visible' : 'hidden';
setRetained(expanded);
};
// Suspense can clean up a layout effect while retaining its DOM, then
// replay setup on reveal. The old animation was stopped, but the ref
// still records its target. Skipping setup here would freeze the
// measured pre-collapse height and retain an empty historical region.
if (previousExpanded.current === expanded) {
settle();
return;
}
previousExpanded.current = expanded;
if (expanded) setRetained(true);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
settle();
return;
}
element.style.height = expanded ? '0px' : `${element.scrollHeight}px`;
@@ -32,12 +43,10 @@ export function LiveActivityCollapse({ expanded, children, id, animateOnMount =
ease: [0.16, 1, 0.3, 1],
});
let cancelled = false;
void animation.finished.then(() => {
if (cancelled) return;
element.style.height = expanded ? 'auto' : '0px';
element.style.overflow = expanded ? 'visible' : 'hidden';
setRetained(expanded);
}).catch(() => undefined);
const finish = () => {
if (!cancelled) settle();
};
void animation.finished.then(finish, finish);
return () => {
cancelled = true;
animation.stop();
@@ -82,6 +82,21 @@ within the session. The disclosure uses a finite 180ms height transition,
respects reduced motion, and delegates end pinning to the existing timeline.
It never calls scroll-to-bottom. Collapsed history does not mount its hidden
message bodies; initial history loads do not animate collapse.
Layout-effect replay after a Suspense hide/reveal must settle the requested
height and retained children even when the expanded target did not change.
Cleanup stops the animation, so a same-target early return can leave a cached
pre-collapse height on the DOM indefinitely. Failed animations also settle;
callbacks from cancelled, superseded animations never settle a newer target.
The virtualizer also adds temporary end padding while compensating prepended
history. The Bun patch for `@legendapp/list@3.3.10` stores that padding's CSSOM
read-back value: Chromium rounds fractional pixel strings, so comparing the
original input with `style.paddingBottom` can skip cleanup permanently. This
leaves a phantom tail even when every Activity region is already zero-height.
The patch covers both web entry points in ESM and CJS; its installed-controller
regression tests live in `scripts/legend-list-padding.test.mjs`. Retain this
fix when updating the dependency unless upstream has equivalent ownership and
cleanup behavior. Chat padding and scroll policies do not compensate for it.
The header retains its report when expanded and has no hover background. Its
left inset matches sorted Activity. Diff deletions use the ASCII hyphen.