Skip to content

Commit bceeff6

Browse files
committed
fix(react-router): guarding against potential change of UNSAFE APIs)
1 parent 84ae705 commit bceeff6

File tree

5 files changed

+227
-2
lines changed

5 files changed

+227
-2
lines changed

packages/react-router/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@
4242
"peerDependencies": {
4343
"react": ">=16.8.6",
4444
"react-dom": ">=16.8.6",
45-
"react-router": ">=6.0.0",
46-
"react-router-dom": ">=6.0.0"
45+
"react-router": ">=6.4.0 <7",
46+
"react-router-dom": ">=6.4.0 <7"
4747
},
4848
"devDependencies": {
4949
"@ionic/eslint-config": "^0.3.0",

packages/react-router/test/base/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import IndexParamPriority from './pages/index-param-priority/IndexParamPriority'
5454
import IndexRouteReuse from './pages/index-route-reuse/IndexRouteReuse';
5555
import TailSliceAmbiguity from './pages/tail-slice-ambiguity/TailSliceAmbiguity';
5656
import WildcardNoHeuristic from './pages/wildcard-no-heuristic/WildcardNoHeuristic';
57+
import RouteContextShape from './pages/route-context-shape/RouteContextShape';
5758

5859
setupIonicReact();
5960

@@ -97,6 +98,7 @@ const App: React.FC = () => {
9798
<Route path="/index-route-reuse/*" element={<IndexRouteReuse />} />
9899
<Route path="/tail-slice-ambiguity/*" element={<TailSliceAmbiguity />} />
99100
<Route path="/wildcard-no-heuristic/*" element={<WildcardNoHeuristic />} />
101+
<Route path="/route-context-shape/*" element={<RouteContextShape />} />
100102
</IonRouterOutlet>
101103
</IonReactRouter>
102104
</IonApp>

packages/react-router/test/base/src/pages/Main.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ const Main: React.FC = () => {
107107
<IonItem routerLink="/wildcard-no-heuristic">
108108
<IonLabel>Wildcard No Heuristic</IonLabel>
109109
</IonItem>
110+
<IonItem routerLink="/route-context-shape">
111+
<IonLabel>Route Context Shape</IonLabel>
112+
</IonItem>
110113
</IonList>
111114
</IonContent>
112115
</IonPage>
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* Test page that validates the UNSAFE_RouteContext shape at runtime.
3+
*
4+
* Context layers in Ionic React:
5+
* 1. RR6 provides native RouteContext at the router level
6+
* 2. StackManager.tsx consumes native RR6 context and validates it via validateRouteContext()
7+
* 3. ReactRouterViewStack.renderViewItem wraps each view in RouteContext.Provider
8+
* with Ionic's constructed context (built by buildContextMatches)
9+
* 4. Components inside IonRouterOutlet read Ionic's constructed context
10+
*
11+
* The RouteContextValidator components here read layer 3/4 (Ionic's constructed context).
12+
* They verify that Ionic's buildContextMatches produces the correct shape.
13+
* The native RR6 context (layer 1) is validated by the validateRouteContext() call
14+
* in StackManager.tsx — the Cypress spec checks this via the console warning assertion.
15+
*/
16+
import {
17+
IonContent,
18+
IonHeader,
19+
IonPage,
20+
IonTitle,
21+
IonToolbar,
22+
IonRouterOutlet,
23+
IonButton,
24+
IonLabel,
25+
IonItem,
26+
IonList,
27+
} from '@ionic/react';
28+
import React, { useContext, useMemo } from 'react';
29+
import { Route, useParams, useNavigate, UNSAFE_RouteContext } from 'react-router-dom';
30+
31+
/**
32+
* Validates a single match entry from the RouteContext matches array.
33+
*/
34+
function validateMatchEntry(entry: unknown): { valid: boolean; missing: string[] } {
35+
const missing: string[] = [];
36+
if (typeof entry !== 'object' || entry === null) {
37+
return { valid: false, missing: ['not-an-object'] };
38+
}
39+
40+
const e = entry as Record<string, unknown>;
41+
42+
if (typeof e.params !== 'object' || e.params === null) missing.push('params');
43+
if (typeof e.pathname !== 'string') missing.push('pathname');
44+
if (typeof e.pathnameBase !== 'string') missing.push('pathnameBase');
45+
46+
if (typeof e.route !== 'object' || e.route === null) {
47+
missing.push('route');
48+
} else {
49+
const route = e.route as Record<string, unknown>;
50+
if (typeof route.id !== 'string') missing.push('route.id');
51+
if (typeof route.hasErrorBoundary !== 'boolean') missing.push('route.hasErrorBoundary');
52+
}
53+
54+
return { valid: missing.length === 0, missing };
55+
}
56+
57+
/**
58+
* Component that reads RouteContext and exposes validation results as data attributes.
59+
* Note: Inside IonRouterOutlet, this reads Ionic's constructed context, not RR6's native context.
60+
*/
61+
const RouteContextValidator: React.FC<{ id: string }> = ({ id }) => {
62+
const routeContext = useContext(UNSAFE_RouteContext);
63+
64+
const validation = useMemo(() => {
65+
if (!routeContext) {
66+
return { hasContext: false, matchCount: 0, allValid: false, details: 'no-context' };
67+
}
68+
69+
const matches = routeContext.matches;
70+
if (!Array.isArray(matches)) {
71+
return { hasContext: true, matchCount: 0, allValid: false, details: 'matches-not-array' };
72+
}
73+
74+
const results = matches.map((m, i) => {
75+
const { valid, missing } = validateMatchEntry(m);
76+
return { index: i, valid, missing };
77+
});
78+
79+
const allValid = results.every((r) => r.valid);
80+
const invalidEntries = results.filter((r) => !r.valid);
81+
const details = allValid
82+
? 'all-valid'
83+
: invalidEntries.map((e) => `match[${e.index}]:${e.missing.join(',')}`).join(';');
84+
85+
return { hasContext: true, matchCount: matches.length, allValid, details };
86+
}, [routeContext]);
87+
88+
return (
89+
<div
90+
id={`validator-${id}`}
91+
data-has-context={String(validation.hasContext)}
92+
data-match-count={String(validation.matchCount)}
93+
data-all-valid={String(validation.allValid)}
94+
data-details={validation.details}
95+
>
96+
<p>Context: {validation.hasContext ? 'yes' : 'no'}</p>
97+
<p>Matches: {validation.matchCount}</p>
98+
<p>Valid: {validation.allValid ? 'yes' : 'no'}</p>
99+
<p>Details: {validation.details}</p>
100+
</div>
101+
);
102+
};
103+
104+
/**
105+
* A nested page that validates context at a deeper level.
106+
*/
107+
const NestedPage: React.FC = () => {
108+
const params = useParams();
109+
return (
110+
<IonPage data-pageid="route-context-nested">
111+
<IonHeader>
112+
<IonToolbar>
113+
<IonTitle>Nested (id: {params.id})</IonTitle>
114+
</IonToolbar>
115+
</IonHeader>
116+
<IonContent>
117+
<RouteContextValidator id="nested" />
118+
<div id="nested-params">{JSON.stringify(params)}</div>
119+
</IonContent>
120+
</IonPage>
121+
);
122+
};
123+
124+
/**
125+
* Root page for the route-context-shape test.
126+
*/
127+
const RouteContextShape: React.FC = () => {
128+
const navigate = useNavigate();
129+
130+
return (
131+
<IonRouterOutlet>
132+
<Route
133+
path="details/:id"
134+
element={<NestedPage />}
135+
/>
136+
<Route
137+
path=""
138+
element={
139+
<IonPage data-pageid="route-context-root">
140+
<IonHeader>
141+
<IonToolbar>
142+
<IonTitle>Route Context Shape</IonTitle>
143+
</IonToolbar>
144+
</IonHeader>
145+
<IonContent>
146+
<RouteContextValidator id="root" />
147+
<IonList>
148+
<IonItem>
149+
<IonLabel>
150+
<IonButton id="go-nested" onClick={() => navigate('details/42')}>
151+
Go to Nested
152+
</IonButton>
153+
</IonLabel>
154+
</IonItem>
155+
</IonList>
156+
</IonContent>
157+
</IonPage>
158+
}
159+
/>
160+
</IonRouterOutlet>
161+
);
162+
};
163+
164+
export default RouteContextShape;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
const port = 3000;
2+
3+
/**
4+
* Validates that React Router's UNSAFE_RouteContext shape is compatible with
5+
* @ionic/react-router. This is a canary test — if React Router changes the
6+
* internal context shape, these tests will fail and signal that the
7+
* RouteContextMatch type and buildContextMatches need updating.
8+
*
9+
* The validators read Ionic's constructed context (built by buildContextMatches
10+
* in ReactRouterViewStack), which mirrors the native RR6 shape. If the shape
11+
* Ionic produces drifts, components like useParams() will break.
12+
*/
13+
describe('UNSAFE_RouteContext shape validation', () => {
14+
it('should produce valid constructed context shape at root outlet level', () => {
15+
cy.visit(`http://localhost:${port}/route-context-shape`);
16+
cy.ionPageVisible('route-context-root');
17+
18+
// The root validator reads Ionic's constructed context and verifies
19+
// that buildContextMatches produces entries with the expected shape
20+
cy.get('#validator-root')
21+
.should('have.attr', 'data-has-context', 'true')
22+
.should('have.attr', 'data-all-valid', 'true');
23+
24+
// Should have at least 1 match (the route-context-shape/* route)
25+
cy.get('#validator-root')
26+
.invoke('attr', 'data-match-count')
27+
.then((count) => {
28+
expect(parseInt(count, 10)).to.be.greaterThan(0);
29+
});
30+
});
31+
32+
it('should produce valid constructed context shape at nested level with params', () => {
33+
cy.visit(`http://localhost:${port}/route-context-shape`);
34+
cy.ionPageVisible('route-context-root');
35+
36+
// Navigate to nested route with params
37+
cy.get('#go-nested').click();
38+
cy.ionPageVisible('route-context-nested');
39+
40+
// The nested validator reads Ionic's constructed context at a deeper
41+
// level — verifies parent + child matches are both correctly shaped
42+
cy.get('#validator-nested')
43+
.should('have.attr', 'data-has-context', 'true')
44+
.should('have.attr', 'data-all-valid', 'true');
45+
46+
// Nested route should have more matches than root (parent + child)
47+
cy.get('#validator-nested')
48+
.invoke('attr', 'data-match-count')
49+
.then((count) => {
50+
expect(parseInt(count, 10)).to.be.greaterThan(1);
51+
});
52+
53+
// Params should be correctly propagated through context
54+
cy.get('#nested-params').should('contain', '"id":"42"');
55+
});
56+
});

0 commit comments

Comments
 (0)