Skip to content

Adopt renderer-function teardown in dummy apps + docs (#3578)#3585

Merged
justin808 merged 18 commits into
mainfrom
jg-conductor/3578-implement
Jun 5, 2026
Merged

Adopt renderer-function teardown in dummy apps + docs (#3578)#3585
justin808 merged 18 commits into
mainfrom
jg-conductor/3578-implement

Conversation

@justin808

@justin808 justin808 commented Jun 3, 2026

Copy link
Copy Markdown
Member

Summary

Follow-up to #3209, after PR #3576 (the renderer-function teardown contract) merged into main as 55fcdcf4af0629f32969dfa55f6fd71794ce200f. This adopts the explicit { teardown } wrapper contract across the in-tree dummy renderer functions and documents the optional teardown wrapper return value. None of these change the public runtime contract — they adopt it in-tree and document it, exactly the checklist deferred in #3578.

Retargeted to main. #3576 has landed, so #3585 is now based on main and the head branch has been rebased onto origin/main.

Closes #3578.

1. In-tree dummy renderers → return { teardown }

Each renderer function (3-arg (props, railsContext, domNodeId) form) now captures its React root and returns { teardown: () => ... } so React on Rails unmounts it on Turbo/Turbolinks navigation (page unload) or same-id node replacement instead of leaking it.

Decision: use the explicit { teardown } wrapper, not a bare callback. #3576 made the runtime/type contract explicit so legacy 3-argument render functions that happened to return a function are not misclassified as cleanup callbacks.

OSS modern (react_on_rails/spec/dummy/client/app/startup/):

  • ManualRenderApp.jsx
  • ReduxApp.client.jsxmodule.hot path now reuses the existing root instead of creating a second root
  • ReduxSharedStoreApp.client.jsxmodule.hot path now reuses the existing root instead of creating a second root

OSS legacy React 16 (react_on_rails/spec/dummy/client/app-react16/startup/):

  • ManualRenderApp.jsx, ReduxApp.client.jsx, ReduxSharedStoreApp.client.jsx — teardown unmounts via ReactDOM.unmountComponentAtNode(domNode) (no root handle in the React 16/17 API)

Pro auto-load (react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/):

  • ManualRenderApp.jsx, ReduxApp.client.jsx, ReduxSharedStoreApp.client.jsx, ApolloGraphQLApp.client.jsx, LazyApolloGraphQLApp.client.tsx

Pro loadable:

  • loadable/loadable-client.imports-loadable.jsx — returns a promise resolving to { teardown }, since the root is created inside loadableReady()
  • @loadable/component version confirmation: the Pro dummy app declares ^5.16.3 and the lockfile resolves 5.16.7, above the Promise API floor (5.12.0).

A note on the module.hot paths

The React 18 createRoot renderers (OSS modern Redux apps) now re-render on the same root during hot reload, so HMR no longer leaks a root or calls createRoot twice on the same node. The React 16/17 renderers re-render into the same container idempotently (ReactDOM.render/hydrate), so there is no separate root to unmount first — their HMR path is unchanged, and the teardown handles unload/replacement cleanup.

2. Docs

  • docs/oss/core-concepts/render-functions.md — the LazyHydrate example captures the root and resolves to { teardown }.
  • docs/oss/api-reference/view-helpers-api.md — new "Cleaning up on Turbo/Turbolinks navigation" subsection with a Turbo-navigation example (React 18 + legacy) and the async best-effort caveat.
  • docs/oss/api-reference/javascript-api.md — short reference note on the { teardown } return value.

3. Imperative ReactOnRails.render(...) — decision recorded

Flagged as out of scope in #3209 ("same family of problem; different surface; deferred").

Decision: document the caller's responsibility; do not auto-track. I added a @remarks block to the render() JSDoc in packages/react-on-rails/src/types/index.ts stating that a root created by this imperative API is not tracked internally and the caller must unmount() it themselves (e.g. on turbo:before-render), or use a renderer function for automatic cleanup.

Rationale: auto-tracking these roots in renderedRoots would be a behavior change that risks double-unmount for callers that already manage unmount() themselves, and #3209 explicitly deferred this surface. Documentation-only, no behavior change. If we later want tracked imperative renders, that can be a separate, opt-in API.

Retarget/rebase status

Follow-up

Non-blocking review nits from the final green #3585 pass are tracked in #3689 instead of delaying this PR. The @loadable/component Promise API floor is recorded above, and the legacy React 16 dummy props mutation cleanup is intentionally deferred because #3585 preserves existing fixture behavior while adding teardown.

The intentionally deferred surface remains the imperative ReactOnRails.render(...) API documented below; if tracked imperative roots are desired later, that should be a separate opt-in API issue/PR.

Acceptance criteria

  • All in-tree dummy renderer functions return { teardown } that unmounts their root (and the module.hot createRoot path reuses the previous root instead of creating a new one).
  • Renderer-function docs show the optional { teardown } return value with a Turbo-navigation example.
  • A decision (documentation) is recorded for the imperative ReactOnRails.render(...) path.

Verification

  • Rebased Adopt renderer-function teardown in dummy apps + docs (#3578) #3585 branch onto latest origin/main after fix(client): clean up renderer-function mounts on unmount (#3209) #3576 merged; no conflicts.
  • pnpm exec prettier --check ...changed files... — clean
  • pnpm exec eslint --report-unused-disable-directives ...changed JS/TS files... — clean
  • pnpm --filter react-on-rails run type-check — clean
  • pnpm --filter react-on-rails-pro run type-check — clean
  • pnpm --dir react_on_rails_pro/spec/dummy exec tsc -p tsconfig.json --noEmit --pretty false — no new errors; still reports the 4 pre-existing tests/strict-mode-support.test.tsx unknown-prop errors documented before
  • git diff --check origin/main...HEAD — clean
  • pnpm run lint — currently blocked by unrelated test/shakaperf/rsc-fouc artifact lint errors on main; those files are not touched by this PR.

No CHANGELOG entry: per the project changelog guidelines these are test fixtures + docs + a JSDoc comment (not user-visible behavior), and #3576 already documents the user-facing contract.


Note

Low Risk
Changes are limited to test fixtures, documentation, and JSDoc; public teardown behavior was already introduced in the prior contract PR.

Overview
Adopts the optional { teardown } wrapper from the merged renderer teardown contract across in-tree dummy 3-argument renderer functions, plus documentation and a JSDoc note—no new runtime behavior in this PR.

Dummy apps: OSS React 16 and Pro auto-load/loadable client startups now resolve the mount DOM node up front (throw if missing), capture the React root (or legacy container), and return { teardown: () => root.unmount() } (or a promise that resolves to it for loadableReady). That lets React on Rails unmount renderer-owned trees on Turbo/Turbolinks navigation and same-id node replacement instead of leaking roots.

Docs: render-functions.md and view-helpers-api.md document optional teardown (React 18 + legacy examples, hydrate-vs-render notes, async teardown best-effort in OSS). javascript-api.md briefly mentions the return shape on 3-param renderers.

Types: ReactOnRails.render() JSDoc now states that imperative mounts are not auto-tracked—callers must unmount() themselves or use a renderer function with { teardown } for automatic cleanup.

Reviewed by Cursor Bugbot for commit 200865b. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • Renderer functions and client entrypoints may return optional teardown handlers so mounted React roots are unmounted cleanly during Turbo/Turbolinks navigation or same-id node replacement.
    • Client startups now validate target DOM nodes before hydrating.
  • Documentation

    • Expanded API and core-concepts docs with examples for teardown return values, hydration patterns, and sync/async teardown behavior.
  • Bug Fixes

    • Prevents leaked mounted React roots across navigations and hot-reloads.

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Docs add an optional renderer teardown return; many OSS and Pro dummy renderer/startup entrypoints now capture React roots and return { teardown } handlers that unmount roots on Turbo/Turbolinks navigation or same-id node replacement.

Changes

Renderer teardown contract implementation and adoption

Layer / File(s) Summary
Documentation of renderer teardown contract
docs/oss/core-concepts/render-functions.md, docs/oss/api-reference/view-helpers-api.md, docs/oss/api-reference/javascript-api.md, packages/react-on-rails/src/types/index.ts
Core docs and JSDoc now describe the optional { teardown } (or promise) return from renderer functions and note that imperative render callers must call unmount() themselves.
Legacy React 16/17 renderer teardown adoption
react_on_rails/spec/dummy/client/app-react16/startup/ManualRenderApp.jsx, react_on_rails/spec/dummy/client/app-react16/startup/ReduxApp.client.jsx, react_on_rails/spec/dummy/client/app-react16/startup/ReduxSharedStoreApp.client.jsx
Legacy startup modules cache the DOM node, render into it, and return teardown handlers invoking ReactDOM.unmountComponentAtNode(domNode).
Modern OSS React 18+ renderer teardown adoption
react_on_rails/spec/dummy/client/app/startup/ManualRenderApp.jsx, react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx, react_on_rails/spec/dummy/client/app/startup/ReduxSharedStoreApp.client.jsx
Modern startup modules validate DOM nodes, add hydrateOrRender helpers, persist a root for rerender/HMR reuse, and return { teardown: () => root?.unmount() }.
Enterprise (Pro) renderer teardown adoption
react_on_rails_pro/spec/dummy/client/app/loadable/loadable-client.imports-loadable.jsx, react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/*
Pro entrypoints switch to promise-based loadableReady().then(...) where applicable, validate DOM nodes, capture hydrateRoot/hydrateOrRender roots, and return { teardown: () => root.unmount() }.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Suggested reviewers

  • AbanoubGhadban

Poem

🐇
I nudge the roots to take a bow,
Whisper "unmount" and tidy how.
No stray leaves on Turbo's breeze,
I close the cups and plant the peace. 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Adopt renderer-function teardown in dummy apps + docs' clearly and specifically describes the main change: adopting teardown in dummy apps and updating documentation.
Linked Issues check ✅ Passed All linked issue #3578 objectives are satisfied: all in-tree dummy renderers now return teardowns, HMR paths reuse roots correctly, docs updated with teardown examples, and imperative API responsibility documented.
Out of Scope Changes check ✅ Passed All changes are directly aligned with issue #3578 scope: dummy app updates, documentation updates, and JSDoc clarifications for the teardown contract adoption.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jg-conductor/3578-implement

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx Outdated
@greptile-apps

greptile-apps Bot commented Jun 3, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adopts the { teardown } renderer-function cleanup contract introduced in #3576 across all in-tree dummy renderers and documents it in the API reference and core-concepts pages. No public runtime behavior changes — all changes are spec fixtures, docs, and a JSDoc note on the imperative ReactOnRails.render() API.

  • Spec fixtures (OSS modern, OSS React 16, Pro auto-load, Pro loadable): renderer functions now capture their React root and return { teardown: () => root.unmount() }. React 18 Redux apps also fix HMR to reuse the existing root instead of creating a second one.
  • Docs: render-functions.md, view-helpers-api.md (new Turbo/Turbolinks cleanup section with React 18 + legacy examples and async best-effort caveat), and javascript-api.md all document the optional { teardown } return value and the ReactOnRails.render() caller-responsibility decision.

Confidence Score: 5/5

Safe to merge — only spec fixtures, documentation, and a JSDoc comment; no changes to production runtime paths.

All changed files are either test fixtures (dummy renderers), documentation, or test suites with no modifications to shipped runtime logic beyond what was already established in the parent PR #3576. The one finding is a mismatch between the suggested grep strings in the async-teardown note and the actual log messages in ClientRenderer.ts, which only affects developer debugging ergonomics.

docs/oss/api-reference/view-helpers-api.md — the async-teardown console.error search strings in the [!NOTE] block don't match what ClientRenderer.ts actually logs.

Important Files Changed

Filename Overview
packages/react-on-rails/src/ClientRenderer.ts Major extension: adds renderer teardown tracking (renderedRoots map, trackRendererMount, teardownEntry), changes delegateToRenderer return type, and refactors unmountAllComponents to handle both React roots and renderer-owned mounts cleanly.
packages/react-on-rails-pro/src/ClientSideRenderer.ts Adds rendererOwnedMount + rendererTeardown tracking to ComponentRenderer, updates delegateToRenderer to return teardown, and adds isRenderingDomNode for same-id node replacement detection — mirror of core changes with the Pro advantage of awaiting async renderers.
packages/react-on-rails/src/types/index.ts Adds RendererFunction, RendererTeardown, RendererTeardownResult, and RendererResult types; refactors RenderFunction into ServerRenderFunction
packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx Captures the reactRoot handle and returns { teardown: () => reactRoot.unmount() }, closing the root-leak for all registerServerComponent users; wrapper type updated from RenderFunction to RendererFunction.
react_on_rails_pro/spec/dummy/client/app/loadable/loadable-client.imports-loadable.jsx Converts loadableReady from callback to promise form, captures the hydrateRoot return value, and returns a promise resolving to { teardown } — the async teardown pattern for loadable renderers.
docs/oss/api-reference/view-helpers-api.md Adds Cleaning up on Turbo/Turbolinks navigation section with React 18 and legacy teardown examples, hydration note, and async best-effort caveat; the log-search strings in the async note don't match actual error messages in ClientRenderer.ts.
packages/react-on-rails/tests/ClientRenderer.test.ts Extended with teardown-capture tests covering sync/async returns, race conditions, renderer vs. React-root path isolation, node replacement, and error-swallowing behavior.
packages/react-on-rails-pro/tests/ClientSideRenderer.test.ts Adds comprehensive Pro-specific teardown tests including async-race handling (where Pro wins over core), sync throw during raced teardown, and node-replacement cleanup for both renderer and React-root mounts.

Sequence Diagram

sequenceDiagram
    participant RoR as ReactOnRails (core)
    participant CR as ClientRenderer
    participant Renderer as Renderer Function (3-arg)
    participant Root as React Root
    participant Turbo as Turbo/Turbolinks

    RoR->>CR: renderElement(el, railsContext)
    CR->>CR: delegateToRenderer(componentObj, props, ...)
    CR->>Renderer: renderer(props, railsContext, domNodeId)
    Renderer->>Root: createRoot / hydrateRoot
    Renderer-->>CR: "{ teardown } or Promise<{ teardown }>"
    CR->>CR: trackRendererMount(domNodeId, domNode, result)
    CR->>CR: "renderedRoots.set(domNodeId, { kind: 'renderer', teardown })"

    Note over CR: Page navigation triggered

    Turbo->>CR: unmountAllComponents()
    CR->>CR: renderedRoots.forEach(teardownEntry)
    CR->>Renderer: teardown()
    Renderer->>Root: root.unmount()

    Note over CR: Pro path — async renderer race
    CR->>Renderer: await renderer(...)
    Renderer-->>CR: Promise (pending)
    Turbo->>CR: "unmountAll() — state = 'unmounted'"
    Renderer-->>CR: "Promise resolves { teardown }"
    CR->>Renderer: invokeRendererTeardown(teardown) — runs late teardown
Loading

Reviews (3): Last reviewed commit: "Address renderer teardown review nits" | Re-trigger Greptile

Comment thread docs/oss/api-reference/view-helpers-api.md Outdated
Comment thread docs/oss/core-concepts/render-functions.md Outdated
@claude

claude Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Code Review: Adopt renderer-function teardown in dummy apps + docs (#3578)

Overall: This is a clean, low-risk follow-up. The core teardown contract was established in the stacked PR (#3576); this PR correctly wires it into every in-tree dummy renderer and documents it. No public behavior changes. A few things worth addressing before merge:


Bugs / Correctness

1. HMR + prerender: true path creates a hydration mismatch (OSS modern Redux apps)

In app/startup/ReduxApp.client.jsx and ReduxSharedStoreApp.client.jsx, when HMR fires with prerender: true, renderApp calls root.unmount() first (emptying the DOM node), then calls hydrateRoot(domNode, element) on a now-empty container. React will emit hydration mismatch warnings because there is no SSR markup left to hydrate.

The pre-existing behavior was the opposite problem: calling hydrateRoot again without unmounting first leaks a root. Both are wrong, but in a different direction. The clean HMR fix is to update in-place via root.render(element) rather than unmount-and-recreate — this avoids both the leak and the mismatch:

const renderApp = (Komponent) => {
  const element = wrapElementInStrictMode(...);
  if (root) {
    root.render(element);   // update existing root on HMR; no unmount needed
  } else {
    root = hydrateOrRender(document.getElementById(domNodeId), element);
  }
};

These are spec/dummy fixtures so the real-world impact is low, but the current code swaps one hydration defect for another.


Documentation

2. view-helpers-api.md code example — confusing dual-check pattern

The new example in the "Cleaning up on Turbo/Turbolinks navigation" subsection detects SSR content with domNode.innerHTML twice — once in the ternary to choose hydrateRoot vs createRoot, and again in an if to call root.render(). The logic is correct but unnecessarily opaque. A straightforward if/else is much easier to follow:

let root;
if (domNode.innerHTML) {
  root = ReactDOMClient.hydrateRoot(domNode, <MyComponent {...props} />);
} else {
  root = ReactDOMClient.createRoot(domNode);
  root.render(<MyComponent {...props} />);
}
return () => root.unmount();

Also worth noting: the actual dummy apps use props.prerender (an explicit Rails-set flag) while the docs example uses DOM inspection. props.prerender is more reliable — domNode.innerHTML can be non-empty for reasons other than SSR (e.g. a skeleton loader). Consider either showing props.prerender in the example, or at minimum adding a comment explaining why DOM inspection is used here.

3. render-functions.md table still says => void for renderer functions

The table at the top of render-functions.md still shows (props, railsContext, domNodeId) => void. After this PR the signature should be => void | (() => void) | Promise<...>. (This inconsistency may originate in the base branch #3576 rather than here — worth confirming either way.)


Minor

4. Redundant if (root) guards in teardowns (OSS modern Redux apps)

In the return () => { if (root) { root.unmount(); } } teardowns: root is guaranteed to be set by the time the renderer returns (because renderApp always runs before the return), so the guard is never needed. The simpler return () => root.unmount(); used in ManualRenderApp.jsx is the better pattern. Minor, but inconsistent with the other renderer files in the same PR.


What's done well

  • The React 16/17 files correctly use ReactDOM.unmountComponentAtNode(domNode) (no root handle available) — all needed imports are already present.
  • The loadableReady(callback)loadableReady().then(...) conversion is the right way to surface the teardown as a promise; the return type is correct.
  • Caching document.getElementById(domNodeId) at the top of the renderer instead of calling it inside renderApp on every HMR cycle is a nice improvement.
  • The @remarks block on the imperative ReactOnRails.render() JSDoc is clear and correctly sets expectations without changing behavior.
  • No CHANGELOG entry is appropriate; these are test fixtures, docs, and a JSDoc comment.

Comment thread react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx Outdated
Comment thread react_on_rails/spec/dummy/client/app/startup/ReduxSharedStoreApp.client.jsx Outdated
Comment thread react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx Outdated
Comment thread react_on_rails/spec/dummy/client/app/startup/ReduxSharedStoreApp.client.jsx Outdated
Comment thread docs/oss/api-reference/view-helpers-api.md Outdated
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 61.41 KB (0%)
react-on-rails/client bundled (gzip) (time) 61.41 KB (0%)
react-on-rails/client bundled (brotli) 52.63 KB (0%)
react-on-rails/client bundled (brotli) (time) 52.63 KB (0%)
react-on-rails-pro/client bundled (gzip) 62.55 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 62.55 KB (0%)
react-on-rails-pro/client bundled (brotli) 53.65 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 53.65 KB (0%)
registerServerComponent/client bundled (gzip) 71.89 KB (0%)
registerServerComponent/client bundled (gzip) (time) 71.89 KB (0%)
registerServerComponent/client bundled (brotli) 61.83 KB (0%)
registerServerComponent/client bundled (brotli) (time) 61.83 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 65.54 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 65.54 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 56.23 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.23 KB (0%)

@justin808 justin808 force-pushed the jg-conductor/3209-fix branch from a97f367 to 6e51939 Compare June 3, 2026 23:57
@justin808 justin808 force-pushed the jg-conductor/3578-implement branch from 0b7771b to bae8c50 Compare June 4, 2026 00:02
Comment thread docs/oss/api-reference/view-helpers-api.md
Comment thread docs/oss/core-concepts/render-functions.md
Comment thread react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx Outdated
@claude

claude Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overall: This is a clean, low-risk follow-up that correctly adopts the renderer-function teardown contract across all in-tree dummy apps. The core implementation changes are sound — capturing root handles, returning teardown callbacks, and fixing the double-createRoot / root-leak on HMR. Three issues worth addressing before merge:


🔴 Docs example uses unreliable domNode.innerHTML check

In view-helpers-api.md, the MyRenderer code example decides whether to hydrate vs. fresh-render based on domNode.innerHTML. This is inconsistent with every actual in-tree renderer (all of which use props.prerender) and is fragile in practice:

  • Whitespace or HTML comments in the container make innerHTML truthy even for a client-only mount.
  • The condition is evaluated twice (once in the ternary, once in the if (!domNode.innerHTML) guard), making the logic hard to follow.
  • It teaches readers a different pattern from what the actual spec dummy apps demonstrate.

See the inline suggestion to replace it with the props.prerender pattern used by ManualRenderApp.jsx.


🟡 ReactDOM.hydrateRoot in render-functions.md example — wrong namespace

The LazyHydrate example uses ReactDOM.hydrateRoot(...), but hydrateRoot lives in 'react-dom/client' in React 18, not on the default 'react-dom' export. The namespace ReactDOM implies the legacy import. Without a visible import, a reader copying this snippet would get a runtime TypeError. The in-tree code correctly uses ReactDOMClient — the docs example should match. See inline comment.


🟡 HMR + SSR: unmount-before-rehydrate may warn in React 18

In ReduxApp.client.jsx and ReduxSharedStoreApp.client.jsx, the new renderApp path calls root.unmount() before calling hydrateOrRender — which, when prerender: true, means calling hydrateRoot again on a container that React has already seen. React 18 may log a warning ("You must not call hydrateRoot on a container that has already been passed to...") even after an unmount(). For the HMR case specifically, calling root.render(newElement) on the existing hydrated root is both safer and preserves state across hot reloads. See inline comment on ReduxApp.client.jsx.


✅ What's good

  • Teardown plumbing is consistent across all six OSS and Pro dummy renderers.
  • The loadable-client correctly returns a promise resolving to the teardown, matching the async contract.
  • Extracting domNode outside renderApp in the React 16 variants avoids redundant DOM lookups on each HMR cycle.
  • The JSDoc @remarks block on ReactOnRails.render() accurately documents the caller-owns-cleanup contract without changing behavior.
  • The async-teardown caveat note in the docs is appropriately scoped ("best-effort in OSS, reliable in Pro").
  • No production runtime behavior changes — all changes are spec fixtures, docs, and a JSDoc comment.

justin808 added a commit that referenced this pull request Jun 4, 2026
Apply review feedback to the renderer-function teardown docs (PR #3585):

- view-helpers-api.md: rewrite the React 18 teardown example as an explicit
  if/else (no double innerHTML read) and add an `if (!domNode) throw` guard so
  a missing element yields a named-id error instead of a cryptic null deref;
  add the same guard to the React 16/17 example.
- view-helpers-api.md: note that a dropped async teardown is logged via
  console.error (diagnosable, not silent), matching the core ClientRenderer.
- render-functions.md: use react-dom/client's hydrateRoot (with import) to
  match the view-helpers example instead of the invalid ReactDOM.hydrateRoot,
  and clarify the LazyHydrate teardown is only registered after hydration runs.

Docs-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Code Review: Adopt renderer-function teardown in dummy apps + docs (#3578)

Overall: well-structured follow-up to #3576. Approved with minor notes.

This PR consistently adopts the new teardown contract across all in-tree renderer functions and documents it. No production runtime behavior is changed — all modifications are test fixtures, docs, and a JSDoc block. The pattern is applied uniformly and the code quality is good.


What works well

  • HMR improvement in React 18 Redux apps: The old code would crash with "container has already been passed to createRoot()" when HMR fired during prerender=true. The new code cleanly unmounts the old root before remounting, turning a hard crash into at-most a hydration warning in development.
  • Bug fix in LazyHydrate docs example: The previous version never returned the Promise from the renderer function, so the async teardown was invisible to React on Rails. The new arrow-function-with-implicit-return fixes this. Also fixes the misleading variable name root (was a DOM element, not a React root).
  • Consistent teardown pattern: All 11 files follow the same return () => root.unmount() or return () => ReactDOM.unmountComponentAtNode(domNode) shape. Easy to read and audit.
  • RendererTeardown JSDoc link: The {@link RendererTeardown} in the render() @remarks block correctly resolves to the type defined in this same file.

Issues

Minor: domNode.innerHTML as a hydration heuristic can be unreliable

In the view-helpers-api.md docs example, the code uses if (domNode.innerHTML) to decide whether to call hydrateRoot vs createRoot. This check can silently misfire:

  • A server-rendered component that outputs only whitespace or an empty string will have a falsy innerHTML, causing client-render when hydrate was intended (possible double-render, hydration mismatch warnings suppressed).
  • A client-rendered container that was accidentally pre-filled with content (e.g. from a cached Turbo frame) will have a truthy innerHTML, causing hydrateRoot to be called when there's no server-rendered React tree to hydrate.

The actual dummy apps (correctly) use the prerender prop passed by React on Rails, which is far more reliable. The docs example should either use prerender (matching the pattern users would actually copy) or at minimum call out the heuristic's limitations. See inline comment.

Nit: LazyHydrate comment overstates the "nothing to clean up" case

The comment // becomes visible leaves nothing mounted to clean up is accurate about memory leaks (nothing was hydrated), but a developer will still see a console.error from React on Rails when whenVisible resolves post-navigation (because the entry was removed from renderedRoots before the promise resolved). Worth a one-liner clarification so that error doesn't look like a mystery. See inline comment.

Nit: HMR + prerender=true logs hydration warnings (React 18 Redux apps)

In ReduxApp.client.jsx and ReduxSharedStoreApp.client.jsx (OSS modern), the prerender flag is captured in the closure and carried into HMR re-renders. On HMR reload: the old root is correctly unmounted (clearing the DOM), then hydrateOrRender with prerender=true calls ReactDOMClient.hydrateRoot on the now-empty node. React 18 will log hydration mismatch warnings in dev. This is development-only and is strictly better than the pre-PR crash, so it's not blocking — but a comment noting the tradeoff in renderApp would help future readers.


No concerns on

  • Security: no user-facing code paths changed; teardown is a DOM cleanup operation.
  • Performance: teardown functions are lightweight closures; no performance impact.
  • The loadableReady().then() API change: @loadable/component has supported the Promise form since v5.x. The change is safe.
  • The delete props.prerender pattern in OSS files: pre-existing pattern, unchanged here.

Comment thread docs/oss/api-reference/view-helpers-api.md Outdated
Comment thread docs/oss/core-concepts/render-functions.md Outdated
justin808 added a commit that referenced this pull request Jun 4, 2026
…th props.prerender (#3578)

Address PR #3585 review feedback on the renderer-function teardown changes:

- Redux dummy renderers (ReduxApp.client.jsx, ReduxSharedStoreApp.client.jsx):
  on hot reload, re-render the existing root in place instead of
  unmount-then-rehydrate. Re-creating the root emptied the DOM node and then
  called hydrateRoot on it, producing hydration-mismatch warnings whenever
  prerender was true.
- Drop the dead `if (root)` guard in the returned teardown. renderApp runs
  unconditionally before the teardown is returned, so root is always set;
  this matches ManualRenderApp.jsx.
- view-helpers-api.md: detect SSR via props.prerender (the Rails-set flag used
  by every in-tree renderer) instead of the fragile domNode.innerHTML heuristic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@justin808

Copy link
Copy Markdown
Member Author

Review feedback addressed

Thanks for the reviews. All actionable items (from @cursor[bot], @greptile-apps[bot], and @claude[bot]) are now resolved across two commits:

94a6e1398fix(client): re-render dummy renderers in place on HMR; align docs with props.prerender

  • HMR re-render in place (ReduxApp.client.jsx, ReduxSharedStoreApp.client.jsx): on hot reload, renderApp now calls root.render(element) on the existing root instead of unmount-then-rehydrate. The old path emptied the DOM node and then called hydrateRoot on it, causing hydration-mismatch warnings whenever prerender was true.
  • Dropped the dead if (root) teardown guard in both renderers — root is always set by the unconditional renderApp() call, so the teardown is now return () => root.unmount();, matching ManualRenderApp.jsx.
  • view-helpers-api.md: SSR detection now uses props.prerender (the Rails-set flag every in-tree renderer uses) instead of the fragile domNode.innerHTML heuristic.

1c5814b2fdocs(client): refine renderer-function teardown examples from PR review (earlier)

  • render-functions.md: switched ReactDOM.hydrateRootReactDOMClient.hydrateRoot with an explicit import ReactDOMClient from 'react-dom/client' (the legacy react-dom default export has no hydrateRoot in React 18+).
  • view-helpers-api.md: replaced the confusing double domNode.innerHTML check with a plain if/else and added a missing-DOM-node guard.

All corresponding review threads have been replied to and resolved. The remaining bot posts (size-limit/bencher reports, overall review summaries, coderabbit approval) were status/summary comments with no action required.

@claude

claude Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overview

This follow-up to #3576 adopts the optional renderer-function teardown contract across all in-tree dummy renderers (OSS modern/React16, Pro ror-auto-load, Pro loadable) and documents it in three docs pages. No production runtime behavior changes — this is spec fixtures + docs + a JSDoc addition.

Positive findings

HMR bug fix (OSS modern Redux apps) — the most important correctness improvement in this PR. The previous code called the top-level render closure again on each HMR cycle, which would have re-invoked hydrateRoot on an already-hydrated node (mismatch) or called createRoot on a node that already had a root. The new if (root) { root.render(element) } else { root = hydrateOrRender(...) } pattern correctly reuses the existing root on subsequent HMR fires. This was a real latent bug, not just a style change.

Loadable async teardownloadable-client.imports-loadable.jsx now returns Promise<() => void> (a promise resolving to the teardown), which matches the RendererResult type defined in #3576. The OSS best-effort caveat is properly documented.

React 16/17 teardown — using ReactDOM.unmountComponentAtNode(domNode) is the only correct API in those versions. The code correctly captures domNode before the teardown closure so the right node is unmounted even if the renderer is called multiple times via HMR.

Docs quality — the new "Cleaning up on Turbo/Turbolinks navigation" subsection in view-helpers-api.md is well-scoped, has a React 18 + legacy example pair, and the async best-effort caveat is clear and honest.


Issue: documentation examples leak prerender into component props

The new code example in view-helpers-api.md spreads props directly into <MyComponent {...props} />, which includes the framework-internal prerender flag that React on Rails injects. The comment next to the check even calls it out as "the Rails-set flag", but the example then silently passes it through to the component. This could cause React prop-type warnings or DOM attribute warnings in development if MyComponent renders DOM elements, and it teaches users a leaky pattern.

The same issue exists in the React 16/17 legacy example. The fix is to destructure prerender out before spreading, which the ReduxApp.client.jsx files already model correctly:

const { prerender, ...componentProps } = props;
if (prerender) {
  root = ReactDOMClient.hydrateRoot(domNode, <MyComponent {...componentProps} />);
} else {
  root = ReactDOMClient.createRoot(domNode);
  root.render(<MyComponent {...componentProps} />);
}

See inline comment for the exact location.


Minor observations (no action required)

  • ManualRenderApp.jsx (OSS modern) and legacy equivalents don't delete props.prerender before passing to <HelloWorld {...props} />, but this is a pre-existing spec-fixture issue not introduced here.
  • LazyHydrate in render-functions.md unconditionally calls hydrateRoot (no prerender check). This is intentional — lazy hydration implies server-rendered markup was present — but a short annotation clarifying that assumption would make the example fully self-contained.
  • Async teardown race (loadable): if navigation fires before loadableReady() resolves, document.getElementById(domNodeId) inside the .then() may return null. This is the documented OSS best-effort limitation and is unchanged behavior, but a null check inside the .then() body would make the fixture more robust.

Overall this is a clean, well-structured PR. The HMR fix and teardown adoption are solid, the type definitions from #3576 are correctly used, and the documentation additions are accurate. The one doc change worth making before merge is the prerender prop leak in the example code.

Comment thread docs/oss/api-reference/view-helpers-api.md Outdated
Comment thread docs/oss/api-reference/view-helpers-api.md Outdated
justin808 added a commit that referenced this pull request Jun 4, 2026
…3578)

Address review feedback on the renderer-function teardown docs (PR #3585):

- view-helpers-api.md: the React 18 example led with an `if (props.prerender)`
  hydrate branch and called prerender "the Rails-set flag." prerender is not a
  prop React on Rails injects (the top-level prerender: option only controls SSR
  and is rejected for renderer functions); the in-tree renderers see it only
  because the spec/dummy views merge it into props themselves. Renderer
  functions are also client-only, so the container is empty. Rewrite the example
  to the correct common case (createRoot + render) and move hydration to an
  advanced note that explains you pass your own prop for a server/client split.
- view-helpers-api.md: name the greppable console.error signatures
  ("resolved after its mount was removed" / "Error resolving renderer teardown")
  in the async best-effort note, and reword "(page unload)" to
  "(when the framework swaps in the next page)" since onPageUnloaded fires on
  turbo:before-render, not the browser beforeunload event.
- render-functions.md: note the LazyHydrate concise-body arrow returns its
  whenVisible(...).then(...) promise implicitly, so switching to a block body
  without an explicit return would silently drop the teardown.

Docs-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Code Review overview: This PR adopts the optional renderer-function teardown contract (from #3576) across all in-tree dummy renderers and adds documentation. Low-risk follow-up with no public API changes. Positives include HMR root-reuse bug fix (root.render path for hot reload), consistent unmount teardown pattern across React 16/17/18, safe loadableReady().then() conversion, clear @remarks block in types/index.ts, and good async teardown documentation. Issues: (1) Minor - verbose meta-commentary inside code examples in render-functions.md should move to prose/NOTE callout; (2) Minor - prerender prop name in view-helpers-api.md example collides confusingly with the prerender render option, consider renaming to serverRendered or isHydrating; (3) Informational - teardown callbacks are not asserted by any test (e.g. turbo:before-render simulation), worth a future integration test. Conclusion: Ready to merge after stacked PR (#3576) lands. Doc issues are polish items only.

Comment thread docs/oss/core-concepts/render-functions.md Outdated
@claude

claude Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Code Review — PR #3585 (Adopt renderer-function teardown in dummy apps + docs)

Overall: Well-scoped and well-documented. The teardown contract, HMR root-reuse fix, loadableReady().then() conversion, and JSDoc @remarks addition are all correct. Four minor issues found, two of which have suggested fixes as inline comments.


Issues

1–2. Missing null guards in React 16 Redux renderers (minor, inconsistency)

react_on_rails/spec/dummy/client/app-react16/startup/ReduxApp.client.jsx and ReduxSharedStoreApp.client.jsx both retrieve domNode via getElementById but never null-check it before passing it to render(element, domNode). Every other renderer changed in this PR throws a descriptive error on a missing node. These two fall through to React 16's internal "Target container is not a DOM element" error, which is harder to diagnose in tests. See inline comments for suggested fixes.

3–4. Hardcoded "render" verb in Pro Redux error messages (minor)

react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ReduxApp.client.jsx (line 55) and ReduxSharedStoreApp.client.jsx (line 46) throw "Cannot render …" even when prerender is true (meaning hydrateRoot is called). The prerender variable is in scope via destructuring in both files; replacing the verb with `${prerender ? 'hydrate' : 'render'}` makes the message accurate. Inline suggestions attached.


Looks good

  • HMR root-reuse in the React 18 Redux apps: the let root pattern with root.render(element) on hot reload correctly avoids creating a second root and prevents the hydration mismatch that would occur if hydrateRoot were called on an already-emptied node.
  • loadableReady().then() switch: the callback form loadableReady(() => {...}) discards the callback's return value; switching to .then() and returning the promise is the correct way to surface the teardown wrapper to the runtime.
  • null guard on el inside the .then() callback is correct — navigation may remove the node before chunks resolve, so returning undefined (no teardown) is the right no-op.
  • @remarks block on ReactOnRails.render() cleanly documents the caller-owns-unmount contract without changing behaviour, consistent with the deferred decision from Renderer functions are never cleaned up on unmount/page-unload (memory leak) #3209.
  • RendererTeardownResult {@link} in the JSDoc resolves to the exported type at types/index.ts:169.
  • Documentation additions (view-helpers-api.md, render-functions.md, javascript-api.md) are accurate and the async teardown caveat is correctly scoped to the OSS package.

@claude

claude Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Code Review — PR #3585

Overall: Well-structured, low-risk PR. The implementation is consistent, the HMR fix is correct, and the docs cover the important edge cases. A few things worth calling out below.


Strengths

  • Correct HMR fix in React 18 Redux renderers — tracking a single root variable and re-rendering into it on module.hot instead of calling hydrateRoot/createRoot a second time is the right approach. Previously, HMR would have created a second root on the same node (silently leaking the first).
  • Consistent null guards across all renderer files before any DOM operation — good defensive practice.
  • loadableReady().then() change is semantically correct. The callback form loadableReady(fn) returns undefined and discards whatever fn returns; switching to the promise form allows the teardown wrapper to propagate through. The version-floor caveat (≥5.12.0; lockfile has 5.16.7) is appropriately documented.
  • root?.unmount() is not required — in both Redux renderers, root is always assigned before the return { teardown } line (if renderApp throws, the renderer throws and no teardown is registered). The current code is safe.

Issues

1. Pre-existing console.log in LazyApolloGraphQLApp (not introduced here, but visible in the diff context)

react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/LazyApolloGraphQLApp.client.tsx line 21 has:

console.log('window.__SSR_COMPUTATION_CACHE', window.__SSR_COMPUTATION_CACHE);

This isn't touched by this PR, but since the file is in the diff it's visible. Recommend removing in a follow-up (or here if you want to clean it up).

2. Documentation: serverRendered example flag vs. framework-injected prerender flag

In the new "Hydrating server-rendered markup?" note in view-helpers-api.md, the example introduces a serverRendered prop as a user-supplied flag to distinguish client vs. server render. However, the actual dummy apps (including the ones in this PR) use the framework-injected prerender flag for this same purpose. A reader who has only seen the dummy app source will try props.prerender and may not realize the docs are showing a different, custom pattern.

Suggest adding one sentence clarifying that prerender is already a framework-injected flag in the props (for apps using React on Rails server rendering), so users who want to branch on it can just read props.prerender directly — the serverRendered pattern is for when you want a clearly named semantic alias or want to isolate the renderer-only flag in a way that's explicit.

3. Async teardown undefined return in the loadable path — add a clarifying comment

The !el → return undefined branch in loadable-client.imports-loadable.jsx is correct (no root was mounted, so no teardown needed), but it sits next to code that always returns { teardown } when a root is created. A reader might not immediately know that React on Rails treats a promise resolving to undefined the same as "no teardown". A short inline comment like // No root was mounted; React on Rails treats undefined as "no teardown". would make the intent clear.


Minor nits

  • The repeated teardown comment block across 9+ files is somewhat verbose. Fine for now, but once the contract is fully adopted, a single pointer to the docs in each file would be cleaner.
  • The hydrateOrRender helper extracted in both OSS React 18 Redux renderers is a nice cleanup — it could live in a shared utility if the dummy apps grow further, though in-file is fine for fixtures.

Verdict: Approve with the documentation clarification (item 2) as the only substantive suggestion. The console.log is pre-existing and can be a follow-up. The core implementation is correct and the HMR fix is a genuine improvement.

Comment thread docs/oss/api-reference/view-helpers-api.md
@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Pro Node Renderer Benchmark Summary

Benchmark RPS p50(ms) p90(ms) Status
Pro Node Renderer: simple_eval (non-RSC) 2832.23 🟢 29.9% (2179.88) 3.16 🟢 24.2% (4.17) 4.55 ▼49.8% (9.07) 200=84971
Pro Node Renderer: react_ssr (non-RSC) 2589.47 🟢 34.9% (1920.24) 3.5 🟢 26.1% (4.74) 4.43 ▼51.5% (9.13) 200=77692

▲/▼ non-zero change vs baseline · 0.0% exact/near-zero match · 🔴 significant regression · 🟢 significant improvement (tracked measures) · (n) = baseline

@claude

claude Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overall: LGTM with one minor fix needed. Low-risk follow-up to #3576 that adopts the already-merged teardown contract consistently across all in-tree renderer functions. The approach is correct, the HMR handling is thoughtful, and the documentation additions are clear.


What the PR does

  • All dummy renderer functions (OSS React 18, OSS React 16/17, Pro auto-load, Pro loadable) now return { teardown: () => root.unmount() } so React on Rails can clean up roots on Turbo/Turbolinks navigation or same-id node replacement.
  • Adds a null-guard (throw if DOM node missing) before every mount call.
  • React 18 Redux apps now reuse their existing root on HMR (root.render(element)) instead of calling createRoot a second time, avoiding hydration mismatches.
  • Migrates loadable-client from loadableReady(callback) to loadableReady().then(callback) so the teardown wrapper can propagate through the returned promise.
  • Docs (view-helpers-api.md, render-functions.md, javascript-api.md) add a Turbo/Turbolinks cleanup section with React 18 and legacy examples, plus a @remarks block on the imperative ReactOnRails.render() JSDoc.

Issues

Minor bug — hardcoded "Cannot hydrate" in Pro ManualRenderApp.jsx

react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ManualRenderApp.jsx throws "Cannot hydrate ManualRenderApp…" regardless of whether prerender is true or false, even though the function calls hydrateOrRender() which supports both paths. All other files that have a render/hydrate branch (app-react16/startup/ReduxApp.client.jsx, app-react16/startup/ReduxSharedStoreApp.client.jsx, app/startup/ReduxApp.client.jsx, app/startup/ReduxSharedStoreApp.client.jsx, and Pro ReduxApp.client.jsx / ReduxSharedStoreApp.client.jsx) already use the conditional pattern. Inline suggestion attached.

Low — root.unmount() without null guard in React 18 Redux renderers

In app/startup/ReduxApp.client.jsx and app/startup/ReduxSharedStoreApp.client.jsx, root is declared let root; and assigned inside renderApp(). The teardown closure () => root.unmount() is safe today because renderApp() is called synchronously before the return, but the closure captures root by reference — a future refactor that defers the first renderApp() call would silently produce a TypeError at teardown time. () => root?.unmount() would be zero-cost insurance, though this is a dummy fixture rather than public API.


Positive callouts

  • HMR root reuse in React 18 Redux apps is the correct fix: re-creating a root on an already-mounted node would empty it before hydrateRoot, causing a hydration mismatch. The if (root) { root.render(element); } else { ... } pattern is the right shape.
  • loadableReady().then() migration is well-justified — the callback form discards the return value, making teardown propagation impossible. The @loadable/component >= 5.12.0 Promise API floor is confirmed by the lockfile (5.16.7).
  • undefined return when el is missing in the loadable callback is the right sentinel — it signals "nothing to tear down" without throwing for a race condition that is expected (navigation can remove the node before chunk loading resolves).
  • Removed stray console.log in LazyApolloGraphQLApp.client.tsx — good cleanup.
  • @remarks on ReactOnRails.render() clearly documents the caller-managed cleanup responsibility without changing behavior; the double-unmount risk from auto-tracking is correctly called out.
  • Docs: The async teardown caveat (resolved after its mount was removed / Error resolving renderer teardown) and the note about React on Rails Pro awaiting reliably are exactly the right level of detail for users migrating to this pattern.

justin808 added 2 commits June 5, 2026 11:08
…lement

* origin/main:
  Migrate OSS dummy client examples to TypeScript (#3606)
  Docs: use async props pattern in metadata and render-function examples (#3672)
  Require PR numbers in squash merge titles (#3684)
  Improve create-app smoke diagnostics (#3599)
  Suppress regression issues when only /posts_page: Pro regresses (#3668)
  Harden ShakaPerf artifact setup
  Polish RSC generator install follow-ups
  Harden renderer teardown polish
  fix(benchmark): harden target monitoring
  Docs: clarify rails new JavaScript skip flag (#3666)
  Tighten benchmark summary annotations
  Decouple Shakapacker config helpers from SystemChecker
  Derive Pro RSC client refs from the RSC graph (#3556)
  Docs: follow up v15 anchor and Rails 5.2 flag

# Conflicts:
#	react_on_rails/spec/dummy/client/app/startup/ManualRenderApp.jsx
#	react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx
#	react_on_rails/spec/dummy/client/app/startup/ReduxSharedStoreApp.client.jsx
@claude

claude Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overview

This PR correctly adopts the { teardown } wrapper contract across the React 16 dummy fixtures, Pro auto-load and loadable renderers, and adds solid documentation. The implementation is consistent and the async teardown note in the docs is accurate.


Critical: OSS modern startup files still leak roots after this PR

The PR description lists three OSS modern startup files as updated (react_on_rails/spec/dummy/client/app/startup/: ManualRenderApp.jsx, ReduxApp.client.jsx, ReduxSharedStoreApp.client.jsx), but none appear in the diff. After PR #3606 landed on main and renamed them to .tsx, those teardown changes appear to have been dropped during the rebase.

Current state on this branch (unchanged from main):

  • ManualRenderApp.tsx line 11: type RendererFunction = (...) => void — no teardown returned
  • ReduxApp.client.tsx line 18: ): void { — no teardown returned
  • ReduxSharedStoreApp.client.tsx: ): void { — no teardown returned

These three renderers will continue to leak their React roots on Turbo/Turbolinks navigation after this PR merges. The domRenderers.ts WeakMap handles HMR root reuse but does not expose a teardown to React on Rails. The OSS modern fixtures need to be updated before or alongside this PR.


Medium: loadableReady().then() — implicit @loadable/component >= 5.12.0 dependency

Converting from loadableReady(callback) to loadableReady().then(callback) is the right approach for returning a teardown promise, and the lockfile confirms 5.16.7. However, if the dependency is downgraded below 5.12.0, loadableReady() returns undefined and .then() crashes silently at runtime. A one-line comment naming the minimum version would make the constraint visible to future maintainers.


Minor: RendererFunction type annotation in ManualRenderApp.tsx

The explicit => void return type will need to change to RendererResult (or void | RendererTeardownResult) when teardown is added — otherwise TypeScript rejects the { teardown } return.


Positive notes

  • DOM-node null-check + descriptive error message applied consistently across all changed files.
  • The module.hot root-reuse fix (caching domNode before hot-replace) is correct for both Redux dummy renderers.
  • { teardown: () => ReactDOM.unmountComponentAtNode(domNode) } is the correct React 16/17 teardown API.
  • The async teardown caveat in view-helpers-api.md (log message to grep for, "best-effort in OSS") is accurate and important.
  • The @remarks block on ReactOnRails.render() cleanly documents caller responsibility for the deferred imperative-API surface.
  • The concise-arrow-body note in render-functions.md (add return when switching to block body) correctly documents a real footgun.

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Pro (shard 1/2) Benchmark Summary

Benchmark RPS p50(ms) p90(ms) Status
/: Pro 213.93 ▲21.1% (176.61) 29.05 ▼30.4% (41.72) 50.21 ▼20.0% (62.74) 200=6469
/error_scenarios_hub: Pro 371.52 ▲5.1% (353.37) 15.4 ▼23.1% (20.02) 37.92 ▲20.8% (31.4) 200=11224
/ssr_async_error: Pro 348.23 ▲4.8% (332.18) 16.51 ▼19.7% (20.55) 42.6 ▲25.0% (34.09) 200=10523
/ssr_async_prop_error: Pro 395.69 ▲22.5% (322.97) 19.49 ▼10.7% (21.83) 31.81 ▼13.4% (36.73) 200=11962
/non_existing_react_component: Pro 419.67 ▲22.4% (342.99) 18.6 ▼8.1% (20.23) 26.99 ▼23.1% (35.1) 200=12680
/non_existing_rsc_payload: Pro 425.21 ▲18.8% (358.06) 17.89 ▼11.2% (20.14) 30.31 ▼15.7% (35.96) 200=12847
/cached_react_helmet: Pro 371.67 ▲0.4% (370.19) 15.35 ▼21.1% (19.45) 51.34 ▲69.1% (30.36) 200=11229
/cached_redux_component: Pro 445.83 ▲15.4% (386.4) 17.66 ▼7.4% (19.06) 27.1 ▼13.8% (31.43) 200=13468
/lazy_apollo_graphql: Pro 168.58 ▲12.1% (150.35) 51.38 ▲6.8% (48.09) 73.06 ▼9.8% (80.97) 200=5094
/redis_receiver: Pro 110.02 ▲26.0% (87.31) 47.21 ▼30.0% (67.4) 95.91 ▼34.5% (146.48) 200=3356
/stream_shell_error_demo: Pro 347.22 ▲4.0% (333.81) 16.25 ▼21.0% (20.58) 20.35 ▼41.1% (34.55) 200=10561
/test_incremental_rendering: Pro 404.35 ▲19.1% (339.64) 18.7 ▼11.2% (21.07) 29.05 ▼20.2% (36.38) 200=12219
/rsc_posts_page_over_redis: Pro 115.91 ▲8.9% (106.48) 64.46 ▲3.0% (62.58) 101.96 ▼6.3% (108.77) 200=3505
/async_on_server_sync_on_client: Pro 371.55 ▲17.4% (316.48) 20.99 ▼5.4% (22.19) 31.29 ▼23.2% (40.75) 200=11156
/server_router: Pro 375.91 ▲13.7% (330.75) 14.87 ▼30.1% (21.27) 28.17 ▼20.0% (35.22) 200=11362
/unwrapped_rsc_route_client_render: Pro 434.18 ▲16.2% (373.72) 17.85 ▼9.2% (19.66) 27.92 ▼9.1% (30.73) 200=13117
/async_render_function_returns_string: Pro 389.9 ▲13.7% (342.91) 19.65 ▼5.1% (20.71) 32.53 ▼3.8% (33.83) 200=11781
/async_components_demo: Pro 204.08 ▲0.1% (203.93) 24.08 ▼33.9% (36.41) 31.17 ▼39.5% (51.55) 200=6212
/stream_native_metadata: Pro 383.22 ▲13.6% (337.34) 19.66 ▼6.5% (21.03) 30.65 ▼12.7% (35.11) 200=11604
/rsc_native_metadata: Pro 378.6 ▲15.6% (327.64) 20.26 ▼9.0% (22.27) 30.79 ▼13.9% (35.75) 200=11441
/client_side_hello_world: Pro 377.38 ▲4.5% (361.08) 14.93 ▼24.1% (19.68) 17.9 ▼41.3% (30.51) 200=11477
/client_side_hello_world_shared_store_controller: Pro 409.52 ▲20.1% (340.99) 13.37 ▼36.0% (20.89) 25.68 ▼20.5% (32.31) 200=12380
/server_side_hello_world_shared_store: Pro 352.56 ▲22.0% (288.96) 22.8 ▼11.4% (25.72) 32.73 ▼15.3% (38.66) 200=10654
/server_side_hello_world_shared_store_defer: Pro 286.97 ▼1.0% (290.0) 19.87 ▼23.1% (25.83) 79.33 ▲111.1% (37.57) 200=8672
/server_side_hello_world_hooks: Pro 412.37 ▲18.5% (348.07) 20.79 ▲1.0% (20.59) 30.37 ▼14.0% (35.33) 200=12457
/server_side_log_throw: Pro 409.49 ▲18.7% (344.97) 19.06 ▼11.3% (21.48) 27.61 ▼20.2% (34.6) 200=12370
/server_side_log_throw_raise: Pro 802.27 ▲22.6% (654.13) 9.66 ▼13.9% (11.22) 17.02 ▼6.2% (18.14) 3xx=24236
/server_side_hello_world_es5: Pro 323.06 ▼5.1% (340.31) 17.9 ▼15.6% (21.21) 53.33 ▲55.3% (34.33) 200=9760
/server_side_hello_world_with_options: Pro 394.16 ▲18.1% (333.74) 19.67 ▼8.7% (21.55) 29.03 ▼14.4% (33.9) 200=11912
/client_side_manual_render: Pro 457.65 ▲25.0% (366.07) 16.23 ▼16.1% (19.35) 25.41 ▼16.2% (30.32) 200=13828
/react_router: Pro 498.25 ▲26.9% (392.53) 14.76 ▼16.3% (17.64) 24.98 ▼15.2% (29.45) 200=15053
/css_modules_images_fonts_example: Pro 394.49 ▲15.6% (341.24) 22.01 ▲3.0% (21.37) 29.6 ▼11.4% (33.39) 200=11918
/rendered_html: Pro 340.32 ▼0.7% (342.87) 16.54 ▼22.0% (21.21) 20.74 ▼37.1% (32.96) 200=10353
/react_helmet: Pro 382.77 ▲13.8% (336.45) 20.27 ▼8.2% (22.09) 30.0 ▼9.4% (33.11) 200=11565
/image_example: Pro 337.4 ▲2.8% (328.18) 16.62 ▼25.3% (22.26) 20.84 ▼40.1% (34.8) 200=10262
/posts_page: Pro 101.28 ▼84.3% (645.24) 78.16 ▲172.8% (28.65) 112.87 ▲176.5% (40.81) 200=3065

▲/▼ non-zero change vs baseline · 0.0% exact/near-zero match · 🔴 significant regression · 🟢 significant improvement (tracked measures) · (n) = baseline

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Core Benchmark Summary

Benchmark RPS p50(ms) p90(ms) Status
/: Core 3.54 🟢 28.5% (2.75) 1734.36 ▼35.8% (2700.96) 2659.83 ▼26.4% (3615.3) 200=117
/client_side_hello_world: Core 796.92 ▲21.7% (654.62) 7.91 ▼14.4% (9.24) 19.91 ▼10.5% (22.24) 200=23916
/client_side_rescript_hello_world: Core 749.33 ▲10.6% (677.69) 8.15 ▼10.8% (9.13) 19.5 ▼8.9% (21.4) 200=22637
/client_side_hello_world_shared_store: Core 505.57 ▼20.2% (633.91) 11.35 ▲15.6% (9.82) 19.72 ▼12.9% (22.64) 200=15274
/client_side_hello_world_shared_store_controller: Core 709.3 ▲13.6% (624.36) 8.55 ▼13.3% (9.86) 18.23 ▼22.2% (23.42) 200=21456
/client_side_hello_world_shared_store_defer: Core 748.94 ▲17.9% (635.33) 8.35 ▼15.7% (9.91) 21.19 ▼4.5% (22.2) 200=22625
/server_side_hello_world_shared_store: Core 15.31 ▲29.1% (11.86) 558.12 ▼16.4% (667.23) 714.29 ▼15.4% (844.5) 200=470
/server_side_hello_world_shared_store_controller: Core 15.29 ▲29.3% (11.83) 546.79 ▼14.3% (638.02) 702.74 ▼16.4% (841.06) 200=473
/server_side_hello_world_shared_store_defer: Core 15.38 🟢 30.9% (11.75) 412.05 ▼35.6% (640.13) 651.29 ▼24.5% (863.03) 200=475
/server_side_hello_world: Core 24.54 ▲1.8% (24.09) 246.26 ▼21.9% (315.3) 320.96 ▼18.5% (393.77) 200=748
/server_side_hello_world_hooks: Core 31.05 ▲30.5% (23.79) 267.81 ▼15.4% (316.62) 305.47 ▼22.9% (396.45) 200=946
/server_side_hello_world_props: Core 24.3 ▲2.0% (23.82) 246.18 ▼24.1% (324.4) 296.28 ▼24.6% (393.01) 200=744
/client_side_log_throw: Core 759.03 ▲14.0% (665.89) 8.05 ▼15.3% (9.5) 19.43 ▼1.1% (19.66) 200=22931
/server_side_log_throw: Core 30.39 ▲29.5% (23.46) 283.18 ▼14.8% (332.51) 320.35 ▼20.4% (402.28) 200=925
/server_side_log_throw_plain_js: Core 30.73 ▲29.7% (23.68) 278.23 ▼10.9% (312.31) 318.95 ▼20.0% (398.91) 200=935
/server_side_log_throw_raise: Core 25.95 ▲9.3% (23.75) 227.8 ▼27.6% (314.51) 248.12 ▼37.5% (397.07) 3xx=794
/server_side_log_throw_raise_invoker: Core 924.12 ▲19.5% (773.53) 7.49 ▼12.6% (8.57) 15.5 ▼5.3% (16.36) 200=27916
/server_side_hello_world_es5: Core 31.11 ▲32.5% (23.48) 293.88 ▼8.1% (319.89) 317.34 ▼20.3% (398.23) 200=944
/server_side_redux_app: Core 30.13 🟢 30.1% (23.16) 279.3 ▼15.6% (331.11) 329.1 ▼18.7% (404.75) 200=915
/server_side_hello_world_with_options: Core 23.94 ▼0.3% (24.01) 250.06 ▼21.9% (320.35) 356.82 ▼9.8% (395.39) 200=728
/server_side_redux_app_cached: Core 737.15 ▲14.9% (641.31) 9.45 ▼9.1% (10.4) 18.21 ▼1.7% (18.53) 200=22271
/client_side_manual_render: Core 743.14 ▲7.4% (691.62) 8.46 ▼8.5% (9.24) 17.22 ▼11.3% (19.41) 200=22454
/render_js: Core 33.12 ▲33.3% (24.85) 256.66 ▼16.5% (307.44) 290.11 ▼23.5% (379.4) 200=1007
/react_router: Core 29.23 ▲29.2% (22.63) 289.0 ▼17.1% (348.77) 329.56 ▼20.7% (415.37) 200=892
/pure_component: Core 31.38 ▲28.3% (24.46) 268.76 ▼16.2% (320.82) 306.73 ▼22.8% (397.56) 200=957
/css_modules_images_fonts_example: Core 31.15 ▲31.6% (23.68) 278.2 ▼12.2% (316.83) 313.71 ▼21.0% (397.02) 200=945
/turbolinks_cache_disabled: Core 527.53 ▼21.1% (668.37) 10.82 ▲13.9% (9.5) 16.6 ▼15.4% (19.62) 200=15940
/rendered_html: Core 31.1 ▲31.8% (23.59) 104.41 🟢 67.7% (323.51) 294.91 ▼26.2% (399.75) 200=951
/xhr_refresh: Core 15.97 🟢 29.2% (12.36) 391.5 ▼36.5% (616.56) 627.24 ▼25.3% (839.94) 200=492
/react_helmet: Core 30.58 ▲29.3% (23.65) 276.86 ▼14.6% (324.22) 317.68 ▼21.0% (402.21) 200=931
/broken_app: Core 25.28 ▲7.5% (23.51) 234.21 ▼27.4% (322.44) 307.5 ▼22.3% (395.61) 200=770
/image_example: Core 30.55 ▲31.9% (23.17) 278.47 ▼14.5% (325.64) 318.19 ▼20.1% (398.16) 200=930
/turbo_frame_tag_hello_world: Core 817.91 ▲12.4% (727.51) 7.89 ▼10.1% (8.78) 15.7 ▼13.0% (18.04) 200=24710
/manual_render_test: Core 531.45 ▼20.6% (668.92) 10.74 ▲14.2% (9.4) 18.31 ▼6.9% (19.67) 200=16056

▲/▼ non-zero change vs baseline · 0.0% exact/near-zero match · 🔴 significant regression · 🟢 significant improvement (tracked measures) · (n) = baseline

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Pro (shard 2/2) Benchmark Summary

Benchmark RPS p50(ms) p90(ms) Status
/empty: Pro 1294.16 ▲4.9% (1233.75) 6.25 ▲7.2% (5.83) 8.52 ▼4.2% (8.89) 200=39092
/ssr_shell_error: Pro 378.34 ▲14.7% (329.8) 20.56 ▼6.4% (21.96) 32.63 ▼7.1% (35.13) 200=11431
/ssr_sync_error: Pro 377.48 ▲15.6% (326.58) 14.67 ▼32.9% (21.86) 27.47 ▼25.7% (36.97) 200=11482
/rsc_component_error: Pro 374.18 ▲14.5% (326.84) 20.26 ▼9.4% (22.37) 31.27 ▼12.6% (35.77) 200=11309
/non_existing_stream_react_component: Pro 387.32 ▲14.9% (337.23) 19.54 ▼7.4% (21.1) 30.34 ▼12.1% (34.52) 200=11705
/server_side_redux_app_cached: Pro 335.91 ▼8.1% (365.44) 17.22 ▼16.4% (20.6) 46.64 ▲43.8% (32.44) 200=10150
/loadable: Pro 353.15 ▲18.0% (299.31) 22.7 ▼7.4% (24.53) 35.56 ▼0.7% (35.83) 200=10671
/apollo_graphql: Pro 151.95 ▲9.1% (139.31) 56.88 ▲10.9% (51.27) 80.57 ▼5.8% (85.51) 200=4592
/console_logs_in_async_server: Pro 2.75 ▼15.2% (3.24) 2119.96 ▼0.1% (2122.07) 2159.88 ▼1.9% (2202.07) 200=94
/stream_error_demo: Pro 364.96 ▲12.9% (323.17) 20.77 ▼5.2% (21.9) 32.29 ▼10.4% (36.06) 200=11028
/stream_async_components: Pro 366.03 ▲12.5% (325.39) 21.17 ▼5.4% (22.38) 34.23 ▼7.6% (37.06) 200=11062
/rsc_posts_page_over_http: Pro 358.37 ▲10.8% (323.32) 15.41 ▼31.5% (22.49) 29.3 ▼18.0% (35.73) 200=10902
/rsc_echo_props: Pro 247.4 ▲8.9% (227.19) 32.6 ▼1.5% (33.09) 50.33 ▲2.1% (49.32) 200=7474
/async_on_server_sync_on_client_client_render: Pro 393.91 ▲13.0% (348.64) 19.36 ▼7.0% (20.83) 32.13 ▼5.9% (34.15) 200=11902
/server_router_client_render: Pro 395.77 ▲14.1% (346.71) 19.37 ▼6.9% (20.8) 30.8 ▼4.2% (32.15) 200=11957
/unwrapped_rsc_route_stream_render: Pro 381.1 ▲14.1% (333.88) 19.67 ▼6.9% (21.12) 34.7 ▼6.1% (36.94) 200=11516
/async_render_function_returns_component: Pro 312.16 ▼4.4% (326.67) 18.53 ▼13.0% (21.29) 31.36 ▼9.7% (34.74) 200=9434
/native_metadata: Pro 358.41 ▲8.6% (330.07) 21.95 ▼1.2% (22.22) 34.38 ▼2.7% (35.32) 200=10830
/hybrid_metadata_streaming: Pro 337.22 ▲2.1% (330.38) 18.88 ▼14.2% (22.01) 30.95 ▼16.9% (37.24) 200=10193
/cache_demo: Pro 331.7 ▲3.3% (321.02) 23.19 ▲2.4% (22.64) 35.57 ▼6.6% (38.07) 200=10023
/client_side_hello_world_shared_store: Pro 305.67 ▼7.1% (329.01) 18.58 ▼15.0% (21.87) 22.2 ▼33.7% (33.49) 200=9297
/client_side_hello_world_shared_store_defer: Pro 344.93 ▲4.4% (330.49) 14.01 ▼37.3% (22.35) 18.94 ▼44.3% (34.02) 200=10495
/server_side_hello_world_shared_store_controller: Pro 260.97 ▼5.2% (275.31) 21.87 ▼17.2% (26.42) 60.39 ▲48.1% (40.78) 200=7886
/server_side_hello_world: Pro 379.54 ▲18.2% (321.13) 20.9 ▼6.2% (22.28) 32.46 ▼8.2% (35.34) 200=11468
/client_side_log_throw: Pro 425.71 ▲16.4% (365.83) 18.19 ▼10.1% (20.22) 26.71 ▼12.8% (30.63) 200=12781
/server_side_log_throw_plain_js: Pro 343.05 ▼7.0% (368.92) 16.83 ▼14.6% (19.71) 44.06 ▲39.3% (31.62) 200=10366
/server_side_log_throw_raise_invoker: Pro 411.15 ▲2.3% (401.83) 5.99 ▼65.7% (17.45) 16.09 ▼42.8% (28.13) 200=12509
/server_side_redux_app: Pro 381.05 ▲17.5% (324.21) 20.88 ▼6.2% (22.26) 32.19 ▼8.6% (35.2) 200=11512
/server_side_redux_app_cached: Pro 417.4 ▲14.2% (365.44) 18.85 ▼8.5% (20.6) 26.84 ▼17.3% (32.44) 200=12611
/render_js: Pro 424.2 ▲15.2% (368.27) 20.28 ▲2.0% (19.88) 27.54 ▼13.4% (31.8) 200=12815
/pure_component: Pro 390.18 ▲18.8% (328.43) 20.25 ▼7.4% (21.87) 31.47 ▼16.3% (37.59) 200=11797
/turbolinks_cache_disabled: Pro 348.82 ▼3.3% (360.87) 16.5 ▼17.9% (20.09) 42.48 ▲35.1% (31.45) 200=10540
/xhr_refresh: Pro 333.58 ▲20.2% (277.59) 23.79 ▼9.1% (26.16) 34.61 ▼9.8% (38.39) 200=10082
/broken_app: Pro 316.93 ▼7.7% (343.37) 18.2 ▼15.7% (21.59) 28.87 ▼18.6% (35.47) 200=9578
/server_render_with_timeout: Pro 387.93 ▲13.5% (341.74) 16.2 ▼26.2% (21.94) 26.96 ▼19.7% (33.59) 200=11724

▲/▼ non-zero change vs baseline · 0.0% exact/near-zero match · 🔴 significant regression · 🟢 significant improvement (tracked measures) · (n) = baseline

@justin808 justin808 merged commit 0ebdff0 into main Jun 5, 2026
56 checks passed
@justin808 justin808 deleted the jg-conductor/3578-implement branch June 5, 2026 21:53
justin808 added a commit that referenced this pull request Jun 5, 2026
…cessing-flow

* origin/main:
  Add maintainer +ci commands for full-CI dispatch and waivers (#3687)
  Adopt renderer-function teardown in dummy apps + docs (#3578) (#3585)
  Migrate OSS dummy client examples to TypeScript (#3606)
  Docs: use async props pattern in metadata and render-function examples (#3672)
  Require PR numbers in squash merge titles (#3684)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Renderer-function teardown: convert in-tree dummy renderers + docs (follow-up to #3209)

1 participant