react-on-rails 17.0.0-rc.5 → 17.0.0-rc.6

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.
@@ -8,6 +8,7 @@ import { onPageUnloaded } from "./pageLifecycle.js";
8
8
  import { supportsRootApi, unmountComponentAtNode } from "./reactApis.cjs";
9
9
  import { isRendererTeardownResult } from "./rendererTeardown.js";
10
10
  import { buildRootErrorCallbackOptions } from "./rootErrorHandlers.js";
11
+ import { convertToError } from "./errorUtils.js";
11
12
  import { isThenable } from "./isThenable.js";
12
13
  const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store';
13
14
  // Track all rendered roots for cleanup
@@ -39,6 +40,10 @@ function invokeRendererTeardown(teardown, domNodeId) {
39
40
  * abort cleanup of the remaining entries.
40
41
  */
41
42
  function teardownEntry(entry, domNodeId) {
43
+ if (entry.kind === 'scheduled') {
44
+ entry.cancel();
45
+ return;
46
+ }
42
47
  if (entry.kind === 'renderer') {
43
48
  invokeRendererTeardown(entry.teardown, domNodeId);
44
49
  return;
@@ -52,6 +57,15 @@ function teardownEntry(entry, domNodeId) {
52
57
  unmountComponentAtNode(entry.domNode);
53
58
  }
54
59
  }
60
+ function teardownErrorLabel(entry, domNodeId) {
61
+ if (entry.kind === 'renderer') {
62
+ return `Error in renderer teardown for dom node "${domNodeId}":`;
63
+ }
64
+ if (entry.kind === 'scheduled') {
65
+ return `Error canceling scheduled render for dom node "${domNodeId}":`;
66
+ }
67
+ return `Error unmounting component for dom node "${domNodeId}":`;
68
+ }
55
69
  function initializeStore(el, railsContext) {
56
70
  const name = el.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || '';
57
71
  const props = el.textContent !== null ? JSON.parse(el.textContent) : {};
@@ -68,6 +82,102 @@ function forEachStore(railsContext) {
68
82
  function domNodeIdForEl(el) {
69
83
  return el.getAttribute('data-dom-id') || '';
70
84
  }
85
+ function hydrateOnForEl(el) {
86
+ const hydrateOn = el.getAttribute('data-hydrate-on');
87
+ if (!hydrateOn || hydrateOn === 'immediate' || hydrateOn === 'visible' || hydrateOn === 'idle') {
88
+ return (hydrateOn || 'immediate');
89
+ }
90
+ console.warn(`[react-on-rails] Unsupported hydrate_on: ${hydrateOn}.`);
91
+ return 'immediate';
92
+ }
93
+ function hasObservableArea(element) {
94
+ const rects = element.getClientRects();
95
+ for (let index = 0; index < rects.length; index += 1) {
96
+ const rect = rects[index];
97
+ if (rect.width > 0 && rect.height > 0)
98
+ return true;
99
+ }
100
+ const boundingRect = element.getBoundingClientRect();
101
+ return boundingRect.width > 0 && boundingRect.height > 0;
102
+ }
103
+ function scheduleTimeout(callback, delay) {
104
+ const timeoutId = window.setTimeout(callback, delay);
105
+ return () => window.clearTimeout(timeoutId);
106
+ }
107
+ function scheduleWhenVisible(domNode, callback) {
108
+ if (typeof IntersectionObserver === 'undefined') {
109
+ console.warn('[react-on-rails] No IntersectionObserver.');
110
+ return scheduleTimeout(callback, 0);
111
+ }
112
+ if (!domNode.hasChildNodes() && !hasObservableArea(domNode)) {
113
+ return scheduleTimeout(callback, 0);
114
+ }
115
+ const observer = new IntersectionObserver((entries) => {
116
+ // Disconnect early if the target was removed outside Turbo navigation to avoid a
117
+ // leaked observer holding a live reference to a detached node.
118
+ const target = entries[0]?.target;
119
+ if (target && !target.isConnected) {
120
+ observer.disconnect();
121
+ return;
122
+ }
123
+ const isVisible = entries.some((entry) => entry.isIntersecting || entry.intersectionRatio > 0);
124
+ if (!isVisible)
125
+ return;
126
+ observer.disconnect();
127
+ callback();
128
+ }, { rootMargin: '200px 0px' });
129
+ observer.observe(domNode);
130
+ return () => {
131
+ observer.disconnect();
132
+ };
133
+ }
134
+ function scheduleWhenIdle(callback) {
135
+ const idleWindow = window;
136
+ if (typeof idleWindow.requestIdleCallback === 'function') {
137
+ const idleCallbackId = idleWindow.requestIdleCallback(callback, { timeout: 2000 });
138
+ return () => {
139
+ if (typeof idleWindow.cancelIdleCallback === 'function') {
140
+ idleWindow.cancelIdleCallback(idleCallbackId);
141
+ }
142
+ };
143
+ }
144
+ // Safari lacks requestIdleCallback; 50 ms defers past the current frame without a layout-blocking
145
+ // setTimeout(0) but still far enough before most long-idle periods (matching common polyfill defaults).
146
+ return scheduleTimeout(callback, 50);
147
+ }
148
+ function scheduleHydration(hydrateOn, domNode, callback) {
149
+ if (hydrateOn === 'visible') {
150
+ return scheduleWhenVisible(domNode, callback);
151
+ }
152
+ if (hydrateOn === 'idle') {
153
+ return scheduleWhenIdle(callback);
154
+ }
155
+ // Defensive: `renderElement` short-circuits for `immediate` before calling `scheduleHydration`, so
156
+ // this branch is normally unreachable; it acts as a safe fallback guard for future callers.
157
+ callback();
158
+ return () => { };
159
+ }
160
+ // Logs a normalized copy of the original thrown value, then returns a fresh ReactOnRails-prefixed
161
+ // error for callers to throw/report. This must not mutate the original value: user code can throw
162
+ // strings, null, cross-realm errors, or frozen Error instances, and delayed hydration catches run
163
+ // inside scheduler callbacks where an error reporter that throws would escape the callback.
164
+ function prepareRenderError(componentName, error) {
165
+ const originalError = convertToError(error);
166
+ console.error(originalError);
167
+ const renderError = new Error(`ReactOnRails encountered an error while rendering component: ${componentName}. See above error message.`);
168
+ renderError.cause = originalError;
169
+ if (typeof originalError.stack === 'string') {
170
+ renderError.stack = originalError.stack;
171
+ }
172
+ return renderError;
173
+ }
174
+ function raiseRenderError(componentName, error) {
175
+ throw prepareRenderError(componentName, error);
176
+ }
177
+ function reportRenderError(componentName, error) {
178
+ // prepareRenderError already logged the original error; do not log again (avoids the double-log).
179
+ prepareRenderError(componentName, error);
180
+ }
71
181
  function delegateToRenderer(componentObj, props, railsContext, domNodeId, trace) {
72
182
  const { name, component, isRenderer } = componentObj;
73
183
  if (isRenderer) {
@@ -173,10 +283,14 @@ function renderElement(el, railsContext) {
173
283
  // (e.g., for asynchronously loaded content)
174
284
  const existing = renderedRoots.get(domNodeId);
175
285
  if (existing) {
176
- // Only skip if it's the exact same DOM node and it's still connected to the document.
177
- // If the node was replaced (e.g., via innerHTML or Turbo), we need to unmount the old
178
- // root and re-render to the new node to prevent memory leaks and ensure rendering works.
179
- const sameNode = existing.domNode === domNode && existing.domNode.isConnected;
286
+ // Only skip if it's the exact same DOM node, it's still connected to the document, AND it has
287
+ // actually mounted. A `scheduled` entry has not mounted yet: if its node was detached and later
288
+ // reattached (e.g. Turbo cache restore or a DOM move), its IntersectionObserver was disconnected
289
+ // and will never re-observe, so we must fall through to cancel the stale schedule and re-schedule
290
+ // fresh rather than skip (which would leave the island permanently non-interactive).
291
+ // If the node was replaced (e.g., via innerHTML or Turbo), we likewise need to unmount/cancel the
292
+ // old entry and re-render to the new node to prevent memory leaks and ensure rendering works.
293
+ const sameNode = existing.kind !== 'scheduled' && existing.domNode === domNode && existing.domNode.isConnected;
180
294
  if (sameNode) {
181
295
  if (trace) {
182
296
  console.log(`Skipping already rendered component: ${name} (dom id: ${domNodeId})`);
@@ -192,10 +306,7 @@ function renderElement(el, railsContext) {
192
306
  // Surface the failure unconditionally (matching unmountAllComponents) so a teardown/unmount
193
307
  // error on node replacement is as visible as one on page unload, using the same greppable
194
308
  // labels. We still continue: the old mount may leak, but the new node must be rendered.
195
- const label = existing.kind === 'renderer'
196
- ? `Error in renderer teardown for dom node "${domNodeId}":`
197
- : `Error unmounting component for dom node "${domNodeId}":`;
198
- console.error(label, unmountError);
309
+ console.error(teardownErrorLabel(existing, domNodeId), unmountError);
199
310
  }
200
311
  renderedRoots.delete(domNodeId);
201
312
  }
@@ -207,38 +318,62 @@ function renderElement(el, railsContext) {
207
318
  trackRendererMount(domNodeId, domNode, delegation.result);
208
319
  return;
209
320
  }
210
- // Hydrate if the DOM node has content (server-rendered HTML)
211
- // Since we skip already-rendered components above, this check now correctly
212
- // identifies only server-rendered content, not previously client-rendered content
213
- const shouldHydrate = !!domNode.innerHTML;
214
- const reactElementOrRouterResult = createReactOutput({
215
- componentObj,
216
- props,
217
- domNodeId,
218
- trace,
219
- railsContext,
220
- shouldHydrate,
221
- });
222
- if (isServerRenderHash(reactElementOrRouterResult)) {
223
- throw new Error(`\
321
+ const mountReactRoot = () => {
322
+ // Hydrate if the DOM node has content (server-rendered HTML)
323
+ // Since we skip already-rendered components above, this check now correctly
324
+ // identifies only server-rendered content, not previously client-rendered content
325
+ const shouldHydrate = !!domNode.innerHTML;
326
+ const reactElementOrRouterResult = createReactOutput({
327
+ componentObj,
328
+ props,
329
+ domNodeId,
330
+ trace,
331
+ railsContext,
332
+ shouldHydrate,
333
+ });
334
+ if (isServerRenderHash(reactElementOrRouterResult)) {
335
+ throw new Error(`\
224
336
  You returned a server side type of react-router error: ${JSON.stringify(reactElementOrRouterResult)}
225
337
  You should return a React.Component always for the client side entry point.`);
226
- }
227
- else {
338
+ }
228
339
  const root = reactHydrateOrRender(domNode, reactElementOrRouterResult, shouldHydrate,
229
340
  // Attach user-registered root error callbacks (and the dev-mode hydration-mismatch
230
341
  // logger) to every root, enriched with this mount's component name and dom id.
231
342
  buildRootErrorCallbackOptions({ componentName: name || undefined, domNodeId: domNodeId || undefined }, shouldHydrate));
232
343
  // Track the root for cleanup
233
344
  renderedRoots.set(domNodeId, { kind: 'react', root, domNode });
345
+ };
346
+ const hydrateOn = hydrateOnForEl(el);
347
+ if (hydrateOn === 'immediate') {
348
+ mountReactRoot();
349
+ return;
234
350
  }
351
+ let scheduledEntry;
352
+ const runScheduledRender = () => {
353
+ if (renderedRoots.get(domNodeId) !== scheduledEntry)
354
+ return;
355
+ if (!domNode.isConnected) {
356
+ renderedRoots.delete(domNodeId);
357
+ return;
358
+ }
359
+ try {
360
+ mountReactRoot();
361
+ }
362
+ catch (scheduledError) {
363
+ renderedRoots.delete(domNodeId);
364
+ reportRenderError(name, scheduledError);
365
+ }
366
+ };
367
+ scheduledEntry = {
368
+ kind: 'scheduled',
369
+ domNode,
370
+ cancel: scheduleHydration(hydrateOn, domNode, runScheduledRender),
371
+ };
372
+ renderedRoots.set(domNodeId, scheduledEntry);
235
373
  }
236
374
  }
237
375
  catch (e) {
238
- const error = e;
239
- console.error(error.message);
240
- error.message = `ReactOnRails encountered an error while rendering component: ${name}. See above error message.`;
241
- throw error;
376
+ raiseRenderError(name, e);
242
377
  }
243
378
  }
244
379
  /**
@@ -295,12 +430,7 @@ function unmountAllComponents() {
295
430
  teardownEntry(entry, domNodeId);
296
431
  }
297
432
  catch (error) {
298
- // Use the same label as the async-rejection path so renderer-teardown failures are greppable
299
- // whether the teardown threw synchronously (here) or rejected (invokeRendererTeardown).
300
- const label = entry.kind === 'renderer'
301
- ? `Error in renderer teardown for dom node "${domNodeId}":`
302
- : `Error unmounting component for dom node "${domNodeId}":`;
303
- console.error(label, error);
433
+ console.error(teardownErrorLabel(entry, domNodeId), error);
304
434
  }
305
435
  });
306
436
  renderedRoots.clear();
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Whether React's dev-only `captureOwnerStack` API exists in the current build — i.e. React >= 19.1
3
+ * running its **development** build. Used to gate dev-mode owner-stack logging so that on older
4
+ * React (or any production build), where the API is absent, React's own default error reporting is
5
+ * left untouched (issue #3887).
6
+ */
7
+ export declare function isOwnerStackSupported(): boolean;
8
+ export default function captureReactOwnerStack(): string | undefined;
9
+ //# sourceMappingURL=captureReactOwnerStack.d.ts.map
@@ -0,0 +1,64 @@
1
+ import * as React from 'react';
2
+ /**
3
+ * React's `captureOwnerStack` (added in React 19.1) returns the "owner stack" for the component
4
+ * currently rendering or erroring — the chain of components that created the failing element, e.g.
5
+ *
6
+ * at Avatar
7
+ * at PostCard
8
+ * at PostList
9
+ *
10
+ * This is dramatically more useful for debugging than a minified JS stack because it names the
11
+ * components a developer actually wrote.
12
+ *
13
+ * IMPORTANT dev-build-on-server / production constraints (verified against React 19.0.x and 19.2.x):
14
+ *
15
+ * 1. `captureOwnerStack` is exported **only from React's development build**. In a production build
16
+ * the export does not exist (`typeof React.captureOwnerStack !== 'function'`), so the guard below
17
+ * makes this a strict no-op in production — there is no capture, no call, and no behavioral change.
18
+ * This is asserted by tests.
19
+ * 2. It is exported only from React **>= 19.1**. On React 19.0 and earlier the export is `undefined`
20
+ * even in dev builds, so the same guard covers the version requirement without needing to parse
21
+ * `React.version`.
22
+ * 3. It returns a meaningful string **only when called synchronously while React is rendering or
23
+ * handling an error** (e.g. inside an `onError`/`onCaughtError`/`onUncaughtError`/`onShellError`
24
+ * callback). Called outside that window it returns `null`. Post-hoc formatting (for example the
25
+ * Ruby layer, or a `try/catch` around `renderToString` after it has already thrown) cannot
26
+ * capture it — which is why capture must happen JS-side inside the error callback.
27
+ *
28
+ * On the server this only yields output when React's **development** build runs in the SSR bundle.
29
+ * Production SSR bundles run React's production build and therefore get the no-op behavior above;
30
+ * that is the documented, intended outcome for production.
31
+ *
32
+ * @returns React's owner stack string verbatim (it typically begins with a newline and indented
33
+ * `at <Component>` frames) when a non-empty one is available, otherwise `undefined`. The
34
+ * whitespace is preserved intentionally so callers can embed it directly under a label. Never
35
+ * throws.
36
+ */
37
+ // `captureOwnerStack` is only present on React's dev build for React >= 19.1. Accessing it through a
38
+ // typed-as-optional view keeps this compiling against the broad `react >= 16` peer range.
39
+ const reactWithOwnerStack = React;
40
+ /**
41
+ * Whether React's dev-only `captureOwnerStack` API exists in the current build — i.e. React >= 19.1
42
+ * running its **development** build. Used to gate dev-mode owner-stack logging so that on older
43
+ * React (or any production build), where the API is absent, React's own default error reporting is
44
+ * left untouched (issue #3887).
45
+ */
46
+ export function isOwnerStackSupported() {
47
+ return typeof reactWithOwnerStack.captureOwnerStack === 'function';
48
+ }
49
+ export default function captureReactOwnerStack() {
50
+ if (typeof reactWithOwnerStack.captureOwnerStack !== 'function') {
51
+ return undefined;
52
+ }
53
+ try {
54
+ const ownerStack = reactWithOwnerStack.captureOwnerStack();
55
+ if (typeof ownerStack === 'string' && ownerStack.trim().length > 0) {
56
+ return ownerStack;
57
+ }
58
+ }
59
+ catch {
60
+ // captureOwnerStack must never break error reporting; swallow any unexpected failure.
61
+ }
62
+ return undefined;
63
+ }
64
+ //# sourceMappingURL=captureReactOwnerStack.js.map
@@ -70,9 +70,12 @@ export default function createReactOutput({ componentObj, props, railsContext, d
70
70
  if (typeof component !== 'function') {
71
71
  throw new Error(`Registered render function "${name}" must be a function.`);
72
72
  }
73
+ // The cast only narrows the broad RegisteredComponentValue union (which isn't structurally
74
+ // callable) to a concrete render-function role; it does not widen what may be returned.
73
75
  const renderFunctionResult = component(props, railsContext);
74
- // Defense-in-depth: a 2-argument render function isn't expected to return a teardown wrapper, but
75
- // the public RenderFunction return type can't structurally exclude it, so reject that at runtime too.
76
+ // Defense-in-depth: RenderFunction (= ServerRenderFunction) returns RenderFunctionResult, which
77
+ // already excludes RendererTeardownResult at the type level, so a teardown can't arrive here from
78
+ // a typed caller. Reject it at runtime anyway for untyped JS callers that ignore the contract.
76
79
  if (isRendererTeardownResult(renderFunctionResult)) {
77
80
  throw new Error(unsupportedManualRendererMessage(name));
78
81
  }
@@ -0,0 +1,4 @@
1
+ import type { RenderingError } from './types/index.ts';
2
+ export declare function remapSourceMappedStack(stack: RenderingError['stack']): string | undefined;
3
+ export declare function convertToError(e: unknown): Error;
4
+ //# sourceMappingURL=errorUtils.d.ts.map
@@ -0,0 +1,50 @@
1
+ // Must match SOURCE_MAP_STACK_REMAPPER_CONTEXT_KEY in
2
+ // packages/react-on-rails-pro-node-renderer/src/worker/vmSourceMapSupport.ts.
3
+ const SOURCE_MAPPED_STACK_REMAPPER_KEY = '__reactOnRailsProRemapStackTrace';
4
+ export function remapSourceMappedStack(stack) {
5
+ const remapper = globalThis[SOURCE_MAPPED_STACK_REMAPPER_KEY];
6
+ if (typeof remapper !== 'function') {
7
+ return stack;
8
+ }
9
+ try {
10
+ const remappedStack = remapper(stack);
11
+ return typeof remappedStack === 'string' ? remappedStack : stack;
12
+ }
13
+ catch {
14
+ return stack;
15
+ }
16
+ }
17
+ function isCrossRealmError(e) {
18
+ return typeof e === 'object' && e !== null && Object.prototype.toString.call(e) === '[object Error]';
19
+ }
20
+ function stringifyThrownValue(e) {
21
+ if (isCrossRealmError(e)) {
22
+ return typeof e.message === 'string' ? e.message : Object.prototype.toString.call(e);
23
+ }
24
+ if (typeof e === 'object' && e !== null) {
25
+ try {
26
+ // JSON.stringify can return undefined without throwing, for example when toJSON returns undefined.
27
+ return JSON.stringify(e) ?? Object.prototype.toString.call(e);
28
+ }
29
+ catch {
30
+ return Object.prototype.toString.call(e);
31
+ }
32
+ }
33
+ return String(e);
34
+ }
35
+ export function convertToError(e) {
36
+ if (e instanceof Error) {
37
+ return e;
38
+ }
39
+ const message = stringifyThrownValue(e);
40
+ // tsconfig uses es2020 libs, which do not type Error.cause even though supported runtimes provide it.
41
+ const error = new Error(message);
42
+ error.cause = e;
43
+ if (isCrossRealmError(e) && typeof e.stack === 'string') {
44
+ // Prefer the cross-realm bundle stack over the host wrapping call site; the
45
+ // original thrown value remains available through `cause`.
46
+ error.stack = remapSourceMappedStack(e.stack);
47
+ }
48
+ return error;
49
+ }
50
+ //# sourceMappingURL=errorUtils.js.map
@@ -1,6 +1,7 @@
1
1
  import { supportsRootApi, supportsReact19RootErrorCallbacks } from "./reactApis.cjs";
2
2
  import { getRailsContext } from "./context.js";
3
3
  import { isThenable } from "./isThenable.js";
4
+ import captureReactOwnerStack, { isOwnerStackSupported } from "./captureReactOwnerStack.js";
4
5
  /**
5
6
  * Guide linked from the development-mode hydration-mismatch message.
6
7
  * TODO(#3894): swap to the stable error-reference URL once error codes and reference pages land.
@@ -143,18 +144,61 @@ function extractComponentStack(errorInfo) {
143
144
  const componentStack = errorInfo?.componentStack;
144
145
  return typeof componentStack === 'string' && componentStack.length > 0 ? componentStack : undefined;
145
146
  }
147
+ /**
148
+ * React 19.2+ includes the owner stack on the `errorInfo` passed to `onCaughtError`/`onUncaughtError`
149
+ * (and to `onRecoverableError` for hydration mismatches) via `errorInfo.ownerStack`. Prefer it when
150
+ * present; callers fall back to a live `captureReactOwnerStack()` call for React 19.1, which exposes
151
+ * the API but not the `errorInfo` field.
152
+ */
153
+ function extractOwnerStack(errorInfo) {
154
+ const ownerStack = errorInfo?.ownerStack;
155
+ return typeof ownerStack === 'string' && ownerStack.trim().length > 0 ? ownerStack : undefined;
156
+ }
157
+ /**
158
+ * Builds the supplemental "Owner stack" suffix for dev-mode error logs (issue #3887).
159
+ *
160
+ * MUST be called synchronously from inside React's error callback. `precomputedOwnerStack` is the
161
+ * owner stack React already captured for this error (e.g. `errorInfo.ownerStack` on React 19.2+),
162
+ * when available; otherwise we fall back to a live `captureReactOwnerStack()` call, which only
163
+ * returns a value while React is still handling the error. Returns an empty string when no owner
164
+ * stack is available — in particular on React < 19.1 and in production builds, where
165
+ * `captureReactOwnerStack` is a strict no-op.
166
+ */
167
+ function ownerStackSuffix(precomputedOwnerStack) {
168
+ const ownerStack = (typeof precomputedOwnerStack === 'string' && precomputedOwnerStack.trim().length > 0
169
+ ? precomputedOwnerStack
170
+ : undefined) ?? captureReactOwnerStack();
171
+ return ownerStack ? `\nOwner stack (the components that rendered this one):${ownerStack}` : '';
172
+ }
146
173
  /**
147
174
  * Branded, supplemental development-mode line: component name, dom id, component stack (when
148
- * React provides one), and the debugging-guide link. Deliberately does NOT dump the error object
149
- * itself the error is default-reported exactly once elsewhere (by `defaultReportRecoverableError`
150
- * on core paths, or by Pro's internal recoverable-error handler on chained paths).
175
+ * React provides one), the owner stack (React >= 19.1 dev builds, issue #3887), and the
176
+ * debugging-guide link. Deliberately does NOT dump the error object itself the error is
177
+ * default-reported exactly once elsewhere (by `defaultReportRecoverableError` on core paths, or by
178
+ * Pro's internal recoverable-error handler on chained paths).
151
179
  */
152
180
  function logDevHydrationError(context, errorInfo) {
153
181
  const componentName = context.componentName ?? 'unknown';
154
182
  const domNodeId = context.domNodeId ?? 'unknown';
155
183
  const componentStack = extractComponentStack(errorInfo);
156
184
  const componentStackSuffix = componentStack ? `\nComponent stack:${componentStack}` : '';
157
- console.error(`[ReactOnRails] Recoverable hydration error in component "${componentName}" (dom id: "${domNodeId}"). The server-rendered HTML did not match what React rendered on the client, so React threw away the server HTML and re-rendered on the client. Common Rails-specific causes and fixes: ${HYDRATION_MISMATCH_GUIDE_URL}${componentStackSuffix}`);
185
+ console.error(`[ReactOnRails] Recoverable hydration error in component "${componentName}" (dom id: "${domNodeId}"). The server-rendered HTML did not match what React rendered on the client, so React threw away the server HTML and re-rendered on the client. Common Rails-specific causes and fixes: ${HYDRATION_MISMATCH_GUIDE_URL}${componentStackSuffix}${ownerStackSuffix(extractOwnerStack(errorInfo))}`);
186
+ }
187
+ /**
188
+ * Development-only supplemental line for render-path errors React reports through an app-registered
189
+ * `onCaughtError`/`onUncaughtError` handler (issue #3887). Names the failing component/dom id and
190
+ * appends the owner stack when React provides one. The error itself is reported by the app's own
191
+ * handler (which we forward to), so this line is purely additive context.
192
+ */
193
+ function logDevRenderError(kind, context, errorInfo) {
194
+ const suffix = ownerStackSuffix(extractOwnerStack(errorInfo));
195
+ if (!suffix) {
196
+ return;
197
+ }
198
+ const componentName = context.componentName ?? 'unknown';
199
+ const domNodeId = context.domNodeId ?? 'unknown';
200
+ const caughtNote = kind === 'onCaughtError' ? ' (caught by an error boundary)' : '';
201
+ console.error(`[ReactOnRails] Render error in component "${componentName}" (dom id: "${domNodeId}")${caughtNote}.${suffix}`);
158
202
  }
159
203
  /**
160
204
  * Builds the `hydrateRoot`/`createRoot` error callback options for one React root, wrapping the
@@ -197,11 +241,33 @@ export function buildRootErrorCallbackOptions(context, hydrating, { defaultRepor
197
241
  };
198
242
  }
199
243
  if (supportsReact19RootErrorCallbacks) {
244
+ // Owner-stack enrichment for client render errors (issue #3887). We only enrich when the app has
245
+ // registered its own onCaughtError/onUncaughtError handler: providing one already replaces
246
+ // React's default reporting for that callback, so prepending our supplemental dev owner-stack
247
+ // line is purely additive. We deliberately do NOT auto-attach a wrapper when the app registered
248
+ // no handler — that would displace React's built-in dev diagnostics (component stack,
249
+ // error-boundary hints) that we cannot faithfully reproduce, a net loss. Owner stacks still reach
250
+ // users automatically on the two paths React on Rails already owns: SSR errors (the Pro streaming
251
+ // onError path) and hydration mismatches (the onRecoverableError path above).
252
+ //
253
+ // The owner-stack line is only emitted on React >= 19.1 dev builds (`isOwnerStackSupported()`);
254
+ // otherwise the wrapper just forwards to the app handler unchanged.
255
+ const enrichDevOwnerStack = inDevelopmentEnv() && isOwnerStackSupported();
200
256
  if (onCaughtError) {
201
- options.onCaughtError = (error, errorInfo) => safeInvoke(onCaughtError, 'onCaughtError', error, errorInfo, context);
257
+ options.onCaughtError = (error, errorInfo) => {
258
+ if (enrichDevOwnerStack) {
259
+ logDevRenderError('onCaughtError', context, errorInfo);
260
+ }
261
+ safeInvoke(onCaughtError, 'onCaughtError', error, errorInfo, context);
262
+ };
202
263
  }
203
264
  if (onUncaughtError) {
204
- options.onUncaughtError = (error, errorInfo) => safeInvoke(onUncaughtError, 'onUncaughtError', error, errorInfo, context);
265
+ options.onUncaughtError = (error, errorInfo) => {
266
+ if (enrichDevOwnerStack) {
267
+ logDevRenderError('onUncaughtError', context, errorInfo);
268
+ }
269
+ safeInvoke(onUncaughtError, 'onUncaughtError', error, errorInfo, context);
270
+ };
205
271
  }
206
272
  }
207
273
  return options;
@@ -1,4 +1,5 @@
1
1
  import type { RegisteredComponent, RegisteredComponentValue, RenderingError, FinalHtmlResult } from './types/index.ts';
2
+ export { convertToError } from './errorUtils.ts';
2
3
  /**
3
4
  * Builds the metadata object for the length-prefixed streaming protocol.
4
5
  * This is the shared metadata builder used by both streaming and non-streaming paths.
@@ -19,7 +20,5 @@ export declare function buildRenderMetadata(consoleReplayScript: string, renderS
19
20
  * buildRenderMetadata directly with Buffer operations for efficiency.
20
21
  */
21
22
  export declare function buildLengthPrefixedResult(html: FinalHtmlResult | null, consoleReplayScript: string, renderState: RenderMetadataSource): string;
22
- export declare function convertToError(e: unknown): Error;
23
23
  export declare function validateComponent(componentObj: RegisteredComponent<RegisteredComponentValue>, componentName: string): void;
24
- export {};
25
24
  //# sourceMappingURL=serverRenderUtils.d.ts.map
@@ -1,19 +1,5 @@
1
- // Must match SOURCE_MAP_STACK_REMAPPER_CONTEXT_KEY in
2
- // packages/react-on-rails-pro-node-renderer/src/worker/vmSourceMapSupport.ts.
3
- const SOURCE_MAPPED_STACK_REMAPPER_KEY = '__reactOnRailsProRemapStackTrace';
4
- function remapSourceMappedStack(stack) {
5
- const remapper = globalThis[SOURCE_MAPPED_STACK_REMAPPER_KEY];
6
- if (typeof remapper !== 'function') {
7
- return stack;
8
- }
9
- try {
10
- const remappedStack = remapper(stack);
11
- return typeof remappedStack === 'string' ? remappedStack : stack;
12
- }
13
- catch {
14
- return stack;
15
- }
16
- }
1
+ import { remapSourceMappedStack } from "./errorUtils.js";
2
+ export { convertToError } from "./errorUtils.js";
17
3
  export function buildRenderMetadata(consoleReplayScript, renderState) {
18
4
  return {
19
5
  consoleReplayScript,
@@ -26,24 +12,6 @@ export function buildRenderMetadata(consoleReplayScript, renderState) {
26
12
  isShellReady: 'isShellReady' in renderState ? renderState.isShellReady : undefined,
27
13
  };
28
14
  }
29
- function isCrossRealmError(e) {
30
- return typeof e === 'object' && e !== null && Object.prototype.toString.call(e) === '[object Error]';
31
- }
32
- function stringifyThrownValue(e) {
33
- if (isCrossRealmError(e)) {
34
- return typeof e.message === 'string' ? e.message : Object.prototype.toString.call(e);
35
- }
36
- if (typeof e === 'object' && e !== null) {
37
- try {
38
- // JSON.stringify can return undefined without throwing, for example when toJSON returns undefined.
39
- return JSON.stringify(e) ?? Object.prototype.toString.call(e);
40
- }
41
- catch {
42
- return Object.prototype.toString.call(e);
43
- }
44
- }
45
- return String(e);
46
- }
47
15
  /**
48
16
  * Returns the UTF-8 byte length of a string.
49
17
  * Uses native Buffer.byteLength when available (Node.js, Pro node renderer).
@@ -105,21 +73,6 @@ export function buildLengthPrefixedResult(html, consoleReplayScript, renderState
105
73
  const byteLength = utf8ByteLength(htmlStr);
106
74
  return `${metadata}\t${byteLength.toString(16).padStart(8, '0')}\n${htmlStr}`;
107
75
  }
108
- export function convertToError(e) {
109
- if (e instanceof Error) {
110
- return e;
111
- }
112
- const message = stringifyThrownValue(e);
113
- // tsconfig uses es2020 libs, which do not type Error.cause even though supported runtimes provide it.
114
- const error = new Error(message);
115
- error.cause = e;
116
- if (isCrossRealmError(e) && typeof e.stack === 'string') {
117
- // Prefer the cross-realm bundle stack over the host wrapping call site; the
118
- // original thrown value remains available through `cause`.
119
- error.stack = remapSourceMappedStack(e.stack);
120
- }
121
- return error;
122
- }
123
76
  export function validateComponent(componentObj, componentName) {
124
77
  if (componentObj.isRenderer) {
125
78
  throw new Error(`Detected a renderer while server rendering component '${componentName}'. See https://github.com/shakacode/react_on_rails#renderer-functions`);
@@ -48,6 +48,7 @@ export type RailsContextWithServerComponentMetadata = RailsContext & {
48
48
  export type RailsContextWithServerStreamingCapabilities = RailsContextWithServerComponentMetadata & {
49
49
  getRSCPayloadStream: (componentName: string, props: unknown) => Promise<NodeJS.ReadableStream>;
50
50
  addPostSSRHook: (hook: () => void) => void;
51
+ recordRSCDiagnostic?: (componentName: string, diagnosticError: Error) => void;
51
52
  };
52
53
  export declare const assertRailsContextWithServerComponentMetadata: (context: RailsContext | undefined) => asserts context is RailsContextWithServerComponentMetadata;
53
54
  export declare const assertRailsContextWithServerStreamingCapabilities: (context: RailsContext | undefined) => asserts context is RailsContextWithServerStreamingCapabilities;
@@ -169,23 +170,32 @@ type AsyncPropsManager = {
169
170
  * anotherRenderFunction.renderFunction = true;
170
171
  *
171
172
  * @remarks
172
- * The 3-argument "renderer" form `(props, railsContext, domNodeId)` owns its own DOM
173
- * rendering/hydration. Use {@link RendererFunction} for renderers that return nothing or an optional
174
- * `{ teardown }` wrapper for cleanup. `RenderFunction` still accepts legacy 3-argument renderers
175
- * that returned a component/server result only to satisfy the old type; React on Rails ignores those
176
- * return values on the client renderer path.
173
+ * `RenderFunction` is exactly this 2-argument server/client render-function form. The 3-argument
174
+ * "renderer" form `(props, railsContext, domNodeId)` owns its own DOM rendering/hydration and is a
175
+ * distinct role: type those functions {@link RendererFunction} (they return nothing or an optional
176
+ * `{ teardown }` wrapper for cleanup). For `RenderFunction`, this makes the illegal combination
177
+ * a server render-function "returning" a teardown — unrepresentable instead of merely discouraged.
178
+ * (`RendererFunction` still accepts legacy {@link RenderFunctionResult} return shapes for backward
179
+ * compatibility with old 3-argument renderers; those values are ignored at runtime.)
180
+ *
181
+ * The doc block above describes {@link RenderFunction}, the public alias for this interface. Prefer
182
+ * `RenderFunction` in public-facing annotations; `ServerRenderFunction` is the concrete interface
183
+ * behind it (exported mainly so call sites can narrow to the precise role after runtime guards).
177
184
  */
178
185
  interface ServerRenderFunction extends RenderFunctionMarker {
179
186
  (props?: any, railsContext?: RailsContext): RenderFunctionResult;
180
187
  }
181
- interface LegacyRendererRenderFunction extends RenderFunctionMarker {
182
- (props?: any, railsContext?: RailsContext, domNodeId?: string): RenderFunctionResult;
183
- }
184
- type RenderFunction = ServerRenderFunction | LegacyRendererRenderFunction;
188
+ /**
189
+ * The public name for the 2-argument server/client render-function form
190
+ * `(props, railsContext) => RenderFunctionResult`. Alias of {@link ServerRenderFunction}; prefer
191
+ * `RenderFunction` in public-facing annotations. See {@link ServerRenderFunction} for the full
192
+ * render-function vs. renderer role explanation.
193
+ */
194
+ type RenderFunction = ServerRenderFunction;
185
195
  type ReactComponentOrRenderFunction = ReactComponent | RenderFunction | RendererFunction;
186
196
  type RegisteredComponentValue = ReactComponentOrRenderFunction | Record<string, unknown>;
187
197
  type PipeableOrReadableStream = PipeableStream | NodeJS.ReadableStream;
188
- export type { ReactComponentOrRenderFunction, RegisteredComponentValue, ReactComponent, ReactComponentRenderFunction, AuthenticityHeaders, RenderFunction, RendererTeardown, RendererTeardownResult, RendererFunction, RenderFunctionResult, Store, StoreGenerator, CreateReactOutputResult, ServerRenderResult, ServerRenderHashRenderedHtml, CreateReactOutputSyncResult, CreateReactOutputAsyncResult, RenderFunctionSyncResult, RenderFunctionAsyncResult, ReactComponentRenderFunctionResult, StreamableComponentResult, PipeableOrReadableStream, };
198
+ export type { ReactComponentOrRenderFunction, RegisteredComponentValue, ReactComponent, ReactComponentRenderFunction, AuthenticityHeaders, RenderFunction, ServerRenderFunction, RendererTeardown, RendererTeardownResult, RendererFunction, RenderFunctionResult, RendererFunctionResult, Store, StoreGenerator, CreateReactOutputResult, ServerRenderResult, ServerRenderHashRenderedHtml, CreateReactOutputSyncResult, CreateReactOutputAsyncResult, RenderFunctionSyncResult, RenderFunctionAsyncResult, ReactComponentRenderFunctionResult, StreamableComponentResult, PipeableOrReadableStream, };
189
199
  /**
190
200
  * The generic defaults to the pre-object-registration component type so existing consumers that
191
201
  * read `registeredComponent.component` stay source-compatible. Use
@@ -16,7 +16,19 @@ export const assertRailsContextWithServerComponentMetadata = (context) => {
16
16
  };
17
17
  export const assertRailsContextWithServerStreamingCapabilities = (context) => {
18
18
  assertRailsContextWithServerComponentMetadata(context);
19
- if (!('getRSCPayloadStream' in context) || !('addPostSSRHook' in context)) {
19
+ // Verify the capabilities are callable, not merely present, so a misconfigured context fails here
20
+ // with the intended diagnostic instead of crashing later at a call site.
21
+ //
22
+ // `recordRSCDiagnostic` is intentionally NOT required here. It is an additive field (#3475); an
23
+ // external consumer constructing this context against an older Pro version may not supply it.
24
+ // Hard-throwing on its absence would be a compat regression for those consumers even though their
25
+ // type-check passes (the field is optional). Callers degrade gracefully when it is missing, so the
26
+ // assertion only guards the two pre-existing required capabilities.
27
+ // Cast to a Partial of the target type (rather than Record<string, unknown>) so the known
28
+ // capability keys stay typed while we runtime-check that each is actually a function.
29
+ const capabilities = context;
30
+ if (typeof capabilities.getRSCPayloadStream !== 'function' ||
31
+ typeof capabilities.addPostSSRHook !== 'function') {
20
32
  throwRailsContextMissingEntries('getRSCPayloadStream and addPostSSRHook functions');
21
33
  }
22
34
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-on-rails",
3
- "version": "17.0.0-rc.5",
3
+ "version": "17.0.0-rc.6",
4
4
  "description": "react-on-rails JavaScript for react_on_rails Ruby gem",
5
5
  "main": "lib/ReactOnRails.full.js",
6
6
  "type": "module",
@@ -39,6 +39,7 @@
39
39
  "./ReactOnRails.full": "./lib/ReactOnRails.full.js",
40
40
  "./handleError": "./lib/handleError.js",
41
41
  "./generateRenderingErrorMessage": "./lib/generateRenderingErrorMessage.js",
42
+ "./captureReactOwnerStack": "./lib/captureReactOwnerStack.js",
42
43
  "./serverRenderUtils": "./lib/serverRenderUtils.js",
43
44
  "./buildConsoleReplay": "./lib/buildConsoleReplay.js",
44
45
  "./ReactDOMServer": "./lib/ReactDOMServer.cjs",