diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 7975097f6a931..d57fb7cee9ab0 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -74,6 +74,7 @@ import { } from "./thread-list-v2-items"; import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance"; import type { ThreadPrPresentation } from "../../state/thread-pr-presentation"; +import { ThreadPrDemandProvider } from "../../state/thread-pr-demand"; import { isThreadListV2ListItem, threadListV2ListItemsAreEqual, @@ -115,10 +116,15 @@ interface ThreadNavigationSidebarProps { } export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) { - if (Platform.OS !== "ios") { - return ; - } - return ; + return ( + + {Platform.OS !== "ios" ? ( + + ) : ( + + )} + + ); } function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { diff --git a/apps/mobile/src/state/thread-pr-demand.tsx b/apps/mobile/src/state/thread-pr-demand.tsx new file mode 100644 index 0000000000000..d6e507bd33727 --- /dev/null +++ b/apps/mobile/src/state/thread-pr-demand.tsx @@ -0,0 +1,16 @@ +import { createContext, type ReactNode } from "react"; + +import { useAppActive } from "../lib/appState"; + +export const ThreadPrDemandContext = createContext(true); + +export function ThreadPrDemandProvider(props: { + readonly visible: boolean; + readonly children: ReactNode; +}) { + const active = useAppActive(); + // A sidebar can stay visible beside a focused detail route, so route focus cannot gate its reads. + return ( + {props.children} + ); +} diff --git a/apps/mobile/src/state/use-thread-pr.demand.test.tsx b/apps/mobile/src/state/use-thread-pr.demand.test.tsx new file mode 100644 index 0000000000000..b3e6965531f63 --- /dev/null +++ b/apps/mobile/src/state/use-thread-pr.demand.test.tsx @@ -0,0 +1,345 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + EnvironmentId, + ProjectId, + ThreadId, + WS_METHODS, + type PullRequestRef, +} from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + EnvironmentRegistry, + EnvironmentSupervisor, + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type SupervisorConnectionState, + type PreparedConnection, + type ConnectionCatalogEntry, +} from "@t3tools/client-runtime/connection"; +import type { RpcSession } from "@t3tools/client-runtime/rpc"; +import * as Effect from "effect/Effect"; +import * as Latch from "effect/Latch"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AtomRegistry } from "effect/reactivity"; +import { Activity, act, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; + +const fixture = vi.hoisted(() => ({ + environment: undefined as EnvironmentRegistry.EnvironmentRegistry["Service"] | undefined, + active: "active", + listeners: new Set<() => void>(), +})); + +vi.mock("react-native", () => ({ + AppState: { + get currentState() { + return fixture.active; + }, + addEventListener: (_event: string, listener: () => void) => { + fixture.listeners.add(listener); + return { remove: () => fixture.listeners.delete(listener) }; + }, + }, +})); +vi.mock("../connection/runtime", async () => { + const { Atom } = await import("effect/reactivity"); + const Layer = await import("effect/Layer"); + const { EnvironmentRegistry } = await import("@t3tools/client-runtime/connection"); + return { + connectionAtomRuntime: Atom.runtime( + Layer.suspend(() => + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, fixture.environment!), + ), + ), + }; +}); +vi.mock("./server", async () => { + const { Atom } = await import("effect/reactivity"); + const config = Atom.make({ environment: { capabilities: { threadPullRequests: true } } }); + return { serverEnvironment: { configValueAtom: () => config } }; +}); + +import { ThreadPrDemandProvider } from "./thread-pr-demand"; +import { useThreadPr } from "./use-thread-pr"; + +const environmentId = EnvironmentId.make("sidebar-environment"); +const reference = { + projectId: ProjectId.make("project"), + repository: "acme/app", + number: 12, + url: "https://github.com/acme/app/pull/12", +}; +function thread(overrides: Partial = {}): EnvironmentThreadShell { + return { + environmentId, + id: ThreadId.make("thread"), + pullRequests: [], + linkedPullRequest: null, + branchPullRequest: reference, + ...overrides, + } as EnvironmentThreadShell; +} + +const setup = Effect.fn("TestThreadPr.setup")(function* () { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers({ + toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], + }); + vi.setSystemTime(0); + fixture.active = "active"; + fixture.listeners.clear(); + const requests: Array<{ environmentId: EnvironmentId; input: PullRequestRef }> = []; + let state = "open"; + let replyGate: Latch.Latch | null = null; + const target = new PrimaryConnectionTarget({ + environmentId, + label: "Sidebar environment", + httpBaseUrl: "https://sidebar.test", + wsBaseUrl: "wss://sidebar.test", + }); + const supervisor = yield* Effect.gen(function* () { + return EnvironmentSupervisor.EnvironmentSupervisor.of({ + target, + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + phase: "connected" as const, + desired: true, + network: "online" as const, + attempt: 1, + generation: 1, + }), + session: yield* SubscriptionRef.make( + Option.some({ + client: { + [WS_METHODS.pullRequestsSummary]: (input: PullRequestRef) => + Effect.gen(function* () { + requests.push({ environmentId: currentEnvironment, input }); + if (replyGate !== null) yield* replyGate.await; + return { + ...input, + provider: "github", + state, + title: `Change ${input.number}`, + url: `https://github.com/${input.repository}/pull/${input.number}`, + baseBranch: "main", + headBranch: "feature", + isDraft: false, + updatedAt: null, + }; + }), + [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.never, + }, + ready: Effect.void, + } as unknown as RpcSession), + ), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + }); + let currentEnvironment = environmentId; + const environment = { + entries: yield* SubscriptionRef.make>( + new Map(), + ), + run: (id, effect) => { + currentEnvironment = id; + return Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + }, + followStream: (_id, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + stateChanges: () => SubscriptionRef.changes(supervisor.state), + } satisfies Pick< + EnvironmentRegistry.EnvironmentRegistry["Service"], + "entries" | "run" | "followStream" | "stateChanges" + >; + fixture.environment = + environment as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]; + const registry = AtomRegistry.make(); + const container = document.createElement("div"); + const root = createRoot(container); + let mounts = 0; + function Row({ value, name }: { value: EnvironmentThreadShell; name: string }) { + const [passive] = useState(() => ++mounts); + const pr = useThreadPr(value); + return ( + + {pr?.accessibilityLabel ?? "none"} + + ); + } + return { + requests, + get mounts() { + return mounts; + }, + setState(next: string) { + state = next; + }, + holdReplies() { + replyGate = Latch.makeUnsafe(); + }, + async releaseReplies() { + await act(async () => { + replyGate?.openUnsafe(); + replyGate = null; + }); + }, + badge(name = "sidebar") { + return container.querySelector(`output[data-name="${name}"]`)?.textContent; + }, + passive() { + return container.querySelector("output")?.getAttribute("data-passive"); + }, + async render({ + visible = true, + value = thread(), + shared = false, + activity = "visible", + }: { + visible?: boolean; + value?: EnvironmentThreadShell; + shared?: boolean; + activity?: "visible" | "hidden"; + } = {}) { + await act(async () => + root.render( + + + + + {shared ? : null} + , + ), + ); + }, + async advance(ms: number) { + await act(async () => vi.advanceTimersByTimeAsync(ms)); + }, + async appState(next: string) { + await act(async () => { + fixture.active = next; + for (const listener of fixture.listeners) listener(); + }); + }, + [Symbol.dispose]() { + act(() => root.unmount()); + registry.dispose(); + expect(fixture.listeners.size).toBe(0); + vi.useRealTimers(); + }, + }; +}); + +it.effect( + "releases retained hidden sidebar demand and keeps the badge while reveal revalidates", + () => + Effect.gen(function* () { + using h = yield* setup(); + yield* Effect.promise(async () => { + await h.render(); + expect(h.badge()).toBe("#12 pull request open"); + const passive = h.passive(); + expect(h.requests).toHaveLength(1); + await h.render({ visible: false }); + await h.advance(10_000); + const afterGrace = h.requests.length; + await h.advance(180_000); + expect(h.requests.length - afterGrace).toBe(0); + expect(h.passive()).toBe(passive); + h.holdReplies(); + h.setState("closed"); + await h.render(); + expect(h.badge()).toBe("#12 pull request open"); + expect(h.requests.length).toBe(afterGrace + 1); + expect(h.mounts).toBe(1); + await h.releaseReplies(); + expect(h.badge()).toBe("#12 pull request closed"); + }); + }), +); + +it.effect("does not start a fallback query for a sidebar first mounted hidden", () => + Effect.gen(function* () { + using h = yield* setup(); + yield* Effect.promise(async () => { + await h.render({ visible: false }); + await h.advance(180_000); + expect(h.requests).toHaveLength(0); + expect(h.badge()).toBe("none"); + await h.render(); + expect(h.requests).toHaveLength(1); + expect(h.badge()).toBe("#12 pull request open"); + expect(h.mounts).toBe(1); + }); + }), +); + +it.effect("keeps a shared visible reader polling while its sidebar reader is hidden", () => + Effect.gen(function* () { + using h = yield* setup(); + yield* Effect.promise(async () => { + await h.render({ shared: true }); + expect(h.requests).toHaveLength(1); + await h.render({ visible: false, shared: true }); + await h.advance(180_000); + expect(h.requests).toHaveLength(4); + expect(h.badge("shared")).toBe("#12 pull request open"); + h.setState("closed"); + await h.advance(60_000); + expect(h.requests).toHaveLength(5); + expect(h.badge("shared")).toBe("#12 pull request closed"); + await h.render({ shared: true }); + expect(h.badge()).toBe("#12 pull request closed"); + }); + }), +); + +it.effect.each(["inactive", "background"])("releases sidebar demand while the OS is %s", (state) => + Effect.gen(function* () { + using h = yield* setup(); + yield* Effect.promise(async () => { + await h.render(); + await h.appState(state); + await h.advance(10_000); + const before = h.requests.length; + await h.advance(180_000); + expect(h.requests.length - before).toBe(0); + h.holdReplies(); + await h.appState("active"); + expect(h.badge()).toBe("#12 pull request open"); + expect(h.requests.length - before).toBe(1); + expect(h.mounts).toBe(1); + }); + }), +); + +it.effect("keeps mounted passive state through an Android-style Activity tab switch", () => + Effect.gen(function* () { + using h = yield* setup(); + yield* Effect.promise(async () => { + await h.render(); + const passive = h.passive(); + await h.render({ activity: "hidden" }); + await h.advance(10_000); + const before = h.requests.length; + await h.advance(180_000); + expect(h.requests.length - before).toBe(0); + h.holdReplies(); + await h.render(); + expect(h.badge()).toBe("#12 pull request open"); + expect(h.passive()).toBe(passive); + expect(h.mounts).toBe(1); + expect(h.requests.length - before).toBe(1); + }); + }), +); diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index 549bdb5f1a91c..af14ce3827b4e 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -7,11 +7,12 @@ import { import type { EnvironmentId, PullRequestSummary } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { Atom } from "effect/reactivity"; -import { useMemo } from "react"; +import { use, useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { serverEnvironment } from "./server"; import { useEnvironmentQuery } from "./query"; +import { ThreadPrDemandContext } from "./thread-pr-demand"; import { rememberBounded, resolveThreadPrSource, @@ -59,6 +60,7 @@ const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily( export { type ThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null { + const demand = use(ThreadPrDemandContext); const supportsLinks = useAtomValue( serverEnvironment.configValueAtom(thread.environmentId), (config) => config !== null, @@ -81,7 +83,7 @@ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentatio key === null ? undefined : current.get(key), ); const pullRequestSummary = useEnvironmentQuery( - pullRequestRef === null + pullRequestRef === null || !demand ? null : pullRequestSummaryAtom({ environmentId: thread.environmentId, diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 375cf6a3f7622..9097287717f7b 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -5,6 +5,7 @@ import "vite-plus/test/config"; const reactTests = [ "src/features/workspace/workspace-tabs-state.test.ts", + "src/state/use-thread-pr.demand.test.tsx", "src/features/threads/git/GitBranchesSheet.occupancy.test.tsx", "src/features/files/useFileTreeEntries.test.tsx", "src/features/projects/AddProjectScreen.test.ts",