From 7cf4e74dc654972d99049a32dd5ace1d64dafe70 Mon Sep 17 00:00:00 2001 From: Molecule AI App-FE Date: Tue, 12 May 2026 04:32:25 +0000 Subject: [PATCH] =?UTF-8?q?fix(mobile):=20prevent=20pushState=20=E2=86=92?= =?UTF-8?q?=20popstate=20infinite=20loop=20in=20MobileApp=20URL=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React error #185 (Maximum update depth exceeded) on mobile when navigating to ?m=chat&a=... was caused by a render loop in the URL sync effect. Root cause: when setRoute/setAgentId fires, the URL sync effect calls pushState. Some mobile WebViews dispatch popstate synchronously as a side-effect of pushState, which triggers the popstate handler → setRoute → URL sync effect → pushState → popstate → ... infinite loop. Fix: track what we just pushed in a prevPushedRef. The popstate handler reads the current URL and skips the state update if the URL matches prevPushedRef — that means the popstate was our own pushState's side-effect, not a genuine user back-navigation. Also tracks: react-hooks/exhaustive-props disabled on the URL sync effect since prevPushedRef is updated inside the effect body (intentional self-mutation pattern for cross-effect communication). Co-Authored-By: Claude Opus 4.7 --- canvas/src/components/mobile/MobileApp.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/canvas/src/components/mobile/MobileApp.tsx b/canvas/src/components/mobile/MobileApp.tsx index a527a5ef..bd966755 100644 --- a/canvas/src/components/mobile/MobileApp.tsx +++ b/canvas/src/components/mobile/MobileApp.tsx @@ -6,7 +6,7 @@ // instantiated when MobileApp is active, so no React Flow + heavy // chrome cost on phones. -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTheme } from "@/lib/theme-provider"; @@ -90,20 +90,30 @@ export function MobileApp() { // the URL is already what we'd produce — that handles the initial // mount (we read FROM the URL) and prevents duplicate history entries // when popstate restores state we just pushed. + // Track what we pushed so the popstate handler can distinguish + // "browser back to our own push" (skip) from "browser back to + // user navigation" (update state). + const prevPushedRef = useRef<{ route: Route; agentId: string | null }>({ route: route as Route, agentId }); useEffect(() => { if (typeof window === "undefined") return; const current = readRouteFromUrl(); if (current.route === route && current.agentId === agentId) return; const url = buildRouteUrl(route, agentId); + prevPushedRef.current = { route, agentId }; window.history.pushState({ route, agentId }, "", url); - }, [route, agentId]); + }, [route, agentId]); // eslint-disable-line react-hooks/exhaustive-props // Sync URL → route state on browser back/forward. The popstate event // fires AFTER the URL has changed, so re-reading is correct. + // Skip: if the URL now reflects the state we just pushed (popstate was + // fired synchronously by our own pushState on some mobile browsers). useEffect(() => { if (typeof window === "undefined") return; const onPop = () => { const next = readRouteFromUrl(); + // If the URL reflects what we just pushed, skip — this popstate + // was a side-effect of our own pushState, not user navigation. + if (next.route === prevPushedRef.current.route && next.agentId === prevPushedRef.current.agentId) return; setRoute(next.route); setAgentId(next.agentId); };