@mandujs/core 0.54.15 → 0.54.17
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/build.test.ts +2 -0
- package/src/bundler/build.ts +51 -22
- package/src/runtime/__tests__/inline-client-hydration.test.ts +134 -0
- package/src/runtime/__tests__/page-render-response.test.ts +2 -0
- package/src/runtime/page-render-response.ts +66 -13
- package/src/runtime/server.ts +42 -5
package/package.json
CHANGED
|
@@ -207,6 +207,8 @@ describe("buildClientBundles vendor shims", () => {
|
|
|
207
207
|
const runtimeSource = await readFile(path.join(rootDir, ".mandu", "client", "_runtime.js"), "utf-8");
|
|
208
208
|
expect(runtimeSource).toContain("function readManduData");
|
|
209
209
|
expect(runtimeSource).toContain("document.getElementById(\"__MANDU_DATA__\")");
|
|
210
|
+
expect(runtimeSource).toContain("function parsePropsScript");
|
|
211
|
+
expect(runtimeSource).toContain("data-mandu-props");
|
|
210
212
|
expect(runtimeSource).toContain("deserializeManduProps");
|
|
211
213
|
expect(runtimeSource).toContain("new Date");
|
|
212
214
|
expect(runtimeSource).toContain("new Map");
|
package/src/bundler/build.ts
CHANGED
|
@@ -624,10 +624,48 @@ function readManduData() {
|
|
|
624
624
|
return window.__MANDU_DATA__;
|
|
625
625
|
}
|
|
626
626
|
|
|
627
|
-
const getServerData = (id) => readManduData()[id]?.serverData || {};
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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)
|
|
631
669
|
* Island의 errorBoundary 옵션을 지원
|
|
632
670
|
*/
|
|
633
671
|
class IslandErrorBoundary extends Component {
|
|
@@ -872,23 +910,7 @@ async function loadAndHydrate(element, src) {
|
|
|
872
910
|
// Dynamic import - 이 시점에 Island 모듈 로드
|
|
873
911
|
const module = await import(src);
|
|
874
912
|
const island = module.default;
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
// Fallback: read data-props from the island root or a child element if
|
|
878
|
-
// __MANDU_DATA__ is empty. Inline partials put their serialized props on
|
|
879
|
-
// the root marker itself.
|
|
880
|
-
if (!data || Object.keys(data).length === 0) {
|
|
881
|
-
const propsEl = element.hasAttribute('data-props')
|
|
882
|
-
? element
|
|
883
|
-
: element.querySelector('[data-props]');
|
|
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
|
-
}
|
|
890
|
-
}
|
|
891
|
-
}
|
|
913
|
+
const data = getIslandProps(id, element);
|
|
892
914
|
|
|
893
915
|
// Mandu Island (preferred)
|
|
894
916
|
if (island && island.__mandu_island === true) {
|
|
@@ -1582,6 +1604,7 @@ function generateIslandEntry(routeId: string, clientModulePath: string, exportNa
|
|
|
1582
1604
|
* Mandu Island: ${commentRouteId} (Generated)
|
|
1583
1605
|
* Pure export - no side effects
|
|
1584
1606
|
*/
|
|
1607
|
+
import React from "react";
|
|
1585
1608
|
import * as islandModule from ${importSpecifier};
|
|
1586
1609
|
|
|
1587
1610
|
const candidateExportNames = ${JSON.stringify(candidates)};
|
|
@@ -1600,7 +1623,13 @@ function resolveIslandExport(mod) {
|
|
|
1600
1623
|
}
|
|
1601
1624
|
|
|
1602
1625
|
const island = resolveIslandExport(islandModule);
|
|
1603
|
-
|
|
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;
|
|
1604
1633
|
`;
|
|
1605
1634
|
}
|
|
1606
1635
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import React from "react";
|
|
6
|
+
import type { BundleManifest } from "../../bundler/types";
|
|
7
|
+
import type { RoutesManifest } from "../../spec/schema";
|
|
8
|
+
import {
|
|
9
|
+
createServerRegistry,
|
|
10
|
+
startServer,
|
|
11
|
+
type ManduServer,
|
|
12
|
+
} from "../server";
|
|
13
|
+
|
|
14
|
+
const TEST_ROOT = path.resolve(process.cwd(), ".tmp-test-artifacts", "inline-client-hydration");
|
|
15
|
+
|
|
16
|
+
function hydratedManifest(routeId: string): BundleManifest {
|
|
17
|
+
return {
|
|
18
|
+
version: 1,
|
|
19
|
+
buildTime: "2026-05-23T00:00:00.000Z",
|
|
20
|
+
env: "production",
|
|
21
|
+
bundles: {
|
|
22
|
+
[routeId]: {
|
|
23
|
+
js: `/.mandu/client/${routeId}.island.js`,
|
|
24
|
+
dependencies: ["_runtime", "_react"],
|
|
25
|
+
priority: "visible",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
shared: {
|
|
29
|
+
runtime: "/.mandu/client/_runtime.js",
|
|
30
|
+
vendor: "/.mandu/client/_react.js",
|
|
31
|
+
router: "/.mandu/client/_router.js",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function resetTestRoot(): Promise<void> {
|
|
37
|
+
await rm(TEST_ROOT, { recursive: true, force: true });
|
|
38
|
+
await mkdir(TEST_ROOT, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
await rm(TEST_ROOT, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("startServer inline client hydration", () => {
|
|
46
|
+
it("captures component props through sync server pages and inferred named client exports", async () => {
|
|
47
|
+
await resetTestRoot();
|
|
48
|
+
|
|
49
|
+
const routeId = "pledges-$id";
|
|
50
|
+
const clientModule = "src/client/widgets/comments-section/CommentsSection.client.tsx";
|
|
51
|
+
const clientPath = path.join(TEST_ROOT, clientModule);
|
|
52
|
+
await mkdir(path.dirname(clientPath), { recursive: true });
|
|
53
|
+
await writeFile(
|
|
54
|
+
clientPath,
|
|
55
|
+
`
|
|
56
|
+
import React from "react";
|
|
57
|
+
|
|
58
|
+
export function CommentsSection({ pledgeId, initialComments }) {
|
|
59
|
+
return React.createElement(
|
|
60
|
+
"section",
|
|
61
|
+
{ "data-pledge-id": pledgeId },
|
|
62
|
+
initialComments.map((comment) =>
|
|
63
|
+
React.createElement("p", { key: comment.id }, comment.body)
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
`,
|
|
68
|
+
"utf-8",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const imported = await import(`${pathToFileURL(clientPath).href}?t=${Date.now()}`);
|
|
72
|
+
const CommentsSection = imported.CommentsSection as React.ComponentType<{
|
|
73
|
+
pledgeId: string;
|
|
74
|
+
initialComments: Array<{ id: string; body: string }>;
|
|
75
|
+
}>;
|
|
76
|
+
|
|
77
|
+
function PledgePage(): React.ReactElement {
|
|
78
|
+
return React.createElement(
|
|
79
|
+
"main",
|
|
80
|
+
null,
|
|
81
|
+
React.createElement(CommentsSection, {
|
|
82
|
+
pledgeId: "pledge-1",
|
|
83
|
+
initialComments: [{ id: "c1", body: "serialized comment" }],
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const registry = createServerRegistry();
|
|
89
|
+
registry.registerRouteComponent(routeId, PledgePage);
|
|
90
|
+
|
|
91
|
+
const manifest: RoutesManifest = {
|
|
92
|
+
version: 1,
|
|
93
|
+
routes: [
|
|
94
|
+
{
|
|
95
|
+
id: routeId,
|
|
96
|
+
kind: "page",
|
|
97
|
+
pattern: "/pledges/:id",
|
|
98
|
+
module: "app/pledges/[id]/page.tsx",
|
|
99
|
+
componentModule: "app/pledges/[id]/page.tsx",
|
|
100
|
+
clientModule,
|
|
101
|
+
hydration: { strategy: "island", priority: "visible", preload: false },
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
let server: ManduServer | undefined;
|
|
107
|
+
try {
|
|
108
|
+
server = startServer(manifest, {
|
|
109
|
+
port: 0,
|
|
110
|
+
registry,
|
|
111
|
+
rootDir: TEST_ROOT,
|
|
112
|
+
bundleManifest: hydratedManifest(routeId),
|
|
113
|
+
transitions: false,
|
|
114
|
+
prefetch: false,
|
|
115
|
+
spa: false,
|
|
116
|
+
devtools: false,
|
|
117
|
+
silent: true,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const response = await fetch(`http://127.0.0.1:${server.server.port}/pledges/pledge-1`);
|
|
121
|
+
const html = await response.text();
|
|
122
|
+
|
|
123
|
+
expect(response.status).toBe(200);
|
|
124
|
+
expect(html).toContain('data-mandu-island="pledges-$id--0"');
|
|
125
|
+
expect(html).toContain('type="application/json" data-mandu-props="pledges-$id--0"');
|
|
126
|
+
expect(html).toContain('"pledgeId":"pledge-1"');
|
|
127
|
+
expect(html).toContain('"initialComments"');
|
|
128
|
+
expect(html).toContain("serialized comment");
|
|
129
|
+
expect(html).not.toContain('data-mandu-island="pledges-$id"');
|
|
130
|
+
} finally {
|
|
131
|
+
server?.stop();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -181,7 +181,9 @@ describe("runtime page render response orchestration", () => {
|
|
|
181
181
|
const html = await response.text();
|
|
182
182
|
expect(html).toContain('data-mandu-island="candidates-$id--0"');
|
|
183
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"');
|
|
184
185
|
expect(html).toContain(""pledges"");
|
|
186
|
+
expect(html).toContain('"pledges"');
|
|
185
187
|
expect(html).toContain("Public transit");
|
|
186
188
|
expect(html).not.toContain('data-mandu-island="candidates-$id"');
|
|
187
189
|
});
|
|
@@ -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;
|
|
@@ -94,28 +95,44 @@ async function resolveAndWrapInlineClientHydration(
|
|
|
94
95
|
|
|
95
96
|
if (type === target.component) {
|
|
96
97
|
const id = `${target.routeId}--${counter.value++}`;
|
|
98
|
+
const props = element.props ?? {};
|
|
99
|
+
const serializedProps = serializeProps(props);
|
|
97
100
|
return {
|
|
98
101
|
node: React.createElement(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
+
}),
|
|
109
123
|
),
|
|
110
124
|
didWrap: true,
|
|
111
125
|
};
|
|
112
126
|
}
|
|
113
127
|
|
|
114
|
-
if (typeof type === "function" &&
|
|
115
|
-
const rendered = await (
|
|
128
|
+
if (typeof type === "function" && !isClassComponent(type)) {
|
|
129
|
+
const rendered = await renderFunctionComponentForInlineHydration(
|
|
130
|
+
type,
|
|
116
131
|
element.props ?? {},
|
|
117
132
|
);
|
|
118
|
-
|
|
133
|
+
if (rendered.ok) {
|
|
134
|
+
return resolveAndWrapInlineClientHydration(rendered.node, target, counter);
|
|
135
|
+
}
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
const props = element.props;
|
|
@@ -140,6 +157,42 @@ function isAsyncFunctionComponent(type: Function): boolean {
|
|
|
140
157
|
(type as { constructor?: { name?: string } }).constructor?.name === "AsyncFunction";
|
|
141
158
|
}
|
|
142
159
|
|
|
160
|
+
function isClassComponent(type: Function): boolean {
|
|
161
|
+
return !!type.prototype?.isReactComponent;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function renderFunctionComponentForInlineHydration(
|
|
165
|
+
type: Function,
|
|
166
|
+
props: Record<string, unknown>,
|
|
167
|
+
): Promise<{ ok: true; node: React.ReactNode } | { ok: false }> {
|
|
168
|
+
const render = type as (props: Record<string, unknown>) => React.ReactNode | Promise<React.ReactNode>;
|
|
169
|
+
if (isAsyncFunctionComponent(type)) {
|
|
170
|
+
return { ok: true, node: await render(props) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (functionComponentLooksHookDependent(type)) {
|
|
174
|
+
return { ok: false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
return { ok: true, node: await render(props) };
|
|
179
|
+
} catch {
|
|
180
|
+
return { ok: false };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function functionComponentLooksHookDependent(type: Function): boolean {
|
|
185
|
+
let source = "";
|
|
186
|
+
try {
|
|
187
|
+
source = Function.prototype.toString.call(type);
|
|
188
|
+
} catch {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return /\bReact\.use[A-Za-z0-9_$]*\s*\(/.test(source) ||
|
|
193
|
+
/\buse[A-Z][A-Za-z0-9_$]*\s*\(/.test(source);
|
|
194
|
+
}
|
|
195
|
+
|
|
143
196
|
function priorityToHydrate(priority: InlineClientHydrationTarget["priority"]): string {
|
|
144
197
|
return priority === "immediate" ? "load" : priority;
|
|
145
198
|
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -1124,16 +1124,13 @@ async function resolveInlineClientHydrationTarget(
|
|
|
1124
1124
|
rootDir: string,
|
|
1125
1125
|
src: string,
|
|
1126
1126
|
): Promise<InlineClientHydrationTarget | undefined> {
|
|
1127
|
-
if (!route.clientModule || !
|
|
1127
|
+
if (!route.clientModule || !src) {
|
|
1128
1128
|
return undefined;
|
|
1129
1129
|
}
|
|
1130
1130
|
|
|
1131
1131
|
try {
|
|
1132
1132
|
const module = await import(path.join(rootDir, route.clientModule));
|
|
1133
|
-
const
|
|
1134
|
-
const component = exportName === "default"
|
|
1135
|
-
? module.default
|
|
1136
|
-
: module[exportName] ?? module.default;
|
|
1133
|
+
const component = resolveInlineClientHydrationComponent(module, route);
|
|
1137
1134
|
|
|
1138
1135
|
if (!component) return undefined;
|
|
1139
1136
|
|
|
@@ -1152,6 +1149,46 @@ async function resolveInlineClientHydrationTarget(
|
|
|
1152
1149
|
}
|
|
1153
1150
|
}
|
|
1154
1151
|
|
|
1152
|
+
function resolveInlineClientHydrationComponent(
|
|
1153
|
+
module: Record<string, unknown>,
|
|
1154
|
+
route: { id: string; clientModule?: string; clientExportName?: string },
|
|
1155
|
+
): unknown {
|
|
1156
|
+
if (module.default) return module.default;
|
|
1157
|
+
|
|
1158
|
+
const candidates = [
|
|
1159
|
+
route.clientExportName && route.clientExportName !== "default" ? route.clientExportName : undefined,
|
|
1160
|
+
inferInlineClientExportNameFromPath(route.clientModule),
|
|
1161
|
+
inferInlineClientExportNameFromRouteId(route.id),
|
|
1162
|
+
].filter((candidate, index, values): candidate is string =>
|
|
1163
|
+
!!candidate && values.indexOf(candidate) === index
|
|
1164
|
+
);
|
|
1165
|
+
|
|
1166
|
+
for (const candidate of candidates) {
|
|
1167
|
+
if (module[candidate]) return module[candidate];
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const runtimeExports = Object.keys(module).filter((name) => name !== "__esModule");
|
|
1171
|
+
return runtimeExports.length === 1 ? module[runtimeExports[0]] : undefined;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function inferInlineClientExportNameFromPath(clientModulePath: string | undefined): string | undefined {
|
|
1175
|
+
if (!clientModulePath) return undefined;
|
|
1176
|
+
const basename = path.basename(clientModulePath).replace(/\.[cm]?[jt]sx?$/, "");
|
|
1177
|
+
const withoutClientSuffix = basename.replace(/\.(client|island)$/, "");
|
|
1178
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(withoutClientSuffix)
|
|
1179
|
+
? withoutClientSuffix
|
|
1180
|
+
: undefined;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function inferInlineClientExportNameFromRouteId(routeId: string): string | undefined {
|
|
1184
|
+
const pascal = routeId
|
|
1185
|
+
.split(/[^A-Za-z0-9]+/)
|
|
1186
|
+
.filter(Boolean)
|
|
1187
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
1188
|
+
.join("");
|
|
1189
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(pascal) ? pascal : undefined;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1155
1192
|
const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
|
|
1156
1193
|
|
|
1157
1194
|
// ========== Request Handler ==========
|