From 3672a9024f6ec8e740db6aa5b5e138d95e253433 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:18:02 -0700 Subject: [PATCH 1/3] refactor(mobile): app update checks start with the app, Home stops rewriting its saved filter App update checks started from an effect in HomeRouteScreen, guarded by module-level "started" flags so remounts would not repeat them. They are app-lifetime work, so they now install once from App.tsx's service list (installAppUpdateChecks), which also removes the AppState listener when the foundation is replaced. Home and the tablet thread sidebar each ran an effect that wrote the resolved legacy scratch project key back into the shared list filter (and so into saved preferences). Both already resolve the key in render, so the write-back effects are gone and the saved filter keeps what the user picked. Effects: HomeRouteScreen 2 -> 0, ThreadNavigationSidebar -1. No effects added. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/App.tsx | 2 + .../src/features/home/HomeRouteScreen.tsx | 27 +++----- .../threads/ThreadNavigationSidebar.tsx | 7 +-- .../src/features/updates/app-updates.test.ts | 22 +++---- .../src/features/updates/app-updates.ts | 61 +++++++------------ 5 files changed, 47 insertions(+), 72 deletions(-) diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 1443544d94697..844835236605b 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -76,6 +76,7 @@ import { rootNavigationRef, } from "./lib/rootNavigation"; import { pruneNewTaskBranchCheckouts } from "./features/threads/NewTaskDraftRouteScreen"; +import { installAppUpdateChecks } from "./features/updates/app-updates"; import "../global.css"; @@ -160,6 +161,7 @@ const appServiceDisposers = [ startIncomingShareInbox(), installAgentNotificationNavigation(linkToAppPath), installAppShortcuts(linkToAppPath), + installAppUpdateChecks(), ]; disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.hot, () => { for (const dispose of appServiceDisposers) dispose(); diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 5756a140e779b..849545b7ed6ef 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -6,7 +6,7 @@ import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; @@ -22,7 +22,6 @@ import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useDeviceType, useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; -import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; @@ -95,11 +94,6 @@ export function HomeRouteScreen() { [selectThreadRoute], ); - useEffect(() => { - void checkForAppUpdateOnLaunch(); - startAppUpdateForegroundRecheck(); - }, []); - const { archiveThread, confirmDeleteThread, @@ -143,16 +137,15 @@ export function HomeRouteScreen() { setSelectedProjectKey, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; - const projectScopeKey = listOptions.selectedProjectKey; - const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { - sidebarProjectGroupingMode: listOptions.projectGroupingMode, - sidebarProjectGroupingOverrides: {}, - }); - useEffect(() => { - if (resolvedProjectKey !== projectScopeKey) { - setSelectedProjectKey(resolvedProjectKey); - } - }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]); + // Legacy scratch keys resolve here rather than being rewritten into the saved filter. + const resolvedProjectKey = resolveScratchProjectScopeKey( + listOptions.selectedProjectKey, + projects, + { + sidebarProjectGroupingMode: listOptions.projectGroupingMode, + sidebarProjectGroupingOverrides: {}, + }, + ); const projectFilterOptions = useMemo( () => buildHomeProjectScopes({ diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index cd2499e296926..19f0d86abb977 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -12,7 +12,7 @@ import { AnimatedLegendList } from "@legendapp/list/reanimated"; import { THREAD_SIDEBAR_ROW_LAYOUT } from "./threadListAnimations"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import type { LayoutChangeEvent, TextInputInstance } from "react-native"; import { Platform, StyleSheet, TextInput, View } from "react-native"; import { GestureDetector, useNativeGesture } from "react-native-gesture-handler"; @@ -195,11 +195,6 @@ function ThreadNavigationSidebarPane( sidebarProjectGroupingMode: options.projectGroupingMode, sidebarProjectGroupingOverrides: {}, }); - useEffect(() => { - if (resolvedProjectKey !== projectScopeKey) { - setSelectedProjectKey(resolvedProjectKey); - } - }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]); const projectScopes = useMemo( () => buildHomeProjectScopes({ diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts index 73c95d01d8eb1..882659bbea324 100644 --- a/apps/mobile/src/features/updates/app-updates.test.ts +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -1,8 +1,9 @@ +import { AppState } from "react-native"; import { describe, expect, it, vi } from "vite-plus/test"; import { createAppUpdateDeferral, - createAppUpdateLaunchCheck, + installAppUpdateChecks, runAppUpdateCheck, type AppUpdateCheckState, type AppUpdateClient, @@ -234,10 +235,9 @@ describe("runAppUpdateCheck", () => { const client = makeUpdateClient({ checkForUpdateAsync: vi.fn(() => checkResult), }); - const checkOnLaunch = createAppUpdateLaunchCheck(client); const manualStates: AppUpdateCheckState[] = []; - const launchCheck = checkOnLaunch(); + const launchCheck = runAppUpdateCheck({ client }); const manualCheck = runAppUpdateCheck({ client, onStateChange: (state) => manualStates.push(state), @@ -303,16 +303,16 @@ describe("runAppUpdateCheck", () => { }); }); -describe("createAppUpdateLaunchCheck", () => { - it("checks at most once for each JavaScript launch", async () => { +describe("installAppUpdateChecks", () => { + it("checks once at launch and stops listening for foregrounds when disposed", () => { + const remove = vi.fn(); + vi.mocked(AppState.addEventListener).mockReturnValueOnce({ remove }); const client = makeUpdateClient(); - const checkOnLaunch = createAppUpdateLaunchCheck(client); - const first = checkOnLaunch(); - const second = checkOnLaunch(); - await first; - - expect(second).toBeUndefined(); + const dispose = installAppUpdateChecks(client); expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + dispose(); + + expect(remove).toHaveBeenCalledOnce(); }); }); diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts index 87111109ebb8a..2d418f028f3de 100644 --- a/apps/mobile/src/features/updates/app-updates.ts +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -466,20 +466,6 @@ function isAppUpdateUnavailableError(error: unknown): boolean { return typeof code === "string" && UPDATE_CHECK_UNAVAILABLE_ERROR_CODES.has(code); } -export function createAppUpdateLaunchCheck( - client: AppUpdateClient = Updates, -): () => Promise | undefined { - let started = false; - - return () => { - if (started || !isAppUpdateCheckAvailable(client)) return undefined; - started = true; - return runAppUpdateCheck({ client }); - }; -} - -export const checkForAppUpdateOnLaunch = createAppUpdateLaunchCheck(); - export const FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS = 15 * 60 * 1000; export function shouldRecheckAppUpdateOnForeground( @@ -494,31 +480,30 @@ export function shouldRecheckAppUpdateOnForeground( ); } -export function createAppUpdateForegroundRecheck( +/** + * App-lifetime update checks: one at launch, then again when the app returns + * from a long stay in the background. + */ +export function installAppUpdateChecks( client: AppUpdateClient = Updates, deferral: AppUpdateDeferral = appUpdateDeferral, ): () => void { - let started = false; - - return () => { - if (started || !isAppUpdateCheckAvailable(client)) return; - started = true; - let backgroundedAtMs: number | null = null; - AppState.addEventListener("change", (state) => { - if (state === "background") { - backgroundedAtMs = Date.now(); - return; - } - if (state !== "active") return; - const shouldCheck = shouldRecheckAppUpdateOnForeground( - backgroundedAtMs, - Date.now(), - deferral.pendingInstall, - ); - backgroundedAtMs = null; - if (shouldCheck) void runAppUpdateCheck({ client, deferral }); - }); - }; + if (!isAppUpdateCheckAvailable(client)) return () => undefined; + void runAppUpdateCheck({ client }); + let backgroundedAtMs: number | null = null; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "background") { + backgroundedAtMs = Date.now(); + return; + } + if (state !== "active") return; + const shouldCheck = shouldRecheckAppUpdateOnForeground( + backgroundedAtMs, + Date.now(), + deferral.pendingInstall, + ); + backgroundedAtMs = null; + if (shouldCheck) void runAppUpdateCheck({ client, deferral }); + }); + return () => subscription.remove(); } - -export const startAppUpdateForegroundRecheck = createAppUpdateForegroundRecheck(); From a6bdf38363d09aca5ec07281c0f2b715c52a5d6c Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:23:51 -0700 Subject: [PATCH 2/3] fix(mobile): keep the saved scratch-filter migration Review found that dropping Home's and the tablet sidebar's write-back effects also dropped the persisted legacy scratch key migration from #17050. resolveScratchProjectScopeKey maps a legacy key only while the original host's scratch project is loaded, so without the write-back the saved filter falls back to All once that host is gone. Restore main's effects in both files; this PR now covers only app update checks. Co-Authored-By: Claude Opus 5.5 --- .../src/features/home/HomeRouteScreen.tsx | 21 ++++++++++--------- .../threads/ThreadNavigationSidebar.tsx | 7 ++++++- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 849545b7ed6ef..b4db8f499ecc9 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -6,7 +6,7 @@ import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; @@ -137,15 +137,16 @@ export function HomeRouteScreen() { setSelectedProjectKey, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; - // Legacy scratch keys resolve here rather than being rewritten into the saved filter. - const resolvedProjectKey = resolveScratchProjectScopeKey( - listOptions.selectedProjectKey, - projects, - { - sidebarProjectGroupingMode: listOptions.projectGroupingMode, - sidebarProjectGroupingOverrides: {}, - }, - ); + const projectScopeKey = listOptions.selectedProjectKey; + const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { + sidebarProjectGroupingMode: listOptions.projectGroupingMode, + sidebarProjectGroupingOverrides: {}, + }); + useEffect(() => { + if (resolvedProjectKey !== projectScopeKey) { + setSelectedProjectKey(resolvedProjectKey); + } + }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]); const projectFilterOptions = useMemo( () => buildHomeProjectScopes({ diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 19f0d86abb977..cd2499e296926 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -12,7 +12,7 @@ import { AnimatedLegendList } from "@legendapp/list/reanimated"; import { THREAD_SIDEBAR_ROW_LAYOUT } from "./threadListAnimations"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { LayoutChangeEvent, TextInputInstance } from "react-native"; import { Platform, StyleSheet, TextInput, View } from "react-native"; import { GestureDetector, useNativeGesture } from "react-native-gesture-handler"; @@ -195,6 +195,11 @@ function ThreadNavigationSidebarPane( sidebarProjectGroupingMode: options.projectGroupingMode, sidebarProjectGroupingOverrides: {}, }); + useEffect(() => { + if (resolvedProjectKey !== projectScopeKey) { + setSelectedProjectKey(resolvedProjectKey); + } + }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]); const projectScopes = useMemo( () => buildHomeProjectScopes({ From c3154bb2de8cc18a1a6d6ed8fccc23c56d2957ff Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:08:17 -0700 Subject: [PATCH 3/3] fix(mobile): the launch update check shares the injected deferral Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/updates/app-updates.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts index 2d418f028f3de..ead6f68ccc783 100644 --- a/apps/mobile/src/features/updates/app-updates.ts +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -489,7 +489,7 @@ export function installAppUpdateChecks( deferral: AppUpdateDeferral = appUpdateDeferral, ): () => void { if (!isAppUpdateCheckAvailable(client)) return () => undefined; - void runAppUpdateCheck({ client }); + void runAppUpdateCheck({ client, deferral }); let backgroundedAtMs: number | null = null; const subscription = AppState.addEventListener("change", (state) => { if (state === "background") {