@rangojs/router 0.0.0-experimental.132 → 0.0.0-experimental.133
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/AGENTS.md +8 -0
- package/README.md +43 -2
- package/dist/bin/rango.js +92 -16
- package/dist/vite/index.js +166 -70
- package/package.json +19 -18
- package/skills/breadcrumbs/SKILL.md +1 -1
- package/skills/bundle-analysis/SKILL.md +2 -2
- package/skills/cache-guide/SKILL.md +2 -2
- package/skills/caching/SKILL.md +16 -9
- package/skills/debug-manifest/SKILL.md +4 -2
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/handler-use/SKILL.md +1 -1
- package/skills/hooks/SKILL.md +2 -2
- package/skills/host-router/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +1 -1
- package/skills/loader/SKILL.md +2 -0
- package/skills/migrate-react-router/SKILL.md +4 -2
- package/skills/mime-routes/SKILL.md +1 -1
- package/skills/prerender/SKILL.md +2 -0
- package/skills/rango/SKILL.md +12 -11
- package/skills/response-routes/SKILL.md +2 -2
- package/skills/route/SKILL.md +4 -0
- package/skills/router-setup/SKILL.md +3 -0
- package/skills/scripts/SKILL.md +179 -0
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing/bindings.md +20 -6
- package/skills/testing/cache-prerender.md +5 -2
- package/skills/testing/client-components.md +2 -0
- package/skills/testing/e2e-parity.md +1 -1
- package/skills/testing/flight.md +8 -9
- package/skills/testing/render-handler.md +1 -1
- package/skills/testing/response-routes.md +1 -1
- package/skills/testing/server-actions.md +11 -11
- package/skills/testing/setup.md +3 -0
- package/skills/typesafety/SKILL.md +3 -2
- package/skills/use-cache/SKILL.md +10 -9
- package/src/browser/event-controller.ts +109 -2
- package/src/browser/partial-update.ts +12 -0
- package/src/browser/prefetch/cache.ts +17 -0
- package/src/browser/prefetch/fetch.ts +69 -2
- package/src/browser/react/Link.tsx +30 -5
- package/src/browser/react/NavigationProvider.tsx +12 -2
- package/src/browser/react/location-state-shared.ts +14 -2
- package/src/browser/react/use-href.tsx +8 -1
- package/src/browser/react/use-link-status.ts +23 -2
- package/src/browser/response-adapter.ts +14 -3
- package/src/browser/rsc-router.tsx +3 -0
- package/src/browser/scroll-restoration.ts +8 -3
- package/src/browser/server-action-bridge.ts +46 -11
- package/src/browser/types.ts +6 -0
- package/src/build/generate-route-types.ts +0 -1
- package/src/build/route-trie.ts +33 -9
- package/src/build/route-types/include-resolution.ts +7 -1
- package/src/build/route-types/router-processing.ts +0 -6
- package/src/build/route-types/source-scan.ts +105 -7
- package/src/cache/cache-policy.ts +42 -8
- package/src/cache/cache-runtime.ts +65 -5
- package/src/cache/cache-scope.ts +71 -11
- package/src/cache/cache-tag.ts +7 -2
- package/src/cache/cf/cf-base64.ts +33 -0
- package/src/cache/cf/cf-cache-constants.ts +127 -0
- package/src/cache/cf/cf-cache-store.ts +85 -613
- package/src/cache/cf/cf-cache-types.ts +349 -0
- package/src/cache/cf/cf-kv-utils.ts +46 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
- package/src/cache/document-cache.ts +11 -0
- package/src/cache/handle-snapshot.ts +8 -1
- package/src/cache/profile-registry.ts +25 -1
- package/src/cache/segment-codec.ts +9 -1
- package/src/cache/types.ts +4 -0
- package/src/client.rsc.tsx +38 -0
- package/src/client.tsx +11 -0
- package/src/components/DefaultDocument.tsx +8 -2
- package/src/context-var.ts +1 -1
- package/src/decode-loader-results.ts +7 -1
- package/src/escape-script.ts +52 -0
- package/src/handles/MetaTags.tsx +56 -5
- package/src/handles/Scripts.tsx +183 -0
- package/src/handles/breadcrumbs.ts +29 -11
- package/src/handles/is-thenable.ts +19 -0
- package/src/handles/meta.ts +46 -0
- package/src/handles/script.ts +244 -0
- package/src/host/cookie-handler.ts +7 -3
- package/src/host/pattern-matcher.ts +16 -2
- package/src/index.rsc.ts +5 -0
- package/src/index.ts +5 -0
- package/src/response-utils.ts +25 -0
- package/src/route-definition/dsl-helpers.ts +7 -0
- package/src/route-definition/redirect.ts +1 -2
- package/src/router/content-negotiation.ts +58 -10
- package/src/router/intercept-resolution.ts +9 -0
- package/src/router/match-middleware/cache-store.ts +10 -1
- package/src/router/middleware.ts +10 -3
- package/src/router/pattern-matching.ts +25 -23
- package/src/router/prefetch-cache-ttl.ts +51 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +23 -0
- package/src/router/segment-resolution/fresh.ts +10 -0
- package/src/router/segment-resolution/helpers.ts +35 -1
- package/src/router/segment-resolution/loader-cache.ts +10 -6
- package/src/router/segment-resolution/revalidation.ts +6 -0
- package/src/router/segment-resolution.ts +1 -0
- package/src/router/trie-matching.ts +14 -9
- package/src/router.ts +18 -10
- package/src/rsc/handler.ts +52 -13
- package/src/rsc/helpers.ts +7 -1
- package/src/rsc/index.ts +1 -4
- package/src/rsc/loader-fetch.ts +107 -37
- package/src/rsc/progressive-enhancement.ts +18 -6
- package/src/rsc/response-cache-serve.ts +238 -0
- package/src/rsc/response-route-handler.ts +16 -133
- package/src/rsc/rsc-rendering.ts +13 -4
- package/src/rsc/server-action.ts +52 -6
- package/src/rsc/types.ts +7 -0
- package/src/search-params.ts +24 -5
- package/src/segment-loader-promise.ts +17 -2
- package/src/server/loader-registry.ts +16 -18
- package/src/server/request-context.ts +47 -20
- package/src/testing/dispatch.ts +108 -25
- package/src/testing/flight.ts +25 -0
- package/src/testing/internal/context.ts +25 -2
- package/src/testing/render-handler.ts +3 -1
- package/src/testing/render-route.tsx +15 -0
- package/src/testing/run-loader.ts +10 -3
- package/src/theme/ThemeProvider.tsx +20 -6
- package/src/theme/ThemeScript.tsx +7 -3
- package/src/theme/constants.ts +54 -3
- package/src/theme/theme-script.ts +22 -7
- package/src/types/request-scope.ts +8 -3
- package/src/vite/plugins/cjs-to-esm.ts +8 -1
- package/src/vite/plugins/expose-id-utils.ts +10 -1
- package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
- package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
- package/src/vite/plugins/expose-internal-ids.ts +0 -1
- package/src/vite/plugins/version-plugin.ts +5 -17
- package/src/vite/plugins/virtual-entries.ts +12 -2
- package/src/vite/rango.ts +15 -6
- package/src/vite/utils/ast-handler-extract.ts +11 -4
- package/src/vite/utils/directive-prologue.ts +40 -0
- package/src/vite/utils/prerender-utils.ts +17 -2
package/src/client.tsx
CHANGED
|
@@ -340,6 +340,11 @@ export { useRouter } from "./browser/react/use-router.js";
|
|
|
340
340
|
export { usePathname } from "./browser/react/use-pathname.js";
|
|
341
341
|
export { useSearchParams } from "./browser/react/use-search-params.js";
|
|
342
342
|
export { useParams } from "./browser/react/use-params.js";
|
|
343
|
+
// CSP nonce for the active request, for userland components that inject their
|
|
344
|
+
// own <script>/<style> into the document head (analytics, GTM, inline init).
|
|
345
|
+
// Returns the nonce during SSR and undefined in the browser; render the tag
|
|
346
|
+
// server-side so the nonce lands in the SSR HTML and hydration stays clean.
|
|
347
|
+
export { useNonce } from "./browser/react/nonce-context.js";
|
|
343
348
|
export type {
|
|
344
349
|
RouterInstance,
|
|
345
350
|
RouterNavigateOptions,
|
|
@@ -391,6 +396,12 @@ export type { DeferredHandleEntry } from "./defer.js";
|
|
|
391
396
|
export { Meta } from "./handles/meta.js";
|
|
392
397
|
export { MetaTags } from "./handles/MetaTags.js";
|
|
393
398
|
export type { MetaDescriptor, MetaDescriptorBase } from "./router/types.js";
|
|
399
|
+
export {
|
|
400
|
+
Script,
|
|
401
|
+
type ScriptConfig,
|
|
402
|
+
type ScriptAttributes,
|
|
403
|
+
} from "./handles/script.js";
|
|
404
|
+
export { Scripts } from "./handles/Scripts.js";
|
|
394
405
|
export { Breadcrumbs, type BreadcrumbItem } from "./handles/breadcrumbs.js";
|
|
395
406
|
|
|
396
407
|
export {
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import type { ReactNode, ReactElement } from "react";
|
|
4
4
|
import { MetaTags } from "../handles/MetaTags.js";
|
|
5
|
+
import { Scripts } from "../handles/Scripts.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Default document component that provides a basic HTML structure.
|
|
8
9
|
* Used when no custom document is provided to createRouter.
|
|
9
|
-
* Includes MetaTags for automatic charset, viewport, and route meta support
|
|
10
|
+
* Includes MetaTags for automatic charset, viewport, and route meta support,
|
|
11
|
+
* and Scripts (head + body sites) so the Script handle works out of the box.
|
|
10
12
|
*
|
|
11
13
|
* Uses suppressHydrationWarning on <html> because the theme script
|
|
12
14
|
* may modify class/style attributes before React hydrates.
|
|
@@ -20,8 +22,12 @@ export function DefaultDocument({
|
|
|
20
22
|
<html lang="en" suppressHydrationWarning>
|
|
21
23
|
<head>
|
|
22
24
|
<MetaTags />
|
|
25
|
+
<Scripts />
|
|
23
26
|
</head>
|
|
24
|
-
<body>
|
|
27
|
+
<body>
|
|
28
|
+
<Scripts position="body" />
|
|
29
|
+
{children}
|
|
30
|
+
</body>
|
|
25
31
|
</html>
|
|
26
32
|
);
|
|
27
33
|
}
|
package/src/context-var.ts
CHANGED
|
@@ -88,7 +88,7 @@ export function hasContextVars(variables: object): boolean {
|
|
|
88
88
|
*/
|
|
89
89
|
const NON_CACHEABLE_KEYS: unique symbol = Symbol.for(
|
|
90
90
|
"rango:non-cacheable-keys",
|
|
91
|
-
)
|
|
91
|
+
);
|
|
92
92
|
|
|
93
93
|
function getNonCacheableKeys(variables: any): Set<string | symbol> {
|
|
94
94
|
if (!variables[NON_CACHEABLE_KEYS]) {
|
|
@@ -25,7 +25,13 @@ export function decodeLoaderResults(
|
|
|
25
25
|
continue;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// null/undefined is the producer's ONLY "no boundary found" sentinel
|
|
29
|
+
// (loader-resolution.ts sets fallback: null for the no-boundary and the
|
|
30
|
+
// fallback-render-threw cases). A matched boundary's rendered ReactNode can
|
|
31
|
+
// legitimately be falsy (0, "", false), so test for null explicitly rather
|
|
32
|
+
// than truthiness, otherwise a valid falsy fallback is discarded and the
|
|
33
|
+
// original loader error is rethrown.
|
|
34
|
+
if (result.fallback != null) {
|
|
29
35
|
errorFallback = result.fallback;
|
|
30
36
|
} else {
|
|
31
37
|
// No boundary: rethrow preserving the ErrorInfo identity (name/stack/
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape a JSON (or JSON-derived) string for safe embedding inside an HTML
|
|
3
|
+
* <script> element via dangerouslySetInnerHTML. Without this a value containing
|
|
4
|
+
* "</script>" closes the tag early — the rest of the page leaks as raw HTML, and
|
|
5
|
+
* in an executable script the trailing content runs. Escaping "<" defeats the
|
|
6
|
+
* early close; ">" and "&" are escaped for completeness so the serialized payload
|
|
7
|
+
* can never form HTML syntax. The result is still valid JSON and a valid JS
|
|
8
|
+
* string literal (\uXXXX escapes are legal in both) and re-parses identically.
|
|
9
|
+
*
|
|
10
|
+
* Used by every site that interpolates JSON.stringify(...) into inline <script>
|
|
11
|
+
* content: the JSON-LD meta descriptors (handles/MetaTags) and the FOUC theme
|
|
12
|
+
* init script (theme/theme-script).
|
|
13
|
+
*/
|
|
14
|
+
export function escapeJsonForScript(json: string): string {
|
|
15
|
+
return json
|
|
16
|
+
.replace(/</g, "\\u003c")
|
|
17
|
+
.replace(/>/g, "\\u003e")
|
|
18
|
+
.replace(/&/g, "\\u0026");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Escape an inline <script> body so it cannot terminate or corrupt the document.
|
|
23
|
+
* Two sequences are rewritten, each via a JS escape that is valid in string,
|
|
24
|
+
* template, regex (including the `u`/`v` flags), and JSON contexts — so the body
|
|
25
|
+
* still parses identically as code AND as JSON (application/json, ld+json):
|
|
26
|
+
* - "</script" -> "<\/script": stops a literal close tag inside the body from
|
|
27
|
+
* ending the element early. `\/` is a valid JSON escape and a valid regex escape.
|
|
28
|
+
* - "<!--": the "!" (U+0021) is emitted as a unicode escape (see the replacement
|
|
29
|
+
* string below), so the literal "<!--" token never reaches the HTML parser. A
|
|
30
|
+
* literal "<!--" puts the parser into the "script data escaped" state and a
|
|
31
|
+
* following "<script" into "script data DOUBLE escaped", where the real
|
|
32
|
+
* "</script>" no longer closes the element — `var x = "<!--<script>"` would
|
|
33
|
+
* swallow the rest of the document. The unicode-escape form decodes back to "!"
|
|
34
|
+
* in string/template/JSON/regex contexts, unlike "\!" (invalid JSON, invalid
|
|
35
|
+
* /u-regex escape).
|
|
36
|
+
* Real operators such as `a < b` and `a && b` are untouched (unlike
|
|
37
|
+
* escapeJsonForScript, which \u-escapes every "<", "&", ">").
|
|
38
|
+
*
|
|
39
|
+
* GUARANTEE / LIMITATION: value-preserving for the contexts where these sequences
|
|
40
|
+
* legitimately appear — string/template literals, regexes (incl. `u`/`v`), and
|
|
41
|
+
* JSON. It is NOT source-text-preserving (e.g. String.raw`</script>` sees the
|
|
42
|
+
* extra backslash), and it cannot rewrite "</script"/"<!--" that appear as bare
|
|
43
|
+
* code (a legacy `<!--` line comment, or `</script` outside any literal) — neither
|
|
44
|
+
* occurs in valid script payloads. Not a general sanitizer for arbitrary UNTRUSTED
|
|
45
|
+
* source; for untrusted dynamic data, JSON-encode it and read it back, rather than
|
|
46
|
+
* inlining it as code.
|
|
47
|
+
*
|
|
48
|
+
* Used by the Script handle's <Scripts> renderer for inline `children`.
|
|
49
|
+
*/
|
|
50
|
+
export function escapeScriptBody(js: string): string {
|
|
51
|
+
return js.replace(/<!--/g, "<\\u0021--").replace(/<\/(script)/gi, "<\\/$1");
|
|
52
|
+
}
|
package/src/handles/MetaTags.tsx
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* When theme is enabled in the router config, MetaTags also renders
|
|
10
10
|
* the theme initialization script to prevent FOUC (flash of unstyled content).
|
|
11
|
+
* This makes MetaTags the sole FOUC-script injector for apps that render it;
|
|
12
|
+
* the standalone `<ThemeScript />` is only needed when MetaTags is not used.
|
|
13
|
+
* Rendering both is safe (the inline script guards listener registration) but
|
|
14
|
+
* redundant.
|
|
11
15
|
*
|
|
12
16
|
* @example
|
|
13
17
|
* ```tsx
|
|
@@ -27,10 +31,12 @@
|
|
|
27
31
|
import { use } from "react";
|
|
28
32
|
import { useHandle } from "../browser/react/use-handle.js";
|
|
29
33
|
import { Meta } from "./meta.js";
|
|
34
|
+
import { isThenable } from "./is-thenable.js";
|
|
30
35
|
import type { MetaDescriptor, MetaDescriptorBase } from "../router/types.js";
|
|
31
36
|
import { useThemeContext } from "../theme/theme-context.js";
|
|
32
37
|
import { generateThemeScript } from "../theme/theme-script.js";
|
|
33
38
|
import { useNonce } from "../browser/react/nonce-context.js";
|
|
39
|
+
import { escapeJsonForScript } from "../escape-script.js";
|
|
34
40
|
|
|
35
41
|
// Type guards for MetaDescriptorBase variants
|
|
36
42
|
function hasCharSet(d: MetaDescriptorBase): d is { charSet: "utf-8" } {
|
|
@@ -91,10 +97,13 @@ function hasTagName(
|
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
/**
|
|
94
|
-
* Check if a value is a Promise.
|
|
100
|
+
* Check if a value is a Promise. Uses the shared thenable predicate (callable
|
|
101
|
+
* `then`) so collect (meta.ts) and render never disagree: an object carrying a
|
|
102
|
+
* non-callable `then` (e.g. `{ then: 5 }`) is a SYNC descriptor on both sides,
|
|
103
|
+
* not a Promise that would crash React's `use()`.
|
|
95
104
|
*/
|
|
96
105
|
function isPromise(value: unknown): value is Promise<unknown> {
|
|
97
|
-
return value
|
|
106
|
+
return isThenable(value);
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
function renderMetaDescriptor(
|
|
@@ -140,7 +149,9 @@ function renderMetaDescriptor(
|
|
|
140
149
|
}
|
|
141
150
|
|
|
142
151
|
if (hasScriptLdJson(descriptor)) {
|
|
143
|
-
const json =
|
|
152
|
+
const json = escapeJsonForScript(
|
|
153
|
+
JSON.stringify(descriptor["script:ld+json"]),
|
|
154
|
+
);
|
|
144
155
|
return (
|
|
145
156
|
<script
|
|
146
157
|
key={`ld-json-${index}`}
|
|
@@ -178,14 +189,54 @@ function renderMetaDescriptor(
|
|
|
178
189
|
);
|
|
179
190
|
}
|
|
180
191
|
|
|
181
|
-
|
|
192
|
+
// Sentinel a rejected async descriptor resolves to: renderMetaDescriptor sees
|
|
193
|
+
// no recognized fields and returns nothing renderable (see renderRejected).
|
|
194
|
+
const REJECTED_META: unique symbol = Symbol("rango.rejectedMeta");
|
|
195
|
+
|
|
196
|
+
// Cache the rejection-swallowing wrapper per source promise so use() gets a
|
|
197
|
+
// stable reference across re-renders (a fresh .then() each render would make
|
|
198
|
+
// React treat it as a new pending promise and never settle). WeakMap keys on
|
|
199
|
+
// the original promise so entries are collected with it.
|
|
200
|
+
const safeMetaPromises = new WeakMap<
|
|
201
|
+
Promise<MetaDescriptorBase>,
|
|
202
|
+
Promise<MetaDescriptorBase | typeof REJECTED_META>
|
|
203
|
+
>();
|
|
204
|
+
|
|
205
|
+
function toSafeMetaPromise(
|
|
206
|
+
promise: Promise<MetaDescriptorBase>,
|
|
207
|
+
): Promise<MetaDescriptorBase | typeof REJECTED_META> {
|
|
208
|
+
let safe = safeMetaPromises.get(promise);
|
|
209
|
+
if (!safe) {
|
|
210
|
+
// Swallow the rejection at the promise boundary, not via an error boundary:
|
|
211
|
+
// an error boundary above a suspended use() makes React abandon the whole
|
|
212
|
+
// Suspense subtree (and on the server switch it to client rendering). A
|
|
213
|
+
// settled-to-sentinel promise degrades the single bad descriptor to nothing
|
|
214
|
+
// while every sibling descriptor still renders.
|
|
215
|
+
//
|
|
216
|
+
// Normalize via Promise.resolve first: a collected async descriptor may be a
|
|
217
|
+
// non-native thenable (a React wakeable in SSR/RSC) whose .then() returns
|
|
218
|
+
// void rather than a Promise. Calling .then directly would leave `safe`
|
|
219
|
+
// undefined and use(undefined) would throw ("unsupported type passed to
|
|
220
|
+
// use()"), 500-ing the page. Promise.resolve adopts the thenable into a
|
|
221
|
+
// native Promise whose .then always returns one.
|
|
222
|
+
safe = Promise.resolve(promise).then(
|
|
223
|
+
(value) => value,
|
|
224
|
+
() => REJECTED_META,
|
|
225
|
+
);
|
|
226
|
+
safeMetaPromises.set(promise, safe);
|
|
227
|
+
}
|
|
228
|
+
return safe;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function AsyncMetaTag({
|
|
182
232
|
promise,
|
|
183
233
|
index,
|
|
184
234
|
}: {
|
|
185
235
|
promise: Promise<MetaDescriptorBase>;
|
|
186
236
|
index: number;
|
|
187
237
|
}): React.ReactNode {
|
|
188
|
-
const resolved = use(promise);
|
|
238
|
+
const resolved = use(toSafeMetaPromise(promise));
|
|
239
|
+
if (resolved === REJECTED_META) return null;
|
|
189
240
|
return renderMetaDescriptor(resolved, index);
|
|
190
241
|
}
|
|
191
242
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Renders the scripts collected by the Script handle into the document.
|
|
5
|
+
*
|
|
6
|
+
* Place `<Scripts />` inside `<head>` (default) and, if you push body scripts,
|
|
7
|
+
* `<Scripts position="body" />` at the top of `<body>`. Each site renders the
|
|
8
|
+
* configs whose `position` matches; the request CSP nonce is applied
|
|
9
|
+
* automatically to every DOCUMENT-RENDERED <script> (consumers never pass it). An
|
|
10
|
+
* async script first encountered on a soft navigation is injected client-side
|
|
11
|
+
* where the nonce is unavailable, so it carries no nonce and relies on
|
|
12
|
+
* 'strict-dynamic' (or a host allowance) — see the nonce caveat in the /scripts
|
|
13
|
+
* skill.
|
|
14
|
+
*
|
|
15
|
+
* EXECUTION CONTRACT — see the Script handle's docs. Inline + ordered (defer)
|
|
16
|
+
* scripts are document-load: they execute only when present in the initial HTML,
|
|
17
|
+
* so this component FREEZES that set after hydration (the initializer below runs
|
|
18
|
+
* once) — a later soft navigation never inserts an inert <script> (React creates
|
|
19
|
+
* client-mounted scripts via innerHTML, which the HTML spec makes non-executing).
|
|
20
|
+
* Async external scripts are React resources and stay reactive: React loads them
|
|
21
|
+
* on first encounter, including after navigation, deduped by src.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* <html>
|
|
26
|
+
* <head>
|
|
27
|
+
* <MetaTags />
|
|
28
|
+
* <Scripts />
|
|
29
|
+
* </head>
|
|
30
|
+
* <body>
|
|
31
|
+
* <Scripts position="body" />
|
|
32
|
+
* {children}
|
|
33
|
+
* </body>
|
|
34
|
+
* </html>
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { useState, type ReactNode } from "react";
|
|
39
|
+
import { useHandle } from "../browser/react/use-handle.js";
|
|
40
|
+
import { useNonce } from "../browser/react/nonce-context.js";
|
|
41
|
+
import { escapeScriptBody } from "../escape-script.js";
|
|
42
|
+
import { Script, type ScriptAttributes, type ScriptConfig } from "./script.js";
|
|
43
|
+
|
|
44
|
+
/** An external async script is a React-managed resource (reactive on nav). */
|
|
45
|
+
function isAsyncResource(config: ScriptConfig): boolean {
|
|
46
|
+
return config.src != null && config.async === true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Fields the Script handle owns (set via the ScriptConfig fields, applied as
|
|
50
|
+
// explicit props by renderScript) plus the inline-content props. Dropped from the
|
|
51
|
+
// attributes bag so untyped/serialized input cannot smuggle them in — e.g.
|
|
52
|
+
// `children`/`dangerouslySetInnerHTML` alongside an inline body makes React throw,
|
|
53
|
+
// or `src` on an inline script. The discriminated type already excludes these;
|
|
54
|
+
// this is the runtime guard.
|
|
55
|
+
const MANAGED_ATTRS = new Set([
|
|
56
|
+
"id",
|
|
57
|
+
"src",
|
|
58
|
+
"async",
|
|
59
|
+
"defer",
|
|
60
|
+
"type",
|
|
61
|
+
"children",
|
|
62
|
+
"nonce",
|
|
63
|
+
"dangerouslySetInnerHTML",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// Drop managed fields + any `on*` event handlers (a config serializes across the
|
|
67
|
+
// server -> client boundary, so a function cannot survive it) from the passthrough
|
|
68
|
+
// attributes, warning in dev.
|
|
69
|
+
function passthroughAttributes(
|
|
70
|
+
attributes: ScriptAttributes | undefined,
|
|
71
|
+
): Record<string, unknown> {
|
|
72
|
+
if (!attributes) return {};
|
|
73
|
+
const out: Record<string, unknown> = {};
|
|
74
|
+
const dev = process.env.NODE_ENV !== "production";
|
|
75
|
+
for (const [key, value] of Object.entries(
|
|
76
|
+
attributes as Record<string, unknown>,
|
|
77
|
+
)) {
|
|
78
|
+
const isHandler = key.startsWith("on");
|
|
79
|
+
if (isHandler || MANAGED_ATTRS.has(key)) {
|
|
80
|
+
if (dev) {
|
|
81
|
+
console.warn(
|
|
82
|
+
isHandler
|
|
83
|
+
? `[Scripts] event handler "${key}" in a script's attributes is ` +
|
|
84
|
+
`dropped; callbacks cannot cross the server -> client handle ` +
|
|
85
|
+
`boundary. Use a "use client" component for load/error handling.`
|
|
86
|
+
: `[Scripts] managed field "${key}" in a script's attributes is ` +
|
|
87
|
+
`dropped; set it via the ScriptConfig fields (the request nonce ` +
|
|
88
|
+
`is applied automatically).`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
out[key] = value;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function renderScript(
|
|
99
|
+
config: ScriptConfig,
|
|
100
|
+
nonce: string | undefined,
|
|
101
|
+
index: number,
|
|
102
|
+
): ReactNode {
|
|
103
|
+
const { id, src, children, async, defer, type, attributes } = config;
|
|
104
|
+
const key = id ?? src ?? `rango-script-${index}`;
|
|
105
|
+
const attrs = passthroughAttributes(attributes);
|
|
106
|
+
|
|
107
|
+
// Inline: rendered in place (never hoisted), escaped against </script> breakout.
|
|
108
|
+
// The server-only nonce makes the attribute differ from the (undefined) client
|
|
109
|
+
// value, so suppressHydrationWarning is required — the same sanctioned pattern
|
|
110
|
+
// as the theme/Meta inline scripts.
|
|
111
|
+
if (src == null) {
|
|
112
|
+
if (children == null) return null;
|
|
113
|
+
return (
|
|
114
|
+
<script
|
|
115
|
+
key={key}
|
|
116
|
+
{...attrs}
|
|
117
|
+
id={id}
|
|
118
|
+
type={type}
|
|
119
|
+
nonce={nonce}
|
|
120
|
+
suppressHydrationWarning
|
|
121
|
+
dangerouslySetInnerHTML={{ __html: escapeScriptBody(children) }}
|
|
122
|
+
/>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
process.env.NODE_ENV !== "production" &&
|
|
128
|
+
config.position === "body" &&
|
|
129
|
+
async
|
|
130
|
+
) {
|
|
131
|
+
console.warn(
|
|
132
|
+
`[Scripts] An async external script (src="${src}") is hoisted into ` +
|
|
133
|
+
`<head> by React; position: "body" is ignored for it.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// External: async => React-hoisted, src-deduped resource; otherwise in place
|
|
138
|
+
// (defer or plain), preserving authoring order.
|
|
139
|
+
return (
|
|
140
|
+
<script
|
|
141
|
+
key={key}
|
|
142
|
+
{...attrs}
|
|
143
|
+
id={id}
|
|
144
|
+
type={type}
|
|
145
|
+
src={src}
|
|
146
|
+
async={async}
|
|
147
|
+
defer={defer}
|
|
148
|
+
nonce={nonce}
|
|
149
|
+
suppressHydrationWarning
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function Scripts({
|
|
155
|
+
position = "head",
|
|
156
|
+
}: { position?: "head" | "body" } = {}): ReactNode {
|
|
157
|
+
const all = useHandle(Script) as ScriptConfig[];
|
|
158
|
+
const nonce = useNonce();
|
|
159
|
+
|
|
160
|
+
const forPosition = all.filter(
|
|
161
|
+
(config) => (config.position ?? "head") === position,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Document-load scripts (inline + ordered external) execute only from the
|
|
165
|
+
// initial HTML, so freeze them to the first-render set. The initializer runs
|
|
166
|
+
// during SSR and again at hydration with the same handle data, so the output
|
|
167
|
+
// matches; afterwards a navigation cannot add an inert <script>.
|
|
168
|
+
const [documentLoad] = useState(() =>
|
|
169
|
+
forPosition.filter((config) => !isAsyncResource(config)),
|
|
170
|
+
);
|
|
171
|
+
// Async external scripts are resources React loads on first encounter; keep
|
|
172
|
+
// them reactive so a script first reached via navigation still loads.
|
|
173
|
+
const asyncResources = forPosition.filter(isAsyncResource);
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<>
|
|
177
|
+
{documentLoad.map((config, index) => renderScript(config, nonce, index))}
|
|
178
|
+
{asyncResources.map((config, index) =>
|
|
179
|
+
renderScript(config, nonce, index),
|
|
180
|
+
)}
|
|
181
|
+
</>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Each layout/route pushes breadcrumb items via `ctx.use(Breadcrumbs)`.
|
|
5
5
|
* Items are collected in parent-to-child order with automatic deduplication
|
|
6
|
-
* by `href
|
|
6
|
+
* by `href`: each href keeps its FIRST position but takes the LAST value, so a
|
|
7
|
+
* child re-pushing a parent href refreshes the label without reordering the trail.
|
|
7
8
|
*
|
|
8
9
|
* @example
|
|
9
10
|
* ```tsx
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
|
|
23
24
|
import type { ReactNode } from "react";
|
|
24
25
|
import { createHandle, type Handle } from "../handle.js";
|
|
26
|
+
import { isThenable } from "./is-thenable.js";
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* A single breadcrumb item.
|
|
@@ -38,8 +40,10 @@ export interface BreadcrumbItem {
|
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
42
|
* Collect function for Breadcrumbs handle.
|
|
41
|
-
* Flattens segments in parent-to-child order with deduplication by href
|
|
42
|
-
*
|
|
43
|
+
* Flattens segments in parent-to-child order with deduplication by href: each
|
|
44
|
+
* href keeps its FIRST position but takes the LAST value (re-pushing a parent
|
|
45
|
+
* href refreshes the label in place without reordering the trail).
|
|
46
|
+
* Deferred slots (`ctx.use(Breadcrumbs).defer()`)
|
|
43
47
|
* arrive as pending Promise entries with no href yet; they are passed through by
|
|
44
48
|
* identity and excluded from the href dedup so concurrent deferred crumbs do not
|
|
45
49
|
* all collapse under a single `undefined` href.
|
|
@@ -50,18 +54,32 @@ function collectBreadcrumbs(segments: BreadcrumbItem[][]): BreadcrumbItem[] {
|
|
|
50
54
|
const isResolvedItem = (item: unknown): item is BreadcrumbItem =>
|
|
51
55
|
item != null &&
|
|
52
56
|
typeof item === "object" &&
|
|
53
|
-
|
|
57
|
+
!isThenable(item) &&
|
|
54
58
|
typeof (item as { href?: unknown }).href === "string";
|
|
55
59
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
// Dedup resolved crumbs by href: keep the FIRST position (preserving
|
|
61
|
+
// parent->child order) but the LAST value (a child re-pushing a parent's href
|
|
62
|
+
// can refresh its label). Deferred items bypass dedup entirely (they have no
|
|
63
|
+
// href yet) and are passed through by identity at their original position.
|
|
64
|
+
const valueByHref = new Map<string, BreadcrumbItem>();
|
|
65
|
+
for (const item of all) {
|
|
66
|
+
if (isResolvedItem(item)) valueByHref.set(item.href, item);
|
|
59
67
|
}
|
|
60
68
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
const result: BreadcrumbItem[] = [];
|
|
70
|
+
const emitted = new Set<string>();
|
|
71
|
+
for (const item of all) {
|
|
72
|
+
if (!isResolvedItem(item)) {
|
|
73
|
+
result.push(item);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
// Emit each href once, at its first occurrence, with the final value.
|
|
77
|
+
if (!emitted.has(item.href)) {
|
|
78
|
+
emitted.add(item.href);
|
|
79
|
+
result.push(valueByHref.get(item.href)!);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
65
83
|
}
|
|
66
84
|
|
|
67
85
|
/**
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single thenable predicate shared by the built-in handles that distinguish a
|
|
3
|
+
* synchronous descriptor/item from a deferred `Promise` one (Meta collect, the
|
|
4
|
+
* MetaTags render side, and Breadcrumbs).
|
|
5
|
+
*
|
|
6
|
+
* Requires a CALLABLE `then` (`typeof obj.then === "function"`), not merely a
|
|
7
|
+
* `"then" in obj` membership check. The two had drifted: a descriptor carrying a
|
|
8
|
+
* non-callable `then` (e.g. a serialized shape `{ then: 5 }`) was classified as
|
|
9
|
+
* synchronous by collect but as a Promise by render — so render would call
|
|
10
|
+
* React's `use()` on a non-thenable and throw. One owner keeps the collect and
|
|
11
|
+
* render sides from ever disagreeing.
|
|
12
|
+
*/
|
|
13
|
+
export function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
14
|
+
return (
|
|
15
|
+
value !== null &&
|
|
16
|
+
typeof value === "object" &&
|
|
17
|
+
typeof (value as { then?: unknown }).then === "function"
|
|
18
|
+
);
|
|
19
|
+
}
|
package/src/handles/meta.ts
CHANGED
|
@@ -29,12 +29,20 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { createHandle, type Handle } from "../handle.js";
|
|
32
|
+
import { isThenable } from "./is-thenable.js";
|
|
32
33
|
import type {
|
|
33
34
|
MetaDescriptor,
|
|
35
|
+
MetaDescriptorBase,
|
|
34
36
|
TitleDescriptor,
|
|
35
37
|
UnsetDescriptor,
|
|
36
38
|
} from "../router/types.js";
|
|
37
39
|
|
|
40
|
+
function isPromiseDescriptor(
|
|
41
|
+
descriptor: MetaDescriptor,
|
|
42
|
+
): descriptor is Promise<MetaDescriptorBase> {
|
|
43
|
+
return isThenable(descriptor);
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
function isUnsetDescriptor(
|
|
39
47
|
descriptor: MetaDescriptor,
|
|
40
48
|
): descriptor is UnsetDescriptor {
|
|
@@ -158,6 +166,37 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
|
|
|
158
166
|
|
|
159
167
|
for (const descriptors of segments) {
|
|
160
168
|
for (const descriptor of descriptors) {
|
|
169
|
+
// Promise descriptors cannot be inspected synchronously (their content is
|
|
170
|
+
// unknown until resolved in <MetaTags> via React's use()), so they bypass
|
|
171
|
+
// key-based dedup and title-templating: they are appended verbatim. Warn in
|
|
172
|
+
// dev when a title template is active so the author knows an async
|
|
173
|
+
// descriptor will NOT participate in the template/dedup.
|
|
174
|
+
//
|
|
175
|
+
// The warning is deliberately a GENERAL note, not a duplicate-<title>
|
|
176
|
+
// prediction: collectMeta cannot tell whether this Promise resolves to a
|
|
177
|
+
// title (which would indeed yield a 2nd <title>) or to an ordinary
|
|
178
|
+
// descriptor like an async og:image (which would not). Asserting a
|
|
179
|
+
// duplicate <title> here is a false positive for the common og:image case,
|
|
180
|
+
// so the message states only that async descriptors bypass templating —
|
|
181
|
+
// not that a duplicate <title> WILL occur.
|
|
182
|
+
if (isPromiseDescriptor(descriptor)) {
|
|
183
|
+
if (
|
|
184
|
+
titleTemplate !== undefined &&
|
|
185
|
+
process.env.NODE_ENV !== "production"
|
|
186
|
+
) {
|
|
187
|
+
console.warn(
|
|
188
|
+
`[Meta] A Promise meta descriptor was pushed while a title template is active. ` +
|
|
189
|
+
`Async descriptors bypass deduplication and title-templating: the template is ` +
|
|
190
|
+
`not applied to them. If this Promise resolves to a title, resolve the value ` +
|
|
191
|
+
`before pushing (or push a synchronous descriptor) so it participates in the ` +
|
|
192
|
+
`template; if it resolves to a non-title descriptor (e.g. og:image), this ` +
|
|
193
|
+
`note does not apply.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
result.push(descriptor);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
161
200
|
if (isUnsetDescriptor(descriptor)) {
|
|
162
201
|
const keyToRemove = descriptor.unset;
|
|
163
202
|
if (keyToIndex.has(keyToRemove)) {
|
|
@@ -222,6 +261,13 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
|
|
|
222
261
|
*
|
|
223
262
|
* Use `ctx.use(Meta)` in route handlers to push meta descriptors.
|
|
224
263
|
* Use `<MetaTags />` component to render them in the document head.
|
|
264
|
+
*
|
|
265
|
+
* Deduplication and title-templating apply only to SYNCHRONOUS descriptors.
|
|
266
|
+
* A Promise descriptor (`Promise<MetaDescriptorBase>`) is appended verbatim —
|
|
267
|
+
* its content is not known until it resolves in `<MetaTags>`, so it cannot be
|
|
268
|
+
* keyed for dedup nor receive a parent title template. If you need a child title
|
|
269
|
+
* to participate in a layout's `%s` template, push the resolved string title
|
|
270
|
+
* synchronously rather than a `Promise<{ title }>`.
|
|
225
271
|
*/
|
|
226
272
|
export const Meta: Handle<MetaDescriptor, MetaDescriptor[]> = createHandle<
|
|
227
273
|
MetaDescriptor,
|