mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
fix(react-router): guarding against potential change of UNSAFE APIs)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user