@nifrajs/web-react 0.1.0-alpha.1 → 0.1.0-alpha.3
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/README.md +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/react-dom-server.d.ts +44 -0
- package/dist/react-dom-server.d.ts.map +1 -0
- package/dist/react-dom-server.js +87 -0
- package/dist/react-dom-server.js.map +1 -0
- package/package.json +4 -4
- package/src/index.ts +10 -4
- package/src/react-dom-server.ts +103 -0
package/README.md
CHANGED
|
@@ -16,3 +16,11 @@ bun add @nifrajs/web-react
|
|
|
16
16
|
- AI-readable: <https://nifra.dev/llms.txt>
|
|
17
17
|
|
|
18
18
|
MIT
|
|
19
|
+
|
|
20
|
+
## For AI agents
|
|
21
|
+
|
|
22
|
+
Start with [`LLM.md`](./LLM.md) — this package's contract card (the exports you call + its footguns),
|
|
23
|
+
one cheap read instead of the whole corpus. For the wider framework: the repo's
|
|
24
|
+
[`AGENTS.md`](../../AGENTS.md) is the copy-paste quick reference, and
|
|
25
|
+
[`llms-full.txt`](../../llms-full.txt) is the full machine-readable corpus. Run `nifra check` as the
|
|
26
|
+
done-gate, or `nifra mcp` to give the agent live project tools.
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AASjD,6EAA6E;AAC7E,eAAO,MAAM,YAAY,EAAE,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AASjD,6EAA6E;AAC7E,eAAO,MAAM,YAAY,EAAE,aAyB1B,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -3,21 +3,27 @@
|
|
|
3
3
|
* `renderToReadableStream`; the layout-chain fold is in `./compose`. Client hydration lives in
|
|
4
4
|
* `@nifrajs/web-react/client`. React's JSX is Bun-native — no build plugin needed.
|
|
5
5
|
*/
|
|
6
|
-
import { renderToString as reactRenderToString, renderToReadableStream } from "react-dom/server";
|
|
7
6
|
import { compose } from "./compose.js";
|
|
7
|
+
import { reactDomServer } from "./react-dom-server.js";
|
|
8
8
|
/** The React server render adapter — pass to @nifrajs/web's `renderPage`. */
|
|
9
9
|
export const reactAdapter = {
|
|
10
10
|
// Synchronous one-pass render for non-deferred pages (renderPage's buffered fast path). React's
|
|
11
11
|
// `renderToString` emits the same hydration-compatible markup as the Fizz stream for non-Suspense
|
|
12
12
|
// content and is markedly cheaper than `renderToReadableStream` — most visibly on Bun, where Fizz's
|
|
13
13
|
// streaming machinery is the heaviest. A page that defer()s/Suspends takes `renderToStream` below.
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
//
|
|
15
|
+
// Async because the `react-dom/server` module is resolved lazily (from the consumer app's node_modules
|
|
16
|
+
// under Bun runtime SSR — see ./react-dom-server). `RenderAdapter.renderToString` permits a Promise;
|
|
17
|
+
// renderPage awaits it.
|
|
18
|
+
async renderToString(chain, props) {
|
|
19
|
+
const { renderToString } = await reactDomServer();
|
|
20
|
+
return renderToString(compose(chain, props));
|
|
16
21
|
},
|
|
17
|
-
renderToStream(chain, props) {
|
|
22
|
+
async renderToStream(chain, props) {
|
|
18
23
|
// Resolves a Web `ReadableStream<Uint8Array>` once the shell is renderable; Suspense
|
|
19
24
|
// boundaries stream as they resolve. No `bootstrapModules` — nifra injects the client entry in
|
|
20
25
|
// the document tail. React's default `onError` logs to console.error (errors aren't swallowed).
|
|
26
|
+
const { renderToReadableStream } = await reactDomServer();
|
|
21
27
|
return renderToReadableStream(compose(chain, props));
|
|
22
28
|
},
|
|
23
29
|
// React reconciles against the existing DOM on hydrate, so no per-document bootstrap
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA;;;;GAIG;AACH,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA;;;;GAIG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAEtD,6EAA6E;AAC7E,MAAM,CAAC,MAAM,YAAY,GAAkB;IACzC,gGAAgG;IAChG,kGAAkG;IAClG,oGAAoG;IACpG,mGAAmG;IACnG,EAAE;IACF,uGAAuG;IACvG,qGAAqG;IACrG,wBAAwB;IACxB,KAAK,CAAC,cAAc,CAAC,KAAK,EAAE,KAAK;QAC/B,MAAM,EAAE,cAAc,EAAE,GAAG,MAAM,cAAc,EAAE,CAAA;QACjD,OAAO,cAAc,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAA;IAC9C,CAAC;IACD,KAAK,CAAC,cAAc,CAAC,KAAK,EAAE,KAAK;QAC/B,qFAAqF;QACrF,+FAA+F;QAC/F,gGAAgG;QAChG,MAAM,EAAE,sBAAsB,EAAE,GAAG,MAAM,cAAc,EAAE,CAAA;QACzD,OAAO,sBAAsB,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAA;IACtD,CAAC;IACD,qFAAqF;IACrF,sFAAsF;IACtF,aAAa;QACX,OAAO,EAAE,CAAA;IACX,CAAC;CACF,CAAA"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve `react-dom/server` so it shares ONE React core with the route components — the fix for the
|
|
3
|
+
* dual-React SSR crash (`resolveDispatcher().useState` is null / "Invalid hook call: mismatching versions
|
|
4
|
+
* of React and the renderer").
|
|
5
|
+
*
|
|
6
|
+
* WHY this exists: under Bun **runtime** SSR (`nifra dev`, `nifra start`, `nifra_render`, all in-process),
|
|
7
|
+
* a static `import "react-dom/server"` in this adapter is resolved by Bun from THIS package's own
|
|
8
|
+
* (symlinked) node_modules — which can be a DIFFERENT physical `react` than the one the consumer app's
|
|
9
|
+
* route components import. Two React cores → two hook dispatchers → the renderer's dispatcher is the wrong
|
|
10
|
+
* (or null) one → the crash. Resolving `react-dom/server` from the consumer **app root** instead makes
|
|
11
|
+
* react-dom pull the app's `react` transitively, matching the components' `react`: a single core, a single
|
|
12
|
+
* dispatcher, no crash. (Empirically verified against a two-copy install fixture — see
|
|
13
|
+
* test/dual-react.test.ts.)
|
|
14
|
+
*
|
|
15
|
+
* Guarding precisely so the PRODUCTION/BUILT path is untouched: when `Bun.resolveSync` is unavailable
|
|
16
|
+
* (Node / Deno / Cloudflare / Vercel / any bundled output), the build has already bundled+deduped a single
|
|
17
|
+
* `react-dom` (buildServer's `reactDedupePlugin` pins `react`), so the static `import` is correct AND is
|
|
18
|
+
* the only thing that works without a Bun resolver. We fall back to it there. The app-root resolution runs
|
|
19
|
+
* ONLY under the Bun runtime, where the duplication can occur and `Bun.resolveSync` exists.
|
|
20
|
+
*/
|
|
21
|
+
import type { ReactNode } from "react";
|
|
22
|
+
/** The slice of `react-dom/server` this adapter uses. Typed locally so the dynamic import (which Bun
|
|
23
|
+
* resolves to an absolute path string) stays strict — no `any` crosses the boundary. */
|
|
24
|
+
export interface ReactDomServer {
|
|
25
|
+
renderToString(node: ReactNode): string;
|
|
26
|
+
renderToReadableStream(node: ReactNode): Promise<ReadableStream<Uint8Array>>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Get `react-dom/server` bound to the consumer app's React. Cached after the first call. Under the Bun
|
|
30
|
+
* runtime, dynamically imports the copy resolved from the app root; otherwise (built/bundled, or a non-Bun
|
|
31
|
+
* host) loads the statically-bundled `react-dom/server`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function reactDomServer(): Promise<ReactDomServer>;
|
|
34
|
+
/** A `Bun.resolveSync`-shaped function (specifier, from) → absolute path. */
|
|
35
|
+
type ResolveSync = (specifier: string, from: string) => string;
|
|
36
|
+
/**
|
|
37
|
+
* Load `react-dom/server`, preferring the app-root-resolved copy under the Bun runtime. Exported for unit
|
|
38
|
+
* tests: `resolve` defaults to the ambient `Bun.resolveSync` (undefined on non-Bun hosts), and a test can
|
|
39
|
+
* inject a stub that succeeds (re-root branch) or throws (fallback branch) to cover both deterministically
|
|
40
|
+
* without depending on the machine's node_modules layout.
|
|
41
|
+
*/
|
|
42
|
+
export declare function loadReactDomServer(resolve?: ResolveSync | undefined): Promise<ReactDomServer>;
|
|
43
|
+
export {};
|
|
44
|
+
//# sourceMappingURL=react-dom-server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react-dom-server.d.ts","sourceRoot":"","sources":["../src/react-dom-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEtC;wFACwF;AACxF,MAAM,WAAW,cAAc;IAC7B,cAAc,CAAC,IAAI,EAAE,SAAS,GAAG,MAAM,CAAA;IACvC,sBAAsB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAAA;CAC7E;AA4BD;;;;GAIG;AACH,wBAAgB,cAAc,IAAI,OAAO,CAAC,cAAc,CAAC,CAIxD;AAED,6EAA6E;AAC7E,KAAK,WAAW,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;AAE9D;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,GAAE,WAAW,GAAG,SAA2B,GACjD,OAAO,CAAC,cAAc,CAAC,CAiBzB"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve `react-dom/server` so it shares ONE React core with the route components — the fix for the
|
|
3
|
+
* dual-React SSR crash (`resolveDispatcher().useState` is null / "Invalid hook call: mismatching versions
|
|
4
|
+
* of React and the renderer").
|
|
5
|
+
*
|
|
6
|
+
* WHY this exists: under Bun **runtime** SSR (`nifra dev`, `nifra start`, `nifra_render`, all in-process),
|
|
7
|
+
* a static `import "react-dom/server"` in this adapter is resolved by Bun from THIS package's own
|
|
8
|
+
* (symlinked) node_modules — which can be a DIFFERENT physical `react` than the one the consumer app's
|
|
9
|
+
* route components import. Two React cores → two hook dispatchers → the renderer's dispatcher is the wrong
|
|
10
|
+
* (or null) one → the crash. Resolving `react-dom/server` from the consumer **app root** instead makes
|
|
11
|
+
* react-dom pull the app's `react` transitively, matching the components' `react`: a single core, a single
|
|
12
|
+
* dispatcher, no crash. (Empirically verified against a two-copy install fixture — see
|
|
13
|
+
* test/dual-react.test.ts.)
|
|
14
|
+
*
|
|
15
|
+
* Guarding precisely so the PRODUCTION/BUILT path is untouched: when `Bun.resolveSync` is unavailable
|
|
16
|
+
* (Node / Deno / Cloudflare / Vercel / any bundled output), the build has already bundled+deduped a single
|
|
17
|
+
* `react-dom` (buildServer's `reactDedupePlugin` pins `react`), so the static `import` is correct AND is
|
|
18
|
+
* the only thing that works without a Bun resolver. We fall back to it there. The app-root resolution runs
|
|
19
|
+
* ONLY under the Bun runtime, where the duplication can occur and `Bun.resolveSync` exists.
|
|
20
|
+
*/
|
|
21
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
22
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
23
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
24
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return path;
|
|
28
|
+
};
|
|
29
|
+
const bunResolver = globalThis.Bun;
|
|
30
|
+
const hasBunResolveSync = typeof bunResolver?.resolveSync === "function";
|
|
31
|
+
// Resolve once and cache: the module identity is stable for the process lifetime, and re-resolving per
|
|
32
|
+
// render would pay the import cost on every page. A Promise is cached (not the module) so concurrent
|
|
33
|
+
// first renders share a single in-flight import rather than racing N imports.
|
|
34
|
+
let cached;
|
|
35
|
+
/**
|
|
36
|
+
* The consumer app root used to re-root `react-dom/server`. `nifra dev` / `nifra start` run with the CLI's
|
|
37
|
+
* `process.cwd()` set to the app directory, and `nifra_render`'s subprocess `process.chdir`es to the app
|
|
38
|
+
* dir before SSR (see packages/cli/src/mcp-render.ts), so `process.cwd()` is the app root on every Bun
|
|
39
|
+
* runtime SSR path. Read lazily (not at module load) so a host that `chdir`s after import still sees the
|
|
40
|
+
* right root on first render.
|
|
41
|
+
*/
|
|
42
|
+
function appRoot() {
|
|
43
|
+
return process.cwd();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Get `react-dom/server` bound to the consumer app's React. Cached after the first call. Under the Bun
|
|
47
|
+
* runtime, dynamically imports the copy resolved from the app root; otherwise (built/bundled, or a non-Bun
|
|
48
|
+
* host) loads the statically-bundled `react-dom/server`.
|
|
49
|
+
*/
|
|
50
|
+
export function reactDomServer() {
|
|
51
|
+
if (cached !== undefined)
|
|
52
|
+
return cached;
|
|
53
|
+
cached = loadReactDomServer();
|
|
54
|
+
return cached;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Load `react-dom/server`, preferring the app-root-resolved copy under the Bun runtime. Exported for unit
|
|
58
|
+
* tests: `resolve` defaults to the ambient `Bun.resolveSync` (undefined on non-Bun hosts), and a test can
|
|
59
|
+
* inject a stub that succeeds (re-root branch) or throws (fallback branch) to cover both deterministically
|
|
60
|
+
* without depending on the machine's node_modules layout.
|
|
61
|
+
*/
|
|
62
|
+
export async function loadReactDomServer(resolve = bunResolverFn()) {
|
|
63
|
+
if (resolve !== undefined) {
|
|
64
|
+
// Bun runtime SSR: re-root to the app's copy so react-dom shares the components' React. `react-dom`
|
|
65
|
+
// is a PEER dependency of this adapter, so a correct install puts it at the app root and this
|
|
66
|
+
// resolves. If it somehow doesn't (an unusual nested layout), fall through to the bundled specifier
|
|
67
|
+
// rather than crashing — degraded (possible duplicate) but never a hard failure.
|
|
68
|
+
try {
|
|
69
|
+
const resolved = resolve("react-dom/server", appRoot());
|
|
70
|
+
return (await import(__rewriteRelativeImportExtension(resolved)));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// App-root resolution failed; use the specifier resolved from this module's own location below.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Built bundle / Node / Deno / edge (or the Bun-resolve fallback above): the static import is
|
|
77
|
+
// bundled+deduped (or is the only react-dom present). A bare-specifier dynamic import here lets the
|
|
78
|
+
// bundler include it (it's a constant), and at runtime resolves the copy visible to this package.
|
|
79
|
+
return (await import("react-dom/server"));
|
|
80
|
+
}
|
|
81
|
+
/** The ambient `Bun.resolveSync`, or undefined on a non-Bun host (where the static import is the only path). */
|
|
82
|
+
function bunResolverFn() {
|
|
83
|
+
return hasBunResolveSync && bunResolver?.resolveSync !== undefined
|
|
84
|
+
? bunResolver.resolveSync.bind(bunResolver)
|
|
85
|
+
: undefined;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=react-dom-server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react-dom-server.js","sourceRoot":"","sources":["../src/react-dom-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;;;;;;;;;AAkBH,MAAM,WAAW,GAAI,UAA6C,CAAC,GAAG,CAAA;AACtE,MAAM,iBAAiB,GAAG,OAAO,WAAW,EAAE,WAAW,KAAK,UAAU,CAAA;AAExE,uGAAuG;AACvG,qGAAqG;AACrG,8EAA8E;AAC9E,IAAI,MAA2C,CAAA;AAE/C;;;;;;GAMG;AACH,SAAS,OAAO;IACd,OAAO,OAAO,CAAC,GAAG,EAAE,CAAA;AACtB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc;IAC5B,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAA;IACvC,MAAM,GAAG,kBAAkB,EAAE,CAAA;IAC7B,OAAO,MAAM,CAAA;AACf,CAAC;AAKD;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,UAAmC,aAAa,EAAE;IAElD,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,oGAAoG;QACpG,8FAA8F;QAC9F,oGAAoG;QACpG,iFAAiF;QACjF,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,OAAO,CAAC,kBAAkB,EAAE,OAAO,EAAE,CAAC,CAAA;YACvD,OAAO,CAAC,MAAM,MAAM,kCAAC,QAAQ,EAAC,CAAmB,CAAA;QACnD,CAAC;QAAC,MAAM,CAAC;YACP,gGAAgG;QAClG,CAAC;IACH,CAAC;IACD,8FAA8F;IAC9F,oGAAoG;IACpG,kGAAkG;IAClG,OAAO,CAAC,MAAM,MAAM,CAAC,kBAAkB,CAAC,CAAmB,CAAA;AAC7D,CAAC;AAED,gHAAgH;AAChH,SAAS,aAAa;IACpB,OAAO,iBAAiB,IAAI,WAAW,EAAE,WAAW,KAAK,SAAS;QAChE,CAAC,CAAC,WAAW,CAAC,WAAW,CAAC,IAAI,CAAC,WAAW,CAAC;QAC3C,CAAC,CAAC,SAAS,CAAA;AACf,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/web-react",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.3",
|
|
4
4
|
"description": "React render adapter for @nifrajs/web — SSR + hydration (Bun-native JSX, no Babel).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -75,9 +75,9 @@
|
|
|
75
75
|
"access": "public"
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@nifrajs/i18n": "
|
|
79
|
-
"@nifrajs/image": "
|
|
80
|
-
"@nifrajs/web": "
|
|
78
|
+
"@nifrajs/i18n": "0.1.0-alpha.2",
|
|
79
|
+
"@nifrajs/image": "0.1.0-alpha.2",
|
|
80
|
+
"@nifrajs/web": "0.1.0-alpha.3"
|
|
81
81
|
},
|
|
82
82
|
"peerDependencies": {
|
|
83
83
|
"react": "^19.0.0",
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,8 @@ import type { RenderAdapter } from "@nifrajs/web"
|
|
|
4
4
|
* `renderToReadableStream`; the layout-chain fold is in `./compose`. Client hydration lives in
|
|
5
5
|
* `@nifrajs/web-react/client`. React's JSX is Bun-native — no build plugin needed.
|
|
6
6
|
*/
|
|
7
|
-
import { renderToString as reactRenderToString, renderToReadableStream } from "react-dom/server"
|
|
8
7
|
import { compose } from "./compose.ts"
|
|
8
|
+
import { reactDomServer } from "./react-dom-server.ts"
|
|
9
9
|
|
|
10
10
|
/** The React server render adapter — pass to @nifrajs/web's `renderPage`. */
|
|
11
11
|
export const reactAdapter: RenderAdapter = {
|
|
@@ -13,13 +13,19 @@ export const reactAdapter: RenderAdapter = {
|
|
|
13
13
|
// `renderToString` emits the same hydration-compatible markup as the Fizz stream for non-Suspense
|
|
14
14
|
// content and is markedly cheaper than `renderToReadableStream` — most visibly on Bun, where Fizz's
|
|
15
15
|
// streaming machinery is the heaviest. A page that defer()s/Suspends takes `renderToStream` below.
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
//
|
|
17
|
+
// Async because the `react-dom/server` module is resolved lazily (from the consumer app's node_modules
|
|
18
|
+
// under Bun runtime SSR — see ./react-dom-server). `RenderAdapter.renderToString` permits a Promise;
|
|
19
|
+
// renderPage awaits it.
|
|
20
|
+
async renderToString(chain, props) {
|
|
21
|
+
const { renderToString } = await reactDomServer()
|
|
22
|
+
return renderToString(compose(chain, props))
|
|
18
23
|
},
|
|
19
|
-
renderToStream(chain, props) {
|
|
24
|
+
async renderToStream(chain, props) {
|
|
20
25
|
// Resolves a Web `ReadableStream<Uint8Array>` once the shell is renderable; Suspense
|
|
21
26
|
// boundaries stream as they resolve. No `bootstrapModules` — nifra injects the client entry in
|
|
22
27
|
// the document tail. React's default `onError` logs to console.error (errors aren't swallowed).
|
|
28
|
+
const { renderToReadableStream } = await reactDomServer()
|
|
23
29
|
return renderToReadableStream(compose(chain, props))
|
|
24
30
|
},
|
|
25
31
|
// React reconciles against the existing DOM on hydrate, so no per-document bootstrap
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve `react-dom/server` so it shares ONE React core with the route components — the fix for the
|
|
3
|
+
* dual-React SSR crash (`resolveDispatcher().useState` is null / "Invalid hook call: mismatching versions
|
|
4
|
+
* of React and the renderer").
|
|
5
|
+
*
|
|
6
|
+
* WHY this exists: under Bun **runtime** SSR (`nifra dev`, `nifra start`, `nifra_render`, all in-process),
|
|
7
|
+
* a static `import "react-dom/server"` in this adapter is resolved by Bun from THIS package's own
|
|
8
|
+
* (symlinked) node_modules — which can be a DIFFERENT physical `react` than the one the consumer app's
|
|
9
|
+
* route components import. Two React cores → two hook dispatchers → the renderer's dispatcher is the wrong
|
|
10
|
+
* (or null) one → the crash. Resolving `react-dom/server` from the consumer **app root** instead makes
|
|
11
|
+
* react-dom pull the app's `react` transitively, matching the components' `react`: a single core, a single
|
|
12
|
+
* dispatcher, no crash. (Empirically verified against a two-copy install fixture — see
|
|
13
|
+
* test/dual-react.test.ts.)
|
|
14
|
+
*
|
|
15
|
+
* Guarding precisely so the PRODUCTION/BUILT path is untouched: when `Bun.resolveSync` is unavailable
|
|
16
|
+
* (Node / Deno / Cloudflare / Vercel / any bundled output), the build has already bundled+deduped a single
|
|
17
|
+
* `react-dom` (buildServer's `reactDedupePlugin` pins `react`), so the static `import` is correct AND is
|
|
18
|
+
* the only thing that works without a Bun resolver. We fall back to it there. The app-root resolution runs
|
|
19
|
+
* ONLY under the Bun runtime, where the duplication can occur and `Bun.resolveSync` exists.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ReactNode } from "react"
|
|
23
|
+
|
|
24
|
+
/** The slice of `react-dom/server` this adapter uses. Typed locally so the dynamic import (which Bun
|
|
25
|
+
* resolves to an absolute path string) stays strict — no `any` crosses the boundary. */
|
|
26
|
+
export interface ReactDomServer {
|
|
27
|
+
renderToString(node: ReactNode): string
|
|
28
|
+
renderToReadableStream(node: ReactNode): Promise<ReadableStream<Uint8Array>>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// `globalThis.Bun` isn't in the ambient lib types; narrow exactly the one method we need so we never
|
|
32
|
+
// reach for `any`. `resolveSync(specifier, from)` returns the absolute path the specifier resolves to
|
|
33
|
+
// when required from `from` — Bun's runtime resolver, the only lever that re-roots a BARE specifier
|
|
34
|
+
// (a runtime `Bun.plugin` onResolve does NOT fire for bare specifiers like `react-dom/server`, verified).
|
|
35
|
+
interface BunResolver {
|
|
36
|
+
resolveSync(specifier: string, from: string): string
|
|
37
|
+
}
|
|
38
|
+
const bunResolver = (globalThis as { Bun?: Partial<BunResolver> }).Bun
|
|
39
|
+
const hasBunResolveSync = typeof bunResolver?.resolveSync === "function"
|
|
40
|
+
|
|
41
|
+
// Resolve once and cache: the module identity is stable for the process lifetime, and re-resolving per
|
|
42
|
+
// render would pay the import cost on every page. A Promise is cached (not the module) so concurrent
|
|
43
|
+
// first renders share a single in-flight import rather than racing N imports.
|
|
44
|
+
let cached: Promise<ReactDomServer> | undefined
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The consumer app root used to re-root `react-dom/server`. `nifra dev` / `nifra start` run with the CLI's
|
|
48
|
+
* `process.cwd()` set to the app directory, and `nifra_render`'s subprocess `process.chdir`es to the app
|
|
49
|
+
* dir before SSR (see packages/cli/src/mcp-render.ts), so `process.cwd()` is the app root on every Bun
|
|
50
|
+
* runtime SSR path. Read lazily (not at module load) so a host that `chdir`s after import still sees the
|
|
51
|
+
* right root on first render.
|
|
52
|
+
*/
|
|
53
|
+
function appRoot(): string {
|
|
54
|
+
return process.cwd()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get `react-dom/server` bound to the consumer app's React. Cached after the first call. Under the Bun
|
|
59
|
+
* runtime, dynamically imports the copy resolved from the app root; otherwise (built/bundled, or a non-Bun
|
|
60
|
+
* host) loads the statically-bundled `react-dom/server`.
|
|
61
|
+
*/
|
|
62
|
+
export function reactDomServer(): Promise<ReactDomServer> {
|
|
63
|
+
if (cached !== undefined) return cached
|
|
64
|
+
cached = loadReactDomServer()
|
|
65
|
+
return cached
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A `Bun.resolveSync`-shaped function (specifier, from) → absolute path. */
|
|
69
|
+
type ResolveSync = (specifier: string, from: string) => string
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Load `react-dom/server`, preferring the app-root-resolved copy under the Bun runtime. Exported for unit
|
|
73
|
+
* tests: `resolve` defaults to the ambient `Bun.resolveSync` (undefined on non-Bun hosts), and a test can
|
|
74
|
+
* inject a stub that succeeds (re-root branch) or throws (fallback branch) to cover both deterministically
|
|
75
|
+
* without depending on the machine's node_modules layout.
|
|
76
|
+
*/
|
|
77
|
+
export async function loadReactDomServer(
|
|
78
|
+
resolve: ResolveSync | undefined = bunResolverFn(),
|
|
79
|
+
): Promise<ReactDomServer> {
|
|
80
|
+
if (resolve !== undefined) {
|
|
81
|
+
// Bun runtime SSR: re-root to the app's copy so react-dom shares the components' React. `react-dom`
|
|
82
|
+
// is a PEER dependency of this adapter, so a correct install puts it at the app root and this
|
|
83
|
+
// resolves. If it somehow doesn't (an unusual nested layout), fall through to the bundled specifier
|
|
84
|
+
// rather than crashing — degraded (possible duplicate) but never a hard failure.
|
|
85
|
+
try {
|
|
86
|
+
const resolved = resolve("react-dom/server", appRoot())
|
|
87
|
+
return (await import(resolved)) as ReactDomServer
|
|
88
|
+
} catch {
|
|
89
|
+
// App-root resolution failed; use the specifier resolved from this module's own location below.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Built bundle / Node / Deno / edge (or the Bun-resolve fallback above): the static import is
|
|
93
|
+
// bundled+deduped (or is the only react-dom present). A bare-specifier dynamic import here lets the
|
|
94
|
+
// bundler include it (it's a constant), and at runtime resolves the copy visible to this package.
|
|
95
|
+
return (await import("react-dom/server")) as ReactDomServer
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The ambient `Bun.resolveSync`, or undefined on a non-Bun host (where the static import is the only path). */
|
|
99
|
+
function bunResolverFn(): ResolveSync | undefined {
|
|
100
|
+
return hasBunResolveSync && bunResolver?.resolveSync !== undefined
|
|
101
|
+
? bunResolver.resolveSync.bind(bunResolver)
|
|
102
|
+
: undefined
|
|
103
|
+
}
|