| 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,7 +9,8 @@ import { Platform, useWindowDimensions } from "react-native"; |
| 9 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 9 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 10 | 10 | ||
| 11 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 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 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; | 14 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; |
| 14 | import { useWorkspaceState } from "../../state/workspace"; | 15 | import { useWorkspaceState } from "../../state/workspace"; |
| 15 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 16 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| ... | @@ -22,6 +23,7 @@ import { AndroidHomeFabLayout } from "./AndroidHomeFab"; | ... | @@ -22,6 +23,7 @@ import { AndroidHomeFabLayout } from "./AndroidHomeFab"; |
| 22 | import { HomeScreen } from "./HomeScreen"; | 23 | import { HomeScreen } from "./HomeScreen"; |
| 23 | import { HomeHeader } from "./HomeHeader"; | 24 | import { HomeHeader } from "./HomeHeader"; |
| 24 | import { useHomeListOptions } from "./home-list-options"; | 25 | import { useHomeListOptions } from "./home-list-options"; |
| 26 | import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; | ||
| 25 | import { useHomeThreadSelection } from "./home-thread-navigation"; | 27 | import { useHomeThreadSelection } from "./home-thread-navigation"; |
| 26 | import { buildHomeProjectScopes } from "./homeThreadList"; | 28 | import { buildHomeProjectScopes } from "./homeThreadList"; |
| 27 | import { usePendingTaskListActions } from "./usePendingTaskListActions"; | 29 | import { usePendingTaskListActions } from "./usePendingTaskListActions"; |
| ... | @@ -37,7 +39,13 @@ export function HomeRouteScreen() { | ... | @@ -37,7 +39,13 @@ export function HomeRouteScreen() { |
| 37 | const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; | 39 | const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; |
| 38 | const { layout, panes } = useAdaptiveWorkspaceLayout(); | 40 | const { layout, panes } = useAdaptiveWorkspaceLayout(); |
| 39 | const projects = useProjects(); | 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 | const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); | 49 | const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); |
| 42 | const { savedConnectionsById } = useSavedRemoteConnections(); | 50 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 43 | const navigation = useNavigation(); | 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 | } | ||