Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/mobile/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Lesson learned!
### ⚠️ Trade-offs

- **Memory overhead**: Each tab has its own WebView (~50-100MB each)
- **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
- **Brief visual flash**: Sometimes visible when first loading a detail page (new WebView instance mounting)
- **Sync complexity**: Two navigation systems must stay coordinated
- **Not "truly native"**: Won't feel as smooth as a pure native app

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

- Detects which page is showing in the WebView
- Maps web URL to a tab route and calls `router.navigate()` to keep tab highlights in sync
- 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
- 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

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

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

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.

**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.
**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.

### isNativeEmbed

Expand Down
6 changes: 1 addition & 5 deletions app/mobile/RELEASE_NOTES.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,2 @@
Bug fixes and improvements:
- Navigation tabs are more reliable and no longer go gray after some time
- Added a back button when viewing profiles from search results
- Tapping a tab now closes open menus like notifications
- Links in the sign-up form now open in your browser instead of inside the app
- Fixed the "Save changes" bar on the My Home editing page floating above the bottom tabs
- Going back from a profile in search results now restores your previous page and scroll position
2 changes: 1 addition & 1 deletion app/mobile/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const getIosIcon = () => {
export default {
name: getAppName(),
slug: "mobile",
version: "1.1.17",
version: "1.1.18",
orientation: "portrait",
icon: getIcon(),
scheme: getAppScheme(),
Expand Down
86 changes: 46 additions & 40 deletions app/mobile/components/WebEmbed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,37 +69,19 @@ export default function WebEmbed({
[],
);

const { handleNavigationStateChange, canGoBackRef, currentWebPathRef } =
useWebNavigation({
webBaseUrl: WEB_BASE_URL,
currentPath: path,
syncTargetPathRef,
onRetryCountReset: () => {
retryCountRef.current = 0;
},
onDetailNavigation: useCallback(() => {
// Pre-navigate to the tab root in the background so returning shows no flash.
const tabRoots = [
"/dashboard",
"/messages",
"/search",
"/communities",
"/events",
];
const targetRoute = stripLocale(path);
if (!tabRoots.includes(targetRoute.split("?")[0])) {
return;
}
const currentLocale = i18n.language !== "en" ? i18n.language : null;
const targetPath = currentLocale
? `/${currentLocale}${targetRoute}`
: targetRoute;
webviewRef.current?.injectJavaScript(`
window.postMessage(${JSON.stringify({ type: "MOBILE_NAVIGATE", path: targetPath })}, "*");
true;
`);
}, [path, stripLocale, i18n.language]),
});
const {
handleNavigationStateChange,
canGoBackRef,
currentWebPathRef,
prepareGoBack,
} = useWebNavigation({
webBaseUrl: WEB_BASE_URL,
currentPath: path,
syncTargetPathRef,
onRetryCountReset: () => {
retryCountRef.current = 0;
},
});

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

const tabRoots = [
"/dashboard",
"/messages",
"/search",
"/communities",
"/events",
];

// If the WebView is on a detail page (e.g. /user/username) and we have
// WebView history to go back through, use native back navigation so the
// browser's bfcache restores the exact search state (page number, scroll
// position) rather than remounting the page from scratch.
const strippedCurrentPath = stripLocale(currentWebPathRef.current).split(
"?",
)[0];
if (
!tabRoots.includes(strippedCurrentPath) &&
canGoBackRef.current &&
tabRoots.includes(stripLocale(path).split("?")[0])
) {
prepareGoBack();
webviewRef.current?.goBack();
return cleanup;
}

const targetRoute = stripLocale(path);
const currentLocale = i18n.language !== "en" ? i18n.language : null;
const targetPath = currentLocale
Expand All @@ -219,14 +226,6 @@ export default function WebEmbed({
}

// [..slug] WebEmbed: don't sync back to the original detail path.
const tabRoots = [
"/dashboard",
"/messages",
"/search",
"/communities",
"/events",
];

if (!tabRoots.includes(stripLocale(path).split("?")[0])) {
return cleanup;
}
Expand All @@ -238,7 +237,14 @@ export default function WebEmbed({
`);

return cleanup;
}, [path, stripLocale, i18n.language, currentWebPathRef]),
}, [
path,
stripLocale,
i18n.language,
currentWebPathRef,
canGoBackRef,
prepareGoBack,
]),
);

// Reload WebView if it's been backgrounded for more than 30 minutes.
Expand Down
19 changes: 14 additions & 5 deletions app/mobile/hooks/useWebNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ interface UseWebNavigationOptions {
currentPath: string;
syncTargetPathRef: React.RefObject<string | null>;
onRetryCountReset?: () => void;
onDetailNavigation?: () => void;
}

interface UseWebNavigationReturn {
handleNavigationStateChange: (navState: WebViewNavigation) => void;
canGoBackRef: React.RefObject<boolean>;
currentWebPathRef: React.RefObject<string>;
prepareGoBack: () => void;
}

/**
Expand All @@ -30,7 +30,6 @@ export function useWebNavigation({
currentPath,
syncTargetPathRef,
onRetryCountReset,
onDetailNavigation,
}: UseWebNavigationOptions): UseWebNavigationReturn {
const router = useRouter();
const { i18n } = useTranslation();
Expand Down Expand Up @@ -189,9 +188,9 @@ export function useWebNavigation({
detailRouteOriginRef.current = currentPath;
lastMobileNavigationRef.current = detailPath;
router.navigate(detailPath as Href);
// Set skip ref now so the stale iOS replay after onDetailNavigation's MOBILE_NAVIGATE is caught.
// Prime skipNextDetailRef so the stale iOS WKWebView replay event fired
// after goBack() (in useFocusEffect) is silently dropped.
skipNextDetailRef.current = webPathWithoutQuery;
onDetailNavigation?.();
} else {
// navigate() switches the active tab in place; push() would add a root-level
// (tabs) stack entry and flash the dashboard before settling on the target tab.
Expand All @@ -208,17 +207,27 @@ export function useWebNavigation({
webBaseUrl,
currentPath,
onRetryCountReset,
onDetailNavigation,
extractLocaleFromPath,
getRouteNameForPath,
router,
i18n,
],
);

// Call this immediately before webviewRef.goBack() when returning from a detail
// route. It re-primes skipNextDetailRef so that any stale iOS WKWebView event
// fired after the back navigation is silently dropped instead of re-triggering
// a detail route navigation. Must NOT strip the locale prefix — the check in
// handleNavigationStateChange compares against webPathWithoutQuery which still
// has it.
const prepareGoBack = useCallback(() => {
skipNextDetailRef.current = currentWebPathRef.current.split("?")[0];
}, []);

return {
handleNavigationStateChange,
canGoBackRef,
currentWebPathRef,
prepareGoBack,
};
}
2 changes: 1 addition & 1 deletion app/mobile/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "couchers.org",
"main": "expo-router/entry",
"version": "1.1.17",
"version": "1.1.18",
"scripts": {
"start": "expo start",
"start:localhost": "expo start --localhost",
Expand Down
61 changes: 61 additions & 0 deletions app/mobile/tests/components/WebEmbed.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const mockWebViewRef = {
let capturedWebViewProps: {
source?: { uri: string };
allowsBackForwardNavigationGestures?: boolean;
onLoad?: () => void;
onNavigationStateChange?: (navState: {
url: string;
loading: boolean;
Expand Down Expand Up @@ -97,6 +98,7 @@ describe("WebEmbed", () => {
// Mock return values for hooks
const mockPickImage = jest.fn();
const mockHandleNavigationStateChange = jest.fn();
const mockPrepareGoBack = jest.fn();
const mockCanGoBackRef = { current: false };
const mockCurrentWebPathRef = { current: "/dashboard" };

Expand Down Expand Up @@ -130,6 +132,7 @@ describe("WebEmbed", () => {
handleNavigationStateChange: mockHandleNavigationStateChange,
canGoBackRef: mockCanGoBackRef,
currentWebPathRef: mockCurrentWebPathRef,
prepareGoBack: mockPrepareGoBack,
});
});

Expand Down Expand Up @@ -381,6 +384,64 @@ describe("WebEmbed", () => {
});
});

describe("focus sync: returning from detail page", () => {
it("calls prepareGoBack and goBack when refocused on a detail URL with history", () => {
mockCurrentWebPathRef.current = "/user/username";
mockCanGoBackRef.current = true;

const { rerender } = render(<WebEmbed path="/search" />);

// Simulate WebView finishing its initial load
act(() => {
capturedWebViewProps.onLoad?.();
});

// Simulate tab regaining focus (useFocusEffect fires again after load)
rerender(<WebEmbed path="/search" />);

expect(mockPrepareGoBack).toHaveBeenCalled();
expect(mockWebViewRef.goBack).toHaveBeenCalled();
expect(mockWebViewRef.injectJavaScript).not.toHaveBeenCalledWith(
expect.stringContaining("MOBILE_NAVIGATE"),
);
});

it("falls back to MOBILE_NAVIGATE when WebView has no history to go back through", () => {
mockCurrentWebPathRef.current = "/user/username";
mockCanGoBackRef.current = false;

const { rerender } = render(<WebEmbed path="/search" />);

act(() => {
capturedWebViewProps.onLoad?.();
});

rerender(<WebEmbed path="/search" />);

expect(mockPrepareGoBack).not.toHaveBeenCalled();
expect(mockWebViewRef.goBack).not.toHaveBeenCalled();
expect(mockWebViewRef.injectJavaScript).toHaveBeenCalledWith(
expect.stringContaining("MOBILE_NAVIGATE"),
);
});

it("does not call goBack when the WebView is already on the correct tab root", () => {
mockCurrentWebPathRef.current = "/search";
mockCanGoBackRef.current = true;

const { rerender } = render(<WebEmbed path="/search" />);

act(() => {
capturedWebViewProps.onLoad?.();
});

rerender(<WebEmbed path="/search" />);

expect(mockPrepareGoBack).not.toHaveBeenCalled();
expect(mockWebViewRef.goBack).not.toHaveBeenCalled();
});
});

// Note: Android back button tests below test integration glue code
// between BackHandler and WebView. While not pure user behavior tests,
// they verify important functionality that can't be tested by simulating
Expand Down
Loading
Loading