authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 22:30:48-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 22:30:48-07:00
log65404eb75de9233c9d1068ddfd09bacfc3b0465b
tree0ae25a11fcdd2b8cd4be973daa76ed3cd9d12e2b
parenteb17e0db2bd075e71b4015dc57588e7f838cbcdc
signature Signed by PGP key B5690EEEBB952194

fix(mobile): pause hidden home thread list updates (#15705)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 210 insertions(+), 2 deletions(-)

apps/mobile/src/features/home/HomeRouteScreen.tsx+10-2
......@@ -9,7 +9,8 @@ import { Platform, useWindowDimensions } from "react-native";
99import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
1010
1111import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
12import { useProjects, useNavigationThreadShells } from "../../state/entities";
12import { useProjects } from "../../state/entities";
13import { environmentThreadShells } from "../../state/threads";
1314import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
1415import { useWorkspaceState } from "../../state/workspace";
1516import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
......@@ -22,6 +23,7 @@ import { AndroidHomeFabLayout } from "./AndroidHomeFab";
2223import { HomeScreen } from "./HomeScreen";
2324import { HomeHeader } from "./HomeHeader";
2425import { useHomeListOptions } from "./home-list-options";
26import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility";
2527import { useHomeThreadSelection } from "./home-thread-navigation";
2628import { buildHomeProjectScopes } from "./homeThreadList";
2729import { usePendingTaskListActions } from "./usePendingTaskListActions";
......@@ -37,7 +39,13 @@ export function HomeRouteScreen() {
3739 const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth;
3840 const { layout, panes } = useAdaptiveWorkspaceLayout();
3941 const projects = useProjects();
40 const threads = useNavigationThreadShells();
42 // Streaming turns rewrite thread shells many times a second. While a Thread
43 // covers Home, rebuilding this list is invisible work.
44 const visible = useHomeRouteVisible();
45 const threads = useAtomValueWhileVisible(
46 environmentThreadShells.navigationThreadShellsAtom,
47 visible,
48 );
4149 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
4250 const { savedConnectionsById } = useSavedRemoteConnections();
4351 const navigation = useNavigation();
apps/mobile/src/features/home/home-route-visibility.test.tsx created+143
......@@ -0,0 +1,143 @@
1// @vitest-environment jsdom
2import { RegistryContext } from "@effect/atom-react";
3import { Atom, AtomRegistry } from "effect/reactivity";
4import { act, useEffect } from "react";
5import { createRoot, type Root } from "react-dom/client";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7
8type Listener = (event: { readonly data?: { readonly closing: boolean } }) => void;
9
10const harness = vi.hoisted(() => {
11 const listeners = new Map<string, Set<Listener>>();
12 const state = { focused: true };
13 return {
14 state,
15 listeners,
16 navigation: {
17 isFocused: () => state.focused,
18 addListener(type: string, listener: Listener) {
19 let set = listeners.get(type);
20 if (set === undefined) {
21 set = new Set();
22 listeners.set(type, set);
23 }
24 set.add(listener);
25 return () => set.delete(listener);
26 },
27 },
28 emit(type: string, data?: { readonly closing: boolean }) {
29 for (const listener of listeners.get(type) ?? []) listener({ data });
30 },
31 };
32});
33
34vi.mock("@react-navigation/native", () => ({
35 useNavigation: () => harness.navigation,
36}));
37
38import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility";
39
40const shellsAtom = Atom.make(0).pipe(Atom.keepAlive);
41let registry: AtomRegistry.AtomRegistry;
42let root: Root | null = null;
43// Value of every committed render, the work a shell update costs on device.
44let renders: Array<{ readonly visible: boolean; readonly value: number }> = [];
45
46function Probe() {
47 const visible = useHomeRouteVisible();
48 const value = useAtomValueWhileVisible(shellsAtom, visible);
49 useEffect(() => {
50 renders.push({ visible, value });
51 });
52 return null;
53}
54
55function mount() {
56 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
57 root = createRoot(document.createElement("div"));
58 act(() =>
59 root!.render(
60 <RegistryContext.Provider value={registry}>
61 <Probe />
62 </RegistryContext.Provider>,
63 ),
64 );
65}
66
67function stream(count: number) {
68 for (let index = 0; index < count; index += 1) {
69 act(() => registry.update(shellsAtom, (value) => value + 1));
70 }
71}
72
73function pushThread() {
74 act(() => harness.emit("transitionStart", { closing: true }));
75 act(() => harness.emit("transitionEnd", { closing: true }));
76}
77
78beforeEach(() => {
79 registry = AtomRegistry.make();
80 harness.listeners.clear();
81 harness.state.focused = true;
82 renders = [];
83});
84
85afterEach(() => {
86 act(() => root?.unmount());
87 root = null;
88 registry.dispose();
89});
90
91describe("Home route visibility", () => {
92 it("re-renders for every shell update while Home is visible", () => {
93 mount();
94 stream(20);
95 expect(renders.at(-1)).toEqual({ visible: true, value: 20 });
96 expect(renders).toHaveLength(21);
97 });
98
99 it("stays live until the covering Thread finishes its push animation", () => {
100 mount();
101 act(() => harness.emit("transitionStart", { closing: true }));
102 stream(3);
103 expect(renders.at(-1)).toEqual({ visible: true, value: 3 });
104 });
105
106 it("does no work for shell updates while a Thread covers Home", () => {
107 mount();
108 stream(2);
109 pushThread();
110 const before = renders.length;
111 stream(600);
112 expect(renders.length - before).toBe(0);
113 expect(renders.at(-1)).toEqual({ visible: false, value: 2 });
114 });
115
116 it("shows the freshest list as soon as a back swipe starts revealing Home", () => {
117 mount();
118 pushThread();
119 stream(50);
120 act(() => harness.emit("transitionStart", { closing: false }));
121 expect(renders.at(-1)).toEqual({ visible: true, value: 50 });
122 stream(1);
123 expect(renders.at(-1)).toEqual({ visible: true, value: 51 });
124 });
125
126 it("refreshes on focus when no appear event arrives", () => {
127 mount();
128 pushThread();
129 stream(5);
130 act(() => harness.emit("focus"));
131 expect(renders.at(-1)).toEqual({ visible: true, value: 5 });
132 });
133
134 it("starts hidden when a deep link mounts Home under its Thread", () => {
135 harness.state.focused = false;
136 act(() => registry.set(shellsAtom, 7));
137 mount();
138 stream(10);
139 expect(renders).toEqual([{ visible: false, value: 7 }]);
140 act(() => harness.emit("transitionStart", { closing: false }));
141 expect(renders.at(-1)).toEqual({ visible: true, value: 17 });
142 });
143});
apps/mobile/src/features/home/home-route-visibility.ts created+57
......@@ -0,0 +1,57 @@
1import { RegistryContext } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";
3import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
4import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
5import type { Atom } from "effect/reactivity";
6
7/**
8 * Whether the Home route can be seen. Home stays mounted under a pushed
9 * Thread, and native-stack never freezes the route directly below the focused
10 * one on Fabric, so Home has to stop its own work.
11 *
12 * Driven by the native appear/disappear events rather than focus. A form sheet
13 * over Home blurs it but leaves it on screen, and the presenting screen gets no
14 * disappear event for it. Home becomes visible as soon as a back swipe or pop
15 * starts revealing it, so the list is fresh during the transition.
16 */
17export function useHomeRouteVisible(): boolean {
18 const navigation =
19 useNavigation<NativeStackNavigationProp<ReactNavigation.RootParamList, "Home">>();
20 // A cold deep link mounts Home under its Thread without ever showing it.
21 const [visible, setVisible] = useState(() => navigation.isFocused());
22 useEffect(() => {
23 const show = () => setVisible(true);
24 const removeTransitionStart = navigation.addListener("transitionStart", ({ data }) => {
25 if (!data.closing) show();
26 });
27 const removeTransitionEnd = navigation.addListener("transitionEnd", ({ data }) => {
28 if (data.closing) setVisible(false);
29 });
30 // Popping a sheet or a deep-linked route above Home has no appear event.
31 const removeFocus = navigation.addListener("focus", show);
32 return () => {
33 removeTransitionStart();
34 removeTransitionEnd();
35 removeFocus();
36 };
37 }, [navigation]);
38 return visible;
39}
40
41/**
42 * Like `useAtomValue`, but unsubscribed while `visible` is false: the last
43 * visible value is held and atom changes do not re-render. Resubscribing reads
44 * the current value in the same render that turns visibility back on.
45 */
46export function useAtomValueWhileVisible<A>(atom: Atom.Atom<A>, visible: boolean): A {
47 const registry = useContext(RegistryContext);
48 const held = useRef<{ readonly value: A } | null>(null);
49 const subscribe = useCallback(
50 (onChange: () => void) => (visible ? registry.subscribe(atom, onChange) : () => undefined),
51 [registry, atom, visible],
52 );
53 return useSyncExternalStore(subscribe, () => {
54 if (visible || held.current === null) held.current = { value: registry.get(atom) };
55 return held.current.value;
56 });
57}