fix(desktop): consolidate prepend scroll correction (#2855)

## Summary

- make Virtua the sole prepend geometry/correction owner by accumulating
active prepend ResizeObserver corrections from the live DOM offset
- retire prepend reconciliation on ordinary reader wheel input, while
preserving it for Ctrl+wheel browser zoom
- remove Buzz's competing three-second semantic-anchor watcher and
corrective `scrollBy` loop
- keep ESM/CJS Virtua patch behavior equivalent and update the patch
lock hash

## Why

Buzz admitted prepended rows using seeded estimates, then Virtua
received multiple measurement corrections for the same transaction. Each
correction was based on the same stale model offset, so a later write
replaced an earlier correction instead of accumulating it. In the
reproduced first page, that resurrected 452px of anchor drift; the
app-level watcher merely corrected the lost virtualizer write afterward.

This fixes the correction inside Virtua and deletes the competing app
writer, following the single-owner geometry invariant used by Berd
rather than copying its spacer implementation.

## Validation

- watcher-off desktop virtualization matrix: 11/11 passed, including 15
cascading prepends, continued wheel input, detached rich-row growth,
channel switching, bottom follow, and buffered live arrivals
- focused cascading prepend/Ctrl+wheel regression passed
- desktop typecheck passed
- desktop unit suite passed: 3,495 tests
- Biome passed on changed desktop files
- `git diff --check` clean

## Manual behavior

Load older history repeatedly while scrolling upward, then wheel
downward during/after a prepend. The visible anchor should stay within
the existing 5px contract during reconciliation, and deliberate reader
movement should not be pulled back. Ctrl+wheel during the prepend commit
must not cancel reconciliation.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-07-25 16:49:56 -07:00
committed by GitHub
co-authored by Carl Carl
parent c7089d3b52
commit 25e7864b35
15 changed files with 744 additions and 214 deletions
@@ -335,6 +335,7 @@ const MessageTimelineBase = React.forwardRef<
scrollContainerRef: activeScrollContainerRef,
splitPanelOpen: splitThreadPanelOpen,
targetMessageId,
virtualCancelBottomIntent: timelineVirtualizerApi?.cancelBottomIntent,
virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage,
virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom,
virtualSettleAtBottom: timelineVirtualizerApi?.settleAtBottom,
@@ -42,6 +42,7 @@ import { useUpwardPaginationWheel } from "./useUpwardPaginationWheel";
import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle";
export type TimelineVirtualizerApi = {
cancelBottomIntent: () => void;
scrollToBottom: (behavior?: ScrollBehavior) => void;
settleAtBottom: () => void;
scrollToMessage: (
@@ -454,145 +455,39 @@ function VirtualizedTimelineRows({
const keys = React.useMemo(() => items.map(virtualizedItemKey), [items]);
itemsLengthRef.current = items.length;
const previousKeysRef = React.useRef<readonly string[]>([]);
const prependAnchorRef = React.useRef<{
itemKey: string;
top: number;
} | null>(null);
const prependWatcherFrameRef = React.useRef<number | null>(null);
const [prependShiftEpoch, clearPrependShift] = React.useReducer(
(version: number) => version + 1,
0,
);
// Virtua's `shift` is a one-render instruction, not a persistent mode. If it
// stays true after a prepend, later measurement changes can keep anchoring
// from the end and leave a stale blank range until the next scroll event.
const { cancel: cancelBottomSettle, settle: settleAtBottom } =
useVirtualizedBottomSettle(hostRef, listRef, itemsLengthRef);
const { arm: armUpwardMomentum } = useUpwardPaginationWheel(
hostRef,
cancelBottomSettle,
);
React.useEffect(
() => () => {
cancelBottomSettle();
},
[cancelBottomSettle],
);
const isPrepend = React.useMemo(() => {
void prependShiftEpoch;
return didPrependVirtualizedTimeline(previousKeysRef.current, keys);
}, [keys, prependShiftEpoch]);
const retirePrependAnchor = React.useCallback(() => {
if (prependWatcherFrameRef.current !== null) {
cancelAnimationFrame(prependWatcherFrameRef.current);
}
prependWatcherFrameRef.current = null;
prependAnchorRef.current = null;
}, []);
const { cancel: cancelBottomSettle, settle: settleAtBottom } =
useVirtualizedBottomSettle(hostRef, listRef, itemsLengthRef);
const retireTimelineSettle = React.useCallback(() => {
retirePrependAnchor();
cancelBottomSettle();
}, [cancelBottomSettle, retirePrependAnchor]);
const { arm: armUpwardMomentum, clear: clearUpwardMomentum } =
useUpwardPaginationWheel(hostRef, retireTimelineSettle);
const capturePrependAnchor = React.useCallback(() => {
// Keep the pending capture current while the fetch is in flight. Once the
// prepend commits and the watcher starts, its baseline is frozen.
if (prependWatcherFrameRef.current !== null) return;
const scroller = hostRef.current?.firstElementChild;
if (!(scroller instanceof HTMLDivElement)) return;
const scrollerTop = scroller.getBoundingClientRect().top;
const row = Array.from(
scroller.querySelectorAll<HTMLElement>("[data-timeline-item-key]"),
).find(
(candidate) => candidate.getBoundingClientRect().top >= scrollerTop - 1,
);
const itemKey = row?.dataset.timelineItemKey;
if (!row || !itemKey) return;
prependAnchorRef.current = {
itemKey,
top: row.getBoundingClientRect().top - scrollerTop,
};
}, []);
React.useLayoutEffect(() => {
if (!isPrepend || !prependAnchorRef.current) return;
// Virtua's shift mode correctly absorbs prepended measurements, but
// estimated offsets can misclassify late row growth deep in history. Keep
// the semantic row identity as a short-lived, deviation-gated backstop.
// Do not correct in this commit: Virtua has shifted its estimate but has not
// applied its ResizeObserver batch yet, so that delta is transient and its
// subsequent absolute correction would overwrite our relative write.
// This watcher deliberately survives a temporary row unmount and waits for
// stable geometry so Virtua remains the primary scroll owner.
if (prependWatcherFrameRef.current !== null) {
cancelAnimationFrame(prependWatcherFrameRef.current);
}
const anchor = prependAnchorRef.current;
const deadline = performance.now() + 3_000;
let previousScrollTop: number | null = null;
let settledFrames = 0;
const watch = () => {
const scroller = hostRef.current?.firstElementChild;
if (!(scroller instanceof HTMLDivElement)) {
prependWatcherFrameRef.current = null;
prependAnchorRef.current = null;
return;
}
const atBottom =
scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <=
32;
const row = Array.from(
scroller.querySelectorAll<HTMLElement>("[data-timeline-item-key]"),
).find(
(candidate) => candidate.dataset.timelineItemKey === anchor.itemKey,
);
const top = row
? row.getBoundingClientRect().top - scroller.getBoundingClientRect().top
: null;
const scrollTop = scroller.scrollTop;
settledFrames =
previousScrollTop !== null &&
Math.abs(scrollTop - previousScrollTop) < 0.5
? settledFrames + 1
: 0;
previousScrollTop = scrollTop;
if (row && top !== null && settledFrames >= 2) {
const delta = top - anchor.top;
if (Math.abs(delta) > 4) {
scroller.scrollBy({ top: delta });
settledFrames = 0;
previousScrollTop = null;
}
}
const retired =
performance.now() >= deadline ||
atBottom ||
(top !== null && top > scroller.clientHeight * 2);
if (retired) {
retirePrependAnchor();
return;
}
prependWatcherFrameRef.current = requestAnimationFrame(watch);
};
prependWatcherFrameRef.current = requestAnimationFrame(watch);
clearUpwardMomentum();
}, [clearUpwardMomentum, isPrepend, retirePrependAnchor]);
React.useEffect(
() => () => {
retirePrependAnchor();
cancelBottomSettle();
},
[cancelBottomSettle, retirePrependAnchor],
);
React.useLayoutEffect(() => {
previousKeysRef.current = keys;
if (isPrepend) {
cancelBottomSettle();
clearPrependShift();
}
if (!hasInitialPositionedRef.current && items.length > 0) {
hasInitialPositionedRef.current = true;
settleAtBottom();
}
}, [cancelBottomSettle, isPrepend, items.length, keys, settleAtBottom]);
}, [isPrepend, items.length, keys, settleAtBottom]);
const messageItemIndexById = React.useMemo(() => {
const byId = new Map<string, number>();
@@ -622,16 +517,13 @@ function VirtualizedTimelineRows({
React.useLayoutEffect(() => {
if (!onVirtualizerApiChange) return;
const api: TimelineVirtualizerApi = {
cancelBottomIntent: cancelBottomSettle,
scrollToBottom() {
retireTimelineSettle();
const lastIndex = itemsLengthRef.current - 1;
if (lastIndex >= 0) {
listRef.current?.scrollToIndex(lastIndex, { align: "end" });
}
settleAtBottom();
},
settleAtBottom,
scrollToMessage(messageId) {
retireTimelineSettle();
cancelBottomSettle();
const index = messageItemIndexByIdRef.current.get(messageId);
if (index === undefined) return false;
listRef.current?.scrollToIndex(index, { align: "center" });
@@ -640,7 +532,7 @@ function VirtualizedTimelineRows({
};
onVirtualizerApiChange(api);
return () => onVirtualizerApiChange(null);
}, [onVirtualizerApiChange, retireTimelineSettle, settleAtBottom]);
}, [cancelBottomSettle, onVirtualizerApiChange, settleAtBottom]);
React.useLayoutEffect(() => {
const host = hostRef.current;
@@ -670,15 +562,13 @@ function VirtualizedTimelineRows({
if (!list || !(scroller instanceof HTMLDivElement)) return;
onVirtualizerRangeChanged?.();
const distanceFromBottom = list.scrollSize - list.viewportSize - offset;
if (distanceFromBottom > 32) cancelBottomSettle();
// Do not infer reader intent from an intermediate virtualizer offset.
// Initial channel positioning deliberately chases the floor while rows
// are measured; those measurements can briefly report a large gap and
// emit `onScroll` without any user input. Cancelling here strands the
// channel above its newest message. The settle hook's wheel, pointer,
// touch, and key listeners are the authoritative user-interaction gate.
onAtBottomStateChange?.(distanceFromBottom <= 32);
if (
prependAnchorRef.current !== null ||
offset <= 200 ||
prependWatcherFrameRef.current === null
) {
capturePrependAnchor();
}
if (offset <= 200) {
// Layout scrolls near the top must not poison the reader's next input.
armUpwardMomentum(onStartReached?.() ?? false);
@@ -686,8 +576,6 @@ function VirtualizedTimelineRows({
},
[
armUpwardMomentum,
cancelBottomSettle,
capturePrependAnchor,
onAtBottomStateChange,
onStartReached,
onVirtualizerRangeChanged,
@@ -48,6 +48,10 @@ function installDOMShim() {
return this.children[0] ?? null;
}
get firstElementChild() {
return this.children[0] ?? null;
}
get lastChild() {
return this.children.at(-1) ?? null;
}
@@ -122,8 +126,16 @@ function installDOMShim() {
}
globalThis.document = new DocumentShim();
const windowEvents = new EventTargetShim();
globalThis.addEventListener =
windowEvents.addEventListener.bind(windowEvents);
globalThis.removeEventListener =
windowEvents.removeEventListener.bind(windowEvents);
globalThis.dispatchEvent = windowEvents.dispatchEvent.bind(windowEvents);
globalThis.HTMLIFrameElement = NodeShim;
globalThis.HTMLDivElement = NodeShim;
globalThis.HTMLElement = NodeShim;
globalThis.Node = NodeShim;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
process.env.IS_REACT_ACT_ENVIRONMENT = "true";
Object.defineProperty(globalThis, "window", {
@@ -142,6 +154,7 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { useAnchoredScroll } from "./useAnchoredScroll.ts";
import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle.ts";
function makePinnedCenterNodes() {
const resizeObservers = [];
@@ -232,6 +245,32 @@ function Harness({ channelId, refs }) {
return null;
}
function VirtualTargetHarness({ refs }) {
const didRun = React.useRef(false);
const bottomApi = useVirtualizedBottomSettle(
refs.host,
refs.list,
refs.itemsLength,
);
const anchored = useAnchoredScroll({
channelId: "conversation",
contentRef: refs.content,
isLoading: false,
messages: Array.from({ length: 5 }, (_, index) => ({ id: String(index) })),
scrollContainerRef: refs.scroller,
virtualCancelBottomIntent: bottomApi.cancel,
virtualizerOwnsPrependAnchoring: true,
virtualScrollToMessage: () => true,
});
React.useLayoutEffect(() => {
if (didRun.current) return;
didRun.current = true;
bottomApi.settle();
refs.targetResult.current = anchored.scrollToMessage("selected");
}, [anchored.scrollToMessage, bottomApi.settle, refs.targetResult]);
return null;
}
test("channel change attaches pinned-center observers after refs mount", async () => {
const refs = {
container: { current: null },
@@ -272,3 +311,79 @@ test("channel change attaches pinned-center observers after refs mount", async (
root.unmount();
});
});
test("mounted virtual target retires bottom intent before direct centering", async () => {
const resizeObservers = [];
globalThis.ResizeObserver = class {
constructor(callback) {
this.callback = callback;
this.targets = [];
resizeObservers.push(this);
}
disconnect() {}
observe(target) {
this.targets.push(target);
}
};
const content = document.createElement("div");
const scroller = document.createElement("div");
const host = document.createElement("div");
scroller.appendChild(content);
host.appendChild(scroller);
scroller.clientHeight = 400;
scroller.scrollHeight = 1_000;
scroller.scrollTop = 0;
scroller.getBoundingClientRect = () => ({ bottom: 400, top: 0 });
const targetContentTop = 250;
const row = {
getBoundingClientRect: () => ({
bottom: targetContentTop - scroller.scrollTop + 40,
height: 40,
top: targetContentTop - scroller.scrollTop,
}),
};
scroller.querySelector = () => row;
scroller.querySelectorAll = () => [];
scroller.scrollTo = ({ top }) => {
scroller.scrollTop = top;
};
const bottomWrites = [];
const refs = {
content: { current: content },
host: { current: host },
itemsLength: { current: 5 },
list: {
current: {
scrollToIndex: (index, options) =>
bottomWrites.push({ index, options }),
},
},
scroller: { current: scroller },
targetResult: { current: null },
};
const root = createRoot(document.createElement("div"));
await act(async () => {
root.render(React.createElement(VirtualTargetHarness, { refs }));
});
assert.deepEqual(bottomWrites, [{ index: 4, options: { align: "end" } }]);
assert.equal(refs.targetResult.current, true);
assert.equal(row.getBoundingClientRect().top, 180);
const bottomGeometryObserver = resizeObservers.find(
(observer) =>
observer.targets.includes(content) && observer.targets.includes(scroller),
);
assert.ok(bottomGeometryObserver);
bottomGeometryObserver.callback();
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
assert.equal(
row.getBoundingClientRect().top,
180,
"target remains centered after later virtual geometry activity",
);
assert.equal(bottomWrites.length, 1, "geometry cannot re-pin to bottom");
await act(async () => root.unmount());
});
@@ -109,6 +109,7 @@ type UseAnchoredScrollOptions = {
/** Keeps a targeted message centered until the user deliberately scrolls. */
pinTargetCentered?: boolean;
onTargetReached?: (messageId: string) => void;
virtualCancelBottomIntent?: () => void;
virtualScrollToMessage?: (
messageId: string,
options?: { behavior?: ScrollBehavior },
@@ -229,6 +230,7 @@ export function useAnchoredScroll({
highlightTargetMessage = true,
pinTargetCentered = false,
onTargetReached,
virtualCancelBottomIntent,
virtualScrollToMessage,
virtualScrollToBottom,
virtualSettleAtBottom,
@@ -439,6 +441,10 @@ export function useAnchoredScroll({
`[data-message-id="${messageId}"]`,
);
if (virtualizerOwnsPrependAnchoring && virtualScrollToMessage) {
// Target navigation owns the viewport before any movement strategy is
// chosen. The already-mounted fast path centers the DOM node directly
// and would otherwise leave durable bottom intent armed.
virtualCancelBottomIntent?.();
if (el) {
const rect = el.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
@@ -534,6 +540,7 @@ export function useAnchoredScroll({
highlightMessage,
pinTargetCentered,
scrollContainerRef,
virtualCancelBottomIntent,
virtualizerOwnsPrependAnchoring,
writePinnedCenterScroll,
virtualScrollToMessage,
@@ -607,6 +614,11 @@ export function useAnchoredScroll({
// to the requested target message, or to the bottom by default.
if (!hasInitializedRef.current) {
if (isLoading) return;
// The virtualized list owns the actual scroll node. Its API registers in
// a child layout effect, after this parent hook's first pass; treating
// that API-less pass as initialized writes to the inert outer wrapper
// and permanently consumes the channel's initial bottom pin.
if (virtualizerOwnsPrependAnchoring && !virtualScrollToBottom) return;
// Establish the initial position before the browser paints. The follow-up
// frame is a settling pass for content whose measurements land with the
// commit (fonts, deferred rows, media), not the first bottom pin. Keeping
@@ -4,13 +4,13 @@ import * as React from "react";
* Holds an older-history prepend out of the rendered timeline until the
* scroller is genuinely at rest, then admits it atomically.
*
* Why: every prepend-compensation mechanism in this design — Virtua's shift
* correction, the pre-paint scrollBy, the semantic-anchor watcher — is a
* scrollTop write. On macOS WKWebView those writes can be dropped or
* overridden while trackpad momentum owns the committed offset, so a page
* commit that lands mid-fling displaces the viewport by the full prepended
* height with no reliable way to correct it. Committing only at rest keeps
* all three writers operating in the regime where they are exact.
* Why: Virtua reconciles an active prepend by correcting scrollTop as the
* inserted rows are measured. On macOS WKWebView those corrections can be
* dropped or overridden while trackpad momentum owns the committed offset, so
* a page admitted mid-fling can displace the viewport by the full prepended
* height. Admitting only at rest gives Virtua a stable geometry window in
* which to complete that reconciliation exactly; subsequent reader wheel input
* then retires it.
*
* The fetched store stays authoritative and fetches still start immediately;
* this hook only delays when the fetched page joins the rendered snapshot.
@@ -15,6 +15,9 @@ export function useUpwardPaginationWheel(
if (!(scroller instanceof HTMLDivElement)) return;
let releaseTimer: number | null = null;
const handleWheel = (event: WheelEvent) => {
// Ctrl+wheel belongs to browser zoom. It must not retire bottom intent or
// arm upward-pagination momentum because it does not move the reader.
if (event.ctrlKey) return;
onWheel();
if (event.deltaY >= 0) {
clear();
@@ -0,0 +1,280 @@
import assert from "node:assert/strict";
import test from "node:test";
class EventTargetShim {
listeners = new Map();
addEventListener(type, listener) {
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
}
removeEventListener(type, listener) {
this.listeners.set(
type,
(this.listeners.get(type) ?? []).filter(
(current) => current !== listener,
),
);
}
dispatchEvent(event) {
for (const listener of this.listeners.get(event.type) ?? [])
listener(event);
return true;
}
}
class ElementShim extends EventTargetShim {
constructor(firstElementChild = null) {
super();
this.firstElementChild = firstElementChild;
this.children = [];
this.childNodes = [];
this.isContentEditable = false;
this.nodeName = "DIV";
this.tagName = "DIV";
this.nodeType = 1;
this.namespaceURI = "http://www.w3.org/1999/xhtml";
}
get ownerDocument() {
return globalThis.document;
}
appendChild(child) {
this.children.push(child);
this.childNodes.push(child);
return child;
}
removeChild(child) {
this.children = this.children.filter((current) => current !== child);
this.childNodes = this.childNodes.filter((current) => current !== child);
return child;
}
insertBefore(child, reference) {
const index = this.children.indexOf(reference);
if (index < 0) return this.appendChild(child);
this.children.splice(index, 0, child);
this.childNodes.splice(index, 0, child);
return child;
}
closest() {
return null;
}
contains(target) {
return this === target || this.firstElementChild?.contains(target) === true;
}
}
globalThis.document = {
addEventListener() {},
createElement: () => new ElementShim(),
get defaultView() {
return globalThis.window;
},
nodeType: 9,
removeEventListener() {},
};
const animationFrames = new Map();
let nextAnimationFrameId = 1;
globalThis.requestAnimationFrame = (callback) => {
const id = nextAnimationFrameId++;
animationFrames.set(id, callback);
return id;
};
globalThis.cancelAnimationFrame = (id) => animationFrames.delete(id);
globalThis.HTMLElement = ElementShim;
globalThis.HTMLDivElement = ElementShim;
globalThis.Node = ElementShim;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
process.env.IS_REACT_ACT_ENVIRONMENT = "true";
Object.defineProperty(globalThis, "window", {
configurable: true,
value: new EventTargetShim(),
});
globalThis.window.HTMLIFrameElement = ElementShim;
const resizeObservers = [];
globalThis.ResizeObserver = class {
constructor(callback) {
this.callback = callback;
resizeObservers.push(this);
}
disconnect() {}
observe(target) {
this.targets = [...(this.targets ?? []), target];
}
};
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle.ts";
function flushAnimationFrames() {
const callbacks = [...animationFrames.values()];
animationFrames.clear();
for (const callback of callbacks) callback(performance.now());
}
function Harness({ apiRef, hostRef, itemsLengthRef, listRef }) {
apiRef.current = useVirtualizedBottomSettle(hostRef, listRef, itemsLengthRef);
return null;
}
async function mountHarness() {
animationFrames.clear();
resizeObservers.length = 0;
const content = new ElementShim();
const scroller = new ElementShim(content);
const host = new ElementShim(scroller);
const writes = [];
const refs = {
api: { current: null },
host: { current: host },
itemsLength: { current: 5 },
list: {
current: {
scrollToIndex(index, options) {
writes.push({ index, options });
},
},
},
};
const root = createRoot(new ElementShim());
await act(async () => {
root.render(
React.createElement(Harness, {
apiRef: refs.api,
hostRef: refs.host,
itemsLengthRef: refs.itemsLength,
listRef: refs.list,
}),
);
});
return { content, refs, root, scroller, writes };
}
test("bottom intent follows arbitrarily late virtual geometry changes", async () => {
const { content, refs, root, scroller, writes } = await mountHarness();
refs.api.current.settle();
assert.deepEqual(writes, [{ index: 4, options: { align: "end" } }]);
const geometryObserver = resizeObservers.find((observer) =>
observer.targets?.includes(content),
);
assert.ok(geometryObserver);
geometryObserver.callback();
flushAnimationFrames();
assert.equal(writes.length, 2);
// There is no deadline: another geometry change much later still re-pins.
geometryObserver.callback();
flushAnimationFrames();
assert.equal(writes.length, 3);
// Viewport changes also move the physical floor without resizing content.
assert.ok(geometryObserver.targets.includes(scroller));
geometryObserver.callback();
flushAnimationFrames();
assert.equal(writes.length, 4);
await act(async () => root.unmount());
});
for (const eventType of ["pointerdown", "touchmove", "wheel", "keydown"]) {
test(`${eventType} transfers ownership away from bottom intent`, async () => {
const { content, refs, root, scroller, writes } = await mountHarness();
refs.api.current.settle();
const target = eventType === "keydown" ? window : scroller;
target.dispatchEvent({
altKey: false,
ctrlKey: false,
key: eventType === "keydown" ? "PageUp" : undefined,
metaKey: false,
target:
eventType === "keydown"
? scroller
: eventType === "pointerdown"
? scroller
: undefined,
type: eventType,
});
resizeObservers
.find((observer) => observer.targets?.includes(content))
.callback();
flushAnimationFrames();
assert.equal(writes.length, 1);
await act(async () => root.unmount());
assert.equal(
scroller.listeners.get(eventType)?.length ?? 0,
0,
`${eventType} listener is removed on unmount`,
);
});
}
test("descendant interaction and outside navigation preserve bottom intent", async () => {
const { content, refs, root, scroller, writes } = await mountHarness();
refs.api.current.settle();
const rowControl = new ElementShim();
scroller.firstElementChild.firstElementChild = rowControl;
scroller.dispatchEvent({ target: rowControl, type: "pointerdown" });
window.dispatchEvent({
altKey: false,
ctrlKey: false,
key: "PageUp",
metaKey: false,
target: new ElementShim(),
type: "keydown",
});
resizeObservers
.find((observer) => observer.targets?.includes(content))
.callback();
flushAnimationFrames();
assert.equal(writes.length, 2);
await act(async () => root.unmount());
});
test("Ctrl+wheel zoom preserves bottom intent through geometry reflow", async () => {
const { content, refs, root, scroller, writes } = await mountHarness();
refs.api.current.settle();
scroller.dispatchEvent({ ctrlKey: true, deltaY: -100, type: "wheel" });
resizeObservers
.find((observer) => observer.targets?.includes(content))
.callback();
flushAnimationFrames();
assert.equal(writes.length, 2);
await act(async () => root.unmount());
});
test("typing and editable navigation keys preserve bottom intent", async () => {
const { content, refs, root, writes } = await mountHarness();
refs.api.current.settle();
window.dispatchEvent({
altKey: false,
ctrlKey: false,
key: "a",
metaKey: false,
target: null,
type: "keydown",
});
const editable = new ElementShim();
editable.isContentEditable = true;
window.dispatchEvent({
altKey: false,
ctrlKey: false,
key: "ArrowUp",
metaKey: false,
target: editable,
type: "keydown",
});
resizeObservers
.find((observer) => observer.targets?.includes(content))
.callback();
flushAnimationFrames();
assert.equal(writes.length, 2);
await act(async () => root.unmount());
});
@@ -1,67 +1,129 @@
import * as React from "react";
import type { VListHandle } from "virtua";
const BOTTOM_EPSILON_PX = 1;
const SETTLE_DEADLINE_MS = 250;
const SCROLL_INTENT_KEYS = new Set([
"ArrowDown",
"ArrowUp",
"End",
"Home",
"PageDown",
"PageUp",
" ",
]);
function isEditableKeyboardTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
return (
target.closest("input, textarea, select, [contenteditable='true']") !== null
);
}
export function useVirtualizedBottomSettle(
hostRef: React.RefObject<HTMLDivElement | null>,
listRef: React.RefObject<VListHandle | null>,
itemsLengthRef: React.RefObject<number>,
) {
const bottomIntentRef = React.useRef(false);
const frameRef = React.useRef<number | null>(null);
const cancel = React.useCallback(() => {
const cancelFrame = React.useCallback(() => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
}, []);
const cancel = React.useCallback(() => {
bottomIntentRef.current = false;
cancelFrame();
}, [cancelFrame]);
const pinToBottom = React.useCallback(() => {
if (!bottomIntentRef.current) return;
const scroller = hostRef.current?.firstElementChild;
const lastIndex = itemsLengthRef.current - 1;
if (!(scroller instanceof HTMLDivElement) || lastIndex < 0) return;
listRef.current?.scrollToIndex(lastIndex, { align: "end" });
}, [hostRef, itemsLengthRef, listRef]);
const schedulePinToBottom = React.useCallback(() => {
if (!bottomIntentRef.current || frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
pinToBottom();
});
}, [pinToBottom]);
React.useLayoutEffect(() => {
const scroller = hostRef.current?.firstElementChild;
if (!(scroller instanceof HTMLDivElement)) return;
const retire = () => cancel();
scroller.addEventListener("pointerdown", retire, { passive: true });
scroller.addEventListener("touchstart", retire, { passive: true });
scroller.addEventListener("wheel", retire, { passive: true });
window.addEventListener("keydown", retire, true);
const retireForPointer = (event: PointerEvent) => {
// A descendant pointerdown is ordinary row interaction (link, reaction,
// thread action), not evidence that the reader took scroll ownership.
// Direct scroller hits cover scrollbar/background drag initiation.
if (event.target === scroller) cancel();
};
const retireForWheel = (event: WheelEvent) => {
// Ctrl+wheel is browser zoom, not reader navigation. Keep bottom intent
// armed so the resulting viewport/content reflow can settle at the new
// physical floor.
if (!event.ctrlKey) cancel();
};
const retireForScrollKey = (event: KeyboardEvent) => {
if (
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
event.target instanceof Node &&
scroller.contains(event.target) &&
!isEditableKeyboardTarget(event.target) &&
SCROLL_INTENT_KEYS.has(event.key)
) {
cancel();
}
};
scroller.addEventListener("pointerdown", retireForPointer, {
passive: true,
});
scroller.addEventListener("touchmove", retire, { passive: true });
scroller.addEventListener("wheel", retireForWheel, { passive: true });
window.addEventListener("keydown", retireForScrollKey, true);
return () => {
scroller.removeEventListener("pointerdown", retire);
scroller.removeEventListener("touchstart", retire);
scroller.removeEventListener("wheel", retire);
window.removeEventListener("keydown", retire, true);
scroller.removeEventListener("pointerdown", retireForPointer);
scroller.removeEventListener("touchmove", retire);
scroller.removeEventListener("wheel", retireForWheel);
window.removeEventListener("keydown", retireForScrollKey, true);
};
}, [cancel, hostRef]);
React.useLayoutEffect(() => {
const scroller = hostRef.current?.firstElementChild;
if (!(scroller instanceof HTMLDivElement)) return;
const content = scroller.firstElementChild;
if (
!(content instanceof HTMLElement) ||
typeof ResizeObserver === "undefined"
) {
return;
}
// Virtua updates this inner element's extent whenever measured row geometry
// changes. Bottom intent therefore follows the physical floor for as long
// as it remains active, rather than guessing that layout is done after an
// arbitrary timeout. Reader input and explicit target/prepend navigation
// retire the intent through `cancel`.
const observer = new ResizeObserver(schedulePinToBottom);
observer.observe(content);
observer.observe(scroller);
return () => observer.disconnect();
}, [hostRef, schedulePinToBottom]);
const settle = React.useCallback(() => {
cancel();
const deadline = performance.now() + SETTLE_DEADLINE_MS;
let settledFrames = 0;
let previousHeight = -1;
const next = () => {
const scroller = hostRef.current?.firstElementChild;
const lastIndex = itemsLengthRef.current - 1;
if (!(scroller instanceof HTMLDivElement) || lastIndex < 0) {
cancel();
return;
}
listRef.current?.scrollToIndex(lastIndex, { align: "end" });
const atBottom =
scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <=
BOTTOM_EPSILON_PX;
settledFrames =
atBottom && scroller.scrollHeight === previousHeight
? settledFrames + 1
: 0;
previousHeight = scroller.scrollHeight;
if (settledFrames >= 2 || performance.now() >= deadline) {
frameRef.current = null;
return;
}
frameRef.current = requestAnimationFrame(next);
};
next();
}, [cancel, hostRef, itemsLengthRef, listRef]);
bottomIntentRef.current = true;
cancelFrame();
pinToBottom();
}, [cancelFrame, pinToBottom]);
React.useEffect(() => cancel, [cancel]);
return { cancel, settle };
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const patch = await readFile(
new URL("../../../../../patches/virtua@0.49.3.patch", import.meta.url),
"utf8",
);
test("reader wheel retires Virtua shift mode without publishing scroll end", () => {
// Virtua 0.49.3 has no public transition for leaving SCROLL_BY_SHIFT.
// Keep the CJS and ESM patch paths symmetric and deliberately narrower than
// ACTION_SCROLL_END (2), which also idles direction, clears frozen range,
// flushes pending jumps, and emits UPDATE_SCROLL_END_EVENT.
const addedActionBodies = [
...patch.matchAll(/\+\s+case 9:\n((?:\+.*\n)+?)(?=\s*})/g),
].map(([, body]) =>
body
.split("\n")
.map((line) => line.replace(/^\+\s*/, "").trim())
.filter(Boolean),
);
assert.deepEqual(addedActionBodies, [["I = 0;"], ["w = 0;"]]);
assert.match(patch, /\+\s+e\.q\(9\);/);
assert.match(patch, /\+\s+e\.B\(9\);/);
assert.doesNotMatch(patch, /\+\s+e\.(?:q|B)\(2\);/);
});
+71 -5
View File
@@ -68,6 +68,61 @@ async function getMessagePosition(
}, messageId);
}
test("channel switch settles at the newest message after virtualized rows measure", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
await page.evaluate(() => {
const base = Math.floor(Date.now() / 1000);
for (let index = 0; index < 80; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `switch-bottom ${index} ${"variable-height ".repeat(
index % 7,
)}`,
createdAt: base + index,
});
}
});
// Open another channel first so this exercises a real channel switch, where
// the virtualizer API is temporarily null while the keyed list remounts.
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const timeline = page.getByTestId("message-timeline");
await expect(timeline).toContainText("switch-bottom 79");
// Reflow a rendered row well after the removed 250ms settle deadline. The
// geometry-driven bottom intent must still chase the new physical floor.
await timeline.evaluate((element) => {
window.setTimeout(() => {
const row = element.querySelector<HTMLElement>("[data-message-id]");
if (row) {
row.style.minHeight = `${row.getBoundingClientRect().height + 240}px`;
}
element.dataset.delayedBottomReflow = "complete";
}, 600);
});
await expect(timeline).toHaveAttribute(
"data-delayed-bottom-reflow",
"complete",
);
await expect
.poll(async () => {
const metrics = await getTimelineMetrics(page);
return metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop;
})
.toBeLessThanOrEqual(1);
await expect(page.getByTestId("message-scroll-to-latest")).toHaveCount(0);
});
test("first channel load paints the first window without waiting for the row-floor top-up", async ({
page,
}) => {
@@ -168,6 +223,7 @@ test("preserves user scroll while older channel history loads", async ({
const scrollToTop = async () =>
timeline.evaluate((element) => {
const container = element as HTMLDivElement;
container.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
container.scrollTop = 0;
container.dispatchEvent(new Event("scroll", { bubbles: true }));
});
@@ -454,11 +510,20 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a
)
.toBe("resolved");
const afterPrepend = await getTimelineMetrics(page);
// (a) Geometry: timeline still pinned to bottom.
expect(
afterPrepend.scrollTop + afterPrepend.clientHeight,
).toBeGreaterThanOrEqual(afterPrepend.scrollHeight - 2);
// (a) Geometry: timeline settles back to bottom. The durable bottom owner
// batches ResizeObserver-driven geometry correction into requestAnimationFrame,
// so scrollHeight growth and the physical-floor write need not share a turn.
await expect
.poll(
async () => {
const metrics = await getTimelineMetrics(page);
return (
metrics.scrollTop + metrics.clientHeight >= metrics.scrollHeight - 2
);
},
{ timeout: 2_000 },
)
.toBe(true);
// (b) DOM: the last rendered [data-message-id] sits within 2px of the
// timeline's bottom edge. This catches a class of bugs where the geometry
@@ -1641,6 +1706,7 @@ test("channel intro stays hidden while paginating past the timeline cap", async
const scrollToTop = async () =>
timeline.evaluate((element) => {
const container = element as HTMLDivElement;
container.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
container.scrollTop = 0;
container.dispatchEvent(new Event("scroll", { bubbles: true }));
});
+3
View File
@@ -634,6 +634,9 @@ test("does not shift the timeline when the composer grows", async ({
await page.waitForTimeout(400);
await page.getByTestId("message-timeline").evaluate((element) => {
const timeline = element as HTMLDivElement;
// The raw position assignment sets up detached history, while wheel is the
// same ownership signal a real reader produces before composer reflow.
timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
timeline.scrollTop = 0;
timeline.dispatchEvent(new Event("scroll"));
});
@@ -481,6 +481,7 @@ test("timeline prepend plus late row reflow keeps the reading row stable", async
await page.waitForTimeout(100);
await timeline.evaluate((element) => {
const scroller = element as HTMLDivElement;
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
scroller.scrollTop = 150;
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
});
+32 -7
View File
@@ -297,8 +297,25 @@ test.describe("list virtualization", () => {
});
const committedAnchor = await sampleVisibleAnchor(before.id);
const motion = await timeline.evaluate(
async (scroller, { anchorId, anchorTop, oldHeight }) => {
async (scroller, { anchorId, anchorTop, oldHeight, testCtrlWheel }) => {
const s = scroller as HTMLElement;
let ctrlWheelDispatched = false;
const mutationObserver = testCtrlWheel
? new MutationObserver(() => {
if (ctrlWheelDispatched) return;
ctrlWheelDispatched = true;
// Ctrl+wheel is browser zoom, not reader scroll intent. Fire it
// synchronously with the prepend DOM commit, before the
// ResizeObserver measurement batch reconciles estimated rows.
s.dispatchEvent(
new WheelEvent("wheel", { ctrlKey: true, deltaY: -100 }),
);
})
: null;
mutationObserver?.observe(s.firstElementChild ?? s, {
childList: true,
subtree: true,
});
let maxDrift = 0;
let sawPrepend = false;
let sawAnchorAfterPrepend = false;
@@ -326,15 +343,20 @@ test.describe("list virtualization", () => {
if (sawAnchorAfterPrepend && stableFrames >= 8) break;
await new Promise((resolve) => requestAnimationFrame(resolve));
}
return { maxDrift, sawPrepend };
mutationObserver?.disconnect();
return { ctrlWheelDispatched, maxDrift, sawPrepend };
},
{
anchorId: committedAnchor.id,
anchorTop: committedAnchor.top,
oldHeight: before.scrollHeight,
// Exercise browser-zoom input once while a real prepend transaction
// still owns its measurement reconciliation.
testCtrlWheel: pageIndex === 0,
},
);
expect(motion.sawPrepend).toBe(true);
if (pageIndex === 0) expect(motion.ctrlWheelDispatched).toBe(true);
expect(motion.maxDrift).toBeLessThan(5);
await expect
@@ -357,11 +379,10 @@ test.describe("list virtualization", () => {
await timeline.evaluate((element) => element.clientHeight),
);
// Leave the boundary with real downward wheel input while this prepend's
// three-second semantic-anchor watcher is still alive. The watcher belongs
// only to the completed prepend: it must not reinterpret this deliberate
// reader movement as row drift and pull the viewport back toward its stale
// baseline before the next upward load.
// Leave the boundary with real downward wheel input after this prepend.
// That reader intent retires Virtua's active prepend reconciliation, so
// later row measurements must not pull the viewport back toward the
// completed prepend before the next upward load.
const exitTracePromise = timeline.evaluate(async (scroller) => {
const s = scroller as HTMLElement;
const startScrollTop = s.scrollTop;
@@ -667,6 +688,10 @@ test("offscreen rich-row resize preserves the viewport-center anchor", async ({
const result = await timeline.evaluate(async (element) => {
const scroller = element as HTMLDivElement;
// Retire bottom-follow intent the same way real reader input does before
// moving into detached history. A raw scrollTop assignment alone is not
// user intent and would correctly leave bottom following armed.
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
scroller.scrollTop = scroller.scrollHeight / 2;
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 250));
+62 -15
View File
@@ -1,5 +1,5 @@
diff --git a/lib/index.cjs b/lib/index.cjs
index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..c3bbc7f4d8dd396f5329a40a2701f4f752e3c5cc 100644
index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..b12677a356f748ebccf6e16cb781efe1315bb55b 100644
--- a/lib/index.cjs
+++ b/lib/index.cjs
@@ -35,10 +35,18 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
@@ -39,11 +39,39 @@ index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..c3bbc7f4d8dd396f5329a40a2701f4f7
case 5:
- t[1] ? (L(w(M, t[0], !0)), I = 2, l = 1) : (w(M, t[0]), l = 1);
+ t[1] ? (L(w(M, t[0], !0, t[2])), I = 2, l = 1) : (w(M, t[0], !1, t[2]), l = 1);
+ t[1] ? (L(w(M, t[0], !0, t[2])), I = 2, l = 1) : (w(M, t[0], !1, t[2]), I = 0, l = 1);
break;
case 6:
@@ -519,11 +527,11 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
@@ -160,6 +168,10 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
case 8:
k = W(t, t + a), l = 1;
+ break;
+
+ case 9:
+ I = 0;
}
l && (d = 1 + (2147483647 & d), o && z && ($ += z, z = 0), O.forEach(([e, t]) => {
l & e && t(n);
@@ -186,6 +198,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
c = l(), d && (a = !0), i && e.q(6, i()), e.q(1, n()), h();
}, p = t => {
if (f || !e.M() || t.ctrlKey) return;
+ e.q(9);
const r = l() - c;
150 > r && 50 < r && (o ? t.deltaX : t.deltaY) && (f = !0);
}, v = () => {
@@ -266,7 +279,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
e[l] = t;
});
}
- f[i] = R(e.C() + t, s), o && u();
+ f[i] = R((o ? R(f[i], s) : e.C()) + t, s), o && u();
}), n[1](!0);
},
v() {
@@ -519,11 +532,11 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o,
})(e);
return [ e => t[e], t.length ];
}, [ e, o ]), j = /*#__PURE__*/ t.forwardRef(({children: r, data: n, bufferSize: s, itemSize: i, shift: l, horizontal: c, keepMounted: d, cache: a, startMargin: h = 0, ssrCount: g, as: p = "div", item: v = "div", scrollRef: _, onScroll: w, onScrollEnd: S}, m) => {
@@ -59,7 +87,7 @@ index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..c3bbc7f4d8dd396f5329a40a2701f4f7
const o = $(t);
return e.jsx(Y, {
diff --git a/lib/index.js b/lib/index.js
index 110ac3858a002a6cdb698da2b56350bc1bf609d2..81d239dad48d4453efd5ce7f8397555c66c99d56 100644
index 110ac3858a002a6cdb698da2b56350bc1bf609d2..db83efc83fb3b6d7aa07d83b7463d6ed7746287e 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -1,7 +1,7 @@
@@ -102,25 +130,44 @@ index 110ac3858a002a6cdb698da2b56350bc1bf609d2..81d239dad48d4453efd5ce7f8397555c
if (n = d(n, e.l - 1), b(e, n) <= t) {
const r = y(e, o, n);
return [ y(e, t, n, r), r ];
@@ -124,7 +132,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
if (!e.length) break;
N(e.reduce((e, [t, o]) => {
let n;
- if (2 === w) n = !0; else if (I && 1 === w) n = t < I[0]; else {
+ if (2 === w) n = J(t) < E(); else if (I && 1 === w) n = t < I[0]; else {
const e = E(), o = J(t), r = A(t);
n = 1 !== _ && 0 === w ? o + r <= e : o < e && o + r < e + l;
}
@@ -151,7 +159,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
break;
case 5:
- t[1] ? (N(x(M, t[0], !0)), w = 2, d = 1) : (x(M, t[0]), d = 1);
+ t[1] ? (N(x(M, t[0], !0, t[2])), w = 2, d = 1) : (x(M, t[0], !1, t[2]), d = 1);
+ t[1] ? (N(x(M, t[0], !0, t[2])), w = 2, d = 1) : (x(M, t[0], !1, t[2]), w = 0, d = 1);
break;
case 6:
@@ -523,11 +531,11 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
@@ -164,6 +172,10 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
case 8:
I = W(t, t + l), d = 1;
+ break;
+
+ case 9:
+ w = 0;
}
d && (i = 1 + (2147483647 & i), o && p && (g += p, p = 0), O.forEach(([e, t]) => {
d & e && t(n);
@@ -190,6 +202,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
l = i(), d && (a = !0), s && e.B(6, s()), e.B(1, n()), h();
}, p = t => {
if (c || !e.M() || t.ctrlKey) return;
+ e.B(9);
const n = i() - l;
150 > n && 50 < n && (o ? t.deltaX : t.deltaY) && (c = !0);
}, v = () => {
@@ -270,7 +283,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
e[l] = t;
});
}
- u[i] = W(e.C() + t, s), o && f();
+ u[i] = W((o ? W(u[i], s) : e.C()) + t, s), o && f();
}), r[1](!0);
},
v() {
@@ -523,11 +536,11 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
})(e);
return [ e => o[e], o.length ];
}, [ e, t ]), Z = /*#__PURE__*/ i(({children: t, data: o, bufferSize: r, itemSize: s, shift: i, horizontal: u, keepMounted: d, cache: a, startMargin: h = 0, ssrCount: g, as: p = "div", item: _ = "div", scrollRef: w, onScroll: S, onScrollEnd: $}, z) => {
+3 -3
View File
@@ -9,7 +9,7 @@ overrides:
patchedDependencies:
isomorphic-git: e9b414a60d4cf1d8aa18f7a779483984e821989967c235662566e94ef0238d3f
virtua@0.49.3: acef47b2cfcb8bfd36668c30a16f918fe492eb07a1ddeafc8cc3a22a3efbf71b
virtua@0.49.3: 63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc
importers:
@@ -232,7 +232,7 @@ importers:
version: 2.1.0
virtua:
specifier: 0.49.3
version: 0.49.3(patch_hash=acef47b2cfcb8bfd36668c30a16f918fe492eb07a1ddeafc8cc3a22a3efbf71b)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 0.49.3(patch_hash=63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
yaml:
specifier: ^2.8.3
version: 2.9.0
@@ -6926,7 +6926,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
virtua@0.49.3(patch_hash=acef47b2cfcb8bfd36668c30a16f918fe492eb07a1ddeafc8cc3a22a3efbf71b)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
virtua@0.49.3(patch_hash=63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
optionalDependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)