@mandujs/core 0.54.16 → 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
CHANGED
|
@@ -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
|
+
});
|
|
@@ -125,11 +125,14 @@ async function resolveAndWrapInlineClientHydration(
|
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
if (typeof type === "function" &&
|
|
129
|
-
const rendered = await (
|
|
128
|
+
if (typeof type === "function" && !isClassComponent(type)) {
|
|
129
|
+
const rendered = await renderFunctionComponentForInlineHydration(
|
|
130
|
+
type,
|
|
130
131
|
element.props ?? {},
|
|
131
132
|
);
|
|
132
|
-
|
|
133
|
+
if (rendered.ok) {
|
|
134
|
+
return resolveAndWrapInlineClientHydration(rendered.node, target, counter);
|
|
135
|
+
}
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
const props = element.props;
|
|
@@ -154,6 +157,42 @@ function isAsyncFunctionComponent(type: Function): boolean {
|
|
|
154
157
|
(type as { constructor?: { name?: string } }).constructor?.name === "AsyncFunction";
|
|
155
158
|
}
|
|
156
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
|
+
|
|
157
196
|
function priorityToHydrate(priority: InlineClientHydrationTarget["priority"]): string {
|
|
158
197
|
return priority === "immediate" ? "load" : priority;
|
|
159
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 ==========
|