@mandujs/core 0.54.29 → 0.54.31

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.29",
3
+ "version": "0.54.31",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -18,6 +18,7 @@ import { describe, expect, test } from "bun:test";
18
18
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
19
19
  import { tmpdir } from "node:os";
20
20
  import path from "node:path";
21
+ import { pathToFileURL } from "node:url";
21
22
  import {
22
23
  _testOnly_generateJsxDevRuntimeShimSource,
23
24
  _testOnly_generateJsxRuntimeShimSource,
@@ -32,10 +33,9 @@ describe("JSX runtime shim sources (issues #322 / #323)", () => {
32
33
  // the import statement, not comment mentions of the path.)
33
34
  expect(source).not.toMatch(/from\s*['"]react\/jsx-dev-runtime['"]/);
34
35
 
35
- // Correct: jsxDEV + Fragment come from bare 'react' (→ _react.js).
36
- expect(source).toMatch(
37
- /import\s*\{\s*jsxDEV\s*,\s*Fragment\s*\}\s*from\s*['"]react['"]/,
38
- );
36
+ // Correct: jsxDEV (+ jsx/jsxs fallback inputs) come from bare 'react'
37
+ // (→ _react.js). Import includes jsxDEV and resolves from "react".
38
+ expect(source).toMatch(/import\s*\{[^}]*\bjsxDEV\b[^}]*\}\s*from\s*['"]react['"]/);
39
39
 
40
40
  // Shim still re-exports what the import map expects.
41
41
  expect(source).toContain("export { jsxDEV, Fragment }");
@@ -83,4 +83,58 @@ describe("JSX runtime shim sources (issues #322 / #323)", () => {
83
83
  await rm(dir, { recursive: true, force: true });
84
84
  }
85
85
  });
86
+
87
+ // #323 re-regression (0.54.30): a SOURCE/static check passed but at RUNTIME
88
+ // `_react.js`'s jsxDEV was `undefined` (jsxDEV is a React dev-only export;
89
+ // a production-built _react.js drops it while keeping jsx/jsxs). The shim
90
+ // re-exported that undefined → "jsxDEV is not a function". Guard the VALUE:
91
+ // build the dev shim against a `react` stub whose jsxDEV is undefined (the
92
+ // exact failing condition) and assert the shim still exposes a callable
93
+ // jsxDEV via its jsx/jsxs fallback.
94
+ test("dev shim exposes a callable jsxDEV even when react.jsxDEV is undefined (runtime value)", async () => {
95
+ const dir = await mkdtemp(path.join(tmpdir(), "mandu-jsx-shim-rt-"));
96
+ try {
97
+ // Stub for bare 'react' = a production-built _react.js: jsx/jsxs are real
98
+ // functions, jsxDEV is missing.
99
+ const reactStub = path.join(dir, "react-stub.js");
100
+ await writeFile(
101
+ reactStub,
102
+ [
103
+ "export const jsx = (type) => ({ type, runtime: 'jsx' });",
104
+ "export const jsxs = (type) => ({ type, runtime: 'jsxs' });",
105
+ "export const Fragment = Symbol.for('react.fragment');",
106
+ "export const jsxDEV = undefined;",
107
+ ].join("\n"),
108
+ "utf-8",
109
+ );
110
+
111
+ // Generate the real shim source, but resolve its bare `react` import to
112
+ // the stub so we can drive the undefined-jsxDEV condition.
113
+ const shimSource = _testOnly_generateJsxDevRuntimeShimSource().replace(
114
+ /from\s*['"]react['"]/g,
115
+ `from ${JSON.stringify(reactStub.replace(/\\/g, "/"))}`,
116
+ );
117
+ const shimSrc = path.join(dir, "_jsx-dev-runtime.src.js");
118
+ await writeFile(shimSrc, shimSource, "utf-8");
119
+
120
+ const result = await Bun.build({
121
+ entrypoints: [shimSrc],
122
+ target: "browser",
123
+ define: { "process.env.NODE_ENV": JSON.stringify("production") },
124
+ });
125
+ expect(result.success).toBe(true);
126
+
127
+ const outPath = path.join(dir, "_jsx-dev-runtime.built.js");
128
+ await writeFile(outPath, await result.outputs[0]!.text(), "utf-8");
129
+ const mod = await import(`${pathToFileURL(outPath).href}?t=${Date.now()}`);
130
+
131
+ // The whole point of #323's re-regression: jsxDEV must be CALLABLE.
132
+ expect(typeof mod.jsxDEV).toBe("function");
133
+ // Fallback routes static-children to jsxs, otherwise jsx.
134
+ expect(mod.jsxDEV("div", {}, undefined, false).runtime).toBe("jsx");
135
+ expect(mod.jsxDEV("div", {}, undefined, true).runtime).toBe("jsxs");
136
+ } finally {
137
+ await rm(dir, { recursive: true, force: true });
138
+ }
139
+ });
86
140
  });
@@ -362,6 +362,40 @@ describe("buildClientBundles vendor shims", () => {
362
362
  expect((globalThis as typeof globalThis & { __MANDU_TEST_DEFAULT_PROPS__?: unknown }).__MANDU_TEST_DEFAULT_PROPS__).toBeUndefined();
363
363
  });
364
364
 
365
+ test("surfaces an island hydration failure to the DevTools hook (#324)", async () => {
366
+ const modulePath = path.join(rootDir, ".mandu", "client", "runtime-failing-boundary.js");
367
+ // A module that throws at import time → loadAndHydrate's catch runs.
368
+ await writeFile(modulePath, `throw new Error("boom at import");`, "utf-8");
369
+
370
+ const emitted: Array<{ type?: string; data?: { message?: string; islandId?: string } }> = [];
371
+ (window as unknown as { __MANDU_DEVTOOLS_HOOK__?: { emit: (e: unknown) => void } }).__MANDU_DEVTOOLS_HOOK__ = {
372
+ emit: (e: unknown) => {
373
+ emitted.push(e as { type?: string });
374
+ },
375
+ };
376
+
377
+ document.body.innerHTML = `
378
+ <div
379
+ data-mandu-island="failing--0"
380
+ data-mandu-src="${pathToFileURL(modulePath).href}?t=${Date.now()}"
381
+ data-hydrate="load"
382
+ ></div>
383
+ `;
384
+ (window as typeof window & { __MANDU_ROOTS__?: Map<string, unknown> }).__MANDU_ROOTS__ = new Map();
385
+
386
+ try {
387
+ const runtime = await evaluateGeneratedHydrationRuntime();
388
+ runtime.hydrateIslands();
389
+
390
+ await waitForRuntimeAssertion(() => emitted.some((e) => e.type === "error"));
391
+ const errorEvent = emitted.find((e) => e.type === "error");
392
+ expect(errorEvent?.data?.islandId).toBe("failing--0");
393
+ expect(errorEvent?.data?.message).toContain("boom at import");
394
+ } finally {
395
+ delete (window as unknown as { __MANDU_DEVTOOLS_HOOK__?: unknown }).__MANDU_DEVTOOLS_HOOK__;
396
+ }
397
+ });
398
+
365
399
  test("runtime hydrates default client boundary from boundary-local props", async () => {
366
400
  const modulePath = path.join(rootDir, ".mandu", "client", "runtime-default-boundary.js");
367
401
  await writeFile(
@@ -660,12 +660,24 @@ function generateJsxDevRuntimeShimSource(): string {
660
660
  * Development JSX 변환용
661
661
  *
662
662
  * #323: import map의 'react/jsx-dev-runtime'이 다시 이 셰임을 가리키므로
663
- * 'react/jsx-dev-runtime'에서 import하면 순환 self-import가 되어 jsxDEV가
664
- * undefined가 된다. 대신 'react'(→ _react.js, external 없이 react 전체를
665
- * 인라인하며 jsxDEV/Fragment를 re-export하는 vendor 번들)에서 가져와
666
- * 순환을 끊는다.
663
+ * 'react/jsx-dev-runtime'에서 import하면 순환 self-import가 된다. 대신
664
+ * 'react'(→ _react.js, react 전체를 인라인하는 vendor 번들)에서 가져온다.
665
+ *
666
+ * #323 재발(0.54.30): jsxDEV는 React의 **dev 전용** export다. _react.js가
667
+ * production NODE_ENV로 번들되면 'react/jsx-dev-runtime'이 production 변형으로
668
+ * 해소되어 jsxDEV 값이 undefined가 된다(jsx/jsxs는 둘 다 존재). 그 결과 dev
669
+ * island가 'jsxDEV is not a function'으로 전멸했다. _react.js가 어떤 NODE_ENV로
670
+ * 빌드되든 안전하도록, jsxDEV가 함수가 아니면 jsx/jsxs로 폴백한다(dev 경고만
671
+ * 잃고 렌더는 정상). isStaticChildren일 때는 jsxs로 라우팅한다.
667
672
  */
668
- import { jsxDEV, Fragment } from 'react';
673
+ import { jsx, jsxs, jsxDEV as __manduReactJsxDEV, Fragment } from 'react';
674
+
675
+ const jsxDEV =
676
+ typeof __manduReactJsxDEV === 'function'
677
+ ? __manduReactJsxDEV
678
+ : function jsxDEV(type, config, key, isStaticChildren) {
679
+ return (isStaticChildren ? jsxs : jsx)(type, config, key);
680
+ };
669
681
 
670
682
  // Named exports
671
683
  export { jsxDEV, Fragment };
@@ -160,6 +160,7 @@ class IslandErrorBoundary extends Component<IslandErrorBoundaryProps, IslandErro
160
160
 
161
161
  componentDidCatch(error: Error, errorInfo: unknown): void {
162
162
  console.error("[Mandu] Island error:", this.props.islandId, error, errorInfo);
163
+ emitIslandHydrationError(this.props.islandId, error);
163
164
  }
164
165
 
165
166
  reset = (): void => {
@@ -371,6 +372,35 @@ function isManduIslandExport(value: unknown): value is ManduIslandExport {
371
372
  "definition" in value;
372
373
  }
373
374
 
375
+ // #324: surface island hydration/render failures to the in-page DevTools so
376
+ // its health badge and Issues counter reflect them instead of staying
377
+ // "HEALTHY". Render-time errors are swallowed by IslandErrorBoundary and never
378
+ // reach the global error listener, so we emit them to the devtools hook here.
379
+ function emitIslandHydrationError(islandId: string, error: unknown): void {
380
+ const devtoolsHook = (window as Window & {
381
+ __MANDU_DEVTOOLS_HOOK__?: RuntimeDevtoolsHook;
382
+ }).__MANDU_DEVTOOLS_HOOK__;
383
+ if (!devtoolsHook) return;
384
+ try {
385
+ devtoolsHook.emit({
386
+ type: "error",
387
+ timestamp: Date.now(),
388
+ data: {
389
+ id: "island-" + islandId + "-" + Date.now(),
390
+ type: "runtime",
391
+ severity: "error",
392
+ message: error instanceof Error ? error.message : String(error),
393
+ stack: error instanceof Error ? error.stack : undefined,
394
+ timestamp: Date.now(),
395
+ url: location.href,
396
+ islandId,
397
+ },
398
+ });
399
+ } catch {
400
+ // DevTools must never break hydration.
401
+ }
402
+ }
403
+
374
404
  async function loadAndHydrate(element: HTMLElement, src: string): Promise<void> {
375
405
  const id = element.getAttribute("data-mandu-island");
376
406
  if (!id) {
@@ -490,6 +520,7 @@ async function loadAndHydrate(element: HTMLElement, src: string): Promise<void>
490
520
  } catch (error) {
491
521
  console.error("[Mandu] Hydration failed for", islandId, error);
492
522
  element.setAttribute("data-mandu-error", "true");
523
+ emitIslandHydrationError(islandId, error);
493
524
 
494
525
  element.dispatchEvent(new CustomEvent("mandu:hydration-error", {
495
526
  bubbles: true,