Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ
#### Changed

- **[Pro]** **Pro generator now creates the Node Renderer at `renderer/node-renderer.js`**: The canonical location for the Node Renderer entry point is now a dedicated top-level `renderer/` directory instead of `client/`, making it straightforward to exclude from production Docker builds that strip JS sources after bundling. Docs and Pro `spec/dummy` now use the new path consistently. Existing apps are unaffected — the generator skips files that already exist (including a legacy `client/node-renderer.js`). Fixes [Issue 3073](https://github.com/shakacode/react_on_rails/issues/3073). [PR 3165](https://github.com/shakacode/react_on_rails/pull/3165) by [justin808](https://github.com/justin808).
- **Turbo cleanup now runs before Turbo snapshots pages**: React on Rails now unmounts on `turbo:before-cache` instead of `turbo:before-render`, preventing live React DOM from being frozen into Turbo's snapshot cache. Apps with custom `turbo:before-render` cleanup workarounds may need to remove those workarounds to avoid double cleanup. [PR 3210](https://github.com/shakacode/react_on_rails/pull/3210) by [AbanoubGhadban](https://github.com/AbanoubGhadban).

#### Fixed

- **Renderer functions now clean up on Turbo unload and same-id replacement**: Renderer functions that return teardown callbacks are now cleaned up when the page unloads or when the same `domNodeId` is replaced, including async renderer teardown promises, preventing leaked roots during Turbo caching and dynamic HTML replacement. [PR 3210](https://github.com/shakacode/react_on_rails/pull/3210) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
- **[Pro]** **Node renderer now exposes `performance` when `supportModules: true`**: React 19's development build of `React.lazy` calls `performance.now()`, which previously threw `ReferenceError: performance is not defined` inside the node renderer's VM context unless users manually added `performance` via `additionalContext`. `performance` is now included in the default globals alongside `Buffer`, `process`, etc. Fixes [Issue 3154](https://github.com/shakacode/react_on_rails/issues/3154). [PR 3158](https://github.com/shakacode/react_on_rails/pull/3158) by [justin808](https://github.com/justin808).
- **Client startup now recovers if initialization begins during `interactive` after `DOMContentLoaded` already fired**: React on Rails now still initializes the page when the client bundle starts in the browser timing window after `DOMContentLoaded` but before the document reaches `complete`. Fixes [Issue 3150](https://github.com/shakacode/react_on_rails/issues/3150). [PR 3151](https://github.com/shakacode/react_on_rails/pull/3151) by [ihabadham](https://github.com/ihabadham).
- **Doctor accepts TypeScript server bundle entrypoints**: `react_on_rails:doctor` now resolves common source entrypoint suffixes (`.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs`) before warning that the server bundle is missing, preventing false positives when apps use `server-bundle.ts`. [PR 3111](https://github.com/shakacode/react_on_rails/pull/3111) by [justin808](https://github.com/justin808).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import * as React from 'react';
import * as ReactDOMClient from 'react-dom/client';
import { ReactComponentOrRenderFunction, RenderFunction } from 'react-on-rails/types';
import type { ReactComponent, ReactComponentOrRenderFunction, RenderFunction } from 'react-on-rails/types';
import isRenderFunction from 'react-on-rails/isRenderFunction';
import { ensureReactUseAvailable } from 'react-on-rails/reactApis';
import { createRSCProvider } from '../RSCProvider.tsx';
Expand Down Expand Up @@ -52,7 +52,7 @@ const wrapServerComponentRenderer = (
const wrapper: RenderFunction = async (props, railsContext, domNodeId) => {
const Component = isRenderFunction(componentOrRenderFunction)
? await componentOrRenderFunction(props, railsContext, domNodeId)
: componentOrRenderFunction;
: (componentOrRenderFunction as ReactComponent);

if (typeof Component !== 'function') {
throw new Error(`wrapServerComponentRenderer: component '${componentName}' is not a function`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
*/

import * as React from 'react';
import type { RenderFunction, ReactComponentOrRenderFunction } from 'react-on-rails/types';
import type { ReactComponent, ReactComponentOrRenderFunction, RenderFunction } from 'react-on-rails/types';
import isRenderFunction from 'react-on-rails/isRenderFunction';
import { assertRailsContextWithServerStreamingCapabilities } from 'react-on-rails/types';
import getReactServerComponent from '../getReactServerComponent.server.ts';
Expand Down Expand Up @@ -69,7 +69,7 @@ const wrapServerComponentRenderer = (

const Component = isRenderFunction(componentOrRenderFunction)
? await componentOrRenderFunction(props, railsContext)
: componentOrRenderFunction;
: (componentOrRenderFunction as ReactComponent);

if (typeof Component !== 'function') {
throw new Error(`wrapServerComponentRenderer: component '${componentName}' is not a function`);
Expand Down
179 changes: 143 additions & 36 deletions packages/react-on-rails/src/ClientRenderer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { ReactElement } from 'react';
import type { RegisteredComponent, RailsContext, RenderReturnType } from './types/index.ts';
import type {
RegisteredComponent,
RailsContext,
RendererFunction,
RendererResolvedResult,
RendererResult,
RendererTeardown,
RenderReturnType,
} from './types/index.ts';
import ComponentRegistry from './ComponentRegistry.ts';
import StoreRegistry from './StoreRegistry.ts';
import createReactOutput from './createReactOutput.ts';
Expand All @@ -11,8 +19,17 @@ import { supportsRootApi, unmountComponentAtNode } from './reactApis.cts';

const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store';

// Track all rendered roots for cleanup
const renderedRoots = new Map<string, { root: RenderReturnType; domNode: Element }>();
type RenderedRoot = {
domNode: Element;
root?: RenderReturnType;
teardown?: RendererTeardown;
pendingTeardown?: Promise<RendererResolvedResult>;
isRenderer?: true;
};

// Track all rendered roots and renderer-function teardowns for cleanup
const renderedRoots = new Map<string, RenderedRoot>();
const pendingUnmounts = new Map<string, Promise<void>>();

function initializeStore(el: Element, railsContext: RailsContext): void {
const name = el.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || '';
Expand All @@ -33,11 +50,75 @@ function domNodeIdForEl(el: Element): string {
return el.getAttribute('data-dom-id') || '';
}

function isRendererTeardown(value: unknown): value is RendererTeardown {
return typeof value === 'function';
}

function isPromiseLikeRendererResult(value: RendererResult): value is PromiseLike<RendererResolvedResult> {
return typeof (value as { then?: unknown })?.then === 'function';
}

function trackRendererTeardown(
domNodeId: string,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the | undefined union in the cast is redundant

RendererResult already includes undefined, and optional chaining (?.then) already handles undefined safely — undefined?.then evaluates to undefined without needing the union hint.

Suggested change
domNodeId: string,
function isPromiseLikeRendererResult(
value: RendererResult,
): value is PromiseLike<undefined | RendererTeardown> {
return typeof (value as { then?: unknown })?.then === 'function';
}

domNode: Element,
rendererResult: RendererResult,
componentName: string,
): void {
Comment on lines +62 to +66

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition: async teardown silently lost on early page unload.

If Turbo fires before-cache before an async renderer's promise resolves:

  1. unmountAllComponents runs → renderedRoots.clear() wipes the entry.
  2. The promise later resolves → storeTeardown calls renderedRoots.get(domNodeId)undefined.
  3. The domNode identity check fails → teardown is never stored, never called.
  4. The React root leaks silently.

The existing unit test avoids this by explicitly awaiting a setTimeout(resolve, 0) before triggering unload, so it doesn't exercise the race.

One mitigation: hold a separate WeakMap<Element, RendererTeardown> keyed by the DOM node, and in unmountRenderedRoot fall back to it when teardown is not yet set. Another option: if an entry is still marked isRenderer with no teardown when unload fires, schedule the teardown call for after the promise resolves (best-effort). Worth at least a // KNOWN LIMITATION comment so future contributors understand the gap.

const renderedRoot: RenderedRoot = { domNode, isRenderer: true };
renderedRoots.set(domNodeId, renderedRoot);

const storeTeardown = (resolvedResult: RendererResolvedResult): RendererResolvedResult => {
if (!isRendererTeardown(resolvedResult)) return undefined;

// Cache for synchronous unmounts and keep pendingTeardown resolving to the
// same function for async renderer results.
renderedRoot.teardown = resolvedResult;
renderedRoot.pendingTeardown = undefined;
return resolvedResult;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After renderedRoot.teardown is set, renderedRoot.pendingTeardown still holds the now-settled promise. unmountRenderedRoot correctly hits the fast path via teardown first, but the settled promise stays reachable through the RenderedRoot object until the map entry is cleared at unmount time. Clearing it here keeps the object's state unambiguous:

Suggested change
return resolvedResult;
renderedRoot.teardown = resolvedResult;
renderedRoot.pendingTeardown = undefined;
return resolvedResult;

};

if (isPromiseLikeRendererResult(rendererResult)) {
renderedRoot.pendingTeardown = Promise.resolve(rendererResult)
.then(storeTeardown)
.catch((error: unknown) => {
console.error(`Error resolving renderer teardown for component ${componentName}:`, error);
return undefined;
});
return;
}

storeTeardown(rendererResult);
}

async function unmountRenderedRoot(renderedRoot: RenderedRoot): Promise<void> {
const { root, domNode, isRenderer } = renderedRoot;
const teardown =
renderedRoot.teardown ?? (renderedRoot.pendingTeardown ? await renderedRoot.pendingTeardown : undefined);

if (teardown) {
await teardown();
return;
}

// Renderer functions own their mounted tree. Without a returned teardown,
// the framework has no React root or DOM contract it can safely unmount.
if (isRenderer) return;

if (supportsRootApi && root && typeof root === 'object' && 'unmount' in root) {
// React 18+ Root API
root.unmount();
} else {
// React 16-17 legacy API
unmountComponentAtNode(domNode);
}
}

function delegateToRenderer(
componentObj: RegisteredComponent,
props: Record<string, unknown>,
railsContext: RailsContext,
domNodeId: string,
domNode: Element,
trace: boolean,
): boolean {
const { name, component, isRenderer } = componentObj;
Expand All @@ -53,17 +134,36 @@ DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, ra
}

// Call the renderer function with the expected signature
(component as (props: Record<string, unknown>, railsContext: RailsContext, domNodeId: string) => void)(
props,
railsContext,
domNodeId,
);
const rendererResult = (component as RendererFunction)(props, railsContext, domNodeId);
trackRendererTeardown(domNodeId, domNode, rendererResult, name);
return true;
}

return false;
}

function queueRenderAfterPendingUnmount(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The queuedRenderRef box exists so the .then callback can compare itself against the latest queued render for this domNodeId, enabling coalescing of rapid replacements. The trick is safe because .then callbacks are always scheduled asynchronously — queuedRenderRef.current is set on the very next line before any callback can fire. Worth a one-line comment here so future readers don't "simplify" it away:

Suggested change
function queueRenderAfterPendingUnmount(
function queueRenderAfterPendingUnmount(
domNodeId: string,
pendingUnmount: Promise<void>,
render: () => void,
): void {
// queuedRenderRef lets the .then callback identify whether it is still the
// *latest* queued render for this domNodeId. Because .then callbacks are
// always async, queuedRenderRef.current is assigned before the callback fires.
const queuedRenderRef: { current?: Promise<void> } = {};

domNodeId: string,
pendingUnmount: Promise<void>,
render: () => void,
): void {
const queuedRenderRef: { current?: Promise<void> } = {};
const queuedRender = pendingUnmount.then(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The queuedRender variable is referenced inside its own .then() callback before the assignment on line 154 completes. This is valid JavaScript — the closure captures queuedRender by reference, and the .then() callback only executes asynchronously, by which time the const is fully initialized. Worth a short comment to prevent future readers from flagging it as a temporal dead zone bug.

Suggested change
const queuedRender = pendingUnmount.then(() => {
// queuedRender is captured by reference in the callback. The callback only
// runs asynchronously, so it is fully initialized by the time it fires.
const queuedRender = pendingUnmount.then(() => {
if (pendingUnmounts.get(domNodeId) !== queuedRender) return;

if (pendingUnmounts.get(domNodeId) !== queuedRenderRef.current) return;

pendingUnmounts.delete(domNodeId);
render();
});
queuedRenderRef.current = queuedRender;

pendingUnmounts.set(domNodeId, queuedRender);
void queuedRender.catch(() => {
if (pendingUnmounts.get(domNodeId) === queuedRender) {
pendingUnmounts.delete(domNodeId);
}
});
}

/**
* Used for client rendering by ReactOnRails. Either calls ReactDOM.hydrate, ReactDOM.render, or
* delegates to a renderer registered by the user.
Expand All @@ -78,6 +178,12 @@ function renderElement(el: Element, railsContext: RailsContext): void {
try {
const domNode = document.getElementById(domNodeId);
if (domNode) {
const pendingUnmount = pendingUnmounts.get(domNodeId);
if (pendingUnmount) {
queueRenderAfterPendingUnmount(domNodeId, pendingUnmount, () => renderElement(el, railsContext));
return;
Comment on lines +181 to +184

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: concurrent renders for the same domNodeId can stack and all fire at once

Each concurrent renderElement call chains onto the same original pendingUnmount promise without updating the map. When pendingUnmount settles all queued callbacks fire simultaneously, potentially mounting the component multiple times.

Suggested change
const pendingUnmount = pendingUnmounts.get(domNodeId);
if (pendingUnmount) {
void pendingUnmount.then(() => renderElement(el, railsContext));
return;
const pendingUnmount = pendingUnmounts.get(domNodeId);
if (pendingUnmount) {
const next = pendingUnmount.then(() => renderElement(el, railsContext));
pendingUnmounts.set(domNodeId, next);
return;
}

This way each queued call chains off the latest promise, serialising them correctly.

}

// Check if this component was already rendered by a previous call
// This prevents hydration errors when reactOnRailsPageLoaded() is called multiple times
// (e.g., for asynchronously loaded content)
Expand All @@ -94,28 +200,31 @@ function renderElement(el: Element, railsContext: RailsContext): void {
return;
}
// DOM node was replaced (e.g., via async HTML injection) - clean up the old root
try {
if (
supportsRootApi &&
existing.root &&
typeof existing.root === 'object' &&
'unmount' in existing.root
) {
existing.root.unmount();
} else {
unmountComponentAtNode(existing.domNode);
}
} catch (unmountError) {
renderedRoots.delete(domNodeId);
if (existing.isRenderer) {
const replacementUnmount = unmountRenderedRoot(existing).catch((unmountError: unknown) => {
// Ignore unmount errors for replaced nodes
if (trace) {
console.log(`Error unmounting replaced component: ${name}`, unmountError);
}
});

queueRenderAfterPendingUnmount(domNodeId, replacementUnmount, () =>
renderElement(el, railsContext),
);
return;
}

void unmountRenderedRoot(existing).catch((unmountError: unknown) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fire-and-forget. For a sync renderer the teardown runs within the microtask chain before trackRendererTeardown (line 137) registers the new entry, so there's no hazard. For an async renderer (pendingTeardown is still unresolved) the new renderer is registered immediately below while the old teardown is still pending. If the old teardown eventually calls root.unmount() it can silently destroy the newly-mounted tree.

Either document that async-renderer + same-id replacement is unsupported, or await the old teardown before proceeding:

// For async renderers, await the old teardown before mounting the new one.
await unmountRenderedRoot(existing).catch((unmountError: unknown) => {
  if (trace) {
    console.log(`Error unmounting replaced component: ${name}`, unmountError);
  }
});
renderedRoots.delete(domNodeId);

That requires making renderElement async (or extracting a renderElementAsync path for the replacement case).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For non-renderer components the unmount is fire-and-forget while the renderer path above (lines 201–211) correctly queues the next render after teardown completes. This is safe today because root.unmount() (React 18+) and unmountComponentAtNode (React 16/17) are both synchronous. A comment explaining that assumption would protect against a future async step in unmountRenderedRoot inadvertently introducing a race here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asymmetry: non-renderer replacement is fire-and-forget while renderer replacement gates the next render.

Lines 200–211 correctly queue the re-render until the renderer's async teardown resolves. Here for non-renderers the old unmount is also async (due to the await undefined issue noted on line 93), but the code does not queue the re-render — it falls through immediately to reactHydrateOrRender. As noted above the actual DOM conflict is avoided because the two calls target different physical nodes, but the intent and the behaviour are in tension. If the async-unmount behaviour on line 93 is intentional, this path should also gate or be documented explicitly as intentionally concurrent. If line 93 is corrected to be synchronous for non-renderers, this becomes a non-issue.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-renderer replacement takes a different path than renderer replacement: it fires unmountRenderedRoot fire-and-forget and falls through to mount immediately. Because unmountRenderedRoot is async, even a synchronous root.unmount() inside it yields to the microtask queue before returning — so a second concurrent renderElement call for this domNodeId could race in before the unmount completes.

Renderer replacement correctly queues the next render behind replacementUnmount (line 212). Non-renderer replacement should either do the same, or include an explicit comment explaining why the race is benign (e.g. React 18's root.unmount() is effectively synchronous at the React commit boundary, so the DOM is clean before any re-render can observe it).

// Ignore unmount errors for replaced nodes
if (trace) {
console.log(`Error unmounting replaced component: ${name}`, unmountError);
}
}
renderedRoots.delete(domNodeId);
});
}

const componentObj = ComponentRegistry.get(name);
if (delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) {
if (delegateToRenderer(componentObj, props, railsContext, domNodeId, domNode, trace)) {
return;
}

Expand Down Expand Up @@ -204,21 +313,19 @@ export function reactOnRailsComponentLoaded(domId: string): Promise<void> {
* Unmount all rendered React components and clear roots.
* This should be called on page unload to prevent memory leaks.
*/
function unmountAllComponents(): void {
renderedRoots.forEach(({ root, domNode }) => {
try {
if (supportsRootApi && root && typeof root === 'object' && 'unmount' in root) {
// React 18+ Root API
root.unmount();
} else {
// React 16-17 legacy API
unmountComponentAtNode(domNode);
}
} catch (error) {
console.error('Error unmounting component:', error);
async function unmountAllComponents(): Promise<void> {
const renderedRootEntries = [...renderedRoots.values()];
renderedRoots.clear();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: pendingUnmounts is not cleared on page unload.

renderedRoots.clear() runs here, but pendingUnmounts is left intact. If a renderer with a slow async teardown was mid-replacement when turbo:before-cache fires, its queued render callback still holds a live closure over el and railsContext from the previous page. When the teardown eventually resolves on the new page, the coalescing identity check can save us only if the new page happened to queue a new render under the same domNodeId first — there's no guarantee of that ordering.

Suggested fix:

Suggested change
renderedRoots.clear();
const renderedRootEntries = [...renderedRoots.values()];
renderedRoots.clear();
pendingUnmounts.clear();

pendingUnmounts.clear();

const results = await Promise.allSettled(
renderedRootEntries.map((renderedRoot) => unmountRenderedRoot(renderedRoot)),
);
results.forEach((result) => {
if (result.status === 'rejected') {
console.error('Error unmounting component:', result.reason);
}
});
renderedRoots.clear();
}
Comment on lines +316 to 329

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sequential await serialises independent teardowns and triggers an eslint disable. Since teardowns for different roots are unrelated, Promise.allSettled is a better fit and removes the lint exception:

Suggested change
async function unmountAllComponents(): Promise<void> {
const renderedRootEntries = [...renderedRoots.values()];
renderedRoots.clear();
for (const renderedRoot of renderedRootEntries) {
try {
if (supportsRootApi && root && typeof root === 'object' && 'unmount' in root) {
// React 18+ Root API
root.unmount();
} else {
// React 16-17 legacy API
unmountComponentAtNode(domNode);
}
// eslint-disable-next-line no-await-in-loop
await unmountRenderedRoot(renderedRoot);
} catch (error) {
console.error('Error unmounting component:', error);
}
});
renderedRoots.clear();
}
}
async function unmountAllComponents(): Promise<void> {
const renderedRootEntries = [...renderedRoots.values()];
renderedRoots.clear();
const results = await Promise.allSettled(renderedRootEntries.map((r) => unmountRenderedRoot(r)));
results.forEach((result) => {
if (result.status === 'rejected') {
console.error('Error unmounting component:', result.reason);
}
});
}


// Register cleanup on page unload
Expand Down
4 changes: 4 additions & 0 deletions packages/react-on-rails/src/capabilities/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
import * as Authenticity from '../Authenticity.ts';
import buildConsoleReplay, { consoleReplay } from '../buildConsoleReplay.ts';
import reactHydrateOrRender from '../reactHydrateOrRender.ts';
import { refreshPageEventListeners } from '../pageLifecycle.ts';
import createReactOutput from '../createReactOutput.ts';

const DEFAULT_OPTIONS = {
Expand Down Expand Up @@ -73,6 +74,9 @@ export function createCoreCapability(registries: Registries) {

if (typeof turbo !== 'undefined') {
this.options.turbo = turbo;
if (turbo) {
refreshPageEventListeners();
}
}

if (typeof debugMode !== 'undefined') {
Expand Down
Loading
Loading