virtual icon indicating copy to clipboard operation
virtual copied to clipboard

Svelte 5 support

Open rayrw opened this issue 1 year ago • 13 comments

Describe the bug

It's working fine in Svelte 4. I'm currently trying out the library in Svelte 5 and found my use case doesn't work. I got an empty table with the same code. I suspect it cannot keep track of the initial element binding of the scroll element, because it works if I manually make a mounted state and call $virtualizer._willUpdate().

Your minimal, reproducible example

https://www.sveltelab.dev/github.com/rayrw/svelte5-tanstack-virtual

Steps to reproduce

I've made a minimal reproduction repo. Please note that we have to manually run npm i --force && npm run dev before #863 is merged. With the hack from L19-L27, it seems to work. However, when I commented out it, I got an empty table. I suspect it cannot keep track of the initial element binding of the scroll element?

Expected behavior

I hope we can get rid of the manual mounting/element binding check and call of $virtualizer._willUpdate().

How often does this bug happen?

None

Screenshots or Videos

No response

Platform

macOS, Arc browser Version 1.65.0 (54911) Chromium Engine Version 130.0.6723.59

tanstack-virtual version

3.10.8

TypeScript version

No response

Additional context

No response

Terms & Code of Conduct

  • [x] I agree to follow this project's Code of Conduct
  • [x] I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

rayrw avatar Oct 28 '24 09:10 rayrw

Here's how I made it work in svelte 5, mimicking the existing @tanstack/virtual-solid package:

import {
    Virtualizer,
    elementScroll,
    observeElementOffset,
    observeElementRect,
    observeWindowOffset,
    observeWindowRect,
    windowScroll,
    type PartialKeys,
    type VirtualizerOptions
} from "@tanstack/virtual-core"

export * from "@tanstack/virtual-core"

function createVirtualizerBase<
    TScrollElement extends Element | Window,
    TItemElement extends Element,
>(
    options: VirtualizerOptions<TScrollElement, TItemElement>,
): Virtualizer<TScrollElement, TItemElement> {

    const resolvedOptions = { ...options }
    const instance = new Virtualizer(resolvedOptions)

    let virtualItems = $state(instance.getVirtualItems())
    let totalSize = $state(instance.getTotalSize())

    const handler = {
        get(
            target: Virtualizer<TScrollElement, TItemElement>,
            prop: keyof Virtualizer<TScrollElement, TItemElement>
        ) {
            if (prop === "getVirtualItems")
                return () => virtualItems
            if (prop === "getTotalSize")
                return () => totalSize
            return Reflect.get(target, prop)
        }
    }

    const virtualizer = new Proxy(instance, handler)
    virtualizer.setOptions(resolvedOptions)

    $effect(() => {
        const cleanup = virtualizer._didMount()
        virtualizer._willUpdate()
        return cleanup
    })

    $effect(() => {
        virtualizer.setOptions({
            ...resolvedOptions,
            ...options,
            onChange: (instance, sync) => {
                instance._willUpdate()
                virtualItems = instance.getVirtualItems()
                totalSize = instance.getTotalSize()
                options.onChange?.(instance, sync)
            }
        })
        virtualizer.measure()
    })

    return virtualizer
}

export function createVirtualizer<
    TScrollElement extends Element,
    TItemElement extends Element,
>(
    options: PartialKeys<
        VirtualizerOptions<TScrollElement, TItemElement>,
        "observeElementRect" | "observeElementOffset" | "scrollToFn"
    >,
): Virtualizer<TScrollElement, TItemElement> {
    return createVirtualizerBase<TScrollElement, TItemElement>({
        observeElementRect: observeElementRect,
        observeElementOffset: observeElementOffset,
        scrollToFn: elementScroll,
        ...options
    });
}

export function createWindowVirtualizer<TItemElement extends Element>(
    options: PartialKeys<
        VirtualizerOptions<Window, TItemElement>,
        | "getScrollElement"
        | "observeElementRect"
        | "observeElementOffset"
        | "scrollToFn"
    >,
): Virtualizer<Window, TItemElement> {
    return createVirtualizerBase<Window, TItemElement>({
        getScrollElement: () => (typeof document !== "undefined" ? window : null),
        observeElementRect: observeWindowRect,
        observeElementOffset: observeWindowOffset,
        scrollToFn: windowScroll,
        initialOffset: () => (typeof document !== "undefined" ? window.scrollY : 0),
        ...options
    })
}

It's literally just a clone of the index.ts from @tanstack/virtual-solid using svelte runes. Note: in svelte the file should be called index.svelte.ts for this to work.

jithujoshyjy avatar Nov 02 '24 16:11 jithujoshyjy

Hello @tannerlinsley!

Svelte 5 was released some time ago, do you think we can get an update to support it? Do you have any internal discussions in the team regarding this? Would be highly appreciated!

gyzerok avatar Dec 23 '24 08:12 gyzerok

@tannerlinsley looking forward to seeing if there is any upgrade plans to Svelte 5

jerriclynsjohn avatar Jan 30 '25 06:01 jerriclynsjohn

Anyone knows how to make this sandbox work on svelte 5? that uses @tanstack/svelte-virtual

slidenerd avatar Feb 06 '25 07:02 slidenerd

@jithujoshyjy tried your method, the list items seem very jumpy when you click on them. Also the scrollbar keeps going to 0 every time you click show more

Image

Code Sandbox Link showing the problem

+layout.svelte

<script lang="ts">
	import '$lib/css/main.css';
	import { page } from '$app/state';
	import { MediaQuery } from 'svelte/reactivity';
	import type { NewsItem } from '$lib/types/NewsItem.js';
	import { isDetailRoute } from '$lib/functions';
	import {
		getNewsListNextPageEndpoint,
		getNewsListWithPinnedItemNextPageEndpoint
	} from '$lib/endpoints/backend';
	import { getNewsDetailEndpoint, getNewsListEndpoint } from '$lib/endpoints/frontend.js';
	import { goto } from '$app/navigation';
	import type { NewsFilter } from '$lib/types/NewsFilter.js';
	import { latestNewsState } from '$lib/state/index.js';
	import { requestProperties } from '$lib/config/index.js';
	import { createVirtualizer } from '$lib/virtual-list/index.svelte.js';

	const large = new MediaQuery('min-width: 800px');
	const { children, data } = $props();
	const hasNoDetailSelected = $derived.by(() => {
		return (
			page.url.pathname === '/' ||
			page.url.pathname === '/news' ||
			page.url.pathname === `/news/${page.params.tag}`
		);
	});

	const filter = $derived(data.filter);
	const id = $derived(data.id);
	const search = $derived(data.search);
	const title = $derived(data.title);

	let newSearch = $state('');
	let virtualListEl = $state<HTMLDivElement | null>(null);
	let virtualListItemEls = $state<HTMLDivElement[]>([]);
	let virtualizer = $derived(
		createVirtualizer({
			count: latestNewsState.newsItems.length,
			getScrollElement: () => virtualListEl,
			estimateSize: () => 50
		})
	);
	let virtualItems = $derived(virtualizer.getVirtualItems());

	$effect(() => {
		data.latestNewsPromise
			.then((items) => {
				latestNewsState.appendNewsItems(items.data);
			})
			.catch((error: Error) => {
				console.error(`Something went wrong when loading news items ${error.message}`);
			});
	});

	$effect(() => {
		if (virtualListItemEls.length) {
			for (let i = 0; i < virtualListItemEls.length; i++) {
				virtualizer.measureElement(virtualListItemEls[i]);
			}
		}
	});

	async function showMore() {
		try {
			let endpoint;
			if (isDetailRoute(page.params.id, page.params.title)) {
				endpoint = getNewsListWithPinnedItemNextPageEndpoint(
					latestNewsState.cursor,
					filter,
					id,
					search
				);
			} else {
				endpoint = getNewsListNextPageEndpoint(latestNewsState.cursor, filter, search);
			}

			const response = await fetch(endpoint, requestProperties);
			if (!response.ok) {
				throw new Error(
					`Something went wrong when loading news items on page N ${response.status} ${response.statusText}`
				);
			}
			const { data: items }: { data: NewsItem[] } = await response.json();
			latestNewsState.appendNewsItems(items);
		} catch (error) {
			console.log(
				`Something when wrong when executing show more ${error instanceof Error ? error.message : ''}`
			);
		}
	}

	function onFilterChange(e: Event) {
		const newFilterValue = (e.target as HTMLSelectElement).value;
		let to;
		if (isDetailRoute(page.params.id, page.params.title)) {
			to = getNewsDetailEndpoint(newFilterValue as NewsFilter, id, search, title);
		} else {
			to = getNewsListEndpoint(newFilterValue as NewsFilter, search);
		}
		return goto(to);
	}

	function onSearchChange(e: KeyboardEvent) {
		if (e.key === 'Enter') {
			let to;
			if (isDetailRoute(page.params.id, page.params.title)) {
				to = getNewsDetailEndpoint(filter as NewsFilter, id, newSearch, title);
			} else {
				to = getNewsListEndpoint(filter as NewsFilter, newSearch);
			}
			return goto(to);
		}
	}
</script>

<header>
	<div>
		<a data-sveltekit-preload-data="off" href="/">TestNewsApp</a>
	</div>
	<div>
		On desktop, list + detail are shown side by side, on mobile you'll see either the list or the
		detail depending on the url
	</div>
</header>

{#if large.current}
	<main style="flex-direction:row;">
		<div class="list">
			<section class="panel">
				<span>Filter: {filter}</span>
				<span>Search: {search}</span>
			</section>
			<br />
			<div class="panel">
				<section class="list-filter" onchange={onFilterChange}>
					<select>
						{#each ['latest', 'likes', 'dislikes', 'trending'] as filterValue}
							<option selected={filter === filterValue}>{filterValue}</option>
						{/each}
					</select>
				</section>
				<section>
					<input
						placeholder="Search for 'china'"
						type="search"
						name="search"
						value={search}
						oninput={(e: Event) => {
							newSearch = (e.target as HTMLInputElement).value;
						}}
						onkeydown={onSearchChange}
					/>
				</section>
			</div>

			{#await data.latestNewsPromise}
				<span>Loading items...</span>
			{:then}
				{#if latestNewsState.newsItems.length > 0}
					<div bind:this={virtualListEl} class="virtual-list-container">
						<div style="position: relative; height: {virtualizer.getTotalSize()}px; width: 100%;">
							<div
								style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({virtualItems[0]
									? virtualItems[0].start
									: 0}px);"
							>
								{#each virtualItems as virtualItem (virtualItem.index)}
									{@const newsItem = latestNewsState.newsItems[virtualItem.index]}
									<div
										bind:this={virtualListItemEls[virtualItem.index]}
										class="list-item"
										class:selected={page.params.id === newsItem.id}
										data-index={virtualItem.index}
									>
										<a
											data-sveltekit-preload-data="off"
											href={getNewsDetailEndpoint(filter, newsItem.id, search, newsItem.title)}
											>{virtualItem.index + 1} {newsItem.title}</a
										>
									</div>
								{/each}
							</div>
						</div>
					</div>
				{:else}
					<div>
						No items to display under the current {filter}
						{search} Maybe try changing them?
					</div>
				{/if}
			{/await}
			<footer>
				<button onclick={showMore}>Show More</button>
			</footer>
		</div>
		<div class="detail">
			{@render children()}
		</div>
	</main>
{:else if !large.current && hasNoDetailSelected}
	<main style="flex-direction:column;">
		<div class="list">
			<section class="panel">
				<span>Filter: {filter}</span>
				<span>Search: {search}</span>
			</section>
			<br />
			<div class="panel">
				<section class="list-filter" onchange={onFilterChange}>
					<select>
						{#each ['latest', 'likes', 'dislikes', 'trending'] as filterValue}
							<option selected={filter === filterValue}>{filterValue}</option>
						{/each}
					</select>
				</section>
				<section>
					<input
						placeholder="Search for 'china'"
						type="search"
						name="search"
						value={search}
						oninput={(e: Event) => {
							newSearch = (e.target as HTMLInputElement).value;
						}}
						onkeydown={onSearchChange}
					/>
				</section>
			</div>
			<nav>
				{#await data.latestNewsPromise}
					<span>Loading items...</span>
				{:then}
					<div>
						Increase the width of this window in the preview to see the desktop view. This works
						only on the desktop view for now
					</div>
				{/await}
				<footer>
					<button onclick={showMore}>Show More</button>
				</footer>
			</nav>
		</div>
	</main>
{:else}
	<div class="detail">
		{@render children()}
	</div>
{/if}

<style>
	.detail {
		background-color: lightcyan;
		flex: 1;
	}
	.list {
		background-color: lightyellow;
		display: flex;
		flex: 1;
		flex-direction: column;
		overflow-y: auto;
		padding: 1rem;
	}
	.list-item {
		border-bottom: 1px dotted lightgray;
		padding: 0.5rem 0;
	}

	.panel {
		display: flex;
		font-size: x-small;
		justify-content: space-between;
	}
	.selected {
		background-color: yellow;
	}
	.virtual-list-container {
		flex: 1;
		overflow-x: hidden;
		overflow-y: scroll;
	}
	footer {
		display: flex;
		justify-content: center;
	}
	main {
		background-color: lightgoldenrodyellow;
		display: flex;
		flex: 1;
		overflow: hidden;
	}
	nav {
		display: flex;
		flex: 1;
		flex-direction: column;
		overflow: hidden;
	}
</style>

slidenerd avatar Feb 07 '25 05:02 slidenerd

Yea, tanstack virtual seems very unusable on svelte 5.

https://tanstack.com/virtual/v3/docs/framework/svelte/examples/dynamic Add setTimeout(() => count++, 2500) and set svelte reactivity on count, you will notice that the virtualizer always scrolls back to 0.

phosmium avatar Feb 19 '25 20:02 phosmium

Here's how I made it work in svelte 5, mimicking the existing @tanstack/virtual-solid package:

Hi @jithujoshyjy , would you be interested in submitting a PR with your work?

lachlancollins avatar Mar 07 '25 23:03 lachlancollins

@slidenerd I have the same issue, in a another situation. I've an action bound to a row, and the response returns the updated item. When updating the underlying array, it jumps to the top of the table. Did you find a workaround to avoid jumpy table?

phhoef avatar Mar 11 '25 11:03 phhoef

@phhoef seems that it has something to do with global state I abandoned this virtual list since i was not getting anywhere with it and started using this Warning though, scroll to index doesnt work as reliably as i expected it to but rest is super solid

slidenerd avatar Mar 11 '25 14:03 slidenerd

@slidenerd thanks for your feedback. Do you use the virtual list with tanstack-table or just a normal list? Would you mind sharing a code example how you use it with tanstack table? For lists I am using virtua.

phhoef avatar Mar 11 '25 19:03 phhoef

@phhoef i dont need a table in my application at all so I just need a virtual list. Given the state of this library, I am not using tanstack anything at the moment. As mentioned above, I independently found a virtual list whose scroll to index with smooth behavior is broken. The problem with virtua is that it does not persist scroll positions between routes. Keep in mind with virtua that this is a MAJOR ISSUE

slidenerd avatar Mar 12 '25 03:03 slidenerd

any updates on this?

vlausargs avatar May 06 '25 06:05 vlausargs

anyone working on this?

noobmaster19 avatar Jun 01 '25 15:06 noobmaster19

Dead project?

rebasecase avatar Jul 12 '25 16:07 rebasecase

I looked into this again, and now I believe the issue is related to how reactivity works in Svelte 5 with runes.

In the original code, when I added console.log(virtualListElement) inside getScrollElement callback, it always logged null just once. I was expecting it to log again with the actual HTMLElement, but that never happened. It seems like the reactivity mechanism can't track virtualListElement properly since it's being accessed inside a function.

I eventually came up with a better workaround: I moved the access into the $derived block so it's read ahead of time. With this approach, it works without needing a mount check or manually calling $virtualizer._willUpdate().

let virtualizer = $derived(
	createVirtualizer<HTMLElement, HTMLTableRowElement>({
		count: numbers.length,
-		getScrollElement: () => virtualListElement,
+		getScrollElement: virtualListElement ? () => virtualListElement : () => null,
		estimateSize: () => 24
	})
);

Alternatively, using a factory function also works. The key point is to access virtualListElement at the same level as the $derived block so the reactivity system can track it properly.

+let makeGetScrollElement = (scrollElement: HTMLElement | null) => () => scrollElement;
let virtualizer = $derived(
	createVirtualizer<HTMLElement, HTMLTableRowElement>({
		count: numbers.length,
-		getScrollElement: () => virtualListElement,
+		getScrollElement: makeGetScrollElement(virtualListElement),
		estimateSize: () => 24
	})
);

I made a PR to my MRE repo to better demonstrate the workaround. The SvelteLab link should now also work directly: https://www.sveltelab.dev/github.com/rayrw/svelte5-tanstack-virtual

Hope this helps! :)

rayrw avatar Jul 20 '25 06:07 rayrw