diff --git a/.changeset/hungry-donkeys-applaud.md b/.changeset/hungry-donkeys-applaud.md new file mode 100644 index 000000000..845c00bbb --- /dev/null +++ b/.changeset/hungry-donkeys-applaud.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-virtual': minor +--- + +Add `useVirtualizerSnapshot` and `useWindowVirtualizerSnapshot`: variants of the existing hooks that return `virtualItems` / `totalSize` as immutable snapshot data via `useSyncExternalStore`, with the stable `virtualizer` instance alongside for imperative APIs. Components consuming the snapshot hooks are compatible with React Compiler — the compiler skips components calling `useVirtualizer` as a known-incompatible API (#736, #743, #1119), while snapshot consumers compile and memoize correctly. diff --git a/docs/framework/react/react-virtual.md b/docs/framework/react/react-virtual.md index 02d544e05..3318fce62 100644 --- a/docs/framework/react/react-virtual.md +++ b/docs/framework/react/react-virtual.md @@ -33,6 +33,106 @@ function useWindowVirtualizer( This function returns a window-based `Virtualizer` instance configured to work with the window as the scrollElement. +## `useVirtualizerSnapshot` + +```tsx +function useVirtualizerSnapshot< + TScrollElement extends Element, + TItemElement extends Element, +>( + options: PartialKeys< + ReactVirtualizerSnapshotOptions, + 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' + >, +): VirtualizerSnapshot +``` + +Like `useVirtualizer`, but returns the render-facing values as immutable +snapshot data instead of methods to call during render: + +```tsx +interface VirtualizerSnapshot< + TScrollElement extends Element | Window, + TItemElement extends Element, +> { + readonly virtualItems: ReadonlyArray> + readonly totalSize: number + readonly virtualizer: Virtualizer +} +``` + +### Why: React Compiler + +`useVirtualizer` returns a stable instance whose `getVirtualItems()` / +`getTotalSize()` read mutable internal state during render. Memoizing +compilers cache those reads keyed on the stable instance and serve the first +result forever, so the list never updates +([#736](https://github.com/TanStack/virtual/issues/736)) — which is why React +Compiler skips any component calling `useVirtualizer` as a +known-incompatible API, leaving that component entirely un-memoized. + +With `useVirtualizerSnapshot`, positional data arrives as reactive values +(via `useSyncExternalStore`): `virtualItems` and `totalSize` change identity +exactly when the computed geometry changes. Components consuming the +snapshot hook are compiled — and memoized correctly — by React Compiler. + +- Read `virtualItems` / `totalSize` from the snapshot during render. +- Use `snapshot.virtualizer` for imperative APIs only — `measureElement` (as + a ref), `scrollToIndex`, `scrollToOffset`, `measure`. Its identity is + stable for the lifetime of the component. Do not read positional data from + it during render. + +```tsx +const { virtualItems, totalSize, virtualizer } = useVirtualizerSnapshot({ + count: rows.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 35, +}) + +return ( +
+
+ {virtualItems.map((item) => ( +
+ Row {item.index} +
+ ))} +
+
+) +``` + +The snapshot hooks do not support `directDomUpdates` — that option is an +alternative strategy that bypasses React rendering for scroll updates, while +the snapshot hooks make React rendering itself compiler-safe. + +## `useWindowVirtualizerSnapshot` + +```tsx +function useWindowVirtualizerSnapshot( + options: PartialKeys< + ReactVirtualizerSnapshotOptions, + | 'getScrollElement' + | 'observeElementRect' + | 'observeElementOffset' + | 'scrollToFn' + >, +): VirtualizerSnapshot +``` + +Window-scrolling variant of `useVirtualizerSnapshot`. + ## React-Specific Options ### `useFlushSync` diff --git a/packages/react-virtual/e2e/app/react-compiler-vite.config.ts b/packages/react-virtual/e2e/app/react-compiler-vite.config.ts index 59ec35a04..28039d2e9 100644 --- a/packages/react-virtual/e2e/app/react-compiler-vite.config.ts +++ b/packages/react-virtual/e2e/app/react-compiler-vite.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ rollupOptions: { input: { 'react-compiler': path.resolve(root, 'react-compiler/index.html'), + 'snapshot-hook': path.resolve(root, 'snapshot-hook/index.html'), }, }, }, diff --git a/packages/react-virtual/e2e/app/snapshot-hook/index.html b/packages/react-virtual/e2e/app/snapshot-hook/index.html new file mode 100644 index 000000000..822a1ab3e --- /dev/null +++ b/packages/react-virtual/e2e/app/snapshot-hook/index.html @@ -0,0 +1,12 @@ + + + + + + useVirtualizerSnapshot + React Compiler + + +
+ + + diff --git a/packages/react-virtual/e2e/app/snapshot-hook/main.tsx b/packages/react-virtual/e2e/app/snapshot-hook/main.tsx new file mode 100644 index 000000000..e982b0c7c --- /dev/null +++ b/packages/react-virtual/e2e/app/snapshot-hook/main.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { useVirtualizerSnapshot } from '@tanstack/react-virtual' + +const ITEM_SIZE = 40 +const COUNT = 1000 + +/** + * Regression page for https://github.com/TanStack/virtual/issues/736 using + * `useVirtualizerSnapshot` under React Compiler. + * + * Unlike `useVirtualizer` — which the compiler skips as a known-incompatible + * API — components calling the snapshot hook ARE compiled. The snapshot's + * identity changes whenever the computed items change, so the compiler's + * memoized output stays fresh: item-500 must appear after scrolling. This + * page must stay free of patterns that make the compiler bail (no ref + * mutation during render, etc.), or it silently stops guarding the compiled + * path. + */ +const App = () => { + const parentRef = React.useRef(null) + + const { virtualItems, totalSize, virtualizer } = useVirtualizerSnapshot({ + count: COUNT, + getScrollElement: () => parentRef.current, + estimateSize: () => ITEM_SIZE, + overscan: 2, + }) + + // Commit counter for the spec, kept outside React-managed content so the + // component stays compiler-clean (no ref/global mutation during render). + const commitCountRef = React.useRef(0) + React.useEffect(() => { + commitCountRef.current += 1 + const el = document.getElementById('commit-count') + if (el) el.textContent = String(commitCountRef.current) + }) + + return ( +
+
+ + +
+
+ {virtualItems.map((v) => ( +
+ Row {v.index} +
+ ))} +
+
+
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/packages/react-virtual/e2e/app/test/snapshot-hook.spec.ts b/packages/react-virtual/e2e/app/test/snapshot-hook.spec.ts new file mode 100644 index 000000000..95e71c27d --- /dev/null +++ b/packages/react-virtual/e2e/app/test/snapshot-hook.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from '@playwright/test' + +const ITEM_SIZE = 40 +const COUNT = 1000 + +test.describe('useVirtualizerSnapshot under React Compiler', () => { + test('renders items on initial load', async ({ page }) => { + await page.goto('/snapshot-hook/') + + await expect(page.locator('[data-testid="item-0"]')).toBeVisible() + await expect(page.locator('[data-testid="item-0"]')).toContainText('Row 0') + + // The sizer height comes from the snapshot's totalSize. + const inner = page.locator('#inner') + await expect(inner).toHaveAttribute( + 'style', + new RegExp(`height:\\s*${COUNT * ITEM_SIZE}px`), + ) + }) + + test('items update after scrolling — the #736 regression, compiled', async ({ + page, + }) => { + await page.goto('/snapshot-hook/') + + await expect(page.locator('[data-testid="item-0"]')).toBeVisible() + + await page.click('#scroll-to-500') + + // With `useVirtualizer` a compiled consumer would keep serving the + // initial items forever. The snapshot hook publishes a new identity, so + // the compiled component re-derives its output. + await expect(page.locator('[data-testid="item-500"]')).toBeVisible({ + timeout: 5000, + }) + const style = + (await page.locator('[data-testid="item-500"]').getAttribute('style')) ?? + '' + expect(style).toMatch(/translateY\(20000px\)/) + + // And the initial row is no longer rendered. + await expect(page.locator('[data-testid="item-0"]')).toHaveCount(0) + }) + + test('re-renders are driven by snapshot changes', async ({ page }) => { + await page.goto('/snapshot-hook/') + await expect(page.locator('[data-testid="item-0"]')).toBeVisible() + + const before = Number( + await page.locator('[data-testid="commit-count"]').textContent(), + ) + expect(before).toBeGreaterThan(0) + + await page.click('#scroll-to-500') + await expect(page.locator('[data-testid="item-500"]')).toBeVisible() + + const after = Number( + await page.locator('[data-testid="commit-count"]').textContent(), + ) + expect(after).toBeGreaterThan(before) + }) +}) diff --git a/packages/react-virtual/package.json b/packages/react-virtual/package.json index e2af702d6..14a83a3bc 100644 --- a/packages/react-virtual/package.json +++ b/packages/react-virtual/package.json @@ -55,12 +55,15 @@ "src" ], "dependencies": { - "@tanstack/virtual-core": "workspace:*" + "@tanstack/virtual-core": "workspace:*", + "use-sync-external-store": "^1.6.0" }, "devDependencies": { + "@babel/core": "^7.26.0", "@testing-library/react": "^16.3.0", "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", + "@types/use-sync-external-store": "^1.5.0", "@vitejs/plugin-react": "^4.5.2", "babel-plugin-react-compiler": "^1.0.0", "react": "^19.2.7", diff --git a/packages/react-virtual/src/index.tsx b/packages/react-virtual/src/index.tsx index 737ad53fc..0fead05ef 100644 --- a/packages/react-virtual/src/index.tsx +++ b/packages/react-virtual/src/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { flushSync } from 'react-dom' +import { useSyncExternalStore } from 'use-sync-external-store/shim' import { Virtualizer, elementScroll, @@ -9,7 +10,11 @@ import { observeWindowRect, windowScroll, } from '@tanstack/virtual-core' -import type { PartialKeys, VirtualizerOptions } from '@tanstack/virtual-core' +import type { + PartialKeys, + VirtualItem, + VirtualizerOptions, +} from '@tanstack/virtual-core' export * from '@tanstack/virtual-core' @@ -277,3 +282,200 @@ export function useWindowVirtualizer( ...options, }) } + +export type ReactVirtualizerSnapshotOptions< + TScrollElement extends Element | Window, + TItemElement extends Element, +> = VirtualizerOptions & { + useFlushSync?: boolean +} + +export interface VirtualizerSnapshot< + TScrollElement extends Element | Window, + TItemElement extends Element, +> { + /** + * The virtual items for the current visible range, as immutable snapshot + * data. The array reference changes when — and only when — the computed + * items change, so memoization keyed on it (manual or via React Compiler) + * stays correct. Treat it as read-only: the reference is shared with the + * core (like `getVirtualItems()`), so mutating it corrupts later reads. + */ + readonly virtualItems: ReadonlyArray> + /** + * Total size of the virtualized extent, captured in the same snapshot as + * `virtualItems`. + */ + readonly totalSize: number + /** + * The underlying virtualizer instance, for imperative APIs only: + * `measureElement` (as a ref), `scrollToIndex`, `scrollToOffset`, + * `measure`, … Its identity is stable for the lifetime of the component. + * + * Do not read positional data (`getVirtualItems()`, `getTotalSize()`, + * `range`, `scrollOffset`, …) from it during render — those reads are what + * memoizing compilers cache stale (#736). Use the snapshot fields instead. + */ + readonly virtualizer: Virtualizer +} + +function useVirtualizerSnapshotBase< + TScrollElement extends Element | Window, + TItemElement extends Element, +>({ + useFlushSync = true, + ...options +}: ReactVirtualizerSnapshotOptions< + TScrollElement, + TItemElement +>): VirtualizerSnapshot { + const [store] = React.useState(() => { + const listeners = new Set<() => void>() + let snapshot: { + virtualItems: ReadonlyArray> + totalSize: number + } | null = null + let dirty = true + + const state = { + instance: null as Virtualizer | null, + subscribe: (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + markDirty: () => { + dirty = true + }, + notify: () => { + listeners.forEach((listener) => listener()) + }, + // Must return a cached value until the store actually changes + // (React warns and loops otherwise). `getVirtualItems` is memoized in + // virtual-core, so comparing the parts keeps the snapshot reference + // stable across notifications that didn't change anything visible + // (e.g. `isScrolling` flips). + getSnapshot: () => { + const instance = state.instance + if (instance === null) { + throw new Error('virtualizer read before initialization') + } + if (dirty || snapshot === null) { + dirty = false + const virtualItems = instance.getVirtualItems() + const totalSize = instance.getTotalSize() + if ( + snapshot === null || + snapshot.virtualItems !== virtualItems || + snapshot.totalSize !== totalSize + ) { + snapshot = { virtualItems, totalSize } + } + } + return snapshot + }, + } + return state + }) + + const resolvedOptions: VirtualizerOptions = { + ...options, + onChange: (instance, sync) => { + store.markDirty() + if (useFlushSync && sync) { + flushSync(store.notify) + } else { + store.notify() + } + options.onChange?.(instance, sync) + }, + } + + const [instance] = React.useState( + () => new Virtualizer(resolvedOptions), + ) + store.instance = instance + instance.setOptions(resolvedOptions) + // setOptions can synchronously change render-facing geometry (count, gap, + // lanes, keys, enabled, …) without notifying when the visible range stays + // the same. Re-read the memoized core values during this render; the store + // keeps the public snapshot reference stable when neither value changed. + store.markDirty() + + useIsomorphicLayoutEffect(() => { + return instance._didMount() + }, []) + + useIsomorphicLayoutEffect(() => { + return instance._willUpdate() + }) + + const snapshot = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ) + + return React.useMemo( + () => ({ + virtualItems: snapshot.virtualItems, + totalSize: snapshot.totalSize, + virtualizer: instance, + }), + [snapshot, instance], + ) +} + +/** + * Like `useVirtualizer`, but returns the render-facing values as immutable + * snapshot data (via `useSyncExternalStore`) instead of methods to call + * during render. + * + * `useVirtualizer` returns a stable instance whose `getVirtualItems()` / + * `getTotalSize()` read mutable internal state. Memoizing compilers (React + * Compiler) cache such reads keyed on the stable instance and serve the + * first result forever (#736, #743) — which is why the compiler skips + * components calling `useVirtualizer` (#1119). With this hook the data + * arrives as reactive values whose identities change when the geometry + * changes, so consuming components compile — and memoize — correctly. + */ +export function useVirtualizerSnapshot< + TScrollElement extends Element, + TItemElement extends Element, +>( + options: PartialKeys< + ReactVirtualizerSnapshotOptions, + 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' + >, +): VirtualizerSnapshot { + return useVirtualizerSnapshotBase({ + observeElementRect: observeElementRect, + observeElementOffset: observeElementOffset, + scrollToFn: elementScroll, + ...options, + }) +} + +/** + * Window-scrolling variant of `useVirtualizerSnapshot`. See its docs for the + * motivation (React Compiler compatibility). + */ +export function useWindowVirtualizerSnapshot( + options: PartialKeys< + ReactVirtualizerSnapshotOptions, + | 'getScrollElement' + | 'observeElementRect' + | 'observeElementOffset' + | 'scrollToFn' + >, +): VirtualizerSnapshot { + return useVirtualizerSnapshotBase({ + getScrollElement: () => (typeof document !== 'undefined' ? window : null), + observeElementRect: observeWindowRect, + observeElementOffset: observeWindowOffset, + scrollToFn: windowScroll, + initialOffset: () => (typeof document !== 'undefined' ? window.scrollY : 0), + ...options, + }) +} diff --git a/packages/react-virtual/tests/compiler.test.ts b/packages/react-virtual/tests/compiler.test.ts new file mode 100644 index 000000000..25335b6c4 --- /dev/null +++ b/packages/react-virtual/tests/compiler.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from 'vitest' +import { transformSync } from '@babel/core' + +/** + * Pins the React Compiler contract this package's snapshot hooks exist for. + * + * babel-plugin-react-compiler ships a hardcoded type entry marking + * `useVirtualizer` from '@tanstack/react-virtual' as known-incompatible + * ("returns functions that cannot be memoized safely"), so components + * calling it are skipped — never memoized. That skip is keyed on the + * imported property name, so components calling `useVirtualizerSnapshot` + * compile normally, and the snapshot's identity semantics (see + * snapshot.test.tsx) are what keep the compiled output correct. + * + * If either half of this test starts failing after a compiler upgrade, the + * ecosystem contract changed: re-evaluate the guidance in the docs. + */ +function compile(source: string): string { + const result = transformSync(source, { + configFile: false, + babelrc: false, + filename: 'consumer.tsx', + parserOpts: { plugins: ['typescript', 'jsx'] }, + plugins: [['babel-plugin-react-compiler', { panicThreshold: 'none' }]], + }) + if (result?.code == null) throw new Error('babel produced no output') + return result.code +} + +const consumerOf = (hookCall: string) => ` + import * as React from 'react' + import { useVirtualizer, useVirtualizerSnapshot } from '@tanstack/react-virtual' + + export function List() { + const parentRef = React.useRef(null) + ${hookCall} + return ( +
+ {items.map((item) => ( +
{item.index}
+ ))} +
+ ) + } +` + +test('components calling useVirtualizerSnapshot are compiled', () => { + const code = compile( + consumerOf(` + const { virtualItems: items } = useVirtualizerSnapshot({ + count: 100, + getScrollElement: () => parentRef.current, + estimateSize: () => 40, + }) + `), + ) + + // The memo cache is the signature of a compiled component. + expect(code).toContain('_c(') +}) + +test('components calling useVirtualizer are skipped as known-incompatible', () => { + const code = compile( + consumerOf(` + const virtualizer = useVirtualizer({ + count: 100, + getScrollElement: () => parentRef.current, + estimateSize: () => 40, + }) + const items = virtualizer.getVirtualItems() + `), + ) + + expect(code).not.toContain('_c(') +}) diff --git a/packages/react-virtual/tests/snapshot.test.tsx b/packages/react-virtual/tests/snapshot.test.tsx new file mode 100644 index 000000000..a0999e080 --- /dev/null +++ b/packages/react-virtual/tests/snapshot.test.tsx @@ -0,0 +1,240 @@ +import { beforeEach, expect, test, vi } from 'vitest' +import * as React from 'react' +import { act, render, screen } from '@testing-library/react' +import { renderToString } from 'react-dom/server' + +import { + useVirtualizerSnapshot, + useWindowVirtualizerSnapshot, +} from '../src/index' +import type { VirtualizerSnapshot } from '../src/index' + +let renderer: ReturnType + +const captured: { + results: Array> + offsetCb: ((offset: number, isScrolling: boolean) => void) | null +} = { results: [], offsetCb: null } + +beforeEach(() => { + renderer = vi.fn(() => undefined) + captured.results = [] + captured.offsetCb = null +}) + +interface SnapshotListProps { + count?: number + gap?: number + height?: number + width?: number + label?: string +} + +function SnapshotList({ + count = 200, + gap, + height = 200, + width = 200, + label = '', +}: SnapshotListProps) { + renderer() + + const parentRef = React.useRef(null) + + const result = useVirtualizerSnapshot({ + count, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, + gap, + observeElementRect: (_, cb) => { + cb({ height, width }) + }, + observeElementOffset: (_, cb) => { + cb(0, false) + captured.offsetCb = cb + }, + }) + captured.results.push(result) + + const { virtualItems, totalSize } = result + + return ( +
+
+ {virtualItems.map((virtualRow) => ( +
+ Row {virtualRow.index} + {label} +
+ ))} +
+
+ ) +} + +test('renders the initial range from the snapshot', () => { + render() + + expect(screen.queryByText('Row 0')).toBeInTheDocument() + expect(screen.queryByText('Row 4')).toBeInTheDocument() + expect(screen.queryByText('Row 5')).not.toBeInTheDocument() +}) + +test('keeps snapshot and result referentially stable across unrelated re-renders', () => { + const { rerender } = render() + const afterMount = captured.results[captured.results.length - 1]! + + rerender() + const afterRerender = captured.results[captured.results.length - 1]! + + // The compiler contract: no geometry change -> identical references, so + // memoization keyed on these values stays correct. + expect(afterRerender.virtualItems).toBe(afterMount.virtualItems) + expect(afterRerender.totalSize).toBe(afterMount.totalSize) + expect(afterRerender.virtualizer).toBe(afterMount.virtualizer) + expect(afterRerender).toBe(afterMount) +}) + +test('publishes a new snapshot when render-time options change geometry', () => { + const { rerender } = render() + const before = captured.results[captured.results.length - 1]! + + expect(screen.getByTestId('sizer')).toHaveStyle({ height: '10000px' }) + + // Growing the count does not change the visible range, so virtual-core does + // not emit onChange. The hook must still refresh during the parent render. + rerender() + const afterCountChange = captured.results[captured.results.length - 1]! + + expect(screen.getByTestId('sizer')).toHaveStyle({ height: '15000px' }) + expect(afterCountChange.virtualItems).not.toBe(before.virtualItems) + expect(afterCountChange.virtualizer).toBe(before.virtualizer) + + rerender() + const afterGapChange = captured.results[captured.results.length - 1]! + + expect(screen.getByTestId('sizer')).toHaveStyle({ height: '17990px' }) + expect(screen.getByTestId('item-1')).toHaveStyle({ + transform: 'translateY(60px)', + }) + expect(afterGapChange.virtualItems).not.toBe(afterCountChange.virtualItems) + expect(afterGapChange.virtualizer).toBe(afterCountChange.virtualizer) +}) + +test('publishes a new snapshot when the scroll offset changes the range', () => { + render() + const before = captured.results[captured.results.length - 1]! + + act(() => { + captured.offsetCb!(500, true) + }) + + const after = captured.results[captured.results.length - 1]! + + expect(screen.queryByText('Row 10')).toBeInTheDocument() + expect(screen.queryByText('Row 0')).not.toBeInTheDocument() + expect(after.virtualItems).not.toBe(before.virtualItems) + expect(after.virtualizer).toBe(before.virtualizer) +}) + +test('renders under StrictMode', () => { + render( + + + , + ) + + expect(screen.queryByText('Row 0')).toBeInTheDocument() + expect(screen.queryByText('Row 4')).toBeInTheDocument() +}) + +test('renders the initial range on the server via getServerSnapshot', () => { + function ServerList() { + const { virtualItems, totalSize } = useVirtualizerSnapshot< + HTMLDivElement, + HTMLDivElement + >({ + count: 200, + getScrollElement: () => null, + estimateSize: () => 50, + initialRect: { height: 200, width: 200 }, + }) + return ( +
+ {virtualItems.map((virtualRow) => ( +
Row {virtualRow.index}
+ ))} +
+ ) + } + + const html = renderToString() + + // renderToString separates adjacent text nodes with comment markers, so + // match the index text node itself rather than the joined string. + expect(html).toMatch(/>045 { + const originalScrollY = Object.getOwnPropertyDescriptor(window, 'scrollY') + let scrollY = 0 + Object.defineProperty(window, 'scrollY', { + configurable: true, + get: () => scrollY, + }) + + function WindowList() { + const { virtualItems } = useWindowVirtualizerSnapshot({ + count: 50, + estimateSize: () => 50, + }) + return ( +
+ {virtualItems.map((virtualRow) => ( +
Row {virtualRow.index}
+ ))} +
+ ) + } + + try { + render() + + expect(screen.queryByText('Row 0')).toBeInTheDocument() + + act(() => { + scrollY = 1000 + window.dispatchEvent(new window.Event('scroll')) + }) + + expect(screen.queryByText('Row 20')).toBeInTheDocument() + expect(screen.queryByText('Row 0')).not.toBeInTheDocument() + } finally { + if (originalScrollY) { + Object.defineProperty(window, 'scrollY', originalScrollY) + } else { + Reflect.deleteProperty(window, 'scrollY') + } + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc2c15494..86c75791c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1696,7 +1696,13 @@ importers: '@tanstack/virtual-core': specifier: workspace:* version: link:../virtual-core + use-sync-external-store: + specifier: ^1.6.0 + version: 1.6.0(react@19.2.7) devDependencies: + '@babel/core': + specifier: ^7.26.0 + version: 7.29.7 '@testing-library/react': specifier: ^16.3.0 version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1706,6 +1712,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.16) + '@types/use-sync-external-store': + specifier: ^1.5.0 + version: 1.5.0 '@vitejs/plugin-react': specifier: ^4.5.2 version: 4.7.0(vite@6.4.2(@types/node@24.9.2)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1)) @@ -4716,6 +4725,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/use-sync-external-store@1.5.0': + resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==} + '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} @@ -9815,8 +9827,8 @@ snapshots: '@babel/helper-module-imports@7.27.1': dependencies: - '@babel/traverse': 7.28.5(supports-color@10.2.2) - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -9832,7 +9844,7 @@ snapshots: '@babel/core': 7.28.5(supports-color@10.2.2) '@babel/helper-module-imports': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5(supports-color@10.2.2) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -9841,7 +9853,7 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-module-imports': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5(supports-color@10.2.2) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -9913,8 +9925,8 @@ snapshots: '@babel/helpers@7.28.4': dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 '@babel/helpers@7.29.7': dependencies: @@ -9923,7 +9935,7 @@ snapshots: '@babel/parser@7.28.5': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@babel/parser@7.29.7': dependencies: @@ -9978,9 +9990,9 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': @@ -10443,9 +10455,9 @@ snapshots: '@babel/template@7.27.2': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@babel/template@7.29.7': dependencies: @@ -10455,12 +10467,12 @@ snapshots: '@babel/traverse@7.28.5(supports-color@10.2.2)': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -12351,16 +12363,16 @@ snapshots: '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@types/body-parser@1.19.6': dependencies: @@ -12549,6 +12561,8 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@types/use-sync-external-store@1.5.0': {} + '@types/web-bluetooth@0.0.21': {} '@types/ws@7.4.7': @@ -13268,12 +13282,12 @@ snapshots: find-up: 5.0.0 webpack: 5.105.0(esbuild@0.28.1) - babel-plugin-jsx-dom-expressions@0.40.3(@babel/core@7.28.5): + babel-plugin-jsx-dom-expressions@0.40.3(@babel/core@7.29.7): dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.18.6 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/types': 7.28.5 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.7) + '@babel/types': 7.29.7 html-entities: 2.3.3 parse5: 7.3.0 @@ -13305,10 +13319,10 @@ snapshots: dependencies: '@babel/types': 7.28.5 - babel-preset-solid@1.9.10(@babel/core@7.28.5)(solid-js@1.9.10): + babel-preset-solid@1.9.10(@babel/core@7.29.7)(solid-js@1.9.10): dependencies: - '@babel/core': 7.28.5 - babel-plugin-jsx-dom-expressions: 0.40.3(@babel/core@7.28.5) + '@babel/core': 7.29.7 + babel-plugin-jsx-dom-expressions: 0.40.3(@babel/core@7.29.7) optionalDependencies: solid-js: 1.9.10 @@ -17051,9 +17065,9 @@ snapshots: vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@6.4.2(@types/node@24.9.2)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1)): dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.7 '@types/babel__core': 7.20.5 - babel-preset-solid: 1.9.10(@babel/core@7.28.5)(solid-js@1.9.10) + babel-preset-solid: 1.9.10(@babel/core@7.29.7)(solid-js@1.9.10) merge-anything: 5.1.7 solid-js: 1.9.10 solid-refresh: 0.6.3(solid-js@1.9.10)