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,68 @@
diff --git a/react-native.web.js b/react-native.web.js
index 5b171ebd6fa7ac86b0148fe17f897a0ca821d55e..954235cd2b970e0dbb6b78f650633e4f07242558 100644
--- a/react-native.web.js
+++ b/react-native.web.js
@@ -6560,8 +6560,11 @@ function ScrollAdjust() {
window.getComputedStyle(contentNode)[axis.paddingEndProp]
);
const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;
- temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: temporaryPaddingEnd };
contentNode.style[axis.paddingEndProp] = temporaryPaddingEnd;
+ // CSSOM rounds fractional pixels (2123.1875px becomes 2123.19px).
+ // Track the serialized value so ownership checks and cleanup
+ // match what the browser actually stored, not the input string.
+ temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: contentNode.style[axis.paddingEndProp] };
void contentNode.offsetHeight;
scrollBy();
if (resetPaddingRafRef.current !== void 0) {
diff --git a/react-native.web.mjs b/react-native.web.mjs
index cf549ca32946a9e35a883290c11423330bf8728b..d6508ee3b88e2f79c5ee9b994f6f93b6654e708c 100644
--- a/react-native.web.mjs
+++ b/react-native.web.mjs
@@ -6539,8 +6539,11 @@ function ScrollAdjust() {
window.getComputedStyle(contentNode)[axis.paddingEndProp]
);
const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;
- temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: temporaryPaddingEnd };
contentNode.style[axis.paddingEndProp] = temporaryPaddingEnd;
+ // CSSOM rounds fractional pixels (2123.1875px becomes 2123.19px).
+ // Track the serialized value so ownership checks and cleanup
+ // match what the browser actually stored, not the input string.
+ temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: contentNode.style[axis.paddingEndProp] };
void contentNode.offsetHeight;
scrollBy();
if (resetPaddingRafRef.current !== void 0) {
diff --git a/react.js b/react.js
index 5b171ebd6fa7ac86b0148fe17f897a0ca821d55e..954235cd2b970e0dbb6b78f650633e4f07242558 100644
--- a/react.js
+++ b/react.js
@@ -6560,8 +6560,11 @@ function ScrollAdjust() {
window.getComputedStyle(contentNode)[axis.paddingEndProp]
);
const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;
- temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: temporaryPaddingEnd };
contentNode.style[axis.paddingEndProp] = temporaryPaddingEnd;
+ // CSSOM rounds fractional pixels (2123.1875px becomes 2123.19px).
+ // Track the serialized value so ownership checks and cleanup
+ // match what the browser actually stored, not the input string.
+ temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: contentNode.style[axis.paddingEndProp] };
void contentNode.offsetHeight;
scrollBy();
if (resetPaddingRafRef.current !== void 0) {
diff --git a/react.mjs b/react.mjs
index cf549ca32946a9e35a883290c11423330bf8728b..d6508ee3b88e2f79c5ee9b994f6f93b6654e708c 100644
--- a/react.mjs
+++ b/react.mjs
@@ -6539,8 +6539,11 @@ function ScrollAdjust() {
window.getComputedStyle(contentNode)[axis.paddingEndProp]
);
const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;
- temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: temporaryPaddingEnd };
contentNode.style[axis.paddingEndProp] = temporaryPaddingEnd;
+ // CSSOM rounds fractional pixels (2123.1875px becomes 2123.19px).
+ // Track the serialized value so ownership checks and cleanup
+ // match what the browser actually stored, not the input string.
+ temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: contentNode.style[axis.paddingEndProp] };
void contentNode.offsetHeight;
scrollBy();
if (resetPaddingRafRef.current !== void 0) {
+3 -2
View File
@@ -166,7 +166,7 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@legendapp/list": "3.3.8", "@legendapp/list": "3.3.10",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "1.18.29", "@opencode-ai/sdk": "1.18.29",
"@pierre/diffs": "1.3.0-beta.6", "@pierre/diffs": "1.3.0-beta.6",
@@ -350,6 +350,7 @@
], ],
"patchedDependencies": { "patchedDependencies": {
"@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch", "@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch",
"@legendapp/list@3.3.10": "bun-patches/@legendapp%2Flist@3.3.10.patch",
"bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch", "bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch",
}, },
"overrides": { "overrides": {
@@ -917,7 +918,7 @@
"@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="],
"@legendapp/list": ["@legendapp/list@3.3.8", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-GM4Hca/6WDvcY33XXCieR9MaG9CoZmACzwqQwRhKFSaNKfQV1lTLiTOpWuA9wnE8n8+6WeA52DwNKC9yrnPPeg=="], "@legendapp/list": ["@legendapp/list@3.3.10", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-S8cwV11oJJD2m47JoRnCmlgbUrnY/f48TPL1zl1C3wwyrvru53Zf1NtYFlMtAU6lUKh/cU9RIffLI7unbf3kmg=="],
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
+2 -1
View File
@@ -198,6 +198,7 @@
}, },
"patchedDependencies": { "patchedDependencies": {
"@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch", "@tanstack/virtual-core@3.17.3": "bun-patches/@tanstack+virtual-core+3.17.3.patch",
"bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch" "bun-pty@0.4.8": "bun-patches/bun-pty@0.4.8.patch",
"@legendapp/list@3.3.10": "bun-patches/@legendapp%2Flist@3.3.10.patch"
} }
} }
+1 -1
View File
@@ -44,7 +44,7 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@legendapp/list": "3.3.8", "@legendapp/list": "3.3.10",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "1.18.29", "@opencode-ai/sdk": "1.18.29",
"@pierre/diffs": "1.3.0-beta.6", "@pierre/diffs": "1.3.0-beta.6",
@@ -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(() => { React.useLayoutEffect(() => {
const element = ref.current; const element = ref.current;
if (!element || previousExpanded.current === expanded) return; if (!element) return;
previousExpanded.current = expanded; const settle = () => {
if (expanded) setRetained(true);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
element.style.height = expanded ? 'auto' : '0px'; element.style.height = expanded ? 'auto' : '0px';
element.style.overflow = expanded ? 'visible' : 'hidden'; element.style.overflow = expanded ? 'visible' : 'hidden';
setRetained(expanded); 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; return;
} }
element.style.height = expanded ? '0px' : `${element.scrollHeight}px`; 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], ease: [0.16, 1, 0.3, 1],
}); });
let cancelled = false; let cancelled = false;
void animation.finished.then(() => { const finish = () => {
if (cancelled) return; if (!cancelled) settle();
element.style.height = expanded ? 'auto' : '0px'; };
element.style.overflow = expanded ? 'visible' : 'hidden'; void animation.finished.then(finish, finish);
setRetained(expanded);
}).catch(() => undefined);
return () => { return () => {
cancelled = true; cancelled = true;
animation.stop(); 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. respects reduced motion, and delegates end pinning to the existing timeline.
It never calls scroll-to-bottom. Collapsed history does not mount its hidden It never calls scroll-to-bottom. Collapsed history does not mount its hidden
message bodies; initial history loads do not animate collapse. 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 The header retains its report when expanded and has no hover background. Its
left inset matches sorted Activity. Diff deletions use the ASCII hyphen. left inset matches sorted Activity. Diff deletions use the ASCII hyphen.
+116
View File
@@ -0,0 +1,116 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { runInNewContext } from 'node:vm';
const requireUI = createRequire(new URL('../packages/ui/package.json', import.meta.url));
const packageDirectory = dirname(requireUI.resolve('@legendapp/list/react'));
const bundles = ['react.js', 'react.mjs', 'react-native.web.js', 'react-native.web.mjs'];
// Exercise the installed dependency's actual private ScrollAdjust controller.
// Only its external hooks, DOM geometry and frame scheduler are supplied here.
// Keeping the controller in the package (rather than copying it into a test)
// makes a missing patch or a changed upstream implementation fail this check.
function controller(bundle, { horizontal = false, baseline = '' } = {}) {
const source = readFileSync(join(packageDirectory, bundle), 'utf8');
const start = source.indexOf('function ScrollAdjust() {');
const end = source.indexOf('var SnapWrapper', start);
assert.ok(start >= 0 && end > start, "Find the pinned package version's ScrollAdjust implementation");
const hooks = { useRef: (value) => ({ current: value }), useCallback: (callback) => callback };
const signals = new Map([['scrollAdjust', 0], ['scrollAdjustUserOffset', 0]]);
const listeners = new Map();
const frames = new Map();
let frameId = 0;
const paddingKey = horizontal ? 'paddingRight' : 'paddingBottom';
let padding = baseline;
const style = {};
Object.defineProperty(style, paddingKey, {
get: () => padding,
set(value) {
// Observed in Chromium: assigning 2123.1875px reads back as
// 2123.19px. This normalization is what a plain JS style mock misses.
padding = value.endsWith('px') ? `${Number(Number.parseFloat(value).toPrecision(6))}px` : value;
},
});
const contentNode = {
style,
get scrollHeight() { return 300 + (Number.parseFloat(padding) || 0); },
get scrollWidth() { return 300 + (Number.parseFloat(padding) || 0); },
get offsetHeight() { return this.scrollHeight; },
};
const scrollElement = {
scrollTop: 0, scrollLeft: 0, clientHeight: 300, clientWidth: 300,
scrollBy({ left, top }) { this.scrollLeft += left; this.scrollTop += top; },
};
const ctx = { state: { props: { horizontal }, scroll: 0, adjustingFromInitialMount: false } };
const ScrollAdjust = runInNewContext(`(${source.slice(start, end).trim()})`, {
React3: hooks,
React3__namespace: hooks,
useStateContext: () => ctx,
peek$: (_ctx, key) => signals.get(key),
useValueListener$: (key, callback) => listeners.set(key, callback),
getScrollAdjustTarget: () => ({ contentNode, scrollElement }),
getScrollAdjustAxis: () => ({
x: horizontal ? 1 : 0, y: horizontal ? 0 : 1,
contentSizeKey: horizontal ? 'scrollWidth' : 'scrollHeight',
viewportSizeKey: horizontal ? 'clientWidth' : 'clientHeight',
paddingEndProp: paddingKey,
}),
scrollAdjustBy: (element, left, top) => element.scrollBy({ left, top }),
window: { getComputedStyle: () => style },
requestAnimationFrame: (callback) => { frames.set(++frameId, callback); return frameId; },
cancelAnimationFrame: (id) => frames.delete(id),
});
ScrollAdjust();
return {
get padding() { return padding; },
set padding(value) { style[paddingKey] = value; },
adjust(offset) {
signals.set('scrollAdjustUserOffset', offset);
listeners.get('scrollAdjustUserOffset')();
},
finishFrame() {
const callbacks = [...frames.values()];
frames.clear();
for (const callback of callbacks) callback();
},
};
}
for (const bundle of bundles) {
describe(`LegendList temporary padding: ${bundle}`, () => {
it('removes browser-rounded fractional padding on the next frame', () => {
const view = controller(bundle);
view.adjust(1061.59375);
assert.equal(view.padding, '2123.19px');
view.finishFrame();
assert.equal(view.padding, '');
});
it('preserves the original baseline across overlapping adjustments', () => {
const view = controller(bundle, { baseline: '12px' });
view.adjust(1061.59375);
view.adjust(5000.25);
view.finishFrame();
assert.equal(view.padding, '12px');
});
it('cleans up the horizontal web entry point too', () => {
const view = controller(bundle, { horizontal: true });
view.adjust(1061.59375);
view.finishFrame();
assert.equal(view.padding, '');
});
it('does not clear padding changed by another owner', () => {
const view = controller(bundle);
view.adjust(1061.59375);
view.padding = '28px';
view.finishFrame();
assert.equal(view.padding, '28px');
});
});
}