651 lines
19 KiB
TypeScript
651 lines
19 KiB
TypeScript
// <reference types="@testing-library/jest-dom" />
|
|
import { act, render, screen, waitFor } from "@testing-library/react"
|
|
import { Cause, Context, Effect, Latch, Layer } from "effect"
|
|
import * as Schema from "effect/Schema"
|
|
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
|
|
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
|
|
import * as Hydration from "effect/unstable/reactivity/Hydration"
|
|
import * as React from "react"
|
|
import { Suspense } from "react"
|
|
import { renderToString } from "react-dom/server"
|
|
import { ErrorBoundary } from "react-error-boundary"
|
|
import { beforeEach, describe, expect, it, test, vi } from "vitest"
|
|
import { HydrationBoundary, RegistryContext, RegistryProvider, useAtomSuspense, useAtomValue } from "../src/index.ts"
|
|
import * as ScopedAtom from "../src/ScopedAtom.ts"
|
|
|
|
describe("atom-react", () => {
|
|
let registry: AtomRegistry.AtomRegistry
|
|
|
|
beforeEach(() => {
|
|
registry = AtomRegistry.make()
|
|
})
|
|
|
|
describe("runtime", () => {
|
|
test("can inject test layers", () => {
|
|
class TheNumber extends Context.Service<TheNumber>()("TheNumber", {
|
|
make: Effect.succeed({ n: 42 as number })
|
|
}) {
|
|
static readonly layer = Layer.effect(this, this.make)
|
|
}
|
|
const runtime = Atom.runtime(TheNumber.layer)
|
|
const numberAtom = runtime.atom(TheNumber.use((_) => Effect.succeed(_.n)))
|
|
|
|
function TestComponent() {
|
|
const value = useAtomValue(numberAtom, AsyncResult.getOrThrow)
|
|
return <div data-testid="value">{value}</div>
|
|
}
|
|
|
|
render(
|
|
<RegistryProvider
|
|
initialValues={[
|
|
Atom.initialValue(
|
|
runtime.layer,
|
|
Layer.succeed(TheNumber, { n: 69 })
|
|
)
|
|
]}
|
|
>
|
|
<TestComponent />
|
|
</RegistryProvider>
|
|
)
|
|
|
|
expect(screen.getByTestId("value")).toHaveTextContent("69")
|
|
})
|
|
})
|
|
|
|
describe("useAtomValue", () => {
|
|
test("should read value from simple Atom", () => {
|
|
const atom = Atom.make(42)
|
|
|
|
function TestComponent() {
|
|
const value = useAtomValue(atom)
|
|
return <div data-testid="value">{value}</div>
|
|
}
|
|
|
|
render(<TestComponent />)
|
|
|
|
expect(screen.getByTestId("value")).toHaveTextContent("42")
|
|
})
|
|
|
|
test("should read value with transform function", () => {
|
|
const atom = Atom.make(42)
|
|
|
|
function TestComponent() {
|
|
const value = useAtomValue(atom, (x) => x * 2)
|
|
return <div data-testid="value">{value}</div>
|
|
}
|
|
|
|
render(<TestComponent />)
|
|
|
|
expect(screen.getByTestId("value")).toHaveTextContent("84")
|
|
})
|
|
|
|
test("should update when Atom value changes", async () => {
|
|
const atom = Atom.make("initial")
|
|
|
|
function TestComponent() {
|
|
const value = useAtomValue(atom)
|
|
return <div data-testid="value">{value}</div>
|
|
}
|
|
|
|
render(
|
|
<RegistryContext.Provider value={registry}>
|
|
<TestComponent />
|
|
</RegistryContext.Provider>
|
|
)
|
|
|
|
expect(screen.getByTestId("value")).toHaveTextContent("initial")
|
|
|
|
act(() => {
|
|
registry.set(atom, "updated")
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("value")).toHaveTextContent("updated")
|
|
})
|
|
})
|
|
|
|
test("should work with computed Atom", () => {
|
|
const baseAtom = Atom.make(10)
|
|
const computedAtom = Atom.make((get) => get(baseAtom) * 2)
|
|
|
|
function TestComponent() {
|
|
const value = useAtomValue(computedAtom)
|
|
return <div data-testid="value">{value}</div>
|
|
}
|
|
|
|
render(<TestComponent />)
|
|
|
|
expect(screen.getByTestId("value")).toHaveTextContent("20")
|
|
})
|
|
|
|
test("suspense success", () => {
|
|
const atom = Atom.make(Effect.never)
|
|
|
|
function TestComponent() {
|
|
const value = useAtomSuspense(atom).value
|
|
return <div data-testid="value">{value}</div>
|
|
}
|
|
|
|
render(
|
|
<Suspense fallback={<div data-testid="loading">Loading...</div>}>
|
|
<TestComponent />
|
|
</Suspense>
|
|
)
|
|
|
|
expect(screen.getByTestId("loading")).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe("ScopedAtom", () => {
|
|
test("throws when used outside Provider", () => {
|
|
const counter = ScopedAtom.make(() => Atom.make(0))
|
|
|
|
function TestComponent() {
|
|
counter.use()
|
|
return <div>ok</div>
|
|
}
|
|
|
|
expect(() => render(<TestComponent />)).toThrow("ScopedAtom used outside of its Provider")
|
|
})
|
|
|
|
test("scopes atom instances per Provider", async () => {
|
|
const counter = ScopedAtom.make(() => Atom.make(0))
|
|
|
|
function Count() {
|
|
const atom = counter.use()
|
|
const value = useAtomValue(atom)
|
|
return <div data-testid="value">{value}</div>
|
|
}
|
|
|
|
function SetTo({ value }: { readonly value: number }) {
|
|
const atom = counter.use()
|
|
const registry = React.useContext(RegistryContext)
|
|
React.useEffect(() => {
|
|
registry.set(atom, value)
|
|
}, [registry, atom, value])
|
|
return null
|
|
}
|
|
|
|
render(
|
|
<div>
|
|
<counter.Provider>
|
|
<SetTo value={1} />
|
|
<Count />
|
|
</counter.Provider>
|
|
<counter.Provider>
|
|
<SetTo value={2} />
|
|
<Count />
|
|
</counter.Provider>
|
|
</div>
|
|
)
|
|
|
|
await waitFor(() => {
|
|
const values = screen.getAllByTestId("value")
|
|
expect(values[0]).toHaveTextContent("1")
|
|
expect(values[1]).toHaveTextContent("2")
|
|
})
|
|
})
|
|
|
|
test("input factory uses provider value once", () => {
|
|
const makeAtom = vi.fn((value: number) => Atom.make(value))
|
|
const scoped = ScopedAtom.make(makeAtom)
|
|
|
|
function Count() {
|
|
const atom = scoped.use()
|
|
const value = useAtomValue(atom)
|
|
return <div data-testid="value">{value}</div>
|
|
}
|
|
|
|
const { rerender } = render(
|
|
<scoped.Provider value={10}>
|
|
<Count />
|
|
</scoped.Provider>
|
|
)
|
|
|
|
expect(screen.getByTestId("value")).toHaveTextContent("10")
|
|
expect(makeAtom).toHaveBeenCalledTimes(1)
|
|
expect(makeAtom).toHaveBeenCalledWith(10)
|
|
|
|
rerender(
|
|
<scoped.Provider value={99}>
|
|
<Count />
|
|
</scoped.Provider>
|
|
)
|
|
|
|
expect(screen.getByTestId("value")).toHaveTextContent("10")
|
|
expect(makeAtom).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test("integrates with useAtomValue", async () => {
|
|
const scoped = ScopedAtom.make(() => Atom.make(0))
|
|
|
|
function Counter() {
|
|
const atom = scoped.use()
|
|
const value = useAtomValue(atom)
|
|
return <div data-testid="value">{value}</div>
|
|
}
|
|
|
|
function IncrementOnce() {
|
|
const atom = scoped.use()
|
|
const registry = React.useContext(RegistryContext)
|
|
React.useEffect(() => {
|
|
registry.set(atom, 1)
|
|
}, [registry, atom])
|
|
return null
|
|
}
|
|
|
|
render(
|
|
<scoped.Provider>
|
|
<IncrementOnce />
|
|
<Counter />
|
|
</scoped.Provider>
|
|
)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("value")).toHaveTextContent("1")
|
|
})
|
|
})
|
|
})
|
|
|
|
test("suspense error", () => {
|
|
const atom = Atom.make(Effect.fail(new Error("test")))
|
|
function TestComponent() {
|
|
const value = useAtomSuspense(atom).value
|
|
return <div data-testid="value">{value}</div>
|
|
}
|
|
|
|
render(
|
|
<ErrorBoundary fallback={<div data-testid="error">Error</div>}>
|
|
<Suspense fallback={<div data-testid="loading">Loading...</div>}>
|
|
<TestComponent />
|
|
</Suspense>
|
|
</ErrorBoundary>,
|
|
{
|
|
onCaughtError: ((error: unknown) => {
|
|
if (error instanceof Error && error.message === "test") {
|
|
return
|
|
}
|
|
// eslint-disable-next-line no-console
|
|
console.error(error)
|
|
}) as unknown as undefined // todo: fix idk why the types are weird
|
|
}
|
|
)
|
|
|
|
expect(screen.getByTestId("error")).toBeInTheDocument()
|
|
})
|
|
|
|
describe("hydration", () => {
|
|
test("basic hydration with number atom and result atoms", () => {
|
|
const atomBasic = Atom.make(0).pipe(
|
|
Atom.serializable({
|
|
key: "basic",
|
|
schema: Schema.Number
|
|
})
|
|
)
|
|
const e: Effect.Effect<number, string> = Effect.never
|
|
const makeAtomResult = (key: string) =>
|
|
Atom.make(e).pipe(
|
|
Atom.serializable({
|
|
key,
|
|
schema: AsyncResult.Schema({
|
|
success: Schema.Number,
|
|
error: Schema.String
|
|
})
|
|
})
|
|
)
|
|
|
|
const atomResult1 = makeAtomResult("success")
|
|
const atomResult2 = makeAtomResult("errored")
|
|
const atomResult3 = makeAtomResult("pending")
|
|
|
|
// Use a server-side registry to generate properly encoded dehydrated state
|
|
const serverRegistry = AtomRegistry.make()
|
|
serverRegistry.mount(atomBasic)
|
|
serverRegistry.set(atomBasic, 1)
|
|
serverRegistry.mount(atomResult1)
|
|
;(serverRegistry.getNodes().get("success") as any).setValue(
|
|
AsyncResult.success(123)
|
|
)
|
|
serverRegistry.mount(atomResult2)
|
|
;(serverRegistry.getNodes().get("errored") as any).setValue(
|
|
AsyncResult.failure(Cause.fail("error"))
|
|
)
|
|
serverRegistry.mount(atomResult3)
|
|
// atomResult3 stays Initial (just mounted, effect is Effect.never)
|
|
|
|
const dehydratedState = Hydration.dehydrate(serverRegistry, {
|
|
encodeInitialAs: "value-only"
|
|
})
|
|
|
|
function Basic() {
|
|
const value = useAtomValue(atomBasic)
|
|
return <div data-testid="hydration-basic-value">{value}</div>
|
|
}
|
|
|
|
function Result1() {
|
|
const value = useAtomValue(atomResult1)
|
|
return AsyncResult.match(value, {
|
|
onSuccess: (result) => <div data-testid="value-1">{result.value}</div>,
|
|
onFailure: () => <div data-testid="error-1">Error</div>,
|
|
onInitial: () => <div data-testid="loading-1">Loading...</div>
|
|
})
|
|
}
|
|
|
|
function Result2() {
|
|
const value = useAtomValue(atomResult2)
|
|
return AsyncResult.match(value, {
|
|
onSuccess: (result) => <div data-testid="value-2">{result.value}</div>,
|
|
onFailure: () => <div data-testid="error-2">Error</div>,
|
|
onInitial: () => <div data-testid="loading-2">Loading...</div>
|
|
})
|
|
}
|
|
|
|
function Result3() {
|
|
const value = useAtomValue(atomResult3)
|
|
return AsyncResult.match(value, {
|
|
onSuccess: (result) => <div data-testid="value-3">{result.value}</div>,
|
|
onFailure: () => <div data-testid="error-3">Error</div>,
|
|
onInitial: () => <div data-testid="loading-3">Loading...</div>
|
|
})
|
|
}
|
|
|
|
render(
|
|
<RegistryContext.Provider value={AtomRegistry.make()}>
|
|
<HydrationBoundary state={dehydratedState}>
|
|
<Basic />
|
|
<Result1 />
|
|
<Result2 />
|
|
<Result3 />
|
|
</HydrationBoundary>
|
|
</RegistryContext.Provider>
|
|
)
|
|
|
|
expect(screen.getByTestId("hydration-basic-value")).toHaveTextContent("1")
|
|
expect(screen.getByTestId("value-1")).toHaveTextContent("123")
|
|
expect(screen.getByTestId("error-2")).toBeInTheDocument()
|
|
expect(screen.getByTestId("loading-3")).toBeInTheDocument()
|
|
})
|
|
|
|
test("hydration streaming with resultPromise", async () => {
|
|
const latch = Latch.makeUnsafe()
|
|
let start = 0
|
|
let stop = 0
|
|
const atom = Atom.make(
|
|
Effect.gen(function*() {
|
|
start = start + 1
|
|
yield* latch.await
|
|
stop = stop + 1
|
|
return 1
|
|
})
|
|
).pipe(
|
|
Atom.serializable({
|
|
key: "test",
|
|
schema: AsyncResult.Schema({
|
|
success: Schema.Number
|
|
})
|
|
})
|
|
)
|
|
|
|
registry.mount(atom)
|
|
|
|
expect(start).toBe(1)
|
|
expect(stop).toBe(0)
|
|
|
|
const dehydratedState = Hydration.dehydrate(registry, {
|
|
encodeInitialAs: "promise"
|
|
})
|
|
|
|
function TestComponent() {
|
|
const value = useAtomValue(atom)
|
|
return <div data-testid="value">{value._tag}</div>
|
|
}
|
|
|
|
render(
|
|
// provide a fresh registry each time to simulate hydration
|
|
<RegistryContext.Provider value={AtomRegistry.make()}>
|
|
<HydrationBoundary state={dehydratedState}>
|
|
<TestComponent />
|
|
</HydrationBoundary>
|
|
</RegistryContext.Provider>
|
|
)
|
|
|
|
expect(screen.getByTestId("value")).toHaveTextContent("Initial")
|
|
|
|
act(() => {
|
|
Effect.runSync(latch.open)
|
|
})
|
|
await Effect.runPromise(latch.await)
|
|
|
|
const result = registry.get(atom)
|
|
expect(result._tag).toBe("Success")
|
|
if (result._tag === "Success") {
|
|
expect(result.value).toBe(1)
|
|
}
|
|
|
|
expect(screen.getByTestId("value")).toHaveTextContent("Success")
|
|
expect(start).toBe(1)
|
|
expect(stop).toBe(1)
|
|
})
|
|
|
|
test("HydrationBoundary splits new vs existing atoms", () => {
|
|
const newAtom = Atom.make(0).pipe(
|
|
Atom.serializable({ key: "new-atom", schema: Schema.Number })
|
|
)
|
|
|
|
// Use a server registry to generate dehydrated state
|
|
const serverRegistry = AtomRegistry.make()
|
|
serverRegistry.mount(newAtom)
|
|
serverRegistry.set(newAtom, 99)
|
|
const dehydratedState = Hydration.dehydrate(serverRegistry)
|
|
|
|
function NewValue() {
|
|
const value = useAtomValue(newAtom)
|
|
return <div data-testid="new">{value}</div>
|
|
}
|
|
|
|
// Render with a fresh client registry (no pre-existing atoms)
|
|
render(
|
|
<RegistryContext.Provider value={AtomRegistry.make()}>
|
|
<HydrationBoundary state={dehydratedState}>
|
|
<NewValue />
|
|
</HydrationBoundary>
|
|
</RegistryContext.Provider>
|
|
)
|
|
|
|
// New atom should be hydrated immediately during render
|
|
expect(screen.getByTestId("new")).toHaveTextContent("99")
|
|
})
|
|
|
|
test("dehydrate with encodeInitialAs ignore (default)", () => {
|
|
const atom = Atom.make(Effect.never as Effect.Effect<number>).pipe(
|
|
Atom.serializable({
|
|
key: "initial-atom",
|
|
schema: AsyncResult.Schema({ success: Schema.Number })
|
|
})
|
|
)
|
|
|
|
registry.mount(atom)
|
|
|
|
// Default behavior: Initial values should be ignored
|
|
const state = Hydration.dehydrate(registry)
|
|
const values = Hydration.toValues(state)
|
|
|
|
expect(values.length).toBe(0)
|
|
})
|
|
|
|
test("dehydrate with encodeInitialAs value-only", () => {
|
|
const atom = Atom.make(Effect.never as Effect.Effect<number>).pipe(
|
|
Atom.serializable({
|
|
key: "initial-atom",
|
|
schema: AsyncResult.Schema({ success: Schema.Number })
|
|
})
|
|
)
|
|
|
|
registry.mount(atom)
|
|
|
|
// value-only: should encode the Initial value without a resultPromise
|
|
const state = Hydration.dehydrate(registry, {
|
|
encodeInitialAs: "value-only"
|
|
})
|
|
const values = Hydration.toValues(state)
|
|
|
|
expect(values.length).toBe(1)
|
|
expect(values[0].key).toBe("initial-atom")
|
|
expect(values[0].resultPromise).toBeUndefined()
|
|
})
|
|
|
|
test("serializable encode/decode survives JSON roundtrip (wire transfer)", () => {
|
|
const atom = Atom.make(0 as never).pipe(
|
|
Atom.serializable({
|
|
key: "wire-test",
|
|
schema: AsyncResult.Schema({
|
|
success: Schema.Struct({ name: Schema.String }),
|
|
error: Schema.String
|
|
})
|
|
})
|
|
)
|
|
|
|
const original = AsyncResult.success({ name: "hello" })
|
|
|
|
// Encode using the atom's serializable encode
|
|
const encoded = atom[Atom.SerializableTypeId].encode(original)
|
|
|
|
// Simulate wire transfer (seroval / JSON serialization roundtrip)
|
|
const wireTransferred = JSON.parse(JSON.stringify(encoded))
|
|
|
|
// Decode after wire transfer — this was the bug: decode would fail
|
|
// because the encoded value lost its AsyncResult prototype
|
|
const decoded = atom[Atom.SerializableTypeId].decode(wireTransferred)
|
|
|
|
expect(AsyncResult.isAsyncResult(decoded)).toBe(true)
|
|
expect(decoded._tag).toBe("Success")
|
|
if (AsyncResult.isSuccess(decoded)) {
|
|
expect(decoded.value).toEqual({ name: "hello" })
|
|
}
|
|
})
|
|
|
|
test("dehydrate + JSON roundtrip + hydrate works (SSR simulation)", () => {
|
|
const atom = Atom.make(Effect.never as Effect.Effect<number>).pipe(
|
|
Atom.serializable({
|
|
key: "ssr-wire",
|
|
schema: AsyncResult.Schema({ success: Schema.Number })
|
|
})
|
|
)
|
|
|
|
// Server: dehydrate
|
|
const serverRegistry = AtomRegistry.make()
|
|
serverRegistry.mount(atom)
|
|
;(serverRegistry.getNodes().get("ssr-wire") as any).setValue(
|
|
AsyncResult.success(42)
|
|
)
|
|
const dehydratedState = Hydration.dehydrate(serverRegistry)
|
|
|
|
// Simulate wire transfer (seroval / JSON)
|
|
const wireTransferred = JSON.parse(JSON.stringify(dehydratedState))
|
|
|
|
// Client: hydrate from wire-transferred state
|
|
function TestComponent() {
|
|
const value = useAtomValue(atom)
|
|
return (
|
|
<div data-testid="ssr-wire-value">
|
|
{AsyncResult.isSuccess(value) ? value.value : "not-success"}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
render(
|
|
<RegistryContext.Provider value={AtomRegistry.make()}>
|
|
<HydrationBoundary state={wireTransferred}>
|
|
<TestComponent />
|
|
</HydrationBoundary>
|
|
</RegistryContext.Provider>
|
|
)
|
|
|
|
expect(screen.getByTestId("ssr-wire-value")).toHaveTextContent("42")
|
|
})
|
|
|
|
test("empty state is a no-op", () => {
|
|
function TestComponent() {
|
|
return <div data-testid="hydration-empty-state">OK</div>
|
|
}
|
|
|
|
render(
|
|
<HydrationBoundary state={[]}>
|
|
<TestComponent />
|
|
</HydrationBoundary>
|
|
)
|
|
|
|
expect(screen.getByTestId("hydration-empty-state")).toHaveTextContent("OK")
|
|
})
|
|
|
|
test("hydrate with no state is a no-op", () => {
|
|
function TestComponent() {
|
|
return <div data-testid="hydration-no-state">OK</div>
|
|
}
|
|
|
|
render(
|
|
<HydrationBoundary>
|
|
<TestComponent />
|
|
</HydrationBoundary>
|
|
)
|
|
|
|
expect(screen.getByTestId("hydration-no-state")).toHaveTextContent("OK")
|
|
})
|
|
})
|
|
|
|
describe("SSR", () => {
|
|
it("should run atom's during SSR by default", () => {
|
|
const getCount = vi.fn(() => 0)
|
|
const counterAtom = Atom.make(getCount)
|
|
|
|
function TestComponent() {
|
|
const count = useAtomValue(counterAtom)
|
|
return <div>{count}</div>
|
|
}
|
|
|
|
function App() {
|
|
return <TestComponent />
|
|
}
|
|
|
|
const ssrHtml = renderToString(<App />)
|
|
|
|
expect(getCount).toHaveBeenCalled()
|
|
expect(ssrHtml).toContain("0")
|
|
|
|
render(<App />)
|
|
|
|
expect(getCount).toHaveBeenCalled()
|
|
expect(screen.getByText("0")).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it("should not execute Atom effects during SSR when using withServerSnapshot", () => {
|
|
const mockFetchData = vi.fn(() => 0)
|
|
|
|
const userDataAtom = Atom.make(Effect.sync(() => mockFetchData())).pipe(
|
|
Atom.withServerValueInitial
|
|
)
|
|
|
|
function TestComponent() {
|
|
const result = useAtomValue(userDataAtom)
|
|
|
|
return <div>{result._tag}</div>
|
|
}
|
|
|
|
function App() {
|
|
return <TestComponent />
|
|
}
|
|
|
|
const ssrHtml = renderToString(<App />)
|
|
|
|
expect(mockFetchData).not.toHaveBeenCalled()
|
|
expect(ssrHtml).toContain("Initial")
|
|
|
|
render(<App />)
|
|
|
|
expect(mockFetchData).toHaveBeenCalled()
|
|
expect(screen.getByText("Success")).toBeInTheDocument()
|
|
})
|
|
})
|