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

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.
@@ -6,9 +6,56 @@ 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";
9
10
  const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store';
11
+ /** Narrows an unknown value to a thenable (has a callable `.then`) without assuming a native Promise. */
12
+ function isThenable(value) {
13
+ return (value != null &&
14
+ (typeof value === 'object' || typeof value === 'function') &&
15
+ typeof value.then === 'function');
16
+ }
10
17
  // Track all rendered roots for cleanup
11
18
  const renderedRoots = new Map();
19
+ /**
20
+ * Invokes a renderer teardown, swallowing async rejections so a failing teardown cannot produce an
21
+ * unhandled promise rejection. Synchronous throws propagate to the caller's try/catch. `domNodeId`
22
+ * is included in the log so a failure can be traced to its mount.
23
+ * MUST SYNC: A sibling helper exists in packages/react-on-rails-pro/src/ClientSideRenderer.ts. If you
24
+ * change the error-handling logic or log format here, update that copy too.
25
+ */
26
+ function invokeRendererTeardown(teardown, domNodeId) {
27
+ if (!teardown)
28
+ return;
29
+ const maybePromise = teardown();
30
+ if (isThenable(maybePromise)) {
31
+ // Detect a thenable with `.then` (Promises/A+) but swallow the rejection via
32
+ // `Promise.resolve(...).catch(...)`: a non-native thenable may lack `.catch`, so calling it
33
+ // directly could itself throw or leave the rejection unhandled. This keeps a failing async
34
+ // teardown from surfacing as an unhandled promise rejection.
35
+ Promise.resolve(maybePromise).catch((error) => {
36
+ console.error(`Error in renderer teardown for dom node "${domNodeId}":`, error);
37
+ });
38
+ }
39
+ }
40
+ /**
41
+ * Tears down a single tracked entry: runs the renderer's teardown, or unmounts the React root.
42
+ * Synchronous errors are not caught here; callers wrap this in try/catch so one failure does not
43
+ * abort cleanup of the remaining entries.
44
+ */
45
+ function teardownEntry(entry, domNodeId) {
46
+ if (entry.kind === 'renderer') {
47
+ invokeRendererTeardown(entry.teardown, domNodeId);
48
+ return;
49
+ }
50
+ if (supportsRootApi && entry.root && typeof entry.root === 'object' && 'unmount' in entry.root) {
51
+ // React 18+ Root API
52
+ entry.root.unmount();
53
+ }
54
+ else {
55
+ // React 16-17 legacy API
56
+ unmountComponentAtNode(entry.domNode);
57
+ }
58
+ }
12
59
  function initializeStore(el, railsContext) {
13
60
  const name = el.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || '';
14
61
  const props = el.textContent !== null ? JSON.parse(el.textContent) : {};
@@ -32,11 +79,85 @@ function delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)
32
79
  console.log(`\
33
80
  DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, railsContext:`, props, railsContext);
34
81
  }
35
- // Call the renderer function with the expected signature
36
- component(props, railsContext, domNodeId);
37
- return true;
82
+ // Call the renderer function with the expected signature. A renderer owns its own mount and may
83
+ // return nothing, a teardown wrapper, or a promise resolving to one. `component` is the registered
84
+ // component union, so `as RendererFunction` is a runtime-invariant assertion guarded by
85
+ // `isRenderer` (the registry only sets it for a 3-arg render function), not a structural
86
+ // narrowing.
87
+ if (typeof component !== 'function') {
88
+ throw new Error(`Registered renderer "${name}" must be a function.`);
89
+ }
90
+ const result = component(props, railsContext, domNodeId);
91
+ return { delegated: true, result };
92
+ }
93
+ return { delegated: false };
94
+ }
95
+ /**
96
+ * Records a renderer-function mount and captures its optional teardown. The renderer may return a
97
+ * teardown wrapper synchronously, or a promise resolving to one (async renderers). The entry is
98
+ * stored immediately so the replaced-node path can find it even before an async teardown resolves.
99
+ *
100
+ * Known limitation (core package): if the mount is unmounted or its node is replaced *before* an
101
+ * async renderer resolves its teardown, the still-pending teardown is dropped and that one mount
102
+ * leaks — the resolved teardown is discarded because the entry is no longer the active mount for
103
+ * this id (the `renderedRoots.get(domNodeId) === entry` guard below). The drop is an expected,
104
+ * documented core limitation, but it still leaves a renderer-owned mount uncleaned, so it is logged
105
+ * with `console.error` rather than silently ignored. Synchronous teardowns are unaffected. The Pro
106
+ * client renderer (`react-on-rails-pro`) awaits the renderer and re-checks the unmount state, so it
107
+ * runs the teardown even when a navigation races the resolve; prefer Pro if you depend on async
108
+ * renderer teardowns surviving fast navigations.
109
+ *
110
+ * Renderer results that ultimately do not include a teardown wrapper are left untracked:
111
+ * synchronous no-wrapper returns are not stored, and async results that resolve without a wrapper use
112
+ * only a temporary placeholder until the promise settles. Before this cleanup contract existed,
113
+ * renderer-owned mounts were never tracked, so repeated page-loaded calls re-invoked those legacy
114
+ * renderers; preserving that behavior keeps "return nothing" backward compatible.
115
+ */
116
+ function trackRendererMount(domNodeId, domNode, result) {
117
+ if (isRendererTeardownResult(result)) {
118
+ renderedRoots.set(domNodeId, { kind: 'renderer', domNode, teardown: result.teardown });
119
+ }
120
+ else if (isThenable(result)) {
121
+ const entry = { kind: 'renderer', domNode, teardown: undefined };
122
+ renderedRoots.set(domNodeId, entry);
123
+ Promise.resolve(result)
124
+ .then((resolved) => {
125
+ if (!isRendererTeardownResult(resolved)) {
126
+ if (renderedRoots.get(domNodeId) === entry) {
127
+ renderedRoots.delete(domNodeId);
128
+ }
129
+ return;
130
+ }
131
+ // Only attach if this exact entry is still the active mount for this id.
132
+ if (renderedRoots.get(domNodeId) === entry) {
133
+ entry.teardown = resolved.teardown;
134
+ }
135
+ else {
136
+ // The mount was unmounted or its node replaced before this async teardown resolved, so the
137
+ // entry is no longer the active mount and the teardown can't be attached — it is dropped
138
+ // and that one mount may leak on cleanup. This is the expected, documented best-effort core
139
+ // limitation, but the consequence is still a leak, so log it as an error. Pro avoids this
140
+ // race entirely.
141
+ console.error(`[react-on-rails] Renderer teardown for dom node "${domNodeId}" resolved after the ` +
142
+ 'page or node was already cleaned up; the teardown was dropped and that mount may ' +
143
+ 'leak. Use react-on-rails-pro for reliable async-renderer teardown on fast navigations.');
144
+ }
145
+ })
146
+ .catch((error) => {
147
+ const isStillActive = renderedRoots.get(domNodeId) === entry;
148
+ if (!isStillActive) {
149
+ return;
150
+ }
151
+ renderedRoots.delete(domNodeId);
152
+ // The renderer's own promise rejected: the render failed, so the component never mounted and
153
+ // no teardown was captured. Log it (rather than letting it surface as an unhandled rejection)
154
+ // so the failure is diagnosable; any partial mount the renderer created may leak on cleanup.
155
+ // If this placeholder was already removed by page unload or node replacement, the page/node is
156
+ // already being cleaned up, so suppress a stale rejection log from the abandoned renderer.
157
+ console.error(`Renderer for dom node "${domNodeId}" rejected; the component did not mount and no ` +
158
+ 'teardown was captured. Any mount it created may leak on cleanup:', error);
159
+ });
38
160
  }
39
- return false;
40
161
  }
41
162
  /**
42
163
  * Used for client rendering by ReactOnRails. Either calls ReactDOM.hydrate, ReactDOM.render, or
@@ -66,28 +187,28 @@ function renderElement(el, railsContext) {
66
187
  }
67
188
  return;
68
189
  }
69
- // DOM node was replaced (e.g., via async HTML injection) - clean up the old root
190
+ // DOM node was replaced (e.g., via async HTML injection) - clean up the old root or run
191
+ // the old renderer's teardown.
70
192
  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
- }
193
+ teardownEntry(existing, domNodeId);
80
194
  }
81
195
  catch (unmountError) {
82
- // Ignore unmount errors for replaced nodes
83
- if (trace) {
84
- console.log(`Error unmounting replaced component: ${name}`, unmountError);
85
- }
196
+ // Surface the failure unconditionally (matching unmountAllComponents) so a teardown/unmount
197
+ // error on node replacement is as visible as one on page unload, using the same greppable
198
+ // labels. We still continue: the old mount may leak, but the new node must be rendered.
199
+ const label = existing.kind === 'renderer'
200
+ ? `Error in renderer teardown for dom node "${domNodeId}":`
201
+ : `Error unmounting component for dom node "${domNodeId}":`;
202
+ console.error(label, unmountError);
86
203
  }
87
204
  renderedRoots.delete(domNodeId);
88
205
  }
89
206
  const componentObj = ComponentRegistry.get(name);
90
- if (delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) {
207
+ const delegation = delegateToRenderer(componentObj, props, railsContext, domNodeId, trace);
208
+ if (delegation.delegated) {
209
+ // The renderer owns its own mount; record it (with any teardown wrapper it returned) so it
210
+ // gets cleaned up on page unload or same-id node replacement.
211
+ trackRendererMount(domNodeId, domNode, delegation.result);
91
212
  return;
92
213
  }
93
214
  // Hydrate if the DOM node has content (server-rendered HTML)
@@ -110,7 +231,7 @@ You should return a React.Component always for the client side entry point.`);
110
231
  else {
111
232
  const root = reactHydrateOrRender(domNode, reactElementOrRouterResult, shouldHydrate);
112
233
  // Track the root for cleanup
113
- renderedRoots.set(domNodeId, { root, domNode });
234
+ renderedRoots.set(domNodeId, { kind: 'react', root, domNode });
114
235
  }
115
236
  }
116
237
  }
@@ -165,23 +286,22 @@ export function reactOnRailsComponentLoaded(domId) {
165
286
  return Promise.resolve();
166
287
  }
167
288
  /**
168
- * Unmount all rendered React components and clear roots.
169
- * This should be called on page unload to prevent memory leaks.
289
+ * Unmount all rendered React components, run all renderer-function teardowns, and clear roots.
290
+ * Registered with `onPageUnloaded` to run on the page-unload lifecycle (Turbo/Turbolinks
291
+ * soft-navigation page swap, not a native browser unload) to prevent memory leaks.
170
292
  */
171
293
  function unmountAllComponents() {
172
- renderedRoots.forEach(({ root, domNode }) => {
294
+ renderedRoots.forEach((entry, domNodeId) => {
173
295
  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
- }
296
+ teardownEntry(entry, domNodeId);
182
297
  }
183
298
  catch (error) {
184
- console.error('Error unmounting component:', error);
299
+ // Use the same label as the async-rejection path so renderer-teardown failures are greppable
300
+ // whether the teardown threw synchronously (here) or rejected (invokeRendererTeardown).
301
+ const label = entry.kind === 'renderer'
302
+ ? `Error in renderer teardown for dom node "${domNodeId}":`
303
+ : `Error unmounting component for dom node "${domNodeId}":`;
304
+ console.error(label, error);
185
305
  }
186
306
  });
187
307
  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
@@ -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;
@@ -6,6 +6,7 @@ import * as Authenticity from "../Authenticity.js";
6
6
  import buildConsoleReplay, { consoleReplay } from "../buildConsoleReplay.js";
7
7
  import reactHydrateOrRender from "../reactHydrateOrRender.js";
8
8
  import createReactOutput from "../createReactOutput.js";
9
+ import componentRegistrationMetric from "../componentRegistrationMetric.js";
9
10
  const DEFAULT_OPTIONS = {
10
11
  traceTurbolinks: false,
11
12
  turbo: false,
@@ -134,8 +135,8 @@ Fix: Use only react-on-rails OR react-on-rails-pro, not both.`);
134
135
  if (this.options.debugMode) {
135
136
  componentNames.forEach((name) => {
136
137
  const component = components[name];
137
- const size = component.toString().length;
138
- console.log(`[ReactOnRails] ✅ Registered: ${name} (${size} chars)`);
138
+ const registrationMetric = componentRegistrationMetric(component);
139
+ console.log(`[ReactOnRails] ✅ Registered: ${name} (${registrationMetric.value} ${registrationMetric.label})`);
139
140
  });
140
141
  }
141
142
  }
@@ -1,10 +1,11 @@
1
1
  import type { ReactElement } from 'react';
2
- import type { RegisteredComponent, RenderReturnType, ReactComponentOrRenderFunction, AuthenticityHeaders, Store, StoreGenerator, ReactOnRailsOptions } from '../types/index.ts';
2
+ import type { RegisteredComponent, RenderReturnType, RegisteredComponentValue, AuthenticityHeaders, Store, StoreGenerator, ReactOnRailsOptions } from '../types/index.ts';
3
+ type RegisteredComponentEntry = RegisteredComponent<RegisteredComponentValue>;
3
4
  export interface Registries {
4
5
  ComponentRegistry: {
5
- register: (components: Record<string, ReactComponentOrRenderFunction>) => void;
6
- get: (name: string) => RegisteredComponent;
7
- components: () => Map<string, RegisteredComponent>;
6
+ register: (components: Record<string, RegisteredComponentValue>) => void;
7
+ get: (name: string) => RegisteredComponentEntry;
8
+ components: () => Map<string, RegisteredComponentEntry>;
8
9
  };
9
10
  StoreRegistry: {
10
11
  register: (storeGenerators: Record<string, StoreGenerator>) => void;
@@ -31,22 +32,22 @@ export declare function createCoreCapability(registries: Registries): {
31
32
  buildConsoleReplay(): string;
32
33
  getConsoleReplayScript(): string;
33
34
  resetOptions(): void;
34
- register(components: Record<string, ReactComponentOrRenderFunction>): void;
35
+ register(components: Record<string, RegisteredComponentValue>): void;
35
36
  registerStore(stores: Record<string, StoreGenerator>): void;
36
37
  registerStoreGenerators(storeGenerators: Record<string, StoreGenerator>): void;
37
38
  getStore(name: string, throwIfMissing?: boolean): Store | undefined;
38
39
  getStoreGenerator(name: string): StoreGenerator;
39
40
  setStore(name: string, store: Store): void;
40
41
  clearHydratedStores(): void;
41
- getComponent(name: string): RegisteredComponent;
42
- registeredComponents(): Map<string, RegisteredComponent>;
42
+ getComponent(name: string): RegisteredComponentEntry;
43
+ registeredComponents(): Map<string, RegisteredComponentEntry>;
43
44
  storeGenerators(): Map<string, StoreGenerator>;
44
45
  stores(): Map<string, Store>;
45
- render(name: string, props: Record<string, string>, domNodeId: string, hydrate: boolean): RenderReturnType;
46
+ render(name: string, props: Record<string, unknown>, domNodeId: string, hydrate: boolean): RenderReturnType;
46
47
  serverRenderReactComponent(...args: any[]): any;
47
48
  handleError(...args: any[]): any;
48
49
  prepareRenderResult(...args: any[]): any;
49
- getOrWaitForComponent(): Promise<RegisteredComponent>;
50
+ getOrWaitForComponent(): Promise<RegisteredComponentEntry>;
50
51
  getOrWaitForStore(): Promise<Store>;
51
52
  getOrWaitForStoreGenerator(): Promise<StoreGenerator>;
52
53
  reactOnRailsStoreLoaded(): Promise<void>;
@@ -55,4 +56,5 @@ export declare function createCoreCapability(registries: Registries): {
55
56
  addAsyncPropsCapabilityToComponentProps(...args: any[]): any;
56
57
  getOrCreateAsyncPropsManager(...args: any[]): any;
57
58
  };
59
+ export {};
58
60
  //# sourceMappingURL=core.d.ts.map
@@ -2,6 +2,7 @@ import * as Authenticity from "../Authenticity.js";
2
2
  import buildConsoleReplay, { consoleReplay } from "../buildConsoleReplay.js";
3
3
  import reactHydrateOrRender from "../reactHydrateOrRender.js";
4
4
  import createReactOutput from "../createReactOutput.js";
5
+ import componentRegistrationMetric from "../componentRegistrationMetric.js";
5
6
  const DEFAULT_OPTIONS = {
6
7
  traceTurbolinks: false,
7
8
  turbo: false,
@@ -82,8 +83,8 @@ export function createCoreCapability(registries) {
82
83
  if (this.options.debugMode) {
83
84
  componentNames.forEach((name) => {
84
85
  const component = components[name];
85
- const size = component.toString().length;
86
- console.log(`[ReactOnRails] ✅ Registered: ${name} (${size} chars)`);
86
+ const registrationMetric = componentRegistrationMetric(component);
87
+ console.log(`[ReactOnRails] ✅ Registered: ${name} (${registrationMetric.value} ${registrationMetric.label})`);
87
88
  });
88
89
  }
89
90
  }
@@ -0,0 +1,8 @@
1
+ import type { RegisteredComponentValue } from './types/index.ts';
2
+ type RegistrationMetric = {
3
+ label: string;
4
+ value: number;
5
+ };
6
+ export default function componentRegistrationMetric(component: RegisteredComponentValue): RegistrationMetric;
7
+ export {};
8
+ //# sourceMappingURL=componentRegistrationMetric.d.ts.map
@@ -0,0 +1,7 @@
1
+ export default function componentRegistrationMetric(component) {
2
+ if (typeof component === 'function') {
3
+ return { label: 'source chars', value: component.toString().length };
4
+ }
5
+ return { label: 'export keys', value: Object.keys(component).length };
6
+ }
7
+ //# sourceMappingURL=componentRegistrationMetric.js.map
@@ -1,5 +1,20 @@
1
1
  import { createElement, isValidElement } from 'react';
2
2
  import { isServerRenderHash, isPromise } from "./isServerRenderResult.js";
3
+ import { isRendererTeardownResult } from "./rendererTeardown.js";
4
+ const unsupportedManualRendererMessage = (name) => `ReactOnRails.render() does not support renderer functions ("${name}"). ` +
5
+ 'Use normal React on Rails component rendering so renderer teardowns are captured on navigation.';
6
+ function isReactObjectComponentType(value) {
7
+ if (value == null || typeof value !== 'object') {
8
+ return false;
9
+ }
10
+ // React.memo, React.forwardRef, React.lazy, and related component types are non-callable
11
+ // objects tagged with React's element-type marker.
12
+ const typeMarker = value.$$typeof;
13
+ return typeof typeMarker === 'symbol' || typeof typeMarker === 'number';
14
+ }
15
+ function isReactComponentType(value) {
16
+ return typeof value === 'function' || typeof value === 'string' || isReactObjectComponentType(value);
17
+ }
3
18
  function createReactElementFromRenderFunctionResult(renderFunctionResult, name, props) {
4
19
  if (isValidElement(renderFunctionResult)) {
5
20
  // If already a ReactElement, then just return it.
@@ -24,7 +39,7 @@ work if you return JSX. Update by wrapping the result JSX of ${name} in a fat ar
24
39
  * @returns {ReactElement}
25
40
  */
26
41
  export default function createReactOutput({ componentObj, props, railsContext, domNodeId, trace, shouldHydrate, }) {
27
- const { name, component, renderFunction } = componentObj;
42
+ const { name, component, renderFunction, isRenderer } = componentObj;
28
43
  if (trace) {
29
44
  if (railsContext && railsContext.serverSide) {
30
45
  console.log(`RENDERED ${name} to dom node with id: ${domNodeId}`);
@@ -41,7 +56,26 @@ export default function createReactOutput({ componentObj, props, railsContext, d
41
56
  if (trace) {
42
57
  console.log(`${name} is a renderFunction`);
43
58
  }
59
+ // createReactOutput only handles render-functions that return a component or server-render hash.
60
+ // The 3-argument renderer form (which owns its own mount and may return a RendererTeardownResult)
61
+ // never reaches here on the supported paths: the client renderers delegate it earlier
62
+ // (`delegateToRenderer`) and return before this call, and server rendering rejects it upstream in
63
+ // validateComponent ("Detected a renderer while server rendering"). The only path that reaches
64
+ // here with a renderer is the manual public `ReactOnRails.render()` API, where renderers are
65
+ // unsupported because their teardown can't be tracked for cleanup. Reject it loudly and *before*
66
+ // invoking, rather than calling it with no domNodeId and rendering a half-wired, leak-prone result.
67
+ if (isRenderer) {
68
+ throw new Error(unsupportedManualRendererMessage(name));
69
+ }
70
+ if (typeof component !== 'function') {
71
+ throw new Error(`Registered render function "${name}" must be a function.`);
72
+ }
44
73
  const renderFunctionResult = component(props, railsContext);
74
+ // Defense-in-depth: a 2-argument render function isn't expected to return a teardown wrapper, but
75
+ // the public RenderFunction return type can't structurally exclude it, so reject that at runtime too.
76
+ if (isRendererTeardownResult(renderFunctionResult)) {
77
+ throw new Error(unsupportedManualRendererMessage(name));
78
+ }
45
79
  if (isServerRenderHash(renderFunctionResult)) {
46
80
  // We just return at this point, because calling function knows how to handle this case and
47
81
  // we can't call React.createElement with this type of Object.
@@ -51,6 +85,9 @@ export default function createReactOutput({ componentObj, props, railsContext, d
51
85
  // We just return at this point, because calling function knows how to handle this case and
52
86
  // we can't call React.createElement with this type of Object.
53
87
  return renderFunctionResult.then((result) => {
88
+ if (isRendererTeardownResult(result)) {
89
+ throw new Error(unsupportedManualRendererMessage(name));
90
+ }
54
91
  // If the result is a function, then it returned a React Component (even class components are functions).
55
92
  if (typeof result === 'function') {
56
93
  return createReactElementFromRenderFunctionResult(result, name, props);
@@ -60,7 +97,9 @@ export default function createReactOutput({ componentObj, props, railsContext, d
60
97
  }
61
98
  return createReactElementFromRenderFunctionResult(renderFunctionResult, name, props);
62
99
  }
63
- // else
100
+ if (!isReactComponentType(component)) {
101
+ throw new Error(`Registered component "${name}" must be a function, string, or React object component type.`);
102
+ }
64
103
  return createElement(component, props);
65
104
  }
66
105
  //# sourceMappingURL=createReactOutput.js.map
@@ -1,9 +1,11 @@
1
- import { ReactComponentOrRenderFunction, RenderFunction } from './types/index.ts';
1
+ import type { RegisteredComponentValue, RenderFunction, RendererFunction } from './types/index.ts';
2
+ type AnyRenderFunction = RenderFunction | RendererFunction;
2
3
  /**
3
- * Used to determine we'll call be calling React.createElement on the component of if this is a
4
- * Render-Function used return a function that takes props to return a React element
4
+ * Used to determine whether we'll call React.createElement on the component or if this is a
5
+ * Render-Function used to return a function that takes props to return a React element
5
6
  * @param component
6
7
  * @returns {boolean}
7
8
  */
8
- export default function isRenderFunction(component: ReactComponentOrRenderFunction): component is RenderFunction;
9
+ export default function isRenderFunction(component: RegisteredComponentValue): component is AnyRenderFunction;
10
+ export {};
9
11
  //# sourceMappingURL=isRenderFunction.d.ts.map
@@ -1,21 +1,25 @@
1
1
  /**
2
- * Used to determine we'll call be calling React.createElement on the component of if this is a
3
- * Render-Function used return a function that takes props to return a React element
2
+ * Used to determine whether we'll call React.createElement on the component or if this is a
3
+ * Render-Function used to return a function that takes props to return a React element
4
4
  * @param component
5
5
  * @returns {boolean}
6
6
  */
7
7
  export default function isRenderFunction(component) {
8
+ if (typeof component !== 'function') {
9
+ return false;
10
+ }
11
+ const callableComponent = component;
8
12
  // No for es5 or es6 React Component
9
13
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
10
- if (component.prototype?.isReactComponent) {
14
+ if (callableComponent.prototype?.isReactComponent) {
11
15
  return false;
12
16
  }
13
- if (component.renderFunction) {
17
+ if (callableComponent.renderFunction) {
14
18
  return true;
15
19
  }
16
20
  // If zero or one args, then we know that this is a regular function that will
17
21
  // return a React component
18
- if (component.length >= 2) {
22
+ if (callableComponent.length >= 2) {
19
23
  return true;
20
24
  }
21
25
  return false;
@@ -0,0 +1,3 @@
1
+ import type { RendererTeardownResult } from './types/index.ts';
2
+ export declare function isRendererTeardownResult(value: unknown): value is RendererTeardownResult;
3
+ //# sourceMappingURL=rendererTeardown.d.ts.map
@@ -0,0 +1,9 @@
1
+ // Shared type guard for RendererTeardownResult, extracted so OSS and Pro can import it without
2
+ // duplicating the predicate or coupling Pro to ClientRenderer internals.
3
+ // eslint-disable-next-line import/prefer-default-export -- single-export module; named export keeps the type guard's API tied to its predicate name.
4
+ export function isRendererTeardownResult(value) {
5
+ return (value != null &&
6
+ typeof value === 'object' &&
7
+ typeof value.teardown === 'function');
8
+ }
9
+ //# sourceMappingURL=rendererTeardown.js.map
@@ -1,4 +1,4 @@
1
- import type { RegisteredComponent, RenderingError, FinalHtmlResult } from './types/index.ts';
1
+ import type { RegisteredComponent, RegisteredComponentValue, RenderingError, FinalHtmlResult } from './types/index.ts';
2
2
  /**
3
3
  * Builds the metadata object for the length-prefixed streaming protocol.
4
4
  * This is the shared metadata builder used by both streaming and non-streaming paths.
@@ -20,6 +20,6 @@ export declare function buildRenderMetadata(consoleReplayScript: string, renderS
20
20
  */
21
21
  export declare function buildLengthPrefixedResult(html: FinalHtmlResult | null, consoleReplayScript: string, renderState: RenderMetadataSource): string;
22
22
  export declare function convertToError(e: unknown): Error;
23
- export declare function validateComponent(componentObj: RegisteredComponent, componentName: string): void;
23
+ export declare function validateComponent(componentObj: RegisteredComponent<RegisteredComponentValue>, componentName: string): void;
24
24
  export {};
25
25
  //# sourceMappingURL=serverRenderUtils.d.ts.map
@@ -1,4 +1,4 @@
1
- import type { ReactElement, ReactNode, Component, ComponentType } from 'react';
1
+ import type { ReactElement, ReactNode, Component, ComponentType, ExoticComponent } from 'react';
2
2
  import type { PipeableStream } from 'react-dom/server';
3
3
  import type { Readable } from 'stream';
4
4
  /**
@@ -10,7 +10,7 @@ import type { Readable } from 'stream';
10
10
  type Store = {
11
11
  getState(): unknown;
12
12
  };
13
- type ReactComponent = ComponentType<any> | string;
13
+ type ReactComponent = ComponentType<any> | ExoticComponent<any> | string;
14
14
  export type RailsContext = {
15
15
  componentRegistryTimeout: number;
16
16
  railsEnv: string;
@@ -76,6 +76,68 @@ type CreateReactOutputResult = CreateReactOutputSyncResult | CreateReactOutputAs
76
76
  type RenderFunctionSyncResult = ReactComponent | ServerRenderResult;
77
77
  type RenderFunctionAsyncResult = Promise<string | ServerRenderHashRenderedHtml | ReactComponent | ServerRenderResult>;
78
78
  type RenderFunctionResult = RenderFunctionSyncResult | RenderFunctionAsyncResult;
79
+ type ReactComponentRenderFunctionResult = ReactComponent | Promise<ReactComponent>;
80
+ /**
81
+ * Optional cleanup callback that a renderer function (the 3-argument form
82
+ * `(props, railsContext, domNodeId) => …`) may return inside a {@link RendererTeardownResult}.
83
+ * React on Rails invokes it when the mount is torn down — on Turbo/Turbolinks navigation (the
84
+ * framework's soft-navigation page swap, not a native browser unload) and when the same `domNodeId`
85
+ * node is replaced — so renderer-managed React roots, event listeners, and subscriptions are released
86
+ * instead of leaked. May be synchronous or asynchronous.
87
+ *
88
+ * @see RenderFunction
89
+ */
90
+ type RendererTeardownReturn = void | Promise<void>;
91
+ type RendererTeardown = () => RendererTeardownReturn;
92
+ /**
93
+ * Object wrapper returned by a 3-argument renderer to opt into cleanup. The wrapper keeps teardown
94
+ * detection unambiguous: legacy renderers may have returned function components before this contract
95
+ * existed, so a bare function return is treated as no teardown.
96
+ */
97
+ type RendererTeardownResult = {
98
+ teardown: RendererTeardown;
99
+ };
100
+ /**
101
+ * What the 3-argument renderer form may return for cleanup: nothing, or a
102
+ * {@link RendererTeardownResult}. Runtime cleanup only recognizes this explicit wrapper; legacy
103
+ * component/server-result returns from 3-argument renderers are ignored.
104
+ *
105
+ * Consumers discriminate this union at runtime by an object with a `teardown` function vs. a thenable
106
+ * (an async renderer, awaited/adopted before re-checking) vs. anything else (no teardown). The
107
+ * `void` arm is therefore treated as "no teardown," same as `undefined`.
108
+ */
109
+ type RendererResult = void | RendererTeardownResult | Promise<void | RendererTeardownResult>;
110
+ type RendererFunctionResult = RendererResult | RenderFunctionResult;
111
+ interface RenderFunctionMarker {
112
+ renderFunction?: true;
113
+ }
114
+ /**
115
+ * The precise call signature of the 3-argument "renderer" form `(props, railsContext, domNodeId) =>
116
+ * …`. A renderer owns its own mount and may return nothing or a {@link RendererTeardownResult}
117
+ * (possibly async) to opt into cleanup. It also accepts the legacy {@link RenderFunctionResult}
118
+ * return shapes because older 3-argument renderers sometimes returned a component only to satisfy
119
+ * the old `RenderFunction` type; those non-teardown values are ignored at runtime. Shared by the
120
+ * core and Pro client renderers so the two cannot drift.
121
+ *
122
+ * @returns New renderer code should return `void` or `{ teardown }`. The broader legacy return
123
+ * shapes stay accepted only so existing 3-argument renderers remain type-compatible.
124
+ */
125
+ interface RendererFunction extends RenderFunctionMarker {
126
+ (props?: Record<string, unknown>, railsContext?: RailsContext, domNodeId?: string): RendererFunctionResult;
127
+ }
128
+ /**
129
+ * A render function variant for APIs that require a React component result, such as
130
+ * Pro server-component wrappers. Unlike {@link RenderFunction}, this does not allow
131
+ * server-render hashes/HTML, and unlike {@link RendererFunction}, this does not allow
132
+ * renderer teardown results.
133
+ *
134
+ * Runtime render-function detection still follows the regular React on Rails
135
+ * convention: declare at least two parameters, or set `renderFunction = true`
136
+ * on one-argument functions.
137
+ */
138
+ interface ReactComponentRenderFunction<Props = any> extends RenderFunctionMarker {
139
+ (props?: Props, railsContext?: RailsContext, domNodeId?: string): ReactComponentRenderFunctionResult;
140
+ }
79
141
  type StreamableComponentResult = ReactElement | Promise<ReactElement | string>;
80
142
  type AsyncPropsManager = {
81
143
  getProp: (propName: string) => Promise<unknown>;
@@ -105,17 +167,34 @@ type AsyncPropsManager = {
105
167
  * // Option 2: Using renderFunction property
106
168
  * const anotherRenderFunction = (props) => { ... };
107
169
  * anotherRenderFunction.renderFunction = true;
170
+ *
171
+ * @remarks
172
+ * The 3-argument "renderer" form `(props, railsContext, domNodeId)` owns its own DOM
173
+ * rendering/hydration. Use {@link RendererFunction} for renderers that return nothing or an optional
174
+ * `{ teardown }` wrapper for cleanup. `RenderFunction` still accepts legacy 3-argument renderers
175
+ * that returned a component/server result only to satisfy the old type; React on Rails ignores those
176
+ * return values on the client renderer path.
108
177
  */
109
- interface RenderFunction {
178
+ interface ServerRenderFunction extends RenderFunctionMarker {
179
+ (props?: any, railsContext?: RailsContext): RenderFunctionResult;
180
+ }
181
+ interface LegacyRendererRenderFunction extends RenderFunctionMarker {
110
182
  (props?: any, railsContext?: RailsContext, domNodeId?: string): RenderFunctionResult;
111
- renderFunction?: true;
112
183
  }
113
- type ReactComponentOrRenderFunction = ReactComponent | RenderFunction;
184
+ type RenderFunction = ServerRenderFunction | LegacyRendererRenderFunction;
185
+ type ReactComponentOrRenderFunction = ReactComponent | RenderFunction | RendererFunction;
186
+ type RegisteredComponentValue = ReactComponentOrRenderFunction | Record<string, unknown>;
114
187
  type PipeableOrReadableStream = PipeableStream | NodeJS.ReadableStream;
115
- export type { ReactComponentOrRenderFunction, ReactComponent, AuthenticityHeaders, RenderFunction, RenderFunctionResult, Store, StoreGenerator, CreateReactOutputResult, ServerRenderResult, ServerRenderHashRenderedHtml, CreateReactOutputSyncResult, CreateReactOutputAsyncResult, RenderFunctionSyncResult, RenderFunctionAsyncResult, StreamableComponentResult, PipeableOrReadableStream, };
116
- export interface RegisteredComponent {
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, };
189
+ /**
190
+ * The generic defaults to the pre-object-registration component type so existing consumers that
191
+ * read `registeredComponent.component` stay source-compatible. Use
192
+ * `RegisteredComponent<RegisteredComponentValue>` when handling plain-object server_render_js
193
+ * registrations.
194
+ */
195
+ export interface RegisteredComponent<ComponentValue extends RegisteredComponentValue = ReactComponentOrRenderFunction> {
117
196
  name: string;
118
- component: ReactComponentOrRenderFunction;
197
+ component: ComponentValue;
119
198
  /**
120
199
  * Indicates if the registered component is a RenderFunction
121
200
  * @see RenderFunction for more details on its behavior and usage.
@@ -141,7 +220,7 @@ export interface RSCRenderParams extends Omit<RenderParams, 'railsContext'> {
141
220
  railsContext: RailsContextWithServerStreamingCapabilities;
142
221
  }
143
222
  export interface CreateParams extends Params {
144
- componentObj: RegisteredComponent;
223
+ componentObj: RegisteredComponent<RegisteredComponentValue>;
145
224
  shouldHydrate?: boolean;
146
225
  }
147
226
  export interface ErrorOptions {
@@ -187,7 +266,7 @@ export interface ReactOnRails {
187
266
  * find you components for rendering.
188
267
  * @param components keys are component names, values are components
189
268
  */
190
- register(components: Record<string, ReactComponentOrRenderFunction>): void;
269
+ register(components: Record<string, RegisteredComponentValue>): void;
191
270
  /** @deprecated Use registerStoreGenerators instead */
192
271
  registerStore(stores: Record<string, StoreGenerator>): void;
193
272
  /**
@@ -300,6 +379,17 @@ export interface ReactOnRailsInternal extends ReactOnRails {
300
379
  * ```
301
380
  * under React 18+.
302
381
  *
382
+ * @remarks
383
+ * **Cleanup is the caller's responsibility.** Unlike the components React on Rails mounts itself
384
+ * (which are unmounted automatically on Turbo/Turbolinks navigation and same-id node replacement),
385
+ * a root created by this imperative API is **not** tracked internally. The returned root is handed
386
+ * back to you, and you must call `unmount()` on it yourself — e.g. on a Turbo `turbo:before-render`
387
+ * / Turbolinks `turbolinks:before-render` event, or in your framework's teardown hook — to avoid
388
+ * leaking the root (and any subscriptions or timers it holds) across navigations. If you want
389
+ * automatic cleanup instead, register a renderer function (the 3-argument render-function form) and
390
+ * return a {@link RendererTeardownResult}; React on Rails tracks those mounts and runs the teardown for
391
+ * you.
392
+ *
303
393
  * @param name Name of your registered component
304
394
  * @param props Props to pass to your component
305
395
  * @param domNodeId HTML ID of the node the component will be rendered at
@@ -308,17 +398,17 @@ export interface ReactOnRailsInternal extends ReactOnRails {
308
398
  * (see "What is a root?" in https://github.com/reactwg/react-18/discussions/5).
309
399
  * Under React 16/17: Reference to your component's backing instance or `null` for stateless components.
310
400
  */
311
- render(name: string, props: Record<string, string>, domNodeId: string, hydrate?: boolean): RenderReturnType;
401
+ render(name: string, props: Record<string, unknown>, domNodeId: string, hydrate?: boolean): RenderReturnType;
312
402
  /**
313
403
  * Get the component that you registered
314
404
  * @returns {name, component, renderFunction, isRenderer}
315
405
  */
316
- getComponent(name: string): RegisteredComponent;
406
+ getComponent(name: string): RegisteredComponent<RegisteredComponentValue>;
317
407
  /**
318
408
  * Get the component that you registered, or wait for it to be registered
319
409
  * @returns {name, component, renderFunction, isRenderer}
320
410
  */
321
- getOrWaitForComponent(name: string): Promise<RegisteredComponent>;
411
+ getOrWaitForComponent(name: string): Promise<RegisteredComponent<RegisteredComponentValue>>;
322
412
  /**
323
413
  * Used by server rendering by Rails
324
414
  */
@@ -353,7 +443,7 @@ export interface ReactOnRailsInternal extends ReactOnRails {
353
443
  /**
354
444
  * Get a Map containing all registered components. Useful for debugging.
355
445
  */
356
- registeredComponents(): Map<string, RegisteredComponent>;
446
+ registeredComponents(): Map<string, RegisteredComponent<RegisteredComponentValue>>;
357
447
  /**
358
448
  * Get a Map containing all registered store generators. Useful for debugging.
359
449
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-on-rails",
3
- "version": "17.0.0-rc.1",
3
+ "version": "17.0.0-rc.3",
4
4
  "description": "react-on-rails JavaScript for react_on_rails Ruby gem",
5
5
  "main": "lib/ReactOnRails.full.js",
6
6
  "type": "module",
@@ -43,6 +43,7 @@
43
43
  "./ReactDOMServer": "./lib/ReactDOMServer.cjs",
44
44
  "./serverRenderReactComponent": "./lib/serverRenderReactComponent.js",
45
45
  "./@internal/sanitizeNonce": "./lib/sanitizeNonce.js",
46
+ "./@internal/rendererTeardown": "./lib/rendererTeardown.js",
46
47
  "./@internal/base/client": "./lib/base/client.js",
47
48
  "./@internal/base/full": {
48
49
  "react-server": "./lib/base/full.rsc.js",