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.
- package/lib/ClientRenderer.js +164 -34
- package/lib/captureReactOwnerStack.d.ts +9 -0
- package/lib/captureReactOwnerStack.js +64 -0
- package/lib/createReactOutput.js +5 -2
- package/lib/errorUtils.d.ts +4 -0
- package/lib/errorUtils.js +50 -0
- package/lib/rootErrorHandlers.js +72 -6
- package/lib/serverRenderUtils.d.ts +1 -2
- package/lib/serverRenderUtils.js +2 -49
- package/lib/types/index.d.ts +20 -10
- package/lib/types/index.js +13 -1
- package/package.json +2 -1
package/lib/ClientRenderer.js
CHANGED
|
@@ -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
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/lib/createReactOutput.js
CHANGED
|
@@ -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:
|
|
75
|
-
//
|
|
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,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
|
package/lib/rootErrorHandlers.js
CHANGED
|
@@ -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),
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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) =>
|
|
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) =>
|
|
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
|
package/lib/serverRenderUtils.js
CHANGED
|
@@ -1,19 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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`);
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
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
|
package/lib/types/index.js
CHANGED
|
@@ -16,7 +16,19 @@ export const assertRailsContextWithServerComponentMetadata = (context) => {
|
|
|
16
16
|
};
|
|
17
17
|
export const assertRailsContextWithServerStreamingCapabilities = (context) => {
|
|
18
18
|
assertRailsContextWithServerComponentMetadata(context);
|
|
19
|
-
|
|
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.
|
|
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",
|