diff --git a/.changeset/named-flight-source.md b/.changeset/named-flight-source.md new file mode 100644 index 0000000000..5e891c3e42 --- /dev/null +++ b/.changeset/named-flight-source.md @@ -0,0 +1,5 @@ +--- +'@tanstack/solid-start': patch +--- + +Adopt Solid's multi-source single-flight protocol: the router's flight data (loader/match state, dehydrated data) now registers under its own source id (`tsr`) when the installed `@solidjs/web` supports named sources, so other caches' slices — e.g. solid-query's `sq` — coexist on the same mutation response instead of competing for the single unnamed consumer slot, and a user-supplied `collectFlightData` hook adds data alongside the router's rather than displacing it. Both halves feature-detect the same installed package and fall back to the previous unnamed-slot behavior on `@solidjs/web` 2.0.0-rc.4 and older. diff --git a/packages/solid-router/src/ssr/loadFlightTarget.ts b/packages/solid-router/src/ssr/loadFlightTarget.ts new file mode 100644 index 0000000000..27c575fd1a --- /dev/null +++ b/packages/solid-router/src/ssr/loadFlightTarget.ts @@ -0,0 +1,97 @@ +import { provideRequestEvent } from '@solidjs/web/storage' +import { createMemoryHistory } from '@tanstack/history' +import type { + ServerFunctionEvent, + ServerFunctionOutcome, +} from '@solidjs/web/server-functions/server' +import type { AnyRouter } from '@tanstack/router-core' + +export interface LoadFlightTargetOptions { + router: TRouter + /** The mutation's server-function event (the collector's first argument). */ + event: ServerFunctionEvent + /** The pre-digested mutation outcome (the collector's second argument). */ + outcome: ServerFunctionOutcome + /** + * Overrides the target the router loads, for frameworks whose redirects + * are serialized before the collector sees them (the pre-digested + * `outcome.targetUrl` only understands raw `Location` headers). Defaults + * to `outcome.targetUrl`. + */ + href?: string + /** + * Runs inside the flight request-event scope once the target's route + * data functions have settled — extract whatever the caller's cache + * needs (dehydrate a query client, snapshot match state). Returning + * undefined omits the slice from the response. + */ + collect: (router: TRouter) => T | Promise +} + +/** + * The router's half of single-flight collection: runs the matched routes' + * data functions for the URL the client will show after a mutation, then + * hands the loaded router to `collect`. This is the cache-agnostic + * trigger — the router knows how to load route data for a target, and + * deliberately not what that data was written into. Any cache builds a + * flight-data hook from it by composing its own extraction: + * + * ```ts + * registerFlightDataSource('sq', (event, outcome) => + * loadFlightTarget({ + * router: createAppRouter((queryClient = createQueryClient())), + * event, + * outcome, + * collect: () => dehydrate(queryClient), + * }), + * ) + * ``` + * + * The load observes post-mutation state: the flight event's request + * targets `outcome.targetUrl` with the mutation's cookie effects already + * folded in (a session the mutation just wrote or cleared is what the + * data functions see, exactly as the browser's next request would), and + * the router is pointed at the target through a fresh memory history. + * Resolves undefined when there is no target (a non-browser caller, or a + * redirect leaving the app). + */ +export async function loadFlightTarget( + options: LoadFlightTargetOptions, +): Promise { + const href = options.href ?? options.outcome.targetUrl + if (!href) { + return undefined + } + + const url = new URL(href) + const request = new Request(url, { + headers: options.outcome.foldedHeaders, + signal: options.outcome.request.signal, + }) + const flightEvent: ServerFunctionEvent = { + ...options.event, + locals: { ...options.event.locals }, + request, + } + + options.router.update({ + history: createMemoryHistory({ + initialEntries: [url.pathname + url.search + url.hash], + }), + origin: url.origin, + }) + + return await provideRequestEvent(flightEvent, async () => { + // Contained, matching Solid Router's own collector: flight data is an + // optimization, so a failure here must never surface as a mutation + // error — the slice is simply omitted and the client cache + // revalidates the normal way. + try { + await options.router.load({ _signal: request.signal }) + return await options.collect(options.router) + } catch (error) { + console.error('Error collecting flight data for the mutation target', error) + return undefined + } + }) +} diff --git a/packages/solid-router/src/ssr/server.ts b/packages/solid-router/src/ssr/server.ts index b4e84970c4..4fd329774b 100644 --- a/packages/solid-router/src/ssr/server.ts +++ b/packages/solid-router/src/ssr/server.ts @@ -3,4 +3,6 @@ export { defaultRenderHandler } from './defaultRenderHandler' export { defaultStreamHandler } from './defaultStreamHandler' export { renderRouterToStream } from './renderRouterToStream' export { renderRouterToString } from './renderRouterToString' +export { loadFlightTarget } from './loadFlightTarget' +export type { LoadFlightTargetOptions } from './loadFlightTarget' export * from '@tanstack/router-core/ssr/server' diff --git a/packages/solid-start/src/server-functions-handler.ts b/packages/solid-start/src/server-functions-handler.ts index 60a648aee9..502d173b35 100644 --- a/packages/solid-start/src/server-functions-handler.ts +++ b/packages/solid-start/src/server-functions-handler.ts @@ -1,3 +1,4 @@ +import * as solidServerFunctions from '@solidjs/web/server-functions/server' import { configureServerFunctionsServer, createNoJSHandler, @@ -12,8 +13,12 @@ import { runWithStartContext, } from '@tanstack/start-storage-context' import { getSolidStartServerFunctionCodec } from './solid-rpc-codec' +import { SOLID_START_FLIGHT_SOURCE } from './solid-rpc-flight' import { collectSolidStartFlightData } from './solid-rpc-flight-server' -import type { HandleServerFunctionOptions } from '@solidjs/web/server-functions/server' +import type { + CollectFlightDataHook, + HandleServerFunctionOptions, +} from '@solidjs/web/server-functions/server' import type { StartStorageContext } from '@tanstack/start-storage-context' import { getServerFnById } from '#tanstack-start-server-fn-resolver' @@ -22,6 +27,31 @@ configureServerFunctionsServer({ endpoint: process.env.TSS_SERVER_FN_BASE, }) +// Named flight sources (Solid's multi-source single-flight protocol) ship +// in the @solidjs/web release after 2.0.0-rc.4. When present, the router's +// collector registers additively under its own source id — other caches' +// collectors (e.g. solid-query's "sq") fold their slices into the same +// mutation response, and a user-supplied `collectFlightData` hook keeps +// the unnamed slot to itself instead of displacing the router's data. On +// older versions the collector falls back to claiming the unnamed slot +// per-handler, exactly as before; the client half detects the same +// installed package, so the two halves cannot disagree. +const registerFlightDataSource = ( + solidServerFunctions as { + registerFlightDataSource?: ( + source: string, + hook: CollectFlightDataHook, + ) => () => void + } +).registerFlightDataSource +const hasNamedFlightSources = registerFlightDataSource !== undefined +if (registerFlightDataSource) { + registerFlightDataSource( + SOLID_START_FLIGHT_SOURCE, + collectSolidStartFlightData, + ) +} + const solidNoJSHandler = createNoJSHandler() export interface HandleSolidServerFunctionRequestOptions extends HandleServerFunctionOptions { @@ -48,7 +78,13 @@ export async function handleSolidServerFunctionRequest( const existingStartContext = getStartContext({ throwIfNotFound: false }) const handlerOptions: HandleServerFunctionOptions = { ...solidOptions, - collectFlightData: collectFlightData ?? collectSolidStartFlightData, + // With named sources the router's collector is registered globally + // under SOLID_START_FLIGHT_SOURCE and the unnamed slot stays the + // user's; without them, the legacy behavior: the router's collector + // claims the unnamed slot unless the user overrides it. + collectFlightData: hasNamedFlightSources + ? collectFlightData + : (collectFlightData ?? collectSolidStartFlightData), transformResult: async (event, result, context) => { const transformed = transformResult ? await transformResult(event, result, context) diff --git a/packages/solid-start/src/solid-rpc-flight-client.ts b/packages/solid-start/src/solid-rpc-flight-client.ts index c96a983c2c..98013ed1bd 100644 --- a/packages/solid-start/src/solid-rpc-flight-client.ts +++ b/packages/solid-start/src/solid-rpc-flight-client.ts @@ -1,11 +1,26 @@ +import * as solidServerFunctions from '@solidjs/web/server-functions/client' import { subscribeFlightData } from '@solidjs/web/server-functions/client' import type { AnyRouteMatch, AnyRouter } from '@tanstack/solid-router' import { getRouterInstance } from '@tanstack/start-client-core' +import { SOLID_START_FLIGHT_SOURCE } from './solid-rpc-flight' import type { SolidStartFlightData, SolidStartFlightMatch, } from './solid-rpc-flight' +// Named flight sources (Solid's multi-source single-flight protocol) ship +// in the @solidjs/web release after 2.0.0-rc.4 — detected by an export the +// protocol introduced. When present, the router subscribes under its own +// source id and receives exactly its slice of the keyed envelope, so other +// caches' consumers (e.g. solid-query's "sq") coexist on the same mutation +// response. On older versions this falls back to the unnamed legacy slot, +// matching the server half's detection of the same installed package. +const hasNamedFlightSources = 'getFlightDataSourceIds' in solidServerFunctions +const subscribeNamedFlightData = subscribeFlightData as unknown as ( + source: string, + consumer: (data: D, context: { response: Response }) => void | Promise, +) => () => void + let subscribed = false export function subscribeSolidStartFlightData() { @@ -14,7 +29,15 @@ export function subscribeSolidStartFlightData() { } subscribed = true - subscribeFlightData(async (data) => { + const subscribe = hasNamedFlightSources + ? (consumer: (data: SolidStartFlightData) => Promise) => + subscribeNamedFlightData( + SOLID_START_FLIGHT_SOURCE, + consumer, + ) + : subscribeFlightData + + subscribe(async (data) => { if (!isSolidStartFlightData(data)) { return } diff --git a/packages/solid-start/src/solid-rpc-flight-server.ts b/packages/solid-start/src/solid-rpc-flight-server.ts index e15f9e2403..02e2612889 100644 --- a/packages/solid-start/src/solid-rpc-flight-server.ts +++ b/packages/solid-start/src/solid-rpc-flight-server.ts @@ -1,6 +1,7 @@ -import { provideRequestEvent } from '@solidjs/web/storage' -import { createMemoryHistory } from '@tanstack/solid-router' -import { attachRouterServerSsrUtils } from '@tanstack/solid-router/ssr/server' +import { + attachRouterServerSsrUtils, + loadFlightTarget, +} from '@tanstack/solid-router/ssr/server' import { getStartContext, runWithStartContext, @@ -10,7 +11,6 @@ import type { CollectFlightDataHook, ServerFunctionOutcome, } from '@solidjs/web/server-functions/server' -import type { ServerFunctionEvent } from '@solidjs/web/server-functions/server' export const collectSolidStartFlightData: CollectFlightDataHook = async ( event, @@ -26,60 +26,55 @@ export const collectSolidStartFlightData: CollectFlightDataHook = async ( return undefined } - const targetUrl = new URL(href) - const request = new Request(targetUrl, { + const router = await startContext.getRouter() + // Mirrors the flight request loadFlightTarget derives, so Start handlers + // reading the start context observe the same target the router loads. + const request = new Request(href, { headers: outcome.foldedHeaders, signal: outcome.request.signal, }) - const flightEvent: ServerFunctionEvent = { - ...event, - request, - } - const router = await startContext.getRouter() - router.update({ - history: createMemoryHistory({ - initialEntries: [targetUrl.pathname + targetUrl.search + targetUrl.hash], - }), - origin: targetUrl.origin, - }) - return await provideRequestEvent(flightEvent, async () => { + let attachedServerSsr = false + try { + if (router.options.dehydrate && !router.serverSsr) { + attachRouterServerSsrUtils({ router, manifest: undefined }) + attachedServerSsr = true + } + return await runWithStartContext( { ...startContext, handlerType: 'router', request, }, - async () => { - let attachedServerSsr = false - try { - if (router.options.dehydrate && !router.serverSsr) { - attachRouterServerSsrUtils({ router, manifest: undefined }) - attachedServerSsr = true - } + () => + loadFlightTarget({ + router, + event, + outcome, + href, + collect: async () => { + const dehydratedData = await router.options.dehydrate?.() + router.serverSsr?.setRenderFinished() - await router.load({ _signal: outcome.request.signal }) - const dehydratedData = await router.options.dehydrate?.() - router.serverSsr?.setRenderFinished() - - return { - ...(dehydratedData === undefined ? {} : { dehydratedData }), - href: targetUrl.href, - matches: router.stores.matches - .get() - .map(createSolidStartFlightMatch), - } - } catch (error) { - console.error('Unable to collect TanStack Start flight data', error) - return undefined - } finally { - if (attachedServerSsr) { - router.serverSsr?.cleanup() - } - } - }, + return { + ...(dehydratedData === undefined ? {} : { dehydratedData }), + href: request.url, + matches: router.stores.matches + .get() + .map(createSolidStartFlightMatch), + } + }, + }), ) - }) + } catch (error) { + console.error('Unable to collect TanStack Start flight data', error) + return undefined + } finally { + if (attachedServerSsr) { + router.serverSsr?.cleanup() + } + } } function getTargetHref(outcome: ServerFunctionOutcome) { diff --git a/packages/solid-start/src/solid-rpc-flight.ts b/packages/solid-start/src/solid-rpc-flight.ts index 9a45a58aa4..27a063d8e5 100644 --- a/packages/solid-start/src/solid-rpc-flight.ts +++ b/packages/solid-start/src/solid-rpc-flight.ts @@ -1,5 +1,16 @@ import type { AnyRouteMatch } from '@tanstack/solid-router' +/** + * The router's single-flight source id on Solid's multi-source flight + * channel. Mutation responses fold one slice per cache — the router's + * loader/match data rides under this id, coexisting with other caches' + * slices (e.g. solid-query's `"sq"`) on the same round trip instead of + * competing for the single unnamed consumer slot. Requires the + * `@solidjs/web` release after 2.0.0-rc.4; both halves feature-detect and + * fall back to the unnamed slot on older versions. + */ +export const SOLID_START_FLIGHT_SOURCE = 'tsr' + export interface SolidStartFlightMatch { id: string beforeLoadContext?: unknown