diff --git a/solid-v2/fullstack-tanstack/src/App.tsx b/solid-v2/fullstack-tanstack/src/App.tsx index 1b29aafa..cebdf32c 100644 --- a/solid-v2/fullstack-tanstack/src/App.tsx +++ b/solid-v2/fullstack-tanstack/src/App.tsx @@ -1,31 +1,20 @@ -import type { DehydratedState } from '@tanstack/solid-query'; -import { QueryClientProvider, hydrate } from '@tanstack/solid-query'; -import { RouterProvider } from '@tanstack/solid-router'; -import { subscribeFlightData } from '@solidjs/web/server-functions'; +import { QueryClientProvider } from '@tanstack/solid-query'; +import { isRedirect, RouterProvider } from '@tanstack/solid-router'; import { bootLoad, createQueryClient } from './lib/queries'; import { createAppRouter } from './router'; import './App.css'; // The client's Query cache: one instance for the session. Everything the -// server hands back — the SSR hydration entries QueryClientProvider consumes -// below, single-flight payloads after mutations — lands here, and useQuery -// reads throughout the app follow. (No hand-rolled SSR handoff: the provider -// owns the hydration channel, priming this cache from the server's streamed -// dehydrated entries as they arrive.) +// server hands back lands here and useQuery reads throughout the app +// follow — with no hand-rolled handoffs on this side: the provider owns +// both channels, priming this cache from the server's streamed hydration +// entries AND hydrating single-flight payloads after mutations (the +// dehydrated QueryClient src/server-config.ts folds into each mutation +// response, applied before `mutate` settles so no follow-up refetch +// happens). const queryClient = createQueryClient(); -// The client half of single-flight — and the opt-in: while a consumer is -// subscribed, every mutation call asks the server to fold refreshed data -// into its own response. That payload is a dehydrated QueryClient (see -// src/server-config.ts), so consuming it is TanStack's own `hydrate`: the -// entries land in the cache, every useQuery on those keys updates, and no -// follow-up refetch happens. The consumer runs before the mutation's -// promise resolves, so by the time `mutate` settles the UI is current. -subscribeFlightData((data) => { - hydrate(queryClient, data); -}); - const router = createAppRouter(queryClient); // Match the URL BEFORE the entry hydrates (top-level await pauses the @@ -37,6 +26,32 @@ const router = createAppRouter(queryClient); // hydrates, moments later), so prefetching would refetch everything the // server just rendered. if (typeof window !== 'undefined') { + // Redirects thrown where the router is driving — beforeLoad, loaders, + // and any queryFn a loader awaits — are the router's own to handle: it + // navigates on the client, and the server answers a real 30x + // (src/setup.tsx). But cache-driven fetches run outside the router: a + // background refetch or a mutation throwing redirect() (a session + // expiring, say) would just settle into cache error state with nobody + // navigating. This is that last stretch of glue — hand redirect errors + // from both caches to the router. Runtime navigation, so it belongs + // here in the client boot, not anywhere near SSR. + const navigateOnRedirect = >( + onError?: (error: Error, ...rest: TRest) => void, + ) => { + return (error: Error, ...rest: TRest) => { + if (isRedirect(error)) { + error.options._fromLocation = router.stores.location.get(); + void router.navigate(router.resolveRedirect(error).options); + return; + } + onError?.(error, ...rest); + }; + }; + const queryCache = queryClient.getQueryCache(); + const mutationCache = queryClient.getMutationCache(); + queryCache.config.onError = navigateOnRedirect(queryCache.config.onError); + mutationCache.config.onError = navigateOnRedirect(mutationCache.config.onError); + await bootLoad(router); } diff --git a/solid-v2/fullstack-tanstack/src/lib/queries.ts b/solid-v2/fullstack-tanstack/src/lib/queries.ts index 52b4c145..c3bf9245 100644 --- a/solid-v2/fullstack-tanstack/src/lib/queries.ts +++ b/solid-v2/fullstack-tanstack/src/lib/queries.ts @@ -79,19 +79,4 @@ export async function bootLoad(router: { load: () => Promise }) { window.location.reload(); await new Promise(() => {}); } -} - -// Awaits every in-flight fetch in the cache — the single-flight collector's -// settling point (src/server-config.ts) after `router.load()`, whose loaders -// only *start* prefetches (they don't block navigation). SSR no longer needs -// it: the render suspends per query and QueryClientProvider's channel streams -// entries as they settle. `query.promise` is query-core's public handle on -// the pending fetch; errors surface through the query state, not here. -export async function settled(queryClient: QueryClient) { - await Promise.all( - queryClient - .getQueryCache() - .getAll() - .map((query) => query.promise?.catch(() => undefined)), - ); -} +} \ No newline at end of file diff --git a/solid-v2/fullstack-tanstack/src/server-config.ts b/solid-v2/fullstack-tanstack/src/server-config.ts index 7c24872d..75946c79 100644 --- a/solid-v2/fullstack-tanstack/src/server-config.ts +++ b/solid-v2/fullstack-tanstack/src/server-config.ts @@ -2,53 +2,48 @@ // via `serverFunctions.configure` in vite.config.ts — it loads before any // dispatch, on the dev middleware and the production handler alike). // -// This is the server half of single-flight on a router Solid doesn't own. -// The hook's contract is router-agnostic: core hands it the pre-digested -// mutation outcome (the URL the client will show, the mutation's folded -// cookie headers) and takes back an opaque payload to fold into the SAME -// response. The strategy here is pure TanStack: build a router + Query -// cache for the target URL, run its loaders, and ship `dehydrate(cache)` — -// the payload IS a dehydrated QueryClient, which the client half -// (src/App.tsx) consumes with TanStack's own `hydrate`. -import { configureServerFunctionsServer } from '@solidjs/web/server-functions/server'; -import { provideRequestEvent } from '@solidjs/web/storage'; -import { dehydrate } from '@tanstack/solid-query'; -import { createMemoryHistory } from '@tanstack/solid-router'; +// This is the server half of single-flight on a router Solid doesn't own, +// composed from each side's own primitive along its natural boundary: +// +// - `loadFlightTarget` (the router's) owns the trigger — it derives the +// flight request for the URL the client will show after the mutation +// (the mutation's cookie effects already folded in, so a session just +// written or cleared is what the loaders see), points the router at it, +// and runs the matched routes' data functions. +// - `dehydrateSettled` (the query cache's) owns the extraction — loaders +// only *start* their prefetches, so it waits for every in-flight fetch +// to land, then dehydrates. +// +// The collector registers under the query cache's source id +// (FLIGHT_DATA_SOURCE, "sq") on Solid's multi-source single-flight channel, +// and the returned slice folds into the SAME response as any other cache's. +// The payload IS a dehydrated QueryClient — QueryClientProvider's built-in +// consumer hydrates it on the client, no app wiring on that side. +import { registerFlightDataSource } from '@solidjs/web/server-functions/server'; +import { FLIGHT_DATA_SOURCE, dehydrateSettled } from '@tanstack/solid-query'; +import { loadFlightTarget } from '@tanstack/solid-router/ssr/server'; -import { createQueryClient, settled } from './lib/queries'; +import { createQueryClient } from './lib/queries'; import { createAppRouter } from './router'; -configureServerFunctionsServer({ - async collectFlightData(sourceEvent, outcome) { +registerFlightDataSource( + FLIGHT_DATA_SOURCE, + function collectFlightData(event, outcome) { // No target (a non-browser caller, or a redirect leaving the app) means // nothing to produce data for. if (!outcome.targetUrl) return undefined; - const url = new URL(outcome.targetUrl); - - // The flight event: the source event pointed at the target URL, with - // the mutation's cookie effects already folded in — so a session the - // mutation just wrote (login) or cleared (logout) is what the reads - // below see, exactly as the browser's next request would. - const event = { - ...sourceEvent, - locals: { ...sourceEvent.locals }, - request: new Request(outcome.targetUrl, { headers: outcome.foldedHeaders }), - }; - - return provideRequestEvent(event, async () => { - // A fresh cache, the target URL's matched loaders prefetching into - // it, then the settle — the same sequence SSR runs in src/setup.tsx, - // minus the render. - const queryClient = createQueryClient(); - const router = createAppRouter( - queryClient, - createMemoryHistory({ initialEntries: [url.pathname + url.search] }), - ); - await router.load(); - await settled(queryClient); - const state = dehydrate(queryClient); - return state.queries.length > 0 ? state : undefined; + // A fresh cache and router per collection — the same pairing SSR builds + // per request in src/setup.tsx, minus the render. + const queryClient = createQueryClient(); + return loadFlightTarget({ + router: createAppRouter(queryClient), + event, + outcome, + collect: async () => { + const state = await dehydrateSettled(queryClient); + return state.queries.length > 0 ? state : undefined; + }, }); }, -}); +);