Summary
On the server the router's location comes from either the request event (AsyncLocalStorage) or config.history, both of which are decided at createRouter time. The provider component accepts no URL. That places a request-scoped input on an app-scoped constructor, and the only non-ALS way to supply a URL is to build a whole router instance per URL.
Observed on 1.0.0-next.9 (50aad93).
Today
createRouter's config is explicitly app-lifetime — routes are immutable per instance so the compiled branch tree is shared across every mount, request, and match() call:
// src/routers/factory.tsx
// Routes are immutable per instance, so compilation is shared by every
// mount, request, and `match()` call — recompiled only when a lazy subtree
// resolves (append-only: resolution, not mutation).
let compiled: Branch[] | undefined;
The provider's props are children-only, and the location source is read from the config rather than the render:
function RouterComponent(props: { children?: (props: RouteSectionProps) => JSX.Element }): JSX.Element {
const root = untrack(() => props.children);
const integration = isServer
? staticIntegration(config.history)
: createIntegration(config.history || browserHistory());
function staticIntegration(history?: RouterHistory): RouterIntegration {
const e = getRequestEvent();
let value: string | LocationChange = "";
if (e) {
const url = new URL(e.request.url);
value = url.pathname + url.search;
} else if (history) {
value = history.get();
}
RouterConfig.history is documented as "History adapter; defaults to browser history on the client and the request URL on the server", and staticIntegration's comment names the intended non-ALS path: "Without a request event (SSG scripts, server-side tests) the configured history adapter provides the location, so e.g. memoryHistory(\"/page\") works isomorphically."
Why this is a problem
Static generation scales the wrong way. Rendering N URLs without a request event requires N createRouter calls, because the URL is baked into the config. Each instance recompiles the route tree. The documented escape hatch from ALS is the option that degrades worst as the site grows; the ALS path is the only one that keeps the compiled tree shared.
Tests pay the same cost. Asserting behavior across several URLs means constructing a router per case rather than rendering the same app at different locations.
Environments without node:async_hooks have no way to hand the router a URL while keeping a module-scope instance.
The URL is already in hand at the render seam. A server entry receives the request directly (e.g. vite-plugin-solid's turnkey render(request, context), or any hand-rolled harness), so the value exists exactly where the provider is instantiated — but there is no way to pass it in.
Proposal
Accept a server-side url on the provider, taking precedence over the request event and falling back to it when absent:
const Router = createRouter({ routes }); // module scope, tree compiled once
// server entry
<Router url={request.url}>{props => <App {...props} />}</Router>
Mechanically this looks like a props-type addition plus one more argument threaded into the existing call site:
const integration = isServer
? staticIntegration(config.history, props.url)
: createIntegration(config.history || browserHistory());
The provider is the right home because it is already instantiated per render, and on the server per render means per request. Existing behavior is unchanged when url is omitted.
Non-goals
This does not remove AsyncLocalStorage from an app. query() scopes its cache per request through the request event:
// src/data/query.ts
if (!isServer) return cacheMap;
const req = getRequestEvent();
and server functions resolve request access through the same store. The win here is narrower: static generation and tests stop needing a router instance per URL, and the router becomes usable without a request event while keeping its compiled route tree.
Summary
On the server the router's location comes from either the request event (AsyncLocalStorage) or
config.history, both of which are decided atcreateRoutertime. The provider component accepts no URL. That places a request-scoped input on an app-scoped constructor, and the only non-ALS way to supply a URL is to build a whole router instance per URL.Observed on
1.0.0-next.9(50aad93).Today
createRouter's config is explicitly app-lifetime — routes are immutable per instance so the compiled branch tree is shared across every mount, request, andmatch()call:The provider's props are children-only, and the location source is read from the config rather than the render:
RouterConfig.historyis documented as "History adapter; defaults to browser history on the client and the request URL on the server", andstaticIntegration's comment names the intended non-ALS path: "Without a request event (SSG scripts, server-side tests) the configured history adapter provides the location, so e.g.memoryHistory(\"/page\")works isomorphically."Why this is a problem
Static generation scales the wrong way. Rendering N URLs without a request event requires N
createRoutercalls, because the URL is baked into the config. Each instance recompiles the route tree. The documented escape hatch from ALS is the option that degrades worst as the site grows; the ALS path is the only one that keeps the compiled tree shared.Tests pay the same cost. Asserting behavior across several URLs means constructing a router per case rather than rendering the same app at different locations.
Environments without
node:async_hookshave no way to hand the router a URL while keeping a module-scope instance.The URL is already in hand at the render seam. A server entry receives the request directly (e.g.
vite-plugin-solid's turnkeyrender(request, context), or any hand-rolled harness), so the value exists exactly where the provider is instantiated — but there is no way to pass it in.Proposal
Accept a server-side
urlon the provider, taking precedence over the request event and falling back to it when absent:Mechanically this looks like a props-type addition plus one more argument threaded into the existing call site:
The provider is the right home because it is already instantiated per render, and on the server per render means per request. Existing behavior is unchanged when
urlis omitted.Non-goals
This does not remove AsyncLocalStorage from an app.
query()scopes its cache per request through the request event:and server functions resolve request access through the same store. The win here is narrower: static generation and tests stop needing a router instance per URL, and the router becomes usable without a request event while keeping its compiled route tree.