Skip to content

fix: clean up renderer functions on unmount (#3209)#3210

Draft
AbanoubGhadban wants to merge 1 commit intomainfrom
3209-renderer-functions-are-never-cleaned-up-on-unmount
Draft

fix: clean up renderer functions on unmount (#3209)#3210
AbanoubGhadban wants to merge 1 commit intomainfrom
3209-renderer-functions-are-never-cleaned-up-on-unmount

Conversation

@AbanoubGhadban
Copy link
Copy Markdown
Collaborator

Summary

TDD baseline for issue #3209 — renderer functions (3-arg (props, railsContext, domNodeId) => …) are never cleaned up on Turbo navigation or same-id node replacement, leaking the React roots they mount.

This PR is intentionally just the failing tests. The framework implementation (extending RenderFunction's return contract, tracking the teardown in renderedRoots, invoking it on unmount + replaced-node) lands in follow-up commits on this same branch.

  • Unitpackages/react-on-rails/tests/ClientRenderer.test.ts gains an Issue #3209 describe block with a pageLifecycle mock that exposes __triggerPageUnload. Two tests fail today (teardown not invoked on page unload; teardown not invoked when the same domNodeId is replaced); one regression-guard test passes today (renderer returning undefined does not throw on unmount).
  • E2Ereact_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/renderer_cleanup.spec.js exercises both paths through the dummy app. The canary is useEffect cleanup inside the rendered tree — it only fires when root.unmount() actually runs, so today it never fires and the assertion fails.
  • Dummy-app scaffoldingRendererCleanupTest.jsx (renderer returning the proposed teardown), renderer_cleanup_test.html.erb view, route entry, and client-bundle.js registration.

Refs #3209.

Status

  • Failing unit tests written (2 fail with Expected: 1 / Received: 0, 1 passes as guard)
  • Failing e2e tests written (Playwright --list confirms parse across chromium/firefox/webkit)
  • Framework API change (RenderFunction type widening + ClientRenderer tracking)
  • wrapServerComponentRenderer/client.tsx returns its teardown
  • Pro ClientSideRenderer mirror change + Pro test analog
  • In-tree dummy renderer functions converted (12 files — see issue checklist)
  • Docs updated (render-functions.md, view-helpers-api.md)

Test plan

  • Re-run npx jest tests/ClientRenderer.test.ts -t 'Issue #3209' after the framework change — all three tests should pass.
  • Re-run the new Playwright spec against the dummy app after the framework change — both navigation and node-replacement assertions should pass.
  • Confirm the rest of the react-on-rails jest suite stays green (the new pageLifecycle mock is scoped to one file).
  • Manual sanity check: visit /renderer_cleanup_test, click the Turbo link, observe window.__rendererCleanupRan__ === true after navigation.

🤖 Generated with Claude Code

Establishes the TDD baseline for issue #3209. The framework currently
discards anything a renderer function returns, so renderer-mounted React
roots leak across Turbo navigation and same-id node replacement.

Unit (jsdom):
  packages/react-on-rails/tests/ClientRenderer.test.ts gains an
  Issue #3209 describe block with a pageLifecycle mock that exposes
  __triggerPageUnload, plus three tests:
    - teardown invoked on page unload (fails today)
    - teardown invoked on same-id node replacement (fails today)
    - renderer returning undefined does not throw (passes today, regression
      guard for the optional-teardown contract)

E2E (Playwright):
  Adds renderer_cleanup.spec.js exercising both paths through the dummy
  app, with the canary being useEffect cleanup inside the rendered tree
  (only fires when root.unmount() actually runs).

Supporting dummy-app scaffolding:
  RendererCleanupTest.jsx (renderer that returns the proposed teardown),
  renderer_cleanup_test.html.erb view, route, and client-bundle.js
  registration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 25, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a3495f8b-48b8-4541-b6da-e624f93c124d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 3209-renderer-functions-are-never-cleaned-up-on-unmount

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.

@AbanoubGhadban AbanoubGhadban changed the title WIP test(renderer-cleanup): failing TDD tests for #3209 fix: clean up renderer functions on unmount (#3209) Apr 25, 2026
@github-actions
Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.76 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.76 KB (0%)
react-on-rails/client bundled (brotli) 53.86 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.86 KB (0%)
react-on-rails-pro/client bundled (gzip) 63.71 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 63.71 KB (0%)
react-on-rails-pro/client bundled (brotli) 54.63 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 54.72 KB (+0.16% 🔺)
registerServerComponent/client bundled (gzip) 127.53 KB (+0.01% 🔺)
registerServerComponent/client bundled (gzip) (time) 127.53 KB (0%)
registerServerComponent/client bundled (brotli) 61.7 KB (0%)
registerServerComponent/client bundled (brotli) (time) 61.7 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 122.02 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 122.02 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 56.73 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.73 KB (0%)

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 25, 2026

Code Review — PR #3210 (TDD baseline for #3209)

Good TDD discipline: the failing-tests-first approach makes the contract explicit before implementation. The mock design for pageLifecycle is clever and the test structure is easy to follow. A few issues worth addressing before the implementation lands.


🐛 Critical: E2E node-replacement test will fail even after the fix

RendererCleanupTest.jsx resets window.__rendererCleanupRan__ = false at the start of every render call. In the node-replacement path, the framework will (after the fix):

  1. Detect the stale DOM node → call the first renderer's teardown → root.unmount() → useEffect cleanup fires → window.__rendererCleanupRan__ = true
  2. Call RendererCleanupTest(...) on the new node → window.__rendererCleanupRan__ = falseoverwrites the flag

The assertion expect(cleanupRan).toBe(true) will then fail even with a correct implementation. Move the reset to the test's beforeEach, or use a monotonic counter (window.__rendererCleanupCount__++) so overwriting is impossible.


⚠️ Test isolation: unloadCallbacks accumulates but is never reset

The mock factory creates unloadCallbacks once for the entire file. ClientRenderer.ts registers unmountAllComponents into it at module-load time (line 225). That's fine today, but if future tests add code paths that call onPageUnloaded again (e.g., when testing re-initialization), callbacks will double-register and triggerPageUnload will call unmountAllComponents twice — silently incorrect. Adding a reset mechanism or clearing the array in beforeEach would make the fixture more robust.


⚠️ Module state (renderedRoots) not reset after non-unload tests

The "DOM node replaced" unit test never calls triggerPageUnload(), so renderedRoots still contains the renderer-replace entry (with teardown2 armed) when the test ends. The outer beforeEach clears the DOM but doesn't reset renderedRoots. The tests currently use unique DOM IDs so there's no observable interference, but this is a latent isolation gap. Exporting a __clearRenderedRoots test-only hook (or resetting via triggerPageUnload in afterEach) would close it.


📋 Missing: async renderer teardown coverage

The PR description mentions widening RenderFunction's return type to include Promise<RendererTeardown>. None of the three new unit tests exercise that path. Adding a fourth test — renderer returning Promise<teardownFn> — would catch the async unwrapping logic before implementation.


Minor

  • The repeated as unknown as RenderFunction cast appears three times. A single local type alias or a shared cast helper would reduce noise (even if the cast itself is intentional and well-commented).
  • The eslint-disable-next-line no-await-in-loop comment in the mock is correct — sequential awaiting is intentional here — but the comment in the source could briefly note why (callbacks may depend on prior unmounts completing).

What looks good

  • The pageLifecycle mock is well-designed: it captures what the real module does without letting jsdom's lack of Turbo events interfere.
  • Each unit test uses a unique DOM ID (renderer-unload, renderer-replace, renderer-noop), preventing cross-test DOM collisions.
  • The TrackedTree canary pattern (useEffect cleanup as observable signal) is elegant — it proves that root.unmount() actually ran, not just that a teardown function was called.
  • Playwright test structure (Promise.all for URL wait + click) is correct.
  • Comments explaining why tests fail today and what the expected post-fix behavior is are excellent — this is exactly what TDD baselines should document.

};

const RendererCleanupTest = (_props, _railsContext, domNodeId) => {
window.__rendererCleanupRan__ = false;
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: This reset fires on every call to RendererCleanupTest, including the second call that happens after the teardown runs during node replacement. The order of events after the fix will be:

  1. Framework detects stale node → calls first teardown → root.unmount() → useEffect cleanup → window.__rendererCleanupRan__ = true
  2. Framework calls RendererCleanupTest(...) again for the new node → this line resets the flag to false

The E2E node-replacement assertion then reads false and fails even with a correct implementation.

Fix: Remove this line from the renderer and set the flag to false in the beforeEach of the E2E test only (or use a monotonic counter):

Suggested change
window.__rendererCleanupRan__ = false;
// Flag is initialised to false in the test's beforeEach, not here,
// so the value set by the useEffect cleanup isn't overwritten by re-renders.

Comment on lines +31 to +46
jest.mock('../src/pageLifecycle.ts', () => {
const unloadCallbacks: Array<() => void | Promise<void>> = [];
return {
__esModule: true,
onPageUnloaded: (cb: () => void | Promise<void>) => {
unloadCallbacks.push(cb);
},
onPageLoaded: () => {},
__triggerPageUnload: async () => {
for (const cb of [...unloadCallbacks]) {
// eslint-disable-next-line no-await-in-loop
await cb();
}
},
};
});
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 unloadCallbacks array is created once per test file and is never cleared. ClientRenderer.ts registers unmountAllComponents into it at module-load time; that's fine for the current tests. But if any future test path exercises code that calls onPageUnloaded a second time (e.g., testing re-initialization), those callbacks will double-register and triggerPageUnload will fire unmountAllComponents twice — silently incorrect behavior.

Consider adding a reset hook to the mock:

Suggested change
jest.mock('../src/pageLifecycle.ts', () => {
const unloadCallbacks: Array<() => void | Promise<void>> = [];
return {
__esModule: true,
onPageUnloaded: (cb: () => void | Promise<void>) => {
unloadCallbacks.push(cb);
},
onPageLoaded: () => {},
__triggerPageUnload: async () => {
for (const cb of [...unloadCallbacks]) {
// eslint-disable-next-line no-await-in-loop
await cb();
}
},
};
});
jest.mock('../src/pageLifecycle.ts', () => {
let unloadCallbacks: Array<() => void | Promise<void>> = [];
return {
__esModule: true,
onPageUnloaded: (cb: () => void | Promise<void>) => {
unloadCallbacks.push(cb);
},
onPageLoaded: () => {},
__triggerPageUnload: async () => {
for (const cb of [...unloadCallbacks]) {
// eslint-disable-next-line no-await-in-loop
await cb();
}
},
__resetCallbacks: () => {
unloadCallbacks = [];
},
};
});

Then call (pageLifecycle as any).__resetCallbacks() in beforeEach if any test needs a clean slate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant