diff --git a/.changeset/cute-falcons-wear.md b/.changeset/cute-falcons-wear.md new file mode 100644 index 000000000..8ddf3b96c --- /dev/null +++ b/.changeset/cute-falcons-wear.md @@ -0,0 +1,49 @@ +--- +"@tanstack/solid-db": minor +--- + +Update solid-db to enable suspense support. +You can now run do + +```tsx +// Use Suspense boundaries +const todosQuery = useLiveQuery((q) => q.from({ todos: todoCollection })) + +return ( + <> + {/* Status and other getters don't trigger Suspense */} +
Status {todosQuery.status}
+
Loading {todosQuery.isLoading ? "yes" : "no"}
+ + Loading...}> + + {(todo) =>
  • {todo.text}
  • } +
    +
    + +) +``` + +All values returned from useLiveQuery are now getters, so no longer need to be called as functions. This is a breaking change. This is to match how createResource works, and everything still stays reactive. + +```tsx +const todos = useLiveQuery(() => existingCollection) + +const handleToggle = (id) => { + // Can now access collection directly + todos.collection.update(id, (draft) => { + draft.completed = !draft.completed + }) +} + +return ( + <> + {/* Status and other getters don't trigger Suspense */} +
    Status {todos.status}
    +
    Loading {todos.isLoading ? "yes" : "no"}
    +
    Ready {todos.isReady ? "yes" : "no"}
    +
    Idle {todos.isIdle ? "yes" : "no"}
    +
    Error {todos.isError ? "yes" : "no"}
    + +) +``` diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index d3e645871..f16b0c9bc 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -1,6 +1,6 @@ import { batch, - createComputed, + createEffect, createMemo, createResource, createSignal, @@ -24,7 +24,7 @@ import type { /** * Create a live query using a query function * @param queryFn - Query function that defines what data to fetch - * @returns Object with reactive data, state, and status information + * @returns Accessor that returns data with Suspense support, with state and status infomation as properties * @example * // Basic query with object syntax * const todosQuery = useLiveQuery((q) => @@ -62,39 +62,57 @@ import type { * * return ( * - * + * *
    Loading...
    *
    - * + * *
    Error: {todosQuery.status()}
    *
    - * - * + * + * * {(todo) =>
  • {todo.text}
  • } *
    *
    *
    * ) + * + * @example + * // Use Suspense boundaries + * const todosQuery = useLiveQuery((q) => + * q.from({ todos: todoCollection }) + * ) + * + * return ( + * Loading...}> + * + * {(todo) =>
  • {todo.text}
  • } + *
    + *
    + * ) */ // Overload 1: Accept just the query function export function useLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder -): { - state: ReactiveMap> +): Accessor>> & { + /** + * @deprecated use function result instead + * query.data -> query() + */ data: Array> - collection: Accessor, string | number, {}>> - status: Accessor - isLoading: Accessor - isReady: Accessor - isIdle: Accessor - isError: Accessor - isCleanedUp: Accessor + state: ReactiveMap> + collection: Collection, string | number, {}> + status: CollectionStatus + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean } /** * Create a live query using configuration object * @param config - Configuration object with query and options - * @returns Object with reactive data, state, and status information + * @returns Accessor that returns data with Suspense support, with state and status infomation as properties * @example * // Basic config object usage * const todosQuery = useLiveQuery(() => ({ @@ -118,14 +136,14 @@ export function useLiveQuery( * })) * * return ( - * {itemsQuery.data.length} items loaded}> - * + * {itemsQuery().length} items loaded}> + * *
    Loading...
    *
    - * + * *
    Something went wrong
    *
    - * + * *
    Preparing...
    *
    *
    @@ -134,22 +152,26 @@ export function useLiveQuery( // Overload 2: Accept config object export function useLiveQuery( config: Accessor> -): { - state: ReactiveMap> +): Accessor>> & { + /** + * @deprecated use function result instead + * query.data -> query() + */ data: Array> - collection: Accessor, string | number, {}>> - status: Accessor - isLoading: Accessor - isReady: Accessor - isIdle: Accessor - isError: Accessor - isCleanedUp: Accessor + state: ReactiveMap> + collection: Collection, string | number, {}> + status: CollectionStatus + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean } /** * Subscribe to an existing live query collection * @param liveQueryCollection - Pre-created live query collection to subscribe to - * @returns Object with reactive data, state, and status information + * @returns Accessor that returns data with Suspense support, with state and status infomation as properties * @example * // Using pre-created live query collection * const myLiveQuery = createLiveQueryCollection((q) => @@ -163,7 +185,7 @@ export function useLiveQuery( * * // Use collection for mutations * const handleToggle = (id) => { - * existingQuery.collection().update(id, draft => { draft.completed = !draft.completed }) + * existingQuery.collection.update(id, draft => { draft.completed = !draft.completed }) * } * * @example @@ -171,11 +193,11 @@ export function useLiveQuery( * const sharedQuery = useLiveQuery(() => sharedCollection) * * return ( - * {(item) => }}> - * + * {(item) => }}> + * *
    Loading...
    *
    - * + * *
    Error loading data
    *
    *
    @@ -188,16 +210,20 @@ export function useLiveQuery< TUtils extends Record, >( liveQueryCollection: Accessor> -): { - state: ReactiveMap +): Accessor> & { + /** + * @deprecated use function result instead + * query.data -> query() + */ data: Array - collection: Accessor> - status: Accessor - isLoading: Accessor - isReady: Accessor - isIdle: Accessor - isError: Accessor - isCleanedUp: Accessor + state: ReactiveMap + collection: Collection + status: CollectionStatus + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean } // Implementation - use function overloads to infer the actual collection type @@ -250,79 +276,115 @@ export function useLiveQuery( ) } - // Track current unsubscribe function - let currentUnsubscribe: (() => void) | null = null - - createComputed( - () => { - const currentCollection = collection() - - // Update status ref whenever the effect runs + const [getDataResource] = createResource( + () => ({ currentCollection: collection() }), + async ({ currentCollection }) => { setStatus(currentCollection.status) - + await currentCollection.toArrayWhenReady() // Initialize state with current collection data - state.clear() - for (const [key, value] of currentCollection.entries()) { - state.set(key, value) - } + batch(() => { + state.clear() + for (const [key, value] of currentCollection.entries()) { + state.set(key, value) + } + syncDataFromCollection(currentCollection) + setStatus(currentCollection.status) + }) + return data + }, + { + name: `TanstackDBData`, + deferStream: false, + initialValue: data, + } + ) - // Subscribe to collection changes with granular updates - const subscription = currentCollection.subscribeChanges( - (changes: Array>) => { - // Apply each change individually to the reactive state - batch(() => { - for (const change of changes) { - switch (change.type) { - case `insert`: - case `update`: - state.set(change.key, change.value) - break - case `delete`: - state.delete(change.key) - break - } + createEffect(() => { + const currentCollection = collection() + const subscription = currentCollection.subscribeChanges( + // Changes is fine grained, so does not work great with an array + (changes: Array>) => { + // Apply each change individually to the reactive state + batch(() => { + for (const change of changes) { + switch (change.type) { + case `insert`: + case `update`: + state.set(change.key, change.value) + break + case `delete`: + state.delete(change.key) + break } - }) + } - // Update the data array to maintain sorted order syncDataFromCollection(currentCollection) // Update status ref on every change setStatus(currentCollection.status) - }, - { - includeInitialState: true, - } - ) - - currentUnsubscribe = subscription.unsubscribe.bind(subscription) - - // Preload collection data if not already started - if (currentCollection.status === `idle`) { - createResource(() => currentCollection.preload()) + }) + }, + { + includeInitialState: true, } + ) - // Cleanup when computed is invalidated - onCleanup(() => { - if (currentUnsubscribe) { - currentUnsubscribe() - currentUnsubscribe = null - } - }) - }, - undefined, - { name: `TanstackDBSyncComputed` } - ) + onCleanup(() => { + subscription.unsubscribe() + }) + }) - return { - state, - data, - collection, - status, - isLoading: () => status() === `loading`, - isReady: () => status() === `ready`, - isIdle: () => status() === `idle`, - isError: () => status() === `error`, - isCleanedUp: () => status() === `cleaned-up`, + // We have to remove getters from the resource function so we wrap it + function getData() { + return getDataResource() } + + Object.defineProperties(getData, { + data: { + get() { + return getData() + }, + }, + status: { + get() { + return status() + }, + }, + collection: { + get() { + return collection() + }, + }, + state: { + get() { + return state + }, + }, + isLoading: { + get() { + return status() === `loading` + }, + }, + isReady: { + get() { + return status() === `ready` + }, + }, + isIdle: { + get() { + return status() === `idle` + }, + }, + isError: { + get() { + return status() === `error` + }, + }, + isCleanedUp: { + get() { + return status() === `cleaned-up` + }, + }, + }) + return getData } diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index 459a10bd1..76a55e463 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -105,9 +105,9 @@ describe(`Query Collections`, () => { await waitFor(() => { expect(rendered.result.state.size).toBe(1) // Only John Smith (age 35) }) - expect(rendered.result.data).toHaveLength(1) + expect(rendered.result()).toHaveLength(1) - const johnSmith = rendered.result.data[0] + const johnSmith = rendered.result()[0] expect(johnSmith).toMatchObject({ id: `3`, name: `John Smith`, @@ -146,8 +146,8 @@ describe(`Query Collections`, () => { name: `John Smith`, }) - expect(rendered.result.data.length).toBe(1) - expect(rendered.result.data[0]).toMatchObject({ + expect(rendered.result().length).toBe(1) + expect(rendered.result()[0]).toMatchObject({ id: `3`, name: `John Smith`, }) @@ -179,8 +179,8 @@ describe(`Query Collections`, () => { name: `Kyle Doe`, }) - expect(rendered.result.data.length).toBe(2) - expect(rendered.result.data).toEqual( + expect(rendered.result().length).toBe(2) + expect(rendered.result()).toEqual( expect.arrayContaining([ expect.objectContaining({ id: `3`, @@ -216,8 +216,8 @@ describe(`Query Collections`, () => { name: `Kyle Doe 2`, }) - expect(rendered.result.data.length).toBe(2) - expect(rendered.result.data).toEqual( + expect(rendered.result().length).toBe(2) + expect(rendered.result()).toEqual( expect.arrayContaining([ expect.objectContaining({ id: `3`, @@ -250,8 +250,8 @@ describe(`Query Collections`, () => { }) expect(rendered.result.state.get(`4`)).toBeUndefined() - expect(rendered.result.data.length).toBe(1) - expect(rendered.result.data[0]).toMatchObject({ + expect(rendered.result().length).toBe(1) + expect(rendered.result()[0]).toMatchObject({ id: `3`, name: `John Smith`, }) @@ -286,7 +286,7 @@ describe(`Query Collections`, () => { .select(({ issues, persons }) => ({ id: issues.id, title: issues.title, - name: persons.name, + name: persons!.name, })) ) }) @@ -373,11 +373,11 @@ describe(`Query Collections`, () => { }) issueCollection.utils.commit() - await new Promise((resolve) => setTimeout(resolve, 10)) - - // After deletion, issue 3 should no longer have a joined result - expect(result.state.get(`[3,1]`)).toBeUndefined() - expect(result.state.size).toBe(3) + await waitFor(() => { + // After deletion, issue 3 should no longer have a joined result + expect(result.state.get(`[3,1]`)).toBeUndefined() + expect(result.state.size).toBe(3) + }) }) it(`should recompile query when parameters change and change results`, async () => { @@ -548,7 +548,7 @@ describe(`Query Collections`, () => { const groupedLiveQuery = renderHook(() => { return useLiveQuery((q) => q - .from({ queryResult: rendered.result.collection() }) + .from({ queryResult: rendered.result.collection }) .groupBy(({ queryResult }) => queryResult.team) .select(({ queryResult }) => ({ team: queryResult.team, @@ -651,7 +651,7 @@ describe(`Query Collections`, () => { .select(({ issues, persons }) => ({ id: issues.id, title: issues.title, - name: persons.name, + name: persons!.name, })) ) @@ -797,9 +797,9 @@ describe(`Query Collections`, () => { await waitFor(() => { expect(result.state.size).toBe(1) // Only John Smith (age 35) }) - expect(result.data).toHaveLength(1) + expect(result()).toHaveLength(1) - const johnSmith = result.data[0] + const johnSmith = result()[0] expect(johnSmith).toMatchObject({ id: `3`, name: `John Smith`, @@ -807,7 +807,7 @@ describe(`Query Collections`, () => { }) // Verify that the returned collection is the same instance - expect(result.collection()).toBe(liveQueryCollection) + expect(result.collection).toBe(liveQueryCollection) }) it(`should switch to a different pre-created live query collection when changed`, async () => { @@ -886,7 +886,7 @@ describe(`Query Collections`, () => { id: `3`, name: `John Smith`, }) - expect(rendered.result.collection()).toBe(liveQueryCollection1) + expect(rendered.result.collection).toBe(liveQueryCollection1) // Switch to the second collection setCollection(liveQueryCollection2) @@ -903,7 +903,7 @@ describe(`Query Collections`, () => { id: `5`, name: `Bob Dylan`, }) - expect(rendered.result.collection()).toBe(liveQueryCollection2) + expect(rendered.result.collection).toBe(liveQueryCollection2) // Verify we no longer have data from the first collection expect(rendered.result.state.get(`3`)).toBeUndefined() @@ -939,9 +939,9 @@ describe(`Query Collections`, () => { await waitFor(() => { expect(result.state.size).toBe(1) // Only John Smith (age 35) }) - expect(result.data).toHaveLength(1) + expect(result()).toHaveLength(1) - const johnSmith = result.data[0] + const johnSmith = result()[0] expect(johnSmith).toMatchObject({ id: `3`, name: `John Smith`, @@ -987,7 +987,7 @@ describe(`Query Collections`, () => { }) // Initially isLoading should be true - expect(rendered.result.isLoading()).toBe(true) + expect(rendered.result.isLoading).toBe(true) // Start sync manually collection.preload() @@ -1010,7 +1010,7 @@ describe(`Query Collections`, () => { // Wait for collection to become ready await waitFor(() => { - expect(rendered.result.isLoading()).toBe(false) + expect(rendered.result.isLoading).toBe(false) }) // Note: Data may not appear immediately due to live query evaluation timing // The main test is that isLoading transitions from true to false @@ -1046,7 +1046,7 @@ describe(`Query Collections`, () => { }) // For pre-created collections that are already syncing, isLoading should be true - expect(rendered.result.isLoading()).toBe(false) + expect(rendered.result.isLoading).toBe(false) expect(rendered.result.state.size).toBe(1) }) @@ -1085,7 +1085,7 @@ describe(`Query Collections`, () => { }) // Initially should be true - expect(rendered.result.isLoading()).toBe(true) + expect(rendered.result.isLoading).toBe(true) // Start sync manually collection.preload() @@ -1109,14 +1109,14 @@ describe(`Query Collections`, () => { await new Promise((resolve) => setTimeout(resolve, 100)) - expect(rendered.result.isLoading()).toBe(false) - expect(rendered.result.isReady()).toBe(true) + expect(rendered.result.isLoading).toBe(false) + expect(rendered.result.isReady).toBe(true) // Wait for collection to become ready await waitFor(() => { - expect(rendered.result.isLoading()).toBe(false) + expect(rendered.result.isLoading).toBe(false) }) - expect(rendered.result.status()).toBe(`ready`) + expect(rendered.result.status).toBe(`ready`) }) it(`should maintain isReady state during live updates`, async () => { @@ -1142,10 +1142,10 @@ describe(`Query Collections`, () => { // Wait for initial load await waitFor(() => { - expect(result.isLoading()).toBe(false) + expect(result.isLoading).toBe(false) }) - const initialIsReady = result.isReady() + const initialIsReady = result.isReady // Perform live updates collection.utils.begin() @@ -1168,8 +1168,8 @@ describe(`Query Collections`, () => { }) // isReady should remain true during live updates - expect(result.isReady()).toBe(true) - expect(result.isReady()).toBe(initialIsReady) + expect(result.isReady).toBe(true) + expect(result.isReady).toBe(initialIsReady) }) it(`should handle isLoading with complex queries including joins`, async () => { @@ -1224,13 +1224,13 @@ describe(`Query Collections`, () => { .select(({ issues, persons }) => ({ id: issues.id, title: issues.title, - name: persons.name, + name: persons!.name, })) ) }) // Initially should be true - expect(result.isLoading()).toBe(true) + expect(result.isLoading).toBe(true) // Start sync for both collections personCollection.preload() @@ -1266,7 +1266,7 @@ describe(`Query Collections`, () => { // Wait for both collections to sync await waitFor(() => { - expect(result.isReady()).toBe(true) + expect(result.isReady).toBe(true) }) // Note: Joined data may not appear immediately due to live query evaluation timing // The main test is that isLoading transitions from false to true @@ -1313,7 +1313,7 @@ describe(`Query Collections`, () => { ) // Initially should be false - expect(result.isLoading()).toBe(true) + expect(result.isLoading).toBe(true) // Start sync manually collection.preload() @@ -1344,7 +1344,7 @@ describe(`Query Collections`, () => { // Wait for initial load await waitFor(() => { - expect(result.isLoading()).toBe(false) + expect(result.isLoading).toBe(false) }) // Change parameters @@ -1352,7 +1352,7 @@ describe(`Query Collections`, () => { // isReady should remain true even when parameters change await waitFor(() => { - expect(result.isReady()).toBe(true) + expect(result.isReady).toBe(true) }) // Note: Data size may not change immediately due to live query evaluation timing // The main test is that isReady remains true when parameters change @@ -1399,9 +1399,9 @@ describe(`Query Collections`, () => { }) // Initially isLoading should be true - expect(result.isLoading()).toBe(true) + expect(result.isLoading).toBe(true) expect(result.state.size).toBe(0) - expect(result.data).toEqual([]) + expect(result()).toEqual([]) // Start sync manually collection.preload() @@ -1409,7 +1409,7 @@ describe(`Query Collections`, () => { await new Promise((resolve) => setTimeout(resolve, 10)) // Still loading - expect(result.isLoading()).toBe(true) + expect(result.isLoading).toBe(true) // Add first batch of data (but don't mark ready yet) syncBegin!() @@ -1430,9 +1430,9 @@ describe(`Query Collections`, () => { await waitFor(() => { expect(result.state.size).toBe(1) }) - expect(result.isLoading()).toBe(true) // Still loading - expect(result.data).toHaveLength(1) - expect(result.data[0]).toMatchObject({ + expect(result.isLoading).toBe(true) // Still loading + expect(result()).toHaveLength(1) + expect(result()[0]).toMatchObject({ id: `1`, name: `John Smith`, }) @@ -1456,18 +1456,18 @@ describe(`Query Collections`, () => { await waitFor(() => { expect(result.state.size).toBe(2) }) - expect(result.isLoading()).toBe(true) // Still loading - expect(result.data).toHaveLength(2) + expect(result.isLoading).toBe(true) // Still loading + expect(result()).toHaveLength(2) // Now mark as ready syncMarkReady!() // Should now be ready await waitFor(() => { - expect(result.isLoading()).toBe(false) + expect(result.isLoading).toBe(false) }) expect(result.state.size).toBe(2) - expect(result.data).toHaveLength(2) + expect(result()).toHaveLength(2) }) it(`should show filtered results during sync with isLoading true`, async () => { @@ -1511,7 +1511,7 @@ describe(`Query Collections`, () => { await new Promise((resolve) => setTimeout(resolve, 10)) - expect(result.isLoading()).toBe(true) + expect(result.isLoading).toBe(true) // Add items from different teams syncBegin!() @@ -1554,15 +1554,15 @@ describe(`Query Collections`, () => { await waitFor(() => { expect(result.state.size).toBe(2) }) - expect(result.isLoading()).toBe(true) - expect(result.data).toHaveLength(2) - expect(result.data.every((p) => p.team === `team1`)).toBe(true) + expect(result.isLoading).toBe(true) + expect(result()).toHaveLength(2) + expect(result().every((p) => p.team === `team1`)).toBe(true) // Mark ready syncMarkReady!() await waitFor(() => { - expect(result.isLoading()).toBe(false) + expect(result.isLoading).toBe(false) }) expect(result.state.size).toBe(2) }) @@ -1622,7 +1622,7 @@ describe(`Query Collections`, () => { .select(({ issues, persons }) => ({ id: issues.id, title: issues.title, - userName: persons.name, + userName: persons!.name, })) ) }) @@ -1633,7 +1633,7 @@ describe(`Query Collections`, () => { await new Promise((resolve) => setTimeout(resolve, 10)) - expect(result.isLoading()).toBe(true) + expect(result.isLoading).toBe(true) // Add a person first userSyncBegin!() @@ -1652,7 +1652,7 @@ describe(`Query Collections`, () => { await new Promise((resolve) => setTimeout(resolve, 10)) - expect(result.isLoading()).toBe(true) + expect(result.isLoading).toBe(true) expect(result.state.size).toBe(0) // No joins yet // Add an issue for that person @@ -1672,9 +1672,9 @@ describe(`Query Collections`, () => { await waitFor(() => { expect(result.state.size).toBe(1) }) - expect(result.isLoading()).toBe(true) - expect(result.data).toHaveLength(1) - expect(result.data[0]).toMatchObject({ + expect(result.isLoading).toBe(true) + expect(result()).toHaveLength(1) + expect(result()[0]).toMatchObject({ id: `1`, title: `First Issue`, userName: `John Doe`, @@ -1685,7 +1685,7 @@ describe(`Query Collections`, () => { issueSyncMarkReady!() await waitFor(() => { - expect(result.isLoading()).toBe(false) + expect(result.isLoading).toBe(false) }) expect(result.state.size).toBe(1) }) @@ -1721,10 +1721,10 @@ describe(`Query Collections`, () => { }) // Initially isLoading should be true - expect(result.isLoading()).toBe(true) - expect(result.isReady()).toBe(false) + expect(result.isLoading).toBe(true) + expect(result.isReady).toBe(false) expect(result.state.size).toBe(0) - expect(result.data).toEqual([]) + expect(result()).toEqual([]) // Start sync manually collection.preload() @@ -1732,20 +1732,20 @@ describe(`Query Collections`, () => { await new Promise((resolve) => setTimeout(resolve, 10)) // Still loading - expect(result.isLoading()).toBe(true) - expect(result.isReady()).toBe(false) + expect(result.isLoading).toBe(true) + expect(result.isReady).toBe(false) // Mark ready without any data commits syncMarkReady!() // Should now be ready, even with no data await waitFor(() => { - expect(result.isReady()).toBe(true) + expect(result.isReady).toBe(true) }) - expect(result.isLoading()).toBe(false) + expect(result.isLoading).toBe(false) expect(result.state.size).toBe(0) // Still no data - expect(result.data).toEqual([]) // Empty array - expect(result.status()).toBe(`ready`) + expect(result()).toEqual([]) // Empty array + expect(result.status).toBe(`ready`) }) }) })