1import { describe, expect, it, vi } from "vite-plus/test";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
6
7import { hotSwappableAtomRuntime } from "./hot-swappable-atom-runtime";
8
9class RuntimeValue extends Context.Service<RuntimeValue, { readonly value: string }>()(
10 "t3/mobile/test/RuntimeValue",
11) {}
12
13function layerRuntime(value: string, events: string[]) {
14 return Layer.effect(
15 RuntimeValue,
16 Effect.acquireRelease(
17 Effect.sync(() => {
18 events.push(`acquire:${value}`);
19 return RuntimeValue.of({ value });
20 }),
21 () =>
22 Effect.sync(() => {
23 events.push(`release:${value}`);
24 }),
25 ),
26 );
27}
28
29function layerRuntimeWithRelease(
30 value: string,
31 events: string[],
32 release: Effect.Effect<void> = Effect.sync(() => {
33 events.push(`release:${value}`);
34 }),
35) {
36 return Layer.effect(
37 RuntimeValue,
38 Effect.acquireRelease(
39 Effect.sync(() => {
40 events.push(`acquire:${value}`);
41 return RuntimeValue.of({ value });
42 }),
43 () => release,
44 ),
45 );
46}
47
48describe("hotSwappableAtomRuntime", () => {
49 it("rebuilds a mounted runtime in place without disturbing unrelated subscribers", () => {
50 vi.stubGlobal("__DEV__", true);
51 const registry = AtomRegistry.make();
52 const accept = () => {};
53 const events: string[] = [];
54 const id = `test-${crypto.randomUUID()}`;
55 const runtime = hotSwappableAtomRuntime({
56 id,
57 hotModule: { accept },
58 registry,
59 layer: layerRuntime("first", events),
60 });
61 const valueAtom = runtime.atom(RuntimeValue.pipe(Effect.map((service) => service.value)));
62 const values: string[] = [];
63 const unsubscribeRuntime = registry.subscribe(
64 valueAtom,
65 (result) => {
66 if (AsyncResult.isSuccess(result)) values.push(result.value);
67 },
68 { immediate: true },
69 );
70 const unrelatedAtom = Atom.make(0);
71 const unrelatedValues: number[] = [];
72 const unsubscribeUnrelated = registry.subscribe(unrelatedAtom, (value) => {
73 unrelatedValues.push(value);
74 });
75 registry.set(unrelatedAtom, 7);
76 const unwatchedDraftAtom = Atom.make("saved");
77 registry.set(unwatchedDraftAtom, "edited");
78 const nodesBefore = registry.getNodes().size;
79
80 const replacement = hotSwappableAtomRuntime({
81 id,
82 hotModule: { accept },
83 registry,
84 layer: layerRuntime("second", events),
85 });
86 hotSwappableAtomRuntime({
87 id,
88 hotModule: { accept },
89 registry,
90 layer: layerRuntime("third", events),
91 });
92 registry.set(unrelatedAtom, 8);
93
94 expect(replacement).toBe(runtime);
95 expect(events).toEqual([
96 "acquire:first",
97 "release:first",
98 "acquire:second",
99 "release:second",
100 "acquire:third",
101 ]);
102 expect(values).toEqual(["first", "second", "third"]);
103 expect(registry.get(unwatchedDraftAtom)).toBe("edited");
104 expect(unrelatedValues).toEqual([7, 8]);
105 expect(registry.getNodes().size).toBe(nodesBefore);
106 unsubscribeRuntime();
107 unsubscribeUnrelated();
108 registry.dispose();
109 expect(events).toEqual([
110 "acquire:first",
111 "release:first",
112 "acquire:second",
113 "release:second",
114 "acquire:third",
115 "release:third",
116 ]);
117 });
118
119 it("does not retain or accept a runtime outside development", () => {
120 vi.stubGlobal("__DEV__", false);
121 const registry = AtomRegistry.make();
122 const accept = vi.fn();
123 const layer = layerRuntime("production", []);
124
125 const first = hotSwappableAtomRuntime({
126 id: "production",
127 hotModule: { accept },
128 registry,
129 layer,
130 });
131 const second = hotSwappableAtomRuntime({
132 id: "production",
133 hotModule: { accept },
134 registry,
135 layer,
136 });
137
138 expect(second).not.toBe(first);
139 expect(accept).not.toHaveBeenCalled();
140 registry.dispose();
141 });
142
143 it("starts an asynchronous old-layer release while exposing the fresh context", async () => {
144 vi.stubGlobal("__DEV__", true);
145 const registry = AtomRegistry.make();
146 const events: string[] = [];
147 const id = `test-${crypto.randomUUID()}`;
148 let finishRelease!: () => void;
149 const releaseGate = new Promise<void>((resolve) => {
150 finishRelease = resolve;
151 });
152 let markReleaseComplete!: () => void;
153 const releaseComplete = new Promise<void>((resolve) => {
154 markReleaseComplete = resolve;
155 });
156 const firstRelease = Effect.promise(async () => {
157 events.push("release:start:first");
158 await releaseGate;
159 events.push("release:end:first");
160 markReleaseComplete();
161 });
162 const runtime = hotSwappableAtomRuntime({
163 id,
164 hotModule: { accept() {} },
165 registry,
166 layer: layerRuntimeWithRelease("first", events, firstRelease),
167 });
168 const valueAtom = runtime.atom(RuntimeValue.pipe(Effect.map((service) => service.value)));
169 const values: string[] = [];
170 const unsubscribe = registry.subscribe(valueAtom, (result) => {
171 if (AsyncResult.isSuccess(result)) values.push(result.value);
172 });
173 registry.get(valueAtom);
174
175 hotSwappableAtomRuntime({
176 id,
177 hotModule: { accept() {} },
178 registry,
179 layer: layerRuntime("second", events),
180 });
181
182 expect(events).toEqual(["acquire:first", "release:start:first", "acquire:second"]);
183 expect(values).toEqual(["first", "second"]);
184
185 finishRelease();
186 await releaseComplete;
187 expect(events).toEqual([
188 "acquire:first",
189 "release:start:first",
190 "acquire:second",
191 "release:end:first",
192 ]);
193
194 unsubscribe();
195 registry.dispose();
196 });
197});