feat: add SSR integration for TanStack Query v6
- Create +layout.ts to initialize QueryClient for SSR support - Update +layout.svelte to receive QueryClient from load function - Add SSR utilities (withInitialData, prefetchQuery, prefetchInfiniteQuery) - Add documentation for SSR integration patterns This enables two SSR patterns: 1. initialData pattern for pages using +page.server.ts 2. prefetchQuery pattern for pages using +page.ts Phase 4 of TanStack Query integration. Co-Authored-By: Justin Edmund <justin@jedmund.com>
This commit is contained in:
parent
be24a84e19
commit
e1c330f376
4 changed files with 343 additions and 6 deletions
119
src/lib/query/README.md
Normal file
119
src/lib/query/README.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
# TanStack Query SSR Integration
|
||||||
|
|
||||||
|
This directory contains utilities for integrating TanStack Query v6 with SvelteKit's server-side rendering.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The project uses a hybrid approach for SSR:
|
||||||
|
|
||||||
|
1. **QueryClient in Layout**: The `QueryClient` is created in `+layout.ts` and passed to `+layout.svelte` via the load function. This enables prefetching in child page load functions.
|
||||||
|
|
||||||
|
2. **Server Data with initialData**: Pages that use `+page.server.ts` can pass server-fetched data as `initialData` to TanStack Query using the `withInitialData()` helper.
|
||||||
|
|
||||||
|
3. **Prefetching in +page.ts**: Pages that use `+page.ts` (universal load functions) can use `prefetchQuery()` to populate the QueryClient cache before rendering.
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Pattern 1: Using Server Data with initialData
|
||||||
|
|
||||||
|
For pages that already fetch data in `+page.server.ts`:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- +page.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from '@tanstack/svelte-query'
|
||||||
|
import { partyQueries } from '$lib/api/queries/party.queries'
|
||||||
|
import { withInitialData } from '$lib/query/ssr'
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
|
let { data } = $props<{ data: PageData }>()
|
||||||
|
|
||||||
|
// Use server-fetched party as initial data
|
||||||
|
// The query won't refetch until the data becomes stale
|
||||||
|
const party = createQuery(() => ({
|
||||||
|
...partyQueries.byShortcode(data.party?.shortcode ?? ''),
|
||||||
|
...withInitialData(data.party),
|
||||||
|
enabled: !!data.party?.shortcode
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $party.data}
|
||||||
|
<h1>{$party.data.name}</h1>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Prefetching in Universal Load Functions
|
||||||
|
|
||||||
|
For pages that can use `+page.ts` (not server-only):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// +page.ts
|
||||||
|
import type { PageLoad } from './$types'
|
||||||
|
import { prefetchQuery } from '$lib/query/ssr'
|
||||||
|
import { partyQueries } from '$lib/api/queries/party.queries'
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ parent, params }) => {
|
||||||
|
const { queryClient } = await parent()
|
||||||
|
|
||||||
|
// Prefetch party data into the cache
|
||||||
|
await prefetchQuery(queryClient, partyQueries.byShortcode(params.id))
|
||||||
|
|
||||||
|
// No need to return data - it's already in the QueryClient cache
|
||||||
|
return { shortcode: params.id }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- +page.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from '@tanstack/svelte-query'
|
||||||
|
import { partyQueries } from '$lib/api/queries/party.queries'
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
|
let { data } = $props<{ data: PageData }>()
|
||||||
|
|
||||||
|
// Data is already in cache from prefetch - no loading state on initial render
|
||||||
|
const party = createQuery(() => partyQueries.byShortcode(data.shortcode))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $party.data}
|
||||||
|
<h1>{$party.data.name}</h1>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Infinite Queries with Prefetching
|
||||||
|
|
||||||
|
For paginated data:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// +page.ts
|
||||||
|
import type { PageLoad } from './$types'
|
||||||
|
import { prefetchInfiniteQuery } from '$lib/query/ssr'
|
||||||
|
import { partyQueries } from '$lib/api/queries/party.queries'
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ parent }) => {
|
||||||
|
const { queryClient } = await parent()
|
||||||
|
|
||||||
|
// Prefetch first page of parties
|
||||||
|
await prefetchInfiniteQuery(queryClient, partyQueries.list())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### From Server-Only to TanStack Query
|
||||||
|
|
||||||
|
1. **Keep existing +page.server.ts** - No changes needed to server load functions
|
||||||
|
2. **Add TanStack Query to component** - Use `createQuery` with `withInitialData`
|
||||||
|
3. **Benefit from caching** - Subsequent navigations use cached data
|
||||||
|
|
||||||
|
### From Custom Resources to TanStack Query
|
||||||
|
|
||||||
|
1. **Replace resource imports** with query/mutation imports
|
||||||
|
2. **Use createQuery** instead of resource state
|
||||||
|
3. **Use mutations** for CRUD operations with automatic cache invalidation
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `queryClient.ts` - QueryClient factory (legacy, kept for reference)
|
||||||
|
- `ssr.ts` - SSR utilities (withInitialData, prefetchQuery, etc.)
|
||||||
186
src/lib/query/ssr.ts
Normal file
186
src/lib/query/ssr.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
/**
|
||||||
|
* SSR Utilities for TanStack Query
|
||||||
|
*
|
||||||
|
* Provides utilities for integrating server-side data fetching with TanStack Query.
|
||||||
|
* These utilities support the initialData pattern for pages that use +page.server.ts
|
||||||
|
* load functions.
|
||||||
|
*
|
||||||
|
* @module query/ssr
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { QueryClient } from '@tanstack/svelte-query'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a query with initial data from SSR
|
||||||
|
*/
|
||||||
|
export interface InitialDataOptions<TData> {
|
||||||
|
/**
|
||||||
|
* The data fetched on the server to use as initial data
|
||||||
|
*/
|
||||||
|
initialData: TData | undefined | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional timestamp when the data was fetched on the server.
|
||||||
|
* If not provided, defaults to 0 (will be considered stale immediately).
|
||||||
|
* Use Date.now() on the server to get accurate timestamps.
|
||||||
|
*/
|
||||||
|
initialDataUpdatedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates query options with initial data from server-side rendering.
|
||||||
|
*
|
||||||
|
* Use this helper when you have data fetched in a +page.server.ts load function
|
||||||
|
* and want to use it as initial data for a TanStack Query.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <script lang="ts">
|
||||||
|
* import { createQuery } from '@tanstack/svelte-query'
|
||||||
|
* import { partyQueries } from '$lib/api/queries/party.queries'
|
||||||
|
* import { withInitialData } from '$lib/query/ssr'
|
||||||
|
* import type { PageData } from './$types'
|
||||||
|
*
|
||||||
|
* let { data } = $props<{ data: PageData }>()
|
||||||
|
*
|
||||||
|
* // Use server-fetched party as initial data
|
||||||
|
* const party = createQuery(() => ({
|
||||||
|
* ...partyQueries.byShortcode(data.party?.shortcode ?? ''),
|
||||||
|
* ...withInitialData(data.party)
|
||||||
|
* }))
|
||||||
|
* </script>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param initialData - The data fetched on the server
|
||||||
|
* @param updatedAt - Optional timestamp when data was fetched (defaults to 0)
|
||||||
|
* @returns Query options object with initialData and initialDataUpdatedAt
|
||||||
|
*/
|
||||||
|
export function withInitialData<TData>(
|
||||||
|
initialData: TData | undefined | null,
|
||||||
|
updatedAt?: number
|
||||||
|
): InitialDataOptions<TData> {
|
||||||
|
return {
|
||||||
|
initialData: initialData ?? undefined,
|
||||||
|
initialDataUpdatedAt: updatedAt ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetches a query on the server and returns the data.
|
||||||
|
*
|
||||||
|
* Use this in +page.ts load functions when you want to prefetch data
|
||||||
|
* into the QueryClient cache. This is the recommended approach for
|
||||||
|
* pages that don't use +page.server.ts.
|
||||||
|
*
|
||||||
|
* Note: This will NOT work with +page.server.ts load functions.
|
||||||
|
* Use withInitialData() instead for server-only load functions.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // +page.ts
|
||||||
|
* import type { PageLoad } from './$types'
|
||||||
|
* import { prefetchQuery } from '$lib/query/ssr'
|
||||||
|
* import { partyQueries } from '$lib/api/queries/party.queries'
|
||||||
|
*
|
||||||
|
* export const load: PageLoad = async ({ parent, params }) => {
|
||||||
|
* const { queryClient } = await parent()
|
||||||
|
*
|
||||||
|
* await prefetchQuery(queryClient, partyQueries.byShortcode(params.id))
|
||||||
|
*
|
||||||
|
* // No need to return data - it's in the cache
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param queryClient - The QueryClient instance from parent layout
|
||||||
|
* @param options - Query options from a query factory
|
||||||
|
*/
|
||||||
|
export async function prefetchQuery<TData>(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
options: {
|
||||||
|
queryKey: readonly unknown[]
|
||||||
|
queryFn: () => Promise<TData>
|
||||||
|
staleTime?: number
|
||||||
|
gcTime?: number
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: options.queryKey,
|
||||||
|
queryFn: options.queryFn,
|
||||||
|
staleTime: options.staleTime,
|
||||||
|
gcTime: options.gcTime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetches an infinite query on the server.
|
||||||
|
*
|
||||||
|
* Use this in +page.ts load functions when you want to prefetch
|
||||||
|
* paginated data into the QueryClient cache.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // +page.ts
|
||||||
|
* import type { PageLoad } from './$types'
|
||||||
|
* import { prefetchInfiniteQuery } from '$lib/query/ssr'
|
||||||
|
* import { partyQueries } from '$lib/api/queries/party.queries'
|
||||||
|
*
|
||||||
|
* export const load: PageLoad = async ({ parent }) => {
|
||||||
|
* const { queryClient } = await parent()
|
||||||
|
*
|
||||||
|
* await prefetchInfiniteQuery(queryClient, partyQueries.list())
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param queryClient - The QueryClient instance from parent layout
|
||||||
|
* @param options - Infinite query options from a query factory
|
||||||
|
*/
|
||||||
|
export async function prefetchInfiniteQuery<TData>(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
options: {
|
||||||
|
queryKey: readonly unknown[]
|
||||||
|
queryFn: (context: { pageParam: number }) => Promise<TData>
|
||||||
|
initialPageParam: number
|
||||||
|
staleTime?: number
|
||||||
|
gcTime?: number
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
await queryClient.prefetchInfiniteQuery({
|
||||||
|
queryKey: options.queryKey,
|
||||||
|
queryFn: options.queryFn,
|
||||||
|
initialPageParam: options.initialPageParam,
|
||||||
|
staleTime: options.staleTime,
|
||||||
|
gcTime: options.gcTime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets query data directly in the cache.
|
||||||
|
*
|
||||||
|
* Use this when you have data from a server load function and want
|
||||||
|
* to populate the QueryClient cache directly. This is useful when
|
||||||
|
* migrating from server-only load functions to TanStack Query.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // In a component or effect
|
||||||
|
* import { useQueryClient } from '@tanstack/svelte-query'
|
||||||
|
* import { setQueryData } from '$lib/query/ssr'
|
||||||
|
* import { partyKeys } from '$lib/api/queries/party.queries'
|
||||||
|
*
|
||||||
|
* const queryClient = useQueryClient()
|
||||||
|
*
|
||||||
|
* // Populate cache with server data
|
||||||
|
* setQueryData(queryClient, partyKeys.detail(shortcode), serverParty)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param queryClient - The QueryClient instance
|
||||||
|
* @param queryKey - The query key to set data for
|
||||||
|
* @param data - The data to set in the cache
|
||||||
|
*/
|
||||||
|
export function setQueryData<TData>(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
queryKey: readonly unknown[],
|
||||||
|
data: TData
|
||||||
|
): void {
|
||||||
|
queryClient.setQueryData(queryKey, data)
|
||||||
|
}
|
||||||
|
|
@ -11,14 +11,12 @@
|
||||||
import { authStore } from '$lib/stores/auth.store'
|
import { authStore } from '$lib/stores/auth.store'
|
||||||
import { browser } from '$app/environment'
|
import { browser } from '$app/environment'
|
||||||
import { QueryClientProvider } from '@tanstack/svelte-query'
|
import { QueryClientProvider } from '@tanstack/svelte-query'
|
||||||
import { createQueryClient } from '$lib/query/queryClient'
|
import type { LayoutData } from './$types'
|
||||||
|
|
||||||
const queryClient = createQueryClient()
|
|
||||||
|
|
||||||
// Get `data` and `children` from the router via $props()
|
// Get `data` and `children` from the router via $props()
|
||||||
// Use a more flexible type that allows additional properties from child pages
|
// QueryClient is now created in +layout.ts for SSR support
|
||||||
const { data, children } = $props<{
|
const { data, children } = $props<{
|
||||||
data: any // Allow any data to pass through from child pages
|
data: LayoutData & { [key: string]: any } // Allow any data to pass through from child pages
|
||||||
children: () => any
|
children: () => any
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|
@ -110,7 +108,7 @@
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={data.queryClient}>
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
<div class="app-container" class:sidebar-open={sidebar.isOpen}>
|
<div class="app-container" class:sidebar-open={sidebar.isOpen}>
|
||||||
<div class="main-pane">
|
<div class="main-pane">
|
||||||
|
|
|
||||||
34
src/routes/+layout.ts
Normal file
34
src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
/**
|
||||||
|
* Root Layout Load Function
|
||||||
|
*
|
||||||
|
* Creates a QueryClient instance for SSR support with TanStack Query v6.
|
||||||
|
* The QueryClient is created here so it can be used for prefetching in
|
||||||
|
* child page load functions.
|
||||||
|
*
|
||||||
|
* @module routes/+layout
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LayoutLoad } from './$types'
|
||||||
|
import { browser } from '$app/environment'
|
||||||
|
import { QueryClient } from '@tanstack/svelte-query'
|
||||||
|
|
||||||
|
export const load: LayoutLoad = async () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
// Disable queries on server - they will be prefetched explicitly
|
||||||
|
enabled: browser,
|
||||||
|
// Cache data for 5 minutes before considering it stale
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
// Keep unused data in cache for 30 minutes
|
||||||
|
gcTime: 1000 * 60 * 30,
|
||||||
|
// Retry failed requests twice
|
||||||
|
retry: 2,
|
||||||
|
// Don't refetch on window focus by default
|
||||||
|
refetchOnWindowFocus: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { queryClient }
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue