@mandujs/core 0.54.14 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.54.14",
3
+ "version": "0.54.15",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -204,9 +204,16 @@ describe("Phase 7.2 Agent B — findRouteIdForSlot", () => {
204
204
  // Section B — HMR server broadcast of slot-refetch
205
205
  // -----------------------------------------------------------------------------
206
206
 
207
- function pickPort(): number {
208
- return 40000 + Math.floor(Math.random() * 10000);
209
- }
207
+ const HMR_TEST_PORT_STATE = "__MANDU_HMR_TEST_PORT_STATE__";
208
+
209
+ function pickPort(): number {
210
+ const stateGlobal = globalThis as typeof globalThis & {
211
+ __MANDU_HMR_TEST_PORT_STATE__?: { next: number };
212
+ };
213
+ stateGlobal.__MANDU_HMR_TEST_PORT_STATE__ ??= { next: 0 };
214
+ const index = stateGlobal.__MANDU_HMR_TEST_PORT_STATE__.next++;
215
+ return 41000 + (((process.pid % 3500) * 2 + index * 2) % 7000);
216
+ }
210
217
 
211
218
  describe("Phase 7.2 Agent B — slot-refetch broadcast", () => {
212
219
  let server: HMRServer | null = null;
@@ -172,17 +172,22 @@ describe("createManduHot — Vite-compat import.meta.hot runtime", () => {
172
172
  * Utility: spin up an HMR server and return it plus the public port the
173
173
  * client should dial. The caller owns teardown via `afterEach`.
174
174
  *
175
- * We pass `port: 0` ... almost. `createHMRServer` computes
176
- * `port + PORTS.HMR_OFFSET` internally, so if we want an ephemeral
177
- * listener we'd need to bind ahead of time. For these tests that would
178
- * complicate setup; we pick a random port in the high range instead
179
- * and accept the tiny risk of collision (test is < 1 s).
180
- */
181
- function pickPort(): number {
182
- // 40000–49999 range — avoids common dev ports while staying well
183
- // below ephemeral ranges Bun may pick for outbound sockets.
184
- return 40000 + Math.floor(Math.random() * 10000);
185
- }
175
+ * We pass `port: 0` ... almost. `createHMRServer` computes
176
+ * `port + PORTS.HMR_OFFSET` internally, so if we want an ephemeral
177
+ * listener we'd need to bind ahead of time. These tests instead use a
178
+ * process-local monotonic port allocator to avoid random collisions
179
+ * during the full parallel core suite.
180
+ */
181
+ const HMR_TEST_PORT_STATE = "__MANDU_HMR_TEST_PORT_STATE__";
182
+
183
+ function pickPort(): number {
184
+ const stateGlobal = globalThis as typeof globalThis & {
185
+ __MANDU_HMR_TEST_PORT_STATE__?: { next: number };
186
+ };
187
+ stateGlobal.__MANDU_HMR_TEST_PORT_STATE__ ??= { next: 0 };
188
+ const index = stateGlobal.__MANDU_HMR_TEST_PORT_STATE__.next++;
189
+ return 41000 + (((process.pid % 3500) * 2 + index * 2) % 7000);
190
+ }
186
191
 
187
192
  /**
188
193
  * A wrapper around WebSocket that stashes every incoming message in an
@@ -128,14 +128,26 @@ afterAll(async () => {
128
128
  // subprocess via `__tests__/build-runner.ts`. In-process retry does not
129
129
  // recover from that one; a fresh module graph does.
130
130
  describe("buildClientBundles vendor shims", () => {
131
- test("build succeeds", () => {
132
- if (!result.success) {
133
- console.error("[build.test] errors:", result.errors);
134
- }
135
- expect(result.success).toBe(true);
136
- });
137
-
138
- test("re-exports modern React 19 APIs used by islands", async () => {
131
+ test("build succeeds", () => {
132
+ if (!result.success) {
133
+ console.error("[build.test] errors:", result.errors);
134
+ }
135
+ expect(result.success).toBe(true);
136
+ });
137
+
138
+ test("runtime reads canonical data-hydrate strategies", async () => {
139
+ const runtimePath = path.join(rootDir, ".mandu", "client", "_runtime.js");
140
+ const runtimeSource = await readFile(runtimePath, "utf-8");
141
+
142
+ expect(runtimeSource).toContain("data-hydrate");
143
+ expect(runtimeSource).toContain("matchMedia");
144
+ expect(runtimeSource).toContain("200px");
145
+ expect(runtimeSource).toContain('"click"');
146
+ expect(runtimeSource).not.toContain("mouseenter");
147
+ expect(runtimeSource).not.toContain("pointerdown");
148
+ });
149
+
150
+ test("re-exports modern React 19 APIs used by islands", async () => {
139
151
  const reactShim = await importBuiltModule(".mandu/client/_react.js");
140
152
  const requiredExports = [
141
153
  "Activity",
@@ -187,15 +199,18 @@ describe("buildClientBundles vendor shims", () => {
187
199
  expect(runtimeSource).toContain("data-mandu-hydrating");
188
200
  expect(runtimeSource).toContain("data-mandu-render-mode");
189
201
  expect(runtimeSource).toContain("data-mandu-recoverable-error");
190
- expect(runtimeSource).toContain("pointerdown");
202
+ expect(runtimeSource).toContain('"click"');
203
+ expect(runtimeSource).not.toContain("pointerdown");
191
204
  });
192
205
 
193
- test("runtime parses SSR data script before island setup", async () => {
194
- const runtimeSource = await readFile(path.join(rootDir, ".mandu", "client", "_runtime.js"), "utf-8");
195
- expect(runtimeSource).toContain("function readManduData");
196
- expect(runtimeSource).toContain("document.getElementById(\"__MANDU_DATA__\")");
197
- expect(runtimeSource).toContain("JSON.parse");
198
- });
206
+ test("runtime parses SSR data script before island setup", async () => {
207
+ const runtimeSource = await readFile(path.join(rootDir, ".mandu", "client", "_runtime.js"), "utf-8");
208
+ expect(runtimeSource).toContain("function readManduData");
209
+ expect(runtimeSource).toContain("document.getElementById(\"__MANDU_DATA__\")");
210
+ expect(runtimeSource).toContain("deserializeManduProps");
211
+ expect(runtimeSource).toContain("new Date");
212
+ expect(runtimeSource).toContain("new Map");
213
+ });
199
214
 
200
215
  test("does not bundle a server page when stale manifest marks page.tsx as clientModule", async () => {
201
216
  const staleRoot = await mkRepoTempDir("stale-client-module-");
@@ -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();
@@ -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
 
@@ -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"),
@@ -161,4 +185,113 @@ describe("runtime page render response orchestration", () => {
161
185
  expect(html).toContain("Public transit");
162
186
  expect(html).not.toContain('data-mandu-island="candidates-$id"');
163
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
+ });
164
297
  });
@@ -76,13 +76,12 @@ async function resolveAndWrapInlineClientHydration(
76
76
 
77
77
  if (Array.isArray(node)) {
78
78
  let didWrap = false;
79
- const children = await Promise.all(
80
- node.map(async (child) => {
81
- const result = await resolveAndWrapInlineClientHydration(child, target, counter);
82
- didWrap = didWrap || result.didWrap;
83
- return result.node;
84
- }),
85
- );
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
+ }
86
85
  return { node: children, didWrap };
87
86
  }
88
87
 
@@ -112,7 +111,7 @@ async function resolveAndWrapInlineClientHydration(
112
111
  };
113
112
  }
114
113
 
115
- if (typeof type === "function" && !isClassComponent(type)) {
114
+ if (typeof type === "function" && isAsyncFunctionComponent(type)) {
116
115
  const rendered = await (type as (props: Record<string, unknown>) => React.ReactNode | Promise<React.ReactNode>)(
117
116
  element.props ?? {},
118
117
  );
@@ -136,8 +135,9 @@ async function resolveAndWrapInlineClientHydration(
136
135
  return { node: cloned, didWrap: resolvedChildren.didWrap };
137
136
  }
138
137
 
139
- function isClassComponent(type: Function): boolean {
140
- return !!(type.prototype && type.prototype.isReactComponent);
138
+ function isAsyncFunctionComponent(type: Function): boolean {
139
+ return !type.prototype?.isReactComponent &&
140
+ (type as { constructor?: { name?: string } }).constructor?.name === "AsyncFunction";
141
141
  }
142
142
 
143
143
  function priorityToHydrate(priority: InlineClientHydrationTarget["priority"]): string {
@@ -390,7 +390,7 @@ function generateErrorScript(error: Error, routeId: string): string {
390
390
  * Island를 Suspense로 감싸는 래퍼
391
391
  * Streaming SSR에서 Island별 점진적 렌더링 지원
392
392
  */
393
- export function SuspenseIsland({
393
+ export function SuspenseIsland({
394
394
  children,
395
395
  fallback,
396
396
  routeId,
@@ -401,14 +401,16 @@ export function SuspenseIsland({
401
401
  fallback?: ReactNode;
402
402
  routeId: string;
403
403
  priority?: HydrationPriority;
404
- bundleSrc?: string;
405
- }): ReactElement {
406
- const defaultFallback = React.createElement("div", {
407
- "data-mandu-island": routeId,
408
- "data-mandu-priority": priority,
409
- "data-mandu-src": bundleSrc ? `${bundleSrc}${bundleSrc.includes('?') ? '&' : '?'}t=${Date.now()}` : bundleSrc,
410
- "data-mandu-loading": "true",
411
- style: { display: "contents", minHeight: "50px" },
404
+ bundleSrc?: string;
405
+ }): ReactElement {
406
+ const hydrate = priorityToHydrateStrategy(priority);
407
+ const defaultFallback = React.createElement("div", {
408
+ "data-mandu-island": routeId,
409
+ "data-mandu-priority": priority,
410
+ "data-hydrate": hydrate,
411
+ "data-mandu-src": bundleSrc ? `${bundleSrc}${bundleSrc.includes('?') ? '&' : '?'}t=${Date.now()}` : bundleSrc,
412
+ "data-mandu-loading": "true",
413
+ style: { display: "contents", minHeight: "50px" },
412
414
  }, React.createElement("div", {
413
415
  className: "mandu-loading-skeleton",
414
416
  style: {
@@ -424,14 +426,19 @@ export function SuspenseIsland({
424
426
  return React.createElement(
425
427
  Suspense,
426
428
  { fallback: fallback || defaultFallback },
427
- React.createElement("div", {
428
- "data-mandu-island": routeId,
429
- "data-mandu-priority": priority,
430
- "data-mandu-src": bundleSrc ? `${bundleSrc}${bundleSrc.includes('?') ? '&' : '?'}t=${Date.now()}` : bundleSrc,
431
- style: { display: "contents" },
432
- }, children)
433
- );
434
- }
429
+ React.createElement("div", {
430
+ "data-mandu-island": routeId,
431
+ "data-mandu-priority": priority,
432
+ "data-hydrate": hydrate,
433
+ "data-mandu-src": bundleSrc ? `${bundleSrc}${bundleSrc.includes('?') ? '&' : '?'}t=${Date.now()}` : bundleSrc,
434
+ style: { display: "contents" },
435
+ }, children)
436
+ );
437
+ }
438
+
439
+ function priorityToHydrateStrategy(priority: HydrationPriority): string {
440
+ return priority === "immediate" ? "load" : priority;
441
+ }
435
442
 
436
443
  /**
437
444
  * Deferred 데이터를 위한 Suspense 컴포넌트
@@ -619,8 +626,9 @@ function generateHTMLShell(options: StreamingSSROptions): string {
619
626
  const bundle = bundleManifest.bundles[routeId];
620
627
  const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : "";
621
628
  const priority = hydration.priority || "visible";
629
+ const hydrate = priorityToHydrateStrategy(priority);
622
630
  if (hasRouteBundle) {
623
- islandOpenTag = `<div data-mandu-island="${escapeHtmlAttr(routeId)}" data-mandu-src="${escapeHtmlAttr(bundleSrc)}" data-mandu-priority="${escapeHtmlAttr(priority)}" style="display:contents">`;
631
+ islandOpenTag = `<div data-mandu-island="${escapeHtmlAttr(routeId)}" data-mandu-src="${escapeHtmlAttr(bundleSrc)}" data-mandu-priority="${escapeHtmlAttr(priority)}" data-hydrate="${escapeHtmlAttr(hydrate)}" style="display:contents">`;
624
632
  }
625
633
  }
626
634