fix(react-router): guarding against potential change of UNSAFE APIs)

This commit is contained in:
ShaneK
2026-03-12 17:50:30 -07:00
parent 84ae705351
commit bceeff65f4
5 changed files with 227 additions and 2 deletions

View File

@@ -42,8 +42,8 @@
"peerDependencies": {
"react": ">=16.8.6",
"react-dom": ">=16.8.6",
"react-router": ">=6.0.0",
"react-router-dom": ">=6.0.0"
"react-router": ">=6.4.0 <7",
"react-router-dom": ">=6.4.0 <7"
},
"devDependencies": {
"@ionic/eslint-config": "^0.3.0",

View File

@@ -54,6 +54,7 @@ import IndexParamPriority from './pages/index-param-priority/IndexParamPriority'
import IndexRouteReuse from './pages/index-route-reuse/IndexRouteReuse';
import TailSliceAmbiguity from './pages/tail-slice-ambiguity/TailSliceAmbiguity';
import WildcardNoHeuristic from './pages/wildcard-no-heuristic/WildcardNoHeuristic';
import RouteContextShape from './pages/route-context-shape/RouteContextShape';
setupIonicReact();
@@ -97,6 +98,7 @@ const App: React.FC = () => {
<Route path="/index-route-reuse/*" element={<IndexRouteReuse />} />
<Route path="/tail-slice-ambiguity/*" element={<TailSliceAmbiguity />} />
<Route path="/wildcard-no-heuristic/*" element={<WildcardNoHeuristic />} />
<Route path="/route-context-shape/*" element={<RouteContextShape />} />
</IonRouterOutlet>
</IonReactRouter>
</IonApp>

View File

@@ -107,6 +107,9 @@ const Main: React.FC = () => {
<IonItem routerLink="/wildcard-no-heuristic">
<IonLabel>Wildcard No Heuristic</IonLabel>
</IonItem>
<IonItem routerLink="/route-context-shape">
<IonLabel>Route Context Shape</IonLabel>
</IonItem>
</IonList>
</IonContent>
</IonPage>

View File

@@ -0,0 +1,164 @@
/**
* Test page that validates the UNSAFE_RouteContext shape at runtime.
*
* Context layers in Ionic React:
* 1. RR6 provides native RouteContext at the router level
* 2. StackManager.tsx consumes native RR6 context and validates it via validateRouteContext()
* 3. ReactRouterViewStack.renderViewItem wraps each view in RouteContext.Provider
* with Ionic's constructed context (built by buildContextMatches)
* 4. Components inside IonRouterOutlet read Ionic's constructed context
*
* The RouteContextValidator components here read layer 3/4 (Ionic's constructed context).
* They verify that Ionic's buildContextMatches produces the correct shape.
* The native RR6 context (layer 1) is validated by the validateRouteContext() call
* in StackManager.tsx — the Cypress spec checks this via the console warning assertion.
*/
import {
IonContent,
IonHeader,
IonPage,
IonTitle,
IonToolbar,
IonRouterOutlet,
IonButton,
IonLabel,
IonItem,
IonList,
} from '@ionic/react';
import React, { useContext, useMemo } from 'react';
import { Route, useParams, useNavigate, UNSAFE_RouteContext } from 'react-router-dom';
/**
* Validates a single match entry from the RouteContext matches array.
*/
function validateMatchEntry(entry: unknown): { valid: boolean; missing: string[] } {
const missing: string[] = [];
if (typeof entry !== 'object' || entry === null) {
return { valid: false, missing: ['not-an-object'] };
}
const e = entry as Record<string, unknown>;
if (typeof e.params !== 'object' || e.params === null) missing.push('params');
if (typeof e.pathname !== 'string') missing.push('pathname');
if (typeof e.pathnameBase !== 'string') missing.push('pathnameBase');
if (typeof e.route !== 'object' || e.route === null) {
missing.push('route');
} else {
const route = e.route as Record<string, unknown>;
if (typeof route.id !== 'string') missing.push('route.id');
if (typeof route.hasErrorBoundary !== 'boolean') missing.push('route.hasErrorBoundary');
}
return { valid: missing.length === 0, missing };
}
/**
* Component that reads RouteContext and exposes validation results as data attributes.
* Note: Inside IonRouterOutlet, this reads Ionic's constructed context, not RR6's native context.
*/
const RouteContextValidator: React.FC<{ id: string }> = ({ id }) => {
const routeContext = useContext(UNSAFE_RouteContext);
const validation = useMemo(() => {
if (!routeContext) {
return { hasContext: false, matchCount: 0, allValid: false, details: 'no-context' };
}
const matches = routeContext.matches;
if (!Array.isArray(matches)) {
return { hasContext: true, matchCount: 0, allValid: false, details: 'matches-not-array' };
}
const results = matches.map((m, i) => {
const { valid, missing } = validateMatchEntry(m);
return { index: i, valid, missing };
});
const allValid = results.every((r) => r.valid);
const invalidEntries = results.filter((r) => !r.valid);
const details = allValid
? 'all-valid'
: invalidEntries.map((e) => `match[${e.index}]:${e.missing.join(',')}`).join(';');
return { hasContext: true, matchCount: matches.length, allValid, details };
}, [routeContext]);
return (
<div
id={`validator-${id}`}
data-has-context={String(validation.hasContext)}
data-match-count={String(validation.matchCount)}
data-all-valid={String(validation.allValid)}
data-details={validation.details}
>
<p>Context: {validation.hasContext ? 'yes' : 'no'}</p>
<p>Matches: {validation.matchCount}</p>
<p>Valid: {validation.allValid ? 'yes' : 'no'}</p>
<p>Details: {validation.details}</p>
</div>
);
};
/**
* A nested page that validates context at a deeper level.
*/
const NestedPage: React.FC = () => {
const params = useParams();
return (
<IonPage data-pageid="route-context-nested">
<IonHeader>
<IonToolbar>
<IonTitle>Nested (id: {params.id})</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<RouteContextValidator id="nested" />
<div id="nested-params">{JSON.stringify(params)}</div>
</IonContent>
</IonPage>
);
};
/**
* Root page for the route-context-shape test.
*/
const RouteContextShape: React.FC = () => {
const navigate = useNavigate();
return (
<IonRouterOutlet>
<Route
path="details/:id"
element={<NestedPage />}
/>
<Route
path=""
element={
<IonPage data-pageid="route-context-root">
<IonHeader>
<IonToolbar>
<IonTitle>Route Context Shape</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<RouteContextValidator id="root" />
<IonList>
<IonItem>
<IonLabel>
<IonButton id="go-nested" onClick={() => navigate('details/42')}>
Go to Nested
</IonButton>
</IonLabel>
</IonItem>
</IonList>
</IonContent>
</IonPage>
}
/>
</IonRouterOutlet>
);
};
export default RouteContextShape;

View File

@@ -0,0 +1,56 @@
const port = 3000;
/**
* Validates that React Router's UNSAFE_RouteContext shape is compatible with
* @ionic/react-router. This is a canary test — if React Router changes the
* internal context shape, these tests will fail and signal that the
* RouteContextMatch type and buildContextMatches need updating.
*
* The validators read Ionic's constructed context (built by buildContextMatches
* in ReactRouterViewStack), which mirrors the native RR6 shape. If the shape
* Ionic produces drifts, components like useParams() will break.
*/
describe('UNSAFE_RouteContext shape validation', () => {
it('should produce valid constructed context shape at root outlet level', () => {
cy.visit(`http://localhost:${port}/route-context-shape`);
cy.ionPageVisible('route-context-root');
// The root validator reads Ionic's constructed context and verifies
// that buildContextMatches produces entries with the expected shape
cy.get('#validator-root')
.should('have.attr', 'data-has-context', 'true')
.should('have.attr', 'data-all-valid', 'true');
// Should have at least 1 match (the route-context-shape/* route)
cy.get('#validator-root')
.invoke('attr', 'data-match-count')
.then((count) => {
expect(parseInt(count, 10)).to.be.greaterThan(0);
});
});
it('should produce valid constructed context shape at nested level with params', () => {
cy.visit(`http://localhost:${port}/route-context-shape`);
cy.ionPageVisible('route-context-root');
// Navigate to nested route with params
cy.get('#go-nested').click();
cy.ionPageVisible('route-context-nested');
// The nested validator reads Ionic's constructed context at a deeper
// level — verifies parent + child matches are both correctly shaped
cy.get('#validator-nested')
.should('have.attr', 'data-has-context', 'true')
.should('have.attr', 'data-all-valid', 'true');
// Nested route should have more matches than root (parent + child)
cy.get('#validator-nested')
.invoke('attr', 'data-match-count')
.then((count) => {
expect(parseInt(count, 10)).to.be.greaterThan(1);
});
// Params should be correctly propagated through context
cy.get('#nested-params').should('contain', '"id":"42"');
});
});