@@ -0,0 +1,63 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import * as Async from "./Async.js"
|
||||
import * as Component from "./Component.js"
|
||||
import * as Memoized from "./Memoized.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
|
||||
describe("Async", () => {
|
||||
it("does not rerun for an unrelated parent state update", async () => {
|
||||
const load = vi.fn((_id: number) => Effect.never)
|
||||
const renderPost = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const context = await runtime.runtime.context()
|
||||
|
||||
const Post = Component.make("Post")(function*(props: { readonly id: number }) {
|
||||
renderPost()
|
||||
const value = yield* Component.useOnChange(() => load(props.id), [props.id])
|
||||
return <div>{value}</div>
|
||||
}).pipe(
|
||||
Async.async,
|
||||
Memoized.memoized,
|
||||
)
|
||||
|
||||
const Parent = Component.make("Parent")(function*() {
|
||||
const [text, setText] = React.useState("")
|
||||
const AsyncPost = yield* Post.use
|
||||
|
||||
return <>
|
||||
<input
|
||||
aria-label="text"
|
||||
value={text}
|
||||
onChange={event => setText(event.currentTarget.value)}
|
||||
/>
|
||||
<AsyncPost id={1} fallback={<div>loading</div>} />
|
||||
</>
|
||||
}).pipe(Component.withContext(runtime.context))
|
||||
|
||||
let view!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
view = render(
|
||||
<runtime.context.Provider value={context}>
|
||||
<Parent />
|
||||
</runtime.context.Provider>,
|
||||
)
|
||||
})
|
||||
|
||||
expect(screen.getByText("loading")).toBeTruthy()
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
const callsAfterLoad = load.mock.calls.length
|
||||
const rendersAfterLoad = renderPost.mock.calls.length
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByLabelText("text"), { target: { value: "a" } })
|
||||
})
|
||||
expect(load).toHaveBeenCalledTimes(callsAfterLoad)
|
||||
expect(renderPost).toHaveBeenCalledTimes(rendersAfterLoad)
|
||||
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,571 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { Context, Effect, HashMap, Layer, SubscriptionRef } from "effect"
|
||||
import * as React from "react"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import * as Component from "./Component.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
import * as Refreshable from "./Refreshable.js"
|
||||
import * as ScopeRegistry from "./ScopeRegistry.js"
|
||||
|
||||
|
||||
class ValueService extends Context.Service<ValueService, { readonly value: string }>()("ValueService") {}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("Component", () => {
|
||||
it("does not rerun useOnMount across rerenders after Strict Mode initialization", async () => {
|
||||
const onMount = vi.fn(() => Effect.succeed("mounted"))
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
const Probe = Component.makeUntraced("UseOnMountProbe")(function*() {
|
||||
const value = yield* Component.useOnMount(onMount)
|
||||
return <div>{value}</div>
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("mounted")
|
||||
expect(onMount).toHaveBeenCalledTimes(2)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
expect(await screen.findByText("mounted")).toBeTruthy()
|
||||
expect(onMount).toHaveBeenCalledTimes(2)
|
||||
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("recomputes useOnChange only when dependencies change", async () => {
|
||||
const onChange = vi.fn((value: number) => Effect.succeed(`value:${value}`))
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
const Probe = Component.makeUntraced("UseOnChangeProbe")(function*(props: { readonly value: number }) {
|
||||
const result = yield* Component.useOnChange(() => onChange(props.value), [props.value], {
|
||||
finalizerExecutionDebounce: 0,
|
||||
})
|
||||
|
||||
return <div>{result}</div>
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={1} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("value:1")
|
||||
expect(onChange).toHaveBeenCalledTimes(2)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={1} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
expect(await screen.findByText("value:1")).toBeTruthy()
|
||||
expect(onChange).toHaveBeenCalledTimes(2)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={2} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("value:2")
|
||||
expect(onChange).toHaveBeenCalledTimes(4)
|
||||
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("closes the previous scope on dependency changes and unmount", async () => {
|
||||
const cleanup = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
const Probe = Component.makeUntraced("ScopeCleanupProbe")(function*(props: { readonly value: string }) {
|
||||
const result = yield* Component.useOnChange(
|
||||
() => Effect.gen(function*() {
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
|
||||
return props.value
|
||||
}),
|
||||
[props.value],
|
||||
{ finalizerExecutionDebounce: 0 },
|
||||
)
|
||||
|
||||
return <div>{result}</div>
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
expect(cleanup).not.toHaveBeenCalled()
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("second")
|
||||
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"))
|
||||
expect(cleanup).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.unmount()
|
||||
|
||||
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"))
|
||||
expect(cleanup).toHaveBeenCalledTimes(2)
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("runs useReactEffect setup and cleanup when dependencies change", async () => {
|
||||
const lifecycle = vi.fn<(message: string) => void>()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
const Probe = Component.makeUntraced("UseReactEffectProbe")(function*(props: { readonly value: string }) {
|
||||
yield* Component.useReactEffect(() =>
|
||||
Effect.gen(function*() {
|
||||
yield* Effect.sync(() => lifecycle(`mount:${props.value}`))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => lifecycle(`cleanup:${props.value}`)))
|
||||
}),
|
||||
[props.value])
|
||||
|
||||
return <div>{props.value}</div>
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:first"))
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("second")
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:first"))
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:second"))
|
||||
|
||||
view.unmount()
|
||||
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:second"))
|
||||
expect(lifecycle.mock.calls.map(([message]) => message)).toEqual([
|
||||
"mount:first",
|
||||
"cleanup:first",
|
||||
"mount:first",
|
||||
"cleanup:first",
|
||||
"mount:second",
|
||||
"cleanup:second",
|
||||
])
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("keeps useCallbackSync stable until dependencies change", async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
const seenCallbacks: Array<(value: number) => string> = []
|
||||
|
||||
const Probe = Component.makeUntraced("UseCallbackSyncProbe")(function*(props: { readonly prefix: string }) {
|
||||
const callback = yield* Component.useCallbackSync(
|
||||
(value: number) => Effect.succeed(`${props.prefix}:${value}`),
|
||||
[props.prefix],
|
||||
)
|
||||
|
||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||
seenCallbacks.push(callback)
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
seenCallbacks.push(callback)
|
||||
}, [callback])
|
||||
|
||||
return <div>{callback(1)}</div>
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe prefix="a" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("a:1")
|
||||
const initialLength = seenCallbacks.length
|
||||
const initialCallback = seenCallbacks.at(-1)
|
||||
expect(initialCallback?.(2)).toBe("a:2")
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe prefix="a" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("a:1")
|
||||
expect(seenCallbacks).toHaveLength(initialLength)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe prefix="b" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("b:1")
|
||||
await waitFor(() => expect(seenCallbacks.length).toBeGreaterThan(initialLength))
|
||||
expect(seenCallbacks.at(-1)).not.toBe(initialCallback)
|
||||
expect(seenCallbacks.at(-1)?.(2)).toBe("b:2")
|
||||
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("delays cleanup according to finalizerExecutionDebounce", async () => {
|
||||
const cleanup = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
const Probe = Component.makeUntraced("DebouncedCleanupProbe")(function*(props: { readonly value: string }) {
|
||||
const result = yield* Component.useOnChange(
|
||||
() => Effect.gen(function*() {
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
|
||||
return props.value
|
||||
}),
|
||||
[props.value],
|
||||
{ finalizerExecutionDebounce: "20 millis" },
|
||||
)
|
||||
|
||||
return <div>{result}</div>
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("second")
|
||||
expect(cleanup).not.toHaveBeenCalled()
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
expect(cleanup).not.toHaveBeenCalled()
|
||||
|
||||
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"), { timeout: 100 })
|
||||
|
||||
view.unmount()
|
||||
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"), { timeout: 100 })
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("does not remount a component when only nonReactiveTags change", async () => {
|
||||
const mounts = vi.fn()
|
||||
const unmounts = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
const SubComponent = Component.makeUntraced("NonReactiveSubComponent")(function*() {
|
||||
const service = yield* ValueService
|
||||
const [count, setCount] = React.useState(0)
|
||||
|
||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
yield* Effect.sync(() => mounts())
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
|
||||
}))
|
||||
|
||||
return <button onClick={() => setCount(value => value + 1)}>{`${service.value}:${count}`}</button>
|
||||
}).pipe(
|
||||
Component.withOptions({
|
||||
nonReactiveTags: [...Component.defaultOptions.nonReactiveTags, ValueService],
|
||||
})
|
||||
)
|
||||
|
||||
const Parent = Component.makeUntraced("NonReactiveParent")(function*(props: { readonly value: string }) {
|
||||
const serviceLayer = React.useMemo(
|
||||
() => Layer.succeed(ValueService, { value: props.value }),
|
||||
[props.value],
|
||||
)
|
||||
const context = yield* Component.useLayer(serviceLayer, {
|
||||
finalizerExecutionDebounce: 0,
|
||||
})
|
||||
const Child = yield* Effect.provide(SubComponent.use, context)
|
||||
|
||||
return <Child />
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Parent value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first:0")
|
||||
const initialMounts = mounts.mock.calls.length
|
||||
expect(initialMounts).toBeGreaterThan(0)
|
||||
expect(unmounts).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "first:0" }))
|
||||
await screen.findByText("first:1")
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Parent value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("second:1")
|
||||
expect(mounts).toHaveBeenCalledTimes(initialMounts)
|
||||
expect(unmounts).not.toHaveBeenCalled()
|
||||
|
||||
view.unmount()
|
||||
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("does not commit effects or retain registered scopes for a discarded Suspense render", async () => {
|
||||
const setup = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
const scopeRegistry = Context.get(effectRuntime, ScopeRegistry.ScopeRegistry)
|
||||
const pending = new Promise<void>(() => {})
|
||||
|
||||
const Probe = Component.makeUntraced("DiscardedSuspenseProbe")(function*() {
|
||||
yield* Component.useReactEffect(() => Effect.sync(setup), [])
|
||||
React.use(pending)
|
||||
return <div>committed</div>
|
||||
}).pipe(
|
||||
Component.withOptions({ scopeCommitTimeout: "10 millis" }),
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
let view!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
view = render(
|
||||
<React.Suspense fallback={<div>fallback</div>}>
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
</React.Suspense>
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
await screen.findByText("fallback")
|
||||
expect(setup).not.toHaveBeenCalled()
|
||||
await waitFor(() => expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(0))
|
||||
}
|
||||
finally {
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps committed scopes alive without heartbeats", async () => {
|
||||
const acquisitions = vi.fn()
|
||||
const releases = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
const scopeRegistry = Context.get(effectRuntime, ScopeRegistry.ScopeRegistry)
|
||||
|
||||
const Probe = Component.makeUntraced("CommittedScopeProbe")(function*() {
|
||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
yield* Effect.sync(acquisitions)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(releases))
|
||||
}))
|
||||
|
||||
return <div>committed</div>
|
||||
}).pipe(
|
||||
Component.withOptions({
|
||||
finalizerExecutionDebounce: "10 millis",
|
||||
scopeCommitTimeout: "10 millis",
|
||||
}),
|
||||
Component.withContext(runtime.context),
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("committed")
|
||||
await waitFor(() => expect(releases).toHaveBeenCalledTimes(acquisitions.mock.calls.length - 1))
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
|
||||
expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(1)
|
||||
expect(releases).toHaveBeenCalledTimes(acquisitions.mock.calls.length - 1)
|
||||
|
||||
view.unmount()
|
||||
await waitFor(() => expect(releases).toHaveBeenCalledTimes(acquisitions.mock.calls.length))
|
||||
await waitFor(() => expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(0))
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("does not expire a scope while a descendant is suspended past the finalizer debounce", async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
let resolve!: () => void
|
||||
const pending = new Promise<void>(complete => {
|
||||
resolve = complete
|
||||
})
|
||||
|
||||
const Suspended = () => {
|
||||
React.use(pending)
|
||||
return <div>committed</div>
|
||||
}
|
||||
|
||||
let view!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
view = render(
|
||||
<ReactRuntime.Provider runtime={runtime} fallback={<div>fallback</div>}>
|
||||
<Suspended />
|
||||
</ReactRuntime.Provider>
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
await screen.findByText("fallback")
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
await act(async () => {
|
||||
resolve()
|
||||
await pending
|
||||
})
|
||||
await screen.findByText("committed")
|
||||
}
|
||||
finally {
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it("clears ScopeRegistry after a suspended render retries, commits, and unmounts", async () => {
|
||||
const lifecycle = vi.fn<(message: string) => void>()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
const scopeRegistry = Context.get(effectRuntime, ScopeRegistry.ScopeRegistry)
|
||||
let resolve!: () => void
|
||||
const pending = new Promise<void>(complete => {
|
||||
resolve = complete
|
||||
})
|
||||
|
||||
const Probe = Component.makeUntraced("RetriedSuspenseProbe")(function*() {
|
||||
React.use(pending)
|
||||
yield* Component.useReactEffect(() => Effect.gen(function*() {
|
||||
yield* Effect.sync(() => lifecycle("mount"))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => lifecycle("cleanup")))
|
||||
}), [])
|
||||
return <div>committed</div>
|
||||
}).pipe(
|
||||
Component.withOptions({ scopeCommitTimeout: "10 millis" }),
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
let view!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
view = render(
|
||||
<React.Suspense fallback={<div>fallback</div>}>
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
</React.Suspense>
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
await screen.findByText("fallback")
|
||||
await act(async () => {
|
||||
resolve()
|
||||
await pending
|
||||
})
|
||||
await screen.findByText("committed")
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount"))
|
||||
|
||||
view.unmount()
|
||||
await waitFor(() => expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(0))
|
||||
expect(lifecycle.mock.calls.filter(([message]) => message === "mount")).toHaveLength(2)
|
||||
expect(lifecycle.mock.calls.filter(([message]) => message === "cleanup")).toHaveLength(2)
|
||||
}
|
||||
finally {
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it("refreshes a registered body while preserving or resetting local state", async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
const makeProbe = (label: string) => Component.makeUntraced("RefreshProbe")(function*() {
|
||||
const [count, setCount] = React.useState(0)
|
||||
return <button type="button" onClick={() => setCount(value => value + 1)}>{label}:{count}</button>
|
||||
})
|
||||
const original = makeProbe("old")
|
||||
const cell = Refreshable.makeCell(original, "hooks", false)
|
||||
Refreshable.attach(original, cell)
|
||||
|
||||
const Probe = Component.withContext(original, runtime.context)
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
fireEvent.click(await screen.findByRole("button"))
|
||||
expect(screen.getByRole("button").textContent).toBe("old:1")
|
||||
|
||||
await act(async () => {
|
||||
cell.update(makeProbe("compatible"), "hooks", false)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole("button").textContent).toBe("compatible:1")
|
||||
|
||||
await act(async () => {
|
||||
cell.update(makeProbe("reset"), "changed-hooks", false)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole("button").textContent).toBe("reset:0")
|
||||
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { Effect, Layer, SubscriptionRef } from "effect"
|
||||
import * as React from "react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Component from "./Component.js"
|
||||
import * as Lens from "./Lens.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
|
||||
|
||||
const makeRuntime = async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
return {
|
||||
runtime,
|
||||
effectRuntime,
|
||||
dispose: () => runtime.runtime.dispose(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("Lens", () => {
|
||||
it("useState stays in sync with lens updates in both directions", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
const ref = await Effect.runPromise(SubscriptionRef.make(0))
|
||||
const lens = Lens.fromSubscriptionRef(ref)
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseStateProbe")(function*() {
|
||||
const [value, setValue] = yield* Lens.useState(lens)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>{value}</div>
|
||||
<button onClick={() => setValue(previous => previous + 1)}>increment</button>
|
||||
</>
|
||||
)
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("0")
|
||||
|
||||
await Effect.runPromise(Lens.set(lens, 5))
|
||||
await screen.findByText("5")
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "increment" }))
|
||||
await screen.findByText("6")
|
||||
expect(await Effect.runPromise(Lens.get(lens))).toBe(6)
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it("useState respects the provided equivalence when subscribing to lens changes", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
const ref = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
|
||||
const lens = Lens.fromSubscriptionRef(ref)
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseStateEquivalenceProbe")(function*() {
|
||||
const [value] = yield* Lens.useState(lens, {
|
||||
equivalence: (self, that) => self.id === that.id,
|
||||
})
|
||||
|
||||
return <div>{value.label}</div>
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
|
||||
await Effect.runPromise(Lens.set(lens, { id: 1, label: "ignored" }))
|
||||
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||
expect(screen.queryByText("ignored")).toBeNull()
|
||||
|
||||
await Effect.runPromise(Lens.set(lens, { id: 2, label: "updated" }))
|
||||
await screen.findByText("updated")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it("useFromReactState writes React state changes into the returned lens", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
let lens: Lens.Lens<string, never, never, never, never> | undefined
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseFromReactStateProbe")(function*() {
|
||||
const [value, setValue] = React.useState("hello")
|
||||
const reactLens = yield* Lens.useFromReactState([value, setValue])
|
||||
|
||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||
lens = reactLens
|
||||
}))
|
||||
|
||||
return <button onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("hello")
|
||||
await waitFor(() => expect(lens).toBeDefined())
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "hello" }))
|
||||
await screen.findByText("hello!")
|
||||
await waitFor(async () => expect(await Effect.runPromise(Lens.get(lens!))).toBe("hello!"))
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it("useFromReactState respects equivalence when lens updates flow back into React state", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
let lens: Lens.Lens<{ readonly id: number; readonly label: string }, never, never, never, never> | undefined
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseFromReactStateEquivalenceProbe")(function*() {
|
||||
const [value, setValue] = React.useState({ id: 1, label: "first" })
|
||||
const reactLens = yield* Lens.useFromReactState([value, setValue], {
|
||||
equivalence: (self, that) => self.id === that.id,
|
||||
})
|
||||
|
||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||
lens = reactLens
|
||||
}))
|
||||
|
||||
return <div>{value.label}</div>
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
await waitFor(() => expect(lens).toBeDefined())
|
||||
|
||||
await Effect.runPromise(Lens.set(lens!, { id: 1, label: "ignored" }))
|
||||
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||
expect(screen.queryByText("ignored")).toBeNull()
|
||||
|
||||
await Effect.runPromise(Lens.set(lens!, { id: 2, label: "updated" }))
|
||||
await screen.findByText("updated")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Effect, type Scope, Stream } from "effect"
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Query from "./Query.js"
|
||||
import * as QueryClient from "./QueryClient.js"
|
||||
import * as View from "./View.js"
|
||||
|
||||
|
||||
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
|
||||
Effect.runPromise(Effect.scoped(effect.pipe(
|
||||
Effect.provide(QueryClient.layer()),
|
||||
)))
|
||||
|
||||
const staticKey = <K>(key: K): View.View<K> => View.make({
|
||||
get: Effect.succeed(key),
|
||||
changes: Stream.make(key),
|
||||
})
|
||||
|
||||
const expectSuccessValue = <A, E>(
|
||||
state: Query.FinalQueryState<unknown, A, E>,
|
||||
): A => {
|
||||
expect(AsyncResult.isSuccess(state.result)).toBe(true)
|
||||
|
||||
if (!AsyncResult.isSuccess(state.result))
|
||||
throw new Error(`Expected Success result, received ${state.result._tag}`)
|
||||
|
||||
return state.result.value
|
||||
}
|
||||
|
||||
describe("Query", () => {
|
||||
it("fetch caches successful results until they are invalidated or stale", async () => {
|
||||
let calls = 0
|
||||
const key = staticKey<readonly [number]>([1])
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
const second = yield* query.fetch([1])
|
||||
|
||||
return [first, second] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:1")
|
||||
})
|
||||
|
||||
it("refresh reruns the latest query key", async () => {
|
||||
let calls = 0
|
||||
const key = staticKey<readonly [number]>([1])
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "0 millis",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
yield* Effect.sleep("1 millis")
|
||||
const refreshed = yield* query.refresh
|
||||
|
||||
return [first, refreshed] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||
})
|
||||
|
||||
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
|
||||
let calls = 0
|
||||
const key = staticKey<readonly [number]>([1])
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
yield* query.invalidateCacheEntry([1])
|
||||
const second = yield* query.fetch([1])
|
||||
|
||||
return [first, second] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||
})
|
||||
|
||||
it("invalidateCache clears cached entries for the query function", async () => {
|
||||
let calls = 0
|
||||
const key = staticKey<readonly [number]>([1])
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
yield* query.invalidateCache
|
||||
const second = yield* query.fetch([1])
|
||||
|
||||
return [first, second] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||
})
|
||||
|
||||
it("service starts the key view automatically and records its latest final state", async () => {
|
||||
let calls = 0
|
||||
const key = staticKey<readonly [number]>([1])
|
||||
|
||||
const effect = Effect.gen(function*() {
|
||||
const query = yield* Query.service({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
const latestFinalState = yield* Effect.sleep("1 millis").pipe(
|
||||
Effect.andThen(View.get(query.latestFinalState)),
|
||||
Effect.flatMap(Effect.fromOption),
|
||||
Effect.eventually,
|
||||
Effect.timeout("1 second"),
|
||||
)
|
||||
|
||||
return {
|
||||
state: yield* View.get(query.state),
|
||||
latestFinalState,
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runQueryTest(effect)
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(result.state.key).toEqual([1])
|
||||
expect(expectSuccessValue(result.latestFinalState)).toBe("value:1:1")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import type * as Component from "./Component.js"
|
||||
import * as Refreshable from "./Refreshable.js"
|
||||
|
||||
|
||||
describe("Refreshable", () => {
|
||||
it("attaches a cell and notifies subscribers after a compatible update", async () => {
|
||||
const first = {} as Component.Component.Any
|
||||
const cell = Refreshable.makeCell(first, "hooks", false)
|
||||
const listener = vi.fn()
|
||||
|
||||
expect(Refreshable.isRefreshable(first)).toBe(false)
|
||||
const attached = Refreshable.attach(first, cell)
|
||||
expect(attached).toBe(first)
|
||||
expect(Refreshable.isRefreshable(first)).toBe(true)
|
||||
expect(attached[Refreshable.RefreshableTypeId]).toBe(cell)
|
||||
expect(Object.getPrototypeOf(attached).asFunctionComponent)
|
||||
.toBe(Refreshable.RefreshablePrototype.asFunctionComponent)
|
||||
|
||||
cell.subscribe(listener)
|
||||
const second = {} as Component.Component.Any
|
||||
cell.update(second, "hooks", false)
|
||||
|
||||
expect(cell.current).toBe(second)
|
||||
expect(cell.snapshot).toEqual({
|
||||
revision: 1,
|
||||
resetRevision: 0,
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("requests a remount when a signature changes or reset is forced", () => {
|
||||
const component = {} as Component.Component.Any
|
||||
const cell = Refreshable.makeCell(component, "one", false)
|
||||
|
||||
cell.update({} as Component.Component.Any, "two", false)
|
||||
expect(cell.snapshot.resetRevision).toBe(1)
|
||||
|
||||
cell.update({} as Component.Component.Any, "two", true)
|
||||
expect(cell.snapshot.resetRevision).toBe(2)
|
||||
})
|
||||
|
||||
it("builds the refresh shell from the current descriptor implementation", () => {
|
||||
const implementation = () => null
|
||||
const makeFunctionComponent = vi.fn(() => implementation)
|
||||
const component = {
|
||||
makeFunctionComponent,
|
||||
} as unknown as Component.ComponentImpl.Any
|
||||
const contextRef = {
|
||||
current: {},
|
||||
} as never
|
||||
const cell = Refreshable.makeCell(component, "hooks", false)
|
||||
const attached = Refreshable.attach(component, cell)
|
||||
|
||||
expect(attached.asFunctionComponent(contextRef)).not.toBe(implementation)
|
||||
expect(makeFunctionComponent).toHaveBeenCalledWith(contextRef)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { Effect, Layer, SubscriptionRef } from "effect"
|
||||
import { Lens } from "effect-lens"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Component from "./Component.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
import * as View from "./View.js"
|
||||
|
||||
|
||||
const makeRuntime = async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
return {
|
||||
runtime,
|
||||
effectRuntime,
|
||||
dispose: () => runtime.runtime.dispose(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("View", () => {
|
||||
it("useAll returns the latest values and rerenders when any input changes", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
const countRef = await Effect.runPromise(SubscriptionRef.make(1))
|
||||
const labelRef = await Effect.runPromise(SubscriptionRef.make("a"))
|
||||
const count = Lens.fromSubscriptionRef(countRef)
|
||||
const label = Lens.fromSubscriptionRef(labelRef)
|
||||
|
||||
const Probe = Component.makeUntraced("ViewUseAllProbe")(function*() {
|
||||
const [currentCount, currentLabel] = yield* View.useAll([count, label])
|
||||
|
||||
return <div>{`${currentCount}:${currentLabel}`}</div>
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("1:a")
|
||||
|
||||
await Effect.runPromise(Lens.set(count, 2))
|
||||
await screen.findByText("2:a")
|
||||
|
||||
await Effect.runPromise(Lens.set(label, "b"))
|
||||
await screen.findByText("2:b")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it("useAll respects the provided equivalence when processing updates", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
const itemRef = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
|
||||
const flagRef = await Effect.runPromise(SubscriptionRef.make(true))
|
||||
const item = Lens.fromSubscriptionRef(itemRef)
|
||||
const flag = Lens.fromSubscriptionRef(flagRef)
|
||||
|
||||
const Probe = Component.makeUntraced("ViewUseAllEquivalenceProbe")(function*() {
|
||||
const [currentItem, currentFlag] = yield* View.useAll([item, flag], {
|
||||
equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) =>
|
||||
selfItem.id === thatItem.id && selfFlag === thatFlag,
|
||||
})
|
||||
|
||||
return <div>{`${currentItem.label}:${currentFlag ? "on" : "off"}`}</div>
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first:on")
|
||||
|
||||
await Effect.runPromise(Lens.set(item, { id: 1, label: "ignored" }))
|
||||
await waitFor(() => expect(screen.getByText("first:on")).toBeTruthy())
|
||||
expect(screen.queryByText("ignored:on")).toBeNull()
|
||||
|
||||
await Effect.runPromise(Lens.set(flag, false))
|
||||
await screen.findByText("ignored:off")
|
||||
|
||||
await Effect.runPromise(Lens.set(item, { id: 2, label: "updated" }))
|
||||
await screen.findByText("updated:off")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
import { configure } from "@testing-library/react"
|
||||
|
||||
|
||||
configure({ reactStrictMode: true })
|
||||
Reference in New Issue
Block a user