akanjs 2.2.12 → 2.2.13-rc.1

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.
Files changed (38) hide show
  1. package/client/csrTypes.ts +37 -6
  2. package/client/makePageProto.tsx +8 -8
  3. package/client/router.ts +5 -2
  4. package/fetch/requestStorage.ts +41 -11
  5. package/package.json +1 -1
  6. package/server/akanApp.ts +55 -0
  7. package/server/cachePolicy.ts +192 -0
  8. package/server/metadata.tsx +114 -0
  9. package/server/routeElementComposer.tsx +21 -1
  10. package/server/routeTreeBuilder.ts +44 -5
  11. package/server/rscClient.tsx +127 -50
  12. package/server/rscHttp.ts +120 -0
  13. package/server/rscNavigationState.ts +95 -0
  14. package/server/rscWorker.tsx +318 -121
  15. package/server/rscWorkerHost.ts +281 -66
  16. package/server/rscWorkerReplay.ts +40 -0
  17. package/server/ssrFromRscRenderer.tsx +462 -77
  18. package/server/ssrTypes.ts +11 -1
  19. package/server/webRouter.ts +173 -88
  20. package/service/ipcTypes.ts +1 -0
  21. package/types/client/csrTypes.d.ts +37 -6
  22. package/types/dictionary/base.dictionary.d.ts +1 -1
  23. package/types/dictionary/dictionary.d.ts +8 -8
  24. package/types/fetch/requestStorage.d.ts +16 -6
  25. package/types/server/cachePolicy.d.ts +55 -0
  26. package/types/server/metadata.d.ts +13 -0
  27. package/types/server/routeElementComposer.d.ts +6 -1
  28. package/types/server/rscHttp.d.ts +16 -0
  29. package/types/server/rscNavigationState.d.ts +35 -0
  30. package/types/server/rscWorkerHost.d.ts +38 -0
  31. package/types/server/rscWorkerReplay.d.ts +29 -0
  32. package/types/server/ssrFromRscRenderer.d.ts +20 -1
  33. package/types/server/ssrTypes.d.ts +10 -1
  34. package/types/server/webRouter.d.ts +27 -1
  35. package/types/service/ipcTypes.d.ts +1 -0
  36. package/types/webkit/useCsrValues.d.ts +1 -1
  37. package/ui/Link/SsrLink.tsx +0 -2
  38. package/webkit/bootCsr.tsx +16 -2
@@ -4,9 +4,11 @@ import type {
4
4
  LayoutFallbackRoute,
5
5
  LayoutNotFoundRender,
6
6
  PathRoute,
7
+ ResolvedHead,
7
8
  RouteRender,
8
9
  } from "akanjs/client";
9
10
  import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode, Suspense } from "react";
11
+ import { resolveHeadResult } from "./metadata";
10
12
 
11
13
  export class RouteElementComposer {
12
14
  static compose({
@@ -43,7 +45,25 @@ export class RouteElementComposer {
43
45
  params: Record<string, string>;
44
46
  searchParams: Record<string, string | string[]>;
45
47
  }): Promise<Head | null | undefined> {
46
- return pathRoute.resolveHead?.({ params, searchParams });
48
+ return (
49
+ await RouteElementComposer.resolveHeadWithMetadata({
50
+ pathRoute,
51
+ params,
52
+ searchParams,
53
+ })
54
+ ).node;
55
+ }
56
+
57
+ static async resolveHeadWithMetadata({
58
+ pathRoute,
59
+ params,
60
+ searchParams,
61
+ }: {
62
+ pathRoute: PathRoute;
63
+ params: Record<string, string>;
64
+ searchParams: Record<string, string[] | string>;
65
+ }): Promise<ResolvedHead> {
66
+ return resolveHeadResult(await pathRoute.resolveHead?.({ params, searchParams }));
47
67
  }
48
68
 
49
69
  static async composeFallback({
@@ -1,5 +1,4 @@
1
1
  import type {
2
- Head,
3
2
  LayoutFallbackRoute,
4
3
  LayoutModule,
5
4
  LayoutProps,
@@ -19,6 +18,7 @@ import {
19
18
  parseRouteModuleKey,
20
19
  routeSegmentToTreePath,
21
20
  } from "akanjs/common";
21
+ import { resolveHeadExport, resolveMetadataHead } from "./metadata";
22
22
 
23
23
  export type PagesContext = Record<string, () => Promise<RouteModule>>;
24
24
 
@@ -44,11 +44,21 @@ export interface RouteModuleCacheStats {
44
44
  }
45
45
 
46
46
  export class RouteTreeBuilder {
47
- static readonly #pageRouteExports = new Set(["default", "pageConfig", "head", "generateHead", "Loading"]);
47
+ static readonly #pageRouteExports = new Set([
48
+ "default",
49
+ "pageConfig",
50
+ "head",
51
+ "metadata",
52
+ "generateHead",
53
+ "generateMetadata",
54
+ "Loading",
55
+ ]);
48
56
  static readonly #rootLayoutExports = new Set([
49
57
  "default",
50
58
  "head",
59
+ "metadata",
51
60
  "generateHead",
61
+ "generateMetadata",
52
62
  "fonts",
53
63
  "manifest",
54
64
  "theme",
@@ -60,7 +70,16 @@ export class RouteTreeBuilder {
60
70
  "NotFound",
61
71
  "Error",
62
72
  ]);
63
- static readonly #layoutRouteExports = new Set(["default", "head", "generateHead", "Loading", "NotFound", "Error"]);
73
+ static readonly #layoutRouteExports = new Set([
74
+ "default",
75
+ "head",
76
+ "metadata",
77
+ "generateHead",
78
+ "generateMetadata",
79
+ "Loading",
80
+ "NotFound",
81
+ "Error",
82
+ ]);
64
83
  static readonly #moduleCacheStats: RouteModuleCacheStats = {
65
84
  moduleCount: 0,
66
85
  loadedModuleCount: 0,
@@ -279,6 +298,18 @@ export class RouteTreeBuilder {
279
298
  if ("head" in mod && "generateHead" in mod) {
280
299
  throw new Error(`[route-convention] head and generateHead cannot both be exported in ${key}`);
281
300
  }
301
+ if (
302
+ !parsed.isInternalRootLayout &&
303
+ ("head" in mod || "generateHead" in mod) &&
304
+ ("metadata" in mod || "generateMetadata" in mod)
305
+ ) {
306
+ throw new Error(
307
+ `[route-convention] head/generateHead and metadata/generateMetadata cannot both be exported in ${key}`,
308
+ );
309
+ }
310
+ if ("metadata" in mod && "generateMetadata" in mod) {
311
+ throw new Error(`[route-convention] metadata and generateMetadata cannot both be exported in ${key}`);
312
+ }
282
313
  }
283
314
 
284
315
  static #makeRouteRender(key: string, kind: "page" | "layout", loader: () => Promise<RouteModule>): RouteRender {
@@ -304,8 +335,16 @@ export class RouteTreeBuilder {
304
335
  routeRender.NotFound = layoutMod.NotFound;
305
336
  routeRender.Error = layoutMod.Error;
306
337
  }
307
- if (mod.generateHead) return mod.generateHead(props);
308
- return mod.head as Head | null | undefined;
338
+ if (mod.generateHead) {
339
+ const head = await mod.generateHead(props);
340
+ if (head !== null && head !== undefined) return resolveHeadExport(head);
341
+ }
342
+ if (mod.generateMetadata) {
343
+ const metadata = await mod.generateMetadata(props);
344
+ return metadata === null || metadata === undefined ? metadata : resolveMetadataHead(metadata);
345
+ }
346
+ if (mod.head !== undefined) return mod.head === null ? null : resolveHeadExport(mod.head);
347
+ return mod.metadata === undefined ? undefined : resolveMetadataHead(mod.metadata);
309
348
  },
310
349
  };
311
350
  if (kind === "page") {
@@ -1,7 +1,13 @@
1
1
  import { createElement, type ReactNode, startTransition, use, useLayoutEffect, useState } from "react";
2
2
  import { hydrateRoot } from "react-dom/client";
3
3
  import { createFromReadableStream } from "react-server-dom-webpack/client.browser";
4
- import { isRscPayloadResponse } from "./rscHttp";
4
+ import { getRscPayloadStream, guardRscRedirectRows, type RscRedirectRow } from "./rscHttp";
5
+ import {
6
+ commitLatestRscNavigation,
7
+ deleteRscCacheEntryIfCurrent,
8
+ observeRscNavigation,
9
+ rememberRscCacheEntry,
10
+ } from "./rscNavigationState";
5
11
 
6
12
  type InlineRscChunk = [1, string] | [3, string];
7
13
 
@@ -32,6 +38,14 @@ function decodeInlineRscChunk([type, data]: InlineRscChunk): Uint8Array {
32
38
  type RscThenable = Promise<ReactNode>;
33
39
  type RscFetchResult = { type: "rsc"; thenable: RscThenable } | { type: "redirected"; status?: number };
34
40
  const MAX_RSC_CACHE_ENTRIES = 32;
41
+ let documentNavigationFallbackInFlight = false;
42
+
43
+ class RscRedirectNavigationStarted extends Error {
44
+ constructor(readonly location: string) {
45
+ super("[rscClient] RSC redirect navigation started");
46
+ this.name = "RscRedirectNavigationStarted";
47
+ }
48
+ }
35
49
 
36
50
  function createInitialRscStream(): ReadableStream<Uint8Array> {
37
51
  return new ReadableStream<Uint8Array>({
@@ -59,7 +73,31 @@ function createRscThenable(stream: ReadableStream<Uint8Array>): RscThenable {
59
73
  return createFromReadableStream<ReactNode>(stream) as RscThenable;
60
74
  }
61
75
 
62
- async function fetchRsc(href: string, options: { buildId?: number } = {}): Promise<RscFetchResult> {
76
+ function hardNavigateAfterRscFailure(target: string, replace = false, error?: unknown): void {
77
+ if (documentNavigationFallbackInFlight) return;
78
+ documentNavigationFallbackInFlight = true;
79
+ console.warn(`[rscClient] RSC navigation failed, falling back to document navigation: ${String(error)}`);
80
+ if (replace) window.location.replace(target);
81
+ else window.location.assign(target);
82
+ }
83
+
84
+ function navigateAfterRscRedirect(target: string, replace = true): void {
85
+ const error = new RscRedirectNavigationStarted(target);
86
+ const navigate = globalThis.__AKAN_RSC_NAVIGATE__;
87
+ if (!navigate) {
88
+ hardNavigateAfterRscFailure(target, replace, error);
89
+ return;
90
+ }
91
+ void navigate(target, { replace, scrollToTop: true }).catch((navError) => {
92
+ hardNavigateAfterRscFailure(target, replace, navError);
93
+ });
94
+ }
95
+
96
+ async function fetchRsc(
97
+ href: string,
98
+ options: { buildId?: number; replaceOnRedirect?: boolean; shouldApplyNavigation?: () => boolean } = {},
99
+ ): Promise<RscFetchResult> {
100
+ const shouldApplyNavigation = options.shouldApplyNavigation ?? (() => true);
63
101
  const endpoint = new URL("/__rsc", window.location.origin);
64
102
  endpoint.searchParams.set("url", href);
65
103
  if (options.buildId !== undefined) endpoint.searchParams.set("buildId", String(options.buildId));
@@ -73,19 +111,30 @@ async function fetchRsc(href: string, options: { buildId?: number } = {}): Promi
73
111
  const method = res.headers.get("X-Akan-Redirect-Method");
74
112
  const statusHeader = res.headers.get("X-Akan-Redirect-Status");
75
113
  const status = statusHeader ? Number(statusHeader) : undefined;
76
- await globalThis.__AKAN_RSC_NAVIGATE__?.(redirect, { replace: method !== "push", scrollToTop: true });
114
+ if (shouldApplyNavigation())
115
+ await globalThis.__AKAN_RSC_NAVIGATE__?.(redirect, { replace: method !== "push", scrollToTop: true });
77
116
  return { type: "redirected", status };
78
117
  }
79
- if (!isRscPayloadResponse(res)) throw new Error(`[rscClient] RSC fetch failed ${res.status} ${res.statusText}`);
80
-
81
- const buffer = await res.arrayBuffer();
82
- const completeStream = new ReadableStream<Uint8Array>({
83
- start(controller) {
84
- controller.enqueue(new Uint8Array(buffer));
85
- controller.close();
86
- },
118
+ const stream = getRscPayloadStream(res);
119
+ if (!stream) throw new Error(`[rscClient] RSC fetch failed ${res.status} ${res.statusText}`);
120
+ let thenable: RscThenable | undefined;
121
+ const handleRedirect = (redirect: RscRedirectRow) => {
122
+ if (!shouldApplyNavigation()) return;
123
+ const location = redirect.location ? normalizeHref(redirect.location) : href;
124
+ if (thenable) deleteRscCacheEntryIfCurrent(rscCache, href, thenable);
125
+ navigateAfterRscRedirect(
126
+ location,
127
+ redirect.method ? redirect.method !== "push" : (options.replaceOnRedirect ?? true),
128
+ );
129
+ };
130
+ const guardedStream = guardRscRedirectRows(stream, {
131
+ onRedirect: handleRedirect,
87
132
  });
88
- return { type: "rsc", thenable: createRscThenable(completeStream) };
133
+ thenable = createRscThenable(guardedStream);
134
+ return {
135
+ type: "rsc",
136
+ thenable,
137
+ };
89
138
  }
90
139
 
91
140
  const rscCache = new Map<string, RscThenable>();
@@ -93,16 +142,6 @@ const initialThenable = createRscThenable(createInitialRscStream());
93
142
  rscCache.set(normalizeHref(window.location.href), initialThenable);
94
143
  let navigationSeq = 0;
95
144
 
96
- function rememberRsc(href: string, thenable: RscThenable): void {
97
- rscCache.delete(href);
98
- rscCache.set(href, thenable);
99
- while (rscCache.size > MAX_RSC_CACHE_ENTRIES) {
100
- const oldest = rscCache.keys().next().value;
101
- if (!oldest) break;
102
- rscCache.delete(oldest);
103
- }
104
- }
105
-
106
145
  function Root(): ReactNode {
107
146
  const [thenable, setThenable] = useState<RscThenable>(initialThenable);
108
147
  const [scrollToTopTick, setScrollToTopTick] = useState(0);
@@ -118,48 +157,86 @@ function Root(): ReactNode {
118
157
  };
119
158
 
120
159
  globalThis.__AKAN_RSC_REFRESH__ = async (options = {}) => {
160
+ const navId = ++navigationSeq;
121
161
  const target = normalizeHref(window.location.href);
122
162
  rscCache.delete(target);
123
- const next = await fetchRsc(target, options);
124
- if (next.type === "redirected") return;
125
- rememberRsc(target, next.thenable);
126
163
  try {
127
- await next.thenable;
164
+ const next = await fetchRsc(target, {
165
+ ...options,
166
+ replaceOnRedirect: true,
167
+ shouldApplyNavigation: () => navId === navigationSeq,
168
+ });
169
+ if (next.type === "redirected") return;
170
+ observeRscNavigation({
171
+ cache: rscCache,
172
+ href: target,
173
+ thenable: next.thenable,
174
+ navId,
175
+ getCurrentNavId: () => navigationSeq,
176
+ isExpectedNavigationError: (error) => error instanceof RscRedirectNavigationStarted,
177
+ onLatestError: (error) => hardNavigateAfterRscFailure(target, true, error),
178
+ });
179
+ commitLatestRscNavigation({
180
+ cache: rscCache,
181
+ href: target,
182
+ thenable: next.thenable,
183
+ maxEntries: MAX_RSC_CACHE_ENTRIES,
184
+ startTransition,
185
+ commitThenable: setThenable,
186
+ navId,
187
+ getCurrentNavId: () => navigationSeq,
188
+ });
128
189
  } catch (error) {
129
- rscCache.delete(target);
130
- throw error;
190
+ if (error instanceof RscRedirectNavigationStarted) return;
191
+ if (navId === navigationSeq) hardNavigateAfterRscFailure(target, true, error);
131
192
  }
132
- startTransition(() => {
133
- setThenable(next.thenable);
134
- });
135
193
  };
136
194
 
137
195
  globalThis.__AKAN_RSC_NAVIGATE__ = async (href, options = {}) => {
138
196
  const navId = ++navigationSeq;
139
197
  const target = normalizeHref(href);
140
198
  const scrollToTop = options.scrollToTop ?? true;
141
- let next = rscCache.get(target);
142
- if (!next) {
143
- const fetched = await fetchRsc(target);
144
- if (fetched.type === "redirected") return;
145
- next = fetched.thenable;
146
- rememberRsc(target, next);
147
- } else {
148
- rememberRsc(target, next);
149
- }
150
199
  try {
151
- await next;
200
+ let next = rscCache.get(target);
201
+ if (!next) {
202
+ const fetched = await fetchRsc(target, {
203
+ replaceOnRedirect: options.replace,
204
+ shouldApplyNavigation: () => navId === navigationSeq,
205
+ });
206
+ if (fetched.type === "redirected") return;
207
+ next = fetched.thenable;
208
+ } else {
209
+ rememberRscCacheEntry(rscCache, target, next, MAX_RSC_CACHE_ENTRIES);
210
+ }
211
+ observeRscNavigation({
212
+ cache: rscCache,
213
+ href: target,
214
+ thenable: next,
215
+ navId,
216
+ getCurrentNavId: () => navigationSeq,
217
+ isExpectedNavigationError: (error) => error instanceof RscRedirectNavigationStarted,
218
+ onLatestError: (error) => hardNavigateAfterRscFailure(target, options.replace, error),
219
+ });
220
+ commitLatestRscNavigation({
221
+ cache: rscCache,
222
+ href: target,
223
+ thenable: next,
224
+ maxEntries: MAX_RSC_CACHE_ENTRIES,
225
+ startTransition,
226
+ commitThenable: setThenable,
227
+ updateHistory: () => {
228
+ if (options.replace) window.history.replaceState(null, "", target);
229
+ else window.history.pushState(null, "", target);
230
+ },
231
+ scrollToTop,
232
+ bumpScrollToTop: () => setScrollToTopTick((tick) => tick + 1),
233
+ navId,
234
+ getCurrentNavId: () => navigationSeq,
235
+ });
152
236
  } catch (error) {
153
- rscCache.delete(target);
154
- throw error;
237
+ if (error instanceof RscRedirectNavigationStarted) return;
238
+ if (navId === navigationSeq) hardNavigateAfterRscFailure(target, options.replace, error);
155
239
  }
156
- if (navId !== navigationSeq) return;
157
- startTransition(() => {
158
- setThenable(next as RscThenable);
159
- if (options.replace) window.history.replaceState(null, "", target);
160
- else window.history.pushState(null, "", target);
161
- if (scrollToTop) setScrollToTopTick((tick) => tick + 1);
162
- });
163
240
  };
164
241
 
165
242
  return use(thenable);
package/server/rscHttp.ts CHANGED
@@ -1,7 +1,127 @@
1
1
  export const RSC_CONTENT_TYPE = "text/x-component; charset=utf-8";
2
+ const RSC_REDIRECT_ROW_RE = /^([0-9a-z]+):E(\{[^\n]*"digest":"AKAN_REDIRECT(?:;[^"]*)?"[^\n]*\})(\n?)$/;
3
+ const AKAN_REDIRECT_DIGEST_PREFIX = "AKAN_REDIRECT";
4
+
5
+ export interface RscRedirectRow {
6
+ rowId: string;
7
+ location?: string;
8
+ method?: "replace" | "push";
9
+ status?: 303 | 307 | 308;
10
+ }
11
+
12
+ export function encodeAkanRedirectDigest(input: {
13
+ location: string;
14
+ method: "replace" | "push";
15
+ status: 303 | 307 | 308;
16
+ }): string {
17
+ return [AKAN_REDIRECT_DIGEST_PREFIX, input.method, String(input.status), encodeURIComponent(input.location)].join(
18
+ ";",
19
+ );
20
+ }
21
+
22
+ export function decodeAkanRedirectDigest(digest: unknown): Omit<RscRedirectRow, "rowId"> {
23
+ if (typeof digest !== "string" || !digest.startsWith(AKAN_REDIRECT_DIGEST_PREFIX)) return {};
24
+ const [, method, rawStatus, encodedLocation] = digest.split(";");
25
+ const out: Omit<RscRedirectRow, "rowId"> = {};
26
+ if (method === "replace" || method === "push") out.method = method;
27
+ const status = Number(rawStatus);
28
+ if (status === 303 || status === 307 || status === 308) out.status = status;
29
+ if (encodedLocation) {
30
+ try {
31
+ out.location = decodeURIComponent(encodedLocation);
32
+ } catch {
33
+ out.location = encodedLocation;
34
+ }
35
+ }
36
+ return out;
37
+ }
2
38
 
3
39
  export function isRscPayloadResponse(res: Response): boolean {
4
40
  if (!res.body) return false;
5
41
  if (res.ok) return true;
6
42
  return res.status === 404 && (res.headers.get("Content-Type") ?? "").toLowerCase().startsWith("text/x-component");
7
43
  }
44
+
45
+ export function getRscPayloadStream(res: Response): ReadableStream<Uint8Array> | null {
46
+ if (!isRscPayloadResponse(res)) return null;
47
+ return res.body;
48
+ }
49
+
50
+ function concatBytes(left: Uint8Array, right: Uint8Array): Uint8Array<ArrayBuffer> {
51
+ const combined = new Uint8Array(left.byteLength + right.byteLength);
52
+ combined.set(left, 0);
53
+ combined.set(right, left.byteLength);
54
+ return combined;
55
+ }
56
+
57
+ export function guardRscRedirectRows(
58
+ stream: ReadableStream<Uint8Array>,
59
+ options: { onRedirect?: (redirect: RscRedirectRow) => void } = {},
60
+ ): ReadableStream<Uint8Array> {
61
+ const decoder = new TextDecoder("utf-8", { fatal: true });
62
+ const encoder = new TextEncoder();
63
+ let buffered: Uint8Array<ArrayBuffer> = new Uint8Array(0);
64
+ let redirected = false;
65
+
66
+ const getRedirectRow = (row: Uint8Array): RscRedirectRow | null => {
67
+ try {
68
+ const match = RSC_REDIRECT_ROW_RE.exec(decoder.decode(row));
69
+ if (!match?.[1] || !match[2]) return null;
70
+ let redirect: Omit<RscRedirectRow, "rowId"> = {};
71
+ try {
72
+ const payload = JSON.parse(match[2]) as { digest?: unknown; location?: unknown; message?: unknown };
73
+ redirect = decodeAkanRedirectDigest(payload.digest);
74
+ if (!redirect.location && typeof payload.location === "string") redirect.location = payload.location;
75
+ else if (!redirect.location && typeof payload.message === "string")
76
+ redirect.location = /^Redirect to (.+)$/.exec(payload.message)?.[1];
77
+ } catch {}
78
+ return { rowId: match[1], ...redirect };
79
+ } catch {
80
+ return null;
81
+ }
82
+ };
83
+
84
+ const enqueueCompleteRows = (chunk: Uint8Array, controller: TransformStreamDefaultController<Uint8Array>) => {
85
+ buffered = concatBytes(buffered, chunk);
86
+ let rowStart = 0;
87
+ for (let index = 0; index < buffered.byteLength; index += 1) {
88
+ if (buffered[index] !== 10) continue;
89
+ const row = buffered.slice(rowStart, index + 1);
90
+ const redirect = getRedirectRow(row);
91
+ if (redirect) {
92
+ if (!redirected) {
93
+ redirected = true;
94
+ options.onRedirect?.(redirect);
95
+ }
96
+ controller.enqueue(encoder.encode(`${redirect.rowId}:null\n`));
97
+ rowStart = index + 1;
98
+ continue;
99
+ }
100
+ controller.enqueue(row);
101
+ rowStart = index + 1;
102
+ }
103
+ buffered = rowStart === 0 ? buffered : buffered.slice(rowStart);
104
+ };
105
+
106
+ return stream.pipeThrough(
107
+ new TransformStream<Uint8Array, Uint8Array>({
108
+ transform(chunk, controller) {
109
+ enqueueCompleteRows(chunk, controller);
110
+ },
111
+ flush(controller) {
112
+ if (buffered.byteLength === 0) return;
113
+ const redirect = getRedirectRow(buffered);
114
+ if (redirect) {
115
+ if (!redirected) {
116
+ redirected = true;
117
+ options.onRedirect?.(redirect);
118
+ }
119
+ controller.enqueue(encoder.encode(`${redirect.rowId}:null`));
120
+ return;
121
+ }
122
+ controller.enqueue(buffered);
123
+ buffered = new Uint8Array(0);
124
+ },
125
+ }),
126
+ );
127
+ }
@@ -0,0 +1,95 @@
1
+ export interface RscNavigationCache<T> {
2
+ get(key: string): T | undefined;
3
+ set(key: string, value: T): void;
4
+ delete(key: string): boolean;
5
+ keys(): IterableIterator<string>;
6
+ readonly size: number;
7
+ }
8
+
9
+ export function rememberRscCacheEntry<T>(
10
+ cache: RscNavigationCache<T>,
11
+ href: string,
12
+ thenable: T,
13
+ maxEntries: number,
14
+ ): void {
15
+ cache.delete(href);
16
+ cache.set(href, thenable);
17
+ while (cache.size > maxEntries) {
18
+ const oldest = cache.keys().next().value;
19
+ if (!oldest) break;
20
+ cache.delete(oldest);
21
+ }
22
+ }
23
+
24
+ export function deleteRscCacheEntryIfCurrent<T>(cache: RscNavigationCache<T>, href: string, thenable: T): boolean {
25
+ if (cache.get(href) !== thenable) return false;
26
+ return cache.delete(href);
27
+ }
28
+
29
+ interface CommitRscNavigationInput<T> {
30
+ cache: RscNavigationCache<T>;
31
+ href: string;
32
+ thenable: T;
33
+ maxEntries: number;
34
+ startTransition: (callback: () => void) => void;
35
+ commitThenable: (thenable: T) => void;
36
+ updateHistory?: () => void;
37
+ scrollToTop?: boolean;
38
+ bumpScrollToTop?: () => void;
39
+ }
40
+
41
+ export function commitRscNavigation<T>({
42
+ cache,
43
+ href,
44
+ thenable,
45
+ maxEntries,
46
+ startTransition,
47
+ commitThenable,
48
+ updateHistory,
49
+ scrollToTop,
50
+ bumpScrollToTop,
51
+ }: CommitRscNavigationInput<T>): void {
52
+ rememberRscCacheEntry(cache, href, thenable, maxEntries);
53
+ startTransition(() => {
54
+ commitThenable(thenable);
55
+ updateHistory?.();
56
+ if (scrollToTop) bumpScrollToTop?.();
57
+ });
58
+ }
59
+
60
+ export function commitLatestRscNavigation<T>({
61
+ navId,
62
+ getCurrentNavId,
63
+ ...input
64
+ }: CommitRscNavigationInput<T> & {
65
+ navId: number;
66
+ getCurrentNavId: () => number;
67
+ }): boolean {
68
+ if (navId !== getCurrentNavId()) return false;
69
+ commitRscNavigation(input);
70
+ return true;
71
+ }
72
+
73
+ export function observeRscNavigation<T extends PromiseLike<unknown>>({
74
+ cache,
75
+ href,
76
+ thenable,
77
+ navId,
78
+ getCurrentNavId,
79
+ isExpectedNavigationError,
80
+ onLatestError,
81
+ }: {
82
+ cache: RscNavigationCache<T>;
83
+ href: string;
84
+ thenable: T;
85
+ navId: number;
86
+ getCurrentNavId: () => number;
87
+ isExpectedNavigationError?: (error: unknown) => boolean;
88
+ onLatestError: (error: unknown) => void;
89
+ }): void {
90
+ void Promise.resolve(thenable).catch((error) => {
91
+ deleteRscCacheEntryIfCurrent(cache, href, thenable);
92
+ if (isExpectedNavigationError?.(error)) return;
93
+ if (navId === getCurrentNavId()) onLatestError(error);
94
+ });
95
+ }