Skip to content
Draft
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
55 changes: 35 additions & 20 deletions solid-v2/fullstack-tanstack/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<DehydratedState>((data) => {
hydrate(queryClient, data);
});

const router = createAppRouter(queryClient);

// Match the URL BEFORE the entry hydrates (top-level await pauses the
Expand All @@ -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 = <TRest extends Array<unknown>>(
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);
}

Expand Down
17 changes: 1 addition & 16 deletions solid-v2/fullstack-tanstack/src/lib/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,4 @@ export async function bootLoad(router: { load: () => Promise<void> }) {
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)),
);
}
}
77 changes: 36 additions & 41 deletions solid-v2/fullstack-tanstack/src/server-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is pretty clean nice

router: createAppRouter(queryClient),
event,
outcome,
collect: async () => {
const state = await dehydrateSettled(queryClient);
return state.queries.length > 0 ? state : undefined;
},
});
},
});
);