| author | |
| committer | |
| log | 65404eb75de9233c9d1068ddfd09bacfc3b0465b |
| tree | 0ae25a11fcdd2b8cd4be973daa76ed3cd9d12e2b |
| parent | eb17e0db2bd075e71b4015dc57588e7f838cbcdc |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 9 | 9 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 10 | 10 | |
| 11 | 11 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 12 | import { useProjects, useNavigationThreadShells } from "../../state/entities"; | |
| 12 | import { useProjects } from "../../state/entities"; | |
| 13 | import { environmentThreadShells } from "../../state/threads"; | |
| 13 | 14 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; |
| 14 | 15 | import { useWorkspaceState } from "../../state/workspace"; |
| 15 | 16 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| ... | ... | @@ -22,6 +23,7 @@ import { AndroidHomeFabLayout } from "./AndroidHomeFab"; |
| 22 | 23 | import { HomeScreen } from "./HomeScreen"; |
| 23 | 24 | import { HomeHeader } from "./HomeHeader"; |
| 24 | 25 | import { useHomeListOptions } from "./home-list-options"; |
| 26 | import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; | |
| 25 | 27 | import { useHomeThreadSelection } from "./home-thread-navigation"; |
| 26 | 28 | import { buildHomeProjectScopes } from "./homeThreadList"; |
| 27 | 29 | import { usePendingTaskListActions } from "./usePendingTaskListActions"; |
| ... | ... | @@ -37,7 +39,13 @@ export function HomeRouteScreen() { |
| 37 | 39 | const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; |
| 38 | 40 | const { layout, panes } = useAdaptiveWorkspaceLayout(); |
| 39 | 41 | 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 | ); | |
| 41 | 49 | const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); |
| 42 | 50 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 43 | 51 | const navigation = useNavigation(); |
apps/mobile/src/features/home/home-route-visibility.test.tsx created+143| ... | ... | @@ -0,0 +1,143 @@ |
| 1 | // @vitest-environment jsdom | |
| 2 | import { RegistryContext } from "@effect/atom-react"; | |
| 3 | import { Atom, AtomRegistry } from "effect/reactivity"; | |
| 4 | import { act, useEffect } from "react"; | |
| 5 | import { createRoot, type Root } from "react-dom/client"; | |
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 7 | ||
| 8 | type Listener = (event: { readonly data?: { readonly closing: boolean } }) => void; | |
| 9 | ||
| 10 | const 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 | ||
| 34 | vi.mock("@react-navigation/native", () => ({ | |
| 35 | useNavigation: () => harness.navigation, | |
| 36 | })); | |
| 37 | ||
| 38 | import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; | |
| 39 | ||
| 40 | const shellsAtom = Atom.make(0).pipe(Atom.keepAlive); | |
| 41 | let registry: AtomRegistry.AtomRegistry; | |
| 42 | let root: Root | null = null; | |
| 43 | // Value of every committed render, the work a shell update costs on device. | |
| 44 | let renders: Array<{ readonly visible: boolean; readonly value: number }> = []; | |
| 45 | ||
| 46 | function Probe() { | |
| 47 | const visible = useHomeRouteVisible(); | |
| 48 | const value = useAtomValueWhileVisible(shellsAtom, visible); | |
| 49 | useEffect(() => { | |
| 50 | renders.push({ visible, value }); | |
| 51 | }); | |
| 52 | return null; | |
| 53 | } | |
| 54 | ||
| 55 | function 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 | ||
| 67 | function stream(count: number) { | |
| 68 | for (let index = 0; index < count; index += 1) { | |
| 69 | act(() => registry.update(shellsAtom, (value) => value + 1)); | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | function pushThread() { | |
| 74 | act(() => harness.emit("transitionStart", { closing: true })); | |
| 75 | act(() => harness.emit("transitionEnd", { closing: true })); | |
| 76 | } | |
| 77 | ||
| 78 | beforeEach(() => { | |
| 79 | registry = AtomRegistry.make(); | |
| 80 | harness.listeners.clear(); | |
| 81 | harness.state.focused = true; | |
| 82 | renders = []; | |
| 83 | }); | |
| 84 | ||
| 85 | afterEach(() => { | |
| 86 | act(() => root?.unmount()); | |
| 87 | root = null; | |
| 88 | registry.dispose(); | |
| 89 | }); | |
| 90 | ||
| 91 | describe("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 @@ |
| 1 | import { RegistryContext } from "@effect/atom-react"; | |
| 2 | import { useNavigation } from "@react-navigation/native"; | |
| 3 | import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; | |
| 4 | import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react"; | |
| 5 | import 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 | */ | |
| 17 | export 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 | */ | |
| 46 | export 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 | } |