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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user