How to reproduce
- View the page with enough width (say a desktop) where list and detail are shown side by side
- Click show more, go to page 2 or 3 or any other page than 1
- Click on any item
- Boom, we jump straight to the top
Codesandbox Link
Here is the codesandbox clearly illustrating the issue
Tried a different virtual list library and still getting the same problem
Third attempt with svelte-virtual-scroll-list and still no luck
Removing that virtual list and simply looping through items doesn't have this issue
Not sure which file is causing it but for stackoverflow purposes, I'll share the +layout.ts and +layout.svelte file and the state file
lib/state/LatestNewsItems.svelte.ts
import type { Cursor } from '$lib/types/Cursor';
import type { NewsFilter } from '$lib/types/NewsFilter';
import type { NewsItem } from '$lib/types/NewsItem';
export class LatestNewsState {
cursor: Cursor = $state({ lastFeedItemId: undefined, lastPubdate: undefined });
filter: NewsFilter = $state('latest');
mapNewsItemIdToTrue = new Map<string, boolean>();
newsItems: NewsItem[] = $state([]);
search: string = $state('');
appendNewsItems(items: NewsItem[]) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!this.mapNewsItemIdToTrue.get(item.id)) {
this.newsItems.push(item);
this.mapNewsItemIdToTrue.set(item.id, true);
}
}
const lastItem = this.newsItems[this.newsItems.length - 1];
if (lastItem) {
this.cursor.lastFeedItemId = lastItem.id;
this.cursor.lastPubdate = lastItem.pubdate;
}
}
clearNewsItems() {
this.newsItems.splice(0, this.newsItems.length);
this.cursor.lastFeedItemId = undefined;
this.cursor.lastPubdate = undefined;
this.mapNewsItemIdToTrue.clear();
}
hasFilterOrSearchChanged(filter: NewsFilter, search: string) {
return !this.isEqualFilter(filter) || !this.isEqualSearch(search);
}
isEqualFilter(filter: NewsFilter) {
return filter === this.filter;
}
isEqualSearch(search: string) {
return search === this.search;
}
}
+layout.ts
import { browser } from '$app/environment';
import { requestProperties } from '$lib/config';
import {
getNewsListFirstPageEndpoint,
getNewsListWithPinnedItemFirstPageEndpoint
} from '$lib/endpoints/backend';
import { isDetailRoute } from '$lib/functions';
import { latestNewsState } from '$lib/state';
import type { NewsFilter } from '$lib/types/NewsFilter';
import type { NewsItem } from '$lib/types/NewsItem';
import type { LayoutLoad } from './$types';
const fetchNewsItemsFromCache = (
newsItems: NewsItem[]
): Promise<{ status_code: number; data: NewsItem[] }> => {
return new Promise((resolve) => {
resolve({ status_code: 200, data: newsItems });
});
};
const fetchNewsItemsFromAPI = (
endpoint: string,
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
): Promise<{ status_code: number; data: NewsItem[] }> => {
return new Promise((resolve, reject) => {
fetch(endpoint, requestProperties)
.then((response) => {
if (!response.ok) {
throw new Error(
`Something went wrong while loading news items ${response.status} ${response.statusText}`
);
}
return response.json();
})
.then(resolve)
.catch(reject);
});
};
const getNewsListEndpoint = (
filter: NewsFilter,
id: string | undefined,
search: string,
title: string | undefined
) => {
let endpoint;
if (isDetailRoute(id, title)) {
endpoint = getNewsListWithPinnedItemFirstPageEndpoint(filter, id, search);
} else {
endpoint = getNewsListFirstPageEndpoint(filter, search);
}
return endpoint;
};
const shouldFetchItemsFromAPI = (filter: NewsFilter, id: string | undefined, search: string) => {
const hasFilterOrSearchChanged = latestNewsState.hasFilterOrSearchChanged(filter, search);
const doesItemExistInCache = latestNewsState.mapNewsItemIdToTrue.has(id as string);
return hasFilterOrSearchChanged || !doesItemExistInCache;
};
const loadOnBrowser = (
filter: NewsFilter,
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
id: string | undefined,
search: string,
title: string | undefined
) => {
let latestNewsPromise;
if (shouldFetchItemsFromAPI(filter, id, search)) {
console.log('oh no lets load');
const endpoint = getNewsListEndpoint(filter, id, search, title);
latestNewsState.clearNewsItems();
latestNewsState.filter = filter;
latestNewsState.search = search;
latestNewsPromise = fetchNewsItemsFromAPI(endpoint, fetch);
} else {
latestNewsPromise = fetchNewsItemsFromCache(latestNewsState.newsItems);
}
return {
filter,
id,
latestNewsPromise,
search,
title
};
};
const loadOnServer = (
filter: NewsFilter,
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
id: string | undefined,
search: string,
title: string | undefined
) => {
const endpoint = getNewsListEndpoint(filter, id, search, title);
return {
filter,
id,
latestNewsPromise: fetchNewsItemsFromAPI(endpoint, fetch),
search,
title
};
};
export const load: LayoutLoad = ({ fetch, params, url }) => {
const filter = (url.searchParams.get('filter') as NewsFilter) || 'latest';
const id = params.id;
const search = url.searchParams.get('search') || '';
const title = params.title;
if (browser) {
return loadOnBrowser(filter, fetch, id, search, title);
} else {
return loadOnServer(filter, fetch, id, search, title);
}
};
+layout.svelte
<script lang="ts">
import { VList } from 'virtua/svelte';
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';
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('');
$effect(() => {
data.latestNewsPromise
.then((items) => {
latestNewsState.appendNewsItems(items.data);
})
.catch((error: Error) => {
console.error(`Something went wrong when loading news items ${error.message}`);
});
});
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>
<nav>
{#await data.latestNewsPromise}
<span>Loading items...</span>
{:then}
<VList data={latestNewsState.newsItems} getKey={(newsItem: NewsItem) => newsItem.id}>
{#snippet children(newsItem, index)}
<div class="list-item" class:selected={page.params.id === newsItem.id}>
<a
data-sveltekit-preload-data="off"
href={getNewsDetailEndpoint(filter, newsItem.id, search, newsItem.title)}
>{index + 1} {newsItem.title}</a
>
</div>
{#if index === latestNewsState.newsItems.length - 1}
<footer>
<button onclick={showMore}>Show More</button>
</footer>
{/if}
{/snippet}
</VList>
{/await}
</nav>
</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}
<VList data={latestNewsState.newsItems} getKey={(newsItem: NewsItem) => newsItem.id}>
{#snippet children(newsItem, index)}
<div class="list-item" class:selected={page.params.id === newsItem.id}>
<a
data-sveltekit-preload-data="off"
href={getNewsDetailEndpoint(filter, newsItem.id, search, newsItem.title)}
>{index + 1} {newsItem.title}</a
>
</div>
{#if index === latestNewsState.newsItems.length - 1}
<footer>
<button onclick={showMore}>Show More</button>
</footer>
{/if}
{/snippet}
</VList>
{/await}
</nav>
</div>
</main>
{:else}
<div class="detail">
{@render children()}
</div>
{/if}
<style>
.detail {
background-color: lightcyan;
flex: 1;
}
.list {
background-color: lightyellow;
flex: 1;
display: flex;
flex-direction: column;
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;
}
footer {
display: flex;
justify-content: center;
}
main {
background-color: lightgoldenrodyellow;
display: flex;
flex: 1;
overflow: hidden;
}
nav {
flex: 1;
}
</style>
Questions
- When filter or search is changed, scroll position should be back to 0
- But when clicking on items, scroll position should be retained
- How to achieve this?