@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.
@@ -522,25 +522,103 @@ function generateRuntimeSource(): string {
522
522
  import React, { useState, useEffect, Component } from 'react';
523
523
  import { hydrateRoot, createRoot } from 'react-dom/client';
524
524
 
525
- // Hydrated roots 추적 (unmount용) - 전역 초기화
526
- window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
527
- const hydratedRoots = window.__MANDU_ROOTS__;
528
-
529
- // 서버 데이터
530
- function readManduData() {
531
- if (window.__MANDU_DATA__) return window.__MANDU_DATA__;
525
+ // Hydrated roots 추적 (unmount용) - 전역 초기화
526
+ window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
527
+ const hydratedRoots = window.__MANDU_ROOTS__;
528
+
529
+ const TYPE_MARKERS = {
530
+ UNDEFINED: "\\u0000_",
531
+ DATE: "\\u0000D",
532
+ URL: "\\u0000U",
533
+ REGEXP: "\\u0000R",
534
+ MAP: "\\u0000M",
535
+ SET: "\\u0000S",
536
+ REF: "\\u0000$",
537
+ BIGINT: "\\u0000B",
538
+ SYMBOL: "\\u0000Y",
539
+ ERROR: "\\u0000E",
540
+ };
541
+
542
+ function deserializeManduProps(json) {
543
+ const ctx = { refs: [] };
544
+ return deserializeManduValue(JSON.parse(json), ctx);
545
+ }
546
+
547
+ function deserializeManduValue(value, ctx) {
548
+ if (value === null) return null;
549
+ if (typeof value === 'string') {
550
+ if (value === TYPE_MARKERS.UNDEFINED) return undefined;
551
+ if (value.startsWith("\\u0000\\u0000")) return value.slice(2);
552
+ if (value.startsWith(TYPE_MARKERS.DATE)) return new Date(value.slice(2));
553
+ if (value.startsWith(TYPE_MARKERS.URL)) return new URL(value.slice(2));
554
+ if (value.startsWith(TYPE_MARKERS.REGEXP)) {
555
+ const str = value.slice(2);
556
+ const match = str.match(/^\\/(.*)\\/([gimsuy]*)$/);
557
+ return match ? new RegExp(match[1], match[2]) : str;
558
+ }
559
+ if (value.startsWith(TYPE_MARKERS.BIGINT)) return BigInt(value.slice(2));
560
+ if (value.startsWith(TYPE_MARKERS.SYMBOL)) return Symbol(value.slice(2));
561
+ if (value.startsWith(TYPE_MARKERS.REF)) return ctx.refs[parseInt(value.slice(2), 10)];
562
+ return value;
563
+ }
564
+ if (typeof value === 'boolean' || typeof value === 'number') return value;
565
+ if (Array.isArray(value)) {
566
+ const marker = value[0];
567
+ if (marker === TYPE_MARKERS.ERROR) {
568
+ const error = new Error(value[2]);
569
+ error.name = value[1];
570
+ if (value[3]) error.stack = value[3];
571
+ ctx.refs.push(error);
572
+ return error;
573
+ }
574
+ if (marker === TYPE_MARKERS.MAP) {
575
+ const map = new Map();
576
+ ctx.refs.push(map);
577
+ for (let i = 1; i < value.length; i++) {
578
+ const entry = value[i];
579
+ map.set(deserializeManduValue(entry[0], ctx), deserializeManduValue(entry[1], ctx));
580
+ }
581
+ return map;
582
+ }
583
+ if (marker === TYPE_MARKERS.SET) {
584
+ const set = new Set();
585
+ ctx.refs.push(set);
586
+ for (let i = 1; i < value.length; i++) {
587
+ set.add(deserializeManduValue(value[i], ctx));
588
+ }
589
+ return set;
590
+ }
591
+ const arr = [];
592
+ ctx.refs.push(arr);
593
+ for (const item of value) arr.push(deserializeManduValue(item, ctx));
594
+ return arr;
595
+ }
596
+ if (typeof value === 'object') {
597
+ const obj = {};
598
+ ctx.refs.push(obj);
599
+ for (const [key, nested] of Object.entries(value)) {
600
+ obj[key] = deserializeManduValue(nested, ctx);
601
+ }
602
+ return obj;
603
+ }
604
+ return value;
605
+ }
606
+
607
+ // 서버 데이터
608
+ function readManduData() {
609
+ if (window.__MANDU_DATA__) return window.__MANDU_DATA__;
532
610
 
533
611
  const raw = window.__MANDU_DATA_RAW__ || document.getElementById('__MANDU_DATA__')?.textContent;
534
612
  if (!raw) {
535
613
  window.__MANDU_DATA__ = {};
536
614
  return window.__MANDU_DATA__;
537
- }
538
-
539
- try {
540
- window.__MANDU_DATA__ = JSON.parse(raw);
541
- } catch (error) {
542
- console.warn('[Mandu] Failed to parse server data:', error);
543
- window.__MANDU_DATA__ = {};
615
+ }
616
+
617
+ try {
618
+ window.__MANDU_DATA__ = deserializeManduProps(raw);
619
+ } catch (error) {
620
+ console.warn('[Mandu] Failed to parse server data:', error);
621
+ window.__MANDU_DATA__ = {};
544
622
  }
545
623
 
546
624
  return window.__MANDU_DATA__;
@@ -681,26 +759,62 @@ function createHydrationOptions(element, id, mode) {
681
759
  };
682
760
  }
683
761
 
684
- /**
685
- * Hydration 스케줄러
686
- */
687
- function scheduleHydration(element, src, priority) {
688
- switch (priority) {
689
- case 'immediate':
690
- loadAndHydrate(element, src);
691
- break;
762
+ /**
763
+ * Hydration 스케줄러
764
+ */
765
+ function priorityToHydrateStrategy(priority) {
766
+ return priority === 'immediate' ? 'load' : priority;
767
+ }
768
+
769
+ function scheduleHydration(element, src, strategy) {
770
+ if (!strategy) strategy = 'load';
771
+ if (strategy === 'immediate') strategy = 'load';
772
+
773
+ if (strategy.startsWith('media(') && strategy.endsWith(')')) {
774
+ const query = strategy.slice('media('.length, -1).trim();
775
+ if (!query || !window.matchMedia) {
776
+ loadAndHydrate(element, src);
777
+ return;
778
+ }
779
+ const mql = window.matchMedia(query);
780
+ if (mql.matches) {
781
+ loadAndHydrate(element, src);
782
+ return;
783
+ }
784
+ const onChange = (event) => {
785
+ if (!event.matches) return;
786
+ if (mql.removeEventListener) {
787
+ mql.removeEventListener('change', onChange);
788
+ } else if (mql.removeListener) {
789
+ mql.removeListener(onChange);
790
+ }
791
+ loadAndHydrate(element, src);
792
+ };
793
+ if (mql.addEventListener) {
794
+ mql.addEventListener('change', onChange);
795
+ } else if (mql.addListener) {
796
+ mql.addListener(onChange);
797
+ }
798
+ return;
799
+ }
800
+
801
+ switch (strategy) {
802
+ case 'load':
803
+ case 'immediate':
804
+ loadAndHydrate(element, src);
805
+ break;
692
806
 
693
807
  case 'visible':
694
808
  if ('IntersectionObserver' in window) {
695
809
  const observer = new IntersectionObserver((entries) => {
696
- if (entries[0].isIntersecting) {
697
- observer.disconnect();
698
- loadAndHydrate(element, src);
699
- }
700
- }, { rootMargin: '50px' });
701
- const target = resolveHydrationTarget(element);
702
- observer.observe(target);
703
- } else {
810
+ if (entries[0].isIntersecting) {
811
+ observer.disconnect();
812
+ loadAndHydrate(element, src);
813
+ }
814
+ }, { rootMargin: '200px' });
815
+ const target = resolveHydrationTarget(element);
816
+ observer.observe(target);
817
+ } else {
704
818
  loadAndHydrate(element, src);
705
819
  }
706
820
  break;
@@ -712,26 +826,26 @@ function scheduleHydration(element, src, priority) {
712
826
  setTimeout(() => loadAndHydrate(element, src), 200);
713
827
  }
714
828
  break;
715
-
716
- case 'interaction': {
717
- const target = resolveHydrationTarget(element);
718
- const hydrate = () => {
719
- target.removeEventListener('mouseenter', hydrate);
720
- target.removeEventListener('focusin', hydrate);
721
- target.removeEventListener('touchstart', hydrate);
722
- target.removeEventListener('pointerdown', hydrate);
723
- target.removeEventListener('keydown', hydrate);
724
- loadAndHydrate(element, src);
725
- };
726
- target.addEventListener('mouseenter', hydrate, { once: true, passive: true });
727
- target.addEventListener('focusin', hydrate, { once: true });
728
- target.addEventListener('touchstart', hydrate, { once: true, passive: true });
729
- target.addEventListener('pointerdown', hydrate, { once: true, passive: true });
730
- target.addEventListener('keydown', hydrate, { once: true });
731
- break;
732
- }
733
- }
734
- }
829
+
830
+ case 'interaction': {
831
+ const target = resolveHydrationTarget(element);
832
+ const hydrate = () => {
833
+ target.removeEventListener('touchstart', hydrate);
834
+ target.removeEventListener('click', hydrate);
835
+ target.removeEventListener('keydown', hydrate);
836
+ loadAndHydrate(element, src);
837
+ };
838
+ target.addEventListener('touchstart', hydrate, { once: true, passive: true });
839
+ target.addEventListener('click', hydrate, { once: true });
840
+ target.addEventListener('keydown', hydrate, { once: true });
841
+ break;
842
+ }
843
+
844
+ default:
845
+ console.warn('[Mandu] Unknown hydrate strategy "' + strategy + '", falling back to load.');
846
+ loadAndHydrate(element, src);
847
+ }
848
+ }
735
849
 
736
850
  /**
737
851
  * Island 로드 및 hydrate (핵심 함수)
@@ -767,12 +881,12 @@ async function loadAndHydrate(element, src) {
767
881
  const propsEl = element.hasAttribute('data-props')
768
882
  ? element
769
883
  : element.querySelector('[data-props]');
770
- if (propsEl) {
771
- try {
772
- data = JSON.parse(propsEl.getAttribute('data-props'));
773
- } catch (e) {
774
- console.warn('[Mandu] Failed to parse data-props fallback:', e);
775
- }
884
+ if (propsEl) {
885
+ try {
886
+ data = deserializeManduProps(propsEl.getAttribute('data-props') || '{}');
887
+ } catch (e) {
888
+ console.warn('[Mandu] Failed to parse data-props fallback:', e);
889
+ }
776
890
  }
777
891
  }
778
892
 
@@ -916,12 +1030,13 @@ function hydrateIslands() {
916
1030
  const seenIds = new Set();
917
1031
 
918
1032
  for (const el of islands) {
919
- const id = el.getAttribute('data-mandu-island');
920
- const src = el.getAttribute('data-mandu-src');
921
- const priority = el.getAttribute('data-mandu-priority') || '${HYDRATION.DEFAULT_PRIORITY}';
922
-
923
- if (!id || !src) {
924
- console.warn('[Mandu] Island missing id or src:', el);
1033
+ const id = el.getAttribute('data-mandu-island');
1034
+ const src = el.getAttribute('data-mandu-src');
1035
+ const priority = el.getAttribute('data-mandu-priority') || '${HYDRATION.DEFAULT_PRIORITY}';
1036
+ const hydrateStrategy = el.getAttribute('data-hydrate') || priorityToHydrateStrategy(priority);
1037
+
1038
+ if (!id || !src) {
1039
+ console.warn('[Mandu] Island missing id or src:', el);
925
1040
  continue;
926
1041
  }
927
1042
 
@@ -929,12 +1044,12 @@ function hydrateIslands() {
929
1044
  if (seenIds.has(id)) {
930
1045
  console.warn('[Mandu] Duplicate island id detected:', id, '- skipping');
931
1046
  continue;
932
- }
933
- seenIds.add(id);
934
-
935
- scheduleHydration(el, src, priority);
936
- }
937
- }
1047
+ }
1048
+ seenIds.add(id);
1049
+
1050
+ scheduleHydration(el, src, hydrateStrategy);
1051
+ }
1052
+ }
938
1053
 
939
1054
  /**
940
1055
  * Island unmount
@@ -1168,39 +1283,44 @@ function patternCacheSet(key, value) {
1168
1283
  patternCache.set(key, value);
1169
1284
  }
1170
1285
 
1171
- function compilePattern(pattern) {
1172
- var cached = patternCacheGet(pattern);
1173
- if (cached) return cached;
1174
-
1175
- const paramNames = [];
1176
- let paramIndex = 0;
1177
- const paramMatches = [];
1178
-
1179
- const withPlaceholders = pattern.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
1180
- paramMatches.push(name);
1181
- return '%%PARAM%%';
1182
- });
1183
-
1184
- const escaped = withPlaceholders.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
1185
- const regexStr = escaped.replace(/%%PARAM%%/g, () => {
1186
- paramNames.push(paramMatches[paramIndex++]);
1187
- return '([^/]+)';
1188
- });
1189
-
1190
- const compiled = { regex: new RegExp('^' + regexStr + '$'), paramNames };
1191
- patternCacheSet(pattern, compiled);
1192
- return compiled;
1193
- }
1286
+ function compilePattern(pattern) {
1287
+ var cached = patternCacheGet(pattern);
1288
+ if (cached) return cached;
1289
+
1290
+ const paramNames = [];
1291
+ const normalized = pattern === '/' ? '/' : pattern.replace(/\\/+$/, '') || '/';
1292
+ const segments = normalized.split('/').filter(Boolean);
1293
+ const regexStr = segments.length === 0
1294
+ ? '/'
1295
+ : segments.map((segment) => {
1296
+ if (segment === '*') return '/.+';
1297
+ const wildcardMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)\\*(\\?)?$/);
1298
+ if (wildcardMatch) {
1299
+ paramNames.push(wildcardMatch[1]);
1300
+ return wildcardMatch[2] === '?' ? '(?:/(.*))?' : '/(.+)';
1301
+ }
1302
+ const paramMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)$/);
1303
+ if (paramMatch) {
1304
+ paramNames.push(paramMatch[1]);
1305
+ return '/([^/]+)';
1306
+ }
1307
+ return '/' + segment.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
1308
+ }).join('');
1309
+
1310
+ const compiled = { regex: new RegExp('^' + regexStr + '$'), paramNames };
1311
+ patternCacheSet(pattern, compiled);
1312
+ return compiled;
1313
+ }
1194
1314
 
1195
1315
  function extractParams(pattern, pathname) {
1196
1316
  const compiled = compilePattern(pattern);
1197
1317
  const match = pathname.match(compiled.regex);
1198
1318
  if (!match) return {};
1199
-
1200
- const params = {};
1201
- compiled.paramNames.forEach((name, i) => { params[name] = match[i + 1]; });
1202
- return params;
1203
- }
1319
+
1320
+ const params = {};
1321
+ compiled.paramNames.forEach((name, i) => { params[name] = match[i + 1] || ''; });
1322
+ return params;
1323
+ }
1204
1324
 
1205
1325
  function notifyListeners() {
1206
1326
  const state = getGlobalState();
@@ -1929,25 +2049,34 @@ async function buildVendorShims(
1929
2049
  };
1930
2050
  }
1931
2051
 
1932
- function vendorShimFailureHint(shimName: string): string {
1933
- if (shimName.includes("react-refresh")) {
1934
- return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
1935
- }
1936
- return "Hint: check the import paths and ensure the vendor package is installed.";
1937
- }
1938
-
1939
- /**
1940
- * 단일 Island 번들 빌드
1941
- */
2052
+ function vendorShimFailureHint(shimName: string): string {
2053
+ if (shimName.includes("react-refresh")) {
2054
+ return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
2055
+ }
2056
+ return "Hint: check the import paths and ensure the vendor package is installed.";
2057
+ }
2058
+
2059
+ function routeIdToAssetStem(routeId: string): string {
2060
+ const safe = routeId.replace(/[<>:"/\\|?*\x00-\x1F]/g, (ch) =>
2061
+ `_${ch.codePointAt(0)!.toString(16)}_`
2062
+ );
2063
+ return safe.replace(/[. ]+$/g, "") || "route";
2064
+ }
2065
+
2066
+ /**
2067
+ * 단일 Island 번들 빌드
2068
+ */
1942
2069
  async function buildIsland(
1943
2070
  route: RouteSpec,
1944
2071
  rootDir: string,
1945
2072
  outDir: string,
1946
2073
  options: BundlerOptions
1947
- ): Promise<BundleOutput> {
1948
- const clientModulePath = path.join(rootDir, route.clientModule!);
1949
- const entryPath = path.join(outDir, `_entry_${route.id}.js`);
1950
- const outputName = `${route.id}.island.js`;
2074
+ ): Promise<BundleOutput> {
2075
+ const clientModulePath = path.join(rootDir, route.clientModule!);
2076
+ const assetStem = routeIdToAssetStem(route.id);
2077
+ const entryStem = `_entry_${assetStem}`;
2078
+ const entryPath = path.join(outDir, `${entryStem}.js`);
2079
+ const outputName = `${assetStem}.island.js`;
1951
2080
 
1952
2081
  // Phase 7.1 B-1/B-4: wire native Fast Refresh transform + Mandu's
1953
2082
  // boundary injection plugin. Dev-only; prod bundles remain clean.
@@ -1988,11 +2117,11 @@ async function buildIsland(
1988
2117
  let actualOutputPath: string;
1989
2118
  let actualOutputName: string;
1990
2119
 
1991
- if (options.splitting && result.outputs.length > 0) {
1992
- // splitting 모드: 결과에서 엔트리 파일 찾기
1993
- const entryOutput = result.outputs.find(
1994
- (o) => o.kind === "entry-point" || o.path.includes(route.id)
1995
- );
2120
+ if (options.splitting && result.outputs.length > 0) {
2121
+ // splitting 모드: 결과에서 엔트리 파일 찾기
2122
+ const entryOutput = result.outputs.find(
2123
+ (o) => o.kind === "entry-point" || o.path.includes(entryStem) || o.path.includes(assetStem)
2124
+ );
1996
2125
  if (entryOutput) {
1997
2126
  actualOutputPath = entryOutput.path;
1998
2127
  actualOutputName = path.basename(entryOutput.path);
@@ -165,39 +165,42 @@ const patternCache = new LRUCache<string, CompiledPattern>(LIMITS.ROUTER_PATTERN
165
165
  // because `registerCacheSize` replaces any prior reporter under the same key.
166
166
  registerCacheSize("patternCache", () => patternCache.size);
167
167
 
168
- /**
169
- * 패턴을 정규식으로 컴파일
170
- */
171
- function compilePattern(pattern: string): CompiledPattern {
172
- const cached = patternCache.get(pattern);
173
- if (cached) return cached;
174
-
175
- const paramNames: string[] = [];
176
- const PARAM_PLACEHOLDER = "\x00PARAM\x00";
177
- const paramMatches: string[] = [];
178
-
179
- const withPlaceholders = pattern.replace(
180
- /:([a-zA-Z_][a-zA-Z0-9_]*)/g,
181
- (_, paramName) => {
182
- paramMatches.push(paramName);
183
- return PARAM_PLACEHOLDER;
184
- }
185
- );
186
-
187
- const escaped = withPlaceholders.replace(/[.*+?^${}()|[\]\\\/]/g, "\\$&");
188
-
189
- let paramIndex = 0;
190
- const regexStr = escaped.replace(
191
- new RegExp(PARAM_PLACEHOLDER.replace(/\x00/g, "\\x00"), "g"),
192
- () => {
193
- paramNames.push(paramMatches[paramIndex++]);
194
- return "([^/]+)";
195
- }
196
- );
197
-
198
- const compiled = {
199
- regex: new RegExp(`^${regexStr}$`),
200
- paramNames,
168
+ /**
169
+ * 패턴을 정규식으로 컴파일
170
+ */
171
+ function compilePattern(pattern: string): CompiledPattern {
172
+ const cached = patternCache.get(pattern);
173
+ if (cached) return cached;
174
+
175
+ const paramNames: string[] = [];
176
+ const normalized = pattern === "/" ? "/" : pattern.replace(/\/+$/, "") || "/";
177
+ const segments = normalized.split("/").filter(Boolean);
178
+
179
+ const regexStr = segments.length === 0
180
+ ? "/"
181
+ : segments.map((segment) => {
182
+ if (segment === "*") {
183
+ return "/.+";
184
+ }
185
+
186
+ const wildcardMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)\*(\?)?$/);
187
+ if (wildcardMatch) {
188
+ paramNames.push(wildcardMatch[1]);
189
+ return wildcardMatch[2] === "?" ? "(?:/(.*))?" : "/(.+)";
190
+ }
191
+
192
+ const paramMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)$/);
193
+ if (paramMatch) {
194
+ paramNames.push(paramMatch[1]);
195
+ return "/([^/]+)";
196
+ }
197
+
198
+ return `/${escapePatternSegment(segment)}`;
199
+ }).join("");
200
+
201
+ const compiled = {
202
+ regex: new RegExp(`^${regexStr}$`),
203
+ paramNames,
201
204
  };
202
205
 
203
206
  patternCache.set(pattern, compiled);
@@ -216,13 +219,17 @@ function extractParamsFromPath(
216
219
 
217
220
  if (!match) return {};
218
221
 
219
- const params: Record<string, string> = {};
220
- compiled.paramNames.forEach((name, index) => {
221
- params[name] = match[index + 1];
222
- });
223
-
224
- return params;
225
- }
222
+ const params: Record<string, string> = {};
223
+ compiled.paramNames.forEach((name, index) => {
224
+ params[name] = match[index + 1] ?? "";
225
+ });
226
+
227
+ return params;
228
+ }
229
+
230
+ function escapePatternSegment(segment: string): string {
231
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
232
+ }
226
233
 
227
234
  // ========== Navigation ==========
228
235
 
@@ -1,14 +1,21 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
3
- import path from "path";
1
+ import { describe, expect, it } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
3
+ import path from "path";
4
4
  import {
5
5
  findClientComponentImports,
6
6
  findRouteLevelClientComponentImport,
7
7
  findRouteLevelClientComponentImports,
8
8
  resolveRouteLevelClientEntryPath,
9
- } from "./client-entry";
10
-
11
- describe("findClientComponentImports", () => {
9
+ } from "./client-entry";
10
+
11
+ const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
12
+
13
+ async function mkRepoTempDir(prefix: string): Promise<string> {
14
+ await mkdir(repoTempRoot, { recursive: true });
15
+ return mkdtemp(path.join(repoTempRoot, prefix));
16
+ }
17
+
18
+ describe("findClientComponentImports", () => {
12
19
  it("detects named .client imports for diagnostics", () => {
13
20
  const imports = findClientComponentImports(`
14
21
  import { LoginForm, SubmitButton as Button } from "@/client/widgets/login-form/LoginForm.client";
@@ -192,8 +199,8 @@ describe("findClientComponentImports", () => {
192
199
  expect(routeClient).toBeNull();
193
200
  });
194
201
 
195
- it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
196
- const rootDir = await mkdtemp(path.join(import.meta.dir, ".tmp-client-entry-"));
202
+ it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
203
+ const rootDir = await mkRepoTempDir("client-entry-");
197
204
  try {
198
205
  await mkdir(path.join(rootDir, "app", "pledges", "new"), { recursive: true });
199
206
  await mkdir(path.join(rootDir, "src", "client", "widgets", "pledge-form"), { recursive: true });
@@ -216,21 +216,28 @@ export async function resolveRouteLevelClientEntry(
216
216
  return null;
217
217
  }
218
218
 
219
- export async function shouldPreserveExistingClientModule(
220
- route: RouteSpec,
221
- clientModule: string,
222
- rootDir: string,
223
- ): Promise<boolean> {
224
- if (!isRouteLevelClientEntrySpecifier(clientModule)) return false;
225
- const source = await readRouteModule(rootDir, clientModule);
226
- if (source === null) return false;
227
- if (hasUseServerDirective(source)) return false;
228
- if (clientModuleIsRouteComponent(route, clientModule)) {
229
- if (hasUseClientDirective(source)) return true;
230
- return await routeComponentHasResolvableClientEntry(rootDir, clientModule, source);
231
- }
232
- return true;
233
- }
219
+ export async function shouldPreserveExistingClientModule(
220
+ route: RouteSpec,
221
+ clientModule: string,
222
+ rootDir: string,
223
+ ): Promise<boolean> {
224
+ if (!isRouteLevelClientEntrySpecifier(clientModule)) return false;
225
+ const source = await readRouteModule(rootDir, clientModule);
226
+ if (source === null) return false;
227
+ if (hasUseServerDirective(source)) return false;
228
+ if (clientModuleIsRouteComponent(route, clientModule)) {
229
+ if (hasUseClientDirective(source)) return true;
230
+ return await routeComponentHasResolvableClientEntry(rootDir, clientModule, source);
231
+ }
232
+
233
+ if (route.kind !== "page" || !route.componentModule) return false;
234
+
235
+ const routeSource = await readRouteModule(rootDir, route.componentModule);
236
+ if (routeSource === null) return false;
237
+
238
+ const currentEntry = await resolveRouteLevelClientEntry(rootDir, route.componentModule, routeSource);
239
+ return normalizeRouteModulePath(currentEntry?.modulePath) === normalizeRouteModulePath(clientModule);
240
+ }
234
241
 
235
242
  function resolveImportBasePath(rootDir: string, importerModule: string, specifier: string): string | null {
236
243
  const normalized = specifier.replace(/\\/g, "/");