@rangojs/router 0.30.1 → 0.32.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/bin/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/dist/bin/rango.js +7891 -197
- package/dist/types/browser/history-state.d.ts +13 -2
- package/dist/types/browser/react/Link.d.ts +10 -13
- package/dist/types/browser/react/location-state-shared.d.ts +2 -1
- package/dist/types/build/generate-route-types.d.ts +1 -1
- package/dist/types/build/route-types/include-resolution.d.ts +1 -1
- package/dist/types/build/route-types/router-processing.d.ts +3 -7
- package/dist/types/cache/document-cache-policy.d.ts +1 -1
- package/dist/types/route-definition/redirect.d.ts +5 -0
- package/dist/types/router/middleware-types.d.ts +6 -0
- package/dist/types/rsc/helpers.d.ts +5 -3
- package/dist/types/rsc/request-stages.d.ts +2 -0
- package/dist/types/server/document-location-state.d.ts +56 -0
- package/dist/types/server/request-context.d.ts +23 -6
- package/dist/types/testing/render-route.d.ts +17 -0
- package/dist/types/testing/serve-shell-request.d.ts +9 -0
- package/dist/types/types/handler-context.d.ts +18 -3
- package/dist/types/types/segments.d.ts +6 -0
- package/dist/types/vite/temp-rsc-server.d.ts +8 -4
- package/dist/types/vite/utils/forward-user-plugins.d.ts +33 -1
- package/dist/vite/index.js +121 -38
- package/package.json +2 -2
- package/skills/comparison/references/framework-comparison.md +3 -2
- package/skills/document-cache/SKILL.md +5 -0
- package/skills/hooks/state.md +110 -6
- package/skills/intercept/SKILL.md +2 -1
- package/skills/ppr/SKILL.md +12 -0
- package/skills/rango/SKILL.md +5 -5
- package/skills/server-actions/SKILL.md +8 -0
- package/skills/testing/client-components.md +44 -19
- package/skills/testing/serve-shell-request.md +38 -13
- package/skills/typesafety/generated-files-and-cli.md +2 -1
- package/skills/ux-architecture/SKILL.md +6 -3
- package/src/bin/rango.ts +13 -8
- package/src/browser/history-state.ts +24 -2
- package/src/browser/react/Link.tsx +10 -13
- package/src/browser/react/location-state-shared.ts +8 -1
- package/src/browser/rsc-router.tsx +5 -2
- package/src/build/generate-route-types.ts +0 -1
- package/src/build/route-types/include-resolution.ts +2 -1
- package/src/build/route-types/router-processing.ts +213 -49
- package/src/build/runtime-discovery.ts +128 -41
- package/src/cache/document-cache-policy.ts +10 -1
- package/src/cache/document-cache.ts +7 -0
- package/src/client-urls/server-projection.ts +3 -1
- package/src/route-definition/redirect.ts +5 -0
- package/src/router/match-middleware/cache-lookup.ts +23 -6
- package/src/router/match-result.ts +9 -1
- package/src/router/middleware-types.ts +6 -0
- package/src/router/revalidation.ts +23 -3
- package/src/rsc/helpers.ts +13 -4
- package/src/rsc/progressive-enhancement.ts +5 -0
- package/src/rsc/request-stages.ts +28 -1
- package/src/rsc/rsc-rendering.ts +39 -9
- package/src/server/document-location-state.ts +313 -0
- package/src/server/request-context.ts +24 -5
- package/src/testing/dispatch.ts +3 -0
- package/src/testing/render-route.tsx +40 -6
- package/src/testing/run-revalidate.ts +11 -1
- package/src/testing/serve-shell-request.ts +16 -0
- package/src/types/handler-context.ts +18 -3
- package/src/types/segments.ts +6 -0
- package/src/vite/temp-rsc-server.ts +9 -4
- package/src/vite/utils/forward-user-plugins.ts +15 -5
package/dist/vite/index.js
CHANGED
|
@@ -4841,6 +4841,11 @@ function getAppVersion() {
|
|
|
4841
4841
|
}
|
|
4842
4842
|
|
|
4843
4843
|
// src/router/basename.ts
|
|
4844
|
+
function normalizeBasename(basename2) {
|
|
4845
|
+
if (!basename2) return void 0;
|
|
4846
|
+
const trimmed = basename2.replace(/^\/+|\/+$/g, "");
|
|
4847
|
+
return trimmed ? "/" + trimmed : void 0;
|
|
4848
|
+
}
|
|
4844
4849
|
function prefixBasename(path6, basename2) {
|
|
4845
4850
|
if (!basename2 || !path6.startsWith("/") || path6 === basename2 || path6.startsWith(basename2 + "/") || path6.startsWith(basename2 + "?") || path6.startsWith(basename2 + "#")) {
|
|
4846
4851
|
return path6;
|
|
@@ -5988,7 +5993,7 @@ function serializeSearchSchema(route2, value) {
|
|
|
5988
5993
|
throw projectionError(route2, 'path option "search" must be an object');
|
|
5989
5994
|
}
|
|
5990
5995
|
const entries = [];
|
|
5991
|
-
for (const key of Object.keys(value)
|
|
5996
|
+
for (const key of Object.keys(value)) {
|
|
5992
5997
|
const schemaValue = value[key];
|
|
5993
5998
|
if (typeof schemaValue !== "string" || !SEARCH_SCHEMA_VALUES.has(schemaValue)) {
|
|
5994
5999
|
throw projectionError(
|
|
@@ -7141,7 +7146,7 @@ function resolveVersionsFrom(table, routerId2) {
|
|
|
7141
7146
|
// package.json
|
|
7142
7147
|
var package_default = {
|
|
7143
7148
|
name: "@rangojs/router",
|
|
7144
|
-
version: "0.
|
|
7149
|
+
version: "0.32.0",
|
|
7145
7150
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
7146
7151
|
keywords: [
|
|
7147
7152
|
"react",
|
|
@@ -7334,7 +7339,7 @@ var package_default = {
|
|
|
7334
7339
|
tag: "latest"
|
|
7335
7340
|
},
|
|
7336
7341
|
scripts: {
|
|
7337
|
-
build: "pnpm run build:types && pnpm exec esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
7342
|
+
build: "pnpm run build:types && pnpm exec esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js && mkdir -p dist/bin/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/bin/plugins/cloudflare-protocol-loader-hook.mjs",
|
|
7338
7343
|
"build:types": "rm -rf dist/types && pnpm exec tsc -p tsconfig.types.json",
|
|
7339
7344
|
prepublishOnly: "pnpm build",
|
|
7340
7345
|
typecheck: "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
|
|
@@ -9325,14 +9330,7 @@ function collectCreateRouterCallChain(endpoint) {
|
|
|
9325
9330
|
current = receiver;
|
|
9326
9331
|
}
|
|
9327
9332
|
}
|
|
9328
|
-
function
|
|
9329
|
-
const sourceFile = ts5.createSourceFile(
|
|
9330
|
-
"router.tsx",
|
|
9331
|
-
code,
|
|
9332
|
-
ts5.ScriptTarget.Latest,
|
|
9333
|
-
true,
|
|
9334
|
-
ts5.ScriptKind.TSX
|
|
9335
|
-
);
|
|
9333
|
+
function findRouterUrls(sourceFile) {
|
|
9336
9334
|
let result = null;
|
|
9337
9335
|
function visit(node) {
|
|
9338
9336
|
if (result) return;
|
|
@@ -9360,7 +9358,7 @@ function extractAllUrlsFromRouter(code) {
|
|
|
9360
9358
|
if (urls2) extracted.push(urls2);
|
|
9361
9359
|
}
|
|
9362
9360
|
if (extracted.length > 0) {
|
|
9363
|
-
result = extracted;
|
|
9361
|
+
result = { createRouterCall, extractions: extracted };
|
|
9364
9362
|
return;
|
|
9365
9363
|
}
|
|
9366
9364
|
}
|
|
@@ -9368,13 +9366,9 @@ function extractAllUrlsFromRouter(code) {
|
|
|
9368
9366
|
ts5.forEachChild(node, visit);
|
|
9369
9367
|
}
|
|
9370
9368
|
visit(sourceFile);
|
|
9371
|
-
return result
|
|
9372
|
-
}
|
|
9373
|
-
function extractUrlsFromRouter(code) {
|
|
9374
|
-
const extractions = extractAllUrlsFromRouter(code);
|
|
9375
|
-
return extractions[extractions.length - 1] ?? null;
|
|
9369
|
+
return result;
|
|
9376
9370
|
}
|
|
9377
|
-
function
|
|
9371
|
+
function extractAllUrlsFromRouter(code) {
|
|
9378
9372
|
const sourceFile = ts5.createSourceFile(
|
|
9379
9373
|
"router.tsx",
|
|
9380
9374
|
code,
|
|
@@ -9382,25 +9376,112 @@ function extractBasenameFromRouter(code) {
|
|
|
9382
9376
|
true,
|
|
9383
9377
|
ts5.ScriptKind.TSX
|
|
9384
9378
|
);
|
|
9385
|
-
|
|
9386
|
-
|
|
9387
|
-
|
|
9388
|
-
|
|
9389
|
-
|
|
9390
|
-
|
|
9391
|
-
|
|
9392
|
-
|
|
9393
|
-
|
|
9394
|
-
|
|
9395
|
-
|
|
9396
|
-
|
|
9379
|
+
return findRouterUrls(sourceFile)?.extractions ?? [];
|
|
9380
|
+
}
|
|
9381
|
+
function extractUrlsFromRouter(code) {
|
|
9382
|
+
const extractions = extractAllUrlsFromRouter(code);
|
|
9383
|
+
return extractions[extractions.length - 1] ?? null;
|
|
9384
|
+
}
|
|
9385
|
+
function unwrapTypeOperators(expr) {
|
|
9386
|
+
while (ts5.isAsExpression(expr) || ts5.isSatisfiesExpression(expr)) {
|
|
9387
|
+
expr = expr.expression;
|
|
9388
|
+
}
|
|
9389
|
+
return expr;
|
|
9390
|
+
}
|
|
9391
|
+
function hasModifier(statement, kind) {
|
|
9392
|
+
return ts5.canHaveModifiers(statement) && (ts5.getModifiers(statement)?.some((m) => m.kind === kind) ?? false);
|
|
9393
|
+
}
|
|
9394
|
+
function findTopLevelConst(sourceFile, name, exported) {
|
|
9395
|
+
for (const statement of sourceFile.statements) {
|
|
9396
|
+
if (ts5.isVariableStatement(statement) && statement.declarationList.flags & ts5.NodeFlags.Const && !hasModifier(statement, ts5.SyntaxKind.DeclareKeyword) && (!exported || hasModifier(statement, ts5.SyntaxKind.ExportKeyword))) {
|
|
9397
|
+
for (const decl of statement.declarationList.declarations) {
|
|
9398
|
+
if (ts5.isIdentifier(decl.name) && decl.name.text === name) {
|
|
9399
|
+
return decl.initializer && unwrapTypeOperators(decl.initializer);
|
|
9397
9400
|
}
|
|
9398
9401
|
}
|
|
9399
9402
|
}
|
|
9400
|
-
ts5.forEachChild(node, visit);
|
|
9401
9403
|
}
|
|
9402
|
-
|
|
9403
|
-
|
|
9404
|
+
return void 0;
|
|
9405
|
+
}
|
|
9406
|
+
function findNamedImport(sourceFile, name) {
|
|
9407
|
+
for (const statement of sourceFile.statements) {
|
|
9408
|
+
if (!ts5.isImportDeclaration(statement) || !ts5.isStringLiteral(statement.moduleSpecifier) || statement.importClause?.isTypeOnly) {
|
|
9409
|
+
continue;
|
|
9410
|
+
}
|
|
9411
|
+
const bindings = statement.importClause?.namedBindings;
|
|
9412
|
+
if (!bindings || !ts5.isNamedImports(bindings)) continue;
|
|
9413
|
+
for (const el of bindings.elements) {
|
|
9414
|
+
if (!el.isTypeOnly && el.name.text === name) {
|
|
9415
|
+
return {
|
|
9416
|
+
specifier: statement.moduleSpecifier.text,
|
|
9417
|
+
exportedName: el.propertyName?.text ?? name
|
|
9418
|
+
};
|
|
9419
|
+
}
|
|
9420
|
+
}
|
|
9421
|
+
}
|
|
9422
|
+
return void 0;
|
|
9423
|
+
}
|
|
9424
|
+
function resolveTopLevelConst(name, sourceFile, routerFilePath) {
|
|
9425
|
+
const local = findTopLevelConst(sourceFile, name, false);
|
|
9426
|
+
if (local) return local;
|
|
9427
|
+
const imported = findNamedImport(sourceFile, name);
|
|
9428
|
+
if (!imported) return void 0;
|
|
9429
|
+
const target = resolveImportPath(imported.specifier, routerFilePath);
|
|
9430
|
+
if (!target) return void 0;
|
|
9431
|
+
const targetSource = readFileSync4(target, "utf-8");
|
|
9432
|
+
return findTopLevelConst(
|
|
9433
|
+
ts5.createSourceFile(target, targetSource, ts5.ScriptTarget.Latest, true),
|
|
9434
|
+
imported.exportedName,
|
|
9435
|
+
true
|
|
9436
|
+
);
|
|
9437
|
+
}
|
|
9438
|
+
function isTopLevelScope(node) {
|
|
9439
|
+
for (let p = node.parent; !ts5.isSourceFile(p); p = p.parent) {
|
|
9440
|
+
if (ts5.isFunctionLike(p) || ts5.isClassLike(p) || ts5.isBlock(p) || ts5.isCaseBlock(p) || ts5.isModuleBlock(p) || ts5.isIterationStatement(p, false)) {
|
|
9441
|
+
return false;
|
|
9442
|
+
}
|
|
9443
|
+
}
|
|
9444
|
+
return true;
|
|
9445
|
+
}
|
|
9446
|
+
function resolveRouterBasename(routerSource, routerFilePath) {
|
|
9447
|
+
const sourceFile = ts5.createSourceFile(
|
|
9448
|
+
routerFilePath,
|
|
9449
|
+
routerSource,
|
|
9450
|
+
ts5.ScriptTarget.Latest,
|
|
9451
|
+
true,
|
|
9452
|
+
ts5.ScriptKind.TSX
|
|
9453
|
+
);
|
|
9454
|
+
const options = findRouterUrls(sourceFile)?.createRouterCall.arguments[0];
|
|
9455
|
+
const prop = options && ts5.isObjectLiteralExpression(options) ? options.properties.find(
|
|
9456
|
+
(p) => (ts5.isPropertyAssignment(p) || ts5.isShorthandPropertyAssignment(p)) && ts5.isIdentifier(p.name) && p.name.text === "basename"
|
|
9457
|
+
) : void 0;
|
|
9458
|
+
if (!prop) return {};
|
|
9459
|
+
const valueExpr = ts5.isPropertyAssignment(prop) ? prop.initializer : prop.name;
|
|
9460
|
+
const expr = unwrapTypeOperators(valueExpr);
|
|
9461
|
+
let value = getStringValue(expr);
|
|
9462
|
+
if (value === null && isTopLevelScope(prop)) {
|
|
9463
|
+
if (ts5.isIdentifier(expr)) {
|
|
9464
|
+
const init = resolveTopLevelConst(expr.text, sourceFile, routerFilePath);
|
|
9465
|
+
value = init ? getStringValue(init) : null;
|
|
9466
|
+
} else if (ts5.isPropertyAccessExpression(expr) && ts5.isIdentifier(expr.expression)) {
|
|
9467
|
+
const owner = resolveTopLevelConst(
|
|
9468
|
+
expr.expression.text,
|
|
9469
|
+
sourceFile,
|
|
9470
|
+
routerFilePath
|
|
9471
|
+
);
|
|
9472
|
+
value = owner ? resolveConstMember(owner, expr.name.text) : null;
|
|
9473
|
+
}
|
|
9474
|
+
}
|
|
9475
|
+
return value === null ? { unresolved: valueExpr.getText(sourceFile).replace(/\s+/g, " ") } : { value };
|
|
9476
|
+
}
|
|
9477
|
+
function resolveConstMember(owner, name) {
|
|
9478
|
+
if (!ts5.isObjectLiteralExpression(owner) || owner.properties.some(ts5.isSpreadAssignment)) {
|
|
9479
|
+
return null;
|
|
9480
|
+
}
|
|
9481
|
+
const member = owner.properties.find(
|
|
9482
|
+
(p) => ts5.isPropertyAssignment(p) && ts5.isIdentifier(p.name) && p.name.text === name
|
|
9483
|
+
);
|
|
9484
|
+
return member ? getStringValue(unwrapTypeOperators(member.initializer)) : null;
|
|
9404
9485
|
}
|
|
9405
9486
|
function applyBasenameToRoutes(result, basename2) {
|
|
9406
9487
|
const prefixed = {};
|
|
@@ -9498,8 +9579,9 @@ function buildCombinedRouteMapForRouterFile(routerFilePath) {
|
|
|
9498
9579
|
if (extractions.length === 0) {
|
|
9499
9580
|
return { routes: {}, searchSchemas: {} };
|
|
9500
9581
|
}
|
|
9501
|
-
const
|
|
9502
|
-
|
|
9582
|
+
const basename2 = normalizeBasename(
|
|
9583
|
+
resolveRouterBasename(routerSource, routerFilePath).value
|
|
9584
|
+
);
|
|
9503
9585
|
const result = { routes: {}, searchSchemas: {} };
|
|
9504
9586
|
const memo = createScanMemo();
|
|
9505
9587
|
for (const extraction of extractions) {
|
|
@@ -12693,7 +12775,8 @@ async function createTempRscServer(state, options = {}) {
|
|
|
12693
12775
|
// to resolveId/load (plus transform for import rewriters, see
|
|
12694
12776
|
// IMPORT_REWRITE_PLUGINS) and placed last so framework resolution runs first;
|
|
12695
12777
|
// Vite re-sorts by `enforce`, so `enforce: "pre"` resolvers still lead.
|
|
12696
|
-
...state.userResolvePlugins
|
|
12778
|
+
...state.userResolvePlugins,
|
|
12779
|
+
...options.plugins ?? []
|
|
12697
12780
|
]
|
|
12698
12781
|
});
|
|
12699
12782
|
}
|
|
@@ -14583,7 +14666,7 @@ function bundleScanPhase(s, context, bundle) {
|
|
|
14583
14666
|
}
|
|
14584
14667
|
|
|
14585
14668
|
// src/vite/utils/forward-user-plugins.ts
|
|
14586
|
-
function
|
|
14669
|
+
function isFrameworkPlugin(name) {
|
|
14587
14670
|
return name.startsWith("vite:") || name === "rsc" || name.startsWith("rsc:") || name.startsWith("@rangojs/router") || name.startsWith("@cloudflare/vite-plugin") || name.startsWith("vite-plugin-cloudflare");
|
|
14588
14671
|
}
|
|
14589
14672
|
var IMPORT_REWRITE_PLUGINS = /* @__PURE__ */ new Set([
|
|
@@ -14610,7 +14693,7 @@ function selectForwardableResolvePlugins(plugins) {
|
|
|
14610
14693
|
const forwarded = [];
|
|
14611
14694
|
for (const p of plugins) {
|
|
14612
14695
|
const name = p?.name;
|
|
14613
|
-
if (!name ||
|
|
14696
|
+
if (!name || isFrameworkPlugin(name)) continue;
|
|
14614
14697
|
if (!isForwardable(p)) continue;
|
|
14615
14698
|
forwarded.push(stripToResolutionHooks(p));
|
|
14616
14699
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rangojs/router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Django-inspired RSC router with composable URL patterns",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -259,7 +259,7 @@
|
|
|
259
259
|
"node": ">=24.0.0"
|
|
260
260
|
},
|
|
261
261
|
"scripts": {
|
|
262
|
-
"build": "pnpm run build:types && pnpm exec esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
262
|
+
"build": "pnpm run build:types && pnpm exec esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js && mkdir -p dist/bin/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/bin/plugins/cloudflare-protocol-loader-hook.mjs",
|
|
263
263
|
"build:types": "rm -rf dist/types && pnpm exec tsc -p tsconfig.types.json",
|
|
264
264
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
|
|
265
265
|
"test": "playwright test",
|
|
@@ -558,8 +558,9 @@ analog but operates on loader/query reload, not RSC-segment render.
|
|
|
558
558
|
|
|
559
559
|
`<Link prefetch="hover|viewport|render|adaptive|none">` (default: the router's
|
|
560
560
|
`defaultPrefetch`, `"none"` in development and `"viewport"` in production), plus
|
|
561
|
-
`prefetchKey` (`":source"` scopes a prefetch to the originating page
|
|
562
|
-
|
|
561
|
+
`prefetchKey` (`":source"` scopes a prefetch to the originating page; the
|
|
562
|
+
router does so itself for an intercept target and for a `revalidate()` that
|
|
563
|
+
read the source page and kept a segment). Intercepted plain `<a href>` elements
|
|
563
564
|
inside the router basename follow the same default; common static-resource
|
|
564
565
|
extensions are excluded unless `data-prefetch="true"` identifies an application
|
|
565
566
|
route, and `data-prefetch="false"` or `"none"` opts out unsafe GET links. A
|
|
@@ -228,6 +228,11 @@ suffixes are still appended.
|
|
|
228
228
|
(`rango-state-fresh` by default), so it is not stored. A stale entry's
|
|
229
229
|
background re-run sets no fresh-reads cookie (no visitor made that
|
|
230
230
|
invalidation), so its invalidation does not keep the refreshed page out.
|
|
231
|
+
- A document that carries location state (`ctx.setLocationState()`, or the
|
|
232
|
+
one-shot cookie a document redirect left for it): it is the visitor's, so it
|
|
233
|
+
is not stored, leaves `private, no-store`, and a request that arrives with
|
|
234
|
+
that cookie, or whose earlier middleware set state, is not answered from a
|
|
235
|
+
stored entry (see `/hooks` `state.md`, "State on a document response")
|
|
231
236
|
- Non-200 responses
|
|
232
237
|
- A 200 whose render reported an error: a component that threw after the
|
|
233
238
|
response started streaming, such as an async server component whose fetch
|
package/skills/hooks/state.md
CHANGED
|
@@ -101,6 +101,7 @@ Back/forward and state the server sets on a navigation
|
|
|
101
101
|
| `<Link state>`, `router.push()` / `router.replace()` | in the commit that shows the destination |
|
|
102
102
|
| back/forward | in the commit that restores the entry (cached or refetched) |
|
|
103
103
|
| the server, on a navigation (`setLocationState`, a redirect) | in the commit that shows the destination |
|
|
104
|
+
| the server, on a document load (a page load, a no-JS post) | once the page has hydrated, as stored state |
|
|
104
105
|
| a navigation the server answers with nothing to re-render | with the new location: the page on screen is the entry's |
|
|
105
106
|
| `router.push(url, { revalidate: false })` (no server fetch) | with the new location: the page does not change |
|
|
106
107
|
| a server action's `setLocationState` (no navigation) | when the action's response arrives, added to what readers show |
|
|
@@ -279,8 +280,8 @@ import { FlashMessage } from "../location-states";
|
|
|
279
280
|
```
|
|
280
281
|
|
|
281
282
|
Or via `ctx.setLocationState()` from a handler or middleware. It is delivered
|
|
282
|
-
with a client navigation's or an action's response
|
|
283
|
-
|
|
283
|
+
with a client navigation's or an action's response, and with a document
|
|
284
|
+
response too (see "State on a document response" below):
|
|
284
285
|
|
|
285
286
|
```tsx
|
|
286
287
|
(ctx) => {
|
|
@@ -289,6 +290,109 @@ carries no location state (there is no history entry to write it to yet):
|
|
|
289
290
|
};
|
|
290
291
|
```
|
|
291
292
|
|
|
293
|
+
### Several writes to one definition
|
|
294
|
+
|
|
295
|
+
Entries accumulate across definitions, but one definition is one slot in
|
|
296
|
+
`history.state`: for the same definition the last write wins. Two
|
|
297
|
+
`ctx.setLocationState(Toasts(...))` calls in one response, or
|
|
298
|
+
`redirect(url, { state: [Toasts(a), Toasts(b)] })`, deliver only the later
|
|
299
|
+
value, without a warning. A server action, a handler and the middleware
|
|
300
|
+
around them all write to the same response, so a shared helper that writes a
|
|
301
|
+
definition replaces what an earlier call wrote.
|
|
302
|
+
|
|
303
|
+
To combine several messages, make the state an array and write it once, for
|
|
304
|
+
example by collecting them in a request variable and writing the definition
|
|
305
|
+
at the end of the action:
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
export const Toasts = createLocationState<ToastMessage[]>({ flash: true });
|
|
309
|
+
|
|
310
|
+
// One write per response, with every toast in it.
|
|
311
|
+
getRequestContext().setLocationState(Toasts([...savedToasts, loginToast]));
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### State on a document response
|
|
315
|
+
|
|
316
|
+
A document request has no history entry yet, so the router carries what the
|
|
317
|
+
server set to the client and writes it there when the client starts. This
|
|
318
|
+
covers the mutations that most need a flash message: a `<form action={fn}>`
|
|
319
|
+
posted before hydration or without JS, a payment provider redirecting the
|
|
320
|
+
shopper back, a link from an email.
|
|
321
|
+
|
|
322
|
+
| The document request | Where the state travels |
|
|
323
|
+
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
324
|
+
| renders a page (`setLocationState` in a handler, middleware or no-JS action) | the document's bootstrap payload |
|
|
325
|
+
| redirects (`redirect(url, { state })`, or `setLocationState` and a redirect) | a one-shot cookie the target's document request reads and clears |
|
|
326
|
+
| redirects with `{ external: true }` or off-origin | nowhere: the cookie would not reach the target |
|
|
327
|
+
|
|
328
|
+
```tsx
|
|
329
|
+
// A payment provider sends the shopper back to /checkout/callback.
|
|
330
|
+
path("/checkout/callback", async (ctx) => {
|
|
331
|
+
const ok = await completeOrder(ctx.searchParams.get("token"));
|
|
332
|
+
return ok
|
|
333
|
+
? redirect("/orders/confirmed", { state: [Flash({ text: "Paid" })] })
|
|
334
|
+
: redirect("/checkout", { state: [Flash({ text: "Payment declined" })] });
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
- **When a reader gets it.** `useLocationState` returns `undefined` while the
|
|
339
|
+
document streams and hydrates, then the value, at the moment it would get
|
|
340
|
+
state stored in the entry. A flash message shows just after hydration.
|
|
341
|
+
- **Without JS, no flash.** The state rides to the client, and only the
|
|
342
|
+
client writes it into the entry and hands it to readers. A visitor with JS
|
|
343
|
+
disabled gets the page as the server rendered it, where every reader of the
|
|
344
|
+
state shows `undefined`: the post and its redirect work, but the message
|
|
345
|
+
never shows. If such visitors must see it, render it from the server, for
|
|
346
|
+
example from a search param or a cookie your handler reads.
|
|
347
|
+
- **Flash and refresh.** Flash state is cleared once read, as always. The
|
|
348
|
+
cookie is cleared by the response that read it, so a refresh of the target
|
|
349
|
+
renders without it. A page whose handler sets state on every request
|
|
350
|
+
delivers it again on a refresh, because the handler ran again. A no-JS post
|
|
351
|
+
answered with a page (no redirect) is re-posted by the browser on refresh;
|
|
352
|
+
redirect after the post (`redirect(url, { status: 303, state })`) to avoid
|
|
353
|
+
that.
|
|
354
|
+
- **Caching.** A document that carries state, and the redirect carrying it
|
|
355
|
+
on, are sent `Cache-Control: private, no-store`, after every middleware.
|
|
356
|
+
The document cache neither stores such a document nor answers a request
|
|
357
|
+
that arrives with the cookie from a stored one. A shared PPR shell never
|
|
358
|
+
holds state: on a `ppr` route, state set before the render (by middleware,
|
|
359
|
+
or the cookie) rides the live payload of a shell HIT or MISS, and the shell
|
|
360
|
+
is still served and captured; state a handler sets keeps that route from
|
|
361
|
+
being captured, so it renders in full. `no-store` also keeps the
|
|
362
|
+
page out of the browser's back/forward cache (Chrome's bfcache), so Back to
|
|
363
|
+
it loads it again. That costs little for a one-off flash; a handler that
|
|
364
|
+
sets state on every request pays it on every page view.
|
|
365
|
+
- **`ppr` and cached routes.** A shell HIT and a `cache()` hit run no
|
|
366
|
+
handler, so a handler there cannot set state on that request. Set it from
|
|
367
|
+
middleware, which runs on every request; the HIT still serves, with the
|
|
368
|
+
state in its live payload.
|
|
369
|
+
- **The cookie.** It is named after the router's state cookie
|
|
370
|
+
(`<stateCookieName>-ls`, `__Host-` prefixed on https), `HttpOnly`,
|
|
371
|
+
`SameSite=Lax`, `Path=/`, and lives 60 seconds. Only the next GET document
|
|
372
|
+
request of the redirect's target pathname that navigates reads it: where
|
|
373
|
+
the browser sends `Sec-Fetch-Dest`, it must be `document`, `iframe` or
|
|
374
|
+
`frame`, so a `<link rel=prefetch>` or a `fetch()` of that path leaves it
|
|
375
|
+
to the navigation. A form POST to it, a client navigation and a Flight
|
|
376
|
+
fetch leave it too, as does a request of another path. A
|
|
377
|
+
target that redirects again carries the state on to the next target. There
|
|
378
|
+
is one such cookie per router: two document redirects within a moment (two
|
|
379
|
+
tabs) keep only the later one's state. Without https there is no `__Host-`
|
|
380
|
+
prefix, so a sibling subdomain could set the cookie: treat state that
|
|
381
|
+
arrives this way as a message to show, not as a fact to act on.
|
|
382
|
+
- **Known limit: a redirect from one router to another.** The cookie is
|
|
383
|
+
named after the redirecting router's state cookie, which holds its router
|
|
384
|
+
id (`<stateCookiePrefix>_<routerId>`), and a router reads only its own. On
|
|
385
|
+
one origin served by several routers (a host router, or several
|
|
386
|
+
`createRouter()` apps behind one worker), a document redirect from one
|
|
387
|
+
router to a path another serves loses its state, without a warning. Carry
|
|
388
|
+
such a message another way, such as a search param.
|
|
389
|
+
- **JSON only across a redirect.** The cookie holds JSON: a `Date` arrives as
|
|
390
|
+
a string, a `Map` or `Set` as `{}`, `undefined` is dropped. Development
|
|
391
|
+
warns, naming the slot. A value JSON cannot encode (a `bigint`), or state
|
|
392
|
+
over the cookie's 4000-byte limit, is dropped from the redirect with a
|
|
393
|
+
`console.warn`. Carry an id and load the rest on the target page.
|
|
394
|
+
Navigation and action payloads are not limited this way.
|
|
395
|
+
|
|
292
396
|
### State from another deploy
|
|
293
397
|
|
|
294
398
|
History entries survive reloads and back/forward, so a tab left open across a
|
|
@@ -419,10 +523,10 @@ as the server rendered it:
|
|
|
419
523
|
where it sits (a `<Suspense>` boundary that hydrates late included), or
|
|
420
524
|
whether the definition's module is loaded at all. Other slots on the entry
|
|
421
525
|
are left alone.
|
|
422
|
-
- State the server sets is never dropped by this: a document
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
526
|
+
- State the server sets is never dropped by this: the state a document
|
|
527
|
+
response carries is written after the `~r` keys are removed, so a
|
|
528
|
+
`clearOnReload` slot the server set for this load is applied on this load
|
|
529
|
+
(and removed by the next one). Navigations and actions write after start-up.
|
|
426
530
|
- `.write()` stores under the same key, so it does not survive a refresh
|
|
427
531
|
either.
|
|
428
532
|
- Adding the option to an existing definition moves it to the `~r` key: state
|
|
@@ -316,7 +316,8 @@ or not the intercept applies from that page (`rsc-rendering.ts`
|
|
|
316
316
|
`x-rsc-prefetch-scope: source`, set from `MatchResult.interceptTargeted`). A
|
|
317
317
|
full page prefetched from a page where `when` is false never serves a click
|
|
318
318
|
from a page where it is true. Routes no intercept targets keep one shared
|
|
319
|
-
prefetch entry
|
|
319
|
+
prefetch entry, unless a `revalidate()` that read the source page kept a
|
|
320
|
+
segment (`MatchResult.sourceHeld`, same header).
|
|
320
321
|
|
|
321
322
|
```typescript
|
|
322
323
|
// Intercept only when opened from the shop index
|
package/skills/ppr/SKILL.md
CHANGED
|
@@ -500,6 +500,18 @@ Rango warns with the shell key and backs capture off instead of rendering the
|
|
|
500
500
|
same doomed generation on every request. Move a deterministic self-invalidation
|
|
501
501
|
out of render code if you want the shell to persist.
|
|
502
502
|
|
|
503
|
+
### Location state on a ppr route
|
|
504
|
+
|
|
505
|
+
A shared shell never holds location state: the capture builds its own
|
|
506
|
+
payload, without it. State set before the render (from middleware, or the
|
|
507
|
+
one-shot cookie a document redirect left for this request) rides the live
|
|
508
|
+
payload: a HIT serves the shell with the state in its tail's payload, a MISS
|
|
509
|
+
carries it and still captures. Either response leaves `private, no-store`.
|
|
510
|
+
State a handler sets skips the capture of that MISS, so that route renders in
|
|
511
|
+
full: a HIT runs no handler, so a handler's `ctx.setLocationState()` does not
|
|
512
|
+
happen on a HIT. Set it from middleware (see `/hooks` `state.md`, "State on a
|
|
513
|
+
document response").
|
|
514
|
+
|
|
503
515
|
### Opting out per request with `ctx.dynamic()`
|
|
504
516
|
|
|
505
517
|
Middleware and handlers can call `ctx.dynamic()` to force this request onto a
|
package/skills/rango/SKILL.md
CHANGED
|
@@ -445,11 +445,11 @@ npx rango generate src/urls.tsx src/api/
|
|
|
445
445
|
|
|
446
446
|
Three modes:
|
|
447
447
|
|
|
448
|
-
| Flag | Behavior
|
|
449
|
-
| ----------- |
|
|
450
|
-
| (none) | Static parser. Exits with an error, writing nothing, if an `include()` can't be resolved statically (factory or dynamic).
|
|
451
|
-
| `--static` | Static parser, but writes partial output and prints the
|
|
452
|
-
| `--runtime` | Vite-based runtime discovery (full coverage; needs `vite`; `@vitejs/plugin-rsc` comes with the router). `--config <path>` picks the config.
|
|
448
|
+
| Flag | Behavior |
|
|
449
|
+
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
450
|
+
| (none) | Static parser. Exits with an error, writing nothing, if an `include()` can't be resolved statically (factory or dynamic), or an explicit `createRouter` `basename` isn't a string literal or top-level const. |
|
|
451
|
+
| `--static` | Static parser, but writes partial output (an unreadable basename leaves patterns unprefixed) and prints the diagnostics as warnings. |
|
|
452
|
+
| `--runtime` | Vite-based runtime discovery (full coverage; needs `vite`; `@vitejs/plugin-rsc` comes with the router). `--config <path>` picks the config. |
|
|
453
453
|
|
|
454
454
|
### Auto-detection
|
|
455
455
|
|
|
@@ -645,6 +645,14 @@ Redirects from actions render the **target** route tree's matched segments
|
|
|
645
645
|
source page's — the target is what the user sees next. See `/hooks
|
|
646
646
|
useLocationState` for reading flash state on the target page.
|
|
647
647
|
|
|
648
|
+
The same holds for a form posted without JS (or before hydration): an action
|
|
649
|
+
that redirects with `state` carries it to the target in a one-shot cookie,
|
|
650
|
+
and one that sets it with `getRequestContext().setLocationState()` and does
|
|
651
|
+
not redirect carries it in the re-rendered page. Either way the target reads
|
|
652
|
+
it with `useLocationState` once it has hydrated. Prefer a `303` redirect, so a
|
|
653
|
+
refresh does not post the form again. State that crosses a no-JS redirect
|
|
654
|
+
travels as JSON (see `/hooks` `state.md`, "State on a document response").
|
|
655
|
+
|
|
648
656
|
Build the target by name rather than a literal path:
|
|
649
657
|
`throw redirect(getRequestContext().reverse("account.settings"))`. It fills
|
|
650
658
|
mount params (`:accountId`, `:locale`) from the page URL the action was called
|
|
@@ -8,25 +8,26 @@
|
|
|
8
8
|
|
|
9
9
|
### Options — `RenderRouteOptions`
|
|
10
10
|
|
|
11
|
-
| Field
|
|
12
|
-
|
|
|
13
|
-
| `request`
|
|
14
|
-
| `loaderData`
|
|
15
|
-
| `outletPending`
|
|
16
|
-
| `loaders`
|
|
17
|
-
| `params`
|
|
18
|
-
| `locationState`
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| ----------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
13
|
+
| `request` | `Request \| string` | Initial location. Only the URL is read (client render — headers/method ignored). Defaults to the leaf spec's static prefix or `"/"`. |
|
|
14
|
+
| `loaderData` | `Record<string, unknown>` | Loader data keyed by loader `$$id`. `useLoader(L)` reads `loaderData[L.$$id]`. |
|
|
15
|
+
| `outletPending` | `boolean` | Seed `useOutlet().pending` through each synthetic segment's production `OutletProvider`. Defaults to `false`; this is context seeding, not a simulated navigation/Suspense/action lifecycle. |
|
|
16
|
+
| `loaders` | `ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>` | Seed by REFERENCE: `[loader, data]` pairs. Robust for real `createLoader()` handles whose `$$id` is empty in a bare test. Prefer over `loaderData`. |
|
|
17
|
+
| `params` | `Record<string, string>` | Explicit params, merged over (and overriding) params extracted from the `request` URL. |
|
|
18
|
+
| `locationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | Seed `useLocationState(def)` by REFERENCE: `[def, value]` pairs; keys an unkeyed `def` (like `withLocationStateKey(def)`) and writes `value` to `history.state` under `def.__rsc_ls_key`. See [Location state](#location-state). |
|
|
19
|
+
| `documentLocationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | The location state the document response carried (a handler, middleware or no-JS form action set it, or a document redirect's cookie brought it), as `[def, value]` pairs. Merged at start-up after `locationState` and the `clearOnReload` pass. See [State a document carried](#state-a-document-carried). |
|
|
20
|
+
| `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown[]]>` | Seed `useHandle(handle)` by REFERENCE: `[handle, pushedValues[]]`. Accumulated GLOBALLY (not segment-scoped). |
|
|
21
|
+
| `lateHandles` | `ReadonlyArray<readonly [Handle<any, any>, unknown[]]>` | Handle values that arrive AFTER hydration, as a late loader push does: `[handle, valuesAfterTheUpdate[]]` replaces that handle's `handles` seed once the root has hydrated (or mounted). See [Hydration](#hydration). |
|
|
22
|
+
| `handle` | `HandleDataSeed` | Advanced: raw wire format `{ [handleId]: { [segmentId]: pushedValues[] } }`. Prefer `handles`. Merged with it. |
|
|
23
|
+
| `routeMap` | `Record<string, string>` | Name -> pattern map (informational; client `useReverse` takes its map as an argument, so this is not consumed). |
|
|
24
|
+
| `basename` | `string` | `createRouter({ basename })` value. Wired into `NavigationProvider` so `useRouter().basename`, `<Link>` prefixing, `useMount`/`useHref` resolve against the mount. Normalized like `createRouter`. Defaults to root. |
|
|
25
|
+
| `mount` | `string` | `include()` mount prefix. Wraps the segment chain in a `MountContext` so `useMount()` returns the prefix. Normalized like a path prefix. Defaults to `"/"`. |
|
|
26
|
+
| `theme` | `ThemeConfig \| true` | Theme config (`createRouter({ theme })` shape) to wrap the tree in a `ThemeProvider`. Defaults to no provider. A component calling `useTheme()` REQUIRES one. |
|
|
27
|
+
| `nonce` | `string` | CSP nonce to seed via `NonceContext`, so a component calling `useNonce()` (e.g. an analytics/GTM head script) sees it — mirroring SSR. Defaults to `undefined` (the browser default). |
|
|
28
|
+
| `defaultPrefetch` | `PrefetchStrategy` | Router default for `<Link>` and eligible plain anchors. `data-prefetch="false"`/`"none"` opts out one anchor; ancestor `data-prefetch-scope="false"`/`"none"` hard-disables the subtree; `"true"` permits routed resource suffixes elsewhere. |
|
|
29
|
+
| `hydrate` | `boolean` | Render the same tree to HTML first (`renderToString`, no `window`/`document`), then hydrate that HTML instead of mounting fresh. The result gains `serverHtml`, `recoverableErrors` and `shellError`. See [Hydration](#hydration). |
|
|
30
|
+
| `rewrite` | `string` | The path middleware rewrote `request` to (`throw rewrite()`): the leaf is matched against it for params, while the location (`usePathname`, `useSearchParams`) stays `request`, as a rewritten document renders and hydrates. |
|
|
30
31
|
|
|
31
32
|
`RenderRouteSpec = { path, Component, layout?, loaderIds?, name?, transition? }` — one node of the route definition. The array is the layout chain root-to-leaf; the LAST entry is the leaf route (its pattern is matched against `request` to extract params; layout patterns are informational). `loaderIds` attaches seeded loaders to THIS node's segment; `layout` on the leaf wraps it; `name` is informational. `transition` is the `transition()` config this node declares (`{}` for a bare `transition()`), attached to its segment exactly as the DSL does — see [Held navigation](#held-navigation).
|
|
32
33
|
|
|
@@ -265,6 +266,30 @@ it("drops them on a refresh and carries again on the next navigation", async ()
|
|
|
265
266
|
});
|
|
266
267
|
```
|
|
267
268
|
|
|
269
|
+
### State a document carried
|
|
270
|
+
|
|
271
|
+
A page load, a no-JS form post that renders a page, and the target of a document redirect carry the location state the server set for them (see `/hooks`, state.md, "State on a document response"). Start-up merges it into the entry after the `clearOnReload` pass, so a reader gets it once the root has hydrated, `clearOnReload` slots included. Seed it with `documentLocationState`, not `locationState`: `locationState` is the entry's stored state, which `hydrate: true` passes through the `clearOnReload` pass first.
|
|
272
|
+
|
|
273
|
+
| To test | Do |
|
|
274
|
+
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
275
|
+
| a flash a document carried, as the page hydrates | `renderRoute(routes, { hydrate: true, documentLocationState })` |
|
|
276
|
+
| a document's `clearOnReload` state replacing a stale entry | `hydrate: true` with the stale value in `locationState`, the new one in `documentLocationState` |
|
|
277
|
+
| what the server put in the document | `serveShellRequest(router, url).readLocationState()` (see [`./serve-shell-request.md`](./serve-shell-request.md)) |
|
|
278
|
+
|
|
279
|
+
```tsx
|
|
280
|
+
it("shows the flash the declined-payment redirect carried", async () => {
|
|
281
|
+
const { serverHtml, getByRole } = await renderRoute(
|
|
282
|
+
[{ path: "/checkout", Component: FlashBanner }],
|
|
283
|
+
{
|
|
284
|
+
hydrate: true,
|
|
285
|
+
documentLocationState: [[FlashMessage, { text: "Payment declined" }]],
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
expect(serverHtml).not.toContain("Payment declined"); // useLocationState is undefined until hydration
|
|
289
|
+
expect(getByRole("alert")).toHaveTextContent("Payment declined");
|
|
290
|
+
});
|
|
291
|
+
```
|
|
292
|
+
|
|
268
293
|
## Hydration
|
|
269
294
|
|
|
270
295
|
A plain `renderRoute` mounts with `createRoot`, so a component never runs its hydration render: `useSyncExternalStore` skips `getServerSnapshot`, and nothing compares the first client render with server HTML. `hydrate: true` runs the document-load sequence instead. renderRoute renders the same element (same providers, seeds, and RTL `reactStrictMode`) to HTML with `react-dom/server`'s `renderToString`, puts that HTML in the container, and hydrates it through RTL (`render(ui, { hydrate: true, onRecoverableError })`), so `cleanup()` unmounts it like any other render. `serverHtml` is what the server pass produced; `recoverableErrors` holds the message of every error React recovered from, which is where a hydration mismatch arrives.
|