1// <reference types="@testing-library/jest-dom" />
2import { act, render, screen, waitFor } from "@testing-library/react"
3import { Cause, Context, Effect, Latch, Layer } from "effect"
4import * as AsyncResult from "effect/reactivity/AsyncResult"
5import * as Atom from "effect/reactivity/Atom"
6import * as AtomRef from "effect/reactivity/AtomRef"
7import * as AtomRegistry from "effect/reactivity/AtomRegistry"
8import * as Hydration from "effect/reactivity/Hydration"
9import * as Schema from "effect/Schema"
10import * as React from "react"
11import { Suspense } from "react"
12import { renderToString } from "react-dom/server"
13import { ErrorBoundary } from "react-error-boundary"
14import { beforeEach, describe, expect, it, test, vi } from "vitest"
15import {
16 HydrationBoundary,
17 RegistryContext,
18 RegistryProvider,
19 useAtomRef,
20 useAtomSuspense,
21 useAtomValue
22} from "../src/index.ts"
23import * as ScopedAtom from "../src/ScopedAtom.ts"
24
25// Tests share the DOM and registry.
26describe("atom-react", { concurrent: false }, () => {
27 let registry: AtomRegistry.AtomRegistry
28
29 beforeEach(() => {
30 registry = AtomRegistry.make()
31 })
32
33 describe("runtime", () => {
34 test("can inject test layers", () => {
35 class TheNumber extends Context.Service<TheNumber>()("TheNumber", {
36 make: Effect.succeed({ n: 42 as number })
37 }) {
38 static readonly layer = Layer.effect(this, this.make)
39 }
40 const runtime = Atom.runtime(TheNumber.layer)
41 const numberAtom = runtime.atom(TheNumber.use((_) => Effect.succeed(_.n)))
42
43 function TestComponent() {
44 const value = useAtomValue(numberAtom, AsyncResult.getOrThrow)
45 return <div data-testid="value">{value}</div>
46 }
47
48 render(
49 <RegistryProvider
50 initialValues={[
51 Atom.initialValue(
52 runtime.layer,
53 Layer.succeed(TheNumber, { n: 69 })
54 )
55 ]}
56 >
57 <TestComponent />
58 </RegistryProvider>
59 )
60
61 expect(screen.getByTestId("value")).toHaveTextContent("69")
62 })
63 })
64
65 describe("useAtomValue", () => {
66 test("should read value from simple Atom", () => {
67 const atom = Atom.make(42)
68
69 function TestComponent() {
70 const value = useAtomValue(atom)
71 return <div data-testid="value">{value}</div>
72 }
73
74 render(<TestComponent />)
75
76 expect(screen.getByTestId("value")).toHaveTextContent("42")
77 })
78
79 test("should read value with transform function", () => {
80 const atom = Atom.make(42)
81
82 function TestComponent() {
83 const value = useAtomValue(atom, (x) => x * 2)
84 return <div data-testid="value">{value}</div>
85 }
86
87 render(<TestComponent />)
88
89 expect(screen.getByTestId("value")).toHaveTextContent("84")
90 })
91
92 test("should update when Atom value changes", async () => {
93 const atom = Atom.make("initial")
94
95 function TestComponent() {
96 const value = useAtomValue(atom)
97 return <div data-testid="value">{value}</div>
98 }
99
100 render(
101 <RegistryContext.Provider value={registry}>
102 <TestComponent />
103 </RegistryContext.Provider>
104 )
105
106 expect(screen.getByTestId("value")).toHaveTextContent("initial")
107
108 act(() => {
109 registry.set(atom, "updated")
110 })
111
112 await waitFor(() => {
113 expect(screen.getByTestId("value")).toHaveTextContent("updated")
114 })
115 })
116
117 test("should work with computed Atom", () => {
118 const baseAtom = Atom.make(10)
119 const computedAtom = Atom.make((get) => get(baseAtom) * 2)
120
121 function TestComponent() {
122 const value = useAtomValue(computedAtom)
123 return <div data-testid="value">{value}</div>
124 }
125
126 render(<TestComponent />)
127
128 expect(screen.getByTestId("value")).toHaveTextContent("20")
129 })
130
131 test("suspense success", () => {
132 const atom = Atom.make(Effect.never)
133
134 function TestComponent() {
135 const value = useAtomSuspense(atom).value
136 return <div data-testid="value">{value}</div>
137 }
138
139 render(
140 <Suspense fallback={<div data-testid="loading">Loading...</div>}>
141 <TestComponent />
142 </Suspense>
143 )
144
145 expect(screen.getByTestId("loading")).toBeInTheDocument()
146 })
147
148 test("suspense subscriptions are isolated per registry", async () => {
149 const atom = Atom.make(AsyncResult.initial<string>())
150 const firstRegistry = AtomRegistry.make()
151 const secondRegistry = AtomRegistry.make()
152
153 function TestComponent({ id }: { readonly id: string }) {
154 const value = useAtomSuspense(atom).value
155 return <div data-testid={`${id}-value`}>{value}</div>
156 }
157
158 render(
159 <RegistryContext.Provider value={firstRegistry}>
160 <Suspense fallback={<div data-testid="first-loading">Loading...</div>}>
161 <TestComponent id="first" />
162 </Suspense>
163 </RegistryContext.Provider>
164 )
165 render(
166 <RegistryContext.Provider value={secondRegistry}>
167 <Suspense fallback={<div data-testid="second-loading">Loading...</div>}>
168 <TestComponent id="second" />
169 </Suspense>
170 </RegistryContext.Provider>
171 )
172
173 act(() => {
174 secondRegistry.set(atom, AsyncResult.success("second"))
175 })
176
177 await waitFor(() => {
178 expect(screen.getByTestId("second-value")).toHaveTextContent("second")
179 })
180 expect(screen.getByTestId("first-loading")).toBeInTheDocument()
181
182 act(() => {
183 firstRegistry.set(atom, AsyncResult.success("first"))
184 })
185
186 await waitFor(() => {
187 expect(screen.getByTestId("first-value")).toHaveTextContent("first")
188 })
189 })
190 })
191
192 test("useAtomRef updates after switching refs", () => {
193 const first = AtomRef.make(0)
194 const second = AtomRef.make(1)
195
196 function TestComponent({ source }: { readonly source: AtomRef.ReadonlyRef<number> }) {
197 return <div data-testid="value">{useAtomRef(source)}</div>
198 }
199
200 const { rerender } = render(<TestComponent source={first} />)
201 rerender(<TestComponent source={second} />)
202
203 act(() => {
204 second.set(0)
205 })
206
207 expect(screen.getByTestId("value")).toHaveTextContent("0")
208 })
209
210 describe("ScopedAtom", () => {
211 test("throws when used outside Provider", () => {
212 const counter = ScopedAtom.make(() => Atom.make(0))
213
214 function TestComponent() {
215 counter.use()
216 return <div>ok</div>
217 }
218
219 expect(() => render(<TestComponent />)).toThrow("ScopedAtom used outside of its Provider")
220 })
221
222 test("scopes atom instances per Provider", async () => {
223 const counter = ScopedAtom.make(() => Atom.make(0))
224
225 function Count() {
226 const atom = counter.use()
227 const value = useAtomValue(atom)
228 return <div data-testid="value">{value}</div>
229 }
230
231 function SetTo({ value }: { readonly value: number }) {
232 const atom = counter.use()
233 const registry = React.useContext(RegistryContext)
234 React.useEffect(() => {
235 registry.set(atom, value)
236 }, [registry, atom, value])
237 return null
238 }
239
240 render(
241 <div>
242 <counter.Provider>
243 <SetTo value={1} />
244 <Count />
245 </counter.Provider>
246 <counter.Provider>
247 <SetTo value={2} />
248 <Count />
249 </counter.Provider>
250 </div>
251 )
252
253 await waitFor(() => {
254 const values = screen.getAllByTestId("value")
255 expect(values[0]).toHaveTextContent("1")
256 expect(values[1]).toHaveTextContent("2")
257 })
258 })
259
260 test("input factory uses provider value once", () => {
261 const makeAtom = vi.fn((value: number) => Atom.make(value))
262 const scoped = ScopedAtom.make(makeAtom)
263
264 function Count() {
265 const atom = scoped.use()
266 const value = useAtomValue(atom)
267 return <div data-testid="value">{value}</div>
268 }
269
270 const { rerender } = render(
271 <scoped.Provider value={10}>
272 <Count />
273 </scoped.Provider>
274 )
275
276 expect(screen.getByTestId("value")).toHaveTextContent("10")
277 expect(makeAtom).toHaveBeenCalledTimes(1)
278 expect(makeAtom).toHaveBeenCalledWith(10)
279
280 rerender(
281 <scoped.Provider value={99}>
282 <Count />
283 </scoped.Provider>
284 )
285
286 expect(screen.getByTestId("value")).toHaveTextContent("10")
287 expect(makeAtom).toHaveBeenCalledTimes(1)
288 })
289
290 test("integrates with useAtomValue", async () => {
291 const scoped = ScopedAtom.make(() => Atom.make(0))
292
293 function Counter() {
294 const atom = scoped.use()
295 const value = useAtomValue(atom)
296 return <div data-testid="value">{value}</div>
297 }
298
299 function IncrementOnce() {
300 const atom = scoped.use()
301 const registry = React.useContext(RegistryContext)
302 React.useEffect(() => {
303 registry.set(atom, 1)
304 }, [registry, atom])
305 return null
306 }
307
308 render(
309 <scoped.Provider>
310 <IncrementOnce />
311 <Counter />
312 </scoped.Provider>
313 )
314
315 await waitFor(() => {
316 expect(screen.getByTestId("value")).toHaveTextContent("1")
317 })
318 })
319 })
320
321 test("suspense error", () => {
322 const atom = Atom.make(Effect.fail(new Error("test")))
323 function TestComponent() {
324 const value = useAtomSuspense(atom).value
325 return <div data-testid="value">{value}</div>
326 }
327
328 render(
329 <ErrorBoundary fallback={<div data-testid="error">Error</div>}>
330 <Suspense fallback={<div data-testid="loading">Loading...</div>}>
331 <TestComponent />
332 </Suspense>
333 </ErrorBoundary>,
334 {
335 onCaughtError: ((error: unknown) => {
336 if (error instanceof Error && error.message === "test") {
337 return
338 }
339 // eslint-disable-next-line no-console
340 console.error(error)
341 }) as unknown as undefined // todo: fix idk why the types are weird
342 }
343 )
344
345 expect(screen.getByTestId("error")).toBeInTheDocument()
346 })
347
348 describe("hydration", () => {
349 test("basic hydration with number atom and result atoms", () => {
350 const atomBasic = Atom.make(0).pipe(
351 Atom.serializable({
352 key: "basic",
353 schema: Schema.Number
354 })
355 )
356 const e: Effect.Effect<number, string> = Effect.never
357 const makeAtomResult = (key: string) =>
358 Atom.make(e).pipe(
359 Atom.serializable({
360 key,
361 schema: AsyncResult.Schema({
362 success: Schema.Number,
363 error: Schema.String
364 })
365 })
366 )
367
368 const atomResult1 = makeAtomResult("success")
369 const atomResult2 = makeAtomResult("errored")
370 const atomResult3 = makeAtomResult("pending")
371
372 // Use a server-side registry to generate properly encoded dehydrated state
373 const serverRegistry = AtomRegistry.make()
374 serverRegistry.mount(atomBasic)
375 serverRegistry.set(atomBasic, 1)
376 serverRegistry.mount(atomResult1)
377 ;(serverRegistry.getNodes().get("success") as any).setValue(
378 AsyncResult.success(123)
379 )
380 serverRegistry.mount(atomResult2)
381 ;(serverRegistry.getNodes().get("errored") as any).setValue(
382 AsyncResult.failure(Cause.fail("error"))
383 )
384 serverRegistry.mount(atomResult3)
385 // atomResult3 stays Initial (just mounted, effect is Effect.never)
386
387 const dehydratedState = Hydration.dehydrate(serverRegistry, {
388 encodeInitialAs: "value-only"
389 })
390
391 function Basic() {
392 const value = useAtomValue(atomBasic)
393 return <div data-testid="hydration-basic-value">{value}</div>
394 }
395
396 function Result1() {
397 const value = useAtomValue(atomResult1)
398 return AsyncResult.match(value, {
399 onSuccess: (result) => <div data-testid="value-1">{result.value}</div>,
400 onFailure: () => <div data-testid="error-1">Error</div>,
401 onInitial: () => <div data-testid="loading-1">Loading...</div>
402 })
403 }
404
405 function Result2() {
406 const value = useAtomValue(atomResult2)
407 return AsyncResult.match(value, {
408 onSuccess: (result) => <div data-testid="value-2">{result.value}</div>,
409 onFailure: () => <div data-testid="error-2">Error</div>,
410 onInitial: () => <div data-testid="loading-2">Loading...</div>
411 })
412 }
413
414 function Result3() {
415 const value = useAtomValue(atomResult3)
416 return AsyncResult.match(value, {
417 onSuccess: (result) => <div data-testid="value-3">{result.value}</div>,
418 onFailure: () => <div data-testid="error-3">Error</div>,
419 onInitial: () => <div data-testid="loading-3">Loading...</div>
420 })
421 }
422
423 render(
424 <RegistryContext.Provider value={AtomRegistry.make()}>
425 <HydrationBoundary state={dehydratedState}>
426 <Basic />
427 <Result1 />
428 <Result2 />
429 <Result3 />
430 </HydrationBoundary>
431 </RegistryContext.Provider>
432 )
433
434 expect(screen.getByTestId("hydration-basic-value")).toHaveTextContent("1")
435 expect(screen.getByTestId("value-1")).toHaveTextContent("123")
436 expect(screen.getByTestId("error-2")).toBeInTheDocument()
437 expect(screen.getByTestId("loading-3")).toBeInTheDocument()
438 })
439
440 test("hydration streaming with resultPromise", async () => {
441 const latch = Latch.makeUnsafe()
442 let start = 0
443 let stop = 0
444 const atom = Atom.make(
445 Effect.gen(function*() {
446 start = start + 1
447 yield* latch.await
448 stop = stop + 1
449 return 1
450 })
451 ).pipe(
452 Atom.serializable({
453 key: "test",
454 schema: AsyncResult.Schema({
455 success: Schema.Number
456 })
457 })
458 )
459
460 registry.mount(atom)
461
462 expect(start).toBe(1)
463 expect(stop).toBe(0)
464
465 const dehydratedState = Hydration.dehydrate(registry, {
466 encodeInitialAs: "promise"
467 })
468
469 function TestComponent() {
470 const value = useAtomValue(atom)
471 return <div data-testid="value">{value._tag}</div>
472 }
473
474 render(
475 // provide a fresh registry each time to simulate hydration
476 <RegistryContext.Provider value={AtomRegistry.make()}>
477 <HydrationBoundary state={dehydratedState}>
478 <TestComponent />
479 </HydrationBoundary>
480 </RegistryContext.Provider>
481 )
482
483 expect(screen.getByTestId("value")).toHaveTextContent("Initial")
484
485 act(() => {
486 Effect.runSync(latch.open)
487 })
488 await Effect.runPromise(latch.await)
489
490 const result = registry.get(atom)
491 expect(result._tag).toBe("Success")
492 if (result._tag === "Success") {
493 expect(result.value).toBe(1)
494 }
495
496 expect(screen.getByTestId("value")).toHaveTextContent("Success")
497 expect(start).toBe(1)
498 expect(stop).toBe(1)
499 })
500
501 test("HydrationBoundary splits new vs existing atoms", () => {
502 const newAtom = Atom.make(0).pipe(
503 Atom.serializable({ key: "new-atom", schema: Schema.Number })
504 )
505
506 // Use a server registry to generate dehydrated state
507 const serverRegistry = AtomRegistry.make()
508 serverRegistry.mount(newAtom)
509 serverRegistry.set(newAtom, 99)
510 const dehydratedState = Hydration.dehydrate(serverRegistry)
511
512 function NewValue() {
513 const value = useAtomValue(newAtom)
514 return <div data-testid="new">{value}</div>
515 }
516
517 // Render with a fresh client registry (no pre-existing atoms)
518 render(
519 <RegistryContext.Provider value={AtomRegistry.make()}>
520 <HydrationBoundary state={dehydratedState}>
521 <NewValue />
522 </HydrationBoundary>
523 </RegistryContext.Provider>
524 )
525
526 // New atom should be hydrated immediately during render
527 expect(screen.getByTestId("new")).toHaveTextContent("99")
528 })
529
530 test("dehydrate with encodeInitialAs ignore (default)", () => {
531 const atom = Atom.make(Effect.never as Effect.Effect<number>).pipe(
532 Atom.serializable({
533 key: "initial-atom",
534 schema: AsyncResult.Schema({ success: Schema.Number })
535 })
536 )
537
538 registry.mount(atom)
539
540 // Default behavior: Initial values should be ignored
541 const state = Hydration.dehydrate(registry)
542 const values = Hydration.toValues(state)
543
544 expect(values.length).toBe(0)
545 })
546
547 test("dehydrate with encodeInitialAs value-only", () => {
548 const atom = Atom.make(Effect.never as Effect.Effect<number>).pipe(
549 Atom.serializable({
550 key: "initial-atom",
551 schema: AsyncResult.Schema({ success: Schema.Number })
552 })
553 )
554
555 registry.mount(atom)
556
557 // value-only: should encode the Initial value without a resultPromise
558 const state = Hydration.dehydrate(registry, {
559 encodeInitialAs: "value-only"
560 })
561 const values = Hydration.toValues(state)
562
563 expect(values.length).toBe(1)
564 expect(values[0].key).toBe("initial-atom")
565 expect(values[0].resultPromise).toBeUndefined()
566 })
567
568 test("serializable encode/decode survives JSON roundtrip (wire transfer)", () => {
569 const atom = Atom.make(0 as never).pipe(
570 Atom.serializable({
571 key: "wire-test",
572 schema: AsyncResult.Schema({
573 success: Schema.Struct({ name: Schema.String }),
574 error: Schema.String
575 })
576 })
577 )
578
579 const original = AsyncResult.success({ name: "hello" })
580
581 // Encode using the atom's serializable encode
582 const encoded = atom[Atom.SerializableTypeId].encode(original)
583
584 // Simulate wire transfer (seroval / JSON serialization roundtrip)
585 const wireTransferred = JSON.parse(JSON.stringify(encoded))
586
587 // Decode after wire transfer — this was the bug: decode would fail
588 // because the encoded value lost its AsyncResult prototype
589 const decoded = atom[Atom.SerializableTypeId].decode(wireTransferred)
590
591 expect(AsyncResult.isAsyncResult(decoded)).toBe(true)
592 expect(decoded._tag).toBe("Success")
593 if (AsyncResult.isSuccess(decoded)) {
594 expect(decoded.value).toEqual({ name: "hello" })
595 }
596 })
597
598 test("dehydrate + JSON roundtrip + hydrate works (SSR simulation)", () => {
599 const atom = Atom.make(Effect.never as Effect.Effect<number>).pipe(
600 Atom.serializable({
601 key: "ssr-wire",
602 schema: AsyncResult.Schema({ success: Schema.Number })
603 })
604 )
605
606 // Server: dehydrate
607 const serverRegistry = AtomRegistry.make()
608 serverRegistry.mount(atom)
609 ;(serverRegistry.getNodes().get("ssr-wire") as any).setValue(
610 AsyncResult.success(42)
611 )
612 const dehydratedState = Hydration.dehydrate(serverRegistry)
613
614 // Simulate wire transfer (seroval / JSON)
615 const wireTransferred = JSON.parse(JSON.stringify(dehydratedState))
616
617 // Client: hydrate from wire-transferred state
618 function TestComponent() {
619 const value = useAtomValue(atom)
620 return (
621 <div data-testid="ssr-wire-value">
622 {AsyncResult.isSuccess(value) ? value.value : "not-success"}
623 </div>
624 )
625 }
626
627 render(
628 <RegistryContext.Provider value={AtomRegistry.make()}>
629 <HydrationBoundary state={wireTransferred}>
630 <TestComponent />
631 </HydrationBoundary>
632 </RegistryContext.Provider>
633 )
634
635 expect(screen.getByTestId("ssr-wire-value")).toHaveTextContent("42")
636 })
637
638 test("empty state is a no-op", () => {
639 function TestComponent() {
640 return <div data-testid="hydration-empty-state">OK</div>
641 }
642
643 render(
644 <HydrationBoundary state={[]}>
645 <TestComponent />
646 </HydrationBoundary>
647 )
648
649 expect(screen.getByTestId("hydration-empty-state")).toHaveTextContent("OK")
650 })
651
652 test("hydrate with no state is a no-op", () => {
653 function TestComponent() {
654 return <div data-testid="hydration-no-state">OK</div>
655 }
656
657 render(
658 <HydrationBoundary>
659 <TestComponent />
660 </HydrationBoundary>
661 )
662
663 expect(screen.getByTestId("hydration-no-state")).toHaveTextContent("OK")
664 })
665 })
666
667 describe("SSR", () => {
668 it("should run atom's during SSR by default", () => {
669 const getCount = vi.fn(() => 0)
670 const counterAtom = Atom.make(getCount)
671
672 function TestComponent() {
673 const count = useAtomValue(counterAtom)
674 return <div>{count}</div>
675 }
676
677 function App() {
678 return <TestComponent />
679 }
680
681 const ssrHtml = renderToString(<App />)
682
683 expect(getCount).toHaveBeenCalled()
684 expect(ssrHtml).toContain("0")
685
686 render(<App />)
687
688 expect(getCount).toHaveBeenCalled()
689 expect(screen.getByText("0")).toBeInTheDocument()
690 })
691 })
692
693 it("should not execute Atom effects during SSR when using withServerSnapshot", () => {
694 const mockFetchData = vi.fn(() => 0)
695
696 const userDataAtom = Atom.make(Effect.sync(() => mockFetchData())).pipe(
697 Atom.withServerValueInitial
698 )
699
700 function TestComponent() {
701 const result = useAtomValue(userDataAtom)
702
703 return <div>{result._tag}</div>
704 }
705
706 function App() {
707 return <TestComponent />
708 }
709
710 const ssrHtml = renderToString(<App />)
711
712 expect(mockFetchData).not.toHaveBeenCalled()
713 expect(ssrHtml).toContain("Initial")
714
715 render(<App />)
716
717 expect(mockFetchData).toHaveBeenCalled()
718 expect(screen.getByText("Success")).toBeInTheDocument()
719 })
720})