Add Effect v4 support, Fast Refresh tooling, and revamped docs #56

Merged
Thilawyn merged 133 commits from next into master 2026-07-26 02:32:59 +02:00
2 changed files with 72 additions and 3 deletions
Showing only changes of commit faac021527 - Show all commits
+9 -3
View File
@@ -1,6 +1,6 @@
/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Context, type Duration, Effect, Equivalence, Exit, Function, identity, Layer, Pipeable, Predicate, Scheduler, Scope, Tracer } from "effect"
import { Context, type Duration, Effect, Equivalence, Exit, Function, identity, Layer, Pipeable, Predicate, References, Scheduler, Scope, Tracer } from "effect"
import * as React from "react"
import * as ScopeRegistry from "./ScopeRegistry.js"
@@ -133,7 +133,7 @@ export interface ComponentOptions {
/**
* Context tags that should not trigger component remount when their values change.
*
* @default [Tracer.ParentSpan]
* @default [Tracer.ParentSpan, References.CurrentStackFrame, Scheduler.Scheduler, Layer.CurrentMemoMap, Scope.Scope]
*/
readonly nonReactiveTags: readonly Context.Key<any, any>[]
@@ -163,7 +163,13 @@ export interface ComponentOptions {
}
export const defaultOptions: ComponentOptions = {
nonReactiveTags: [Tracer.ParentSpan, Scheduler.Scheduler, Layer.CurrentMemoMap],
nonReactiveTags: [
Tracer.ParentSpan,
References.CurrentStackFrame,
Scheduler.Scheduler,
Layer.CurrentMemoMap,
Scope.Scope,
],
finalizerExecutionStrategy: "sequential",
finalizerExecutionDebounce: "100 millis",
scopeCommitTimeout: "1 minute",
@@ -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 "../src/Async.js"
import * as Component from "../src/Component.js"
import * as Memoized from "../src/Memoized.js"
import * as ReactRuntime from "../src/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()
})
})