@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 +1 -1
- package/src/bundler/__tests__/hdr.test.ts +10 -3
- package/src/bundler/__tests__/hmr-client.test.ts +16 -11
- package/src/bundler/build.test.ts +30 -15
- package/src/bundler/build.ts +216 -96
- package/src/client/router.ts +47 -40
- package/src/runtime/__tests__/page-render-response.test.ts +133 -0
- package/src/runtime/page-render-response.ts +10 -10
- package/src/runtime/streaming-ssr.ts +26 -18
package/package.json
CHANGED
|
@@ -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
|
-
|
|
208
|
-
|
|
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.
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*/
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
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-");
|
package/src/bundler/build.ts
CHANGED
|
@@ -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
|
-
|
|
531
|
-
|
|
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__ =
|
|
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
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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: '
|
|
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('
|
|
720
|
-
target.removeEventListener('
|
|
721
|
-
target.removeEventListener('
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
};
|
|
726
|
-
target.addEventListener('
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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 =
|
|
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
|
-
|
|
924
|
-
|
|
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,
|
|
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
|
-
|
|
1177
|
-
const
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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();
|
package/src/client/router.ts
CHANGED
|
@@ -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
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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" &&
|
|
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
|
|
140
|
-
return
|
|
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
|
|
407
|
-
|
|
408
|
-
"data-mandu-
|
|
409
|
-
"data-mandu-
|
|
410
|
-
"data-
|
|
411
|
-
|
|
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-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
|