From 15cdfbaf35b7cc39ef4ac748829d9c97cf16dd0c Mon Sep 17 00:00:00 2001 From: Enno Richter Date: Mon, 12 Jan 2026 10:42:49 +0100 Subject: [PATCH] feat(svelte-db): add SSR and hydration support for SvelteKit Add server-side rendering and hydration support following the patterns from the React implementation. This enables prefetching data on the server and seamlessly hydrating it on the client. New APIs: - createServerContext() - creates context to collect prefetched queries - prefetchLiveQuery() - prefetches data server-side without starting sync - dehydrate() - serializes context for JSON transport - HydrationBoundary - component that provides hydration context - setHydrationContext/getHydrationContext - Svelte context utilities Changes to useLiveQuery: - Checks for hydrated data via context when query has an `id` - Returns hydrated data immediately when collection is empty - Sets isReady: true when using hydrated data - Warns in dev mode if HydrationBoundary exists but no matching data Also adds subpath exports (./server, ./hydration) for better tree-shaking. --- .changeset/svelte-db-ssr-hydration.md | 20 +++ packages/svelte-db/package.json | 8 + .../svelte-db/src/HydrationBoundary.svelte | 41 +++++ packages/svelte-db/src/hydration.svelte.ts | 52 +++++++ packages/svelte-db/src/index.ts | 15 ++ packages/svelte-db/src/server.ts | 140 ++++++++++++++++++ packages/svelte-db/src/useLiveQuery.svelte.ts | 90 ++++++++++- 7 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 .changeset/svelte-db-ssr-hydration.md create mode 100644 packages/svelte-db/src/HydrationBoundary.svelte create mode 100644 packages/svelte-db/src/hydration.svelte.ts create mode 100644 packages/svelte-db/src/server.ts diff --git a/.changeset/svelte-db-ssr-hydration.md b/.changeset/svelte-db-ssr-hydration.md new file mode 100644 index 000000000..dd19dddf6 --- /dev/null +++ b/.changeset/svelte-db-ssr-hydration.md @@ -0,0 +1,20 @@ +--- +'@tanstack/svelte-db': minor +--- + +Add SSR and hydration support for SvelteKit + +New APIs for server-side rendering: + +- `createServerContext()` - creates context to collect prefetched queries +- `prefetchLiveQuery()` - prefetches data server-side without starting sync +- `dehydrate()` - serializes context for JSON transport +- `HydrationBoundary` - component that provides hydration context to children + +The `useLiveQuery` hook now supports hydration: + +- Pass an `id` in the config object to match with server-prefetched data +- Hydrated data is returned immediately while the collection syncs in background +- `isReady` returns `true` when using hydrated data + +Also adds subpath exports (`@tanstack/svelte-db/server`, `@tanstack/svelte-db/hydration`) for better tree-shaking. diff --git a/packages/svelte-db/package.json b/packages/svelte-db/package.json index 0c1dd1f1f..d109498b5 100644 --- a/packages/svelte-db/package.json +++ b/packages/svelte-db/package.json @@ -28,6 +28,14 @@ ".": { "types": "./dist/index.d.ts", "svelte": "./dist/index.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" + }, + "./hydration": { + "types": "./dist/hydration.svelte.d.ts", + "svelte": "./dist/hydration.svelte.js" } }, "sideEffects": [ diff --git a/packages/svelte-db/src/HydrationBoundary.svelte b/packages/svelte-db/src/HydrationBoundary.svelte new file mode 100644 index 000000000..43db65050 --- /dev/null +++ b/packages/svelte-db/src/HydrationBoundary.svelte @@ -0,0 +1,41 @@ + + + +{@render children()} diff --git a/packages/svelte-db/src/hydration.svelte.ts b/packages/svelte-db/src/hydration.svelte.ts new file mode 100644 index 000000000..ce06c8b1e --- /dev/null +++ b/packages/svelte-db/src/hydration.svelte.ts @@ -0,0 +1,52 @@ +import { getContext, setContext } from 'svelte' +import type { DehydratedState } from './server' + +/** + * Context key for hydration state + * @internal + */ +const HYDRATION_KEY = Symbol(`tanstack-db-hydration`) + +/** + * Set the hydration context with dehydrated state from the server + * This should be called in a parent component to make hydrated data available to child components. + * + * @param state - The dehydrated state from the server, or undefined if no SSR data + * @internal + */ +export function setHydrationContext(state: DehydratedState | undefined): void { + setContext(HYDRATION_KEY, state) +} + +/** + * Get the hydration context containing dehydrated state + * Returns undefined if no hydration context has been set or if called outside a component. + * + * @returns The dehydrated state, or undefined if not in a hydration context + * @internal + */ +export function getHydrationContext(): DehydratedState | undefined { + try { + return getContext(HYDRATION_KEY) + } catch { + // getContext throws when called outside of component initialization + // In that case, we simply return undefined (no hydration data available) + return undefined + } +} + +/** + * Hook to access hydrated data for a specific query by ID + * + * @param id - The query ID to look up + * @returns The hydrated data for the query, or undefined if not found + * @internal + */ +export function useHydratedQuery(id: string): T | undefined { + const state = getHydrationContext() + + if (!state) return undefined + + const query = state.queries.find((q) => q.id === id) + return query?.data as T | undefined +} diff --git a/packages/svelte-db/src/index.ts b/packages/svelte-db/src/index.ts index a07f98a64..9d9111f02 100644 --- a/packages/svelte-db/src/index.ts +++ b/packages/svelte-db/src/index.ts @@ -1,6 +1,21 @@ // Re-export all public APIs export * from './useLiveQuery.svelte.js' +// SSR/Hydration exports +export { + createServerContext, + prefetchLiveQuery, + dehydrate, + type ServerContext, + type DehydratedState, + type DehydratedQuery, + type PrefetchLiveQueryOptions, +} from './server.js' + +export { setHydrationContext, getHydrationContext } from './hydration.svelte.js' + +export { default as HydrationBoundary } from './HydrationBoundary.svelte' + // Re-export everything from @tanstack/db export * from '@tanstack/db' diff --git a/packages/svelte-db/src/server.ts b/packages/svelte-db/src/server.ts new file mode 100644 index 000000000..ed912bc0e --- /dev/null +++ b/packages/svelte-db/src/server.ts @@ -0,0 +1,140 @@ +import { createLiveQueryCollection } from '@tanstack/db' +import type { + Context, + InitialQueryBuilder, + LiveQueryCollectionConfig, + QueryBuilder, +} from '@tanstack/db' + +/** + * Server context for managing live query prefetching and dehydration + */ +export interface ServerContext { + queries: Map +} + +/** + * Dehydrated query result that can be serialized and sent to the client + */ +export interface DehydratedQuery { + id: string + data: T + timestamp: number +} + +/** + * Dehydrated state containing all prefetched queries + */ +export interface DehydratedState { + queries: Array +} + +/** + * Options for prefetching a live query + */ +export interface PrefetchLiveQueryOptions { + /** + * Unique identifier for this query. Required for hydration to work. + * Must match the id used in the client-side useLiveQuery call. + */ + id: string + + /** + * The query to execute + */ + query: + | ((q: InitialQueryBuilder) => QueryBuilder) + | QueryBuilder + + /** + * Optional transform function to apply to the query results before dehydration. + * Useful for serialization (e.g., converting Date objects to ISO strings). + * Should return an array of rows; non-arrays are normalized to a single-element array. + * + * @example + * ```ts + * transform: (rows) => rows.map(row => ({ + * ...row, + * createdAt: row.createdAt.toISOString() + * })) + * ``` + */ + transform?: (rows: Array) => Array | any +} + +/** + * Create a new server context for managing queries during SSR + */ +export function createServerContext(): ServerContext { + return { + queries: new Map(), + } +} + +/** + * Prefetch a live query on the server and store the result in the server context + * + * @example + * ```ts + * // In +page.server.ts + * const serverContext = createServerContext() + * + * await prefetchLiveQuery(serverContext, { + * id: 'todos', + * query: (q) => q.from({ todos: todosCollection }) + * }) + * + * return { dehydratedState: dehydrate(serverContext) } + * ``` + */ +export async function prefetchLiveQuery( + serverContext: ServerContext, + options: PrefetchLiveQueryOptions, +): Promise { + const { id, query, transform } = options + + // Create a temporary collection for this query + const config: LiveQueryCollectionConfig = { + id, + query, + startSync: false, // Don't auto-start, we'll preload manually + } + + const collection = createLiveQueryCollection(config) + + try { + // Wait for the collection to be ready with data + const base = await collection.toArrayWhenReady() + + // Apply optional transform (e.g., for serialization) + const out = transform ? transform(base as Array) : base + // Normalize to array (defensive) + const data = Array.isArray(out) ? out : [out] + + // Store in server context + serverContext.queries.set(id, { + id, + data, + timestamp: Date.now(), + }) + } finally { + // Clean up the collection + await collection.cleanup() + } +} + +/** + * Serialize the server context into a dehydrated state that can be sent to the client + * + * @example + * ```ts + * // In +page.server.ts + * const dehydratedState = dehydrate(serverContext) + * return { dehydratedState } + * ``` + */ +export function dehydrate(serverContext: ServerContext): DehydratedState { + return { + queries: Array.from(serverContext.queries.values()), + } +} diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index cd82a59fa..f83b606ba 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -3,6 +3,7 @@ import { untrack } from 'svelte' // eslint-disable-next-line import/no-duplicates -- See https://github.com/un-ts/eslint-plugin-import-x/issues/308 import { SvelteMap } from 'svelte/reactivity' import { BaseQueryBuilder, createLiveQueryCollection } from '@tanstack/db' +import { getHydrationContext, useHydratedQuery } from './hydration.svelte' import type { ChangeMessage, Collection, @@ -286,6 +287,20 @@ export function useLiveQuery( configOrQueryOrCollection: any, deps: Array<() => unknown> = [], ): UseLiveQueryReturn | UseLiveQueryReturnWithCollection { + // Extract query ID from config object (not from collections or functions) + // Only config objects support id for SSR hydration matching + const queryId = + typeof configOrQueryOrCollection === `object` && + configOrQueryOrCollection !== null && + `id` in configOrQueryOrCollection && + typeof configOrQueryOrCollection.subscribeChanges !== `function` // Not a collection + ? configOrQueryOrCollection.id + : undefined + + // Get hydration context and hydrated data + const hydrationState = getHydrationContext() + const hydratedData = queryId ? useHydratedQuery(queryId) : undefined + const collection = $derived.by(() => { // First check if the original parameter might be a getter // by seeing if toValue returns something different than the original @@ -336,6 +351,17 @@ export function useLiveQuery( startSync: true, }) } else { + // Config object case - check if query returns null/undefined + const queryFn = unwrappedParam.query + if (typeof queryFn === `function`) { + const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder + const result = queryFn(queryBuilder) + if (result === undefined || result === null) { + // Disabled query - return null + return null + } + } + return createLiveQueryCollection({ ...unwrappedParam, startSync: true, @@ -454,11 +480,70 @@ export function useLiveQuery( } }) + // Check if we should use hydrated data + // Use hydrated data if: + // 1. We have hydrated data + // 2. The collection is empty (no data loaded yet) + const shouldUseHydratedData = () => + hydratedData !== undefined && internalData.length === 0 + + // Dev-mode hint: warn if hydrationState exists (SSR setup) but query has id and no matching data + // This catches the case where HydrationBoundary is present but this specific query wasn't prefetched + if ( + process.env.NODE_ENV !== `production` && + hydrationState && // Only warn if we're in an SSR environment with HydrationBoundary + queryId && + hydratedData === undefined + ) { + console.warn( + `TanStack DB: no hydrated data found for id "${queryId}" — did you prefetch this query on the server with prefetchLiveQuery()?`, + ) + } + return { get state() { + // If using hydrated data, convert to Map + if (shouldUseHydratedData()) { + const currentCollection = collection + const config = currentCollection?.config as + | CollectionConfigSingleRowOption + | undefined + const hydrated = Array.isArray(hydratedData) + ? hydratedData + : [hydratedData] + return new Map( + hydrated.map((item, index) => { + // Try to use getKey if available, otherwise use index + const key = + config && typeof config.getKey === `function` + ? config.getKey(item) + : index + return [key, item] + }), + ) + } return state }, get data() { + // If using hydrated data, return it directly + if (shouldUseHydratedData()) { + const currentCollection = collection + if (currentCollection) { + const config = + currentCollection.config as CollectionConfigSingleRowOption< + any, + any, + any + > + if (config.singleResult) { + return Array.isArray(hydratedData) ? hydratedData[0] : hydratedData + } + } + // Ensure array when singleResult is false + return Array.isArray(hydratedData) ? hydratedData : [hydratedData] + } + + // Normal case: use collection data const currentCollection = collection if (currentCollection) { const config = @@ -483,7 +568,10 @@ export function useLiveQuery( return status === `loading` }, get isReady() { - return status === `ready` || status === `disabled` + // Consider hydrated data as "ready enough" for UI + return ( + status === `ready` || status === `disabled` || shouldUseHydratedData() + ) }, get isIdle() { return status === `idle`