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});