@rangojs/router 0.19.0 → 0.20.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/cache/cache-exec-scope.d.ts +6 -0
- package/dist/types/cache/cache-tag.d.ts +24 -1
- package/dist/types/server/context.d.ts +38 -1
- package/dist/types/ssr/entry-preload-priority.d.ts +69 -0
- package/dist/types/ssr/index.d.ts +1 -0
- package/dist/types/ssr/preinit-client-references.d.ts +17 -5
- package/dist/types/vite/plugin-types.d.ts +20 -6
- package/dist/types/vite/plugins/virtual-entries.d.ts +3 -1
- package/dist/types/vite/utils/client-chunks.d.ts +22 -2
- package/dist/vite/index.js +43 -10
- package/package.json +1 -1
- package/skills/bundle-analysis/SKILL.md +1 -1
- package/skills/cache-guide/SKILL.md +3 -3
- package/skills/caching/SKILL.md +4 -4
- package/skills/parallel/SKILL.md +1 -1
- package/skills/rango/SKILL.md +2 -2
- package/skills/router-setup/SKILL.md +8 -2
- package/skills/use-cache/SKILL.md +8 -5
- package/src/cache/cache-exec-scope.ts +6 -0
- package/src/cache/cache-runtime.ts +5 -0
- package/src/cache/cache-tag.ts +104 -0
- package/src/router/loader-resolution.ts +15 -3
- package/src/router/segment-resolution/loader-cache.ts +2 -1
- package/src/server/context.ts +75 -1
- package/src/server/request-context.ts +11 -3
- package/src/ssr/entry-preload-priority.ts +308 -0
- package/src/ssr/index.tsx +46 -9
- package/src/ssr/preinit-client-references.ts +17 -5
- package/src/vite/plugin-types.ts +20 -6
- package/src/vite/plugins/virtual-entries.ts +9 -2
- package/src/vite/rango.ts +2 -0
- package/src/vite/utils/client-chunks.ts +80 -14
package/src/cache/cache-tag.ts
CHANGED
|
@@ -17,11 +17,19 @@ import {
|
|
|
17
17
|
} from "../server/request-context.js";
|
|
18
18
|
import {
|
|
19
19
|
getCurrentLoaderBodyId,
|
|
20
|
+
getLoaderRunIdentity,
|
|
20
21
|
getLoaderBodyTags,
|
|
21
22
|
isInsideLoaderScope,
|
|
23
|
+
useCacheLoaderIdentityError,
|
|
22
24
|
type LoaderIdentityRead,
|
|
23
25
|
type LoaderIdentityReadVerb,
|
|
26
|
+
type LoaderRunIdentity,
|
|
24
27
|
} from "../server/context.js";
|
|
28
|
+
import {
|
|
29
|
+
getCacheExecScope,
|
|
30
|
+
isInsideIdentityExempt,
|
|
31
|
+
type CacheExecScope,
|
|
32
|
+
} from "./cache-exec-scope.js";
|
|
25
33
|
import type { ExecutionStart } from "./tag-invalidation.js";
|
|
26
34
|
|
|
27
35
|
const cacheTagStorage = new AsyncLocalStorage<Set<string>>();
|
|
@@ -481,6 +489,102 @@ export function recordedIdentityRead(
|
|
|
481
489
|
return undefined;
|
|
482
490
|
}
|
|
483
491
|
|
|
492
|
+
/**
|
|
493
|
+
* Loader value identity (#1011). Each loader execution's value promise maps to
|
|
494
|
+
* its LoaderRunIdentity (server/context.ts); a loader-cache binding's value
|
|
495
|
+
* carries its reads on its recorded-tag set instead (tagLoaderValue,
|
|
496
|
+
* markIdentityRead). On globalThis like identityReads: the executor that
|
|
497
|
+
* tracks a value and the reader that checks it must share the map.
|
|
498
|
+
*/
|
|
499
|
+
const loaderRuns: WeakMap<object, LoaderRunIdentity> = ((globalThis as any)[
|
|
500
|
+
Symbol.for("rangojs-router:loader-runs")
|
|
501
|
+
] ??= new WeakMap());
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* `value` is the promise of the execution `run` records, started by the
|
|
505
|
+
* current execution (if any), which reads it. Both loader runners call it
|
|
506
|
+
* (loader-resolution.ts createLoaderExecutor, request-context.ts
|
|
507
|
+
* createUseFunction).
|
|
508
|
+
*/
|
|
509
|
+
export function trackLoaderRun(value: object, run: LoaderRunIdentity): void {
|
|
510
|
+
loaderRuns.set(value, run);
|
|
511
|
+
linkLoaderRun(value);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The current loader execution reads `value`: its record links the value's. */
|
|
515
|
+
function linkLoaderRun(value: object): void {
|
|
516
|
+
const reader = getLoaderRunIdentity();
|
|
517
|
+
const run = loaderRuns.get(value);
|
|
518
|
+
if (reader && run && reader !== run) (reader.reads ??= new Set()).add(run);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The identity read behind a loader value: its execution's or one it read
|
|
523
|
+
* (nearest first), or the one its recorded-tag set links to.
|
|
524
|
+
*/
|
|
525
|
+
function loaderValueIdentityRead(
|
|
526
|
+
value: object,
|
|
527
|
+
): LoaderIdentityRead | undefined {
|
|
528
|
+
const root = loaderRuns.get(value);
|
|
529
|
+
if (root) {
|
|
530
|
+
const queue = [root];
|
|
531
|
+
const seen = new Set<LoaderRunIdentity>();
|
|
532
|
+
for (let i = 0; i < queue.length; i++) {
|
|
533
|
+
const run = queue[i]!;
|
|
534
|
+
if (seen.has(run)) continue;
|
|
535
|
+
seen.add(run);
|
|
536
|
+
if (run.read) return run.read;
|
|
537
|
+
for (const linked of run.reads ?? []) queue.push(linked);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const tags = loaderValueTags.get(value);
|
|
541
|
+
return tags && recordedIdentityRead(tags);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* A ctx.use(Loader) or getRequestContext().use(Loader) read that returns an
|
|
546
|
+
* already-started value (the request memo, a loader cache() binding's value).
|
|
547
|
+
* The current loader execution, if any, links it (it reads the value).
|
|
548
|
+
* Inside a "use cache" body the value is refused when its execution read
|
|
549
|
+
* request identity, as a body entered there would have been
|
|
550
|
+
* (guardIdentityRead). The check runs once the value settles, so a read the
|
|
551
|
+
* body makes after an await counts. Each enclosing execution also notes the
|
|
552
|
+
* value, and checks it again after encoding its result
|
|
553
|
+
* (assertLoaderReadsClean): a read that settles after the value (a nested
|
|
554
|
+
* promise in it) then fails the write instead.
|
|
555
|
+
*/
|
|
556
|
+
export function readStartedLoaderValue<T>(
|
|
557
|
+
value: Promise<T>,
|
|
558
|
+
loaderId: string,
|
|
559
|
+
): Promise<T> {
|
|
560
|
+
linkLoaderRun(value);
|
|
561
|
+
const scope = getCacheExecScope();
|
|
562
|
+
if (!scope || isInsideIdentityExempt()) return value;
|
|
563
|
+
for (let s: CacheExecScope | undefined = scope; s; s = s.parent) {
|
|
564
|
+
(s.loaderReads ??= new Map()).set(value, loaderId);
|
|
565
|
+
}
|
|
566
|
+
const checked = value.then((resolved) => {
|
|
567
|
+
assertLoaderValueClean(value, loaderId);
|
|
568
|
+
return resolved;
|
|
569
|
+
});
|
|
570
|
+
// A read the body never awaits must not crash the process as an unhandled
|
|
571
|
+
// rejection; the write check (assertLoaderReadsClean) still refuses it.
|
|
572
|
+
checked.catch(() => {});
|
|
573
|
+
return checked;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Throw when a loader value `scope` read carries an identity read. */
|
|
577
|
+
export function assertLoaderReadsClean(scope: CacheExecScope): void {
|
|
578
|
+
for (const [value, loaderId] of scope.loaderReads ?? []) {
|
|
579
|
+
assertLoaderValueClean(value, loaderId);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function assertLoaderValueClean(value: object, loaderId: string): void {
|
|
584
|
+
const read = loaderValueIdentityRead(value);
|
|
585
|
+
if (read) throw useCacheLoaderIdentityError(read, loaderId);
|
|
586
|
+
}
|
|
587
|
+
|
|
484
588
|
/** `root` and every set it links to, transitively. */
|
|
485
589
|
function linkedSets(root: Set<string>): Set<Set<string>> {
|
|
486
590
|
const seen = new Set<Set<string>>();
|
|
@@ -15,8 +15,10 @@ import {
|
|
|
15
15
|
linkValueTags,
|
|
16
16
|
loaderTagSetsArmed,
|
|
17
17
|
markTagSetStart,
|
|
18
|
+
readStartedLoaderValue,
|
|
18
19
|
readValueTags,
|
|
19
20
|
tagLoaderValue,
|
|
21
|
+
trackLoaderRun,
|
|
20
22
|
} from "../cache/cache-tag.js";
|
|
21
23
|
import { executionStart } from "../cache/tag-invalidation.js";
|
|
22
24
|
import type {
|
|
@@ -57,8 +59,10 @@ import {
|
|
|
57
59
|
assertNonCacheableReadAllowed,
|
|
58
60
|
isInsideLoaderScope,
|
|
59
61
|
runInsideLoaderBodyScope,
|
|
62
|
+
runInsideLoaderRun,
|
|
60
63
|
isInsidePushCallbackScope,
|
|
61
64
|
runInsidePushCallbackScope,
|
|
65
|
+
type LoaderRunIdentity,
|
|
62
66
|
} from "../server/context.js";
|
|
63
67
|
import { debugLog } from "./logging.js";
|
|
64
68
|
|
|
@@ -479,7 +483,9 @@ function createLoaderExecutor<TEnv>(
|
|
|
479
483
|
const memo = bound || loaderPromises.get(loader.$$id);
|
|
480
484
|
if (memo) {
|
|
481
485
|
if (recordTags) readValueTags(memo);
|
|
482
|
-
|
|
486
|
+
// A "use cache" body reading it refuses an identity read the run made
|
|
487
|
+
// (#1011): the body ran outside the cached function, unguarded.
|
|
488
|
+
return readStartedLoaderValue(memo, loader.$$id);
|
|
483
489
|
}
|
|
484
490
|
|
|
485
491
|
// Get loader function - either from loader object or fetchable registry
|
|
@@ -724,8 +730,10 @@ function createLoaderExecutor<TEnv>(
|
|
|
724
730
|
// BAKED copy in every shared artifact (cache(), "use cache", the PPR
|
|
725
731
|
// shell). The cache() guard permits its identity reads; a "use cache"
|
|
726
732
|
// body it was entered in and a PPR capture do not (server/context.ts
|
|
727
|
-
// guardIdentityRead)
|
|
733
|
+
// guardIdentityRead), and a "use cache" body that reads the value after
|
|
734
|
+
// another reader started it refuses what `identity` recorded (#1011).
|
|
728
735
|
|
|
736
|
+
const identity: LoaderRunIdentity = { loaderId: loader.$$id };
|
|
729
737
|
const recordedTags = recordTags ? new Set<string>() : undefined;
|
|
730
738
|
// A loader-cache write that stores or reads this value gates on the
|
|
731
739
|
// earliest start behind it (#977, earliestRecordedStart).
|
|
@@ -736,7 +744,10 @@ function createLoaderExecutor<TEnv>(
|
|
|
736
744
|
const promise = observePhase(PHASES.loader(loader.$$id), () =>
|
|
737
745
|
Promise.resolve(
|
|
738
746
|
runInsideLoaderBodyScope(
|
|
739
|
-
() =>
|
|
747
|
+
() =>
|
|
748
|
+
runInsideLoaderRun(identity, () =>
|
|
749
|
+
loaderFn(loaderCtx as LoaderContext<any, TEnv>),
|
|
750
|
+
),
|
|
740
751
|
loader.$$id,
|
|
741
752
|
recordedTags,
|
|
742
753
|
),
|
|
@@ -747,6 +758,7 @@ function createLoaderExecutor<TEnv>(
|
|
|
747
758
|
runEndStore?.settleLoaderRun(loader.$$id);
|
|
748
759
|
}),
|
|
749
760
|
);
|
|
761
|
+
trackLoaderRun(promise, identity);
|
|
750
762
|
if (recordedTags) {
|
|
751
763
|
tagLoaderValue(promise, recordedTags);
|
|
752
764
|
readValueTags(promise);
|
|
@@ -96,6 +96,7 @@ import {
|
|
|
96
96
|
linkRecordedTags,
|
|
97
97
|
markIdentityRead,
|
|
98
98
|
markTagSetStart,
|
|
99
|
+
readStartedLoaderValue,
|
|
99
100
|
readValueTags,
|
|
100
101
|
recordedIdentityRead,
|
|
101
102
|
recordLoaderTags,
|
|
@@ -591,7 +592,7 @@ function executeLoaderData<TEnv>(
|
|
|
591
592
|
// useLoader.
|
|
592
593
|
linkLoaderTags(item.$$id);
|
|
593
594
|
readValueTags(cached);
|
|
594
|
-
return cached;
|
|
595
|
+
return readStartedLoaderValue(cached, item.$$id);
|
|
595
596
|
}
|
|
596
597
|
return originalUse(item);
|
|
597
598
|
}) as typeof ctx.use;
|
package/src/server/context.ts
CHANGED
|
@@ -1131,18 +1131,91 @@ const IDENTITY_READ_RECORDER_KEY = Symbol.for(
|
|
|
1131
1131
|
* (cache-tag.ts recordedIdentityRead), so both orders fail the same way.
|
|
1132
1132
|
* Response directives do not record: a key cannot make a skipped body's side
|
|
1133
1133
|
* effect reach a HIT.
|
|
1134
|
+
*
|
|
1135
|
+
* The read also lands on the execution's LoaderRunIdentity (#1011), which
|
|
1136
|
+
* every execution has, from either loader runner (runInsideLoaderRun).
|
|
1134
1137
|
*/
|
|
1135
1138
|
export function recordLoaderIdentityRead(
|
|
1136
1139
|
surface: string,
|
|
1137
1140
|
verb: LoaderIdentityReadVerb = "called",
|
|
1138
1141
|
): void {
|
|
1139
1142
|
if (isInsideIdentityExempt()) return;
|
|
1143
|
+
const run = loaderRunALS.getStore();
|
|
1144
|
+
if (run && run.read === undefined) {
|
|
1145
|
+
run.read = { surface, verb, bodyId: run.loaderId };
|
|
1146
|
+
}
|
|
1140
1147
|
const recorder = (globalThis as Record<symbol, unknown>)[
|
|
1141
1148
|
IDENTITY_READ_RECORDER_KEY
|
|
1142
1149
|
] as ((surface: string, verb: LoaderIdentityReadVerb) => void) | undefined;
|
|
1143
1150
|
recorder?.(surface, verb);
|
|
1144
1151
|
}
|
|
1145
1152
|
|
|
1153
|
+
/**
|
|
1154
|
+
* One loader execution's request-identity record (#1011): the first identity
|
|
1155
|
+
* read its body made, and the executions whose values it read with
|
|
1156
|
+
* ctx.use(Loader). Every execution has one; the recorded-tag sets that carry
|
|
1157
|
+
* the same reads for a loader cache() fill (cache-tag.ts) exist only in a
|
|
1158
|
+
* request that binds one.
|
|
1159
|
+
*
|
|
1160
|
+
* A "use cache" function that reads a loader's value through the request
|
|
1161
|
+
* memo (a handler, the route's loader() binding or a parent layout's started
|
|
1162
|
+
* it first) does not run the body, so guardIdentityRead never sees the read.
|
|
1163
|
+
* Scar: the entry, keyed without the cookie, stored the first visitor's value
|
|
1164
|
+
* and served it to the next. cache-tag.ts readStartedLoaderValue refuses such
|
|
1165
|
+
* a value through this record.
|
|
1166
|
+
*/
|
|
1167
|
+
export interface LoaderRunIdentity {
|
|
1168
|
+
readonly loaderId: string;
|
|
1169
|
+
read?: LoaderIdentityRead;
|
|
1170
|
+
reads?: Set<LoaderRunIdentity>;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Its own scope, not a LoaderBodyScope field: the request-context runner
|
|
1174
|
+
// (request-context.ts createUseFunction) records reads too, but does not
|
|
1175
|
+
// enter a loader body scope, whose cache() exemption it never had.
|
|
1176
|
+
const loaderRunALS: AsyncLocalStorage<LoaderRunIdentity> = ((globalThis as any)[
|
|
1177
|
+
Symbol.for("rangojs-router:loader-run-identity")
|
|
1178
|
+
] ??= new AsyncLocalStorage<LoaderRunIdentity>());
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Run a loader body with `run` as its identity record: both loader runners
|
|
1182
|
+
* (loader-resolution.ts createLoaderExecutor, request-context.ts
|
|
1183
|
+
* createUseFunction) enter it around the loader function.
|
|
1184
|
+
*/
|
|
1185
|
+
export function runInsideLoaderRun<T>(run: LoaderRunIdentity, fn: () => T): T {
|
|
1186
|
+
return loaderRunALS.run(run, fn);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** The innermost loader execution's identity record (runInsideLoaderRun). */
|
|
1190
|
+
export function getLoaderRunIdentity(): LoaderRunIdentity | undefined {
|
|
1191
|
+
return loaderRunALS.getStore();
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* The error a "use cache" function's read of a loader value fails with when
|
|
1196
|
+
* the loader's execution, or one whose value it read, made `read` (#1011).
|
|
1197
|
+
* Worded like guardIdentityRead's "use cache" refusal, naming the loaders.
|
|
1198
|
+
*/
|
|
1199
|
+
export function useCacheLoaderIdentityError(
|
|
1200
|
+
read: LoaderIdentityRead,
|
|
1201
|
+
loaderId: string,
|
|
1202
|
+
): Error {
|
|
1203
|
+
const bodyId = read.bodyId ?? loaderId;
|
|
1204
|
+
const source =
|
|
1205
|
+
bodyId === loaderId
|
|
1206
|
+
? `Loader "${loaderId}" ${read.verb} it, and the cached function reads that loader's value`
|
|
1207
|
+
: `Loader "${bodyId}" ${read.verb} it, and the cached function reads loader "${loaderId}", whose value is built from it`;
|
|
1208
|
+
return new Error(
|
|
1209
|
+
`${read.surface} cannot be ${read.verb} inside a "use cache" function. ` +
|
|
1210
|
+
`${source}. The loader ran outside the function (a handler or a ` +
|
|
1211
|
+
`loader() binding started it first), but its value becomes part of ` +
|
|
1212
|
+
`what the function returns, and the cache key does not include it, so ` +
|
|
1213
|
+
`the first caller's value would be stored and served to later callers. ` +
|
|
1214
|
+
`Read the loader before calling the cached function and pass the value ` +
|
|
1215
|
+
`in as an argument so it becomes part of the cache key.`,
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1146
1219
|
/** The error a loader cache() fill with no declared key fails with (#972). */
|
|
1147
1220
|
export function loaderCacheIdentityError(
|
|
1148
1221
|
read: LoaderIdentityRead,
|
|
@@ -1344,7 +1417,8 @@ export function runInsideLoaderScope<T>(fn: () => T): T {
|
|
|
1344
1417
|
* and handler-invoked via ctx.use) so request-scoped reads inside a loader
|
|
1345
1418
|
* never trip the cache() guard — a route cache() never stores loader values.
|
|
1346
1419
|
* A "use cache" body the loader was entered in still refuses them
|
|
1347
|
-
* (guardIdentityRead)
|
|
1420
|
+
* (guardIdentityRead); one that reads the value later refuses what the run
|
|
1421
|
+
* recorded (runInsideLoaderRun, cache-tag.ts readStartedLoaderValue).
|
|
1348
1422
|
*/
|
|
1349
1423
|
export function runInsideLoaderBodyScope<T>(
|
|
1350
1424
|
fn: () => T,
|
|
@@ -64,7 +64,10 @@ import {
|
|
|
64
64
|
assertCachedHeaderWriteAllowed,
|
|
65
65
|
assertNonCacheableReadAllowed,
|
|
66
66
|
clearPprHeaderScope,
|
|
67
|
+
runInsideLoaderRun,
|
|
68
|
+
type LoaderRunIdentity,
|
|
67
69
|
} from "./context.js";
|
|
70
|
+
import { readStartedLoaderValue, trackLoaderRun } from "../cache/cache-tag.js";
|
|
68
71
|
import {
|
|
69
72
|
guardRawCookieRead,
|
|
70
73
|
guardRequestHeaders,
|
|
@@ -1859,8 +1862,11 @@ export function createUseFunction<TEnv>(
|
|
|
1859
1862
|
|
|
1860
1863
|
const loader = item as LoaderDefinition<any, any>;
|
|
1861
1864
|
|
|
1862
|
-
|
|
1863
|
-
|
|
1865
|
+
const memo = loaderPromises.get(loader.$$id);
|
|
1866
|
+
if (memo) {
|
|
1867
|
+
// Same refusal as the handler ctx.use memo hit (#1011): a "use cache"
|
|
1868
|
+
// body reading a value whose run read request identity.
|
|
1869
|
+
return readStartedLoaderValue(memo, loader.$$id);
|
|
1864
1870
|
}
|
|
1865
1871
|
|
|
1866
1872
|
let loaderFn = loader.fn;
|
|
@@ -1947,9 +1953,11 @@ export function createUseFunction<TEnv>(
|
|
|
1947
1953
|
// (observePhase), so a loader resolved via this base request-context ctx.use
|
|
1948
1954
|
// co-emits the "loader:<id>" perf metric AND the "rango.loader" span — no
|
|
1949
1955
|
// drift between the two ctx.use implementations.
|
|
1956
|
+
const run: LoaderRunIdentity = { loaderId: loader.$$id };
|
|
1950
1957
|
const promise = observePhase(PHASES.loader(loader.$$id), () =>
|
|
1951
|
-
Promise.resolve(loaderFn(loaderCtx)),
|
|
1958
|
+
Promise.resolve(runInsideLoaderRun(run, () => loaderFn(loaderCtx))),
|
|
1952
1959
|
);
|
|
1960
|
+
trackLoaderRun(promise, run);
|
|
1953
1961
|
|
|
1954
1962
|
loaderPromises.set(loader.$$id, promise);
|
|
1955
1963
|
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client entry's head hint at default fetch priority, after the head chunk
|
|
3
|
+
* scripts (issue #1025).
|
|
4
|
+
*
|
|
5
|
+
* For `bootstrapModules`, Fizz writes `<link rel="modulepreload"
|
|
6
|
+
* fetchPriority="low">` into the head and claims the URL
|
|
7
|
+
* (`moduleScriptResources[url] = null`, react-dom 19.3.0
|
|
8
|
+
* `react-dom-server.edge.production.js:546-565`) before the render starts, so
|
|
9
|
+
* no `preloadModule`/`preinitModule` call can replace it, and the
|
|
10
|
+
* `bootstrapModules` entry shape has no priority field. Chromium fetches that
|
|
11
|
+
* hint at Low, and once it boosts in-viewport images to High after first
|
|
12
|
+
* layout, the entry queues behind them; the executing end-of-shell module
|
|
13
|
+
* script reuses the in-flight Low fetch through the module map. Hydration
|
|
14
|
+
* then waits on a few-hundred-byte file while the head chunk scripts it
|
|
15
|
+
* imports (High) have long arrived: measured +3.8 s on a priority-honouring
|
|
16
|
+
* HTTP/2 link at Slow 4G.
|
|
17
|
+
*
|
|
18
|
+
* The fix serves that one tag without the attribute, so it fetches at
|
|
19
|
+
* Chromium's modulepreload default (High), and moves it after the head
|
|
20
|
+
* chunk scripts. Fizz flushes the bootstrap hint before the head chunk scripts; at
|
|
21
|
+
* High in that position it took one of HTTP/1.1's six connections ahead of
|
|
22
|
+
* the react chunk and delayed the last head chunk (hydration +91 ms, LCP
|
|
23
|
+
* +84 ms measured). After the scripts it is the last High request in the
|
|
24
|
+
* queue, so it never displaces a chunk hydration also needs. The accepted
|
|
25
|
+
* cost: an HTTP/2 server that sends equal-priority streams first in, first
|
|
26
|
+
* out delivers it with the last head chunk instead of before it (text-only
|
|
27
|
+
* pages +12 to +56 ms measured, against -0.15 to -3.3 s hydration on image
|
|
28
|
+
* pages; CHANGELOG has the matrix). Everything else about the bootstrap pair
|
|
29
|
+
* stays Fizz's: the nonce, the executing `id="_R_"` tag, the URL claim that
|
|
30
|
+
* keeps plugin-rsc's and the preinit hook's calls for the entry inert, and the
|
|
31
|
+
* PPR prelude/resume behaviour.
|
|
32
|
+
*
|
|
33
|
+
* The match is the exact tag Fizz writes for a string `bootstrapModules`
|
|
34
|
+
* entry (`rel`, `fetchPriority`, `nonce`, `href` in that order), not a
|
|
35
|
+
* pattern, and only before the first `<body`. A Fizz version that writes it
|
|
36
|
+
* differently leaves the bytes untouched: the output keeps the Low hint.
|
|
37
|
+
*
|
|
38
|
+
* Where the tag goes: right after the run of Fizz resource tags that directly
|
|
39
|
+
* follows the hint, before the first token that is anything else (`</head>`,
|
|
40
|
+
* the app's own head children, or body content in a head-less document).
|
|
41
|
+
* Fizz flushes the head chunk scripts and the bulk preloads straight after
|
|
42
|
+
* the bootstrap hint (react-dom 19.3.0 `flushCompletedQueues`, `:7321-7325`),
|
|
43
|
+
* as `<script …></script>` with no body and `<link …/>`; the scan accepts
|
|
44
|
+
* only those two shapes and stops at any other token without reading into it.
|
|
45
|
+
* It never searches the app's head content for `</head>` or `<body`: React
|
|
46
|
+
* does not escape either inside `<script>` or `<style>` text, so a head script
|
|
47
|
+
* holding the string `"<body>"` once received the tag inside its source and
|
|
48
|
+
* threw. Fizz escapes `>` in attribute values, so the first `>` ends a tag.
|
|
49
|
+
* Every insertion point is the boundary between two complete tags; a stream
|
|
50
|
+
* that ends mid-run, or a run tag longer than {@link MAX_HELD_TAG}, gets the
|
|
51
|
+
* tag before the unfinished one. Only that one tag is held back while the run
|
|
52
|
+
* streams; each complete run tag is forwarded as soon as it is scanned.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
const LOW_PRIORITY_ATTR = ' fetchPriority="low"';
|
|
56
|
+
const encoder = new TextEncoder();
|
|
57
|
+
const BODY_OPEN = encoder.encode("<body");
|
|
58
|
+
const LINK_OPEN = encoder.encode("<link");
|
|
59
|
+
const SCRIPT_OPEN = encoder.encode("<script");
|
|
60
|
+
const SCRIPT_CLOSE = encoder.encode("</script>");
|
|
61
|
+
const GT = 0x3e;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Longest run tag held while its end has not arrived. Fizz's chunk scripts
|
|
65
|
+
* and preload links are a few hundred bytes; past this the tag goes before
|
|
66
|
+
* the held one, which keeps the per-chunk work bounded.
|
|
67
|
+
*/
|
|
68
|
+
const MAX_HELD_TAG = 8192;
|
|
69
|
+
|
|
70
|
+
/** React's escapeTextForBrowser: the attribute escaping Fizz applies. */
|
|
71
|
+
function escapeAttr(value: string): string {
|
|
72
|
+
return value.replace(/["&'<>]/g, (c) =>
|
|
73
|
+
c === '"'
|
|
74
|
+
? """
|
|
75
|
+
: c === "&"
|
|
76
|
+
? "&"
|
|
77
|
+
: c === "'"
|
|
78
|
+
? "'"
|
|
79
|
+
: c === "<"
|
|
80
|
+
? "<"
|
|
81
|
+
: ">",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface EntryHintBytes {
|
|
86
|
+
low: Uint8Array;
|
|
87
|
+
auto: Uint8Array;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function entryHintBytes(
|
|
91
|
+
entryUrl: string,
|
|
92
|
+
nonce: string | undefined,
|
|
93
|
+
): EntryHintBytes {
|
|
94
|
+
const tail =
|
|
95
|
+
(nonce ? ` nonce="${escapeAttr(nonce)}"` : "") +
|
|
96
|
+
` href="${escapeAttr(entryUrl)}"/>`;
|
|
97
|
+
return {
|
|
98
|
+
low: encoder.encode(`<link rel="modulepreload"${LOW_PRIORITY_ATTR}${tail}`),
|
|
99
|
+
auto: encoder.encode(`<link rel="modulepreload"${tail}`),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function indexOfBytes(
|
|
104
|
+
haystack: Uint8Array,
|
|
105
|
+
needle: Uint8Array,
|
|
106
|
+
from = 0,
|
|
107
|
+
): number {
|
|
108
|
+
const first = needle[0];
|
|
109
|
+
const last = haystack.length - needle.length;
|
|
110
|
+
outer: for (let i = from; i <= last; i++) {
|
|
111
|
+
if (haystack[i] !== first) continue;
|
|
112
|
+
for (let j = 1; j < needle.length; j++) {
|
|
113
|
+
if (haystack[i + j] !== needle[j]) continue outer;
|
|
114
|
+
}
|
|
115
|
+
return i;
|
|
116
|
+
}
|
|
117
|
+
return -1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Length of the longest suffix of `buf` that is a proper prefix of `needle`:
|
|
122
|
+
* the bytes a chunk boundary may have split off a match.
|
|
123
|
+
*/
|
|
124
|
+
function partialSuffixLength(buf: Uint8Array, needle: Uint8Array): number {
|
|
125
|
+
const max = Math.min(buf.length, needle.length - 1);
|
|
126
|
+
outer: for (let len = max; len > 0; len--) {
|
|
127
|
+
const start = buf.length - len;
|
|
128
|
+
for (let j = 0; j < len; j++) {
|
|
129
|
+
if (buf[start + j] !== needle[j]) continue outer;
|
|
130
|
+
}
|
|
131
|
+
return len;
|
|
132
|
+
}
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
137
|
+
const out = new Uint8Array(a.length + b.length);
|
|
138
|
+
out.set(a, 0);
|
|
139
|
+
out.set(b, a.length);
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Index of the hint in `buf` when it precedes the first `<body`, else -1. */
|
|
144
|
+
function findHint(buf: Uint8Array, hint: EntryHintBytes): number {
|
|
145
|
+
const bodyAt = indexOfBytes(buf, BODY_OPEN);
|
|
146
|
+
return indexOfBytes(bodyAt === -1 ? buf : buf.subarray(0, bodyAt), hint.low);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 1 when `needle` starts at `pos`, 0 when it does not, -1 when `buf` ends
|
|
151
|
+
* before that can be decided (the available bytes match a prefix).
|
|
152
|
+
*/
|
|
153
|
+
function matchAt(buf: Uint8Array, pos: number, needle: Uint8Array): number {
|
|
154
|
+
const n = Math.min(needle.length, buf.length - pos);
|
|
155
|
+
for (let j = 0; j < n; j++) {
|
|
156
|
+
if (buf[pos + j] !== needle[j]) return 0;
|
|
157
|
+
}
|
|
158
|
+
return n === needle.length ? 1 : -1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** HTML whitespace, `/` or `>`: the byte that ends a tag name. */
|
|
162
|
+
function endsTagName(byte: number): boolean {
|
|
163
|
+
return (
|
|
164
|
+
byte === 0x20 ||
|
|
165
|
+
byte === 0x09 ||
|
|
166
|
+
byte === 0x0a ||
|
|
167
|
+
byte === 0x0c ||
|
|
168
|
+
byte === 0x0d ||
|
|
169
|
+
byte === 0x2f ||
|
|
170
|
+
byte === GT
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface RunScan {
|
|
175
|
+
/** The run ended: `at` is the first byte of the token after it. */
|
|
176
|
+
ended: boolean;
|
|
177
|
+
/** Insertion point, or the start of the unfinished token when not ended. */
|
|
178
|
+
at: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Skip the run of Fizz resource tags at the start of `buf`: `<link …>` and
|
|
183
|
+
* `<script …></script>` with nothing between the two tags.
|
|
184
|
+
*/
|
|
185
|
+
function scanRun(buf: Uint8Array): RunScan {
|
|
186
|
+
let pos = 0;
|
|
187
|
+
while (pos < buf.length) {
|
|
188
|
+
const link = matchAt(buf, pos, LINK_OPEN);
|
|
189
|
+
const script = link === 1 ? 0 : matchAt(buf, pos, SCRIPT_OPEN);
|
|
190
|
+
if (link !== 1 && script !== 1) {
|
|
191
|
+
return { ended: link === 0 && script === 0, at: pos };
|
|
192
|
+
}
|
|
193
|
+
const nameEnd = pos + (link === 1 ? LINK_OPEN.length : SCRIPT_OPEN.length);
|
|
194
|
+
if (nameEnd >= buf.length) return { ended: false, at: pos };
|
|
195
|
+
if (!endsTagName(buf[nameEnd]!)) return { ended: true, at: pos };
|
|
196
|
+
const gt = buf.indexOf(GT, nameEnd);
|
|
197
|
+
if (gt === -1) return { ended: false, at: pos };
|
|
198
|
+
let end = gt + 1;
|
|
199
|
+
if (script === 1) {
|
|
200
|
+
const close = matchAt(buf, end, SCRIPT_CLOSE);
|
|
201
|
+
// An inline script body: the hint goes before the <script, never in it.
|
|
202
|
+
if (close !== 1) return { ended: close === 0, at: pos };
|
|
203
|
+
end += SCRIPT_CLOSE.length;
|
|
204
|
+
}
|
|
205
|
+
pos = end;
|
|
206
|
+
}
|
|
207
|
+
return { ended: false, at: pos };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `rest` with `hint` inserted at `at`. */
|
|
211
|
+
function insertAt(rest: Uint8Array, at: number, hint: Uint8Array): Uint8Array {
|
|
212
|
+
const out = new Uint8Array(rest.length + hint.length);
|
|
213
|
+
out.set(rest.subarray(0, at), 0);
|
|
214
|
+
out.set(hint, at);
|
|
215
|
+
out.set(rest.subarray(at), at + hint.length);
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Rewrite a complete document prefix (the PPR shell capture's prelude).
|
|
221
|
+
* Returns `bytes` unchanged when no hint precedes the first `<body`.
|
|
222
|
+
*/
|
|
223
|
+
export function rewriteEntryPreloadPriority(
|
|
224
|
+
bytes: Uint8Array,
|
|
225
|
+
entryUrl: string,
|
|
226
|
+
nonce?: string,
|
|
227
|
+
): Uint8Array {
|
|
228
|
+
const hint = entryHintBytes(entryUrl, nonce);
|
|
229
|
+
const at = findHint(bytes, hint);
|
|
230
|
+
if (at === -1) return bytes;
|
|
231
|
+
const rest = bytes.subarray(at + hint.low.length);
|
|
232
|
+
return concat(
|
|
233
|
+
bytes.subarray(0, at),
|
|
234
|
+
insertAt(rest, scanRun(rest).at, hint.auto),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Streaming form for live SSR: the same rewrite as
|
|
240
|
+
* {@link rewriteEntryPreloadPriority}, then pass-through. Before the hint is
|
|
241
|
+
* found, only a chunk tail that may be the start of a split `<body` or hint
|
|
242
|
+
* tag is held back. After it, complete run tags are forwarded as they are
|
|
243
|
+
* scanned and only an unfinished one (at most {@link MAX_HELD_TAG} bytes) is
|
|
244
|
+
* held, so every byte is scanned a bounded number of times. Once the tag is
|
|
245
|
+
* placed, or once `<body` passes without a hint, chunks are forwarded
|
|
246
|
+
* untouched and unscanned.
|
|
247
|
+
*/
|
|
248
|
+
export function entryPreloadPriorityTransform(
|
|
249
|
+
entryUrl: string,
|
|
250
|
+
nonce?: string,
|
|
251
|
+
): TransformStream<Uint8Array, Uint8Array> {
|
|
252
|
+
const hint = entryHintBytes(entryUrl, nonce);
|
|
253
|
+
let carry: Uint8Array | null = null;
|
|
254
|
+
// The unfinished run tag after the removed hint (possibly empty).
|
|
255
|
+
let held: Uint8Array | null = null;
|
|
256
|
+
let done = false;
|
|
257
|
+
const place = (
|
|
258
|
+
rest: Uint8Array,
|
|
259
|
+
controller: TransformStreamDefaultController<Uint8Array>,
|
|
260
|
+
): void => {
|
|
261
|
+
const scan = scanRun(rest);
|
|
262
|
+
if (scan.ended || rest.length - scan.at > MAX_HELD_TAG) {
|
|
263
|
+
done = true;
|
|
264
|
+
held = null;
|
|
265
|
+
controller.enqueue(insertAt(rest, scan.at, hint.auto));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (scan.at > 0) controller.enqueue(rest.slice(0, scan.at));
|
|
269
|
+
held = rest.slice(scan.at);
|
|
270
|
+
};
|
|
271
|
+
return new TransformStream<Uint8Array, Uint8Array>({
|
|
272
|
+
transform(chunk, controller) {
|
|
273
|
+
if (done) {
|
|
274
|
+
controller.enqueue(chunk);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (held) {
|
|
278
|
+
place(concat(held, chunk), controller);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const buf: Uint8Array = carry ? concat(carry, chunk) : chunk;
|
|
282
|
+
carry = null;
|
|
283
|
+
const at = findHint(buf, hint);
|
|
284
|
+
if (at !== -1) {
|
|
285
|
+
if (at > 0) controller.enqueue(buf.slice(0, at));
|
|
286
|
+
place(buf.subarray(at + hint.low.length), controller);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (indexOfBytes(buf, BODY_OPEN) !== -1) {
|
|
290
|
+
done = true;
|
|
291
|
+
controller.enqueue(buf);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const hold = Math.max(
|
|
295
|
+
partialSuffixLength(buf, hint.low),
|
|
296
|
+
partialSuffixLength(buf, BODY_OPEN),
|
|
297
|
+
);
|
|
298
|
+
if (hold > 0) carry = buf.slice(buf.length - hold);
|
|
299
|
+
const out = hold > 0 ? buf.subarray(0, buf.length - hold) : buf;
|
|
300
|
+
if (out.length > 0) controller.enqueue(out);
|
|
301
|
+
},
|
|
302
|
+
flush(controller) {
|
|
303
|
+
if (carry) controller.enqueue(carry);
|
|
304
|
+
// The stream ended inside the run: the tag goes before the unfinished one.
|
|
305
|
+
if (held) controller.enqueue(concat(hint.auto, held));
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
}
|