@rangojs/router 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/types/client-urls/server-projection.d.ts +4 -4
- package/dist/types/client-urls/types.d.ts +13 -12
- package/dist/types/route-definition/helpers-types.d.ts +6 -5
- package/dist/types/router/segment-resolution/loader-mask.d.ts +1 -1
- package/dist/types/rsc/shell-capture.d.ts +9 -0
- package/dist/types/rsc/types.d.ts +8 -0
- package/dist/types/server/context.d.ts +1 -1
- package/dist/types/server/request-context.d.ts +1 -1
- package/dist/types/ssr/index.d.ts +16 -0
- package/dist/types/ssr/ssr-root.d.ts +5 -0
- package/dist/types/types/loader-types.d.ts +24 -22
- package/dist/types/urls/path-helper-types.d.ts +8 -7
- package/dist/vite/index.js +8 -5
- package/package.json +22 -21
- package/skills/breadcrumbs/SKILL.md +2 -2
- package/skills/catalog.json +2 -2
- package/skills/client-urls/SKILL.md +9 -8
- package/skills/comparison/references/framework-comparison.md +2 -2
- package/skills/hooks/data.md +1 -1
- package/skills/hooks/handle-and-actions.md +1 -1
- package/skills/loader/SKILL.md +29 -28
- package/skills/migrate-nextjs/SKILL.md +3 -3
- package/skills/migrate-react-router/component-migration.md +1 -1
- package/skills/migrate-react-router/data-and-actions.md +1 -1
- package/skills/migrate-react-router/route-mapping.md +1 -1
- package/skills/parallel/SKILL.md +1 -1
- package/skills/ppr/SKILL.md +1 -1
- package/skills/rango/SKILL.md +20 -20
- package/skills/router-setup/SKILL.md +1 -1
- package/skills/scripts/SKILL.md +1 -1
- package/skills/shell-manifest/SKILL.md +1 -1
- package/src/browser/react/Link.tsx +27 -4
- package/src/client-urls/client-urls.ts +12 -9
- package/src/client-urls/server-projection.ts +6 -6
- package/src/client-urls/types.ts +13 -12
- package/src/route-definition/dsl-helpers.ts +7 -3
- package/src/route-definition/helpers-types.ts +6 -5
- package/src/router/loader-resolution.ts +3 -3
- package/src/router/segment-resolution/fresh.ts +2 -2
- package/src/router/segment-resolution/loader-cache.ts +1 -1
- package/src/router/segment-resolution/loader-mask.ts +1 -1
- package/src/rsc/rsc-rendering.ts +10 -0
- package/src/rsc/shell-capture.ts +10 -0
- package/src/rsc/ssr-setup.ts +4 -0
- package/src/rsc/types.ts +18 -2
- package/src/server/context.ts +1 -1
- package/src/server/request-context.ts +1 -1
- package/src/ssr/index.tsx +22 -2
- package/src/ssr/ssr-root.tsx +17 -1
- package/src/types/loader-types.ts +21 -19
- package/src/urls/path-helper-types.ts +8 -7
package/skills/loader/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: loader
|
|
3
|
-
description: Define data loaders for fetching data in routes with createLoader. Use when pages need per-request data that stays fresh, data should stream while the page renders, client components need reactive server data, a loader should throw notFound()/redirect(), set page meta/breadcrumbs from loader data (handle writes), or loader data must be guaranteed in the SSR'd document (
|
|
3
|
+
description: Define data loaders for fetching data in routes with createLoader. Use when pages need per-request data that stays fresh, data should stream while the page renders, client components need reactive server data, a loader should throw notFound()/redirect(), set page meta/breadcrumbs from loader data (handle writes), or loader data must be guaranteed in the SSR'd document (ssr:false).
|
|
4
4
|
argument-hint: "[loader]"
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -172,23 +172,23 @@ Loaders receive the same context shape as route handlers.
|
|
|
172
172
|
|
|
173
173
|
### Full field surface
|
|
174
174
|
|
|
175
|
-
| Field | Type | Notes
|
|
176
|
-
| -------------- | ------------------------------- |
|
|
177
|
-
| `params` | `TParams` | Merged route + explicit loader params; overridable by fetchable `load({ params })`.
|
|
178
|
-
| `routeParams` | `Record<string, string>` | Server-trusted route params from URL pattern matching; cannot be overridden.
|
|
179
|
-
| `request` | `Request` | The incoming `Request` (headers, method, body, `signal` for abort).
|
|
180
|
-
| `url` | `URL` | Parsed request URL.
|
|
181
|
-
| `pathname` | `string` | URL pathname (shortcut for `ctx.url.pathname`).
|
|
182
|
-
| `searchParams` | `URLSearchParams` | Shortcut for `ctx.url.searchParams`.
|
|
183
|
-
| `search` | `ResolveSearchSchema<TSearch>` | Typed query params when a search schema is declared on the route; `{}` otherwise.
|
|
184
|
-
| `env` | `TEnv` | Plain bindings from `createRouter<TEnv>()` (DB, KV, secrets, etc.).
|
|
185
|
-
| `get` | `(key \| ContextVar \| handle)` | Reads middleware variables/context-vars — or READS a handle's collected data, after `await ctx.rendered()`.
|
|
186
|
-
| `use` | `(loader \| handle) => T` | Access another loader's data (Promise), or WRITE a handle: `ctx.use(Meta)({ title })` returns the push function — handler parity. Reads moved to `get`.
|
|
187
|
-
| `rendered` | `() => Promise<void>` | **Experimental.** DSL loaders only — waits for all non-loader segments (including `loading()` streaming handlers) to settle before reading handle data. Not with `
|
|
188
|
-
| `method` | `string` | HTTP method. `"GET"` for SSR loader runs; reflects real method for fetchable loaders.
|
|
189
|
-
| `body` | `TBody \| undefined` | Parsed request body for fetchable POST/PUT/PATCH/DELETE calls.
|
|
190
|
-
| `formData` | `FormData \| undefined` | Present when a fetchable loader is invoked via form submission.
|
|
191
|
-
| `reverse` | `ScopedReverseFunction` | Generate type-checked URLs from route names (same scoped semantics as route handlers).
|
|
175
|
+
| Field | Type | Notes |
|
|
176
|
+
| -------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
177
|
+
| `params` | `TParams` | Merged route + explicit loader params; overridable by fetchable `load({ params })`. |
|
|
178
|
+
| `routeParams` | `Record<string, string>` | Server-trusted route params from URL pattern matching; cannot be overridden. |
|
|
179
|
+
| `request` | `Request` | The incoming `Request` (headers, method, body, `signal` for abort). |
|
|
180
|
+
| `url` | `URL` | Parsed request URL. |
|
|
181
|
+
| `pathname` | `string` | URL pathname (shortcut for `ctx.url.pathname`). |
|
|
182
|
+
| `searchParams` | `URLSearchParams` | Shortcut for `ctx.url.searchParams`. |
|
|
183
|
+
| `search` | `ResolveSearchSchema<TSearch>` | Typed query params when a search schema is declared on the route; `{}` otherwise. |
|
|
184
|
+
| `env` | `TEnv` | Plain bindings from `createRouter<TEnv>()` (DB, KV, secrets, etc.). |
|
|
185
|
+
| `get` | `(key \| ContextVar \| handle)` | Reads middleware variables/context-vars — or READS a handle's collected data, after `await ctx.rendered()`. |
|
|
186
|
+
| `use` | `(loader \| handle) => T` | Access another loader's data (Promise), or WRITE a handle: `ctx.use(Meta)({ title })` returns the push function — handler parity. Reads moved to `get`. |
|
|
187
|
+
| `rendered` | `() => Promise<void>` | **Experimental.** DSL loaders only — waits for all non-loader segments (including `loading()` streaming handlers) to settle before reading handle data. Not with `ssr: false` (cycle; throws). |
|
|
188
|
+
| `method` | `string` | HTTP method. `"GET"` for SSR loader runs; reflects real method for fetchable loaders. |
|
|
189
|
+
| `body` | `TBody \| undefined` | Parsed request body for fetchable POST/PUT/PATCH/DELETE calls. |
|
|
190
|
+
| `formData` | `FormData \| undefined` | Present when a fetchable loader is invoked via form submission. |
|
|
191
|
+
| `reverse` | `ScopedReverseFunction` | Generate type-checked URLs from route names (same scoped semantics as route handlers). |
|
|
192
192
|
|
|
193
193
|
### Example
|
|
194
194
|
|
|
@@ -443,8 +443,8 @@ boundary a parallel loader blocks its parent, so add one to keep the overlap.)
|
|
|
443
443
|
If you come from a framework where the loader is a blocking step that runs
|
|
444
444
|
before the response is built, this is the shift to internalize: here the
|
|
445
445
|
response starts streaming first and loader data fills in. (The one deliberate
|
|
446
|
-
exception is per-loader: `loader(Def, {
|
|
447
|
-
loader before first flush on document renders — see "`
|
|
446
|
+
exception is per-loader: `loader(Def, { ssr: false })` awaits that
|
|
447
|
+
loader before first flush on document renders — see "`ssr: false`"
|
|
448
448
|
below.)
|
|
449
449
|
|
|
450
450
|
### See it: `debugPerformance`
|
|
@@ -687,10 +687,10 @@ export const ProductLoader = createLoader(async (ctx) => {
|
|
|
687
687
|
|
|
688
688
|
Semantics by lane:
|
|
689
689
|
|
|
690
|
-
| Signal | Document load
|
|
691
|
-
| ------------ |
|
|
692
|
-
| `notFound()` | Not-found UI resolves server-side (nearest `notFoundBoundary` → router option → default) and rides the envelope; the 404 STATUS is **opportunistic** — real only if the rejection beats Response construction. `
|
|
693
|
-
| `redirect()` | 200 document, then a client-side replace to the target — **no document-lane 302 from loaders**; pre-stream redirect authority belongs to middleware
|
|
690
|
+
| Signal | Document load | Client navigation |
|
|
691
|
+
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
|
692
|
+
| `notFound()` | Not-found UI resolves server-side (nearest `notFoundBoundary` → router option → default) and rides the envelope; the 404 STATUS is **opportunistic** — real only if the rejection beats Response construction. `ssr: false` (below) makes it deterministic. | 404 UI swaps in, URL preserved, payload stays 200 |
|
|
693
|
+
| `redirect()` | 200 document, then a client-side replace to the target — **no document-lane 302 from loaders**; pre-stream redirect authority belongs to middleware | Redirect envelope navigates to the target |
|
|
694
694
|
|
|
695
695
|
Session/auth gates belong in middleware (they are request-shaped, not
|
|
696
696
|
data-shaped, and middleware CAN emit a real pre-stream 302). Data-dependent
|
|
@@ -725,14 +725,14 @@ barrier ride the SSR handle snapshot (in the SSR'd document — `<MetaTags />`,
|
|
|
725
725
|
on document loads (`metadata.handlesLate`) or progressively on navigations.
|
|
726
726
|
A push before your slow fetch usually beats the barrier; a push derived from
|
|
727
727
|
the fetched data usually does not. When it MUST be in the document, use
|
|
728
|
-
`
|
|
728
|
+
`ssr: false` below.
|
|
729
729
|
|
|
730
730
|
Reads are the other direction and gated: `ctx.get(handle)` throws unless the
|
|
731
731
|
loader first does `await ctx.rendered()` (DSL-registered loaders only —
|
|
732
732
|
handler-invoked loaders cannot use `rendered()`, and a handler already
|
|
733
733
|
awaiting the loader via `ctx.use()` makes it a detected deadlock).
|
|
734
734
|
|
|
735
|
-
## `
|
|
735
|
+
## `ssr: false` — Guarantee a Loader in the Document
|
|
736
736
|
|
|
737
737
|
Streaming means nothing a slow loader produces is _guaranteed_ in the SSR'd
|
|
738
738
|
HTML: its section SSRs as the fallback, a late handle push applies
|
|
@@ -743,12 +743,13 @@ use callback:
|
|
|
743
743
|
|
|
744
744
|
```typescript
|
|
745
745
|
path("/product/:slug", ProductPage, { name: "product" }, () => [
|
|
746
|
-
loader(ProductLoader, {
|
|
746
|
+
loader(ProductLoader, { ssr: false }, () => [cache({ ttl: 60 })]),
|
|
747
747
|
loader(RelatedLoader), // untouched: still streams behind its boundary
|
|
748
748
|
]),
|
|
749
749
|
```
|
|
750
750
|
|
|
751
|
-
The
|
|
751
|
+
The knob mirrors `loading(fallback, { ssr: false })` — SSR delivery is off
|
|
752
|
+
for this loader, so nothing of it is left to stream in the document:
|
|
752
753
|
document renders await this loader before first flush — data is settled
|
|
753
754
|
(`useLoader` reads it synchronously, no fallback paints), handle pushes beat
|
|
754
755
|
the barrier snapshot, and a thrown `notFound()` deterministically precedes
|
|
@@ -726,7 +726,7 @@ const HomePage: Handler<"home"> = (ctx) => {
|
|
|
726
726
|
|
|
727
727
|
`generateMetadata({ params })` — DATA-derived, document-blocking metadata —
|
|
728
728
|
maps to a Meta push from the LOADER that owns the data, plus
|
|
729
|
-
`{
|
|
729
|
+
`{ ssr: false }` for the blocking-until-in-head part:
|
|
730
730
|
|
|
731
731
|
```typescript
|
|
732
732
|
// Next.js: export async function generateMetadata({ params }) {
|
|
@@ -744,7 +744,7 @@ export const ProductLoader = createLoader(async (ctx) => {
|
|
|
744
744
|
});
|
|
745
745
|
|
|
746
746
|
path("/product/:slug", ProductPage, { name: "product" }, () => [
|
|
747
|
-
loader(ProductLoader, {
|
|
747
|
+
loader(ProductLoader, { ssr: false }),
|
|
748
748
|
]);
|
|
749
749
|
```
|
|
750
750
|
|
|
@@ -843,7 +843,7 @@ See `/theme` for full API including system detection and cookie persistence.
|
|
|
843
843
|
10. [ ] Convert loading/error files to `loading()` / `errorBoundary()`
|
|
844
844
|
11. [ ] Migrate API routes to `path.json()` / `path.text()`
|
|
845
845
|
12. [ ] Update metadata to use `Meta` handle + `<MetaTags />` in document head
|
|
846
|
-
(`generateMetadata` → loader push + `{
|
|
846
|
+
(`generateMetadata` → loader push + `{ ssr: false }`)
|
|
847
847
|
13. [ ] Replace `next-themes` with `theme: true` in createRouter (see `/theme`)
|
|
848
848
|
14. [ ] Map rendering-mode segment config: `revalidate = N` → `cache({ ttl })`,
|
|
849
849
|
`force-static` → `Static()`/`Prerender()`, `experimental_ppr` → the
|
|
@@ -134,7 +134,7 @@ const HomePage: Handler<"home"> = (ctx) => {
|
|
|
134
134
|
|
|
135
135
|
RR's data-derived `meta({ data })` maps to the same push from the LOADER that
|
|
136
136
|
owns the data — `ctx.use(Meta)({ title: data.name })` in the loader body, with
|
|
137
|
-
`loader(Def, {
|
|
137
|
+
`loader(Def, { ssr: false })` when the title must be in the SSR'd
|
|
138
138
|
head. See `/loader` → "Writing Handles from Loaders".
|
|
139
139
|
|
|
140
140
|
Add `<MetaTags />` in the Document component's `<head>`:
|
|
@@ -46,7 +46,7 @@ Use it only when you need capabilities beyond what the handler provides:
|
|
|
46
46
|
document loads, never an HTTP 302 — pre-stream 302s move to middleware)
|
|
47
47
|
- **`meta({ data })` / `handle` exports** — data-derived page metadata becomes
|
|
48
48
|
a handle push from the loader body (`ctx.use(Meta)({ title: data.name })`),
|
|
49
|
-
with `loader(L, {
|
|
49
|
+
with `loader(L, { ssr: false })` when it must be in the SSR'd head
|
|
50
50
|
|
|
51
51
|
If the React Router loader just fetches data for its page component AND the
|
|
52
52
|
component can become a server component, merge it into the handler. If the
|
|
@@ -135,7 +135,7 @@ The loader-shaped variant is equally valid — and closer to the RR module when
|
|
|
135
135
|
the loader carried authority. A `createLoader()` body can throw `notFound()`
|
|
136
136
|
for the missing product AND push the data-derived meta itself
|
|
137
137
|
(`ctx.use(Meta)({ title: product.name })`); register it with
|
|
138
|
-
`loader(ProductLoader, {
|
|
138
|
+
`loader(ProductLoader, { ssr: false })` when the 404 status and
|
|
139
139
|
title must be in the document deterministically. See `/loader` → "Loader
|
|
140
140
|
Authority" and "Writing Handles from Loaders". (One RR habit that does NOT
|
|
141
141
|
carry over: a loader `throw redirect()` is a client-side navigate on document
|
package/skills/parallel/SKILL.md
CHANGED
|
@@ -136,7 +136,7 @@ naturally.
|
|
|
136
136
|
> the baked lane under `cache()`/PPR — just to reach a `ctx.use(Meta)` call.
|
|
137
137
|
> The loader can now push `Meta` itself (`/loader` → "Writing Handles from
|
|
138
138
|
> Loaders"), keeping the data on the live lane; add
|
|
139
|
-
> `loader(Def, {
|
|
139
|
+
> `loader(Def, { ssr: false })` when the meta must be in the SSR'd
|
|
140
140
|
> head. Keep the `@meta` slot for metadata that is NOT loader-derived
|
|
141
141
|
> (templates, static descriptions, structured data with independent inputs).
|
|
142
142
|
|
package/skills/ppr/SKILL.md
CHANGED
|
@@ -669,7 +669,7 @@ LOADER-pushed Meta is different: loaders are masked at capture, so the push
|
|
|
669
669
|
happens at request time and applies client-side (`metadata.handlesLate`) — it
|
|
670
670
|
is never in the cached shell's head, by construction.
|
|
671
671
|
|
|
672
|
-
One flag to know about here: `loader(Def, {
|
|
672
|
+
One flag to know about here: `loader(Def, { ssr: false })` (the
|
|
673
673
|
document-render await, `/loader`) is **inert under PPR** — capture renders
|
|
674
674
|
mask loaders and skip the await, and a shell HIT flushes the stored prelude
|
|
675
675
|
before loaders resolve. Flagging a loader on a `ppr` route does not bake it
|
package/skills/rango/SKILL.md
CHANGED
|
@@ -95,26 +95,26 @@ stated, greppable contract.
|
|
|
95
95
|
|
|
96
96
|
## Pick a primitive
|
|
97
97
|
|
|
98
|
-
| I need to… | Use
|
|
99
|
-
| --------------------------------------- |
|
|
100
|
-
| render data fresh every request | `loader()` + `useLoader()`
|
|
101
|
-
| cache a rendered subtree | `cache()` on a segment
|
|
102
|
-
| cache one function/component's result | `"use cache"`
|
|
103
|
-
| cache a loader's data | `loader(L, () => [cache()])`
|
|
104
|
-
| re-render a segment after an action | `revalidate()`
|
|
105
|
-
| mutate | `"use server"` action
|
|
106
|
-
| debug a slow request | `debugPerformance` / telemetry
|
|
107
|
-
| share config across routes | factory returning a helper array
|
|
108
|
-
| compose a sub-app / module | `include()`
|
|
109
|
-
| modal / soft navigation | `intercept()`
|
|
110
|
-
| route group of client components | `clientUrls()` in `"use client"`
|
|
111
|
-
| set meta/breadcrumbs from loader data | `ctx.use(Handle)` in the loader
|
|
112
|
-
| guarantee loader output in the SSR HTML | `loader(L, {
|
|
113
|
-
| pre-render a route at build time | `Prerender(...)` wrapper
|
|
114
|
-
| feed live loaders from a cached shell | replayed handle + `ctx.rendered()`
|
|
115
|
-
| cache the HTML shell, keep loaders live | `ppr` path option
|
|
116
|
-
| choose in-function vs CDN caching | deployment cache boundary
|
|
117
|
-
| stream SSE / upgrade a WebSocket | `path.stream()` / `path.any()`
|
|
98
|
+
| I need to… | Use | Skill |
|
|
99
|
+
| --------------------------------------- | ---------------------------------- | ----------------------- |
|
|
100
|
+
| render data fresh every request | `loader()` + `useLoader()` | /loader |
|
|
101
|
+
| cache a rendered subtree | `cache()` on a segment | /caching |
|
|
102
|
+
| cache one function/component's result | `"use cache"` | /use-cache |
|
|
103
|
+
| cache a loader's data | `loader(L, () => [cache()])` | /loader, /caching |
|
|
104
|
+
| re-render a segment after an action | `revalidate()` | /loader |
|
|
105
|
+
| mutate | `"use server"` action | /server-actions |
|
|
106
|
+
| debug a slow request | `debugPerformance` / telemetry | /observability |
|
|
107
|
+
| share config across routes | factory returning a helper array | /composability |
|
|
108
|
+
| compose a sub-app / module | `include()` | /route |
|
|
109
|
+
| modal / soft navigation | `intercept()` | /intercept |
|
|
110
|
+
| route group of client components | `clientUrls()` in `"use client"` | /client-urls |
|
|
111
|
+
| set meta/breadcrumbs from loader data | `ctx.use(Handle)` in the loader | /loader |
|
|
112
|
+
| guarantee loader output in the SSR HTML | `loader(L, { ssr: false })` | /loader |
|
|
113
|
+
| pre-render a route at build time | `Prerender(...)` wrapper | /prerender |
|
|
114
|
+
| feed live loaders from a cached shell | replayed handle + `ctx.rendered()` | /shell-manifest |
|
|
115
|
+
| cache the HTML shell, keep loaders live | `ppr` path option | /ppr |
|
|
116
|
+
| choose in-function vs CDN caching | deployment cache boundary | /deployment-caching |
|
|
117
|
+
| stream SSE / upgrade a WebSocket | `path.stream()` / `path.any()` | /streams-and-websockets |
|
|
118
118
|
|
|
119
119
|
## Invariants
|
|
120
120
|
|
|
@@ -375,7 +375,7 @@ Handler and no-match cases set HTTP 404 status. A LOADER-thrown `notFound()`
|
|
|
375
375
|
on a document load always streams the resolved not-found UI, but the 404
|
|
376
376
|
STATUS is opportunistic — real only when the rejection settles before the
|
|
377
377
|
document Response is constructed (loaders stream). Register the loader as
|
|
378
|
-
`loader(Def, {
|
|
378
|
+
`loader(Def, { ssr: false })` to make the 404 status deterministic;
|
|
379
379
|
on client navigations the 404 UI swaps in with the URL preserved (payload
|
|
380
380
|
stays 200 — the client owns presentation there). See `/loader` → "Loader
|
|
381
381
|
Authority".
|
package/skills/scripts/SKILL.md
CHANGED
|
@@ -106,7 +106,7 @@ during navigation.
|
|
|
106
106
|
> settles before the handler barrier. A push that lands after a slow fetch
|
|
107
107
|
> arrives post-hydration — and for an inline/ordered script the frozen set
|
|
108
108
|
> means it is silently dropped. If a loader must contribute an inline script
|
|
109
|
-
> to the document, register it `loader(Def, {
|
|
109
|
+
> to the document, register it `loader(Def, { ssr: false })` so the
|
|
110
110
|
> document render awaits the push; otherwise push from a handler (or use an
|
|
111
111
|
> `async` config, which stays reactive).
|
|
112
112
|
|
|
@@ -151,7 +151,7 @@ cache({ ttl: 600, tags: ["products"] }, () => [
|
|
|
151
151
|
fetchable/standalone loader calls that run outside a route render, in
|
|
152
152
|
handler-invoked loaders (a handler already awaiting the loader via
|
|
153
153
|
`ctx.use()` is a detected deadlock), and in loaders registered with
|
|
154
|
-
`{
|
|
154
|
+
`{ ssr: false }` (the document render awaits the loader before
|
|
155
155
|
the barrier — a cycle by construction; see `/loader`).
|
|
156
156
|
- **The reading loader serializes after the shell.** `await ctx.rendered()`
|
|
157
157
|
deliberately gives up loader/render parallelism — on a miss the loader
|
|
@@ -167,17 +167,28 @@ export interface LinkProps extends Omit<
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
|
-
* Check if URL is external (different origin)
|
|
170
|
+
* Check if URL is external (different origin). `origin` is resolved by the
|
|
171
|
+
* component from the navigation store location so SSR and browser agree —
|
|
172
|
+
* reading window here made every absolute-URL Link a hydration mismatch:
|
|
173
|
+
* on the server the ReferenceError was swallowed by the malformed-URL catch
|
|
174
|
+
* and the SSR HTML never carried data-external, then the browser evaluated
|
|
175
|
+
* the same link as external.
|
|
171
176
|
*/
|
|
172
|
-
function isExternalUrl(href: string): boolean {
|
|
177
|
+
function isExternalUrl(href: string, origin: string | undefined): boolean {
|
|
173
178
|
// Protocol-relative URLs
|
|
174
179
|
if (href.startsWith("//")) return true;
|
|
175
180
|
|
|
176
181
|
// Absolute URLs
|
|
177
182
|
if (href.startsWith("http://") || href.startsWith("https://")) {
|
|
183
|
+
// No known origin (no provider and no window): treat as internal — the
|
|
184
|
+
// pre-fix behavior for that configuration, and stable across hydration
|
|
185
|
+
// because both sides resolve the same store origin when a provider
|
|
186
|
+
// exists (the real-app case).
|
|
187
|
+
if (!origin) return false;
|
|
178
188
|
try {
|
|
179
|
-
return new URL(href).origin !==
|
|
189
|
+
return new URL(href).origin !== origin;
|
|
180
190
|
} catch {
|
|
191
|
+
// Genuinely malformed absolute URL.
|
|
181
192
|
return false;
|
|
182
193
|
}
|
|
183
194
|
}
|
|
@@ -225,7 +236,19 @@ export const Link: ForwardRefExoticComponent<
|
|
|
225
236
|
ref,
|
|
226
237
|
) {
|
|
227
238
|
const ctx = useContext(NavigationStoreContext);
|
|
228
|
-
|
|
239
|
+
// Origin from the store location — the same both-sides source
|
|
240
|
+
// useSearchParams seeds from (SSR: the live request's URL; browser:
|
|
241
|
+
// window.location), so data-external agrees across hydration. Origin is
|
|
242
|
+
// immutable per document, so the inline getState() read cannot tear.
|
|
243
|
+
// window is the provider-less browser fallback (tests, portals outside
|
|
244
|
+
// the app root); provider-less SSR has no origin and keeps links internal.
|
|
245
|
+
const storeLocation = ctx?.eventController.getState().location as
|
|
246
|
+
| URL
|
|
247
|
+
| undefined;
|
|
248
|
+
const origin =
|
|
249
|
+
storeLocation?.origin ??
|
|
250
|
+
(typeof window !== "undefined" ? window.location.origin : undefined);
|
|
251
|
+
const isExternal = isExternalUrl(to, origin);
|
|
229
252
|
|
|
230
253
|
// Auto-prefix with basename for app-local paths.
|
|
231
254
|
// Skip if external, already prefixed, or not a root-relative path.
|
|
@@ -137,7 +137,7 @@ interface LoaderItem extends ItemBase {
|
|
|
137
137
|
readonly type: "loader";
|
|
138
138
|
readonly definition: LoaderDefinition<any, any>;
|
|
139
139
|
readonly revalidate: readonly ClientRevalidateFn[];
|
|
140
|
-
readonly
|
|
140
|
+
readonly ssr?: false;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
interface RevalidateItem extends ItemBase {
|
|
@@ -380,9 +380,14 @@ function createHelpers(): ClientUrlHelpers {
|
|
|
380
380
|
const options =
|
|
381
381
|
typeof optionsOrUse === "function" ? undefined : optionsOrUse;
|
|
382
382
|
const use = typeof optionsOrUse === "function" ? optionsOrUse : maybeUse;
|
|
383
|
-
if (options
|
|
383
|
+
if ((options as { stream?: unknown } | undefined)?.stream !== undefined) {
|
|
384
384
|
throw new Error(
|
|
385
|
-
|
|
385
|
+
"clientUrls() loader() stream was replaced: use loader(Def, { ssr: false }) — the same knob as loading(fallback, { ssr: false }) — to await the loader before first flush on document requests.",
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
if (options?.ssr !== undefined && typeof options.ssr !== "boolean") {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`clientUrls() loader() ssr must be a boolean (got ${JSON.stringify(options.ssr)}). Omit it (or pass true) to stream on every render.`,
|
|
386
391
|
);
|
|
387
392
|
}
|
|
388
393
|
const items = use ? runUse(use, "loader use") : [];
|
|
@@ -397,9 +402,7 @@ function createHelpers(): ClientUrlHelpers {
|
|
|
397
402
|
item.type === "revalidate",
|
|
398
403
|
)
|
|
399
404
|
.map((item) => item.fn),
|
|
400
|
-
...(options?.
|
|
401
|
-
? { stream: "navigation" as const }
|
|
402
|
-
: {}),
|
|
405
|
+
...(options?.ssr === false ? { ssr: false as const } : {}),
|
|
403
406
|
});
|
|
404
407
|
};
|
|
405
408
|
|
|
@@ -505,9 +508,9 @@ function createHelpers(): ClientUrlHelpers {
|
|
|
505
508
|
for (const item of items) {
|
|
506
509
|
// Intercept loaders run on soft navigations only — a document-render
|
|
507
510
|
// await can never apply, so accepting the flag would be silently inert.
|
|
508
|
-
if (item.type === "loader" && item.
|
|
511
|
+
if (item.type === "loader" && item.ssr !== undefined) {
|
|
509
512
|
throw new Error(
|
|
510
|
-
|
|
513
|
+
"clientUrls() intercept() loaders cannot use ssr: false — intercepts render on client navigations only",
|
|
511
514
|
);
|
|
512
515
|
}
|
|
513
516
|
}
|
|
@@ -557,7 +560,7 @@ function applyConfig(
|
|
|
557
560
|
Object.freeze({
|
|
558
561
|
loader: item.definition,
|
|
559
562
|
revalidate: Object.freeze([...item.revalidate]),
|
|
560
|
-
...(item.
|
|
563
|
+
...(item.ssr === false ? { ssr: false as const } : {}),
|
|
561
564
|
}),
|
|
562
565
|
);
|
|
563
566
|
} else if (item.type === "loading") {
|
|
@@ -89,10 +89,10 @@ export interface ClientUrlProjectionRoute {
|
|
|
89
89
|
readonly options: ClientUrlProjectionOptions;
|
|
90
90
|
readonly loaderIds: readonly string[];
|
|
91
91
|
readonly hasLoading: boolean;
|
|
92
|
-
/** Indices into loaderIds of loaders declared loader(Def, {
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
92
|
+
/** Indices into loaderIds of loaders declared loader(Def, { ssr: false });
|
|
93
|
+
* materialization passes the option through to the server loader() so
|
|
94
|
+
* document renders await them before first flush. Absent (= none) in
|
|
95
|
+
* projections serialized before the option existed. */
|
|
96
96
|
readonly awaitedLoaderIndices?: readonly number[];
|
|
97
97
|
/** Data-only transition config (no `when` — server-tree only); absent in
|
|
98
98
|
* projections serialized before transition support. */
|
|
@@ -274,7 +274,7 @@ function serializeRoute(route: ClientUrlRouteRecord): ClientUrlProjectionRoute {
|
|
|
274
274
|
});
|
|
275
275
|
|
|
276
276
|
const awaitedLoaderIndices = route.loaders
|
|
277
|
-
.map(({
|
|
277
|
+
.map(({ ssr }, index) => (ssr === false ? index : -1))
|
|
278
278
|
.filter((index) => index >= 0);
|
|
279
279
|
|
|
280
280
|
const transition = serializeTransition(route);
|
|
@@ -526,7 +526,7 @@ function materializeRouteItems(
|
|
|
526
526
|
loader(
|
|
527
527
|
createLoaderStub(id),
|
|
528
528
|
route.awaitedLoaderIndices?.includes(loaderIndex)
|
|
529
|
-
? {
|
|
529
|
+
? { ssr: false }
|
|
530
530
|
: undefined,
|
|
531
531
|
() => [revalidate(makeClientDecisionRevalidate(id))],
|
|
532
532
|
),
|
package/src/client-urls/types.ts
CHANGED
|
@@ -87,12 +87,12 @@ export interface ClientUrlLoaderRecord {
|
|
|
87
87
|
/** Client-run per-loader revalidation predicates; empty = locked defaults. */
|
|
88
88
|
readonly revalidate: readonly ClientRevalidateFn[];
|
|
89
89
|
/**
|
|
90
|
-
* loader(Def, {
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
90
|
+
* loader(Def, { ssr: false }): document renders await this loader before
|
|
91
|
+
* first flush (see {@link LoaderOptions}). Projected into the server tree,
|
|
92
|
+
* where the per-isSSR entry stamping applies — client navigations stream
|
|
93
|
+
* regardless.
|
|
94
94
|
*/
|
|
95
|
-
readonly
|
|
95
|
+
readonly ssr?: false;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
/**
|
|
@@ -141,13 +141,14 @@ export interface ClientUrlHelpers {
|
|
|
141
141
|
* revalidate() only — a CLIENT-RUN per-loader predicate; its decision (not
|
|
142
142
|
* the function) is sent with the revalidation request.
|
|
143
143
|
*
|
|
144
|
-
* Pass `{
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* live
|
|
144
|
+
* Pass `{ ssr: false }` — the same knob as loading(fallback, { ssr:
|
|
145
|
+
* false }) — to await this loader before first flush on DOCUMENT requests
|
|
146
|
+
* (see {@link LoaderOptions}): the opt-in for loaders whose data, handle
|
|
147
|
+
* pushes, or thrown notFound()/redirect() must be in the SSR'd HTML.
|
|
148
|
+
* Per-loader: a streaming sibling keeps streaming. Under a `ppr` group
|
|
149
|
+
* route the flag BAKES: the loader executes at shell capture and its
|
|
150
|
+
* settled return freezes into the shell (nested promises stay live
|
|
151
|
+
* holes).
|
|
151
152
|
*/
|
|
152
153
|
readonly loader: <TData>(
|
|
153
154
|
definition: LoaderDefinition<TData>,
|
|
@@ -807,8 +807,12 @@ const loader: RouteHelpers<any, any>["loader"] = (
|
|
|
807
807
|
"loader() received two use() callbacks. Pass loader(Def, options, use) or loader(Def, use).",
|
|
808
808
|
);
|
|
809
809
|
invariant(
|
|
810
|
-
optionsGiven
|
|
811
|
-
|
|
810
|
+
(optionsGiven as { stream?: unknown } | undefined)?.stream === undefined,
|
|
811
|
+
"loader() stream was replaced: use loader(Def, { ssr: false }) — the same knob as loading(fallback, { ssr: false }) — to await the loader before first flush on document requests.",
|
|
812
|
+
);
|
|
813
|
+
invariant(
|
|
814
|
+
optionsGiven?.ssr === undefined || typeof optionsGiven.ssr === "boolean",
|
|
815
|
+
`loader() ssr must be a boolean (got ${JSON.stringify(optionsGiven?.ssr)}). Omit it (or pass true) to stream on every render.`,
|
|
812
816
|
);
|
|
813
817
|
|
|
814
818
|
const name = `${ctx.namespace}.$${store.getNextIndex("loader")}`;
|
|
@@ -820,7 +824,7 @@ const loader: RouteHelpers<any, any>["loader"] = (
|
|
|
820
824
|
const loaderEntry: LoaderEntry = {
|
|
821
825
|
loader: loaderDef,
|
|
822
826
|
revalidate: [] as ShouldRevalidateFn<any, any>[],
|
|
823
|
-
...(optionsGiven?.
|
|
827
|
+
...(optionsGiven?.ssr === false && ctx.isSSR
|
|
824
828
|
? { awaitBeforeFlush: true as const }
|
|
825
829
|
: {}),
|
|
826
830
|
};
|
|
@@ -310,13 +310,14 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
310
310
|
* return <div>{data.name}</div>;
|
|
311
311
|
* }
|
|
312
312
|
* ```
|
|
313
|
-
* Pass `{
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
313
|
+
* Pass `{ ssr: false }` — the same knob as loading(fallback, { ssr:
|
|
314
|
+
* false }) — to await this loader before first flush on DOCUMENT requests
|
|
315
|
+
* (see {@link LoaderOptions}): the opt-in for loaders whose data, handle
|
|
316
|
+
* pushes, or thrown notFound()/redirect() must be in the SSR'd HTML.
|
|
317
|
+
* Per-loader: a streaming sibling in the same segment keeps streaming.
|
|
317
318
|
*
|
|
318
319
|
* ```typescript
|
|
319
|
-
* loader(ProductLoader, {
|
|
320
|
+
* loader(ProductLoader, { ssr: false }, () => [cache()]),
|
|
320
321
|
* loader(RecommendationsLoader), // still streams behind loading()
|
|
321
322
|
* ```
|
|
322
323
|
*
|
|
@@ -572,7 +572,7 @@ function createLoaderExecutor<TEnv>(
|
|
|
572
572
|
}
|
|
573
573
|
|
|
574
574
|
// awaitBeforeFlush cycle: segment resolution awaits this loader
|
|
575
|
-
// (loader(Def, {
|
|
575
|
+
// (loader(Def, { ssr: false })), the barrier awaits segment
|
|
576
576
|
// resolution, and rendered() awaits the barrier — waiting here can
|
|
577
577
|
// never complete. Fail fast with the cause instead of hanging the
|
|
578
578
|
// document render. Only document renders populate the set (the flag is
|
|
@@ -581,10 +581,10 @@ function createLoaderExecutor<TEnv>(
|
|
|
581
581
|
if (reqCtx._awaitBeforeFlushLoaderIds?.has(currentLoaderId)) {
|
|
582
582
|
throw new Error(
|
|
583
583
|
`Deadlock: loader "${currentLoaderId}" is registered with ` +
|
|
584
|
-
`
|
|
584
|
+
`ssr: false, so the document render awaits it before ` +
|
|
585
585
|
`the render barrier resolves — ctx.rendered() (and the ` +
|
|
586
586
|
`ctx.get(handle) read it gates) can never settle here. Drop ` +
|
|
587
|
-
`
|
|
587
|
+
`ssr: false on this loader or move the handle read to ` +
|
|
588
588
|
`a component.`,
|
|
589
589
|
);
|
|
590
590
|
}
|
|
@@ -104,7 +104,7 @@ export async function resolveLoaders<TEnv>(
|
|
|
104
104
|
const errorContext = buildLoaderErrorContext(ctx);
|
|
105
105
|
|
|
106
106
|
if (emitStreaming) {
|
|
107
|
-
// awaitBeforeFlush (loader(Def, {
|
|
107
|
+
// awaitBeforeFlush (loader(Def, { ssr: false })): document
|
|
108
108
|
// renders await these loaders before returning, so their data is settled,
|
|
109
109
|
// their handle pushes beat the barrier snapshot, and a thrown notFound()'s
|
|
110
110
|
// status write deterministically precedes Response construction. The ids
|
|
@@ -148,7 +148,7 @@ export async function resolveLoaders<TEnv>(
|
|
|
148
148
|
loaderEntry,
|
|
149
149
|
ctx,
|
|
150
150
|
ctx.pathname,
|
|
151
|
-
// The bake key rides for flagged loaders too:
|
|
151
|
+
// The bake key rides for flagged loaders too: ssr:false
|
|
152
152
|
// bakes at capture regardless of the entry's loading() lane
|
|
153
153
|
// (loader-cache.ts capture branch) and its HIT-tail seed
|
|
154
154
|
// overlay needs the same key.
|
|
@@ -159,7 +159,7 @@ export function resolveLoaderData<TEnv>(
|
|
|
159
159
|
if (isShellCaptureActive(reqCtx)) {
|
|
160
160
|
// Capture lane, per LOADER (not per entry):
|
|
161
161
|
//
|
|
162
|
-
// - `
|
|
162
|
+
// - `ssr: false` (awaitBeforeFlush) — the BAKE lane. The flag's
|
|
163
163
|
// document promise is "this loader's data is in the HTML before first
|
|
164
164
|
// flush"; under ppr the pre-flush HTML IS the frozen prelude, so the
|
|
165
165
|
// loader executes at capture and its SETTLED return bakes into the
|
|
@@ -44,7 +44,7 @@ export { createMaskedLoaderPromise } from "./mask-nested.js";
|
|
|
44
44
|
/**
|
|
45
45
|
* Entry-level lane input for an entry's loaders under PPR (the loading()
|
|
46
46
|
* value; docs/design/loader-container-bake.md). The CAPTURE decision itself
|
|
47
|
-
* is per LOADER in loader-cache.ts:
|
|
47
|
+
* is per LOADER in loader-cache.ts: an `ssr: false`
|
|
48
48
|
* (awaitBeforeFlush) loader BAKES at capture regardless of this value — the
|
|
49
49
|
* flag's document promise ("data in the HTML before first flush") maps to
|
|
50
50
|
* the frozen prelude — while every other loader is LIVE (masked at capture,
|