react-on-rails 17.0.0-rc.1 → 17.0.0-rc.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/lib/ClientRenderer.js +310 -58
- package/lib/ComponentRegistry.d.ts +5 -4
- package/lib/StoreRegistry.d.ts +4 -0
- package/lib/StoreRegistry.js +11 -6
- package/lib/base/client.d.ts +6 -4
- package/lib/base/client.js +36 -163
- package/lib/base/full.js +7 -25
- package/lib/base/full.rsc.js +7 -13
- package/lib/capabilities/core.d.ts +13 -9
- package/lib/capabilities/core.js +26 -5
- package/lib/captureReactOwnerStack.d.ts +9 -0
- package/lib/captureReactOwnerStack.js +64 -0
- package/lib/componentRegistrationMetric.d.ts +8 -0
- package/lib/componentRegistrationMetric.js +7 -0
- package/lib/createReactOutput.js +44 -2
- package/lib/errorUtils.d.ts +4 -0
- package/lib/errorUtils.js +50 -0
- package/lib/isRenderFunction.d.ts +6 -4
- package/lib/isRenderFunction.js +9 -5
- package/lib/isThenable.d.ts +8 -0
- package/lib/isThenable.js +13 -0
- package/lib/railsAction.d.ts +68 -0
- package/lib/railsAction.js +373 -0
- package/lib/reactApis.cjs +4 -1
- package/lib/reactApis.d.cts +1 -0
- package/lib/rendererTeardown.d.ts +3 -0
- package/lib/rendererTeardown.js +9 -0
- package/lib/rootErrorHandlers.d.ts +82 -0
- package/lib/rootErrorHandlers.js +288 -0
- package/lib/serverRenderUtils.d.ts +3 -4
- package/lib/serverRenderUtils.js +3 -29
- package/lib/types/index.d.ts +166 -18
- package/lib/types/index.js +13 -1
- package/lib/useRailsForm.d.ts +137 -0
- package/lib/useRailsForm.js +430 -0
- package/package.json +14 -1
package/README.md
CHANGED
|
@@ -81,11 +81,12 @@ ReactOnRails.register({
|
|
|
81
81
|
|
|
82
82
|
## Exports
|
|
83
83
|
|
|
84
|
-
| Export Path
|
|
85
|
-
|
|
|
86
|
-
| `react-on-rails`
|
|
87
|
-
| `react-on-rails/client`
|
|
88
|
-
| `react-on-rails/
|
|
84
|
+
| Export Path | Description |
|
|
85
|
+
| ---------------------------- | ------------------------------------ |
|
|
86
|
+
| `react-on-rails` | Full build with SSR utilities |
|
|
87
|
+
| `react-on-rails/client` | Client-only build (smaller, no SSR) |
|
|
88
|
+
| `react-on-rails/railsAction` | CSRF-aware typed Rails action caller |
|
|
89
|
+
| `react-on-rails/types` | TypeScript type exports |
|
|
89
90
|
|
|
90
91
|
## Rails-Side Setup
|
|
91
92
|
|
package/lib/ClientRenderer.js
CHANGED
|
@@ -6,9 +6,66 @@ import { getRailsContext } from "./context.js";
|
|
|
6
6
|
import { isServerRenderHash } from "./isServerRenderResult.js";
|
|
7
7
|
import { onPageUnloaded } from "./pageLifecycle.js";
|
|
8
8
|
import { supportsRootApi, unmountComponentAtNode } from "./reactApis.cjs";
|
|
9
|
+
import { isRendererTeardownResult } from "./rendererTeardown.js";
|
|
10
|
+
import { buildRootErrorCallbackOptions } from "./rootErrorHandlers.js";
|
|
11
|
+
import { convertToError } from "./errorUtils.js";
|
|
12
|
+
import { isThenable } from "./isThenable.js";
|
|
9
13
|
const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store';
|
|
10
14
|
// Track all rendered roots for cleanup
|
|
11
15
|
const renderedRoots = new Map();
|
|
16
|
+
/**
|
|
17
|
+
* Invokes a renderer teardown, swallowing async rejections so a failing teardown cannot produce an
|
|
18
|
+
* unhandled promise rejection. Synchronous throws propagate to the caller's try/catch. `domNodeId`
|
|
19
|
+
* is included in the log so a failure can be traced to its mount.
|
|
20
|
+
* MIRROR OF: packages/react-on-rails-pro/src/ClientSideRenderer.ts (sibling helper). If you change
|
|
21
|
+
* the error-handling logic or log format here, update that copy too.
|
|
22
|
+
*/
|
|
23
|
+
function invokeRendererTeardown(teardown, domNodeId) {
|
|
24
|
+
if (!teardown)
|
|
25
|
+
return;
|
|
26
|
+
const maybePromise = teardown();
|
|
27
|
+
if (isThenable(maybePromise)) {
|
|
28
|
+
// Detect a thenable with `.then` (Promises/A+) but swallow the rejection via
|
|
29
|
+
// `Promise.resolve(...).catch(...)`: a non-native thenable may lack `.catch`, so calling it
|
|
30
|
+
// directly could itself throw or leave the rejection unhandled. This keeps a failing async
|
|
31
|
+
// teardown from surfacing as an unhandled promise rejection.
|
|
32
|
+
Promise.resolve(maybePromise).catch((error) => {
|
|
33
|
+
console.error(`Error in renderer teardown for dom node "${domNodeId}":`, error);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Tears down a single tracked entry: runs the renderer's teardown, or unmounts the React root.
|
|
39
|
+
* Synchronous errors are not caught here; callers wrap this in try/catch so one failure does not
|
|
40
|
+
* abort cleanup of the remaining entries.
|
|
41
|
+
*/
|
|
42
|
+
function teardownEntry(entry, domNodeId) {
|
|
43
|
+
if (entry.kind === 'scheduled') {
|
|
44
|
+
entry.cancel();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (entry.kind === 'renderer') {
|
|
48
|
+
invokeRendererTeardown(entry.teardown, domNodeId);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (supportsRootApi && entry.root && typeof entry.root === 'object' && 'unmount' in entry.root) {
|
|
52
|
+
// React 18+ Root API
|
|
53
|
+
entry.root.unmount();
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
// React 16-17 legacy API
|
|
57
|
+
unmountComponentAtNode(entry.domNode);
|
|
58
|
+
}
|
|
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
|
+
}
|
|
12
69
|
function initializeStore(el, railsContext) {
|
|
13
70
|
const name = el.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || '';
|
|
14
71
|
const props = el.textContent !== null ? JSON.parse(el.textContent) : {};
|
|
@@ -25,6 +82,103 @@ function forEachStore(railsContext) {
|
|
|
25
82
|
function domNodeIdForEl(el) {
|
|
26
83
|
return el.getAttribute('data-dom-id') || '';
|
|
27
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
|
+
callback();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const isVisible = entries.some((entry) => entry.isIntersecting || entry.intersectionRatio > 0);
|
|
125
|
+
if (!isVisible)
|
|
126
|
+
return;
|
|
127
|
+
observer.disconnect();
|
|
128
|
+
callback();
|
|
129
|
+
}, { rootMargin: '200px 0px' });
|
|
130
|
+
observer.observe(domNode);
|
|
131
|
+
return () => {
|
|
132
|
+
observer.disconnect();
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function scheduleWhenIdle(callback) {
|
|
136
|
+
const idleWindow = window;
|
|
137
|
+
if (typeof idleWindow.requestIdleCallback === 'function') {
|
|
138
|
+
const idleCallbackId = idleWindow.requestIdleCallback(callback, { timeout: 2000 });
|
|
139
|
+
return () => {
|
|
140
|
+
if (typeof idleWindow.cancelIdleCallback === 'function') {
|
|
141
|
+
idleWindow.cancelIdleCallback(idleCallbackId);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// Safari lacks requestIdleCallback; 50 ms defers past the current frame without a layout-blocking
|
|
146
|
+
// setTimeout(0) but still far enough before most long-idle periods (matching common polyfill defaults).
|
|
147
|
+
return scheduleTimeout(callback, 50);
|
|
148
|
+
}
|
|
149
|
+
function scheduleHydration(hydrateOn, domNode, callback) {
|
|
150
|
+
if (hydrateOn === 'visible') {
|
|
151
|
+
return scheduleWhenVisible(domNode, callback);
|
|
152
|
+
}
|
|
153
|
+
if (hydrateOn === 'idle') {
|
|
154
|
+
return scheduleWhenIdle(callback);
|
|
155
|
+
}
|
|
156
|
+
// Defensive: `renderElement` short-circuits for `immediate` before calling `scheduleHydration`, so
|
|
157
|
+
// this branch is normally unreachable; it acts as a safe fallback guard for future callers.
|
|
158
|
+
callback();
|
|
159
|
+
return () => { };
|
|
160
|
+
}
|
|
161
|
+
// Logs a normalized copy of the original thrown value, then returns a fresh ReactOnRails-prefixed
|
|
162
|
+
// error for callers to throw/report. This must not mutate the original value: user code can throw
|
|
163
|
+
// strings, null, cross-realm errors, or frozen Error instances, and delayed hydration catches run
|
|
164
|
+
// inside scheduler callbacks where an error reporter that throws would escape the callback.
|
|
165
|
+
function prepareRenderError(componentName, error) {
|
|
166
|
+
const originalError = convertToError(error);
|
|
167
|
+
console.error(originalError);
|
|
168
|
+
const renderError = new Error(`ReactOnRails encountered an error while rendering component: ${componentName}. See above error message.`);
|
|
169
|
+
renderError.cause = originalError;
|
|
170
|
+
if (typeof originalError.stack === 'string') {
|
|
171
|
+
renderError.stack = originalError.stack;
|
|
172
|
+
}
|
|
173
|
+
return renderError;
|
|
174
|
+
}
|
|
175
|
+
function raiseRenderError(componentName, error) {
|
|
176
|
+
throw prepareRenderError(componentName, error);
|
|
177
|
+
}
|
|
178
|
+
function reportRenderError(componentName, error) {
|
|
179
|
+
// prepareRenderError already logged the original error; do not log again (avoids the double-log).
|
|
180
|
+
prepareRenderError(componentName, error);
|
|
181
|
+
}
|
|
28
182
|
function delegateToRenderer(componentObj, props, railsContext, domNodeId, trace) {
|
|
29
183
|
const { name, component, isRenderer } = componentObj;
|
|
30
184
|
if (isRenderer) {
|
|
@@ -32,18 +186,94 @@ function delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)
|
|
|
32
186
|
console.log(`\
|
|
33
187
|
DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, railsContext:`, props, railsContext);
|
|
34
188
|
}
|
|
35
|
-
// Call the renderer function with the expected signature
|
|
36
|
-
|
|
37
|
-
|
|
189
|
+
// Call the renderer function with the expected signature. A renderer owns its own mount and may
|
|
190
|
+
// return nothing, a teardown wrapper, or a promise resolving to one. `component` is the registered
|
|
191
|
+
// component union, so `as RendererFunction` is a runtime-invariant assertion guarded by
|
|
192
|
+
// `isRenderer` (the registry only sets it for a 3-arg render function), not a structural
|
|
193
|
+
// narrowing.
|
|
194
|
+
if (typeof component !== 'function') {
|
|
195
|
+
throw new Error(`Registered renderer "${name}" must be a function.`);
|
|
196
|
+
}
|
|
197
|
+
const result = component(props, railsContext, domNodeId);
|
|
198
|
+
return { delegated: true, result };
|
|
199
|
+
}
|
|
200
|
+
return { delegated: false };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Records a renderer-function mount and captures its optional teardown. The renderer may return a
|
|
204
|
+
* teardown wrapper synchronously, or a promise resolving to one (async renderers). The entry is
|
|
205
|
+
* stored immediately so the replaced-node path can find it even before an async teardown resolves.
|
|
206
|
+
*
|
|
207
|
+
* Known limitation (core package): if the mount is unmounted or its node is replaced *before* an
|
|
208
|
+
* async renderer resolves its teardown, the still-pending teardown is dropped and that one mount
|
|
209
|
+
* leaks — the resolved teardown is discarded because the entry is no longer the active mount for
|
|
210
|
+
* this id (the `renderedRoots.get(domNodeId) === entry` guard below). The drop is an expected,
|
|
211
|
+
* documented core limitation, but it still leaves a renderer-owned mount uncleaned, so it is logged
|
|
212
|
+
* with `console.error` rather than silently ignored. Synchronous teardowns are unaffected. The Pro
|
|
213
|
+
* client renderer (`react-on-rails-pro`) awaits the renderer and re-checks the unmount state, so it
|
|
214
|
+
* runs the teardown even when a navigation races the resolve; prefer Pro if you depend on async
|
|
215
|
+
* renderer teardowns surviving fast navigations.
|
|
216
|
+
*
|
|
217
|
+
* Renderer results that ultimately do not include a teardown wrapper are left untracked:
|
|
218
|
+
* synchronous no-wrapper returns are not stored, and async results that resolve without a wrapper use
|
|
219
|
+
* only a temporary placeholder until the promise settles. Before this cleanup contract existed,
|
|
220
|
+
* renderer-owned mounts were never tracked, so repeated page-loaded calls re-invoked those legacy
|
|
221
|
+
* renderers; preserving that behavior keeps "return nothing" backward compatible.
|
|
222
|
+
*/
|
|
223
|
+
function trackRendererMount(domNodeId, domNode, result) {
|
|
224
|
+
if (isRendererTeardownResult(result)) {
|
|
225
|
+
renderedRoots.set(domNodeId, { kind: 'renderer', domNode, teardown: result.teardown });
|
|
226
|
+
}
|
|
227
|
+
else if (isThenable(result)) {
|
|
228
|
+
const entry = { kind: 'renderer', domNode, teardown: undefined };
|
|
229
|
+
renderedRoots.set(domNodeId, entry);
|
|
230
|
+
Promise.resolve(result)
|
|
231
|
+
.then((resolved) => {
|
|
232
|
+
if (!isRendererTeardownResult(resolved)) {
|
|
233
|
+
if (renderedRoots.get(domNodeId) === entry) {
|
|
234
|
+
renderedRoots.delete(domNodeId);
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
// Only attach if this exact entry is still the active mount for this id.
|
|
239
|
+
if (renderedRoots.get(domNodeId) === entry) {
|
|
240
|
+
entry.teardown = resolved.teardown;
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
// The mount was unmounted or its node replaced before this async teardown resolved, so the
|
|
244
|
+
// entry is no longer the active mount and the teardown can't be attached — it is dropped
|
|
245
|
+
// and that one mount may leak on cleanup. This is the expected, documented best-effort core
|
|
246
|
+
// limitation, but the consequence is still a leak, so log it as an error. Pro avoids this
|
|
247
|
+
// race entirely.
|
|
248
|
+
console.error(`[react-on-rails] Renderer teardown for dom node "${domNodeId}" resolved after the ` +
|
|
249
|
+
'page or node was already cleaned up; the teardown was dropped and that mount may ' +
|
|
250
|
+
'leak. Use react-on-rails-pro for reliable async-renderer teardown on fast navigations.');
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
.catch((error) => {
|
|
254
|
+
const isStillActive = renderedRoots.get(domNodeId) === entry;
|
|
255
|
+
if (!isStillActive) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
renderedRoots.delete(domNodeId);
|
|
259
|
+
// The renderer's own promise rejected: the render failed, so the component never mounted and
|
|
260
|
+
// no teardown was captured. Log it (rather than letting it surface as an unhandled rejection)
|
|
261
|
+
// so the failure is diagnosable; any partial mount the renderer created may leak on cleanup.
|
|
262
|
+
// If this placeholder was already removed by page unload or node replacement, the page/node is
|
|
263
|
+
// already being cleaned up, so suppress a stale rejection log from the abandoned renderer.
|
|
264
|
+
console.error(`Renderer for dom node "${domNodeId}" rejected; the component did not mount and no ` +
|
|
265
|
+
'teardown was captured. Any mount it created may leak on cleanup:', error);
|
|
266
|
+
});
|
|
38
267
|
}
|
|
39
|
-
return false;
|
|
40
268
|
}
|
|
41
269
|
/**
|
|
42
270
|
* Used for client rendering by ReactOnRails. Either calls ReactDOM.hydrate, ReactDOM.render, or
|
|
43
271
|
* delegates to a renderer registered by the user.
|
|
44
272
|
*/
|
|
45
273
|
function renderElement(el, railsContext) {
|
|
46
|
-
// This must match lib/react_on_rails/
|
|
274
|
+
// This must match the data-* attributes emitted by lib/react_on_rails/pro_helper.rb.
|
|
275
|
+
// Covered end-to-end by every spec/dummy client-rendering test (renaming either side fails
|
|
276
|
+
// loudly), so no MIRROR VALUES guard is added here.
|
|
47
277
|
const name = el.getAttribute('data-component-name') || '';
|
|
48
278
|
const domNodeId = domNodeIdForEl(el);
|
|
49
279
|
const props = el.textContent !== null ? JSON.parse(el.textContent) : {};
|
|
@@ -56,69 +286,97 @@ function renderElement(el, railsContext) {
|
|
|
56
286
|
// (e.g., for asynchronously loaded content)
|
|
57
287
|
const existing = renderedRoots.get(domNodeId);
|
|
58
288
|
if (existing) {
|
|
59
|
-
// Only skip if it's the exact same DOM node
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
289
|
+
// Only skip if it's the exact same DOM node, it's still connected to the document, AND it has
|
|
290
|
+
// actually mounted. A `scheduled` entry has not mounted yet: if its node was detached and later
|
|
291
|
+
// reattached (e.g. Turbo cache restore or a DOM move), its IntersectionObserver was disconnected
|
|
292
|
+
// and will never re-observe, so we must fall through to cancel the stale schedule and re-schedule
|
|
293
|
+
// fresh rather than skip (which would leave the island permanently non-interactive).
|
|
294
|
+
// If the node was replaced (e.g., via innerHTML or Turbo), we likewise need to unmount/cancel the
|
|
295
|
+
// old entry and re-render to the new node to prevent memory leaks and ensure rendering works.
|
|
296
|
+
const sameNode = existing.kind !== 'scheduled' && existing.domNode === domNode && existing.domNode.isConnected;
|
|
63
297
|
if (sameNode) {
|
|
64
298
|
if (trace) {
|
|
65
299
|
console.log(`Skipping already rendered component: ${name} (dom id: ${domNodeId})`);
|
|
66
300
|
}
|
|
67
301
|
return;
|
|
68
302
|
}
|
|
69
|
-
// DOM node was replaced (e.g., via async HTML injection) - clean up the old root
|
|
303
|
+
// DOM node was replaced (e.g., via async HTML injection) - clean up the old root or run
|
|
304
|
+
// the old renderer's teardown.
|
|
70
305
|
try {
|
|
71
|
-
|
|
72
|
-
existing.root &&
|
|
73
|
-
typeof existing.root === 'object' &&
|
|
74
|
-
'unmount' in existing.root) {
|
|
75
|
-
existing.root.unmount();
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
unmountComponentAtNode(existing.domNode);
|
|
79
|
-
}
|
|
306
|
+
teardownEntry(existing, domNodeId);
|
|
80
307
|
}
|
|
81
308
|
catch (unmountError) {
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
309
|
+
// Surface the failure unconditionally (matching unmountAllComponents) so a teardown/unmount
|
|
310
|
+
// error on node replacement is as visible as one on page unload, using the same greppable
|
|
311
|
+
// labels. We still continue: the old mount may leak, but the new node must be rendered.
|
|
312
|
+
console.error(teardownErrorLabel(existing, domNodeId), unmountError);
|
|
86
313
|
}
|
|
87
314
|
renderedRoots.delete(domNodeId);
|
|
88
315
|
}
|
|
89
316
|
const componentObj = ComponentRegistry.get(name);
|
|
90
|
-
|
|
317
|
+
const delegation = delegateToRenderer(componentObj, props, railsContext, domNodeId, trace);
|
|
318
|
+
if (delegation.delegated) {
|
|
319
|
+
// The renderer owns its own mount; record it (with any teardown wrapper it returned) so it
|
|
320
|
+
// gets cleaned up on page unload or same-id node replacement.
|
|
321
|
+
trackRendererMount(domNodeId, domNode, delegation.result);
|
|
91
322
|
return;
|
|
92
323
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
324
|
+
const mountReactRoot = () => {
|
|
325
|
+
// Hydrate if the DOM node has content (server-rendered HTML)
|
|
326
|
+
// Since we skip already-rendered components above, this check now correctly
|
|
327
|
+
// identifies only server-rendered content, not previously client-rendered content
|
|
328
|
+
const shouldHydrate = !!domNode.innerHTML;
|
|
329
|
+
const reactElementOrRouterResult = createReactOutput({
|
|
330
|
+
componentObj,
|
|
331
|
+
props,
|
|
332
|
+
domNodeId,
|
|
333
|
+
trace,
|
|
334
|
+
railsContext,
|
|
335
|
+
shouldHydrate,
|
|
336
|
+
});
|
|
337
|
+
if (isServerRenderHash(reactElementOrRouterResult)) {
|
|
338
|
+
throw new Error(`\
|
|
107
339
|
You returned a server side type of react-router error: ${JSON.stringify(reactElementOrRouterResult)}
|
|
108
340
|
You should return a React.Component always for the client side entry point.`);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
341
|
+
}
|
|
342
|
+
const root = reactHydrateOrRender(domNode, reactElementOrRouterResult, shouldHydrate,
|
|
343
|
+
// Attach user-registered root error callbacks (and the dev-mode hydration-mismatch
|
|
344
|
+
// logger) to every root, enriched with this mount's component name and dom id.
|
|
345
|
+
buildRootErrorCallbackOptions({ componentName: name || undefined, domNodeId: domNodeId || undefined }, shouldHydrate));
|
|
112
346
|
// Track the root for cleanup
|
|
113
|
-
renderedRoots.set(domNodeId, { root, domNode });
|
|
347
|
+
renderedRoots.set(domNodeId, { kind: 'react', root, domNode });
|
|
348
|
+
};
|
|
349
|
+
const hydrateOn = hydrateOnForEl(el);
|
|
350
|
+
if (hydrateOn === 'immediate') {
|
|
351
|
+
mountReactRoot();
|
|
352
|
+
return;
|
|
114
353
|
}
|
|
354
|
+
let scheduledEntry;
|
|
355
|
+
const runScheduledRender = () => {
|
|
356
|
+
if (renderedRoots.get(domNodeId) !== scheduledEntry)
|
|
357
|
+
return;
|
|
358
|
+
if (!domNode.isConnected) {
|
|
359
|
+
renderedRoots.delete(domNodeId);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
mountReactRoot();
|
|
364
|
+
}
|
|
365
|
+
catch (scheduledError) {
|
|
366
|
+
renderedRoots.delete(domNodeId);
|
|
367
|
+
reportRenderError(name, scheduledError);
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
scheduledEntry = {
|
|
371
|
+
kind: 'scheduled',
|
|
372
|
+
domNode,
|
|
373
|
+
cancel: scheduleHydration(hydrateOn, domNode, runScheduledRender),
|
|
374
|
+
};
|
|
375
|
+
renderedRoots.set(domNodeId, scheduledEntry);
|
|
115
376
|
}
|
|
116
377
|
}
|
|
117
378
|
catch (e) {
|
|
118
|
-
|
|
119
|
-
console.error(error.message);
|
|
120
|
-
error.message = `ReactOnRails encountered an error while rendering component: ${name}. See above error message.`;
|
|
121
|
-
throw error;
|
|
379
|
+
raiseRenderError(name, e);
|
|
122
380
|
}
|
|
123
381
|
}
|
|
124
382
|
/**
|
|
@@ -165,23 +423,17 @@ export function reactOnRailsComponentLoaded(domId) {
|
|
|
165
423
|
return Promise.resolve();
|
|
166
424
|
}
|
|
167
425
|
/**
|
|
168
|
-
* Unmount all rendered React components and clear roots.
|
|
169
|
-
*
|
|
426
|
+
* Unmount all rendered React components, run all renderer-function teardowns, and clear roots.
|
|
427
|
+
* Registered with `onPageUnloaded` to run on the page-unload lifecycle (Turbo/Turbolinks
|
|
428
|
+
* soft-navigation page swap, not a native browser unload) to prevent memory leaks.
|
|
170
429
|
*/
|
|
171
430
|
function unmountAllComponents() {
|
|
172
|
-
renderedRoots.forEach((
|
|
431
|
+
renderedRoots.forEach((entry, domNodeId) => {
|
|
173
432
|
try {
|
|
174
|
-
|
|
175
|
-
// React 18+ Root API
|
|
176
|
-
root.unmount();
|
|
177
|
-
}
|
|
178
|
-
else {
|
|
179
|
-
// React 16-17 legacy API
|
|
180
|
-
unmountComponentAtNode(domNode);
|
|
181
|
-
}
|
|
433
|
+
teardownEntry(entry, domNodeId);
|
|
182
434
|
}
|
|
183
435
|
catch (error) {
|
|
184
|
-
console.error(
|
|
436
|
+
console.error(teardownErrorLabel(entry, domNodeId), error);
|
|
185
437
|
}
|
|
186
438
|
});
|
|
187
439
|
renderedRoots.clear();
|
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
import type { RegisteredComponent,
|
|
1
|
+
import type { RegisteredComponent, RegisteredComponentValue } from './types/index.ts';
|
|
2
|
+
type RegisteredComponentEntry = RegisteredComponent<RegisteredComponentValue>;
|
|
2
3
|
declare const _default: {
|
|
3
4
|
/**
|
|
4
5
|
* @param components { component1: component1, component2: component2, etc. }
|
|
5
6
|
*/
|
|
6
|
-
register(components: Record<string,
|
|
7
|
+
register(components: Record<string, RegisteredComponentValue>): void;
|
|
7
8
|
/**
|
|
8
9
|
* @param name
|
|
9
10
|
* @returns { name, component, renderFunction, isRenderer }
|
|
10
11
|
*/
|
|
11
|
-
get(name: string):
|
|
12
|
+
get(name: string): RegisteredComponentEntry;
|
|
12
13
|
/**
|
|
13
14
|
* Get a Map containing all registered components. Useful for debugging.
|
|
14
15
|
* @returns Map where key is the component name and values are the
|
|
15
16
|
* { name, component, renderFunction, isRenderer}
|
|
16
17
|
*/
|
|
17
|
-
components(): Map<string,
|
|
18
|
+
components(): Map<string, RegisteredComponentEntry>;
|
|
18
19
|
/**
|
|
19
20
|
* Pro-only method that waits for component registration
|
|
20
21
|
* @param _name Component name to wait for
|
package/lib/StoreRegistry.d.ts
CHANGED
|
@@ -29,6 +29,10 @@ declare const _default: {
|
|
|
29
29
|
* Internally used function to completely clear hydratedStores Map.
|
|
30
30
|
*/
|
|
31
31
|
clearHydratedStores(): void;
|
|
32
|
+
/**
|
|
33
|
+
* Internally used function to completely clear registeredStoreGenerators Map.
|
|
34
|
+
*/
|
|
35
|
+
clearStoreGenerators(): void;
|
|
32
36
|
/**
|
|
33
37
|
* Get a Map containing all registered store generators. Useful for debugging.
|
|
34
38
|
* @returns Map where key is the component name and values are the store generators.
|
package/lib/StoreRegistry.js
CHANGED
|
@@ -33,6 +33,9 @@ export default {
|
|
|
33
33
|
if (hydratedStores.has(name)) {
|
|
34
34
|
return hydratedStores.get(name);
|
|
35
35
|
}
|
|
36
|
+
if (!throwIfMissing) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
36
39
|
const storeKeys = Array.from(hydratedStores.keys()).join(', ');
|
|
37
40
|
if (storeKeys.length === 0) {
|
|
38
41
|
const msg = `There are no stores hydrated and you are requesting the store ${name}.
|
|
@@ -42,12 +45,8 @@ This can happen if you are server rendering and either:
|
|
|
42
45
|
2. You do not render redux_store_hydration_data anywhere on your page.`;
|
|
43
46
|
throw new Error(msg);
|
|
44
47
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
throw new Error(`Could not find hydrated store with name '${name}'. ` +
|
|
48
|
-
`Hydrated store names include [${storeKeys}].`);
|
|
49
|
-
}
|
|
50
|
-
return undefined;
|
|
48
|
+
console.log('storeKeys', storeKeys);
|
|
49
|
+
throw new Error(`Could not find hydrated store with name '${name}'. Hydrated store names include [${storeKeys}].`);
|
|
51
50
|
},
|
|
52
51
|
/**
|
|
53
52
|
* Internally used function to get the store creator that was passed to `register`.
|
|
@@ -77,6 +76,12 @@ This can happen if you are server rendering and either:
|
|
|
77
76
|
clearHydratedStores() {
|
|
78
77
|
hydratedStores.clear();
|
|
79
78
|
},
|
|
79
|
+
/**
|
|
80
|
+
* Internally used function to completely clear registeredStoreGenerators Map.
|
|
81
|
+
*/
|
|
82
|
+
clearStoreGenerators() {
|
|
83
|
+
registeredStoreGenerators.clear();
|
|
84
|
+
},
|
|
80
85
|
/**
|
|
81
86
|
* Get a Map containing all registered store generators. Useful for debugging.
|
|
82
87
|
* @returns Map where key is the component name and values are the store generators.
|
package/lib/base/client.d.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* @deprecated Use `capabilities/core.ts` instead. This file is kept for backward compatibility
|
|
3
3
|
* with older versions of react-on-rails-pro that import from `react-on-rails/@internal/base/client`.
|
|
4
4
|
*/
|
|
5
|
-
import type { RegisteredComponent,
|
|
5
|
+
import type { RegisteredComponent, RegisteredComponentValue, Store, StoreGenerator, ReactOnRailsInternal } from '../types/index.ts';
|
|
6
|
+
type RegisteredComponentEntry = RegisteredComponent<RegisteredComponentValue>;
|
|
6
7
|
interface Registries {
|
|
7
8
|
ComponentRegistry: {
|
|
8
|
-
register: (components: Record<string,
|
|
9
|
-
get: (name: string) =>
|
|
10
|
-
components: () => Map<string,
|
|
9
|
+
register: (components: Record<string, RegisteredComponentValue>) => void;
|
|
10
|
+
get: (name: string) => RegisteredComponentEntry;
|
|
11
|
+
components: () => Map<string, RegisteredComponentEntry>;
|
|
11
12
|
};
|
|
12
13
|
StoreRegistry: {
|
|
13
14
|
register: (storeGenerators: Record<string, StoreGenerator>) => void;
|
|
@@ -15,6 +16,7 @@ interface Registries {
|
|
|
15
16
|
getStoreGenerator: (name: string) => StoreGenerator;
|
|
16
17
|
setStore: (name: string, store: Store) => void;
|
|
17
18
|
clearHydratedStores: () => void;
|
|
19
|
+
clearStoreGenerators: () => void;
|
|
18
20
|
storeGenerators: () => Map<string, StoreGenerator>;
|
|
19
21
|
stores: () => Map<string, Store>;
|
|
20
22
|
};
|