spike(timeline): assess @tanstack/react-virtual feasibility for main timeline

Read-only Phase 2 feasibility spike (NOT ship-ready). Assesses whether
@tanstack/react-virtual can cleanly own the main timeline, replacing the
bespoke scrollTop-locking scroll manager and the ~2,000-row render ceiling.

Artifacts:
- buildVirtualTimelineRows.ts: pure helper flattening day-grouped entries
  into a flat indexed row list (day-divider + message kinds) the virtualizer
  can measure/key; includes findVirtualRowIndexForMessage for deep-link/find.
- buildVirtualTimelineRows.test.mjs: 9 tests covering dividers, renderKey
  keying, prepend key stability (native position-retention contract), and
  flat-index lookup.
- __spike__/VirtualizedTimelinePoc.tsx: thin non-wired PoC showing the
  react-virtual integration shape (autoscroll, prepend retention via
  getItemKey, scrollToIndex for find/deep-link).
- __spike__/FEASIBILITY.md: honest verdict on the three scoped points —
  sticky-bottom autoscroll, scroll-up pagination with native position
  retention, and cmd+F find-in-page plan.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-15 18:42:25 -07:00
co-authored by Taylor Ho
parent cbc754cffb
commit 606dfac37e
6 changed files with 554 additions and 3 deletions
+1
View File
@@ -43,6 +43,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.168.10",
"@tanstack/react-virtual": "^3.14.2",
"@tauri-apps/api": "~2.11",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "~2.5",
@@ -0,0 +1,137 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildVirtualTimelineRows,
findVirtualRowIndexForMessage,
} from "./buildVirtualTimelineRows.ts";
const DAY = 24 * 60 * 60;
// Anchor at local noon so day boundaries are unambiguous across timezones.
const DAY_1 = Math.floor(new Date(2026, 0, 1, 12, 0, 0).getTime() / 1000);
const DAY_2 = DAY_1 + DAY;
const DAY_3 = DAY_2 + DAY;
function message(overrides) {
return {
id: "message",
createdAt: DAY_1,
depth: 0,
kind: 9,
...overrides,
};
}
test("empty snapshot produces no rows", () => {
assert.deepEqual(buildVirtualTimelineRows([]), []);
});
test("single message emits one divider then the message row", () => {
const rows = buildVirtualTimelineRows([message({ id: "a" })]);
assert.equal(rows.length, 2);
assert.equal(rows[0].kind, "day-divider");
assert.equal(rows[0].messageIndex, -1);
assert.equal(rows[1].kind, "message");
assert.equal(rows[1].messageIndex, 0);
assert.equal(rows[1].key, "a");
});
test("messages on the same day share a single divider", () => {
const rows = buildVirtualTimelineRows([
message({ id: "a", createdAt: DAY_1 }),
message({ id: "b", createdAt: DAY_1 + 60 }),
message({ id: "c", createdAt: DAY_1 + 120 }),
]);
// 1 divider + 3 messages
assert.equal(rows.length, 4);
assert.equal(rows.filter((r) => r.kind === "day-divider").length, 1);
assert.deepEqual(
rows.filter((r) => r.kind === "message").map((r) => r.messageIndex),
[0, 1, 2],
);
});
test("a new calendar day inserts a fresh divider before its first message", () => {
const rows = buildVirtualTimelineRows([
message({ id: "a", createdAt: DAY_1 }),
message({ id: "b", createdAt: DAY_2 }),
message({ id: "c", createdAt: DAY_3 }),
]);
// 3 dividers + 3 messages, strictly interleaved
assert.deepEqual(
rows.map((r) => r.kind),
[
"day-divider",
"message",
"day-divider",
"message",
"day-divider",
"message",
],
);
// message indices stay monotonic and correct across dividers
assert.deepEqual(
rows.filter((r) => r.kind === "message").map((r) => r.messageIndex),
[0, 1, 2],
);
});
test("message row key prefers renderKey over id (optimistic-send stability)", () => {
const rows = buildVirtualTimelineRows([
message({ id: "server-id", renderKey: "local-key" }),
]);
const messageRow = rows.find((r) => r.kind === "message");
assert.equal(messageRow.key, "local-key");
});
test("divider keys are stable across re-flatten of the same snapshot", () => {
const snapshot = [
message({ id: "a", createdAt: DAY_1 }),
message({ id: "b", createdAt: DAY_2 }),
];
const first = buildVirtualTimelineRows(snapshot);
const second = buildVirtualTimelineRows(snapshot);
assert.deepEqual(
first.map((r) => r.key),
second.map((r) => r.key),
);
});
test("findVirtualRowIndexForMessage returns the FLAT index, accounting for dividers", () => {
const messages = [
message({ id: "a", createdAt: DAY_1 }),
message({ id: "b", createdAt: DAY_2 }),
message({ id: "c", createdAt: DAY_2 + 60 }),
];
const rows = buildVirtualTimelineRows(messages);
// layout: [div, a, div, b, c] -> flat indices 0..4
assert.equal(findVirtualRowIndexForMessage(rows, "a", messages), 1);
assert.equal(findVirtualRowIndexForMessage(rows, "b", messages), 3);
assert.equal(findVirtualRowIndexForMessage(rows, "c", messages), 4);
});
test("findVirtualRowIndexForMessage returns -1 for an absent or empty target", () => {
const messages = [message({ id: "a" })];
const rows = buildVirtualTimelineRows(messages);
assert.equal(findVirtualRowIndexForMessage(rows, "missing", messages), -1);
assert.equal(findVirtualRowIndexForMessage(rows, null, messages), -1);
assert.equal(findVirtualRowIndexForMessage(rows, undefined, messages), -1);
});
test("prepend keeps surviving rows' keys stable (native position-retention contract)", () => {
// Older page prepended at the top: 'a' was the head, now 'older' precedes it.
const before = [message({ id: "a", createdAt: DAY_2 })];
const after = [
message({ id: "older", createdAt: DAY_1 }),
message({ id: "a", createdAt: DAY_2 }),
];
const rowsBefore = buildVirtualTimelineRows(before);
const rowsAfter = buildVirtualTimelineRows(after);
const keyOfA = (rows) =>
rows.find((r) => r.kind === "message" && r.key === "a")?.key;
// 'a' keeps its identity across the prepend -> virtualizer measurement cache
// survives -> scroll position re-anchors natively (no double-rAF correction).
assert.equal(keyOfA(rowsBefore), "a");
assert.equal(keyOfA(rowsAfter), "a");
});
@@ -0,0 +1,124 @@
/**
* SPIKE (Phase 2 virtualization feasibility): flatten the day-grouped main
* timeline into a single ordered list of "virtual rows".
*
* `@tanstack/react-virtual` measures and keys a FLAT, index-addressable list. But
* the live timeline renders a NESTED shape — `<section>` per day, each wrapping a
* `<DayDivider>` plus its message `<div>`s (see `TimelineMessageList.tsx`). A
* virtualizer can't measure a nested-section tree; it needs every visually
* distinct, separately-measured thing (each divider AND each message) as its own
* top-level row.
*
* This helper performs exactly that flattening, off the SAME snapshot the rows
* render from (the correctness property `timelineSnapshot.ts` documents). It
* reuses `buildDayGroupBoundaries` so divider placement is byte-identical to the
* current render — no second source of truth for "where does a day start".
*
* Two row kinds come out, in render order:
* - { kind: "day-divider", key, label-source timestamp, messageIndex: -1 }
* - { kind: "message", key, messageIndex } — index back into the snapshot
*
* `key` is the virtualizer's stable identity (`getItemKey`). For messages it
* prefers `renderKey` (stable across optimistic send-ack, mirroring
* `selectLatestMessageKey`) and falls back to `id`. Stable keys are what let the
* virtualizer hold scroll position on PREPEND: when older pages splice in at the
* top, every surviving row keeps its key, so the measurement cache survives and
* the virtualizer re-anchors natively — the mechanism that lets us DELETE the
* double-rAF `scrollTop` correction in `useLoadOlderOnScroll.ts`.
*/
import type { TimelineMessage } from "@/features/messages/types";
import { buildDayGroupBoundaries } from "./timelineSnapshot";
/** A divider row — one per calendar-day boundary. Carries no message. */
export type VirtualDayDividerRow = {
kind: "day-divider";
/** Stable virtualizer key. */
key: string;
/** `createdAt` (unix seconds) of the first message in the day; drives the label. */
headingTimestamp: number;
/** Always -1 for dividers — they don't map to a message. */
messageIndex: -1;
};
/** A message row — one per timeline message, in snapshot order. */
export type VirtualMessageRow = {
kind: "message";
/** Stable virtualizer key — prefers `renderKey`, falls back to `id`. */
key: string;
/** Index back into the source snapshot, for O(1) message lookup at render. */
messageIndex: number;
};
export type VirtualTimelineRow = VirtualDayDividerRow | VirtualMessageRow;
/**
* Flatten a message snapshot into ordered virtual rows (dividers + messages).
*
* Walks the snapshot once; emits a divider row at each day-group start index
* (computed by `buildDayGroupBoundaries`, the same helper the live render uses),
* then the message row. Order is identical to the current nested render read
* top-to-bottom, so a virtualizer driven off this list paints the exact same
* visual sequence.
*/
export function buildVirtualTimelineRows(
messages: readonly TimelineMessage[],
): VirtualTimelineRow[] {
const rows: VirtualTimelineRow[] = [];
const dayStartIndices = new Set(
buildDayGroupBoundaries(messages).map((boundary) => boundary.startIndex),
);
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (dayStartIndices.has(i)) {
rows.push({
kind: "day-divider",
key: `day-${message.createdAt}`,
headingTimestamp: message.createdAt,
messageIndex: -1,
});
}
rows.push({
kind: "message",
key: message.renderKey ?? message.id,
messageIndex: i,
});
}
return rows;
}
/**
* Find the flat virtual-row index for a target message id. The virtualizer's
* `scrollToIndex` needs a FLAT index, not a message index — and the two diverge
* because divider rows are interleaved. This is the bridge that lets BOTH
* jump-to-message deep links AND find-in-page (cmd+F replacement) drive
* `scrollToIndex` against a virtualized list, since the target row may be
* unmounted (the whole reason native find breaks under virtualization).
*
* Returns -1 when the target isn't in this snapshot — same "row not committed
* yet, bail and wait for the next snapshot" contract as `resolveDeepLinkTarget`.
*/
export function findVirtualRowIndexForMessage(
rows: readonly VirtualTimelineRow[],
targetMessageId: string | null | undefined,
messages: readonly TimelineMessage[],
): number {
if (!targetMessageId) {
return -1;
}
const messageIndex = messages.findIndex(
(message) => message.id === targetMessageId,
);
if (messageIndex === -1) {
return -1;
}
return rows.findIndex(
(row) => row.kind === "message" && row.messageIndex === messageIndex,
);
}
@@ -0,0 +1,117 @@
# Phase 2 Virtualization Feasibility Spike
**Verdict: GO.** `@tanstack/react-virtual` can cleanly own the main timeline. It
absorbs scroll-up pagination with native position retention, owns sticky-bottom
autoscroll, and gives `cmd`+F a real plan. The one non-trivial cost — it must
*replace*, not coexist with, the bespoke scroll manager — is expected and
bounded. Nothing in the three proof points is a blocker.
Spike artifacts (all under `desktop/src/features/messages/`):
- `lib/buildVirtualTimelineRows.ts` — pure flatten helper (+ 9 `.test.mjs` tests, all green)
- `ui/__spike__/VirtualizedTimelinePoc.tsx` — integration-shape PoC (not wired)
- this file
Dependency added: `@tanstack/react-virtual@^3.14.2`.
---
## The core structural mismatch (and the fix)
The live render is **nested**: `TimelineMessageList` builds `<section>` per day,
each wrapping a `<DayDivider>` + its message `<div>`s. A virtualizer can't
measure a nested-section tree — it needs a **flat, index-addressable** list where
every separately-measured thing (each divider AND each message) is its own row.
`buildVirtualTimelineRows` does exactly that flattening, reusing
`buildDayGroupBoundaries` so divider placement stays byte-identical to today's
render — no second source of truth. This is the load-bearing transform and it's
fully unit-tested. Day dividers survive as first-class variable-height rows.
---
## Proof point 1 — Sticky-bottom autoscroll (the riskiest) — OWNABLE, requires replacement
**Finding: the virtualizer must REPLACE `useTimelineScrollManager`, not reconcile
with it.** They both want to own the single scroll container, and that's a
head-on collision — you cannot run both.
Why replacement is clean rather than scary: nearly everything the 427-line
bespoke manager does by hand, the virtualizer does natively.
| Bespoke manager does (by hand) | Virtualizer equivalent |
|-----------------------------------------------------------------|-------------------------------------------------|
| `scrollToBottom()` — `scrollIntoView` + `scrollTo` + 2 rAF settle | `scrollToIndex(lastRow, { align: "end" })` |
| `lockedScrollTopRef` scrollTop locking | virtualizer owns scrollTop; no manual lock |
| ResizeObserver re-pinning on content/timeline resize | dynamic `measureElement` re-measures + re-anchors |
| `isNearBottom` / `shouldStickToBottomRef` pin tracking | a `stickToBottom` flag + `scrollToIndex(last)` |
The PoC shows the autoscroll path in ~3 lines. The real migration deletes the
manual `scrollTop` plumbing; the genuinely-bespoke bits worth porting carefully
are the **`accent` smooth-scroll** (highlighted-message smooth vs auto) and the
**`newMessageCount`** ("N new messages" pill when scrolled up) — both are app
state layered ON the scroll position, easy to keep as a thin wrapper around the
virtualizer. **Risk: medium, as Ned flagged — but it's replacement risk, not
"can it be done" risk.**
## Proof point 2 — Scroll-up pagination with NATIVE position retention — YES. Delete the band-aid.
**This is the headline answer Ned asked for: the virtualizer absorbs it
natively. The double-rAF correction in `useLoadOlderOnScroll.ts` gets DELETED,
not patched.**
Mechanism: the virtualizer keys every row by stable identity (`getItemKey`).
When an older page splices in at the top, every surviving row keeps its key, so
its cached measurement and offset survive — the virtualizer re-anchors the
viewport to the same logical rows automatically. No `previousHeight` snapshot, no
`newHeight - previousHeight` delta, no `requestAnimationFrame(requestAnimationFrame(...))`
post-paint `scrollTop` yank. That brittle after-the-paint correction (tho's
flagged jank #1) is *exactly* what stable-key retention replaces.
`useLoadOlderOnScroll` collapses to "fire `fetchOlder` when near top" — the
`IntersectionObserver` trigger stays, the `restoreScrollPosition` plumbing goes.
The `buildVirtualTimelineRows` prepend test locks in the key-stability contract
this depends on.
## Proof point 3 — `cmd`+F find-in-page — PLAN (with code shape proven)
Virtualization unmounts off-screen rows, so native browser find can't see them.
**Plan: in-app find drives `scrollToIndex` to the target row, mounting it on
demand.** The wiring already exists — `searchActiveMessageId` /
`searchMatchingMessageIds` / `searchQuery` are plumbed through the rows today, and
the current search-scroll effect (`MessageTimeline.tsx` ~L182) already does
`querySelector([data-message-id]) + scrollIntoView`. That `querySelector`
approach breaks under virtualization (row may be unmounted), so it gets replaced
by `findVirtualRowIndexForMessage(...) -> virtualizer.scrollToIndex(rowIndex)`,
which scrolls the row into existence first. The PoC proves this exact path.
Recommended additions for the real PR (not in this spike):
- **Match navigation** (next/prev match) reuses the same `scrollToIndex` bridge.
- **"render-all" escape hatch** for true native `cmd`+F: when find opens, optionally
bypass virtualization and render all rows. Keep as a fallback toggle, not the
default — defeats the perf win if always on. My lean: ship the in-app path,
hold the escape hatch unless QA finds a gap.
---
## Must-keeps — all survive
- **Sticky-bottom autoscroll** — proof point 1. ✅ (via replacement)
- **Day dividers (variable-height interleaved rows)** — first-class flat rows via
`buildVirtualTimelineRows` + `measureElement`. ✅
- **Jump-to-message deep links (`resolveDeepLinkTarget`)** — same `scrollToIndex`
bridge as find; `findVirtualRowIndexForMessage` mirrors the "bail if not in
snapshot yet" contract. ✅
- **No-tearing** — the Phase 1 property (drive scroll logic + render off the SAME
snapshot) is PRESERVED: `buildVirtualTimelineRows` consumes the same
`deferredMessages` snapshot the rows render from. ✅
## Recommended sequence for the real PR (post-spike)
1. Land `buildVirtualTimelineRows` + tests (already done here).
2. Build the virtualized list driving off `deferredMessages`; render via the
existing `MessageRow` / `DayDivider` (reuse, don't rebuild).
3. Replace `useTimelineScrollManager` with a thin virtualizer wrapper preserving
`accent` smooth-scroll + `newMessageCount`.
4. Gut `useLoadOlderOnScroll` down to the trigger; delete the rAF correction.
5. Rewire search-scroll + deep-link to `scrollToIndex`.
6. Main timeline ONLY — leave the thread pane (Phase 3 may shrink it away).
@@ -0,0 +1,155 @@
/**
* SPIKE ONLY — Phase 2 virtualization feasibility proof-of-concept.
*
* This is NOT wired into the live `MessageTimeline` and is NOT ship-ready. It
* exists to demonstrate the integration SHAPE of `@tanstack/react-virtual`
* against the real main-timeline constraints, and to make the three required
* proof points concrete in code rather than prose:
*
* 1. Sticky-bottom autoscroll -> `virtualizer.scrollToIndex(last, end)`
* 2. Native scroll-up prepend -> stable `getItemKey` + the virtualizer's
* retention own scroll anchoring (NO double-rAF)
* 3. cmd+F find / deep-link jump -> `virtualizer.scrollToIndex(rowIndex)`
* to an UNMOUNTED row via `findVirtualRowIndexForMessage`
*
* Variable-height rows (messages vs day dividers) are handled by
* `measureElement` (dynamic measurement), which is exactly why react-virtual is
* the lean over react-window — see FEASIBILITY.md.
*/
import * as React from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { TimelineMessage } from "@/features/messages/types";
import {
buildVirtualTimelineRows,
findVirtualRowIndexForMessage,
} from "@/features/messages/lib/buildVirtualTimelineRows";
type VirtualizedTimelinePocProps = {
messages: TimelineMessage[];
/** Find-in-page / deep-link target. Drives a programmatic scroll to a row that may be unmounted. */
scrollToMessageId?: string | null;
/** Whether to keep pinned to the newest message as it arrives (sticky-bottom). */
stickToBottom?: boolean;
renderMessage: (message: TimelineMessage) => React.ReactNode;
renderDayDivider: (headingTimestamp: number) => React.ReactNode;
};
// Initial guesses only — `measureElement` corrects to real height after paint.
const ESTIMATED_MESSAGE_HEIGHT = 64;
const ESTIMATED_DIVIDER_HEIGHT = 32;
const OVERSCAN = 8;
export function VirtualizedTimelinePoc({
messages,
scrollToMessageId,
stickToBottom = true,
renderMessage,
renderDayDivider,
}: VirtualizedTimelinePocProps) {
const scrollRef = React.useRef<HTMLDivElement>(null);
// Flat, index-addressable rows (dividers + messages) off the SAME snapshot the
// rows render from — the correctness property timelineSnapshot.ts documents.
const rows = React.useMemo(
() => buildVirtualTimelineRows(messages),
[messages],
);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: (index) =>
rows[index]?.kind === "day-divider"
? ESTIMATED_DIVIDER_HEIGHT
: ESTIMATED_MESSAGE_HEIGHT,
// Stable per-row identity. THIS is what lets a top-prepend (older page)
// retain scroll position natively: surviving rows keep their key, so the
// measurement cache survives and the virtualizer re-anchors itself. No
// before/after scrollHeight delta math, no double-rAF correction.
getItemKey: (index) => rows[index]?.key ?? index,
overscan: OVERSCAN,
});
// (1) Sticky-bottom autoscroll: when a new latest row arrives and we're
// pinned, jump to the last row aligned to the bottom edge. Replaces the
// bespoke scrollTop-locking manager's `scrollToBottom`.
const lastRowIndex = rows.length - 1;
React.useEffect(() => {
if (!stickToBottom || lastRowIndex < 0) {
return;
}
virtualizer.scrollToIndex(lastRowIndex, { align: "end" });
}, [lastRowIndex, stickToBottom, virtualizer]);
// (3) cmd+F find / deep-link jump to a possibly-UNMOUNTED row. The virtualizer
// scrolls the row into existence then aligns it — the in-app-search path that
// replaces native browser find (which can't see unmounted rows).
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run only when the target changes
React.useEffect(() => {
if (!scrollToMessageId) {
return;
}
const rowIndex = findVirtualRowIndexForMessage(
rows,
scrollToMessageId,
messages,
);
if (rowIndex === -1) {
// Target not in this snapshot yet — bail; the next snapshot drives it.
return;
}
virtualizer.scrollToIndex(rowIndex, { align: "center" });
}, [scrollToMessageId, virtualizer]);
const virtualItems = virtualizer.getVirtualItems();
return (
<div
ref={scrollRef}
// Single scroll container the virtualizer OWNS. The current bespoke
// manager owns this same div via scrollContainerRef — they cannot coexist;
// the virtualizer must replace it (see FEASIBILITY.md, autoscroll section).
className="h-full overflow-y-auto [overflow-anchor:none]"
>
<div
// Spacer sized to the full virtual height; rows are absolutely
// positioned within it at their measured offsets.
style={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative",
}}
>
{virtualItems.map((virtualItem) => {
const row = rows[virtualItem.index];
if (!row) {
return null;
}
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
// Dynamic measurement: each row reports its real height back, so
// variable-height messages AND dividers are handled without a
// fixed row-height assumption.
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start}px)`,
}}
>
{row.kind === "day-divider"
? renderDayDivider(row.headingTimestamp)
: renderMessage(messages[row.messageIndex])}
</div>
);
})}
</div>
</div>
);
}
+20 -3
View File
@@ -87,6 +87,9 @@ importers:
'@tanstack/react-router':
specifier: ^1.168.10
version: 1.170.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tanstack/react-virtual':
specifier: ^3.14.2
version: 3.14.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tauri-apps/api':
specifier: ~2.11
version: 2.11.0
@@ -1456,14 +1459,12 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
@@ -1540,6 +1541,12 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/react-virtual@3.14.2':
resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/router-core@1.171.5':
resolution: {integrity: sha512-BfilbQqqWiQwJn68cD8wmk1ajEWIO3IlEA1zVuWslWbiVc23CDn+6ACO5tfPAcc96ED37hxela5ij3VBvAtusw==}
engines: {node: '>=20.19'}
@@ -1576,6 +1583,9 @@ packages:
'@tanstack/store@0.9.3':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
'@tanstack/virtual-core@3.17.0':
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
'@tanstack/virtual-file-routes@1.162.0':
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
engines: {node: '>=20.19'}
@@ -1620,7 +1630,6 @@ packages:
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
@@ -4194,6 +4203,12 @@ snapshots:
react-dom: 19.2.6(react@19.2.6)
use-sync-external-store: 1.6.0(react@19.2.6)
'@tanstack/react-virtual@3.14.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@tanstack/virtual-core': 3.17.0
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
'@tanstack/router-core@1.171.5':
dependencies:
'@tanstack/history': 1.162.0
@@ -4251,6 +4266,8 @@ snapshots:
'@tanstack/store@0.9.3': {}
'@tanstack/virtual-core@3.17.0': {}
'@tanstack/virtual-file-routes@1.162.0': {}
'@tauri-apps/api@2.11.0': {}