Skip to content

Commit e271561

Browse files
authored
Merge pull request #97849 from Expensify/claude-fixAIFeaturesPromoSecurityError
Fix AI Features Promo modal SecurityError from doubled-slash path and re-entrancy
2 parents 4df225a + c37f733 commit e271561

3 files changed

Lines changed: 26 additions & 2 deletions

File tree

src/hooks/useAIFeaturesPromoModal.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ function useAIFeaturesPromoModal(session: OnyxEntry<Session>) {
9898
if (lastRoute === NAVIGATORS.SHARE_MODAL_NAVIGATOR || lastRoute === SCREENS.NOT_FOUND) {
9999
return;
100100
}
101+
if (lastRoute === NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR) {
102+
// The promo modal is already the active route (e.g. deep-linked directly). Re-navigating would
103+
// append the suffix onto a base that already ends in it, producing a duplicated
104+
// `…/ai-features-promo/ai-features-promo` path. Treat it as already shown and bail out.
105+
hasRedirectedToAIFeaturesPromoModal = true;
106+
return;
107+
}
101108
Log.info('[useAIFeaturesPromoModal] Navigating to AI features promo modal');
102109
hasRedirectedToAIFeaturesPromoModal = true;
103110
Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AI_FEATURES_PROMO.path, Navigation.getActiveRoute() || ROUTES.HOME));

src/libs/Navigation/helpers/getPathFromState.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,8 @@ function getPathFromStateWithDynamicRoute(state: State): string {
141141
const tabPath = focusedTab && isScreen(focusedTab.name) ? normalizedConfigs[focusedTab.name]?.path : undefined;
142142
if (tabPath) {
143143
const [suffixPathOnly, suffixQueryOnly] = splitPathAndQuery(actualSuffix);
144-
actualSuffix = `${suffixPathOnly}/${tabPath}${suffixQueryOnly ? `?${suffixQueryOnly}` : ''}`;
144+
const combinedSuffixPath = suffixPathOnly === '/' ? `/${tabPath}` : `${suffixPathOnly}/${tabPath}`;
145+
actualSuffix = `${combinedSuffixPath}${suffixQueryOnly ? `?${suffixQueryOnly}` : ''}`;
145146
}
146147
}
147148
}
@@ -163,7 +164,12 @@ function getPathFromStateWithDynamicRoute(state: State): string {
163164
}
164165
const queryString = mergedParams.toString();
165166

166-
return `${basePathWithoutQuery}/${suffixPath}${queryString ? `?${queryString}` : ''}`;
167+
// Mirror createDynamicRoute's slash handling: a root base (`/`) must not be concatenated with a leading slash,
168+
// otherwise the result is a `//`-prefixed path that the browser parses as protocol-relative (host = first segment),
169+
// making `history.pushState` throw a SecurityError.
170+
const combinedPath = basePathWithoutQuery === '/' ? `/${suffixPath}` : `${basePathWithoutQuery}/${suffixPath}`;
171+
172+
return `${combinedPath}${queryString ? `?${queryString}` : ''}`;
167173
}
168174

169175
function getPathFromState(state: State): string {

tests/navigation/getPathFromStateTests.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ describe('getPathFromState', () => {
8989
expect(result).toBe('/settings/wallet/test-dynamic');
9090
});
9191

92+
it('root base path does not produce a doubled leading slash', () => {
93+
// A base screen that resolves to root ('/') must not be concatenated with a leading slash,
94+
// otherwise the result is a '//'-prefixed protocol-relative URL that makes history.pushState
95+
// throw a SecurityError. Regression test for the AI Features Promo crash (issue #97470).
96+
mockRNGetPathFromState.mockReturnValue('/');
97+
98+
const state = buildState([{name: 'StandardScreen'}, {name: 'TestDynamicScreen'}]);
99+
100+
expect(getPathFromState(state as PartialState<NavigationState>)).toBe('/test-dynamic');
101+
});
102+
92103
it('should use RN getPathFromState for standard screens', () => {
93104
const expectedPath = '/standard/path';
94105
mockRNGetPathFromState.mockReturnValue(expectedPath);

0 commit comments

Comments
 (0)