@mandujs/core 0.54.18 โ†’ 0.54.19

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.
@@ -1,404 +1,50 @@
1
- /**
2
- * Mandu Props Serialization ๐Ÿ“ฆ
3
- * Fresh ์Šคํƒ€์ผ ๊ณ ๊ธ‰ ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™”
4
- *
5
- * @see https://fresh.deno.dev/docs/concepts/islands
6
- *
7
- * ์ง€์› ํƒ€์ž…:
8
- * - ์›์‹œํ˜•: null, boolean, number, string, bigint, undefined
9
- * - ํŠน์ˆ˜ ๊ฐ์ฒด: Date, URL, RegExp, Map, Set
10
- * - ์ˆœํ™˜ ์ฐธ์กฐ
11
- * - ์ค‘์ฒฉ ๊ฐ์ฒด/๋ฐฐ์—ด
12
- */
13
-
14
- // ============================================
15
- // ํƒ€์ž… ๋งˆ์ปค
16
- // ============================================
17
-
18
- const TYPE_MARKERS = {
19
- /** undefined */
20
- UNDEFINED: "\x00_",
21
- /** Date */
22
- DATE: "\x00D",
23
- /** URL */
24
- URL: "\x00U",
25
- /** RegExp */
26
- REGEXP: "\x00R",
27
- /** Map */
28
- MAP: "\x00M",
29
- /** Set */
30
- SET: "\x00S",
31
- /** ์ˆœํ™˜ ์ฐธ์กฐ */
32
- REF: "\x00$",
33
- /** BigInt */
34
- BIGINT: "\x00B",
35
- /** Symbol (์ œํ•œ์  ์ง€์›) */
36
- SYMBOL: "\x00Y",
37
- /** Error */
38
- ERROR: "\x00E",
39
- } as const;
40
-
41
- // ============================================
42
- // ์ง๋ ฌํ™”
43
- // ============================================
44
-
45
- /**
46
- * ์ง๋ ฌํ™” ์ปจํ…์ŠคํŠธ (์ˆœํ™˜ ์ฐธ์กฐ ์ถ”์ )
47
- */
48
- interface SerializeContext {
49
- /** ์ด๋ฏธ ๋ณธ ๊ฐ์ฒด โ†’ ์ธ๋ฑ์Šค */
50
- seen: Map<object, number>;
51
- /** ์ฐธ์กฐ ํ…Œ์ด๋ธ” */
52
- refs: object[];
53
- }
54
-
55
- /**
56
- * Props ์ง๋ ฌํ™”
57
- *
58
- * @example
59
- * ```typescript
60
- * const props = {
61
- * date: new Date(),
62
- * url: new URL('https://example.com'),
63
- * items: new Set([1, 2, 3]),
64
- * cache: new Map([['key', 'value']]),
65
- * };
66
- *
67
- * const json = serializeProps(props);
68
- * // ํด๋ผ์ด์–ธํŠธ๋กœ ์ „์†ก
69
- * ```
70
- */
71
- export function serializeProps(props: Record<string, unknown>): string {
72
- const ctx: SerializeContext = { seen: new Map(), refs: [] };
73
- return JSON.stringify(serialize(props, ctx));
74
- }
75
-
76
- /**
77
- * ๊ฐ’ ์ง๋ ฌํ™” (์žฌ๊ท€)
78
- */
79
- function serialize(value: unknown, ctx: SerializeContext): unknown {
80
- // null
81
- if (value === null) return null;
82
-
83
- // undefined
84
- if (value === undefined) return TYPE_MARKERS.UNDEFINED;
85
-
86
- // ์›์‹œํ˜•
87
- if (typeof value === "boolean" || typeof value === "number") {
88
- return value;
89
- }
90
-
91
- if (typeof value === "string") {
92
- // ํƒ€์ž… ๋งˆ์ปค์™€ ์ถฉ๋Œ ๋ฐฉ์ง€ (์ฒซ ๋ฌธ์ž๊ฐ€ \x00์ธ ๊ฒฝ์šฐ)
93
- if (value.startsWith("\x00")) {
94
- return "\x00\x00" + value;
95
- }
96
- return value;
97
- }
98
-
99
- if (typeof value === "bigint") {
100
- return TYPE_MARKERS.BIGINT + value.toString();
101
- }
102
-
103
- if (typeof value === "symbol") {
104
- // Symbol์€ description๋งŒ ๋ณด์กด
105
- return TYPE_MARKERS.SYMBOL + (value.description ?? "");
106
- }
107
-
108
- // ํ•จ์ˆ˜๋Š” ์ง๋ ฌํ™” ๋ถˆ๊ฐ€
109
- if (typeof value === "function") {
110
- console.warn("[Mandu Serialize] Functions cannot be serialized, skipping");
111
- return undefined;
112
- }
113
-
114
- // ๊ฐ์ฒด ์ˆœํ™˜ ์ฐธ์กฐ ์ฒดํฌ
115
- if (typeof value === "object") {
116
- const existing = ctx.seen.get(value);
117
- if (existing !== undefined) {
118
- return TYPE_MARKERS.REF + existing;
119
- }
120
-
121
- const idx = ctx.refs.length;
122
- ctx.seen.set(value, idx);
123
- ctx.refs.push(value);
124
- }
125
-
126
- // Date
127
- if (value instanceof Date) {
128
- return TYPE_MARKERS.DATE + value.toISOString();
129
- }
130
-
131
- // URL
132
- if (value instanceof URL) {
133
- return TYPE_MARKERS.URL + value.href;
134
- }
135
-
136
- // RegExp
137
- if (value instanceof RegExp) {
138
- return TYPE_MARKERS.REGEXP + value.toString();
139
- }
140
-
141
- // Error
142
- if (value instanceof Error) {
143
- return [
144
- TYPE_MARKERS.ERROR,
145
- value.name,
146
- value.message,
147
- value.stack ?? "",
148
- ];
149
- }
150
-
151
- // Map
152
- if (value instanceof Map) {
153
- const entries: [unknown, unknown][] = [];
154
- for (const [k, v] of value.entries()) {
155
- entries.push([serialize(k, ctx), serialize(v, ctx)]);
156
- }
157
- return [TYPE_MARKERS.MAP, ...entries];
158
- }
159
-
160
- // Set
161
- if (value instanceof Set) {
162
- const items: unknown[] = [];
163
- for (const item of value) {
164
- items.push(serialize(item, ctx));
165
- }
166
- return [TYPE_MARKERS.SET, ...items];
167
- }
168
-
169
- // ๋ฐฐ์—ด
170
- if (Array.isArray(value)) {
171
- return value.map((item) => serialize(item, ctx));
172
- }
173
-
174
- // ์ผ๋ฐ˜ ๊ฐ์ฒด
175
- const result: Record<string, unknown> = {};
176
- for (const [k, v] of Object.entries(value as object)) {
177
- const serialized = serialize(v, ctx);
178
- if (serialized !== undefined) {
179
- result[k] = serialized;
180
- }
181
- }
182
- return result;
183
- }
184
-
185
- // ============================================
186
- // ์—ญ์ง๋ ฌํ™”
187
- // ============================================
188
-
189
- /**
190
- * ์—ญ์ง๋ ฌํ™” ์ปจํ…์ŠคํŠธ (์ˆœํ™˜ ์ฐธ์กฐ ๋ณต์›)
191
- */
192
- interface DeserializeContext {
193
- refs: unknown[];
194
- }
195
-
196
- /**
197
- * Props ์—ญ์ง๋ ฌํ™”
198
- *
199
- * @example
200
- * ```typescript
201
- * // ์„œ๋ฒ„์—์„œ ๋ฐ›์€ JSON
202
- * const json = '{"date":"\x00D2025-01-28T00:00:00.000Z"}';
203
- *
204
- * const props = deserializeProps(json);
205
- * console.log(props.date instanceof Date); // true
206
- * ```
207
- */
208
- export function deserializeProps(json: string): Record<string, unknown> {
209
- const ctx: DeserializeContext = { refs: [] };
210
- const parsed = JSON.parse(json);
211
- return deserialize(parsed, ctx) as Record<string, unknown>;
212
- }
213
-
214
- /**
215
- * ๊ฐ’ ์—ญ์ง๋ ฌํ™” (์žฌ๊ท€)
216
- */
217
- function deserialize(value: unknown, ctx: DeserializeContext): unknown {
218
- // null
219
- if (value === null) return null;
220
-
221
- // ๋ฌธ์ž์—ด โ†’ ํƒ€์ž… ๋งˆ์ปค ์ฒดํฌ
222
- if (typeof value === "string") {
223
- // undefined
224
- if (value === TYPE_MARKERS.UNDEFINED) return undefined;
225
-
226
- // ์ด์Šค์ผ€์ดํ”„๋œ ๋ฌธ์ž์—ด (\x00\x00 โ†’ \x00)
227
- if (value.startsWith("\x00\x00")) {
228
- return value.slice(2);
229
- }
230
-
231
- // Date
232
- if (value.startsWith(TYPE_MARKERS.DATE)) {
233
- return new Date(value.slice(2));
234
- }
235
-
236
- // URL
237
- if (value.startsWith(TYPE_MARKERS.URL)) {
238
- return new URL(value.slice(2));
239
- }
240
-
241
- // RegExp
242
- if (value.startsWith(TYPE_MARKERS.REGEXP)) {
243
- const str = value.slice(2);
244
- const match = str.match(/^\/(.*)\/([gimsuy]*)$/);
245
- if (match) {
246
- return new RegExp(match[1], match[2]);
247
- }
248
- return str; // ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ ๋ฌธ์ž์—ด ๋ฐ˜ํ™˜
249
- }
250
-
251
- // BigInt
252
- if (value.startsWith(TYPE_MARKERS.BIGINT)) {
253
- return BigInt(value.slice(2));
254
- }
255
-
256
- // Symbol
257
- if (value.startsWith(TYPE_MARKERS.SYMBOL)) {
258
- return Symbol(value.slice(2));
259
- }
260
-
261
- // ์ˆœํ™˜ ์ฐธ์กฐ
262
- if (value.startsWith(TYPE_MARKERS.REF)) {
263
- const idx = parseInt(value.slice(2), 10);
264
- return ctx.refs[idx];
265
- }
266
-
267
- return value;
268
- }
269
-
270
- // ์›์‹œํ˜•
271
- if (typeof value === "boolean" || typeof value === "number") {
272
- return value;
273
- }
274
-
275
- // ๋ฐฐ์—ด โ†’ ํŠน์ˆ˜ ํƒ€์ž… ์ฒดํฌ
276
- if (Array.isArray(value)) {
277
- const marker = value[0];
278
-
279
- // Error
280
- if (marker === TYPE_MARKERS.ERROR) {
281
- const [, name, message, stack] = value as [string, string, string, string];
282
- const error = new Error(message);
283
- error.name = name;
284
- if (stack) error.stack = stack;
285
- ctx.refs.push(error);
286
- return error;
287
- }
288
-
289
- // Map
290
- if (marker === TYPE_MARKERS.MAP) {
291
- const map = new Map();
292
- ctx.refs.push(map);
293
- for (let i = 1; i < value.length; i++) {
294
- const [k, v] = value[i] as [unknown, unknown];
295
- map.set(deserialize(k, ctx), deserialize(v, ctx));
296
- }
297
- return map;
298
- }
299
-
300
- // Set
301
- if (marker === TYPE_MARKERS.SET) {
302
- const set = new Set();
303
- ctx.refs.push(set);
304
- for (let i = 1; i < value.length; i++) {
305
- set.add(deserialize(value[i], ctx));
306
- }
307
- return set;
308
- }
309
-
310
- // ์ผ๋ฐ˜ ๋ฐฐ์—ด
311
- const arr: unknown[] = [];
312
- ctx.refs.push(arr);
313
- for (const item of value) {
314
- arr.push(deserialize(item, ctx));
315
- }
316
- return arr;
317
- }
318
-
319
- // ์ผ๋ฐ˜ ๊ฐ์ฒด
320
- if (typeof value === "object") {
321
- const obj: Record<string, unknown> = {};
322
- ctx.refs.push(obj);
323
- for (const [k, v] of Object.entries(value)) {
324
- obj[k] = deserialize(v, ctx);
325
- }
326
- return obj;
327
- }
328
-
329
- return value;
330
- }
331
-
332
- // ============================================
333
- // ์œ ํ‹ธ๋ฆฌํ‹ฐ
334
- // ============================================
335
-
336
- /**
337
- * ์ง๋ ฌํ™” ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ์ฒดํฌ
338
- */
339
- export function isSerializable(value: unknown): boolean {
340
- if (value === null || value === undefined) return true;
341
-
342
- const type = typeof value;
343
- if (type === "boolean" || type === "number" || type === "string" || type === "bigint") {
344
- return true;
345
- }
346
-
347
- if (type === "function" || type === "symbol") {
348
- return false;
349
- }
350
-
351
- if (value instanceof Date || value instanceof URL || value instanceof RegExp) {
352
- return true;
353
- }
354
-
355
- if (value instanceof Map || value instanceof Set) {
356
- return true;
357
- }
358
-
359
- if (Array.isArray(value)) {
360
- return value.every(isSerializable);
361
- }
362
-
363
- if (type === "object") {
364
- return Object.values(value as object).every(isSerializable);
365
- }
366
-
367
- return false;
368
- }
369
-
370
- /**
371
- * SSR์—์„œ ํด๋ผ์ด์–ธํŠธ๋กœ props ์ „๋‹ฌ์šฉ ์Šคํฌ๋ฆฝํŠธ ์ƒ์„ฑ
372
- */
373
- export function generatePropsScript(
374
- islandId: string,
375
- props: Record<string, unknown>
376
- ): string {
377
- const json = serializeProps(props);
378
- const escaped = json
379
- .replace(/</g, "\\u003c")
380
- .replace(/>/g, "\\u003e")
381
- .replace(/&/g, "\\u0026");
382
-
383
- return `<script type="application/json" data-mandu-props="${islandId}">${escaped}</script>`;
384
- }
385
-
386
- /**
387
- * ํด๋ผ์ด์–ธํŠธ์—์„œ props ์Šคํฌ๋ฆฝํŠธ ํŒŒ์‹ฑ
388
- */
389
- export function parsePropsScript(islandId: string): Record<string, unknown> | null {
390
- if (typeof document === "undefined") return null;
391
-
392
- const script = document.querySelector(
393
- `script[data-mandu-props="${islandId}"]`
394
- ) as HTMLScriptElement | null;
395
-
396
- if (!script?.textContent) return null;
397
-
398
- try {
399
- return deserializeProps(script.textContent);
400
- } catch (err) {
401
- console.error(`[Mandu] Failed to parse props for island ${islandId}:`, err);
402
- return null;
403
- }
404
- }
1
+ /**
2
+ * Mandu props serialization public API.
3
+ *
4
+ * Core serialization/deserialization lives in `props-serialization.ts` so the
5
+ * browser runtime and server render path share exactly one wire format.
6
+ */
7
+
8
+ import { deserializeProps, serializeProps } from "./props-serialization";
9
+
10
+ export {
11
+ deserializeProps,
12
+ isSerializable,
13
+ serializeProps,
14
+ } from "./props-serialization";
15
+
16
+ /**
17
+ * SSR์—์„œ ํด๋ผ์ด์–ธํŠธ๋กœ props ์ „๋‹ฌ์šฉ ์Šคํฌ๋ฆฝํŠธ ์ƒ์„ฑ
18
+ */
19
+ export function generatePropsScript(
20
+ islandId: string,
21
+ props: Record<string, unknown>
22
+ ): string {
23
+ const json = serializeProps(props);
24
+ const escaped = json
25
+ .replace(/</g, "\\u003c")
26
+ .replace(/>/g, "\\u003e")
27
+ .replace(/&/g, "\\u0026");
28
+
29
+ return `<script type="application/json" data-mandu-props="${islandId}">${escaped}</script>`;
30
+ }
31
+
32
+ /**
33
+ * ํด๋ผ์ด์–ธํŠธ์—์„œ props ์Šคํฌ๋ฆฝํŠธ ํŒŒ์‹ฑ
34
+ */
35
+ export function parsePropsScript(islandId: string): Record<string, unknown> | null {
36
+ if (typeof document === "undefined") return null;
37
+
38
+ const script = document.querySelector(
39
+ `script[data-mandu-props="${islandId}"]`
40
+ ) as HTMLScriptElement | null;
41
+
42
+ if (!script?.textContent) return null;
43
+
44
+ try {
45
+ return deserializeProps(script.textContent);
46
+ } catch (err) {
47
+ console.error(`[Mandu] Failed to parse props for island ${islandId}:`, err);
48
+ return null;
49
+ }
50
+ }
@@ -457,6 +457,21 @@ describe("checkClientBoundaryManifests", () => {
457
457
  expect(result.details?.missingEntries).toEqual(["home--0"]);
458
458
  });
459
459
 
460
+ it("treats a missing bundle manifest as an error when routes declare compiler-owned boundaries", async () => {
461
+ await writeFile(rootDir, ".mandu/routes.manifest.json", JSON.stringify({
462
+ routes: [
463
+ {
464
+ id: "home",
465
+ boundaries: [{ id: "home--0", routeId: "home", module: "src/client/Home.client.tsx", exportName: "Home" }],
466
+ },
467
+ ],
468
+ }));
469
+ const result = await checkClientBoundaryManifests(rootDir);
470
+ expect(result.ok).toBe(false);
471
+ expect(result.severity).toBe("error");
472
+ expect(result.message).toMatch(/bundle manifest is missing/);
473
+ });
474
+
460
475
  it("passes when route and bundle boundary manifests line up", async () => {
461
476
  await writeFile(rootDir, ".mandu/routes.manifest.json", JSON.stringify({
462
477
  routes: [
@@ -964,7 +964,7 @@ export async function checkClientBoundaryManifests(rootDir: string): Promise<Dia
964
964
  return {
965
965
  ok: false,
966
966
  rule: "client_boundary_manifests",
967
- severity: "warning",
967
+ severity: "error",
968
968
  message: `Routes manifest declares ${boundaries.length} client boundary record(s), but bundle manifest is missing.`,
969
969
  suggestion: "Run `mandu build` before deploy so boundary bundles are emitted and can be checked.",
970
970
  details: { boundaryCount: boundaries.length, bundleManifestPath: path.relative(rootDir, bundleManifestPath) },
@@ -1,13 +1,14 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
3
3
  import path from "path";
4
- import {
5
- findClientComponentImports,
4
+ import {
5
+ findClientComponentImports,
6
6
  findRouteLevelClientComponentImport,
7
7
  findRouteLevelClientComponentImports,
8
8
  resolveRouteLevelClientEntryPath,
9
9
  validateClientModuleForBrowserBundle,
10
10
  } from "./client-entry";
11
+ import { analyzeRouteSource } from "./route-source-analyzer";
11
12
 
12
13
  const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
13
14
 
@@ -17,10 +18,10 @@ async function mkRepoTempDir(prefix: string): Promise<string> {
17
18
  }
18
19
 
19
20
  describe("findClientComponentImports", () => {
20
- it("detects named .client imports for diagnostics", () => {
21
- const imports = findClientComponentImports(`
22
- import { LoginForm, SubmitButton as Button } from "@/client/widgets/login-form/LoginForm.client";
23
- import Header from "./Header.client.tsx";
21
+ it("detects named .client imports for diagnostics", () => {
22
+ const imports = findClientComponentImports(`
23
+ import { LoginForm, SubmitButton as Button } from "@/client/widgets/login-form/LoginForm.client";
24
+ import Header from "./Header.client.tsx";
24
25
  `);
25
26
 
26
27
  expect(imports).toEqual([
@@ -33,9 +34,34 @@ describe("findClientComponentImports", () => {
33
34
  module: "./Header.client.tsx",
34
35
  kind: "default",
35
36
  names: ["Header"],
36
- },
37
- ]);
38
- });
37
+ },
38
+ ]);
39
+ });
40
+
41
+ it("ignores import syntax inside string literals", () => {
42
+ const imports = findClientComponentImports(`
43
+ const example = "import Fake from './Fake.client'";
44
+
45
+ export default function Page() {
46
+ return <main>{example}</main>;
47
+ }
48
+ `);
49
+
50
+ expect(imports).toEqual([]);
51
+ });
52
+
53
+ it("ignores type-only client imports", () => {
54
+ const imports = findClientComponentImports(`
55
+ import type { LoginForm } from "@/client/widgets/login-form/LoginForm.client";
56
+ import { type SubmitButton } from "@/client/widgets/login-form/LoginForm.client";
57
+
58
+ export default function Page() {
59
+ return <main />;
60
+ }
61
+ `);
62
+
63
+ expect(imports).toEqual([]);
64
+ });
39
65
 
40
66
  it("detects a default-imported client component when the page returns only that component", () => {
41
67
  const routeClient = findRouteLevelClientComponentImport(`
@@ -125,11 +151,11 @@ describe("findClientComponentImports", () => {
125
151
  });
126
152
  });
127
153
 
128
- it("promotes client imports when the page wrapper passes props", () => {
129
- const routeClient = findRouteLevelClientComponentImport(`
130
- import PledgePage from "@/client/pages/pledges/PledgePage.client";
131
-
132
- export default function Page({ params }) {
154
+ it("promotes client imports when the page wrapper passes props", () => {
155
+ const routeClient = findRouteLevelClientComponentImport(`
156
+ import PledgePage from "@/client/pages/pledges/PledgePage.client";
157
+
158
+ export default function Page({ params }) {
133
159
  return <PledgePage id={params.id} />;
134
160
  }
135
161
  `);
@@ -139,7 +165,23 @@ describe("findClientComponentImports", () => {
139
165
  localName: "PledgePage",
140
166
  exportName: "default",
141
167
  });
142
- });
168
+ });
169
+
170
+ it("detects client entries rendered by an identifier default export", () => {
171
+ const routeClient = findRouteLevelClientComponentImport(`
172
+ import PledgePage from "@/client/pages/pledges/PledgePage.client";
173
+
174
+ const Page = ({ params }) => <PledgePage id={params.id} />;
175
+
176
+ export default Page;
177
+ `);
178
+
179
+ expect(routeClient).toEqual({
180
+ module: "@/client/pages/pledges/PledgePage.client",
181
+ localName: "PledgePage",
182
+ exportName: "default",
183
+ });
184
+ });
143
185
 
144
186
  it("detects multiple rendered client imports in a server shell", () => {
145
187
  const routeClients = findRouteLevelClientComponentImports(`
@@ -188,17 +230,63 @@ describe("findClientComponentImports", () => {
188
230
  });
189
231
  });
190
232
 
191
- it("does not promote shared UI primitives to route-level client entries", () => {
192
- const routeClient = findRouteLevelClientComponentImport(`
193
- import { Button } from "@/client/shared/ui/button";
194
-
195
- export default function Page() {
233
+ it("does not promote shared UI primitives to route-level client entries", () => {
234
+ const routeClient = findRouteLevelClientComponentImport(`
235
+ import { Button } from "@/client/shared/ui/button";
236
+
237
+ export default function Page() {
196
238
  return <main><Button>Open</Button></main>;
197
239
  }
198
240
  `);
199
-
200
- expect(routeClient).toBeNull();
201
- });
241
+
242
+ expect(routeClient).toBeNull();
243
+ });
244
+
245
+ it("classifies default re-exports without treating them as rendered JSX", () => {
246
+ const analysis = analyzeRouteSource(`
247
+ export { default } from "./Page";
248
+ `);
249
+
250
+ expect(analysis.defaultExport).toMatchObject({
251
+ kind: "identifier",
252
+ localName: "default",
253
+ source: "./Page",
254
+ referencesJsx: false,
255
+ renderedJsxNames: [],
256
+ });
257
+ });
258
+
259
+ it("diagnoses default export wrapper calls conservatively", () => {
260
+ const analysis = analyzeRouteSource(`
261
+ import { memo } from "react";
262
+
263
+ const Page = () => <main />;
264
+
265
+ export default memo(Page);
266
+ `);
267
+
268
+ expect(analysis.defaultExport.kind).toBe("call");
269
+ expect(analysis.diagnostics.some((diagnostic) =>
270
+ diagnostic.code === "MANDU_ROUTE_DEFAULT_EXPORT_WRAPPER"
271
+ )).toBe(true);
272
+ });
273
+
274
+ it("records namespace imports without promoting member JSX as a route-level client entry", () => {
275
+ const source = `
276
+ import * as Widgets from "@/client/widgets/Widget.client";
277
+
278
+ export default function Page() {
279
+ return <Widgets.Panel />;
280
+ }
281
+ `;
282
+ const analysis = analyzeRouteSource(source);
283
+
284
+ expect(analysis.imports[0]).toMatchObject({
285
+ source: "@/client/widgets/Widget.client",
286
+ namespaceName: "Widgets",
287
+ });
288
+ expect(findRouteLevelClientComponentImport(source)).toBeNull();
289
+ });
202
290
 
203
291
  it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
204
292
  const rootDir = await mkRepoTempDir("client-entry-");