Skip to content

Server location arrives through the createRouter config instead of the provider, forcing a router instance per URL #579

Description

@ryansolid

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions