@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
|
@@ -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 +
|
|
36
|
-
|
|
37
|
-
|
|
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(
|
package/src/bundler/build.ts
CHANGED
|
@@ -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가
|
|
664
|
-
*
|
|
665
|
-
*
|
|
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,
|