Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/solid/solid-start-streaming/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"start": "solid-start start"
},
"type": "module",
"resolutions": {
"solid-js": "^1.7.7"
},
"dependencies": {
"@solidjs/meta": "^0.28.2",
"@solidjs/router": "^0.7.0",
Expand Down
22 changes: 16 additions & 6 deletions examples/solid/solid-start-streaming/src/components/user-info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@ export interface UserInfoProps {
simulateError?: boolean
}

export const userInfoQueryOpts = (props?: UserInfoProps) => ({
queryKey: ['user'],
queryFn: () => fetchUser(props),
deferStream: props?.deferStream,
})

export const UserInfo: Component<UserInfoProps> = (props) => {
const [simulateError, setSimulateError] = createSignal(props.simulateError)

const query = createQuery(() => ({
queryKey: ['user'],
queryFn: () =>
fetchUser({ sleep: props.sleep, simulateError: simulateError() }),
deferStream: props.deferStream,
}))
const query = createQuery(() =>
userInfoQueryOpts({ ...props, simulateError: simulateError() }),
)

return (
<Example
Expand Down Expand Up @@ -49,6 +52,13 @@ export const UserInfo: Component<UserInfoProps> = (props) => {
<div>id: {user.id}</div>
<div>name: {user.name}</div>
<div>queryTime: {user.queryTime}</div>
<button
onClick={() => {
query.refetch()
}}
>
refetch
</button>
</>
)}
</QueryBoundary>
Expand Down
4 changes: 3 additions & 1 deletion examples/solid/solid-start-streaming/src/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default function Root() {
defaultOptions: {
queries: {
retry: false,
staleTime: 5000,
},
},
})
Expand All @@ -38,12 +39,13 @@ export default function Root() {
<Suspense
fallback={<div>loading... [root.tsx suspense boundary]</div>}
>
<A href="/">Index</A>
<A href="/">Home</A>
<A href="/streamed">Streamed</A>
<A href="/deferred">Deferred</A>
<A href="/mixed">Mixed</A>
<A href="/with-error">With Error</A>
<A href="/hydration">Hydration</A>
<A href="/prefetch">Prefetch</A>

<Routes>
<FileRoutes />
Expand Down
36 changes: 36 additions & 0 deletions examples/solid/solid-start-streaming/src/routes/prefetch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useQueryClient } from '@tanstack/solid-query'
import { isServer } from 'solid-js/web'
import { Title } from 'solid-start'
import { UserInfo, userInfoQueryOpts } from '~/components/user-info'

export default function Prefetch() {
const queryClient = useQueryClient()

if (isServer) {
void queryClient.prefetchQuery(userInfoQueryOpts({ sleep: 500 }))
}

return (
<main>
<Title>Solid Query - Prefetch</Title>

<h1>Solid Query - Prefetch Example</h1>

<div class="description">
<p>
In some cases you may want to prefetch a query on the server before
the component with the relevant `createQuery` call is mounted. A major
use case for this is in router data loaders, in order to avoid request
waterfalls.
</p>
<p>
In this example we prefetch the user query (on the server only). There
should be no extra `fetchUser.start` and `fetchUser.done` logs in the
console on the client when refreshing the page.
</p>
</div>

<UserInfo sleep={500} deferStream />
</main>
)
}
2 changes: 1 addition & 1 deletion packages/solid-query/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"test:eslint": "eslint --ext .ts,.tsx ./src",
"test:types": "tsc",
"test:lib": "vitest run --coverage",
"test:lib:dev": "pnpm run test:lib --watch",
"test:lib:dev": "vitest watch --coverage",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vitest --watch is a thing, vitest run --watch isn't - they have a dedicated watch command instead.

"test:build": "publint --strict",
"build": "tsup"
},
Expand Down
85 changes: 57 additions & 28 deletions packages/solid-query/src/createBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import type { CreateBaseQueryOptions } from './types'
import type { Accessor } from 'solid-js'
import type { QueryClient } from './QueryClient'
import type {
Query,
QueryKey,
QueryObserver,
QueryObserverResult,
QueryState,
} from '@tanstack/query-core'

function reconcileFn<TData, TError>(
Expand All @@ -40,6 +42,49 @@ function reconcileFn<TData, TError>(
return { ...result, data: newData } as typeof result
}

type HydrateableQueryState<TData, TError> = QueryObserverResult<TData, TError> &
QueryState<TData, TError>

/**
* Solid's `onHydrated` functionality will silently "fail" (hydrate with an empty object)
* if the resource data is not serializable.
*/
const hydrateableObserverResult = <
TQueryFnData,
TError,
TData,
TQueryKey extends QueryKey,
T2,
>(
query: Query<TQueryFnData, TError, TData, TQueryKey>,
result: QueryObserverResult<T2, TError>,
): HydrateableQueryState<T2, TError> => {
// Including the extra properties is only relevant on the server
if (!isServer) return result as HydrateableQueryState<T2, TError>

return {
...unwrap(result),

// cast to refetch function should be safe, since we only remove it on the server,
// and refetch is not relevant on the server
refetch: undefined as unknown as HydrateableQueryState<
T2,
TError
>['refetch'],

// hydrate() expects a QueryState object, which is similar but not
// quite the same as a QueryObserverResult object. Thus, for now, we're
// copying over the missing properties from state in order to support hydration
dataUpdateCount: query.state.dataUpdateCount,
fetchFailureCount: query.state.fetchFailureCount,
isInvalidated: query.state.isInvalidated,

// Unsetting these properties on the server since they might not be serializable
fetchFailureReason: null,
fetchMeta: null,
}
}

// Base Query Function that is used to create the query.
export function createBaseQuery<
TQueryFnData,
Expand All @@ -54,6 +99,10 @@ export function createBaseQuery<
Observer: typeof QueryObserver,
queryClient?: Accessor<QueryClient>,
) {
type ResourceData =
| HydrateableQueryState<TData, TError>
| QueryObserverResult<TData, TError>

const client = createMemo(() => useQueryClient(queryClient?.()))

const defaultedOptions = client().defaultQueryOptions(options())
Expand All @@ -71,41 +120,19 @@ export function createBaseQuery<

const createServerSubscriber = (
resolve: (
data:
| QueryObserverResult<TData, TError>
| PromiseLike<QueryObserverResult<TData, TError> | undefined>
| undefined,
data: ResourceData | PromiseLike<ResourceData | undefined> | undefined,
) => void,
reject: (reason?: any) => void,
) => {
return observer.subscribe((result) => {
notifyManager.batchCalls(() => {
const query = observer.getCurrentQuery()
const { refetch, ...rest } = unwrap(result)
const unwrappedResult = {
...rest,

// hydrate() expects a QueryState object, which is similar but not
// quite the same as a QueryObserverResult object. Thus, for now, we're
// copying over the missing properties from state in order to support hydration
dataUpdateCount: query.state.dataUpdateCount,
fetchFailureCount: query.state.fetchFailureCount,
// Removing these properties since they might not be serializable
// fetchFailureReason: query.state.fetchFailureReason,
// fetchMeta: query.state.fetchMeta,
isInvalidated: query.state.isInvalidated,
}
const unwrappedResult = hydrateableObserverResult(query, result)

if (unwrappedResult.isError) {
if (process.env['NODE_ENV'] === 'development') {
console.error(unwrappedResult.error)
}
Comment on lines -100 to -102
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wasn't sure if this log is supposed to be here or an accidental leftover - lmk if it should stay and I can add it back.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We added a log in there because of this discussion https://discord.com/channels/719702312431386674/1072140965910949929

Please let me know if you have a better way to resolve/fix this 😅

reject(unwrappedResult.error)
}
if (unwrappedResult.isSuccess) {
// Use of any here is fine
// We cannot include refetch since it is not serializable
resolve(unwrappedResult as any)
} else {
resolve(unwrappedResult)
Comment on lines -105 to +135
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO we should always fall back to calling resolve(). I ran into an issue where neither reject nor resolve were called, resulting in a hung promise (and this was on the server, resulting in the entire page being hung). LMK if there's a reason to sometimes not resolve the promise.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we dont call reject here. How do we throw an error to the ErrorBoundary on the server?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well it's still calling reject like it was before. There was just an additional check where if we decided not to reject, we would only call resolve under certain conditions. Thus there was a scenario where neither reject NOR resolve would get called, leaving a hanging promise. Does that make sense?

}
})()
})
Expand Down Expand Up @@ -148,7 +175,7 @@ export function createBaseQuery<
let unsubscribe: (() => void) | null = null

const [queryResource, { refetch, mutate }] = createResource<
QueryObserverResult<TData, TError> | undefined
ResourceData | undefined
>(
() => {
return new Promise((resolve, reject) => {
Expand All @@ -159,8 +186,10 @@ export function createBaseQuery<
unsubscribe = createClientSubscriber()
}
}

if (!state.isLoading) {
resolve(state)
const query = observer.getCurrentQuery()
resolve(hydrateableObserverResult(query, state))
Comment on lines -163 to +192
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is the code path that wasn't covered - this is triggered if the data is already pre-loaded prior to createQuery. We need to "sanitize" the state on the server before resolving, so that it can be correctly hydrated back to the client.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome! This is looking great! Thanks for working on this :D

}
})
},
Expand Down