react-on-rails 17.0.0-rc.1 → 17.0.0-rc.10

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 CHANGED
@@ -81,11 +81,12 @@ ReactOnRails.register({
81
81
 
82
82
  ## Exports
83
83
 
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/types` | TypeScript type exports |
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
 
@@ -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
- component(props, railsContext, domNodeId);
37
- return true;
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/helper.rb
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 and it's still connected to the document.
60
- // If the node was replaced (e.g., via innerHTML or Turbo), we need to unmount the old
61
- // root and re-render to the new node to prevent memory leaks and ensure rendering works.
62
- const sameNode = existing.domNode === domNode && existing.domNode.isConnected;
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
- if (supportsRootApi &&
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
- // Ignore unmount errors for replaced nodes
83
- if (trace) {
84
- console.log(`Error unmounting replaced component: ${name}`, unmountError);
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
- if (delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) {
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
- // Hydrate if the DOM node has content (server-rendered HTML)
94
- // Since we skip already-rendered components above, this check now correctly
95
- // identifies only server-rendered content, not previously client-rendered content
96
- const shouldHydrate = !!domNode.innerHTML;
97
- const reactElementOrRouterResult = createReactOutput({
98
- componentObj,
99
- props,
100
- domNodeId,
101
- trace,
102
- railsContext,
103
- shouldHydrate,
104
- });
105
- if (isServerRenderHash(reactElementOrRouterResult)) {
106
- throw new Error(`\
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
- else {
111
- const root = reactHydrateOrRender(domNode, reactElementOrRouterResult, shouldHydrate);
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
- const error = e;
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
- * This should be called on page unload to prevent memory leaks.
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(({ root, domNode }) => {
431
+ renderedRoots.forEach((entry, domNodeId) => {
173
432
  try {
174
- if (supportsRootApi && root && typeof root === 'object' && 'unmount' in root) {
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('Error unmounting component:', error);
436
+ console.error(teardownErrorLabel(entry, domNodeId), error);
185
437
  }
186
438
  });
187
439
  renderedRoots.clear();
@@ -1,20 +1,21 @@
1
- import type { RegisteredComponent, ReactComponentOrRenderFunction } from './types/index.ts';
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, ReactComponentOrRenderFunction>): void;
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): RegisteredComponent;
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, RegisteredComponent>;
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
@@ -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.
@@ -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
- if (throwIfMissing) {
46
- console.log('storeKeys', storeKeys);
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.
@@ -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, ReactComponentOrRenderFunction, Store, StoreGenerator, ReactOnRailsInternal } from '../types/index.ts';
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, ReactComponentOrRenderFunction>) => void;
9
- get: (name: string) => RegisteredComponent;
10
- components: () => Map<string, RegisteredComponent>;
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
  };