@@ -1,10 +1,17 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react"
|
import { render, screen, waitFor } from "@testing-library/react"
|
||||||
import { Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { describe, expect, it, vi } from "vitest"
|
import * as React from "react"
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
import * as Component from "../src/Component.js"
|
import * as Component from "../src/Component.js"
|
||||||
import * as ReactRuntime from "../src/ReactRuntime.js"
|
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||||
|
|
||||||
|
|
||||||
|
class ValueService extends Context.Tag("ValueService")<ValueService, { readonly value: string }>() {}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
describe("Component", () => {
|
describe("Component", () => {
|
||||||
it("runs useOnMount only once across rerenders", async () => {
|
it("runs useOnMount only once across rerenders", async () => {
|
||||||
const onMount = vi.fn(() => Effect.succeed("mounted"))
|
const onMount = vi.fn(() => Effect.succeed("mounted"))
|
||||||
@@ -130,4 +137,218 @@ describe("Component", () => {
|
|||||||
expect(cleanup).toHaveBeenCalledTimes(2)
|
expect(cleanup).toHaveBeenCalledTimes(2)
|
||||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||||
|
|
||||||
|
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.withRuntime(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:second",
|
||||||
|
"cleanup:second",
|
||||||
|
])
|
||||||
|
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps useCallbackSync stable until dependencies change", async () => {
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||||
|
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.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe prefix="a" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("a:1")
|
||||||
|
expect(seenCallbacks).toHaveLength(2)
|
||||||
|
expect(seenCallbacks[0]).toBe(seenCallbacks[1])
|
||||||
|
expect(seenCallbacks[0]?.(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(2)
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe prefix="b" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("b:1")
|
||||||
|
await waitFor(() => expect(seenCallbacks).toHaveLength(3))
|
||||||
|
expect(seenCallbacks[2]).not.toBe(seenCallbacks[1])
|
||||||
|
expect(seenCallbacks[2]?.(2)).toBe("b:2")
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("delays cleanup according to finalizerExecutionDebounce", async () => {
|
||||||
|
const cleanup = vi.fn()
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||||
|
|
||||||
|
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.withRuntime(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 Effect.runPromise(runtime.runtime.disposeEffect)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||||
|
|
||||||
|
const SubComponent = Component.makeUntraced("NonReactiveSubComponent")(function*() {
|
||||||
|
const service = yield* ValueService
|
||||||
|
|
||||||
|
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||||
|
yield* Effect.sync(() => mounts())
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
|
||||||
|
}))
|
||||||
|
|
||||||
|
return <div>{service.value}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withOptions({ 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.useContextFromLayer(serviceLayer, {
|
||||||
|
finalizerExecutionDebounce: 0,
|
||||||
|
})
|
||||||
|
const Child = yield* Effect.provide(SubComponent.use, context)
|
||||||
|
|
||||||
|
return <Child />
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Parent value="first" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("first")
|
||||||
|
expect(mounts).toHaveBeenCalledTimes(1)
|
||||||
|
expect(unmounts).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Parent value="second" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("second")
|
||||||
|
expect(mounts).toHaveBeenCalledTimes(1)
|
||||||
|
expect(unmounts).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
|
||||||
|
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user