Skip to content

Commit 79f6cd3

Browse files
refactor(mobile): workspace tabs, home filters and connection status derive instead of syncing (#25813)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 790ba13 commit 79f6cd3

8 files changed

Lines changed: 176 additions & 125 deletions

‎apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx‎

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
2-
import { createContext, use, useEffect, useMemo, useState, type ReactNode } from "react";
2+
import { createContext, use, useEffect, useState, type ReactNode } from "react";
33
import { Animated, Platform, Pressable, View } from "react-native";
44

55
import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
@@ -11,6 +11,7 @@ import {
1111
CompactBrandTitle,
1212
getCompactBrandHeaderOptions,
1313
} from "../../components/CompactBrandTitle";
14+
import { useDeadlinePassed } from "../../lib/clock";
1415
import { useWorkspaceState } from "../../state/workspace";
1516
import {
1617
workspaceConnectionStatusPresentation,
@@ -37,29 +38,24 @@ export function WorkspaceConnectionStatusProvider(props: { readonly children: Re
3738
const { state } = useWorkspaceState();
3839
const presentation = workspaceConnectionStatusPresentation(state);
3940
const hasStatus = presentation !== null;
40-
const [phase, setPhase] = useState<"waiting" | "fading" | "shown">("waiting");
41-
42-
useEffect(() => {
43-
if (!hasStatus) return;
44-
const timers = [
45-
setTimeout(() => setPhase("fading"), STATUS_SHOW_DELAY_MS),
46-
setTimeout(() => setPhase("shown"), STATUS_SHOW_DELAY_MS + FADE_IN_MS),
47-
];
48-
return () => {
49-
for (const timer of timers) clearTimeout(timer);
50-
setPhase("waiting");
51-
};
52-
}, [hasStatus]);
41+
// When the current status run began; a gap in the status restarts the delay.
42+
const [statusSince, setStatusSince] = useState(() => (hasStatus ? Date.now() : null));
43+
if (hasStatus !== (statusSince !== null)) {
44+
// oxlint-disable-next-line react/purity -- Reads the clock only in the render-phase adjustment that runs when the status appears.
45+
setStatusSince(hasStatus ? Date.now() : null);
46+
}
47+
const since = hasStatus ? statusSince : null;
48+
const fadeStarted = useDeadlinePassed(since === null ? null : since + STATUS_SHOW_DELAY_MS);
49+
const fadeEnded = useDeadlinePassed(
50+
since === null ? null : since + STATUS_SHOW_DELAY_MS + FADE_IN_MS,
51+
);
5352

5453
const label = presentation?.label;
5554
const showsProgress = presentation?.showsProgress ?? false;
56-
const status = useMemo(
57-
() =>
58-
label === undefined || phase === "waiting"
59-
? null
60-
: { label, showsProgress, fadeIn: phase === "fading" },
61-
[label, showsProgress, phase],
62-
);
55+
const status =
56+
label === undefined || since === null || !fadeStarted
57+
? null
58+
: { label, showsProgress, fadeIn: !fadeEnded };
6359
return <ConnectionStatusContext value={status}>{props.children}</ConnectionStatusContext>;
6460
}
6561

@@ -72,7 +68,6 @@ function StatusFadeIn(props: {
7268
}) {
7369
const { animate } = props;
7470
const [opacity] = useState(() => new Animated.Value(0));
75-
7671
useEffect(() => {
7772
if (!animate) return;
7873
const animation = Animated.timing(opacity, {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { EnvironmentId } from "@t3tools/contracts";
2+
import { act, useLayoutEffect } from "react";
3+
import { createRoot } from "react-dom/client";
4+
import { expect, it, vi } from "vite-plus/test";
5+
6+
const harness = vi.hoisted(() => ({ saved: [] as unknown[] }));
7+
8+
vi.mock("../../state/preferences", () => ({ updateMobilePreferencesAtom: {} }));
9+
vi.mock("@effect/atom-react", async (importOriginal) => ({
10+
...(await importOriginal<typeof import("@effect/atom-react")>()),
11+
useAtomSet: () => (value: unknown) => harness.saved.push(value),
12+
}));
13+
14+
import { HomeListOptionsProvider, useHomeListOptions } from "./home-list-options";
15+
16+
const ENVIRONMENT = "env-1" as EnvironmentId;
17+
const AVAILABLE = new Set([ENVIRONMENT]);
18+
19+
it("saves the scope from every update made in one event", async () => {
20+
harness.saved = [];
21+
let controls: ReturnType<typeof useHomeListOptions> | undefined;
22+
function Probe() {
23+
const result = useHomeListOptions(AVAILABLE);
24+
useLayoutEffect(() => {
25+
controls = result;
26+
});
27+
return null;
28+
}
29+
30+
const root = createRoot(document.createElement("div"));
31+
await act(async () =>
32+
root.render(
33+
<HomeListOptionsProvider projectGroupingMode="repository">
34+
<Probe />
35+
</HomeListOptionsProvider>,
36+
),
37+
);
38+
act(() => {
39+
controls!.setSelectedEnvironmentId(ENVIRONMENT);
40+
controls!.setSelectedProjectKey("env-1:project-1");
41+
});
42+
43+
expect(harness.saved.at(-1)).toEqual({
44+
threadListFilters: { environmentId: ENVIRONMENT, projectKey: "env-1:project-1" },
45+
});
46+
expect(controls!.options.selectedEnvironmentId).toBe(ENVIRONMENT);
47+
expect(controls!.options.selectedProjectKey).toBe("env-1:project-1");
48+
act(() => root.unmount());
49+
});

‎apps/mobile/src/features/home/home-list-options.ts‎

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
11
import type { EnvironmentId, SidebarProjectGroupingMode } from "@t3tools/contracts";
22
import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER } from "@t3tools/contracts";
3-
import { useAtomSet } from "@effect/atom-react";
4-
import {
5-
createContext,
6-
createElement,
7-
useContext,
8-
useEffect,
9-
useRef,
10-
useState,
11-
type PropsWithChildren,
12-
type Dispatch,
13-
type SetStateAction,
14-
} from "react";
3+
import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react";
4+
import { Atom } from "effect/reactivity";
5+
import { createContext, createElement, useContext, useState, type PropsWithChildren } from "react";
156

167
import type { ThreadListFilters } from "../../persistence/mobile-preferences";
178
import { updateMobilePreferencesAtom } from "../../state/preferences";
@@ -40,7 +31,7 @@ function defaultHomeListOptions(filters?: ThreadListFilters): HomeListOptions {
4031

4132
interface HomeListOptionsContextValue {
4233
readonly options: HomeListOptions;
43-
readonly setOptions: Dispatch<SetStateAction<HomeListOptions>>;
34+
readonly updateOptions: (patch: Partial<HomeListOptions>) => void;
4435
readonly projectGroupingMode: SidebarProjectGroupingMode;
4536
}
4637

@@ -54,28 +45,39 @@ export function HomeListOptionsProvider({
5445
readonly projectGroupingMode: SidebarProjectGroupingMode;
5546
readonly savedFilters?: ThreadListFilters | undefined;
5647
}>) {
57-
const [options, setOptions] = useState(() => defaultHomeListOptions(savedFilters));
48+
const registry = useContext(RegistryContext);
49+
const [optionsAtom] = useState(() => Atom.make(defaultHomeListOptions(savedFilters)));
50+
const options = useAtomValue(optionsAtom);
5851
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
59-
const saved = useRef(savedFilters);
60-
const { selectedEnvironmentId: environmentId, selectedProjectKey: projectKey } = options;
61-
useEffect(() => {
52+
// Only the scope persists; the sort order is per session. Updates read the
53+
// registry, so several in one event each build on the last and save it.
54+
const updateOptions = (patch: Partial<HomeListOptions>) => {
55+
const current = registry.get(optionsAtom);
56+
const next = { ...current, ...patch };
57+
registry.set(optionsAtom, next);
6258
if (
63-
(saved.current?.environmentId ?? null) === environmentId &&
64-
(saved.current?.projectKey ?? null) === projectKey
65-
)
66-
return;
67-
saved.current = { environmentId, projectKey };
68-
savePreferences({ threadListFilters: saved.current });
69-
}, [environmentId, projectKey, savePreferences]);
70-
const value = { options, setOptions, projectGroupingMode };
59+
next.selectedEnvironmentId !== current.selectedEnvironmentId ||
60+
next.selectedProjectKey !== current.selectedProjectKey
61+
) {
62+
savePreferences({
63+
threadListFilters: {
64+
environmentId: next.selectedEnvironmentId,
65+
projectKey: next.selectedProjectKey,
66+
},
67+
});
68+
}
69+
};
70+
const value = { options, updateOptions, projectGroupingMode };
7171
return createElement(HomeListOptionsContext, { value }, children);
7272
}
7373

7474
export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<EnvironmentId>) {
7575
const shared = useContext(HomeListOptionsContext);
7676
const [localOptions, setLocalOptions] = useState<HomeListOptions>(defaultHomeListOptions);
7777
const options = shared?.options ?? localOptions;
78-
const setOptions = shared?.setOptions ?? setLocalOptions;
78+
const updateOptions =
79+
shared?.updateOptions ??
80+
((patch: Partial<HomeListOptions>) => setLocalOptions((current) => ({ ...current, ...patch })));
7981
const selectedEnvironmentId =
8082
options.selectedEnvironmentId !== null &&
8183
availableEnvironmentIds.has(options.selectedEnvironmentId)
@@ -91,13 +93,13 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<Environm
9193
};
9294

9395
const setSelectedEnvironmentId = (value: EnvironmentId | null) => {
94-
setOptions((current) => ({ ...current, selectedEnvironmentId: value }));
96+
updateOptions({ selectedEnvironmentId: value });
9597
};
9698
const setSelectedProjectKey = (value: string | null) => {
97-
setOptions((current) => ({ ...current, selectedProjectKey: value }));
99+
updateOptions({ selectedProjectKey: value });
98100
};
99101
const setProjectSortOrder = (value: HomeProjectSortOrder) => {
100-
setOptions((current) => ({ ...current, projectSortOrder: value }));
102+
updateOptions({ projectSortOrder: value });
101103
};
102104
return {
103105
options: resolvedOptions,

‎apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useKeyboardState } from "react-native-keyboard-controller";
1313

1414
import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing";
1515
import { useToolbarLayout } from "../../components/ui/useToolbarLayout";
16+
import { rootNavigationRef } from "../../lib/rootNavigation";
1617
import { useWorkspaceConnectionState } from "../../state/workspace";
1718
import { androidWorkspaceNavigationColors } from "./android-workspace-navigation-colors";
1819
import {
@@ -116,11 +117,12 @@ export function AndroidWorkspaceNavigation(props: {
116117
const tab = event.nativeEvent.tab;
117118
if (tab !== "settings") workspace.dispatch({ type: "select", tab });
118119
// Reset the root stack even when a nested settings navigator has focus.
119-
if (root) {
120+
// The provider renders inside the root stack's layout, so the container's root state is
121+
// that stack's current state.
122+
const rootState = rootNavigationRef.getRootState();
123+
if (root && rootState) {
120124
navigation.dispatch({
121-
...CommonActions.reset(
122-
androidWorkspaceTabNavigationState(root.getState(), tab, rail),
123-
),
125+
...CommonActions.reset(androidWorkspaceTabNavigationState(rootState, tab, rail)),
124126
target: root.key,
125127
});
126128
}

‎apps/mobile/src/features/workspace/AndroidWorkspaceTabsProvider.tsx‎

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,15 @@
11
import type { NavigationState } from "@react-navigation/native";
2-
import {
3-
createContext,
4-
use,
5-
useCallback,
6-
useLayoutEffect,
7-
useMemo,
8-
useRef,
9-
type ReactNode,
10-
} from "react";
2+
import { createContext, use, type ReactNode } from "react";
113
import { Platform } from "react-native";
124
import { androidWorkspaceSettingsOpen } from "./android-workspace-tabs";
135
import { WorkspaceTabsProvider } from "./WorkspaceTabsContext";
146

157
interface AndroidWorkspaceRoot {
168
/**
179
* The rail's `useNavigation()` dispatches to the focused navigator, which may be nested Settings.
18-
* Use the root stack's state and key so a tab press resets the root.
10+
* Target the root stack's key (and read `rootNavigationRef`'s state) so a tab press resets the root.
1911
*/
2012
readonly key: string;
21-
readonly getState: () => NavigationState;
2213
/** Settings is derived from navigation rather than kept in the tab reducer. */
2314
readonly settingsOpen: boolean;
2415
readonly settingsRoute: NavigationState["routes"][number] | undefined;
@@ -36,21 +27,13 @@ export function AndroidWorkspaceTabsProvider(props: {
3627
readonly state: NavigationState;
3728
readonly children: ReactNode;
3829
}) {
39-
const stateRef = useRef(props.state);
40-
useLayoutEffect(() => {
41-
stateRef.current = props.state;
42-
}, [props.state]);
43-
const getState = useCallback(() => stateRef.current, []);
4430
const key = props.state.key;
4531
const settingsOpen = Platform.OS === "android" && androidWorkspaceSettingsOpen(props.state);
4632
const settingsIndex = props.state.routes.findIndex((route) => route.name === "Settings");
4733
const settingsRoute = settingsIndex < 0 ? undefined : props.state.routes[settingsIndex];
4834
const settingsPage =
4935
settingsIndex < 0 ? null : (props.state.routes[settingsIndex + 1]?.name ?? null);
50-
const root = useMemo(
51-
() => ({ key, getState, settingsOpen, settingsRoute, settingsPage }),
52-
[key, getState, settingsOpen, settingsRoute, settingsPage],
53-
);
36+
const root = { key, settingsOpen, settingsRoute, settingsPage };
5437
return Platform.OS === "android" ? (
5538
<WorkspaceTabsProvider>
5639
<AndroidWorkspaceRootContext value={root}>{props.children}</AndroidWorkspaceRootContext>

0 commit comments

Comments
 (0)