diff --git a/desktop/package.json b/desktop/package.json index c1f6de5fd..3ebacba41 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/src/features/messages/lib/buildVirtualTimelineRows.test.mjs b/desktop/src/features/messages/lib/buildVirtualTimelineRows.test.mjs new file mode 100644 index 000000000..7d1fe044b --- /dev/null +++ b/desktop/src/features/messages/lib/buildVirtualTimelineRows.test.mjs @@ -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"); +}); diff --git a/desktop/src/features/messages/lib/buildVirtualTimelineRows.ts b/desktop/src/features/messages/lib/buildVirtualTimelineRows.ts new file mode 100644 index 000000000..82d7d0aaf --- /dev/null +++ b/desktop/src/features/messages/lib/buildVirtualTimelineRows.ts @@ -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 — `
` per day, each wrapping a + * `` plus its message `
`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, + ); +} diff --git a/desktop/src/features/messages/ui/__spike__/FEASIBILITY.md b/desktop/src/features/messages/ui/__spike__/FEASIBILITY.md new file mode 100644 index 000000000..bb8a2b505 --- /dev/null +++ b/desktop/src/features/messages/ui/__spike__/FEASIBILITY.md @@ -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 `
` per day, +each wrapping a `` + its message `
`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). diff --git a/desktop/src/features/messages/ui/__spike__/VirtualizedTimelinePoc.tsx b/desktop/src/features/messages/ui/__spike__/VirtualizedTimelinePoc.tsx new file mode 100644 index 000000000..7340b3b96 --- /dev/null +++ b/desktop/src/features/messages/ui/__spike__/VirtualizedTimelinePoc.tsx @@ -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(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 ( +
+
+ {virtualItems.map((virtualItem) => { + const row = rows[virtualItem.index]; + if (!row) { + return null; + } + return ( +
+ {row.kind === "day-divider" + ? renderDayDivider(row.headingTimestamp) + : renderMessage(messages[row.messageIndex])} +
+ ); + })} +
+
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08682ab15..8967b96d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': {}