fix(react-router): use reRender callback in getChildrenToRender to clean up stale views

This commit is contained in:
ShaneK
2026-03-11 07:56:25 -07:00
parent 7196910b1b
commit cfed206e28
5 changed files with 81 additions and 3 deletions

View File

@@ -339,7 +339,7 @@ export class ReactRouterViewStack extends ViewStacks {
* - Wraps the route element in <Routes> to support nested routing and ensure remounting
* - Adds a unique key to <Routes> so React Router remounts routes when switching
*/
private renderViewItem = (viewItem: ViewItem, routeInfo: RouteInfo, parentPath?: string) => {
private renderViewItem = (viewItem: ViewItem, routeInfo: RouteInfo, parentPath?: string, reRender?: () => void) => {
const routePath = viewItem.reactElement.props.path || '';
let match = matchComponent(viewItem.reactElement, routeInfo.pathname);
@@ -383,6 +383,7 @@ export class ReactRouterViewStack extends ViewStacks {
// This ensures the redirect completes before removal
setTimeout(() => {
this.remove(viewItem);
reRender?.();
}, NAVIGATE_REDIRECT_DELAY_MS);
}
}
@@ -409,6 +410,7 @@ export class ReactRouterViewStack extends ViewStacks {
const stillNotNeeded = !viewItem.mount && !viewItem.ionPageElement;
if (stillNotNeeded) {
this.remove(viewItem);
reRender?.();
}
}, VIEW_CLEANUP_DELAY_MS);
} else {
@@ -522,7 +524,12 @@ export class ReactRouterViewStack extends ViewStacks {
* 3. Returns a list of React components that will be rendered inside the outlet
* Each view is wrapped in <ViewLifeCycleManager> to manage lifecycle and rendering
*/
getChildrenToRender = (outletId: string, ionRouterOutlet: React.ReactElement, routeInfo: RouteInfo) => {
getChildrenToRender = (
outletId: string,
ionRouterOutlet: React.ReactElement,
routeInfo: RouteInfo,
reRender: () => void
) => {
const viewItems = this.getViewItemsForOutlet(outletId);
// Determine parentPath for nested outlets to properly evaluate index routes
@@ -621,6 +628,7 @@ export class ReactRouterViewStack extends ViewStacks {
// View is outside current route hierarchy, remove it
setTimeout(() => {
this.remove(viewItem);
reRender();
}, 0);
return false;
}
@@ -630,7 +638,9 @@ export class ReactRouterViewStack extends ViewStacks {
return true;
});
const renderedItems = renderableViewItems.map((viewItem) => this.renderViewItem(viewItem, routeInfo, parentPath));
const renderedItems = renderableViewItems.map((viewItem) =>
this.renderViewItem(viewItem, routeInfo, parentPath, reRender)
);
return renderedItems;
};

View File

@@ -49,6 +49,7 @@ import ContentChangeNavigation from './pages/content-change-navigation/ContentCh
import SearchParams from './pages/search-params/SearchParams';
import IonRoutePropsTest from './pages/ion-route-props/IonRouteProps';
import PrefixMatchWildcard from './pages/prefix-match-wildcard/PrefixMatchWildcard';
import StaleViewCleanup from './pages/stale-view-cleanup/StaleViewCleanup';
setupIonicReact();
@@ -87,6 +88,7 @@ const App: React.FC = () => {
<Route path="/search-params" element={<SearchParams />} />
<Route path="/ion-route-props/*" element={<IonRoutePropsTest />} />
<Route path="/prefix-match-wildcard/*" element={<PrefixMatchWildcard />} />
<Route path="/stale-view-cleanup/*" element={<StaleViewCleanup />} />
</IonRouterOutlet>
</IonReactRouter>
</IonApp>

View File

@@ -92,6 +92,9 @@ const Main: React.FC = () => {
<IonItem routerLink="/prefix-match-wildcard">
<IonLabel>Prefix Match Wildcard</IonLabel>
</IonItem>
<IonItem routerLink="/stale-view-cleanup/non-ionpage">
<IonLabel>Stale View Cleanup</IonLabel>
</IonItem>
</IonList>
</IonContent>
</IonPage>

View File

@@ -0,0 +1,39 @@
import { IonRouterOutlet } from '@ionic/react';
import React from 'react';
import { Route, useNavigate } from 'react-router-dom';
/**
* A component without IonPage wrapper.
* When navigated away from, this should be cleaned up from the DOM.
*/
const NonIonPageSource: React.FC = () => {
const navigate = useNavigate();
return (
<div data-testid="non-ionpage-source">
<h1>Non-IonPage Source</h1>
<button id="go-to-target" onClick={() => navigate('/stale-view-cleanup/target')}>
Go to Target
</button>
</div>
);
};
const NonIonPageTarget: React.FC = () => {
return (
<div data-testid="non-ionpage-target">
<div data-testid="target-loaded">Target page loaded</div>
</div>
);
};
const StaleViewCleanup: React.FC = () => {
return (
<IonRouterOutlet>
<Route path="non-ionpage" element={<NonIonPageSource />} />
<Route path="target" element={<NonIonPageTarget />} />
</IonRouterOutlet>
);
};
export default StaleViewCleanup;

View File

@@ -0,0 +1,24 @@
const port = 3000;
describe('Stale View Cleanup', () => {
/**
* Tests that non-IonPage components are properly cleaned up from the DOM
* after navigating away. Both source and target lack IonPage wrappers.
* Guards against regressions in the view stack cleanup logic.
*/
it('should clean up stale non-IonPage view after navigating to another non-IonPage view', () => {
cy.visit(`http://localhost:${port}/stale-view-cleanup/non-ionpage`);
// Verify the non-IonPage source component is visible
cy.get('[data-testid="non-ionpage-source"]').should('exist');
// Navigate to the target (also non-IonPage)
cy.get('#go-to-target').click();
// Verify the target loaded
cy.get('[data-testid="target-loaded"]').should('exist');
// The non-IonPage source component should be cleaned up from the DOM
cy.get('[data-testid="non-ionpage-source"]').should('not.exist');
});
});