Skip to content

Commit 9b352ee

Browse files
authored
Merge pull request #8488 from Couchers-org/na/mobile/map-back-pagination
Mobile v1.1.18: Fix losing map search results scroll location and page
2 parents 4fdf179 + aa8efce commit 9b352ee

8 files changed

Lines changed: 203 additions & 55 deletions

File tree

app/mobile/ARCHITECTURE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Lesson learned!
6565
### ⚠️ Trade-offs
6666

6767
- **Memory overhead**: Each tab has its own WebView (~50-100MB each)
68-
- **Brief visual flash**: Sometimes visible when first loading a detail page (new WebView instance mounting); the return flash when navigating back to a tab is avoided by pre-navigating the tab's WebView to its root while the detail screen is shown
68+
- **Brief visual flash**: Sometimes visible when first loading a detail page (new WebView instance mounting)
6969
- **Sync complexity**: Two navigation systems must stay coordinated
7070
- **Not "truly native"**: Won't feel as smooth as a pure native app
7171

@@ -94,7 +94,7 @@ The "glue" that keeps mobile tabs in sync with web navigation:
9494

9595
- Detects which page is showing in the WebView
9696
- Maps web URL to a tab route and calls `router.navigate()` to keep tab highlights in sync
97-
- Detail pages (profiles, events, etc.) navigate to `[...slug]` so no tab is highlighted; named-tab WebEmbeds sync back to their root when they regain focus
97+
- Detail pages (profiles, events, etc.) navigate to `[...slug]` so no tab is highlighted; when the tab regains focus it calls `prepareGoBack()` + `webviewRef.goBack()` — the browser's bfcache restores the exact previous page state (scroll position, pagination) rather than remounting from scratch
9898

9999
**Key insight**: Must strip locale prefixes before mobile navigation:
100100

@@ -114,7 +114,7 @@ Shared refs across all WebView instances:
114114

115115
Each `WebEmbed` instance also has its own **per-instance** `currentWebPathRef` (inside `useWebNavigation`) that tracks that tab's current URL independently. This is intentional — a shared global ref caused cross-tab contamination where one tab's navigation would corrupt another tab's sync checks.
116116

117-
**Note**: You may occasionally see a brief flash when first navigating to a detail page, since `[...slug]` mounts a fresh WebView. The return flash (detail → tab) is avoided by pre-navigating the tab's WebView back to its root while the detail screen is open.
117+
**Note**: You may occasionally see a brief flash when first navigating to a detail page, since `[...slug]` mounts a fresh WebView. On return, the tab's WebView uses native back navigation (`goBack()`) so the bfcache restores the previous page state exactly — including pagination and scroll position.
118118

119119
### isNativeEmbed
120120

app/mobile/RELEASE_NOTES.txt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,2 @@
11
Bug fixes and improvements:
2-
- Navigation tabs are more reliable and no longer go gray after some time
3-
- Added a back button when viewing profiles from search results
4-
- Tapping a tab now closes open menus like notifications
5-
- Links in the sign-up form now open in your browser instead of inside the app
6-
- Fixed the "Save changes" bar on the My Home editing page floating above the bottom tabs
2+
- Going back from a profile in search results now restores your previous page and scroll position

app/mobile/app.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ const getIosIcon = () => {
6161
export default {
6262
name: getAppName(),
6363
slug: "mobile",
64-
version: "1.1.17",
64+
version: "1.1.18",
6565
orientation: "portrait",
6666
icon: getIcon(),
6767
scheme: getAppScheme(),

app/mobile/components/WebEmbed.tsx

Lines changed: 46 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -69,37 +69,19 @@ export default function WebEmbed({
6969
[],
7070
);
7171

72-
const { handleNavigationStateChange, canGoBackRef, currentWebPathRef } =
73-
useWebNavigation({
74-
webBaseUrl: WEB_BASE_URL,
75-
currentPath: path,
76-
syncTargetPathRef,
77-
onRetryCountReset: () => {
78-
retryCountRef.current = 0;
79-
},
80-
onDetailNavigation: useCallback(() => {
81-
// Pre-navigate to the tab root in the background so returning shows no flash.
82-
const tabRoots = [
83-
"/dashboard",
84-
"/messages",
85-
"/search",
86-
"/communities",
87-
"/events",
88-
];
89-
const targetRoute = stripLocale(path);
90-
if (!tabRoots.includes(targetRoute.split("?")[0])) {
91-
return;
92-
}
93-
const currentLocale = i18n.language !== "en" ? i18n.language : null;
94-
const targetPath = currentLocale
95-
? `/${currentLocale}${targetRoute}`
96-
: targetRoute;
97-
webviewRef.current?.injectJavaScript(`
98-
window.postMessage(${JSON.stringify({ type: "MOBILE_NAVIGATE", path: targetPath })}, "*");
99-
true;
100-
`);
101-
}, [path, stripLocale, i18n.language]),
102-
});
72+
const {
73+
handleNavigationStateChange,
74+
canGoBackRef,
75+
currentWebPathRef,
76+
prepareGoBack,
77+
} = useWebNavigation({
78+
webBaseUrl: WEB_BASE_URL,
79+
currentPath: path,
80+
syncTargetPathRef,
81+
onRetryCountReset: () => {
82+
retryCountRef.current = 0;
83+
},
84+
});
10385

10486
// Register escape-dispatch callback while this tab is focused so the tab bar
10587
// can close open menus (e.g. notifications) on any tab press.
@@ -207,6 +189,31 @@ export default function WebEmbed({
207189
return cleanup;
208190
}
209191

192+
const tabRoots = [
193+
"/dashboard",
194+
"/messages",
195+
"/search",
196+
"/communities",
197+
"/events",
198+
];
199+
200+
// If the WebView is on a detail page (e.g. /user/username) and we have
201+
// WebView history to go back through, use native back navigation so the
202+
// browser's bfcache restores the exact search state (page number, scroll
203+
// position) rather than remounting the page from scratch.
204+
const strippedCurrentPath = stripLocale(currentWebPathRef.current).split(
205+
"?",
206+
)[0];
207+
if (
208+
!tabRoots.includes(strippedCurrentPath) &&
209+
canGoBackRef.current &&
210+
tabRoots.includes(stripLocale(path).split("?")[0])
211+
) {
212+
prepareGoBack();
213+
webviewRef.current?.goBack();
214+
return cleanup;
215+
}
216+
210217
const targetRoute = stripLocale(path);
211218
const currentLocale = i18n.language !== "en" ? i18n.language : null;
212219
const targetPath = currentLocale
@@ -219,14 +226,6 @@ export default function WebEmbed({
219226
}
220227

221228
// [..slug] WebEmbed: don't sync back to the original detail path.
222-
const tabRoots = [
223-
"/dashboard",
224-
"/messages",
225-
"/search",
226-
"/communities",
227-
"/events",
228-
];
229-
230229
if (!tabRoots.includes(stripLocale(path).split("?")[0])) {
231230
return cleanup;
232231
}
@@ -238,7 +237,14 @@ export default function WebEmbed({
238237
`);
239238

240239
return cleanup;
241-
}, [path, stripLocale, i18n.language, currentWebPathRef]),
240+
}, [
241+
path,
242+
stripLocale,
243+
i18n.language,
244+
currentWebPathRef,
245+
canGoBackRef,
246+
prepareGoBack,
247+
]),
242248
);
243249

244250
// Reload WebView if it's been backgrounded for more than 30 minutes.

app/mobile/hooks/useWebNavigation.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ interface UseWebNavigationOptions {
1313
currentPath: string;
1414
syncTargetPathRef: React.RefObject<string | null>;
1515
onRetryCountReset?: () => void;
16-
onDetailNavigation?: () => void;
1716
}
1817

1918
interface UseWebNavigationReturn {
2019
handleNavigationStateChange: (navState: WebViewNavigation) => void;
2120
canGoBackRef: React.RefObject<boolean>;
2221
currentWebPathRef: React.RefObject<string>;
22+
prepareGoBack: () => void;
2323
}
2424

2525
/**
@@ -30,7 +30,6 @@ export function useWebNavigation({
3030
currentPath,
3131
syncTargetPathRef,
3232
onRetryCountReset,
33-
onDetailNavigation,
3433
}: UseWebNavigationOptions): UseWebNavigationReturn {
3534
const router = useRouter();
3635
const { i18n } = useTranslation();
@@ -189,9 +188,9 @@ export function useWebNavigation({
189188
detailRouteOriginRef.current = currentPath;
190189
lastMobileNavigationRef.current = detailPath;
191190
router.navigate(detailPath as Href);
192-
// Set skip ref now so the stale iOS replay after onDetailNavigation's MOBILE_NAVIGATE is caught.
191+
// Prime skipNextDetailRef so the stale iOS WKWebView replay event fired
192+
// after goBack() (in useFocusEffect) is silently dropped.
193193
skipNextDetailRef.current = webPathWithoutQuery;
194-
onDetailNavigation?.();
195194
} else {
196195
// navigate() switches the active tab in place; push() would add a root-level
197196
// (tabs) stack entry and flash the dashboard before settling on the target tab.
@@ -208,17 +207,27 @@ export function useWebNavigation({
208207
webBaseUrl,
209208
currentPath,
210209
onRetryCountReset,
211-
onDetailNavigation,
212210
extractLocaleFromPath,
213211
getRouteNameForPath,
214212
router,
215213
i18n,
216214
],
217215
);
218216

217+
// Call this immediately before webviewRef.goBack() when returning from a detail
218+
// route. It re-primes skipNextDetailRef so that any stale iOS WKWebView event
219+
// fired after the back navigation is silently dropped instead of re-triggering
220+
// a detail route navigation. Must NOT strip the locale prefix — the check in
221+
// handleNavigationStateChange compares against webPathWithoutQuery which still
222+
// has it.
223+
const prepareGoBack = useCallback(() => {
224+
skipNextDetailRef.current = currentWebPathRef.current.split("?")[0];
225+
}, []);
226+
219227
return {
220228
handleNavigationStateChange,
221229
canGoBackRef,
222230
currentWebPathRef,
231+
prepareGoBack,
223232
};
224233
}

app/mobile/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "couchers.org",
33
"main": "expo-router/entry",
4-
"version": "1.1.17",
4+
"version": "1.1.18",
55
"scripts": {
66
"start": "expo start",
77
"start:localhost": "expo start --localhost",

app/mobile/tests/components/WebEmbed.test.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const mockWebViewRef = {
4040
let capturedWebViewProps: {
4141
source?: { uri: string };
4242
allowsBackForwardNavigationGestures?: boolean;
43+
onLoad?: () => void;
4344
onNavigationStateChange?: (navState: {
4445
url: string;
4546
loading: boolean;
@@ -97,6 +98,7 @@ describe("WebEmbed", () => {
9798
// Mock return values for hooks
9899
const mockPickImage = jest.fn();
99100
const mockHandleNavigationStateChange = jest.fn();
101+
const mockPrepareGoBack = jest.fn();
100102
const mockCanGoBackRef = { current: false };
101103
const mockCurrentWebPathRef = { current: "/dashboard" };
102104

@@ -130,6 +132,7 @@ describe("WebEmbed", () => {
130132
handleNavigationStateChange: mockHandleNavigationStateChange,
131133
canGoBackRef: mockCanGoBackRef,
132134
currentWebPathRef: mockCurrentWebPathRef,
135+
prepareGoBack: mockPrepareGoBack,
133136
});
134137
});
135138

@@ -381,6 +384,64 @@ describe("WebEmbed", () => {
381384
});
382385
});
383386

387+
describe("focus sync: returning from detail page", () => {
388+
it("calls prepareGoBack and goBack when refocused on a detail URL with history", () => {
389+
mockCurrentWebPathRef.current = "/user/username";
390+
mockCanGoBackRef.current = true;
391+
392+
const { rerender } = render(<WebEmbed path="/search" />);
393+
394+
// Simulate WebView finishing its initial load
395+
act(() => {
396+
capturedWebViewProps.onLoad?.();
397+
});
398+
399+
// Simulate tab regaining focus (useFocusEffect fires again after load)
400+
rerender(<WebEmbed path="/search" />);
401+
402+
expect(mockPrepareGoBack).toHaveBeenCalled();
403+
expect(mockWebViewRef.goBack).toHaveBeenCalled();
404+
expect(mockWebViewRef.injectJavaScript).not.toHaveBeenCalledWith(
405+
expect.stringContaining("MOBILE_NAVIGATE"),
406+
);
407+
});
408+
409+
it("falls back to MOBILE_NAVIGATE when WebView has no history to go back through", () => {
410+
mockCurrentWebPathRef.current = "/user/username";
411+
mockCanGoBackRef.current = false;
412+
413+
const { rerender } = render(<WebEmbed path="/search" />);
414+
415+
act(() => {
416+
capturedWebViewProps.onLoad?.();
417+
});
418+
419+
rerender(<WebEmbed path="/search" />);
420+
421+
expect(mockPrepareGoBack).not.toHaveBeenCalled();
422+
expect(mockWebViewRef.goBack).not.toHaveBeenCalled();
423+
expect(mockWebViewRef.injectJavaScript).toHaveBeenCalledWith(
424+
expect.stringContaining("MOBILE_NAVIGATE"),
425+
);
426+
});
427+
428+
it("does not call goBack when the WebView is already on the correct tab root", () => {
429+
mockCurrentWebPathRef.current = "/search";
430+
mockCanGoBackRef.current = true;
431+
432+
const { rerender } = render(<WebEmbed path="/search" />);
433+
434+
act(() => {
435+
capturedWebViewProps.onLoad?.();
436+
});
437+
438+
rerender(<WebEmbed path="/search" />);
439+
440+
expect(mockPrepareGoBack).not.toHaveBeenCalled();
441+
expect(mockWebViewRef.goBack).not.toHaveBeenCalled();
442+
});
443+
});
444+
384445
// Note: Android back button tests below test integration glue code
385446
// between BackHandler and WebView. While not pure user behavior tests,
386447
// they verify important functionality that can't be tested by simulating

0 commit comments

Comments
 (0)