@mandujs/core 0.54.13 → 0.54.15

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.
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
3
+ import path from "path";
4
+ import { generateManifest } from "./fs-routes";
5
+
6
+ const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
7
+
8
+ async function mkRepoTempDir(prefix: string): Promise<string> {
9
+ await mkdir(repoTempRoot, { recursive: true });
10
+ return mkdtemp(path.join(repoTempRoot, prefix));
11
+ }
12
+
13
+ describe("generateManifest hydration config", () => {
14
+ it("does not preserve stale island hydration after the client entry disappears", async () => {
15
+ const rootDir = await mkRepoTempDir("routes-hydration-stale-");
16
+ try {
17
+ await mkdir(path.join(rootDir, "app", "candidates", "[id]"), { recursive: true });
18
+ await mkdir(path.join(rootDir, "src", "client", "widgets"), { recursive: true });
19
+ await writeFile(
20
+ path.join(rootDir, "app", "candidates", "[id]", "page.tsx"),
21
+ `
22
+ import { PledgeAccordion } from "@/client/widgets/PledgeAccordion.client";
23
+ export default function Page() {
24
+ return <main><PledgeAccordion pledges={[]} /></main>;
25
+ }
26
+ `,
27
+ "utf-8",
28
+ );
29
+ await writeFile(
30
+ path.join(rootDir, "src", "client", "widgets", "PledgeAccordion.client.tsx"),
31
+ `
32
+ "use client";
33
+ export function PledgeAccordion() {
34
+ return <div />;
35
+ }
36
+ `,
37
+ "utf-8",
38
+ );
39
+
40
+ const first = await generateManifest(rootDir);
41
+ const firstRoute = first.manifest.routes.find((route) => route.id === "candidates-$id");
42
+ expect(firstRoute?.clientModule).toContain("PledgeAccordion.client.tsx");
43
+ expect(firstRoute?.hydration?.strategy).toBe("island");
44
+
45
+ await writeFile(
46
+ path.join(rootDir, "app", "candidates", "[id]", "page.tsx"),
47
+ `
48
+ export default function Page() {
49
+ return <main><details><summary>server only</summary></details></main>;
50
+ }
51
+ `,
52
+ "utf-8",
53
+ );
54
+
55
+ const second = await generateManifest(rootDir);
56
+ const secondRoute = second.manifest.routes.find((route) => route.id === "candidates-$id");
57
+ expect(secondRoute?.clientModule).toBeUndefined();
58
+ expect(secondRoute?.hydration?.strategy).not.toBe("island");
59
+ } finally {
60
+ await rm(rootDir, { recursive: true, force: true });
61
+ }
62
+ });
63
+
64
+ it("reads page-level hydration exports from the current page source", async () => {
65
+ const rootDir = await mkRepoTempDir("routes-hydration-export-");
66
+ try {
67
+ await mkdir(path.join(rootDir, "app", "about"), { recursive: true });
68
+ await writeFile(
69
+ path.join(rootDir, "app", "about", "page.tsx"),
70
+ `
71
+ export const hydration = { strategy: "none", priority: "idle", preload: true };
72
+ export default function Page() {
73
+ return <main>About</main>;
74
+ }
75
+ `,
76
+ "utf-8",
77
+ );
78
+
79
+ const result = await generateManifest(rootDir);
80
+ const route = result.manifest.routes.find((entry) => entry.id === "about");
81
+ expect(route?.hydration).toEqual({
82
+ strategy: "none",
83
+ priority: "idle",
84
+ preload: true,
85
+ });
86
+ } finally {
87
+ await rm(rootDir, { recursive: true, force: true });
88
+ }
89
+ });
90
+ });
@@ -94,10 +94,12 @@ export function fsRouteToRouteSpec(fsRoute: FSRouteConfig): RouteSpec {
94
94
  hydration: fsRoute.hydration ?? {
95
95
  strategy: "island" as const,
96
96
  priority: "immediate" as const,
97
- preload: false,
98
- },
99
- }
100
- : {}),
97
+ preload: false,
98
+ },
99
+ }
100
+ : fsRoute.hydration
101
+ ? { hydration: fsRoute.hydration }
102
+ : {}),
101
103
  ...(fsRoute.layoutChain && fsRoute.layoutChain.length > 0
102
104
  ? { layoutChain: fsRoute.layoutChain.map(normalizePath) }
103
105
  : {}),
@@ -322,9 +324,13 @@ export async function generateManifest(
322
324
  route.clientModule = prev.clientModule;
323
325
  route.clientExportName = prev.clientExportName;
324
326
  }
325
- if (prev.hydration && !route.hydration) {
326
- route.hydration = prev.hydration;
327
- }
327
+ if (prev.hydration && !route.hydration) {
328
+ const canPreserveHydration =
329
+ !!route.clientModule || prev.hydration.strategy === "none";
330
+ if (canPreserveHydration) {
331
+ route.hydration = prev.hydration;
332
+ }
333
+ }
328
334
  // Issue #214 — preserve prerender-time fields (`dynamicParams`,
329
335
  // `staticParams`) across manifest rescans. `mandu build`'s prerender
330
336
  // phase stamps these onto the manifest; a subsequent `mandu dev`
@@ -28,16 +28,55 @@ import {
28
28
  sortRoutesByPriority,
29
29
  getPatternShape,
30
30
  } from "./fs-patterns";
31
- import { mark, measure } from "../perf";
32
- import { METADATA_ROUTES } from "../routes/types";
31
+ import { mark, measure } from "../perf";
32
+ import { METADATA_ROUTES } from "../routes/types";
33
+ import type { HydrationConfig } from "../spec/schema";
33
34
  import {
34
35
  hasUseClientDirective,
35
36
  resolveRouteLevelClientEntry,
36
37
  } from "./client-entry";
37
-
38
- // ═══════════════════════════════════════════════════════════════════════════
39
- // Scanner Class
40
- // ═══════════════════════════════════════════════════════════════════════════
38
+
39
+ const HYDRATION_STRATEGIES = new Set(["none", "island", "full", "progressive"]);
40
+ const HYDRATION_PRIORITIES = new Set(["immediate", "visible", "idle", "interaction"]);
41
+
42
+ function parsePageHydrationConfig(source: string): HydrationConfig | undefined {
43
+ const stringMatch = source.match(
44
+ /export\s+const\s+hydration\s*(?::[^=]+)?=\s*["'](none|island|full|progressive)["']/m,
45
+ );
46
+ if (stringMatch?.[1]) {
47
+ return {
48
+ strategy: stringMatch[1] as HydrationConfig["strategy"],
49
+ priority: "visible",
50
+ preload: false,
51
+ };
52
+ }
53
+
54
+ const objectMatch = source.match(
55
+ /export\s+const\s+hydration\s*(?::[^=]+)?=\s*\{([\s\S]*?)\}\s*;?/m,
56
+ );
57
+ const body = objectMatch?.[1];
58
+ if (!body) return undefined;
59
+
60
+ const strategyMatch = body.match(/\bstrategy\s*:\s*["']([^"']+)["']/);
61
+ const strategy = strategyMatch?.[1];
62
+ if (!strategy || !HYDRATION_STRATEGIES.has(strategy)) return undefined;
63
+
64
+ const priorityMatch = body.match(/\bpriority\s*:\s*["']([^"']+)["']/);
65
+ const priority = priorityMatch?.[1];
66
+ const preloadMatch = body.match(/\bpreload\s*:\s*(true|false)\b/);
67
+
68
+ return {
69
+ strategy: strategy as HydrationConfig["strategy"],
70
+ priority: HYDRATION_PRIORITIES.has(priority ?? "")
71
+ ? (priority as HydrationConfig["priority"])
72
+ : "visible",
73
+ preload: preloadMatch?.[1] === "true",
74
+ };
75
+ }
76
+
77
+ // ═══════════════════════════════════════════════════════════════════════════
78
+ // Scanner Class
79
+ // ═══════════════════════════════════════════════════════════════════════════
41
80
 
42
81
  /**
43
82
  * FS Routes 스캐너
@@ -367,15 +406,19 @@ export class FSScanner {
367
406
  // clientModule 결정: island 파일 또는 "use client"가 있는 page 자체
368
407
  let clientModule: string | undefined;
369
408
  let clientExportName: string | undefined;
409
+ let hydration: HydrationConfig | undefined;
370
410
  let pageFileContent: string | null = null;
371
-
372
- if (file.type === "page") {
373
- try {
374
- pageFileContent = await Bun.file(file.absolutePath).text();
375
- } catch {
376
- pageFileContent = null;
377
- }
378
- }
411
+
412
+ if (file.type === "page") {
413
+ try {
414
+ pageFileContent = await Bun.file(file.absolutePath).text();
415
+ } catch {
416
+ pageFileContent = null;
417
+ }
418
+ if (pageFileContent) {
419
+ hydration = parsePageHydrationConfig(pageFileContent);
420
+ }
421
+ }
379
422
 
380
423
  if (islands?.[0]) {
381
424
  // 우선순위: 명시적 island 파일
@@ -422,9 +465,10 @@ export class FSScanner {
422
465
  componentModule: file.type === "page" ? modulePath : undefined,
423
466
  clientModule,
424
467
  clientExportName,
468
+ hydration,
425
469
  layoutChain,
426
- loadingModule,
427
- errorModule,
470
+ loadingModule,
471
+ errorModule,
428
472
  notFoundModule,
429
473
  sourceFile: file.absolutePath,
430
474
  };
@@ -78,6 +78,30 @@ describe("runtime page render response orchestration", () => {
78
78
  expect(html).toContain("Stream Page");
79
79
  });
80
80
 
81
+ it("emits canonical data-hydrate attributes on streaming island wrappers", async () => {
82
+ const response = await renderPageResponse({
83
+ app: React.createElement("main", null, "stream-hydrated-page"),
84
+ useStreaming: true,
85
+ title: "Stream Hydrated Page",
86
+ headTags: "",
87
+ isDev: false,
88
+ routeId: "home",
89
+ routePattern: "/",
90
+ loaderData: { ok: true },
91
+ hydration: { strategy: "island", priority: "interaction", preload: false },
92
+ bundleManifest: HYDRATED_MANIFEST,
93
+ transitions: false,
94
+ prefetch: false,
95
+ spa: false,
96
+ devtools: false,
97
+ });
98
+
99
+ const html = await response.text();
100
+ expect(html).toContain('data-mandu-island="home"');
101
+ expect(html).toContain('data-mandu-priority="interaction"');
102
+ expect(html).toContain('data-hydrate="interaction"');
103
+ });
104
+
81
105
  it("serializes non-streaming loaderData as the route server data exactly once", async () => {
82
106
  const response = await renderPageResponse({
83
107
  app: React.createElement("main", null, "hydrated-page"),
@@ -100,4 +124,174 @@ describe("runtime page render response orchestration", () => {
100
124
  expect(html).toContain('"home":{"serverData":{"items":["a","b"]}');
101
125
  expect(html).not.toContain('"serverData":{"home"');
102
126
  });
127
+
128
+ it("serializes props for inline client components rendered by async server pages", async () => {
129
+ function PledgeAccordion({ pledges }: { pledges: Array<{ id: string; title: string }> }) {
130
+ return React.createElement(
131
+ "ul",
132
+ null,
133
+ pledges.map((pledge) => React.createElement("li", { key: pledge.id }, pledge.title)),
134
+ );
135
+ }
136
+
137
+ async function CandidatePage() {
138
+ const pledges = [
139
+ { id: "p1", title: "Public transit" },
140
+ { id: "p2", title: "Housing" },
141
+ ];
142
+ return React.createElement(
143
+ "main",
144
+ null,
145
+ React.createElement(PledgeAccordion, { pledges }),
146
+ );
147
+ }
148
+
149
+ const response = await renderPageResponse({
150
+ app: React.createElement(CandidatePage),
151
+ useStreaming: false,
152
+ title: "Candidate",
153
+ headTags: "",
154
+ isDev: false,
155
+ routeId: "candidates-$id",
156
+ routePattern: "/candidates/:id",
157
+ hydration: { strategy: "island", priority: "immediate", preload: false },
158
+ bundleManifest: {
159
+ ...HYDRATED_MANIFEST,
160
+ bundles: {
161
+ "candidates-$id": {
162
+ js: "/.mandu/client/candidates-$id.island.js",
163
+ dependencies: ["_runtime", "_react"],
164
+ priority: "immediate",
165
+ },
166
+ },
167
+ },
168
+ loaderData: undefined,
169
+ transitions: false,
170
+ prefetch: false,
171
+ spa: false,
172
+ devtools: false,
173
+ inlineClientHydration: {
174
+ routeId: "candidates-$id",
175
+ src: "/.mandu/client/candidates-$id.island.js",
176
+ priority: "immediate",
177
+ component: PledgeAccordion,
178
+ },
179
+ });
180
+
181
+ const html = await response.text();
182
+ expect(html).toContain('data-mandu-island="candidates-$id--0"');
183
+ expect(html).toContain('data-mandu-src="/.mandu/client/candidates-$id.island.js"');
184
+ expect(html).toContain("&quot;pledges&quot;");
185
+ expect(html).toContain("Public transit");
186
+ expect(html).not.toContain('data-mandu-island="candidates-$id"');
187
+ });
188
+
189
+ it("does not invoke sync function components while looking for inline client targets", async () => {
190
+ function ClientWidget({ label }: { label: string }) {
191
+ return React.createElement("button", null, label);
192
+ }
193
+
194
+ function HookPage() {
195
+ const id = React.useId();
196
+ return React.createElement("main", { id }, React.createElement(ClientWidget, { label: "Click" }));
197
+ }
198
+
199
+ const response = await renderPageResponse({
200
+ app: React.createElement(HookPage),
201
+ useStreaming: false,
202
+ title: "Hook Page",
203
+ headTags: "",
204
+ isDev: false,
205
+ routeId: "hook-page",
206
+ routePattern: "/hook",
207
+ hydration: { strategy: "island", priority: "visible", preload: false },
208
+ bundleManifest: {
209
+ ...HYDRATED_MANIFEST,
210
+ bundles: {
211
+ "hook-page": {
212
+ js: "/.mandu/client/hook-page.island.js",
213
+ dependencies: ["_runtime", "_react"],
214
+ priority: "visible",
215
+ },
216
+ },
217
+ },
218
+ loaderData: undefined,
219
+ transitions: false,
220
+ prefetch: false,
221
+ spa: false,
222
+ devtools: false,
223
+ inlineClientHydration: {
224
+ routeId: "hook-page",
225
+ src: "/.mandu/client/hook-page.island.js",
226
+ priority: "visible",
227
+ component: ClientWidget,
228
+ },
229
+ });
230
+
231
+ const html = await response.text();
232
+ expect(html).toContain("Hook Page");
233
+ expect(html).toContain("Click");
234
+ expect(html).toContain('data-mandu-island="hook-page"');
235
+ expect(html).not.toContain('data-mandu-island="hook-page--0"');
236
+ });
237
+
238
+ it("assigns inline client island IDs in document order", async () => {
239
+ function ClientWidget({ label }: { label: string }) {
240
+ return React.createElement("button", null, label);
241
+ }
242
+
243
+ async function SlowSection() {
244
+ await new Promise((resolve) => setTimeout(resolve, 5));
245
+ return React.createElement(ClientWidget, { label: "first" });
246
+ }
247
+
248
+ async function FastSection() {
249
+ return React.createElement(ClientWidget, { label: "second" });
250
+ }
251
+
252
+ async function OrderedPage() {
253
+ return [
254
+ React.createElement(SlowSection, { key: "slow" }),
255
+ React.createElement(FastSection, { key: "fast" }),
256
+ ];
257
+ }
258
+
259
+ const response = await renderPageResponse({
260
+ app: React.createElement(OrderedPage),
261
+ useStreaming: false,
262
+ title: "Ordered",
263
+ headTags: "",
264
+ isDev: false,
265
+ routeId: "ordered",
266
+ routePattern: "/ordered",
267
+ hydration: { strategy: "island", priority: "visible", preload: false },
268
+ bundleManifest: {
269
+ ...HYDRATED_MANIFEST,
270
+ bundles: {
271
+ ordered: {
272
+ js: "/.mandu/client/ordered.island.js",
273
+ dependencies: ["_runtime", "_react"],
274
+ priority: "visible",
275
+ },
276
+ },
277
+ },
278
+ loaderData: undefined,
279
+ transitions: false,
280
+ prefetch: false,
281
+ spa: false,
282
+ devtools: false,
283
+ inlineClientHydration: {
284
+ routeId: "ordered",
285
+ src: "/.mandu/client/ordered.island.js",
286
+ priority: "visible",
287
+ component: ClientWidget,
288
+ },
289
+ });
290
+
291
+ const html = await response.text();
292
+ expect(html.indexOf('data-mandu-island="ordered--0"')).toBeLessThan(
293
+ html.indexOf('data-mandu-island="ordered--1"'),
294
+ );
295
+ expect(html.indexOf("first")).toBeLessThan(html.indexOf("second"));
296
+ });
103
297
  });
@@ -1,8 +1,16 @@
1
- import type React from "react";
1
+ import React from "react";
2
2
  import type { BundleManifest } from "../bundler/types";
3
3
  import type { HydrationConfig } from "../spec/schema";
4
4
  import type { CookieManager } from "../filling/context";
5
5
  import { renderSSR, renderStreamingResponse, resolveAsyncElement } from "./ssr";
6
+ import { serializeProps } from "../client/serialize";
7
+
8
+ export interface InlineClientHydrationTarget {
9
+ routeId: string;
10
+ src: string;
11
+ priority: NonNullable<HydrationConfig["priority"]>;
12
+ component: unknown;
13
+ }
6
14
 
7
15
  export interface PageRenderResponseOptions {
8
16
  app: React.ReactElement;
@@ -23,6 +31,7 @@ export interface PageRenderResponseOptions {
23
31
  spa?: boolean;
24
32
  devtools?: boolean;
25
33
  islandPreWrapped?: boolean;
34
+ inlineClientHydration?: InlineClientHydrationTarget;
26
35
  cookies?: CookieManager;
27
36
  }
28
37
 
@@ -30,18 +39,111 @@ export async function renderPageResponse(
30
39
  options: PageRenderResponseOptions
31
40
  ): Promise<Response> {
32
41
  let app = options.app;
42
+ let islandPreWrapped = !!options.islandPreWrapped;
33
43
 
34
44
  if (!options.useStreaming) {
35
- app = (await resolveAsyncElement(app)) as React.ReactElement;
45
+ if (options.inlineClientHydration) {
46
+ const resolved = await resolveAndWrapInlineClientHydration(
47
+ app,
48
+ options.inlineClientHydration,
49
+ );
50
+ app = resolved.node as React.ReactElement;
51
+ islandPreWrapped = islandPreWrapped || resolved.didWrap;
52
+ } else {
53
+ app = (await resolveAsyncElement(app)) as React.ReactElement;
54
+ }
36
55
  }
37
56
 
57
+ const effectiveOptions = islandPreWrapped === !!options.islandPreWrapped
58
+ ? options
59
+ : { ...options, islandPreWrapped };
60
+
38
61
  const response = options.useStreaming
39
- ? await renderStreamingPageResponse(app, options)
40
- : renderNonStreamingPageResponse(app, options);
62
+ ? await renderStreamingPageResponse(app, effectiveOptions)
63
+ : renderNonStreamingPageResponse(app, effectiveOptions);
41
64
 
42
65
  return options.cookies ? options.cookies.applyToResponse(response) : response;
43
66
  }
44
67
 
68
+ async function resolveAndWrapInlineClientHydration(
69
+ node: React.ReactNode,
70
+ target: InlineClientHydrationTarget,
71
+ counter = { value: 0 },
72
+ ): Promise<{ node: React.ReactNode; didWrap: boolean }> {
73
+ if (node == null || typeof node !== "object") {
74
+ return { node, didWrap: false };
75
+ }
76
+
77
+ if (Array.isArray(node)) {
78
+ let didWrap = false;
79
+ const children: React.ReactNode[] = [];
80
+ for (const child of node) {
81
+ const result = await resolveAndWrapInlineClientHydration(child, target, counter);
82
+ didWrap = didWrap || result.didWrap;
83
+ children.push(result.node);
84
+ }
85
+ return { node: children, didWrap };
86
+ }
87
+
88
+ if (!React.isValidElement(node)) {
89
+ return { node, didWrap: false };
90
+ }
91
+
92
+ const element = node as React.ReactElement<Record<string, unknown>>;
93
+ const type = element.type;
94
+
95
+ if (type === target.component) {
96
+ const id = `${target.routeId}--${counter.value++}`;
97
+ return {
98
+ node: React.createElement(
99
+ "div",
100
+ {
101
+ "data-mandu-island": id,
102
+ "data-mandu-src": target.src,
103
+ "data-mandu-priority": target.priority,
104
+ "data-hydrate": priorityToHydrate(target.priority),
105
+ "data-props": serializeProps(element.props ?? {}),
106
+ style: { display: "contents" },
107
+ },
108
+ element,
109
+ ),
110
+ didWrap: true,
111
+ };
112
+ }
113
+
114
+ if (typeof type === "function" && isAsyncFunctionComponent(type)) {
115
+ const rendered = await (type as (props: Record<string, unknown>) => React.ReactNode | Promise<React.ReactNode>)(
116
+ element.props ?? {},
117
+ );
118
+ return resolveAndWrapInlineClientHydration(rendered, target, counter);
119
+ }
120
+
121
+ const props = element.props;
122
+ const rawChildren = props?.children as React.ReactNode | undefined;
123
+ if (rawChildren === undefined) {
124
+ return { node: element, didWrap: false };
125
+ }
126
+
127
+ const resolvedChildren = await resolveAndWrapInlineClientHydration(rawChildren, target, counter);
128
+ if (!resolvedChildren.didWrap && resolvedChildren.node === rawChildren) {
129
+ return { node: element, didWrap: false };
130
+ }
131
+
132
+ const cloned = Array.isArray(resolvedChildren.node)
133
+ ? React.cloneElement(element, undefined, ...resolvedChildren.node)
134
+ : React.cloneElement(element, undefined, resolvedChildren.node);
135
+ return { node: cloned, didWrap: resolvedChildren.didWrap };
136
+ }
137
+
138
+ function isAsyncFunctionComponent(type: Function): boolean {
139
+ return !type.prototype?.isReactComponent &&
140
+ (type as { constructor?: { name?: string } }).constructor?.name === "AsyncFunction";
141
+ }
142
+
143
+ function priorityToHydrate(priority: InlineClientHydrationTarget["priority"]): string {
144
+ return priority === "immediate" ? "load" : priority;
145
+ }
146
+
45
147
  async function renderStreamingPageResponse(
46
148
  app: React.ReactElement,
47
149
  options: PageRenderResponseOptions
@@ -157,10 +157,10 @@ describe("Wildcard Matching", () => {
157
157
 
158
158
  const result = router.match("/docs");
159
159
 
160
- expect(result).not.toBeNull();
161
- expect(result!.route.id).toBe("docs");
162
- expect(result!.params).toEqual({});
163
- });
160
+ expect(result).not.toBeNull();
161
+ expect(result!.route.id).toBe("docs");
162
+ expect(result!.params).toEqual({ path: "" });
163
+ });
164
164
 
165
165
  test("optional wildcard matches with remaining path", () => {
166
166
  const router = createRouter([
@@ -415,12 +415,10 @@ export class Router {
415
415
  route,
416
416
  };
417
417
 
418
- // Optional wildcard: 현재 노드도 매칭 가능하게 route 설정
419
- if (isOptional && !node.route) {
420
- node.route = route;
421
- }
422
- return;
423
- }
418
+ // Optional wildcard base paths are matched from wildcardConfig so
419
+ // the named wildcard param is still materialized as an empty string.
420
+ return;
421
+ }
424
422
 
425
423
  // Regular parameter: :param
426
424
  const paramName = seg.slice(1);
@@ -528,12 +526,12 @@ export class Router {
528
526
  if (node.wildcardConfig.optional) {
529
527
  if (this.debug) {
530
528
  console.log(`[Router] Optional wildcard match: ${node.wildcardConfig.route.id} with empty path`);
531
- }
532
- return {
533
- route: node.wildcardConfig.route,
534
- params,
535
- };
536
- }
529
+ }
530
+ return {
531
+ route: node.wildcardConfig.route,
532
+ params: { ...params, [node.wildcardConfig.name]: "" },
533
+ };
534
+ }
537
535
  // Non-optional wildcard: /files/:path* does NOT match /files
538
536
  if (this.debug) {
539
537
  console.log(`[Router] Wildcard policy: ${pathname} does not match non-optional wildcard`);