-
-
Notifications
You must be signed in to change notification settings - Fork 629
fix: clean up renderer functions on unmount (#3209) #3210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7e7e876
dc92c83
8f19855
04a49e4
997564c
527ddc8
bc61d3a
9cc2d8c
589a6c7
eeb054a
9e3d71e
ca263c1
75f2451
612ab21
0606e79
5282e31
d0c7a11
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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) || ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| domNode: Element, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rendererResult: RendererResult, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| componentName: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): void { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+62
to
+66
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
The existing unit test avoids this by explicitly One mitigation: hold a separate |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| domNodeId: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pendingUnmount: Promise<void>, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| render: () => void, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): void { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const queuedRenderRef: { current?: Promise<void> } = {}; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const queuedRender = pendingUnmount.then(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: concurrent renders for the same Each concurrent
Suggested change
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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-renderer replacement takes a different path than renderer replacement: it fires Renderer replacement correctly queues the next render behind |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug:
Suggested fix:
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The sequential
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Register cleanup on page unload | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor: the
| undefinedunion in the cast is redundantRendererResultalready includesundefined, and optional chaining (?.then) already handlesundefinedsafely —undefined?.thenevaluates toundefinedwithout needing the union hint.