feat: typed outlets (#409)
Summary
Adds typed, per-outlet bound ReactServerComponent exposed through a
new virtual module @lazarv/react-server/outlets, alongside the
existing @lazarv/react-server/routes. Where
@lazarv/react-server/routes types the consuming side of outlets (a
layout's createLayout props), this PR types and packages the
producing side, so call sites no longer hand-write
<ReactServerComponent outlet="sidebar" url="/dashboard/nav" /> —
instead they import a namespace per outlet and write <sidebar.Outlet url="/dashboard/nav" />.
The bound component closes over its outlet name (no stringly-typed
prop), accepts a url typed against the same RouteImpl<T> union as
Link.to (so typos are rejected and dynamic segments must be concrete),
and returns a branded Outlet<"sidebar"> so the value can satisfy a
createLayout slot of the same name without casts.
By default the outlet preloads on the server. When the bound component
is rendered in a server component, the runtime resolves the url
against the file-router manifest, locates the matching
@outletName/…page.tsx (or the @outletName.default.tsx fallback),
renders it, and passes the result as children to
ReactServerComponent — so the SSR HTML contains the outlet content on
first paint, no client round-trip. defer={true} and explicit
children opt out (client-only fetch and full override respectively).
To make this work cleanly, Outlet<Name> from the routes module is now
exported so the outlets module can produce values that satisfy the same
brand consumed by createLayout.
Implementation notes
The file-router plugin now collects unique outlet names from the
manifest and generates two artifacts: a virtual
@lazarv/react-server/outlets runtime module and a
react-server-outlets.d.ts declaration written into .react-server/.
The runtime module ships in two flavours selected by Vite environment —
an async preloading variant for any server-side environment (RSC and
SSR), and a sync forwarder for the client environment where the
preload's server-only imports would crash a browser bundle. Build-mode
SSR/client builds receive the simple variant through the resources
fallback plugin's store, the same way @lazarv/react-server/routes is
delivered today. Outlet names that aren't valid JavaScript identifiers
(e.g. hyphenated directory names) are skipped from the module with a
logger warning and stay reachable via the bare <ReactServerComponent outlet="…" /> form. Both generateRoutesDts and generateOutletsDts
were rewritten as single template literals composed via
.map(...).join("\n\n"), replacing the previous lines.push chains;
the emitted output is byte-identical apart from now-exported
Outlet<Name>.
Adjacent fix: default <Link> navigation with named outlets active
Surfacing this feature exposed a pre-existing bug in
ClientProvider.navigate(). With outlets.size > 1 (which now happens
any time a page mounts a <*.Outlet />), a default <Link> click —
where target/local/root are unset and Link.jsx therefore passes
outlet: undefined — entered the broadcast branch and fanned the
navigation out to every active non-root outlet, skipping PAGE_ROOT.
The user stayed on the previous page with the outlet slots rendering
null. Fixed by narrowing the implicit broadcast: when options.outlet
is undefined and the target pathname differs from the current
pathname, default to PAGE_ROOT. Same-pathname updates
(?filter=active-style URL changes) still broadcast, preserving the
multi-pane filter use case the broadcast was designed for.