src
└── routes
└── (news)
└── [[news=newsMatcher]]
├── [[tag]]
├── +layout.server.ts
├── +layout.svelte
├── +layout.ts
└── +page.svelte
└── [id=idMatcher]
└── [title]
└── +page.svelte
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
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
};
};
<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>
src
└── routes
└── (news)
└── [[news=newsMatcher]]
├── [[tag]]
├── +layout.server.ts
├── +layout.svelte
├── +layout.ts
└── +page.svelte
└── [id=idMatcher]
└── [title]
└── +page.svelte
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
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
};
};
<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>
Your 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!