Skip to content

Commit a97f367

Browse files
justin808claude
andcommitted
fix(client): guard Pro race-path teardown and log dropped core teardown (#3209)
Addresses PR #3576 review feedback on renderer-function mount cleanup: - Pro ClientSideRenderer: guard the unmount-race teardown call the same way unmount() does, so a synchronously-throwing renderer teardown is logged rather than escaping render()'s outer catch and rejecting renderPromise with a misleading "encountered an error while rendering" error after the component is already unmounted. - Core ClientRenderer: log (instead of silently dropping) an async renderer teardown that resolves after its mount was removed, so the documented best-effort limitation is diagnosable. Label synchronous renderer-teardown failures with the same "Error in renderer teardown:" message as the async path so the logs are greppable. - Mark unmountAllComponents @internal instead of the contradictory @Private. - Export RendererResult alongside RendererTeardown for consumer annotations. - Document why invokeRendererTeardown is intentionally duplicated in Pro. - Tests: add core unmount-race coverage and a Pro throwing-teardown race case; align a Pro test with the underscore-prefix convention. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8dae7f8 commit a97f367

5 files changed

Lines changed: 111 additions & 20 deletions

File tree

packages/react-on-rails-pro/src/ClientSideRenderer.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store';
4040
/**
4141
* Invokes a renderer teardown, swallowing async rejections so a failing teardown cannot produce an
4242
* unhandled promise rejection. Synchronous throws propagate to the caller's try/catch.
43+
*
44+
* Intentionally duplicated from the OSS `react-on-rails` ClientRenderer rather than imported: it is a
45+
* tiny self-contained helper that the OSS module does not export, so duplicating keeps the Pro client
46+
* renderer decoupled from OSS internals (no reliance on a non-public export) instead of widening the
47+
* OSS public API just to share it.
4348
*/
4449
function invokeRendererTeardown(teardown: RendererTeardown | undefined): void {
4550
if (!teardown) return;
@@ -158,8 +163,16 @@ class ComponentRenderer {
158163
// @ts-expect-error The state can change while awaiting delegateToRenderer
159164
if (this.state === 'unmounted') {
160165
// unmount() ran while the renderer was resolving and could not see the teardown yet, so
161-
// run it now to avoid leaking the renderer's mount.
162-
invokeRendererTeardown(delegation.teardown);
166+
// run it now to avoid leaking the renderer's mount. Guard it like unmount() does (below)
167+
// so a synchronously-throwing teardown is logged here rather than escaping to render()'s
168+
// outer catch, which would rethrow it as a misleading "encountered an error while
169+
// rendering" rejection even though the component is already unmounted.
170+
try {
171+
invokeRendererTeardown(delegation.teardown);
172+
} catch (teardownError: unknown) {
173+
const error = teardownError instanceof Error ? teardownError : new Error('Unknown error');
174+
console.error('Error in renderer teardown:', error);
175+
}
163176
} else {
164177
this.rendererTeardown = delegation.teardown;
165178
this.state = 'rendered';

packages/react-on-rails-pro/tests/ClientSideRenderer.test.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -312,15 +312,10 @@ describe('ClientSideRenderer', () => {
312312
it('runs the teardown returned by a renderer when the component is unmounted', async () => {
313313
const teardown = jest.fn();
314314
const TestRenderer: RenderFunction = (
315-
props?: Record<string, unknown>,
316-
railsContext?: RailsContext,
317-
domNodeId?: string,
318-
) => {
319-
void props;
320-
void railsContext;
321-
void domNodeId;
322-
return teardown;
323-
};
315+
_props?: Record<string, unknown>,
316+
_railsContext?: RailsContext,
317+
_domNodeId?: string,
318+
) => teardown;
324319
ComponentRegistry.register({ TestComponent: TestRenderer });
325320
const componentSpec = setupTestComponentDom('dom-id-teardown');
326321
addRailsContext();
@@ -408,4 +403,42 @@ describe('ClientSideRenderer', () => {
408403

409404
expect(teardown).toHaveBeenCalledTimes(1);
410405
});
406+
407+
it('logs (and does not reject) when a raced async renderer teardown throws synchronously', async () => {
408+
// Same race as above, but the resolved teardown throws synchronously. render() must guard the
409+
// call (like unmount() does) so the throw is logged rather than escaping render()'s outer catch,
410+
// which would reject renderPromise with a misleading "encountered an error while rendering" error
411+
// after the component is already unmounted.
412+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
413+
const teardownError = new Error('teardown boom');
414+
const teardown = jest.fn(() => {
415+
throw teardownError;
416+
});
417+
let resolveRenderer!: (value: () => void) => void;
418+
const rendererPromise = new Promise<() => void>((resolve) => {
419+
resolveRenderer = resolve;
420+
});
421+
const TestRenderer: RenderFunction = (
422+
_props?: Record<string, unknown>,
423+
_railsContext?: RailsContext,
424+
_domNodeId?: string,
425+
) => rendererPromise;
426+
ComponentRegistry.register({ TestComponent: TestRenderer });
427+
const componentSpec = setupTestComponentDom('dom-id-teardown-race-throws');
428+
addRailsContext();
429+
430+
const renderPromise = renderOrHydrateComponent(componentSpec);
431+
await new Promise((resolve) => {
432+
setTimeout(resolve, 0);
433+
});
434+
435+
unmountAll();
436+
resolveRenderer(teardown);
437+
438+
// The synchronous throw is caught and logged, so renderPromise resolves rather than rejecting.
439+
await expect(renderPromise).resolves.toBeUndefined();
440+
expect(teardown).toHaveBeenCalledTimes(1);
441+
expect(consoleErrorSpy).toHaveBeenCalledWith('Error in renderer teardown:', teardownError);
442+
consoleErrorSpy.mockRestore();
443+
});
411444
});

packages/react-on-rails/src/ClientRenderer.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,9 @@ DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, ra
119119
* Known limitation (core package): if the mount is unmounted or its node is replaced *before* an
120120
* async renderer resolves its teardown, the still-pending teardown is dropped and that one mount
121121
* leaks — the resolved teardown is discarded because the entry is no longer the active mount for
122-
* this id (the `renderedRoots.get(domNodeId) === entry` guard below). Synchronous teardowns are
123-
* unaffected. The Pro client renderer (`react-on-rails-pro`) awaits the renderer and re-checks the
122+
* this id (the `renderedRoots.get(domNodeId) === entry` guard below). The drop is logged via
123+
* `console.error` so it is diagnosable rather than silent. Synchronous teardowns are unaffected.
124+
* The Pro client renderer (`react-on-rails-pro`) awaits the renderer and re-checks the
124125
* unmount state, so it runs the teardown even when a navigation races the resolve; prefer Pro if you
125126
* depend on async renderer teardowns surviving fast navigations.
126127
*/
@@ -133,10 +134,19 @@ function trackRendererMount(domNodeId: string, domNode: Element, result: unknown
133134
} else if (result && typeof (result as Promise<unknown>).then === 'function') {
134135
(result as Promise<unknown>)
135136
.then((resolved) => {
136-
// Only attach if this exact entry is still the active mount for this id (it may have been
137-
// replaced or unmounted while the renderer was resolving).
138-
if (typeof resolved === 'function' && renderedRoots.get(domNodeId) === entry) {
137+
if (typeof resolved !== 'function') return;
138+
// Only attach if this exact entry is still the active mount for this id.
139+
if (renderedRoots.get(domNodeId) === entry) {
139140
entry.teardown = resolved as RendererTeardown;
141+
} else {
142+
// The mount was unmounted or its node replaced before this async teardown resolved, so the
143+
// entry is no longer the active mount and the teardown can't be attached — it is dropped
144+
// and that one mount may leak on cleanup. Log it (rather than dropping silently) so the
145+
// documented limitation above is diagnosable in production. Pro avoids this race entirely.
146+
console.error(
147+
`Renderer teardown for dom node "${domNodeId}" resolved after its mount was removed; ` +
148+
'the teardown was dropped and that mount may leak on cleanup.',
149+
);
140150
}
141151
})
142152
.catch((error: unknown) => {
@@ -283,14 +293,17 @@ export function reactOnRailsComponentLoaded(domId: string): Promise<void> {
283293
/**
284294
* Unmount all rendered React components, run all renderer-function teardowns, and clear roots.
285295
* This should be called on page unload to prevent memory leaks. Exported for testing.
286-
* @private
296+
* @internal
287297
*/
288298
export function unmountAllComponents(): void {
289299
renderedRoots.forEach((entry) => {
290300
try {
291301
teardownEntry(entry);
292302
} catch (error) {
293-
console.error('Error unmounting component:', error);
303+
// Use the same label as the async-rejection path so renderer-teardown failures are greppable
304+
// whether the teardown threw synchronously (here) or rejected (invokeRendererTeardown).
305+
const label = entry.kind === 'renderer' ? 'Error in renderer teardown:' : 'Error unmounting component:';
306+
console.error(label, error);
294307
}
295308
});
296309
renderedRoots.clear();

packages/react-on-rails/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export type {
227227
AuthenticityHeaders,
228228
RenderFunction,
229229
RendererTeardown,
230+
RendererResult,
230231
RenderFunctionResult,
231232
Store,
232233
StoreGenerator,

packages/react-on-rails/tests/ClientRenderer.test.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,9 @@ describe('ClientRenderer', () => {
286286
expect(() => unmountAllComponents()).not.toThrow();
287287
expect(throwingTeardown).toHaveBeenCalledTimes(1);
288288
expect(okTeardown).toHaveBeenCalledTimes(1);
289-
expect(consoleErrorSpy).toHaveBeenCalledWith('Error unmounting component:', expect.any(Error));
289+
// Renderer-owned teardown failures use the renderer label so they are greppable alongside the
290+
// async-rejection path, regardless of whether the teardown threw synchronously or rejected.
291+
expect(consoleErrorSpy).toHaveBeenCalledWith('Error in renderer teardown:', expect.any(Error));
290292

291293
consoleErrorSpy.mockRestore();
292294
});
@@ -295,6 +297,7 @@ describe('ClientRenderer', () => {
295297
// Documents the core best-effort limitation (Pro handles this race): the first mount's async
296298
// teardown resolves only after the same-id node has been replaced, so it must NOT be attached
297299
// to or run against the new mount.
300+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
298301
const resolvers: Array<(teardown: () => void) => void> = [];
299302
const renderer = jest.fn();
300303
const AsyncRenderer: RenderFunction = (_props, _railsContext, _domNodeId) => {
@@ -322,9 +325,37 @@ describe('ClientRenderer', () => {
322325
resolvers[0](staleTeardown);
323326
await Promise.resolve();
324327

325-
// The stale teardown was not attached to the replaced mount, so cleanup never runs it.
328+
// The stale teardown was not attached to the replaced mount, so cleanup never runs it. The
329+
// drop is logged (not silent) so the documented limitation is diagnosable.
326330
unmountAllComponents();
327331
expect(staleTeardown).not.toHaveBeenCalled();
332+
expect(consoleErrorSpy).toHaveBeenCalledWith(
333+
expect.stringContaining('resolved after its mount was removed'),
334+
);
335+
consoleErrorSpy.mockRestore();
336+
});
337+
338+
it('drops and logs an async teardown when unmount races the renderer resolving', async () => {
339+
// Complements the happy-path async test above: here unmountAllComponents() fires BEFORE the
340+
// renderer's promise resolves, which is the actual race the core package cannot win (Pro does).
341+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
342+
const teardown = jest.fn();
343+
const TestRenderer: RenderFunction = (_props, _railsContext, _domNodeId) => Promise.resolve(teardown);
344+
ComponentRegistry.register({ TestRenderer });
345+
setupRendererDom('renderer-async-race');
346+
347+
renderComponent('renderer-async-race');
348+
// Unmount before the renderer's promise resolves, so the tracked entry is cleared first.
349+
unmountAllComponents();
350+
351+
// Let the renderer's promise resolve; the teardown can no longer attach to the cleared entry.
352+
await Promise.resolve();
353+
354+
expect(teardown).not.toHaveBeenCalled();
355+
expect(consoleErrorSpy).toHaveBeenCalledWith(
356+
expect.stringContaining('resolved after its mount was removed'),
357+
);
358+
consoleErrorSpy.mockRestore();
328359
});
329360

330361
it('logs (and does not rethrow) when an async renderer rejects before returning a teardown', async () => {

0 commit comments

Comments
 (0)