1import { RegistryContext } from "@effect/atom-react";
2import { EnvironmentId } from "@t3tools/contracts";
3import { AtomRegistry } from "effect/reactivity";
4import { act, createElement, useLayoutEffect } from "react";
5import { createRoot, type Root } from "react-dom/client";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7
8const saved = vi.hoisted(() => [] as unknown[]);
9vi.mock("../../state/preferences", async () => {
10 const { Atom } = await import("effect/reactivity");
11 return {
12 updateMobilePreferencesAtom: Atom.writable(
13 () => null,
14 (_ctx, patch: unknown) => {
15 saved.push(patch);
16 },
17 ),
18 };
19});
20
21import type { ThreadListFilters } from "../../persistence/mobile-preferences";
22import { HomeListOptionsProvider, useHomeListOptions } from "./home-list-options";
23
24describe("home list options across layout changes", () => {
25 const environmentId = EnvironmentId.make("environment-1");
26 const availableEnvironmentIds = new Set([environmentId]);
27 let root: Root;
28 let latest: ReturnType<typeof useHomeListOptions>;
29
30 function ThreadList() {
31 const state = useHomeListOptions(availableEnvironmentIds);
32 useLayoutEffect(() => {
33 latest = state;
34 });
35 return null;
36 }
37
38 const registry = AtomRegistry.make();
39
40 async function showLayout(layout: "compact" | "sidebar", savedFilters?: ThreadListFilters) {
41 await act(() => {
42 root.render(
43 createElement(
44 RegistryContext,
45 { value: registry },
46 createElement(
47 HomeListOptionsProvider,
48 { projectGroupingMode: "repository", savedFilters },
49 // Replace the list while keeping the workspace provider mounted.
50 createElement(ThreadList, { key: layout }),
51 ),
52 ),
53 );
54 });
55 }
56
57 beforeEach(() => {
58 // The hook probe renders no DOM, but ReactDOM needs an event target.
59 const document = {
60 nodeType: 9,
61 addEventListener() {},
62 removeEventListener() {},
63 };
64 const container = {
65 nodeType: 1,
66 tagName: "DIV",
67 namespaceURI: "http://www.w3.org/1999/xhtml",
68 ownerDocument: document,
69 addEventListener() {},
70 removeEventListener() {},
71 };
72 vi.stubGlobal("document", document);
73 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
74 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
75 root = createRoot(container as unknown as HTMLElement);
76 saved.length = 0;
77 });
78
79 afterEach(async () => {
80 await act(() => root.unmount());
81 vi.unstubAllGlobals();
82 });
83
84 it.each([
85 ["compact", "sidebar"],
86 ["sidebar", "compact"],
87 ] as const)(
88 "retains filters through repeated %s → %s round trips and keeps clears",
89 async (initialLayout, otherLayout) => {
90 await showLayout(initialLayout);
91
92 for (const [selectedEnvironmentId, selectedProjectKey] of [
93 [environmentId, "repository:one"],
94 [environmentId, "repository:two"],
95 [environmentId, null],
96 [null, null],
97 [null, "repository:one"],
98 ] as const) {
99 await act(() => {
100 latest.setSelectedEnvironmentId(selectedEnvironmentId);
101 latest.setSelectedProjectKey(selectedProjectKey);
102 });
103 for (let cycle = 0; cycle < 3; cycle += 1) {
104 for (const layout of [otherLayout, initialLayout]) {
105 await showLayout(layout);
106 expect(latest.options).toMatchObject({
107 selectedEnvironmentId,
108 selectedProjectKey,
109 projectGroupingMode: "repository",
110 });
111 }
112 }
113 // Make the next selection (or clear it) from the opposite layout.
114 await showLayout(otherLayout);
115 }
116 },
117 );
118
119 it("restores saved filters on launch and saves only real changes", async () => {
120 await showLayout("compact", { environmentId, projectKey: "repository:one" });
121 expect(latest.options).toMatchObject({
122 selectedEnvironmentId: environmentId,
123 selectedProjectKey: "repository:one",
124 });
125 expect(saved).toEqual([]);
126
127 await act(() => latest.setSelectedProjectKey(null));
128 expect(saved).toEqual([{ threadListFilters: { environmentId, projectKey: null } }]);
129 });
130});