From 21dfb4be6639a8b9f67365feec79555e64d494cd Mon Sep 17 00:00:00 2001 From: Enno Richter Date: Thu, 29 Jan 2026 06:35:39 +0100 Subject: [PATCH] fix(svelte-db): initialize useLiveQuery data synchronously for SSR $effect doesn't run during SSR, so internalData remained empty during server-side rendering even when the collection was populated with initial data via sync config. This adds synchronous initialization of state and internalData from the collection immediately after $state declarations, ensuring data is available for SSR before effects run on the client. --- .changeset/svelte-db-ssr-init.md | 9 ++ packages/svelte-db/src/useLiveQuery.svelte.ts | 11 +- .../tests/useLiveQuery.svelte.test.ts | 115 ++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 .changeset/svelte-db-ssr-init.md diff --git a/.changeset/svelte-db-ssr-init.md b/.changeset/svelte-db-ssr-init.md new file mode 100644 index 000000000..f20ace034 --- /dev/null +++ b/.changeset/svelte-db-ssr-init.md @@ -0,0 +1,9 @@ +--- +'@tanstack/svelte-db': patch +--- + +Fix SSR synchronous initialization for useLiveQuery. + +`$effect` doesn't run during server-side rendering, so `internalData` and `state` remained empty even when the collection was populated with initial data via sync config. + +This adds synchronous initialization of state and internalData from the collection immediately after `$state` declarations, ensuring data is available for SSR before effects run on the client. diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 436112b5f..976fb24ac 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -331,6 +331,15 @@ export function useLiveQuery( // Track collection status reactively let status = $state(collection ? collection.status : (`disabled` as const)) + // SSR: Synchronously initialize data from collection (effects don't run during SSR) + // This ensures initial data is available for server-side rendering + if (collection) { + for (const [key, value] of collection.entries()) { + state.set(key, value) + } + internalData = Array.from(collection.values()) + } + // Helper to sync data array from collection in correct order const syncDataFromCollection = ( currentCollection: Collection, @@ -344,7 +353,7 @@ export function useLiveQuery( // Track current unsubscribe function let currentUnsubscribe: (() => void) | null = null - // Watch for collection changes and subscribe to updates + // Watch for collection changes and subscribe to updates (client-side only) $effect(() => { const currentCollection = collection diff --git a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts index edf7eb79c..7be10525f 100644 --- a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts +++ b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts @@ -1474,6 +1474,121 @@ describe(`Query Collections`, () => { }) }) + describe(`SSR synchronous initialization`, () => { + it(`should have data available synchronously without flushSync for SSR`, () => { + // This test verifies the SSR fix: data should be available immediately + // after calling useLiveQuery, before any effects run (flushSync). + // In SSR, $effect doesn't run, so synchronous initialization is critical. + const collection = createCollection( + mockSyncCollectionOptions({ + id: `ssr-sync-init-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })), + ) + + // IMPORTANT: Check data BEFORE flushSync - this simulates SSR behavior + // where $effect doesn't run. The data should already be available. + expect(query.state.size).toBe(1) // John Smith (age 35) should be present + expect(query.data).toHaveLength(1) + expect(query.data[0]).toMatchObject({ + id: `3`, + name: `John Smith`, + age: 35, + }) + + // After flushSync, data should still be correct + flushSync() + expect(query.state.size).toBe(1) + expect(query.data).toHaveLength(1) + }) + }) + + it(`should have data available synchronously with pre-created collection`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `ssr-pre-created-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + cleanup = $effect.root(() => { + // Create a live query collection beforehand + const liveQueryCollection = createLiveQueryCollection({ + query: (q) => + q.from({ persons: collection }).select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + startSync: true, + }) + + // Give it a moment to sync + flushSync() + + // Now use it with useLiveQuery + const query = useLiveQuery(liveQueryCollection) + + // Data should be available immediately (SSR scenario) + expect(query.state.size).toBe(3) + expect(query.data).toHaveLength(3) + }) + }) + + it(`should have empty state for collection without initial data`, () => { + let syncMarkReady: (() => void) | undefined + + const collection = createCollection({ + id: `ssr-empty-collection-test`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ markReady }) => { + syncMarkReady = markReady + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + cleanup = $effect.root(() => { + const query = useLiveQuery((q) => + q.from({ persons: collection }).select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + ) + + // Before sync, data should be empty (correct SSR behavior for unsynced collections) + expect(query.state.size).toBe(0) + expect(query.data).toHaveLength(0) + + // Start sync and add data + collection.preload() + syncMarkReady!() + + flushSync() + + // Still empty because no data was added + expect(query.state.size).toBe(0) + }) + }) + }) + describe(`eager execution during sync`, () => { it(`should show state while isLoading is true during sync`, () => { let syncBegin: (() => void) | undefined