Skip to content

[2.0] lazy() hydration shortcut assumes the preloaded module's default export is the component — named-export wrappers silently orphan SSR'd DOM #3011

Description

@brenelz

Affected version: solid-js@2.0.0-rc.0

Summary

During hydration, lazy()'s preloaded-module lookup renders the cached module's default export whenever the boundary's hydration key has an entry in _$HY.modules:

// lazyHydrationLookup
const cached = key != null ? globalThis._$HY?.modules?.[key] : undefined;
if (cached) return () => cached.default;

"The component is cached.default" is only guaranteed for call sites the bundler transform rewrote (lazy(() => import("./X")) — the same rewrite that injects moduleUrl). For a wrapper that selects a named export from a multi-export module, cached.default is undefined (or an unrelated export), and the failure mode is severe and completely silent:

  • the boundary renders "" instead of the component,
  • the server-rendered DOM for that subtree is never claimed,
  • the app looks fine (the SSR HTML is still on screen) with zero console errors,
  • until the first client-side navigation tries to dispose the subtree — Solid only disposes nodes it owns, so the old content stays put and the new route renders next to it, stacking orphaned content with every navigation.

Reproduction

Any SSR'd wrapper over lazy() that picks a named export:

// routes.js — multi-export module, no default export
export function HomePage() { return <h1>Home</h1> }
export function AboutPage() { return <h1>About</h1> }
const lazyNamed = (importer, name) =>
  lazy(() => importer().then((m) => ({ default: m[name], $$moduleUrl: m.$$moduleUrl })));

const Home = lazyNamed(() => import("./routes.js"), "HomePage");

Server-side, $$moduleUrl registers the module and serializes the hydration asset map, so the client preloads routes.js into _$HY.modules[key]. At hydration the lookup returns () => cached.defaultundefined → empty render, orphaned SSR DOM, content duplication on nav. (This is how we found it: TanStack Router's lazyRouteComponent selects named exports from route chunks — 20 e2e failures with no error anywhere, TanStack/router#8081.)

Suggested fix

Gate the shortcut on moduleUrl — the transform's marker that the default-export convention actually holds:

if (cached && moduleUrl) return () => cached.default;

Un-transformed call sites fall through to their own import, which resolves near-instantly since the gating already preloaded the chunk, and hydrates through the async path with the wrapper's resolved value (the correct component). Transformed call sites keep the sync shortcut. We've been running this one-line change as a pnpm patch: it took the affected e2e suite from 60 passed / 20 failed to 80 passed.

Related: an explicit opt-out for self-managed assets

Adjacent gap, happy to split into its own issue if preferred: there's no way for a resolved module to say "this boundary deliberately manages its own client assets." $$moduleUrl: undefined draws the per-request server warning, and registering a real module id is actively wrong when the module is a multi-route chunk (asset resolution is per-chunk, so every route gets the union of the chunk's CSS — breaks per-route stylesheet isolation when the integration has a more precise per-route manifest). We patched $$moduleUrl: null to mean "no registration, no warning":

const id = cur.mod?.$$moduleUrl;
if (typeof id === "string") assetsPending = registerLazyAssets(id);
else if (id === null) { /* explicit opt-out */ }
else console.warn("lazy() used in SSR without a moduleUrl…");

Any spelling works for us (null, false, a symbol) — the need is a deliberate marker distinct from "missing" so real misconfigurations still warn.

Context

Follow-up to #2999 (thanks for the fast turnaround on that one!). These two are the remaining hunks of the pnpm patch carried in TanStack/router#8081 — with them upstream, the patch disappears entirely on the next rc. Happy to PR either against next if the approach looks right.

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