@rsc-kit/core 0.30.19 → 0.30.21
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/compress.js +47 -5
- package/dist/compress.js.map +1 -1
- package/dist/js/navigate.js +53 -2
- package/dist/js/navigate.js.map +1 -1
- package/dist/vite.js +36 -5
- package/dist/vite.js.map +1 -1
- package/package.json +1 -1
package/dist/compress.js
CHANGED
|
@@ -17,6 +17,18 @@ import { deflate } from "./compressRuntime.js";
|
|
|
17
17
|
const COMPRESSIBLE = /^(?:text\/|application\/(?:json|javascript|xml|manifest\+json|rss\+xml|atom\+xml)|image\/svg\+xml)/i;
|
|
18
18
|
/** Answers too small to be worth a compressor's frame. */
|
|
19
19
|
const MIN_BYTES = 1024;
|
|
20
|
+
/**
|
|
21
|
+
* How long a body that has begun is given to end, or to reach MIN_BYTES,
|
|
22
|
+
* before it is taken to be streaming.
|
|
23
|
+
*
|
|
24
|
+
* A complete answer built from a string ends in the same turn it starts, so
|
|
25
|
+
* this is only ever spent on one that is still being written. Waiting for it
|
|
26
|
+
* to finish was the bug: a client navigation that skips every layout sends a
|
|
27
|
+
* shell under a kilobyte and then the page's slow data, and the shell sat in
|
|
28
|
+
* the peek until that data was in. The loading state it carried reached
|
|
29
|
+
* nobody, and the old page stayed on screen the whole time.
|
|
30
|
+
*/
|
|
31
|
+
const PEEK_MS = 5;
|
|
20
32
|
/** Stored answers, compressed once: the same bytes go to every visitor who accepts gzip. */
|
|
21
33
|
const stored = new Map();
|
|
22
34
|
const STORED_CAP = 1000;
|
|
@@ -87,18 +99,31 @@ export async function compressed(request, response, storedKey) {
|
|
|
87
99
|
// first kilobyte decides. An answer that ends inside it - /api/health, a
|
|
88
100
|
// 15-byte "ok" - goes out as it was; anything longer is compressed from
|
|
89
101
|
// its first byte, the peeked part included, and keeps streaming.
|
|
102
|
+
//
|
|
103
|
+
// The first chunk is waited for however long it takes, since it is the
|
|
104
|
+
// first byte. After it, only PEEK_MS: a body still being written after
|
|
105
|
+
// that is a stream, and a stream is compressed from its first chunk - its
|
|
106
|
+
// shell must not wait for the rest of it to decide a question of size.
|
|
90
107
|
const reader = response.body.getReader();
|
|
91
108
|
const peeked = [];
|
|
92
109
|
let size = 0;
|
|
93
110
|
let ended = false;
|
|
111
|
+
// A read that lost the race is still a read: the next chunk is its, and
|
|
112
|
+
// asking again would skip one.
|
|
113
|
+
let pending = null;
|
|
94
114
|
while (size < MIN_BYTES) {
|
|
95
|
-
const
|
|
96
|
-
|
|
115
|
+
const read = pending ?? reader.read();
|
|
116
|
+
pending = read;
|
|
117
|
+
const result = size === 0 ? await read : await within(read, PEEK_MS);
|
|
118
|
+
if (result === null)
|
|
119
|
+
break;
|
|
120
|
+
pending = null;
|
|
121
|
+
if (result.done) {
|
|
97
122
|
ended = true;
|
|
98
123
|
break;
|
|
99
124
|
}
|
|
100
|
-
peeked.push(value);
|
|
101
|
-
size += value.byteLength;
|
|
125
|
+
peeked.push(result.value);
|
|
126
|
+
size += result.value.byteLength;
|
|
102
127
|
}
|
|
103
128
|
if (ended) {
|
|
104
129
|
return new Response(size === 0 ? null : concat(peeked, size), {
|
|
@@ -113,7 +138,9 @@ export async function compressed(request, response, storedKey) {
|
|
|
113
138
|
controller.enqueue(chunk);
|
|
114
139
|
},
|
|
115
140
|
async pull(controller) {
|
|
116
|
-
const
|
|
141
|
+
const read = pending ?? reader.read();
|
|
142
|
+
pending = null;
|
|
143
|
+
const { done, value } = await read;
|
|
117
144
|
if (done)
|
|
118
145
|
controller.close();
|
|
119
146
|
else
|
|
@@ -125,6 +152,21 @@ export async function compressed(request, response, storedKey) {
|
|
|
125
152
|
});
|
|
126
153
|
return withEncoding(response, gzip.stream(rest));
|
|
127
154
|
}
|
|
155
|
+
/** What the promise settles with, or null if it has not within `ms`. */
|
|
156
|
+
async function within(promise, ms) {
|
|
157
|
+
let timer;
|
|
158
|
+
try {
|
|
159
|
+
return await Promise.race([
|
|
160
|
+
promise,
|
|
161
|
+
new Promise((resolve) => {
|
|
162
|
+
timer = setTimeout(() => resolve(null), ms);
|
|
163
|
+
}),
|
|
164
|
+
]);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
128
170
|
function concat(chunks, size) {
|
|
129
171
|
const out = new Uint8Array(size);
|
|
130
172
|
let at = 0;
|
package/dist/compress.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compress.js","sourceRoot":"","sources":["../src/compress.ts"],"names":[],"mappings":"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","sourcesContent":["// Compression for what the host answers, where nothing in front of it will.\n//\n// Behind Cloudflare, Vercel or nginx the platform compresses and this never\n// runs. A bun or node server answering the internet by itself sent every\n// byte raw - a 90 kB document and 145 kB of stylesheet at their full size -\n// and that alone was the mobile Lighthouse regression a port measured:\n// first paint at 3.8 s where 1.2 s was the baseline. Public assets are\n// precompressed by the build and served by Nitro with their encoding; the\n// pages, payloads, stored answers and api routes come through here.\n//\n// gzip, flushed on every chunk. A streamed page is a shell followed by the\n// holes as they fill, and a compressor left to its own buffer would hold\n// the shell back until enough had accumulated - so each write is flushed\n// out, which costs a little ratio and keeps the stream a stream.\n\nimport { deflate } from \"./compressRuntime.js\";\n\n/** What is worth compressing: text of every kind, and the formats that are text. */\nconst COMPRESSIBLE = /^(?:text\\/|application\\/(?:json|javascript|xml|manifest\\+json|rss\\+xml|atom\\+xml)|image\\/svg\\+xml)/i;\n\n/** Answers too small to be worth a compressor's frame. */\nconst MIN_BYTES = 1024;\n\n/** Stored answers, compressed once: the same bytes go to every visitor who accepts gzip. */\nconst stored = new Map<string, Uint8Array>();\n\nconst STORED_CAP = 1000;\n\nfunction acceptsGzip(request: Request): boolean {\n const accept = request.headers.get(\"accept-encoding\") ?? \"\";\n\n return /(?:^|,)\\s*(?:gzip|\\*)\\s*(?:;\\s*q=(?!0(?:\\.0*)?\\s*(?:,|$)))?/i.test(accept);\n}\n\n/** Whether this answer is one to compress at all. */\nexport function shouldCompress(request: Request, response: Response): boolean {\n if (response.body === null || request.method === \"HEAD\") return false;\n if (response.status === 204 || response.status === 304 || response.status < 200) return false;\n if (response.headers.has(\"content-encoding\")) return false;\n if (/\\bno-transform\\b/i.test(response.headers.get(\"cache-control\") ?? \"\")) return false;\n const type = response.headers.get(\"content-type\") ?? \"\";\n\n if (!COMPRESSIBLE.test(type)) return false;\n // A stream of events is text, and must not be compressed: each message is\n // a few bytes that has to reach the browser the moment it is written, and\n // a compressor between them delivered nothing at all - not even headers -\n // to a browser that sent Accept-Encoding: gzip, which is every browser.\n if (/^text\\/event-stream\\b/i.test(type)) return false;\n\n const length = Number(response.headers.get(\"content-length\"));\n\n if (Number.isFinite(length) && length > 0 && length < MIN_BYTES) return false;\n\n return acceptsGzip(request);\n}\n\nfunction withEncoding(response: Response, body: BodyInit): Response {\n const headers = new Headers(response.headers);\n\n headers.set(\"Content-Encoding\", \"gzip\");\n headers.delete(\"Content-Length\");\n\n const vary = headers.get(\"Vary\");\n\n if (!vary) headers.set(\"Vary\", \"Accept-Encoding\");\n else if (!/\\baccept-encoding\\b/i.test(vary) && vary.trim() !== \"*\") headers.set(\"Vary\", vary + \", Accept-Encoding\");\n\n return new Response(body, { status: response.status, statusText: response.statusText, headers });\n}\n\n/**\n * The response, gzipped if the request accepts it and the answer is worth it.\n *\n * `storedKey` names an answer the build wrote - the same bytes for every\n * visitor - so it is compressed once and kept; everything else is streamed\n * through the compressor as it is produced.\n */\nexport async function compressed(\n request: Request,\n response: Response,\n storedKey?: string,\n): Promise<Response> {\n if (!shouldCompress(request, response)) return response;\n\n const gzip = await deflate();\n\n if (!gzip) return response;\n\n if (storedKey !== undefined) {\n let bytes = stored.get(storedKey);\n\n if (!bytes) {\n bytes = await gzip.whole(new Uint8Array(await response.arrayBuffer()));\n\n if (stored.size >= STORED_CAP) stored.clear();\n\n stored.set(storedKey, bytes);\n }\n\n return withEncoding(response, bytes as unknown as BodyInit);\n }\n\n // A Response made from a string or JSON carries no Content-Length for the\n // check above to read, so the body is peeked: what arrives before the\n // first kilobyte decides. An answer that ends inside it - /api/health, a\n // 15-byte \"ok\" - goes out as it was; anything longer is compressed from\n // its first byte, the peeked part included, and keeps streaming.\n const reader = response.body!.getReader();\n const peeked: Uint8Array[] = [];\n let size = 0;\n let ended = false;\n\n while (size < MIN_BYTES) {\n const { done, value } = await reader.read();\n\n if (done) {\n ended = true;\n break;\n }\n\n peeked.push(value);\n size += value.byteLength;\n }\n\n if (ended) {\n return new Response(size === 0 ? null : (concat(peeked, size) as unknown as BodyInit), {\n status: response.status,\n statusText: response.statusText,\n headers: response.headers,\n });\n }\n\n const rest = new ReadableStream<Uint8Array>({\n start(controller) {\n for (const chunk of peeked) controller.enqueue(chunk);\n },\n async pull(controller) {\n const { done, value } = await reader.read();\n\n if (done) controller.close();\n else controller.enqueue(value);\n },\n cancel(reason) {\n return reader.cancel(reason);\n },\n });\n\n return withEncoding(response, gzip.stream(rest));\n}\n\nfunction concat(chunks: Uint8Array[], size: number): Uint8Array {\n const out = new Uint8Array(size);\n let at = 0;\n\n for (const chunk of chunks) {\n out.set(chunk, at);\n at += chunk.byteLength;\n }\n\n return out;\n}\n\n/** For a test, or a redeploy that reuses the process: forget the stored bytes. */\nexport function forgetCompressed(): void {\n stored.clear();\n}\n"]}
|
|
1
|
+
{"version":3,"file":"compress.js","sourceRoot":"","sources":["../src/compress.ts"],"names":[],"mappings":"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","sourcesContent":["// Compression for what the host answers, where nothing in front of it will.\n//\n// Behind Cloudflare, Vercel or nginx the platform compresses and this never\n// runs. A bun or node server answering the internet by itself sent every\n// byte raw - a 90 kB document and 145 kB of stylesheet at their full size -\n// and that alone was the mobile Lighthouse regression a port measured:\n// first paint at 3.8 s where 1.2 s was the baseline. Public assets are\n// precompressed by the build and served by Nitro with their encoding; the\n// pages, payloads, stored answers and api routes come through here.\n//\n// gzip, flushed on every chunk. A streamed page is a shell followed by the\n// holes as they fill, and a compressor left to its own buffer would hold\n// the shell back until enough had accumulated - so each write is flushed\n// out, which costs a little ratio and keeps the stream a stream.\n\nimport { deflate } from \"./compressRuntime.js\";\n\n/** What is worth compressing: text of every kind, and the formats that are text. */\nconst COMPRESSIBLE = /^(?:text\\/|application\\/(?:json|javascript|xml|manifest\\+json|rss\\+xml|atom\\+xml)|image\\/svg\\+xml)/i;\n\n/** Answers too small to be worth a compressor's frame. */\nconst MIN_BYTES = 1024;\n\n/**\n * How long a body that has begun is given to end, or to reach MIN_BYTES,\n * before it is taken to be streaming.\n *\n * A complete answer built from a string ends in the same turn it starts, so\n * this is only ever spent on one that is still being written. Waiting for it\n * to finish was the bug: a client navigation that skips every layout sends a\n * shell under a kilobyte and then the page's slow data, and the shell sat in\n * the peek until that data was in. The loading state it carried reached\n * nobody, and the old page stayed on screen the whole time.\n */\nconst PEEK_MS = 5;\n\n/** Stored answers, compressed once: the same bytes go to every visitor who accepts gzip. */\nconst stored = new Map<string, Uint8Array>();\n\nconst STORED_CAP = 1000;\n\nfunction acceptsGzip(request: Request): boolean {\n const accept = request.headers.get(\"accept-encoding\") ?? \"\";\n\n return /(?:^|,)\\s*(?:gzip|\\*)\\s*(?:;\\s*q=(?!0(?:\\.0*)?\\s*(?:,|$)))?/i.test(accept);\n}\n\n/** Whether this answer is one to compress at all. */\nexport function shouldCompress(request: Request, response: Response): boolean {\n if (response.body === null || request.method === \"HEAD\") return false;\n if (response.status === 204 || response.status === 304 || response.status < 200) return false;\n if (response.headers.has(\"content-encoding\")) return false;\n if (/\\bno-transform\\b/i.test(response.headers.get(\"cache-control\") ?? \"\")) return false;\n const type = response.headers.get(\"content-type\") ?? \"\";\n\n if (!COMPRESSIBLE.test(type)) return false;\n // A stream of events is text, and must not be compressed: each message is\n // a few bytes that has to reach the browser the moment it is written, and\n // a compressor between them delivered nothing at all - not even headers -\n // to a browser that sent Accept-Encoding: gzip, which is every browser.\n if (/^text\\/event-stream\\b/i.test(type)) return false;\n\n const length = Number(response.headers.get(\"content-length\"));\n\n if (Number.isFinite(length) && length > 0 && length < MIN_BYTES) return false;\n\n return acceptsGzip(request);\n}\n\nfunction withEncoding(response: Response, body: BodyInit): Response {\n const headers = new Headers(response.headers);\n\n headers.set(\"Content-Encoding\", \"gzip\");\n headers.delete(\"Content-Length\");\n\n const vary = headers.get(\"Vary\");\n\n if (!vary) headers.set(\"Vary\", \"Accept-Encoding\");\n else if (!/\\baccept-encoding\\b/i.test(vary) && vary.trim() !== \"*\") headers.set(\"Vary\", vary + \", Accept-Encoding\");\n\n return new Response(body, { status: response.status, statusText: response.statusText, headers });\n}\n\n/**\n * The response, gzipped if the request accepts it and the answer is worth it.\n *\n * `storedKey` names an answer the build wrote - the same bytes for every\n * visitor - so it is compressed once and kept; everything else is streamed\n * through the compressor as it is produced.\n */\nexport async function compressed(\n request: Request,\n response: Response,\n storedKey?: string,\n): Promise<Response> {\n if (!shouldCompress(request, response)) return response;\n\n const gzip = await deflate();\n\n if (!gzip) return response;\n\n if (storedKey !== undefined) {\n let bytes = stored.get(storedKey);\n\n if (!bytes) {\n bytes = await gzip.whole(new Uint8Array(await response.arrayBuffer()));\n\n if (stored.size >= STORED_CAP) stored.clear();\n\n stored.set(storedKey, bytes);\n }\n\n return withEncoding(response, bytes as unknown as BodyInit);\n }\n\n // A Response made from a string or JSON carries no Content-Length for the\n // check above to read, so the body is peeked: what arrives before the\n // first kilobyte decides. An answer that ends inside it - /api/health, a\n // 15-byte \"ok\" - goes out as it was; anything longer is compressed from\n // its first byte, the peeked part included, and keeps streaming.\n //\n // The first chunk is waited for however long it takes, since it is the\n // first byte. After it, only PEEK_MS: a body still being written after\n // that is a stream, and a stream is compressed from its first chunk - its\n // shell must not wait for the rest of it to decide a question of size.\n const reader = response.body!.getReader();\n const peeked: Uint8Array[] = [];\n let size = 0;\n let ended = false;\n // A read that lost the race is still a read: the next chunk is its, and\n // asking again would skip one.\n let pending: ReturnType<typeof reader.read> | null = null;\n\n while (size < MIN_BYTES) {\n const read = pending ?? reader.read();\n\n pending = read;\n\n const result = size === 0 ? await read : await within(read, PEEK_MS);\n\n if (result === null) break;\n\n pending = null;\n\n if (result.done) {\n ended = true;\n break;\n }\n\n peeked.push(result.value);\n size += result.value.byteLength;\n }\n\n if (ended) {\n return new Response(size === 0 ? null : (concat(peeked, size) as unknown as BodyInit), {\n status: response.status,\n statusText: response.statusText,\n headers: response.headers,\n });\n }\n\n const rest = new ReadableStream<Uint8Array>({\n start(controller) {\n for (const chunk of peeked) controller.enqueue(chunk);\n },\n async pull(controller) {\n const read = pending ?? reader.read();\n\n pending = null;\n\n const { done, value } = await read;\n\n if (done) controller.close();\n else controller.enqueue(value);\n },\n cancel(reason) {\n return reader.cancel(reason);\n },\n });\n\n return withEncoding(response, gzip.stream(rest));\n}\n\n/** What the promise settles with, or null if it has not within `ms`. */\nasync function within<T>(promise: Promise<T>, ms: number): Promise<T | null> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n\n try {\n return await Promise.race([\n promise,\n new Promise<null>((resolve) => {\n timer = setTimeout(() => resolve(null), ms);\n }),\n ]);\n } finally {\n clearTimeout(timer);\n }\n}\n\nfunction concat(chunks: Uint8Array[], size: number): Uint8Array {\n const out = new Uint8Array(size);\n let at = 0;\n\n for (const chunk of chunks) {\n out.set(chunk, at);\n at += chunk.byteLength;\n }\n\n return out;\n}\n\n/** For a test, or a redeploy that reuses the process: forget the stored bytes. */\nexport function forgetCompressed(): void {\n stored.clear();\n}\n"]}
|
package/dist/js/navigate.js
CHANGED
|
@@ -500,7 +500,10 @@ notFound = false) {
|
|
|
500
500
|
* hoists document metadata into <head>. Nothing needs injecting from headers.
|
|
501
501
|
*/
|
|
502
502
|
function deserializeResponse(response) {
|
|
503
|
-
return
|
|
503
|
+
return deserializeStream(response.body);
|
|
504
|
+
}
|
|
505
|
+
function deserializeStream(stream) {
|
|
506
|
+
return flightDeserializer(stream, {
|
|
504
507
|
callServer: callServerFn ??
|
|
505
508
|
(async () => {
|
|
506
509
|
throw new Error("Server actions not initialized");
|
|
@@ -713,7 +716,6 @@ export async function navigate(url, opts) {
|
|
|
713
716
|
// open over the page behind it.
|
|
714
717
|
const usable = isUsable(cached, chain);
|
|
715
718
|
if (usable) {
|
|
716
|
-
treePromise = cached.tree;
|
|
717
719
|
// Read after the tree resolves, not now. A prefetch fills these in when
|
|
718
720
|
// its response lands, and a click can land first — hover a modal link
|
|
719
721
|
// and click it quickly and the entry still says depth 0, so the
|
|
@@ -722,6 +724,26 @@ export async function navigate(url, opts) {
|
|
|
722
724
|
// waiting for it is what makes them true.
|
|
723
725
|
reused = cached;
|
|
724
726
|
cache.delete(cacheKey);
|
|
727
|
+
if (reused.settled) {
|
|
728
|
+
treePromise = reused.tree;
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
// Still arriving, so read it as it comes - see follow(). The request
|
|
732
|
+
// is this navigation's from here: leaving the link, or an action
|
|
733
|
+
// dropping the other prefetches, must not cut short a page the
|
|
734
|
+
// visitor is waiting on, and being overtaken by another navigation
|
|
735
|
+
// must stop it as it would a request of its own.
|
|
736
|
+
const arriving = reused;
|
|
737
|
+
prefetchControllers.delete(cacheKey);
|
|
738
|
+
controller.signal.addEventListener("abort", () => arriving.cancel(), { once: true });
|
|
739
|
+
// A request that dies is asked again, as a prefetch that died before
|
|
740
|
+
// the click is; the entry says which it was once it has settled.
|
|
741
|
+
treePromise = arriving.follow().catch((error) => arriving.body.then(() => {
|
|
742
|
+
if (arriving.failed)
|
|
743
|
+
return null;
|
|
744
|
+
throw error;
|
|
745
|
+
}));
|
|
746
|
+
}
|
|
725
747
|
}
|
|
726
748
|
else {
|
|
727
749
|
cache.delete(cacheKey);
|
|
@@ -1172,6 +1194,11 @@ function warm(entry, cacheKey, followed = new Set()) {
|
|
|
1172
1194
|
return;
|
|
1173
1195
|
// The page about to show: its pictures ahead of every other page's.
|
|
1174
1196
|
preloadImages(text, "high");
|
|
1197
|
+
// Taken by a click before it had all arrived, which read it as it came:
|
|
1198
|
+
// the page is on its way to the screen, and decoding it again here would
|
|
1199
|
+
// be the same chunks asked for twice.
|
|
1200
|
+
if (cache.get(cacheKey) !== entry)
|
|
1201
|
+
return;
|
|
1175
1202
|
return entry.tree.then((tree) => {
|
|
1176
1203
|
if (entry.slot !== null ||
|
|
1177
1204
|
entry.segmentDepth === 0 ||
|
|
@@ -1200,11 +1227,24 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
|
|
|
1200
1227
|
const controller = new AbortController();
|
|
1201
1228
|
prefetchControllers.set(cacheKey, controller);
|
|
1202
1229
|
let decoded = null;
|
|
1230
|
+
// Settled once the response has said whether there is a page to follow: its
|
|
1231
|
+
// bytes as they arrive, or nothing for a redirect or a failure.
|
|
1232
|
+
let arriving;
|
|
1233
|
+
let followed = false;
|
|
1234
|
+
const followable = new Promise((resolve) => {
|
|
1235
|
+
arriving = resolve;
|
|
1236
|
+
});
|
|
1203
1237
|
const entry = {
|
|
1204
1238
|
body: Promise.resolve(null),
|
|
1205
1239
|
get tree() {
|
|
1206
1240
|
return (decoded ??= this.body.then((text) => (text === null ? null : deserializeResponse(new Response(text)))));
|
|
1207
1241
|
},
|
|
1242
|
+
settled: false,
|
|
1243
|
+
follow() {
|
|
1244
|
+
followed = true;
|
|
1245
|
+
return followable.then((stream) => (stream ? deserializeStream(stream) : null));
|
|
1246
|
+
},
|
|
1247
|
+
cancel: () => controller.abort(),
|
|
1208
1248
|
expiresAt: Date.now() + ttl,
|
|
1209
1249
|
segmentDepth: 0,
|
|
1210
1250
|
layouts: null,
|
|
@@ -1237,6 +1277,7 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
|
|
|
1237
1277
|
entry.redirectTo = redirectTo;
|
|
1238
1278
|
if (!isExternalUrl(redirectTo) && isSafeRedirect(redirectTo))
|
|
1239
1279
|
prefetch(redirectTo, ttl);
|
|
1280
|
+
arriving(null);
|
|
1240
1281
|
return null;
|
|
1241
1282
|
}
|
|
1242
1283
|
entry.slot = response.headers.get("X-RSC-Revalidate");
|
|
@@ -1261,7 +1302,16 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
|
|
|
1261
1302
|
// - unless the visitor is already on the way, see prefetch(). The
|
|
1262
1303
|
// pictures the page shows are asked for now, though: on a phone they
|
|
1263
1304
|
// take longer than the touch-to-click a hidden render has.
|
|
1305
|
+
//
|
|
1306
|
+
// Read twice: whole, for the cache, and from the first byte by a click
|
|
1307
|
+
// that lands before the last of them - see follow(). The second read is
|
|
1308
|
+
// let go as soon as the first is complete and nobody took it.
|
|
1309
|
+
const copy = response.clone().body;
|
|
1310
|
+
arriving(copy);
|
|
1264
1311
|
return response.text().then((text) => {
|
|
1312
|
+
entry.settled = true;
|
|
1313
|
+
if (!followed)
|
|
1314
|
+
copy?.cancel();
|
|
1265
1315
|
preloadImages(text);
|
|
1266
1316
|
// And the code for its client components, so the first visit to a
|
|
1267
1317
|
// page does not wait on a chunk after it has arrived.
|
|
@@ -1272,6 +1322,7 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
|
|
|
1272
1322
|
.catch(() => {
|
|
1273
1323
|
entry.failed = true;
|
|
1274
1324
|
cache.delete(cacheKey);
|
|
1325
|
+
arriving(null);
|
|
1275
1326
|
return null;
|
|
1276
1327
|
})
|
|
1277
1328
|
.finally(() => {
|