Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Svelte 5 support #866

Open
2 tasks done
rayrw opened this issue Oct 28, 2024 · 6 comments
Open
2 tasks done

Svelte 5 support #866

rayrw opened this issue Oct 28, 2024 · 6 comments

Comments

@rayrw
Copy link

rayrw commented Oct 28, 2024

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

  • I agree to follow this project's Code of Conduct
  • 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.
@jithujoshyjy
Copy link

jithujoshyjy commented Nov 2, 2024

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.

@gyzerok
Copy link

gyzerok commented Dec 23, 2024

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!

@jerriclynsjohn
Copy link

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

@slidenerd
Copy link

slidenerd commented Feb 6, 2025

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

@slidenerd
Copy link

@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>

@phosmium
Copy link

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants