@mandujs/core 0.54.14 → 0.54.16
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 +32 -15
- package/src/bundler/build.ts +261 -112
- package/src/client/router.ts +47 -40
- package/src/runtime/__tests__/page-render-response.test.ts +135 -0
- package/src/runtime/page-render-response.ts +34 -20
- 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,20 @@ 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("function parsePropsScript");
|
|
211
|
+
expect(runtimeSource).toContain("data-mandu-props");
|
|
212
|
+
expect(runtimeSource).toContain("deserializeManduProps");
|
|
213
|
+
expect(runtimeSource).toContain("new Date");
|
|
214
|
+
expect(runtimeSource).toContain("new Map");
|
|
215
|
+
});
|
|
199
216
|
|
|
200
217
|
test("does not bundle a server page when stale manifest marks page.tsx as clientModule", async () => {
|
|
201
218
|
const staleRoot = await mkRepoTempDir("stale-client-module-");
|
package/src/bundler/build.ts
CHANGED
|
@@ -522,34 +522,150 @@ 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__;
|
|
547
625
|
}
|
|
548
626
|
|
|
549
|
-
const getServerData = (id) => readManduData()[id]?.serverData || {};
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
627
|
+
const getServerData = (id) => readManduData()[id]?.serverData || {};
|
|
628
|
+
|
|
629
|
+
function findPropsScript(id) {
|
|
630
|
+
const scripts = document.querySelectorAll('script[data-mandu-props]');
|
|
631
|
+
for (const script of scripts) {
|
|
632
|
+
if (script.getAttribute('data-mandu-props') === id) {
|
|
633
|
+
return script;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function parsePropsScript(id) {
|
|
640
|
+
const script = findPropsScript(id);
|
|
641
|
+
if (!script || !script.textContent) return null;
|
|
642
|
+
try {
|
|
643
|
+
return deserializeManduProps(script.textContent);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
console.warn('[Mandu] Failed to parse data-mandu-props for island ' + id + ':', error);
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function readDataProps(element) {
|
|
651
|
+
const propsEl = element.hasAttribute('data-props')
|
|
652
|
+
? element
|
|
653
|
+
: element.querySelector('[data-props]');
|
|
654
|
+
if (!propsEl) return null;
|
|
655
|
+
try {
|
|
656
|
+
return deserializeManduProps(propsEl.getAttribute('data-props') || '{}');
|
|
657
|
+
} catch (error) {
|
|
658
|
+
console.warn('[Mandu] Failed to parse data-props fallback:', error);
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function getIslandProps(id, element) {
|
|
664
|
+
return parsePropsScript(id) || readDataProps(element) || getServerData(id);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Error Boundary 컴포넌트 (Class Component)
|
|
553
669
|
* Island의 errorBoundary 옵션을 지원
|
|
554
670
|
*/
|
|
555
671
|
class IslandErrorBoundary extends Component {
|
|
@@ -681,26 +797,62 @@ function createHydrationOptions(element, id, mode) {
|
|
|
681
797
|
};
|
|
682
798
|
}
|
|
683
799
|
|
|
684
|
-
/**
|
|
685
|
-
* Hydration 스케줄러
|
|
686
|
-
*/
|
|
687
|
-
function
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
800
|
+
/**
|
|
801
|
+
* Hydration 스케줄러
|
|
802
|
+
*/
|
|
803
|
+
function priorityToHydrateStrategy(priority) {
|
|
804
|
+
return priority === 'immediate' ? 'load' : priority;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function scheduleHydration(element, src, strategy) {
|
|
808
|
+
if (!strategy) strategy = 'load';
|
|
809
|
+
if (strategy === 'immediate') strategy = 'load';
|
|
810
|
+
|
|
811
|
+
if (strategy.startsWith('media(') && strategy.endsWith(')')) {
|
|
812
|
+
const query = strategy.slice('media('.length, -1).trim();
|
|
813
|
+
if (!query || !window.matchMedia) {
|
|
814
|
+
loadAndHydrate(element, src);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
const mql = window.matchMedia(query);
|
|
818
|
+
if (mql.matches) {
|
|
819
|
+
loadAndHydrate(element, src);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const onChange = (event) => {
|
|
823
|
+
if (!event.matches) return;
|
|
824
|
+
if (mql.removeEventListener) {
|
|
825
|
+
mql.removeEventListener('change', onChange);
|
|
826
|
+
} else if (mql.removeListener) {
|
|
827
|
+
mql.removeListener(onChange);
|
|
828
|
+
}
|
|
829
|
+
loadAndHydrate(element, src);
|
|
830
|
+
};
|
|
831
|
+
if (mql.addEventListener) {
|
|
832
|
+
mql.addEventListener('change', onChange);
|
|
833
|
+
} else if (mql.addListener) {
|
|
834
|
+
mql.addListener(onChange);
|
|
835
|
+
}
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
switch (strategy) {
|
|
840
|
+
case 'load':
|
|
841
|
+
case 'immediate':
|
|
842
|
+
loadAndHydrate(element, src);
|
|
843
|
+
break;
|
|
692
844
|
|
|
693
845
|
case 'visible':
|
|
694
846
|
if ('IntersectionObserver' in window) {
|
|
695
847
|
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 {
|
|
848
|
+
if (entries[0].isIntersecting) {
|
|
849
|
+
observer.disconnect();
|
|
850
|
+
loadAndHydrate(element, src);
|
|
851
|
+
}
|
|
852
|
+
}, { rootMargin: '200px' });
|
|
853
|
+
const target = resolveHydrationTarget(element);
|
|
854
|
+
observer.observe(target);
|
|
855
|
+
} else {
|
|
704
856
|
loadAndHydrate(element, src);
|
|
705
857
|
}
|
|
706
858
|
break;
|
|
@@ -712,26 +864,26 @@ function scheduleHydration(element, src, priority) {
|
|
|
712
864
|
setTimeout(() => loadAndHydrate(element, src), 200);
|
|
713
865
|
}
|
|
714
866
|
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
|
-
}
|
|
867
|
+
|
|
868
|
+
case 'interaction': {
|
|
869
|
+
const target = resolveHydrationTarget(element);
|
|
870
|
+
const hydrate = () => {
|
|
871
|
+
target.removeEventListener('touchstart', hydrate);
|
|
872
|
+
target.removeEventListener('click', hydrate);
|
|
873
|
+
target.removeEventListener('keydown', hydrate);
|
|
874
|
+
loadAndHydrate(element, src);
|
|
875
|
+
};
|
|
876
|
+
target.addEventListener('touchstart', hydrate, { once: true, passive: true });
|
|
877
|
+
target.addEventListener('click', hydrate, { once: true });
|
|
878
|
+
target.addEventListener('keydown', hydrate, { once: true });
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
default:
|
|
883
|
+
console.warn('[Mandu] Unknown hydrate strategy "' + strategy + '", falling back to load.');
|
|
884
|
+
loadAndHydrate(element, src);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
735
887
|
|
|
736
888
|
/**
|
|
737
889
|
* Island 로드 및 hydrate (핵심 함수)
|
|
@@ -758,23 +910,7 @@ async function loadAndHydrate(element, src) {
|
|
|
758
910
|
// Dynamic import - 이 시점에 Island 모듈 로드
|
|
759
911
|
const module = await import(src);
|
|
760
912
|
const island = module.default;
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
// Fallback: read data-props from the island root or a child element if
|
|
764
|
-
// __MANDU_DATA__ is empty. Inline partials put their serialized props on
|
|
765
|
-
// the root marker itself.
|
|
766
|
-
if (!data || Object.keys(data).length === 0) {
|
|
767
|
-
const propsEl = element.hasAttribute('data-props')
|
|
768
|
-
? element
|
|
769
|
-
: 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
|
-
}
|
|
776
|
-
}
|
|
777
|
-
}
|
|
913
|
+
const data = getIslandProps(id, element);
|
|
778
914
|
|
|
779
915
|
// Mandu Island (preferred)
|
|
780
916
|
if (island && island.__mandu_island === true) {
|
|
@@ -916,12 +1052,13 @@ function hydrateIslands() {
|
|
|
916
1052
|
const seenIds = new Set();
|
|
917
1053
|
|
|
918
1054
|
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
|
-
|
|
1055
|
+
const id = el.getAttribute('data-mandu-island');
|
|
1056
|
+
const src = el.getAttribute('data-mandu-src');
|
|
1057
|
+
const priority = el.getAttribute('data-mandu-priority') || '${HYDRATION.DEFAULT_PRIORITY}';
|
|
1058
|
+
const hydrateStrategy = el.getAttribute('data-hydrate') || priorityToHydrateStrategy(priority);
|
|
1059
|
+
|
|
1060
|
+
if (!id || !src) {
|
|
1061
|
+
console.warn('[Mandu] Island missing id or src:', el);
|
|
925
1062
|
continue;
|
|
926
1063
|
}
|
|
927
1064
|
|
|
@@ -929,12 +1066,12 @@ function hydrateIslands() {
|
|
|
929
1066
|
if (seenIds.has(id)) {
|
|
930
1067
|
console.warn('[Mandu] Duplicate island id detected:', id, '- skipping');
|
|
931
1068
|
continue;
|
|
932
|
-
}
|
|
933
|
-
seenIds.add(id);
|
|
934
|
-
|
|
935
|
-
scheduleHydration(el, src,
|
|
936
|
-
}
|
|
937
|
-
}
|
|
1069
|
+
}
|
|
1070
|
+
seenIds.add(id);
|
|
1071
|
+
|
|
1072
|
+
scheduleHydration(el, src, hydrateStrategy);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
938
1075
|
|
|
939
1076
|
/**
|
|
940
1077
|
* Island unmount
|
|
@@ -1168,39 +1305,44 @@ function patternCacheSet(key, value) {
|
|
|
1168
1305
|
patternCache.set(key, value);
|
|
1169
1306
|
}
|
|
1170
1307
|
|
|
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
|
-
}
|
|
1308
|
+
function compilePattern(pattern) {
|
|
1309
|
+
var cached = patternCacheGet(pattern);
|
|
1310
|
+
if (cached) return cached;
|
|
1311
|
+
|
|
1312
|
+
const paramNames = [];
|
|
1313
|
+
const normalized = pattern === '/' ? '/' : pattern.replace(/\\/+$/, '') || '/';
|
|
1314
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
1315
|
+
const regexStr = segments.length === 0
|
|
1316
|
+
? '/'
|
|
1317
|
+
: segments.map((segment) => {
|
|
1318
|
+
if (segment === '*') return '/.+';
|
|
1319
|
+
const wildcardMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)\\*(\\?)?$/);
|
|
1320
|
+
if (wildcardMatch) {
|
|
1321
|
+
paramNames.push(wildcardMatch[1]);
|
|
1322
|
+
return wildcardMatch[2] === '?' ? '(?:/(.*))?' : '/(.+)';
|
|
1323
|
+
}
|
|
1324
|
+
const paramMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)$/);
|
|
1325
|
+
if (paramMatch) {
|
|
1326
|
+
paramNames.push(paramMatch[1]);
|
|
1327
|
+
return '/([^/]+)';
|
|
1328
|
+
}
|
|
1329
|
+
return '/' + segment.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
|
|
1330
|
+
}).join('');
|
|
1331
|
+
|
|
1332
|
+
const compiled = { regex: new RegExp('^' + regexStr + '$'), paramNames };
|
|
1333
|
+
patternCacheSet(pattern, compiled);
|
|
1334
|
+
return compiled;
|
|
1335
|
+
}
|
|
1194
1336
|
|
|
1195
1337
|
function extractParams(pattern, pathname) {
|
|
1196
1338
|
const compiled = compilePattern(pattern);
|
|
1197
1339
|
const match = pathname.match(compiled.regex);
|
|
1198
1340
|
if (!match) return {};
|
|
1199
|
-
|
|
1200
|
-
const params = {};
|
|
1201
|
-
compiled.paramNames.forEach((name, i) => { params[name] = match[i + 1]; });
|
|
1202
|
-
return params;
|
|
1203
|
-
}
|
|
1341
|
+
|
|
1342
|
+
const params = {};
|
|
1343
|
+
compiled.paramNames.forEach((name, i) => { params[name] = match[i + 1] || ''; });
|
|
1344
|
+
return params;
|
|
1345
|
+
}
|
|
1204
1346
|
|
|
1205
1347
|
function notifyListeners() {
|
|
1206
1348
|
const state = getGlobalState();
|
|
@@ -1462,6 +1604,7 @@ function generateIslandEntry(routeId: string, clientModulePath: string, exportNa
|
|
|
1462
1604
|
* Mandu Island: ${commentRouteId} (Generated)
|
|
1463
1605
|
* Pure export - no side effects
|
|
1464
1606
|
*/
|
|
1607
|
+
import React from "react";
|
|
1465
1608
|
import * as islandModule from ${importSpecifier};
|
|
1466
1609
|
|
|
1467
1610
|
const candidateExportNames = ${JSON.stringify(candidates)};
|
|
@@ -1480,7 +1623,13 @@ function resolveIslandExport(mod) {
|
|
|
1480
1623
|
}
|
|
1481
1624
|
|
|
1482
1625
|
const island = resolveIslandExport(islandModule);
|
|
1483
|
-
|
|
1626
|
+
const exportedIsland = island && island.__mandu_island === true
|
|
1627
|
+
? island
|
|
1628
|
+
: function ManduGeneratedIsland(props) {
|
|
1629
|
+
return React.createElement(island, props || {});
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
export default exportedIsland;
|
|
1484
1633
|
`;
|
|
1485
1634
|
}
|
|
1486
1635
|
|
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"),
|
|
@@ -157,8 +181,119 @@ describe("runtime page render response orchestration", () => {
|
|
|
157
181
|
const html = await response.text();
|
|
158
182
|
expect(html).toContain('data-mandu-island="candidates-$id--0"');
|
|
159
183
|
expect(html).toContain('data-mandu-src="/.mandu/client/candidates-$id.island.js"');
|
|
184
|
+
expect(html).toContain('type="application/json" data-mandu-props="candidates-$id--0"');
|
|
160
185
|
expect(html).toContain(""pledges"");
|
|
186
|
+
expect(html).toContain('"pledges"');
|
|
161
187
|
expect(html).toContain("Public transit");
|
|
162
188
|
expect(html).not.toContain('data-mandu-island="candidates-$id"');
|
|
163
189
|
});
|
|
190
|
+
|
|
191
|
+
it("does not invoke sync function components while looking for inline client targets", async () => {
|
|
192
|
+
function ClientWidget({ label }: { label: string }) {
|
|
193
|
+
return React.createElement("button", null, label);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function HookPage() {
|
|
197
|
+
const id = React.useId();
|
|
198
|
+
return React.createElement("main", { id }, React.createElement(ClientWidget, { label: "Click" }));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const response = await renderPageResponse({
|
|
202
|
+
app: React.createElement(HookPage),
|
|
203
|
+
useStreaming: false,
|
|
204
|
+
title: "Hook Page",
|
|
205
|
+
headTags: "",
|
|
206
|
+
isDev: false,
|
|
207
|
+
routeId: "hook-page",
|
|
208
|
+
routePattern: "/hook",
|
|
209
|
+
hydration: { strategy: "island", priority: "visible", preload: false },
|
|
210
|
+
bundleManifest: {
|
|
211
|
+
...HYDRATED_MANIFEST,
|
|
212
|
+
bundles: {
|
|
213
|
+
"hook-page": {
|
|
214
|
+
js: "/.mandu/client/hook-page.island.js",
|
|
215
|
+
dependencies: ["_runtime", "_react"],
|
|
216
|
+
priority: "visible",
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
loaderData: undefined,
|
|
221
|
+
transitions: false,
|
|
222
|
+
prefetch: false,
|
|
223
|
+
spa: false,
|
|
224
|
+
devtools: false,
|
|
225
|
+
inlineClientHydration: {
|
|
226
|
+
routeId: "hook-page",
|
|
227
|
+
src: "/.mandu/client/hook-page.island.js",
|
|
228
|
+
priority: "visible",
|
|
229
|
+
component: ClientWidget,
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const html = await response.text();
|
|
234
|
+
expect(html).toContain("Hook Page");
|
|
235
|
+
expect(html).toContain("Click");
|
|
236
|
+
expect(html).toContain('data-mandu-island="hook-page"');
|
|
237
|
+
expect(html).not.toContain('data-mandu-island="hook-page--0"');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("assigns inline client island IDs in document order", async () => {
|
|
241
|
+
function ClientWidget({ label }: { label: string }) {
|
|
242
|
+
return React.createElement("button", null, label);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function SlowSection() {
|
|
246
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
247
|
+
return React.createElement(ClientWidget, { label: "first" });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function FastSection() {
|
|
251
|
+
return React.createElement(ClientWidget, { label: "second" });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function OrderedPage() {
|
|
255
|
+
return [
|
|
256
|
+
React.createElement(SlowSection, { key: "slow" }),
|
|
257
|
+
React.createElement(FastSection, { key: "fast" }),
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const response = await renderPageResponse({
|
|
262
|
+
app: React.createElement(OrderedPage),
|
|
263
|
+
useStreaming: false,
|
|
264
|
+
title: "Ordered",
|
|
265
|
+
headTags: "",
|
|
266
|
+
isDev: false,
|
|
267
|
+
routeId: "ordered",
|
|
268
|
+
routePattern: "/ordered",
|
|
269
|
+
hydration: { strategy: "island", priority: "visible", preload: false },
|
|
270
|
+
bundleManifest: {
|
|
271
|
+
...HYDRATED_MANIFEST,
|
|
272
|
+
bundles: {
|
|
273
|
+
ordered: {
|
|
274
|
+
js: "/.mandu/client/ordered.island.js",
|
|
275
|
+
dependencies: ["_runtime", "_react"],
|
|
276
|
+
priority: "visible",
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
loaderData: undefined,
|
|
281
|
+
transitions: false,
|
|
282
|
+
prefetch: false,
|
|
283
|
+
spa: false,
|
|
284
|
+
devtools: false,
|
|
285
|
+
inlineClientHydration: {
|
|
286
|
+
routeId: "ordered",
|
|
287
|
+
src: "/.mandu/client/ordered.island.js",
|
|
288
|
+
priority: "visible",
|
|
289
|
+
component: ClientWidget,
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const html = await response.text();
|
|
294
|
+
expect(html.indexOf('data-mandu-island="ordered--0"')).toBeLessThan(
|
|
295
|
+
html.indexOf('data-mandu-island="ordered--1"'),
|
|
296
|
+
);
|
|
297
|
+
expect(html.indexOf("first")).toBeLessThan(html.indexOf("second"));
|
|
298
|
+
});
|
|
164
299
|
});
|
|
@@ -4,6 +4,7 @@ import type { HydrationConfig } from "../spec/schema";
|
|
|
4
4
|
import type { CookieManager } from "../filling/context";
|
|
5
5
|
import { renderSSR, renderStreamingResponse, resolveAsyncElement } from "./ssr";
|
|
6
6
|
import { serializeProps } from "../client/serialize";
|
|
7
|
+
import { escapeJsonForInlineScript } from "./escape";
|
|
7
8
|
|
|
8
9
|
export interface InlineClientHydrationTarget {
|
|
9
10
|
routeId: string;
|
|
@@ -76,13 +77,12 @@ async function resolveAndWrapInlineClientHydration(
|
|
|
76
77
|
|
|
77
78
|
if (Array.isArray(node)) {
|
|
78
79
|
let didWrap = false;
|
|
79
|
-
const children =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
);
|
|
80
|
+
const children: React.ReactNode[] = [];
|
|
81
|
+
for (const child of node) {
|
|
82
|
+
const result = await resolveAndWrapInlineClientHydration(child, target, counter);
|
|
83
|
+
didWrap = didWrap || result.didWrap;
|
|
84
|
+
children.push(result.node);
|
|
85
|
+
}
|
|
86
86
|
return { node: children, didWrap };
|
|
87
87
|
}
|
|
88
88
|
|
|
@@ -95,24 +95,37 @@ async function resolveAndWrapInlineClientHydration(
|
|
|
95
95
|
|
|
96
96
|
if (type === target.component) {
|
|
97
97
|
const id = `${target.routeId}--${counter.value++}`;
|
|
98
|
+
const props = element.props ?? {};
|
|
99
|
+
const serializedProps = serializeProps(props);
|
|
98
100
|
return {
|
|
99
101
|
node: React.createElement(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
"
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
102
|
+
React.Fragment,
|
|
103
|
+
null,
|
|
104
|
+
React.createElement(
|
|
105
|
+
"div",
|
|
106
|
+
{
|
|
107
|
+
"data-mandu-island": id,
|
|
108
|
+
"data-mandu-src": target.src,
|
|
109
|
+
"data-mandu-priority": target.priority,
|
|
110
|
+
"data-hydrate": priorityToHydrate(target.priority),
|
|
111
|
+
"data-props": serializedProps,
|
|
112
|
+
style: { display: "contents" },
|
|
113
|
+
},
|
|
114
|
+
element,
|
|
115
|
+
),
|
|
116
|
+
React.createElement("script", {
|
|
117
|
+
type: "application/json",
|
|
118
|
+
"data-mandu-props": id,
|
|
119
|
+
dangerouslySetInnerHTML: {
|
|
120
|
+
__html: escapeJsonForInlineScript(serializedProps),
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
110
123
|
),
|
|
111
124
|
didWrap: true,
|
|
112
125
|
};
|
|
113
126
|
}
|
|
114
127
|
|
|
115
|
-
if (typeof type === "function" &&
|
|
128
|
+
if (typeof type === "function" && isAsyncFunctionComponent(type)) {
|
|
116
129
|
const rendered = await (type as (props: Record<string, unknown>) => React.ReactNode | Promise<React.ReactNode>)(
|
|
117
130
|
element.props ?? {},
|
|
118
131
|
);
|
|
@@ -136,8 +149,9 @@ async function resolveAndWrapInlineClientHydration(
|
|
|
136
149
|
return { node: cloned, didWrap: resolvedChildren.didWrap };
|
|
137
150
|
}
|
|
138
151
|
|
|
139
|
-
function
|
|
140
|
-
return
|
|
152
|
+
function isAsyncFunctionComponent(type: Function): boolean {
|
|
153
|
+
return !type.prototype?.isReactComponent &&
|
|
154
|
+
(type as { constructor?: { name?: string } }).constructor?.name === "AsyncFunction";
|
|
141
155
|
}
|
|
142
156
|
|
|
143
157
|
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
|
|