diff --git a/.changeset/solid-query-status-resource.md b/.changeset/solid-query-status-resource.md new file mode 100644 index 00000000000..12a340a386f --- /dev/null +++ b/.changeset/solid-query-status-resource.md @@ -0,0 +1,5 @@ +--- +"@tanstack/solid-query": patch +--- + +Start Solid query resources when status fields are read so curried `queryOptions` fetch on mount without requiring `data` access. diff --git a/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts b/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts index 6e09d8a86e2..f594b6e1f61 100644 --- a/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts +++ b/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts @@ -1,11 +1,11 @@ -import { QueryClient } from '@tanstack/query-core' -import { beforeEach, describe, expect, it } from 'vitest' +import { QueryClient, type QueryCache, type QueryState } from '@tanstack/query-core' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { broadcastQueryClient } from '..' -import type { QueryCache } from '@tanstack/query-core' describe('broadcastQueryClient', () => { let queryClient: QueryClient let queryCache: QueryCache + const broadcastChannel = 'test_channel' beforeEach(() => { queryClient = new QueryClient() @@ -15,7 +15,7 @@ describe('broadcastQueryClient', () => { it('should subscribe to the query cache', () => { broadcastQueryClient({ queryClient, - broadcastChannel: 'test_channel', + broadcastChannel, }) expect(queryCache.hasListeners()).toBe(true) }) @@ -23,9 +23,55 @@ describe('broadcastQueryClient', () => { it('should not have any listeners after cleanup', () => { const unsubscribe = broadcastQueryClient({ queryClient, - broadcastChannel: 'test_channel', + broadcastChannel, }) unsubscribe() expect(queryCache.hasListeners()).toBe(false) }) + + it('should sync initial query state when query is added from another tab', async () => { + const receiverClient = new QueryClient() + const senderClient = new QueryClient() + + const senderUnsubscribe = broadcastQueryClient({ + queryClient: senderClient, + broadcastChannel, + }) + const receiverUnsubscribe = broadcastQueryClient({ + queryClient: receiverClient, + broadcastChannel, + }) + + const seededData = { value: 'seeded' } + const seededState: QueryState = { + data: seededData, + dataUpdateCount: 1, + dataUpdatedAt: Date.now(), + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + isInvalidated: false, + status: 'success', + fetchStatus: 'idle', + } + + senderClient.getQueryCache().build( + senderClient, + { queryKey: ['seeded-query'] }, + seededState, + ) + + const findState = () => + receiverClient.getQueryCache().find({ queryKey: ['seeded-query'] })?.state + + await vi.waitFor(() => { + expect(findState()).toMatchObject(seededState) + }) + + senderUnsubscribe() + receiverUnsubscribe() + }) }) diff --git a/packages/query-broadcast-client-experimental/src/index.ts b/packages/query-broadcast-client-experimental/src/index.ts index e102b3c0b01..4d915654873 100644 --- a/packages/query-broadcast-client-experimental/src/index.ts +++ b/packages/query-broadcast-client-experimental/src/index.ts @@ -58,6 +58,7 @@ export function broadcastQueryClient({ type: 'added', queryHash, queryKey, + state, }) } }) @@ -92,6 +93,9 @@ export function broadcastQueryClient({ } } else if (type === 'added') { if (query) { + if (!state) { + return + } query.setState(state) return } diff --git a/packages/solid-query/src/__tests__/useQuery.test.tsx b/packages/solid-query/src/__tests__/useQuery.test.tsx index 745a9ab1cc2..44b0346a403 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -30,6 +30,7 @@ import { QueryClient, QueryClientProvider, keepPreviousData, + queryOptions, useQuery, } from '..' import { Blink, mockOnlineManagerIsOnline, setActTimeout } from './utils' @@ -244,6 +245,71 @@ describe('useQuery', () => { expect(rendered.getByText('test')).toBeInTheDocument() }) + it('should fetch when a curried queryOptions result only reads status fields', async () => { + const key = queryKey() + const queryFn = vi.fn((slug: string) => `test-${slug}`) + const fetchQueryOptions = (slug: string) => + queryOptions({ + queryKey: [key, slug], + queryFn: () => queryFn(slug), + }) + + function Page() { + const options = fetchQueryOptions('slug') + const state = useQuery(() => options) + + return ( + + pending + error + success + + ) + } + + const rendered = render(() => ( + + + + )) + + expect(rendered.getByText('pending')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(1) + expect(rendered.getByText('success')).toBeInTheDocument() + }) + + it('should fetch when a curried queryOptions result only reads resource fields', async () => { + const key = queryKey() + const queryFn = vi.fn((slug: string) => `test-${slug}`) + const fetchQueryOptions = (slug: string) => + queryOptions({ + queryKey: [key, slug], + queryFn: () => queryFn(slug), + }) + + function Page() { + const options = fetchQueryOptions('slug') + const state = useQuery(() => options) + + void state.error + void state.failureReason + void state.refetch + void state.promise + + return
mounted
+ } + + const rendered = render(() => ( + + + + )) + + expect(rendered.getByText('mounted')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(1) + }) it('should return the correct states for a successful query', async () => { const key = queryKey() const states: Array> = [] diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index 773d0719e0c..97b665631e0 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -99,6 +99,33 @@ const hydratableObserverResult = < return obj } +const resourceTrackingProps = new Set([ + 'dataUpdatedAt', + 'error', + 'errorUpdatedAt', + 'failureCount', + 'failureReason', + 'errorUpdateCount', + 'isError', + 'isFetched', + 'isFetchedAfterMount', + 'isFetching', + 'isLoading', + 'isPending', + 'isLoadingError', + 'isInitialLoading', + 'isPaused', + 'isPlaceholderData', + 'isRefetchError', + 'isRefetching', + 'isStale', + 'isSuccess', + 'isEnabled', + 'refetch', + 'promise', + 'status', + 'fetchStatus', +]) // Base Query Function that is used to create the query. export function useBaseQuery< TQueryFnData, @@ -381,6 +408,11 @@ export function useBaseQuery< } return queryResource()?.data } + if (resourceTrackingProps.has(prop)) { + // Solid resources are lazy, so status-only consumers still need to read + // the resource once to start the observer subscription and fetch. + queryResource() + } return Reflect.get(target, prop) }, } diff --git a/packages/svelte-query/src/containers.svelte.ts b/packages/svelte-query/src/containers.svelte.ts index 60d27c68431..82ff9287020 100644 --- a/packages/svelte-query/src/containers.svelte.ts +++ b/packages/svelte-query/src/containers.svelte.ts @@ -31,6 +31,29 @@ export function createRawRef>( ): [T, (newValue: T) => void] { const refObj = (Array.isArray(init) ? [] : {}) as T const hiddenKeys = new SvelteSet() + const isArrayIndex = (value: PropertyKey): value is `${number}` => { + if (typeof value !== 'string' || value === 'length') { + return false + } + + const index = Number(value) + return Number.isInteger(index) && index >= 0 && String(index) === value + } + + const syncLengthFromVisibleIndexes = (target: Array) => { + const visibleIndexes = Object.keys(target).filter( + (key) => isArrayIndex(key) && !hiddenKeys.has(key), + ) + + if (visibleIndexes.length === 0) { + target.length = 0 + return + } + + const maxIndex = visibleIndexes.reduce((max, key) => Math.max(max, Number(key)), -1) + target.length = maxIndex + 1 + } + const out = new Proxy(refObj, { set(target, prop, value, receiver) { hiddenKeys.delete(prop) @@ -51,6 +74,12 @@ export function createRawRef>( state = v }, }) + + if (Array.isArray(target) && isArrayIndex(prop)) { + const parsed = Number(prop) + target.length = Math.max(target.length, parsed + 1) + } + return true }, has: (target, prop) => { @@ -75,8 +104,8 @@ export function createRawRef>( // If we just deleted it, the reactivity system wouldn't have any idea that the value was gone. target[prop] = undefined hiddenKeys.add(prop) - if (Array.isArray(target)) { - target.length-- + if (Array.isArray(target) && isArrayIndex(prop)) { + syncLengthFromVisibleIndexes(target) } return true } diff --git a/packages/svelte-query/tests/containers.svelte.test.ts b/packages/svelte-query/tests/containers.svelte.test.ts index 3511dbb5b5d..222150cae3d 100644 --- a/packages/svelte-query/tests/containers.svelte.test.ts +++ b/packages/svelte-query/tests/containers.svelte.test.ts @@ -198,6 +198,51 @@ describe('createRawRef', () => { expect(ref).toEqual([7, 8, 9]) }) + it('should update array length when replacing with longer arrays', () => { + const [ref, update] = createRawRef([]) + + expect(ref.length).toBe(0) + + update([1]) + expect(ref).toEqual([1]) + expect(ref.length).toBe(1) + expect(ref[0]).toBe(1) + + update([2, 3]) + expect(ref).toEqual([2, 3]) + expect(ref.length).toBe(2) + }) + + it('should preserve array length when destructuring', () => { + const [ref, update] = createRawRef([]) + + update([1]) + + const [first] = ref + + expect(first).toBe(1) + }) + + it('should preserve array length when multiple trailing elements are removed', () => { + const [ref, update] = createRawRef([1, 2, 3, 4]) + + update([1]) + + expect(ref).toEqual([1]) + expect(ref.length).toBe(1) + }) + + it('should ignore non-canonical array index keys', () => { + const [ref] = createRawRef([]) + + // @ts-expect-error + ref['1e3'] = 1000 + + expect(ref.length).toBe(0) + // @ts-expect-error + expect(ref['1e3']).toBe(1000) + }) + it('should behave like a regular object when not using `update`', () => { const [ref] = createRawRef>({ a: 1, b: 2 }) diff --git a/packages/svelte-query/tests/createQueries/createQueries.svelte.test.ts b/packages/svelte-query/tests/createQueries/createQueries.svelte.test.ts index 26af47d1c87..5ccd4b3eea9 100644 --- a/packages/svelte-query/tests/createQueries/createQueries.svelte.test.ts +++ b/packages/svelte-query/tests/createQueries/createQueries.svelte.test.ts @@ -341,4 +341,38 @@ describe('createQueries', () => { expect(queryFn1).toHaveBeenCalledTimes(0) expect(queryFn2).toHaveBeenCalledTimes(0) }) + + it( + 'should activate query when started from an empty array', + withEffectRoot(async () => { + const key = queryKey() + const queryFn = () => sleep(10).then(() => 'ok') + let isActive = $state(false) + + const result = createQueries( + () => ({ + queries: isActive + ? [ + { + queryKey: key, + queryFn, + }, + ] + : [], + }), + () => queryClient, + ) + + expect(result.length).toBe(0) + isActive = true + + await vi.advanceTimersByTimeAsync(0) + + expect(result.length).toBe(1) + expect(result[0]).toMatchObject({ data: undefined }) + + await vi.advanceTimersByTimeAsync(10) + expect(result[0].data).toBe('ok') + }), + ) })