@nifrajs/web-react 2.1.0 → 2.2.0

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.
@@ -1 +1 @@
1
- {"version":3,"file":"compose.d.ts","sourceRoot":"","sources":["../src/compose.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAC/C,OAAO,EAAyC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAM7E;;;;;;;;;;GAUG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,OAAO,EAAE,EAAE,KAAK,EAAE,WAAW,GAAG,SAAS,CAmBhF"}
1
+ {"version":3,"file":"compose.d.ts","sourceRoot":"","sources":["../src/compose.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAC/C,OAAO,EAAyC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAM7E;;;;;;;;;;GAUG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,SAAS,OAAO,EAAE,EAAE,KAAK,EAAE,WAAW,GAAG,SAAS,CA0BhF"}
package/dist/compose.js CHANGED
@@ -18,7 +18,10 @@ export function compose(chain, props) {
18
18
  let node = createElement(chain[last], props);
19
19
  for (let i = last - 1; i >= 0; i--) {
20
20
  // children passed as the 3rd arg (not a `children` prop) — React's canonical form.
21
- node = createElement(chain[i], null, node);
21
+ // Each layout receives its own loader data at its own index. Layouts are the chain's leading
22
+ // prefix, so `layoutData[i]` belongs to `chain[i]`; anything past that end (a client-only `_error`
23
+ // boundary marker, the page) reads `undefined` and is unaffected.
24
+ node = createElement(chain[i], { data: props.layoutData?.[i] ?? null }, node);
22
25
  }
23
26
  return createElement(RouterContext.Provider, {
24
27
  value: {
@@ -1 +1 @@
1
- {"version":3,"file":"compose.js","sourceRoot":"","sources":["../src/compose.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAA0C,MAAM,OAAO,CAAA;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAE3C,oGAAoG;AACpG,MAAM,YAAY,GAAqC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AAExE;;;;;;;;;;GAUG;AACH,MAAM,UAAU,OAAO,CAAC,KAAyB,EAAE,KAAkB;IACnE,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAA;IAC7B,IAAI,IAAI,GAAc,aAAa,CAAC,KAAK,CAAC,IAAI,CAAmC,EAAE,KAAK,CAAC,CAAA;IACzF,KAAK,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QACnC,mFAAmF;QACnF,IAAI,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC,CAAsB,EAAE,IAAI,EAAE,IAAI,CAAC,CAAA;IACjE,CAAC;IACD,OAAO,aAAa,CAClB,aAAa,CAAC,QAAQ,EACtB;QACE,KAAK,EAAE;YACL,MAAM,EAAE,KAAK,CAAC,MAAM,IAAI,YAAY;YACpC,IAAI,EAAE,KAAK,CAAC,IAAI,IAAI,EAAE;YACtB,OAAO,EAAE,KAAK,CAAC,OAAO,IAAI,KAAK;YAC/B,WAAW,EAAE,KAAK,CAAC,WAAW;SAC/B;KACF,EACD,IAAI,CACL,CAAA;AACH,CAAC"}
1
+ {"version":3,"file":"compose.js","sourceRoot":"","sources":["../src/compose.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAA0C,MAAM,OAAO,CAAA;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAE3C,oGAAoG;AACpG,MAAM,YAAY,GAAqC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AAExE;;;;;;;;;;GAUG;AACH,MAAM,UAAU,OAAO,CAAC,KAAyB,EAAE,KAAkB;IACnE,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAA;IAC7B,IAAI,IAAI,GAAc,aAAa,CAAC,KAAK,CAAC,IAAI,CAAmC,EAAE,KAAK,CAAC,CAAA;IACzF,KAAK,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QACnC,mFAAmF;QACnF,6FAA6F;QAC7F,mGAAmG;QACnG,kEAAkE;QAClE,IAAI,GAAG,aAAa,CAClB,KAAK,CAAC,CAAC,CAAyC,EAChD,EAAE,IAAI,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,EACvC,IAAI,CACL,CAAA;IACH,CAAC;IACD,OAAO,aAAa,CAClB,aAAa,CAAC,QAAQ,EACtB;QACE,KAAK,EAAE;YACL,MAAM,EAAE,KAAK,CAAC,MAAM,IAAI,YAAY;YACpC,IAAI,EAAE,KAAK,CAAC,IAAI,IAAI,EAAE;YACtB,OAAO,EAAE,KAAK,CAAC,OAAO,IAAI,KAAK;YAC/B,WAAW,EAAE,KAAK,CAAC,WAAW;SAC/B;KACF,EACD,IAAI,CACL,CAAA;AACH,CAAC"}
@@ -37,6 +37,27 @@ export interface ReactDomServer {
37
37
  export declare function reactDomServer(): Promise<ReactDomServer>;
38
38
  /** A `Bun.resolveSync`-shaped function (specifier, from) → absolute path. */
39
39
  type ResolveSync = (specifier: string, from: string) => string;
40
+ /**
41
+ * The dual-React crash, caught on the RESOLVED graph instead of after it detonates.
42
+ *
43
+ * The re-root above makes `react-dom/server` load from the app root, which is right almost always. What it
44
+ * cannot guarantee is that react-dom then pulls in the SAME physical `react` the route components import -
45
+ * a nested `react` under react-dom, or a components tree resolving react elsewhere, still yields two cores.
46
+ * Two cores is two hook dispatchers, and the renderer sees a null (or foreign) one: the SSR throws
47
+ * `resolveDispatcher().useState is null` from deep inside react-dom-server, naming a React internal and
48
+ * nothing about the two directories that actually caused it. That is hours of inference from a message
49
+ * that points nowhere useful.
50
+ *
51
+ * `nifra doctor` checks what is INSTALLED; this checks what SSR actually RESOLVED, which is the only thing
52
+ * that can catch a duplicate the two dev pipelines introduce (Bun for SSR, Vite for the client) rather
53
+ * than the install. It compares the realpath of the `react` react-dom will render with against the
54
+ * `react` the components import, and if they differ throws with BOTH paths - turning a five-hour hunt into
55
+ * a five-second read. Silent when they agree, which is the single-copy common case.
56
+ *
57
+ * Never manufactures a failure: if either side cannot be resolved it returns, because a resolver that
58
+ * cannot answer is not evidence of a duplicate. Exported for direct unit testing.
59
+ */
60
+ export declare function assertSingleReactCore(reactDomServerPath: string, resolve: ResolveSync, realpath?: (path: string) => string): void;
40
61
  /**
41
62
  * Load `react-dom/server`, preferring the app-root-resolved copy under the Bun runtime. Exported for unit
42
63
  * tests: `resolve` defaults to the ambient `Bun.resolveSync` (undefined on non-Bun hosts), and a test can
@@ -1 +1 @@
1
- {"version":3,"file":"react-dom-server.d.ts","sourceRoot":"","sources":["../src/react-dom-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;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;AAED;;;;;;;;kEAQkE;AAClE,wBAAgB,aAAa,IAAI,WAAW,GAAG,SAAS,CAKvD"}
1
+ {"version":3,"file":"react-dom-server.d.ts","sourceRoot":"","sources":["../src/react-dom-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;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;AA6CD;;;;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;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,qBAAqB,CACnC,kBAAkB,EAAE,MAAM,EAC1B,OAAO,EAAE,WAAW,EACpB,QAAQ,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAwB,GACnD,IAAI,CAsBN;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,GAAE,WAAW,GAAG,SAA2B,GACjD,OAAO,CAAC,cAAc,CAAC,CAyBzB;AAED;;;;;;;;kEAQkE;AAClE,wBAAgB,aAAa,IAAI,WAAW,GAAG,SAAS,CAKvD"}
@@ -30,6 +30,15 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
30
30
  }
31
31
  return path;
32
32
  };
33
+ function dirnamePortable(path) {
34
+ const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
35
+ return slash <= 0 ? "." : path.slice(0, slash);
36
+ }
37
+ function runtimeRealpath(path) {
38
+ const runtimeProcess = globalThis.process;
39
+ const fs = runtimeProcess?.getBuiltinModule?.("node:fs");
40
+ return fs?.realpathSync?.(path) ?? path;
41
+ }
33
42
  const bunResolver = globalThis.Bun;
34
43
  const hasBunResolveSync = typeof bunResolver?.resolveSync === "function";
35
44
  // Resolve once and cache: the module identity is stable for the process lifetime, and re-resolving per
@@ -57,6 +66,49 @@ export function reactDomServer() {
57
66
  cached = loadReactDomServer();
58
67
  return cached;
59
68
  }
69
+ /**
70
+ * The dual-React crash, caught on the RESOLVED graph instead of after it detonates.
71
+ *
72
+ * The re-root above makes `react-dom/server` load from the app root, which is right almost always. What it
73
+ * cannot guarantee is that react-dom then pulls in the SAME physical `react` the route components import -
74
+ * a nested `react` under react-dom, or a components tree resolving react elsewhere, still yields two cores.
75
+ * Two cores is two hook dispatchers, and the renderer sees a null (or foreign) one: the SSR throws
76
+ * `resolveDispatcher().useState is null` from deep inside react-dom-server, naming a React internal and
77
+ * nothing about the two directories that actually caused it. That is hours of inference from a message
78
+ * that points nowhere useful.
79
+ *
80
+ * `nifra doctor` checks what is INSTALLED; this checks what SSR actually RESOLVED, which is the only thing
81
+ * that can catch a duplicate the two dev pipelines introduce (Bun for SSR, Vite for the client) rather
82
+ * than the install. It compares the realpath of the `react` react-dom will render with against the
83
+ * `react` the components import, and if they differ throws with BOTH paths - turning a five-hour hunt into
84
+ * a five-second read. Silent when they agree, which is the single-copy common case.
85
+ *
86
+ * Never manufactures a failure: if either side cannot be resolved it returns, because a resolver that
87
+ * cannot answer is not evidence of a duplicate. Exported for direct unit testing.
88
+ */
89
+ export function assertSingleReactCore(reactDomServerPath, resolve, realpath = runtimeRealpath) {
90
+ let rendererReact;
91
+ let componentsReact;
92
+ try {
93
+ // The `react` react-dom/server itself resolves - the core whose dispatcher the renderer sets.
94
+ rendererReact = realpath(resolve("react", dirnamePortable(reactDomServerPath)));
95
+ // The `react` the app's route components import - the core they call hooks on.
96
+ componentsReact = realpath(resolve("react", appRoot()));
97
+ }
98
+ catch {
99
+ return;
100
+ }
101
+ if (rendererReact === componentsReact)
102
+ return;
103
+ throw new Error("[nifra/web-react] two copies of React reached SSR, so hooks render against a null dispatcher " +
104
+ "(the `resolveDispatcher().useState is null` crash). react-dom renders with a DIFFERENT React " +
105
+ "than your components import:\n" +
106
+ ` react-dom's react: ${rendererReact}\n` +
107
+ ` components' react: ${componentsReact}\n` +
108
+ "Module identity is path-based, so two copies fail even at the same version. Dedupe react to one " +
109
+ "physical copy - usually a single hoisted install at the workspace root (`nifra doctor` locates " +
110
+ "the duplicate). A Vite `resolve.dedupe`/alias fixes only the client bundle, not this SSR path.");
111
+ }
60
112
  /**
61
113
  * Load `react-dom/server`, preferring the app-root-resolved copy under the Bun runtime. Exported for unit
62
114
  * tests: `resolve` defaults to the ambient `Bun.resolveSync` (undefined on non-Bun hosts), and a test can
@@ -71,9 +123,18 @@ export async function loadReactDomServer(resolve = bunResolverFn()) {
71
123
  // rather than crashing — degraded (possible duplicate) but never a hard failure.
72
124
  try {
73
125
  const resolved = resolve("react-dom/server", appRoot());
126
+ // Before returning the module, verify the react-dom we just re-rooted shares the components' React.
127
+ // The re-root fixes the common case; this catches the residual duplicate loudly, with both paths,
128
+ // instead of leaving it to throw a dispatcher-null error on the first hook render. Runs once (this
129
+ // whole function is cached), dev/unbundled Bun only (the branch `resolve` gates).
130
+ assertSingleReactCore(resolved, resolve);
74
131
  return (await import(__rewriteRelativeImportExtension(resolved)));
75
132
  }
76
- catch {
133
+ catch (err) {
134
+ // A genuine duplicate is surfaced (re-thrown with both paths); any other failure (react-dom not at
135
+ // the app root, an odd nested layout) falls through to the bundled specifier rather than crashing.
136
+ if (err instanceof Error && err.message.includes("two copies of React"))
137
+ throw err;
77
138
  // App-root resolution failed; use the specifier resolved from this module's own location below.
78
139
  }
79
140
  }
@@ -1 +1 @@
1
- {"version":3,"file":"react-dom-server.js","sourceRoot":"","sources":["../src/react-dom-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;;;;;;;;;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;;;;;;;;kEAQkE;AAClE,MAAM,UAAU,aAAa;IAC3B,IAAI,OAAO,CAAC,GAAG,CAAC,iBAAiB,KAAK,GAAG;QAAE,OAAO,SAAS,CAAA;IAC3D,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"}
1
+ {"version":3,"file":"react-dom-server.js","sourceRoot":"","sources":["../src/react-dom-server.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/web-react",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "React render adapter for @nifrajs/web — SSR + hydration (Bun-native JSX, no Babel).",
5
5
  "keywords": [
6
6
  "bun",
@@ -80,10 +80,10 @@
80
80
  "access": "public"
81
81
  },
82
82
  "dependencies": {
83
- "@nifrajs/core": "2.1.0",
84
- "@nifrajs/i18n": "2.1.0",
85
- "@nifrajs/image": "2.1.0",
86
- "@nifrajs/web": "2.1.0"
83
+ "@nifrajs/core": "2.2.0",
84
+ "@nifrajs/i18n": "2.2.0",
85
+ "@nifrajs/image": "2.2.0",
86
+ "@nifrajs/web": "2.2.0"
87
87
  },
88
88
  "peerDependencies": {
89
89
  "react": "^19.0.0",
package/src/compose.ts CHANGED
@@ -21,7 +21,14 @@ export function compose(chain: readonly unknown[], props: RenderProps): ReactNod
21
21
  let node: ReactNode = createElement(chain[last] as FunctionComponent<RenderProps>, props)
22
22
  for (let i = last - 1; i >= 0; i--) {
23
23
  // children passed as the 3rd arg (not a `children` prop) — React's canonical form.
24
- node = createElement(chain[i] as FunctionComponent, null, node)
24
+ // Each layout receives its own loader data at its own index. Layouts are the chain's leading
25
+ // prefix, so `layoutData[i]` belongs to `chain[i]`; anything past that end (a client-only `_error`
26
+ // boundary marker, the page) reads `undefined` and is unaffected.
27
+ node = createElement(
28
+ chain[i] as FunctionComponent<{ data: unknown }>,
29
+ { data: props.layoutData?.[i] ?? null },
30
+ node,
31
+ )
25
32
  }
26
33
  return createElement(
27
34
  RouterContext.Provider,
@@ -32,6 +32,23 @@ export interface ReactDomServer {
32
32
  renderToReadableStream(node: ReactNode): Promise<ReadableStream<Uint8Array>>
33
33
  }
34
34
 
35
+ interface RuntimeProcess {
36
+ getBuiltinModule?(specifier: string): unknown
37
+ }
38
+
39
+ function dirnamePortable(path: string): string {
40
+ const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
41
+ return slash <= 0 ? "." : path.slice(0, slash)
42
+ }
43
+
44
+ function runtimeRealpath(path: string): string {
45
+ const runtimeProcess = (globalThis as { process?: RuntimeProcess }).process
46
+ const fs = runtimeProcess?.getBuiltinModule?.("node:fs") as
47
+ | { realpathSync?: (value: string) => string }
48
+ | undefined
49
+ return fs?.realpathSync?.(path) ?? path
50
+ }
51
+
35
52
  // `globalThis.Bun` isn't in the ambient lib types; narrow exactly the one method we need so we never
36
53
  // reach for `any`. `resolveSync(specifier, from)` returns the absolute path the specifier resolves to
37
54
  // when required from `from` — Bun's runtime resolver, the only lever that re-roots a BARE specifier
@@ -72,6 +89,54 @@ export function reactDomServer(): Promise<ReactDomServer> {
72
89
  /** A `Bun.resolveSync`-shaped function (specifier, from) → absolute path. */
73
90
  type ResolveSync = (specifier: string, from: string) => string
74
91
 
92
+ /**
93
+ * The dual-React crash, caught on the RESOLVED graph instead of after it detonates.
94
+ *
95
+ * The re-root above makes `react-dom/server` load from the app root, which is right almost always. What it
96
+ * cannot guarantee is that react-dom then pulls in the SAME physical `react` the route components import -
97
+ * a nested `react` under react-dom, or a components tree resolving react elsewhere, still yields two cores.
98
+ * Two cores is two hook dispatchers, and the renderer sees a null (or foreign) one: the SSR throws
99
+ * `resolveDispatcher().useState is null` from deep inside react-dom-server, naming a React internal and
100
+ * nothing about the two directories that actually caused it. That is hours of inference from a message
101
+ * that points nowhere useful.
102
+ *
103
+ * `nifra doctor` checks what is INSTALLED; this checks what SSR actually RESOLVED, which is the only thing
104
+ * that can catch a duplicate the two dev pipelines introduce (Bun for SSR, Vite for the client) rather
105
+ * than the install. It compares the realpath of the `react` react-dom will render with against the
106
+ * `react` the components import, and if they differ throws with BOTH paths - turning a five-hour hunt into
107
+ * a five-second read. Silent when they agree, which is the single-copy common case.
108
+ *
109
+ * Never manufactures a failure: if either side cannot be resolved it returns, because a resolver that
110
+ * cannot answer is not evidence of a duplicate. Exported for direct unit testing.
111
+ */
112
+ export function assertSingleReactCore(
113
+ reactDomServerPath: string,
114
+ resolve: ResolveSync,
115
+ realpath: (path: string) => string = runtimeRealpath,
116
+ ): void {
117
+ let rendererReact: string
118
+ let componentsReact: string
119
+ try {
120
+ // The `react` react-dom/server itself resolves - the core whose dispatcher the renderer sets.
121
+ rendererReact = realpath(resolve("react", dirnamePortable(reactDomServerPath)))
122
+ // The `react` the app's route components import - the core they call hooks on.
123
+ componentsReact = realpath(resolve("react", appRoot()))
124
+ } catch {
125
+ return
126
+ }
127
+ if (rendererReact === componentsReact) return
128
+ throw new Error(
129
+ "[nifra/web-react] two copies of React reached SSR, so hooks render against a null dispatcher " +
130
+ "(the `resolveDispatcher().useState is null` crash). react-dom renders with a DIFFERENT React " +
131
+ "than your components import:\n" +
132
+ ` react-dom's react: ${rendererReact}\n` +
133
+ ` components' react: ${componentsReact}\n` +
134
+ "Module identity is path-based, so two copies fail even at the same version. Dedupe react to one " +
135
+ "physical copy - usually a single hoisted install at the workspace root (`nifra doctor` locates " +
136
+ "the duplicate). A Vite `resolve.dedupe`/alias fixes only the client bundle, not this SSR path.",
137
+ )
138
+ }
139
+
75
140
  /**
76
141
  * Load `react-dom/server`, preferring the app-root-resolved copy under the Bun runtime. Exported for unit
77
142
  * tests: `resolve` defaults to the ambient `Bun.resolveSync` (undefined on non-Bun hosts), and a test can
@@ -88,8 +153,16 @@ export async function loadReactDomServer(
88
153
  // rather than crashing — degraded (possible duplicate) but never a hard failure.
89
154
  try {
90
155
  const resolved = resolve("react-dom/server", appRoot())
156
+ // Before returning the module, verify the react-dom we just re-rooted shares the components' React.
157
+ // The re-root fixes the common case; this catches the residual duplicate loudly, with both paths,
158
+ // instead of leaving it to throw a dispatcher-null error on the first hook render. Runs once (this
159
+ // whole function is cached), dev/unbundled Bun only (the branch `resolve` gates).
160
+ assertSingleReactCore(resolved, resolve)
91
161
  return (await import(resolved)) as ReactDomServer
92
- } catch {
162
+ } catch (err) {
163
+ // A genuine duplicate is surfaced (re-thrown with both paths); any other failure (react-dom not at
164
+ // the app root, an odd nested layout) falls through to the bundled specifier rather than crashing.
165
+ if (err instanceof Error && err.message.includes("two copies of React")) throw err
93
166
  // App-root resolution failed; use the specifier resolved from this module's own location below.
94
167
  }
95
168
  }