@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.54.16",
3
+ "version": "0.54.17",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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" && isAsyncFunctionComponent(type)) {
129
- const rendered = await (type as (props: Record<string, unknown>) => React.ReactNode | Promise<React.ReactNode>)(
128
+ if (typeof type === "function" && !isClassComponent(type)) {
129
+ const rendered = await renderFunctionComponentForInlineHydration(
130
+ type,
130
131
  element.props ?? {},
131
132
  );
132
- return resolveAndWrapInlineClientHydration(rendered, target, counter);
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
  }
@@ -1124,16 +1124,13 @@ async function resolveInlineClientHydrationTarget(
1124
1124
  rootDir: string,
1125
1125
  src: string,
1126
1126
  ): Promise<InlineClientHydrationTarget | undefined> {
1127
- if (!route.clientModule || !route.clientExportName || !src) {
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 exportName = route.clientExportName;
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 ==========