|
|
@@ -1,5 +1,5 @@
|
|
|
|
import { render, screen, waitFor } from "@testing-library/react"
|
|
|
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
|
|
|
import { Context, Effect, Layer } from "effect"
|
|
|
|
import { Context, Effect, HashMap, Layer, Ref } from "effect"
|
|
|
|
import * as React from "react"
|
|
|
|
import * as React from "react"
|
|
|
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
|
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
|
|
import * as Component from "../src/Component.js"
|
|
|
|
import * as Component from "../src/Component.js"
|
|
|
@@ -13,7 +13,7 @@ afterEach(() => {
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
describe("Component", () => {
|
|
|
|
describe("Component", () => {
|
|
|
|
it("runs useOnMount only once across rerenders", async () => {
|
|
|
|
it("does not rerun useOnMount across rerenders after Strict Mode initialization", async () => {
|
|
|
|
const onMount = vi.fn(() => Effect.succeed("mounted"))
|
|
|
|
const onMount = vi.fn(() => Effect.succeed("mounted"))
|
|
|
|
const runtime = ReactRuntime.make(Layer.empty)
|
|
|
|
const runtime = ReactRuntime.make(Layer.empty)
|
|
|
|
const effectRuntime = await runtime.runtime.context()
|
|
|
|
const effectRuntime = await runtime.runtime.context()
|
|
|
@@ -32,7 +32,7 @@ describe("Component", () => {
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await screen.findByText("mounted")
|
|
|
|
await screen.findByText("mounted")
|
|
|
|
expect(onMount).toHaveBeenCalledTimes(1)
|
|
|
|
expect(onMount).toHaveBeenCalledTimes(2)
|
|
|
|
|
|
|
|
|
|
|
|
view.rerender(
|
|
|
|
view.rerender(
|
|
|
|
<runtime.context.Provider value={effectRuntime}>
|
|
|
|
<runtime.context.Provider value={effectRuntime}>
|
|
|
@@ -40,7 +40,7 @@ describe("Component", () => {
|
|
|
|
</runtime.context.Provider>
|
|
|
|
</runtime.context.Provider>
|
|
|
|
)
|
|
|
|
)
|
|
|
|
expect(await screen.findByText("mounted")).toBeTruthy()
|
|
|
|
expect(await screen.findByText("mounted")).toBeTruthy()
|
|
|
|
expect(onMount).toHaveBeenCalledTimes(1)
|
|
|
|
expect(onMount).toHaveBeenCalledTimes(2)
|
|
|
|
|
|
|
|
|
|
|
|
view.unmount()
|
|
|
|
view.unmount()
|
|
|
|
await runtime.runtime.dispose()
|
|
|
|
await runtime.runtime.dispose()
|
|
|
@@ -68,7 +68,7 @@ describe("Component", () => {
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await screen.findByText("value:1")
|
|
|
|
await screen.findByText("value:1")
|
|
|
|
expect(onChange).toHaveBeenCalledTimes(1)
|
|
|
|
expect(onChange).toHaveBeenCalledTimes(2)
|
|
|
|
|
|
|
|
|
|
|
|
view.rerender(
|
|
|
|
view.rerender(
|
|
|
|
<runtime.context.Provider value={effectRuntime}>
|
|
|
|
<runtime.context.Provider value={effectRuntime}>
|
|
|
@@ -77,7 +77,7 @@ describe("Component", () => {
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByText("value:1")).toBeTruthy()
|
|
|
|
expect(await screen.findByText("value:1")).toBeTruthy()
|
|
|
|
expect(onChange).toHaveBeenCalledTimes(1)
|
|
|
|
expect(onChange).toHaveBeenCalledTimes(2)
|
|
|
|
|
|
|
|
|
|
|
|
view.rerender(
|
|
|
|
view.rerender(
|
|
|
|
<runtime.context.Provider value={effectRuntime}>
|
|
|
|
<runtime.context.Provider value={effectRuntime}>
|
|
|
@@ -86,7 +86,7 @@ describe("Component", () => {
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await screen.findByText("value:2")
|
|
|
|
await screen.findByText("value:2")
|
|
|
|
expect(onChange).toHaveBeenCalledTimes(2)
|
|
|
|
expect(onChange).toHaveBeenCalledTimes(4)
|
|
|
|
|
|
|
|
|
|
|
|
view.unmount()
|
|
|
|
view.unmount()
|
|
|
|
await runtime.runtime.dispose()
|
|
|
|
await runtime.runtime.dispose()
|
|
|
@@ -179,6 +179,8 @@ describe("Component", () => {
|
|
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:second"))
|
|
|
|
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:second"))
|
|
|
|
expect(lifecycle.mock.calls.map(([message]) => message)).toEqual([
|
|
|
|
expect(lifecycle.mock.calls.map(([message]) => message)).toEqual([
|
|
|
|
|
|
|
|
"mount:first",
|
|
|
|
|
|
|
|
"cleanup:first",
|
|
|
|
"mount:first",
|
|
|
|
"mount:first",
|
|
|
|
"cleanup:first",
|
|
|
|
"cleanup:first",
|
|
|
|
"mount:second",
|
|
|
|
"mount:second",
|
|
|
@@ -218,9 +220,9 @@ describe("Component", () => {
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await screen.findByText("a:1")
|
|
|
|
await screen.findByText("a:1")
|
|
|
|
expect(seenCallbacks).toHaveLength(2)
|
|
|
|
const initialLength = seenCallbacks.length
|
|
|
|
expect(seenCallbacks[0]).toBe(seenCallbacks[1])
|
|
|
|
const initialCallback = seenCallbacks.at(-1)
|
|
|
|
expect(seenCallbacks[0]?.(2)).toBe("a:2")
|
|
|
|
expect(initialCallback?.(2)).toBe("a:2")
|
|
|
|
|
|
|
|
|
|
|
|
view.rerender(
|
|
|
|
view.rerender(
|
|
|
|
<runtime.context.Provider value={effectRuntime}>
|
|
|
|
<runtime.context.Provider value={effectRuntime}>
|
|
|
@@ -229,7 +231,7 @@ describe("Component", () => {
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await screen.findByText("a:1")
|
|
|
|
await screen.findByText("a:1")
|
|
|
|
expect(seenCallbacks).toHaveLength(2)
|
|
|
|
expect(seenCallbacks).toHaveLength(initialLength)
|
|
|
|
|
|
|
|
|
|
|
|
view.rerender(
|
|
|
|
view.rerender(
|
|
|
|
<runtime.context.Provider value={effectRuntime}>
|
|
|
|
<runtime.context.Provider value={effectRuntime}>
|
|
|
@@ -238,9 +240,9 @@ describe("Component", () => {
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await screen.findByText("b:1")
|
|
|
|
await screen.findByText("b:1")
|
|
|
|
await waitFor(() => expect(seenCallbacks).toHaveLength(3))
|
|
|
|
await waitFor(() => expect(seenCallbacks.length).toBeGreaterThan(initialLength))
|
|
|
|
expect(seenCallbacks[2]).not.toBe(seenCallbacks[1])
|
|
|
|
expect(seenCallbacks.at(-1)).not.toBe(initialCallback)
|
|
|
|
expect(seenCallbacks[2]?.(2)).toBe("b:2")
|
|
|
|
expect(seenCallbacks.at(-1)?.(2)).toBe("b:2")
|
|
|
|
|
|
|
|
|
|
|
|
view.unmount()
|
|
|
|
view.unmount()
|
|
|
|
await runtime.runtime.dispose()
|
|
|
|
await runtime.runtime.dispose()
|
|
|
@@ -301,15 +303,18 @@ describe("Component", () => {
|
|
|
|
|
|
|
|
|
|
|
|
const SubComponent = Component.makeUntraced("NonReactiveSubComponent")(function*() {
|
|
|
|
const SubComponent = Component.makeUntraced("NonReactiveSubComponent")(function*() {
|
|
|
|
const service = yield* ValueService
|
|
|
|
const service = yield* ValueService
|
|
|
|
|
|
|
|
const [count, setCount] = React.useState(0)
|
|
|
|
|
|
|
|
|
|
|
|
yield* Component.useOnMount(() => Effect.gen(function*() {
|
|
|
|
yield* Component.useOnMount(() => Effect.gen(function*() {
|
|
|
|
yield* Effect.sync(() => mounts())
|
|
|
|
yield* Effect.sync(() => mounts())
|
|
|
|
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
|
|
|
|
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
|
|
|
|
}))
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
return <div>{service.value}</div>
|
|
|
|
return <button onClick={() => setCount(value => value + 1)}>{`${service.value}:${count}`}</button>
|
|
|
|
}).pipe(
|
|
|
|
}).pipe(
|
|
|
|
Component.withOptions({ nonReactiveTags: [ValueService] })
|
|
|
|
Component.withOptions({
|
|
|
|
|
|
|
|
nonReactiveTags: [...Component.defaultOptions.nonReactiveTags, ValueService],
|
|
|
|
|
|
|
|
})
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const Parent = Component.makeUntraced("NonReactiveParent")(function*(props: { readonly value: string }) {
|
|
|
|
const Parent = Component.makeUntraced("NonReactiveParent")(function*(props: { readonly value: string }) {
|
|
|
@@ -333,22 +338,115 @@ describe("Component", () => {
|
|
|
|
</runtime.context.Provider>
|
|
|
|
</runtime.context.Provider>
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await screen.findByText("first")
|
|
|
|
await screen.findByText("first:0")
|
|
|
|
expect(mounts).toHaveBeenCalledTimes(1)
|
|
|
|
const initialMounts = mounts.mock.calls.length
|
|
|
|
|
|
|
|
expect(initialMounts).toBeGreaterThan(0)
|
|
|
|
expect(unmounts).not.toHaveBeenCalled()
|
|
|
|
expect(unmounts).not.toHaveBeenCalled()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "first:0" }))
|
|
|
|
|
|
|
|
await screen.findByText("first:1")
|
|
|
|
|
|
|
|
|
|
|
|
view.rerender(
|
|
|
|
view.rerender(
|
|
|
|
<runtime.context.Provider value={effectRuntime}>
|
|
|
|
<runtime.context.Provider value={effectRuntime}>
|
|
|
|
<Parent value="second" />
|
|
|
|
<Parent value="second" />
|
|
|
|
</runtime.context.Provider>
|
|
|
|
</runtime.context.Provider>
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await screen.findByText("second")
|
|
|
|
await screen.findByText("second:1")
|
|
|
|
expect(mounts).toHaveBeenCalledTimes(1)
|
|
|
|
expect(mounts).toHaveBeenCalledTimes(initialMounts)
|
|
|
|
expect(unmounts).not.toHaveBeenCalled()
|
|
|
|
expect(unmounts).not.toHaveBeenCalled()
|
|
|
|
|
|
|
|
|
|
|
|
view.unmount()
|
|
|
|
view.unmount()
|
|
|
|
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
|
|
|
|
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
|
|
|
|
await runtime.runtime.dispose()
|
|
|
|
await runtime.runtime.dispose()
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
it.fails("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 scopeMap = Context.get(effectRuntime, Component.ScopeMap)
|
|
|
|
|
|
|
|
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.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()
|
|
|
|
|
|
|
|
expect(HashMap.size(Effect.runSync(Ref.get(scopeMap.ref)))).toBe(0)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
finally {
|
|
|
|
|
|
|
|
view.unmount()
|
|
|
|
|
|
|
|
await runtime.runtime.dispose()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
it.fails("clears ScopeMap 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 scopeMap = Context.get(effectRuntime, Component.ScopeMap)
|
|
|
|
|
|
|
|
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.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(Ref.get(scopeMap.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()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
})
|
|
|
|
})
|
|
|
|
})
|
|
|
|