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
5 changes: 5 additions & 0 deletions .changeset/named-flight-source.md
Original file line number Diff line number Diff line change
@@ -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.
97 changes: 97 additions & 0 deletions packages/solid-router/src/ssr/loadFlightTarget.ts
Original file line number Diff line number Diff line change
@@ -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<TRouter extends AnyRouter, T> {
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<T>
}

/**
* 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<TRouter extends AnyRouter, T>(
options: LoadFlightTargetOptions<TRouter, T>,
): Promise<T | undefined> {
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
}
})
}
2 changes: 2 additions & 0 deletions packages/solid-router/src/ssr/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
40 changes: 38 additions & 2 deletions packages/solid-start/src/server-functions-handler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as solidServerFunctions from '@solidjs/web/server-functions/server'
import {
configureServerFunctionsServer,
createNoJSHandler,
Expand All @@ -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'

Expand All @@ -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 {
Expand All @@ -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)
Expand Down
25 changes: 24 additions & 1 deletion packages/solid-start/src/solid-rpc-flight-client.ts
Original file line number Diff line number Diff line change
@@ -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 <D>(
source: string,
consumer: (data: D, context: { response: Response }) => void | Promise<void>,
) => () => void

let subscribed = false

export function subscribeSolidStartFlightData() {
Expand All @@ -14,7 +29,15 @@ export function subscribeSolidStartFlightData() {
}
subscribed = true

subscribeFlightData<SolidStartFlightData>(async (data) => {
const subscribe = hasNamedFlightSources
? (consumer: (data: SolidStartFlightData) => Promise<void>) =>
subscribeNamedFlightData<SolidStartFlightData>(
SOLID_START_FLIGHT_SOURCE,
consumer,
)
: subscribeFlightData<SolidStartFlightData>

subscribe(async (data) => {
if (!isSolidStartFlightData(data)) {
return
}
Expand Down
87 changes: 41 additions & 46 deletions packages/solid-start/src/solid-rpc-flight-server.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions packages/solid-start/src/solid-rpc-flight.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading