From a470a34889b719c5330aedb9b64237c5191546e2 Mon Sep 17 00:00:00 2001 From: clean99 <53188310+clean99@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:25:14 +0800 Subject: [PATCH] fix(svelte-virtual): read a scroll element bound after the store subscribes A component usually subscribes to the store before `bind:this` assigns the scroll element. The store's start callback was the only place that read `getScrollElement`, so it saw null, no observers were attached and the list rendered no rows until an option update happened to call `setOptions`. Read the element again after `tick()`, unless the last subscriber has already gone. The new test binds the element after subscribing and fails without the source change. --- .changeset/bound-rows-render.md | 5 ++++ packages/svelte-virtual/src/index.ts | 13 ++++++++- packages/svelte-virtual/tests/index.test.ts | 31 +++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 .changeset/bound-rows-render.md diff --git a/.changeset/bound-rows-render.md b/.changeset/bound-rows-render.md new file mode 100644 index 000000000..6f008d222 --- /dev/null +++ b/.changeset/bound-rows-render.md @@ -0,0 +1,5 @@ +--- +'@tanstack/svelte-virtual': patch +--- + +Read the scroll element again after Svelte updates the DOM, so a virtualizer whose scroll element is assigned with `bind:this` after the store is subscribed renders its rows. diff --git a/packages/svelte-virtual/src/index.ts b/packages/svelte-virtual/src/index.ts index 49eb96cef..30d7d4f3b 100644 --- a/packages/svelte-virtual/src/index.ts +++ b/packages/svelte-virtual/src/index.ts @@ -7,6 +7,7 @@ import { observeWindowRect, windowScroll, } from '@tanstack/virtual-core' +import { tick } from 'svelte' import { derived, writable } from 'svelte/store' import type { PartialKeys, VirtualizerOptions } from '@tanstack/virtual-core' import type { Readable, Writable } from 'svelte/store' @@ -64,7 +65,17 @@ function createVirtualizerBase< virtualizerWritable = writable(virtualizer, () => { setOptions({}) - return virtualizer._didMount() + let subscribed = true + // The store is usually subscribed before `bind:this` assigns the scroll + // element, so read it again once Svelte has updated the DOM. + tick().then(() => { + if (subscribed) virtualizer._willUpdate() + }) + const cleanup = virtualizer._didMount() + return () => { + subscribed = false + cleanup() + } }) return derived(virtualizerWritable, (instance) => diff --git a/packages/svelte-virtual/tests/index.test.ts b/packages/svelte-virtual/tests/index.test.ts index b0de1eb78..e28b1ab8c 100644 --- a/packages/svelte-virtual/tests/index.test.ts +++ b/packages/svelte-virtual/tests/index.test.ts @@ -1,3 +1,4 @@ +import { tick } from 'svelte' import { get } from 'svelte/store' import { describe, expect, test, vi } from 'vitest' import { createVirtualizer, createWindowVirtualizer } from '../src' @@ -149,3 +150,33 @@ describe.each([ } }) }) + +test('createVirtualizer reads a scroll element bound after subscribing', async () => { + let scrollElement: Element | null = null + const subscriber = vi.fn() + const store = createVirtualizer({ + count: 100, + estimateSize: () => 40, + getScrollElement: () => scrollElement, + observeElementRect: (_instance, cb) => { + cb({ width: 400, height: 400 }) + }, + observeElementOffset: () => {}, + scrollToFn: () => {}, + }) + const unsubscribe = store.subscribe(subscriber) + const virtualizer = get(store) + + try { + expect(virtualizer.getVirtualItems()).toHaveLength(0) + + scrollElement = {} as Element + subscriber.mockClear() + await tick() + + expect(virtualizer.getVirtualItems()).toHaveLength(11) + expect(subscriber).toHaveBeenCalledWith(virtualizer) + } finally { + unsubscribe() + } +})