- New to svelte and sveltekit so please bear with me
- I have a master detail layout in sveltekit (list of items and detail shown side by side on desktop and shown separately on mobile)
- The main challenge is that the urls change when filter or search is changed and item is clicked
- The list has infinite scrolling that keeps adding more and more items as we go down
- When I click on an item from page 1 it works but clicking on an item from any other page breaks the app
The Problem
- Similarly typing the URL of any item in the address bar directly works
- But typing the URL of any item in the address bar from page 2 breaks the app
URL Structure
src
└── routes
└── (news)
└── [[news=newsMatcher]]
├── [[tag]]
├── +layout.server.ts
├── +layout.svelte
├── +layout.ts
└── +page.svelte
└── [id=idMatcher]
└── [title]
└── +page.svelte
CodeSandbox Link
You can find the codesandbox link here
Keep in mind that this sandbox may not work on Firefox and may also have issues with ublock origin or adblock, best to run it on a different browser that you may have installed but don't use much
+layout.ts
export const load: LayoutLoad = ({ data, fetch, params, url }): LayoutLoadReturnType => {
const filter = (url.searchParams.get('filter') as NewsFilter) || 'latest';
const id = params.id;
const search = url.searchParams.get('search') || '';
const title = params.title;
const {
mapNameNoSpecialCharsToSymbolName,
mapSymbolNoSpecialCharsToSymbolName,
symbolNames,
symbolRanks
} = data;
const endpoint = getNewsListFirstPageEndpoint(filter, search);
return {
filter,
id,
latestNewsPromise: fetch(endpoint).then((r) => r.json()),
mapNameNoSpecialCharsToSymbolName,
mapSymbolNoSpecialCharsToSymbolName,
search,
symbolNames,
symbolRanks,
title
};
};
+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,
getNewsListWithItemNextPageEndpoint
} 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';
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('');
const newsItems: NewsItem[] = $state([]);
const mapNewsItemIdToTrue = new Map<string, boolean>();
const cursor: { lastFeedItemId: string | undefined; lastPubdate: string | undefined } = {
lastFeedItemId: undefined,
lastPubdate: undefined
};
$effect(() => {
data.latestNewsPromise.then((items) => {
appendNewsItems(items.data, mapNewsItemIdToTrue);
updateCursor();
});
return () => {
// If I don't clear the items they keep accumulating and changing filters doesn't work
clear();
};
});
function appendNewsItems(items: NewsItem[], mapNewsItemIdToTrue: Map<string, boolean>) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!mapNewsItemIdToTrue.get(item.id)) {
newsItems.push(item);
mapNewsItemIdToTrue.set(item.id, true);
}
}
}
function clear() {
newsItems.splice(0, newsItems.length);
cursor.lastFeedItemId = undefined;
cursor.lastPubdate = undefined;
mapNewsItemIdToTrue.clear();
}
function updateCursor() {
const lastItem = newsItems[newsItems.length - 1];
if (lastItem) {
cursor.lastFeedItemId = lastItem.id;
cursor.lastPubdate = lastItem.pubdate;
}
}
async function showMore() {
try {
const { lastFeedItemId, lastPubdate } = cursor;
let endpoint;
if (isDetailRoute(page.params.id, page.params.title)) {
endpoint = getNewsListWithItemNextPageEndpoint(cursor, filter, id, search);
} else {
endpoint = getNewsListNextPageEndpoint(cursor, filter, search);
}
const response = await fetch(endpoint);
const { data: items }: { data: NewsItem[] } = await response.json();
appendNewsItems(items, mapNewsItemIdToTrue);
updateCursor();
} catch (error) {
console.log(error);
}
}
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 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}
{#each newsItems as newsItem, index (newsItem.id)}
<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>
{:else}
<div>
No items to display under the current {filter}
{search} Maybe try changing them?
</div>
{/each}
{/await}
<footer>
<button onclick={showMore}>Show More</button>
</footer>
</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}
{#each newsItems as newsItem, index (newsItem.id)}
<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>
{:else}
<div>
No items to display under the current {filter}
{search} Maybe try changing them?
</div>
{/each}
{/await}
<footer>
<button onclick={showMore}>Show More</button>
</footer>
</nav>
</div>
</main>
{:else}
<div class="detail">
{@render children()}
</div>
{/if}
<style>
main {
background-color: lightgoldenrodyellow;
display: flex;
flex: 1;
overflow: hidden;
}
.list {
background-color: lightyellow;
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.list-item {
border-bottom: 1px dotted lightgray;
padding: 0.5rem 0;
}
.detail {
background-color: lightcyan;
flex: 1;
}
.panel {
display: flex;
font-size: x-small;
justify-content: space-between;
}
.selected {
background-color: yellow;
}
</style>
- This is the file I believe that may have some issue
- It loads fresh data for every request and this load function is called everytime the URL changes
- What needs to happen somehow is that when the filter or search changes while navigating to a list it loads fresh data
- But when navigating to the detail route , it checks if the id is present in the list of items we currently have and only issues a fetch request if that id doesn't exist
Questions
- Documentation says keep load functions pure
- It also says avoid shared state on the server
- How do I accesss news items inside +layout.ts or conditionally load data here so that it doesn't fire a fetch request every single time the URL changes?
- New to svelte and sveltekit so please bear with me
- I have a master detail layout in sveltekit (list of items and detail shown side by side on desktop and shown separately on mobile)
- The main challenge is that the urls change when filter or search is changed and item is clicked
- The list has infinite scrolling that keeps adding more and more items as we go down
- When I click on an item from page 1 it works but clicking on an item from any other page breaks the app
The Problem
- Similarly typing the URL of any item in the address bar directly works
- But typing the URL of any item in the address bar from page 2 breaks the app
URL Structure
src
└── routes
└── (news)
└── [[news=newsMatcher]]
├── [[tag]]
├── +layout.server.ts
├── +layout.svelte
├── +layout.ts
└── +page.svelte
└── [id=idMatcher]
└── [title]
└── +page.svelte
CodeSandbox Link
You can find the codesandbox link here
Keep in mind that this sandbox may not work on Firefox and may also have issues with ublock origin or adblock, best to run it on a different browser that you may have installed but don't use much
+layout.ts
export const load: LayoutLoad = ({ data, fetch, params, url }): LayoutLoadReturnType => {
const filter = (url.searchParams.get('filter') as NewsFilter) || 'latest';
const id = params.id;
const search = url.searchParams.get('search') || '';
const title = params.title;
const {
mapNameNoSpecialCharsToSymbolName,
mapSymbolNoSpecialCharsToSymbolName,
symbolNames,
symbolRanks
} = data;
const endpoint = getNewsListFirstPageEndpoint(filter, search);
return {
filter,
id,
latestNewsPromise: fetch(endpoint).then((r) => r.json()),
mapNameNoSpecialCharsToSymbolName,
mapSymbolNoSpecialCharsToSymbolName,
search,
symbolNames,
symbolRanks,
title
};
};
+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,
getNewsListWithItemNextPageEndpoint
} 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';
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('');
const newsItems: NewsItem[] = $state([]);
const mapNewsItemIdToTrue = new Map<string, boolean>();
const cursor: { lastFeedItemId: string | undefined; lastPubdate: string | undefined } = {
lastFeedItemId: undefined,
lastPubdate: undefined
};
$effect(() => {
data.latestNewsPromise.then((items) => {
appendNewsItems(items.data, mapNewsItemIdToTrue);
updateCursor();
});
return () => {
// If I don't clear the items they keep accumulating and changing filters doesn't work
clear();
};
});
function appendNewsItems(items: NewsItem[], mapNewsItemIdToTrue: Map<string, boolean>) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!mapNewsItemIdToTrue.get(item.id)) {
newsItems.push(item);
mapNewsItemIdToTrue.set(item.id, true);
}
}
}
function clear() {
newsItems.splice(0, newsItems.length);
cursor.lastFeedItemId = undefined;
cursor.lastPubdate = undefined;
mapNewsItemIdToTrue.clear();
}
function updateCursor() {
const lastItem = newsItems[newsItems.length - 1];
if (lastItem) {
cursor.lastFeedItemId = lastItem.id;
cursor.lastPubdate = lastItem.pubdate;
}
}
async function showMore() {
try {
const { lastFeedItemId, lastPubdate } = cursor;
let endpoint;
if (isDetailRoute(page.params.id, page.params.title)) {
endpoint = getNewsListWithItemNextPageEndpoint(cursor, filter, id, search);
} else {
endpoint = getNewsListNextPageEndpoint(cursor, filter, search);
}
const response = await fetch(endpoint);
const { data: items }: { data: NewsItem[] } = await response.json();
appendNewsItems(items, mapNewsItemIdToTrue);
updateCursor();
} catch (error) {
console.log(error);
}
}
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 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}
{#each newsItems as newsItem, index (newsItem.id)}
<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>
{:else}
<div>
No items to display under the current {filter}
{search} Maybe try changing them?
</div>
{/each}
{/await}
<footer>
<button onclick={showMore}>Show More</button>
</footer>
</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}
{#each newsItems as newsItem, index (newsItem.id)}
<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>
{:else}
<div>
No items to display under the current {filter}
{search} Maybe try changing them?
</div>
{/each}
{/await}
<footer>
<button onclick={showMore}>Show More</button>
</footer>
</nav>
</div>
</main>
{:else}
<div class="detail">
{@render children()}
</div>
{/if}
<style>
main {
background-color: lightgoldenrodyellow;
display: flex;
flex: 1;
overflow: hidden;
}
.list {
background-color: lightyellow;
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.list-item {
border-bottom: 1px dotted lightgray;
padding: 0.5rem 0;
}
.detail {
background-color: lightcyan;
flex: 1;
}
.panel {
display: flex;
font-size: x-small;
justify-content: space-between;
}
.selected {
background-color: yellow;
}
</style>
- This is the file I believe that may have some issue
- It loads fresh data for every request and this load function is called everytime the URL changes
- What needs to happen somehow is that when the filter or search changes while navigating to a list it loads fresh data
- But when navigating to the detail route , it checks if the id is present in the list of items we currently have and only issues a fetch request if that id doesn't exist
Questions
- Documentation says keep load functions pure
- It also says avoid shared state on the server
- How do I accesss news items inside +layout.ts or conditionally load data here so that it doesn't fire a fetch request every single time the URL changes?
1 Answer
Reset to default 1Your codeSandbox doesn't work so I can't really test this solution. First it seems to me that if you don't want newsItems
to be cleared every single time then don't call it in the return of the effect that appends news items. Instead, call it within its own $effect
when your conditions to clear it are met like this:
<script lang="ts">
// ...
$effect(() => {
data.latestNewsPromise.then((items) => {
appendNewsItems(items.data, mapNewsItemIdToTrue);
updateCursor();
});
});
// Clear the items so they don't keep accumulating
$effect(() => {
// assign your conditions to a `const filterOrSearchWasChanged` boolean
if (filterOrSearchWasChanged) clear();
})
// ...
</script>
Regarding how to safely access state in your +layout.ts
or +page.ts
, since those pages run both on client and server you could disable SSR by adding export const ssr = false
. Or you can use either the browser
environment from Svelte or globalThis.window
to only read state in the load
function from the client without disabling SSR if you need it. If you need data from the server you can make an API call from there passing newsItems
as part of the request to your server. It'd go something like this:
// layout.svelte.ts
// declare and export newsItems from here
export const newsItems: NewsItem[] = $state([]);
// +layout.svelte
<script>
// instead of declaring it here just import it from here, like you'd do with a store
import {newsItems} from './state.svelte'
// any updates to `newsItems` will be reflected wherever it's imported
// ...
</script>
//+page.ts
import { newsItems } from "../../state.svelte";
import { browser } from "$app/environment";
export async function load({ params, parent }) {
// make sure you only return from the load function once you're on the client side
if (browser) {
// if you need to fetch data from the server then you can create an API and
// fetch in something like this:
//
// const fetchData = await fetch("/path/to/your/api", {
// method:'POST',
// body:JSON.stringify(newsItems)
// })
// const response = await fetchData.json()
//
// return response
// if the data's already set in the newsItems array from the layout load then you
// may be able to proceed with something like this:
await parent();
return {
item: newsItems.data.find((item) => item.id === params.id)
};
}
}
It seems to me though that if you're already load the data from the layout, you can just set it to this shared newsItems
state and access it straight from your +page.svelte
in your details without a need for the load funciton there too.
Now as to why the app breaks with links from anywhere but the first page. It's not immediately evident to me but it probably has something to do with the fact that the +layout
and +page
load functions are not referencing the same object.
I hope this helps solve your problem!