Refactor Form, add Component.provide
Lint / lint (push) Failing after 19s

This commit is contained in:
Julien Valverdé
2026-08-21 01:19:29 +02:00
parent 44bea41e39
commit ecd337a45d
4 changed files with 279 additions and 64 deletions
@@ -10,11 +10,56 @@ import * as ScopeRegistry from "./ScopeRegistry.js"
class ValueService extends Context.Service<ValueService, { readonly value: string }>()("ValueService") {}
class ParentService extends Context.Service<ParentService, { readonly prefix: string }>()("ParentService") {}
afterEach(() => {
vi.useRealTimers()
})
describe("Component", () => {
it("provides a layer once per mounted component instance", async () => {
const setup = vi.fn()
const cleanup = vi.fn()
const serviceLayer = Layer.effect(ValueService, Effect.gen(function*() {
const parent = yield* ParentService
yield* Effect.sync(setup)
yield* Effect.addFinalizer(() => Effect.sync(cleanup))
return { value: `${parent.prefix} value` }
}))
const runtime = ReactRuntime.make(Layer.succeed(ParentService, { prefix: "provided" }))
const effectRuntime = await runtime.runtime.context()
const Probe = Component.makeUntraced("ProvidedServiceProbe")(function*() {
const service = yield* ValueService
return <div>{service.value}</div>
}).pipe(
Component.provide(serviceLayer),
Component.withContext(runtime.context),
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
expect(await screen.findByText("provided value")).toBeTruthy()
expect(setup).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
expect(await screen.findByText("provided value")).toBeTruthy()
expect(setup).toHaveBeenCalledTimes(1)
view.unmount()
await waitFor(() => expect(cleanup).toHaveBeenCalledTimes(1))
await runtime.runtime.dispose()
})
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)
+62 -2
View File
@@ -607,6 +607,66 @@ export const withOptions: {
Object.getPrototypeOf(self),
))
export declare namespace provide {
export type Result<T extends Component.Any, ROut, E, RIn> = (
& Omit<T, keyof Component.AsComponent<T>>
& Component<
Component.Props<T>,
Component.Success<T>,
Component.Error<T> | E,
Exclude<Component.Context<T>, ROut> | RIn,
Component.Function<T>
>
)
}
/**
* Provides a layer to an Effect View component.
*
* The layer is built once for each mounted instance of the returned component and
* is released when that instance unmounts. Any services still required by the
* layer remain requirements of the returned component.
*
* @example
* ```tsx
* const TodosViewLive = Component.provide(TodosView, TodosService.Default)
*
* // Equivalent pipeline form
* const TodosViewLive = TodosView.pipe(
* Component.provide(TodosService.Default),
* )
* ```
*
* @example Providing a layer with dependencies
* ```tsx
* const UserViewLive = Component.provide(
* UserView,
* UserService.layer,
* )
* // UserViewLive still requires UserRepository, which UserService.layer needs.
* ```
*/
export const provide: {
<ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>,
): <T extends Component.Any>(self: T) => provide.Result<T, ROut, E, RIn>
<T extends Component.Any, ROut, E, RIn>(
self: T,
layer: Layer.Layer<ROut, E, RIn>,
): provide.Result<T, ROut, E, RIn>
} = Function.dual(2, <T extends Component.Any, ROut, E, RIn>(
self: T,
layer: Layer.Layer<ROut, E, RIn>,
): provide.Result<T, ROut, E, RIn> => Object.setPrototypeOf(
Object.assign(function() {}, self, {
body: Effect.fnUntraced(function*(props: Component.Props<T>) {
const context = yield* useLayer(layer)
return React.createElement(yield* Effect.provide(self.use, context), props)
}),
}),
Object.getPrototypeOf(self),
))
/**
* Wraps an Effect View Component and converts it into a standard React function component,
* serving as an **entrypoint** into an Effect View component hierarchy.
@@ -1047,7 +1107,7 @@ export const useCallbackPromise = Effect.fnUntraced(function* <Args extends unkn
return React.useCallback((...args: Args) => Effect.runPromiseWith(contextRef.current)(f(...args)), deps)
})
export declare namespace useContext {
export declare namespace useLayer {
export interface Options extends useOnChange.Options {}
}
@@ -1112,7 +1172,7 @@ export declare namespace useContext {
*/
export const useLayer = <ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>,
options?: useContext.Options,
options?: useLayer.Options,
): Effect.Effect<Context.Context<ROut>, E, RIn | Scope.Scope> => useOnChange(() => Effect.flatMap(
Effect.context<RIn>(),
context => Layer.build(Layer.provide(layer, Layer.succeedContext(context))),
+140 -54
View File
@@ -1,4 +1,4 @@
import { type Context, Effect, Equal, Exit, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
import { Cause, type Context, Effect, Exit, type Fiber, Option, Pipeable, Predicate, PubSub, Ref, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js"
import * as View from "./View.js"
@@ -16,11 +16,26 @@ extends Pipeable.Pipeable {
readonly latestKey: View.View<Option.Option<K>>
readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
readonly state: View.View<AsyncResult.AsyncResult<A, E>>
readonly latestFinalResult: View.View<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>
readonly state: View.View<LatestMutationState<K, A, E>>
readonly latestFinalState: View.View<Option.Option<FinalMutationState<K, A, E>>>
mutate(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>>
mutate(key: K): Effect.Effect<FinalMutationState<K, A, E>>
mutateView(key: K): Effect.Effect<View.View<MutationState<K, A, E>>>
}
export interface LatestMutationState<out K, out A, out E = never> {
readonly key: Option.Option<K>
readonly result: AsyncResult.AsyncResult<A, E>
}
export interface MutationState<out K, out A, out E = never> {
readonly key: Option.Some<K>
readonly result: AsyncResult.AsyncResult<A, E>
}
export interface FinalMutationState<out K, out A, out E = never> {
readonly key: Option.Some<K>
readonly result: AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>
}
export const isMutation = (u: unknown): u is Mutation<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
@@ -36,20 +51,20 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
readonly latestKey: Lens.Lens<Option.Option<K>>,
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
readonly state: Lens.Lens<AsyncResult.AsyncResult<A, E>>,
readonly latestFinalResult: Lens.Lens<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>,
readonly state: Lens.Lens<LatestMutationState<K, A, E>>,
readonly latestFinalState: Lens.Lens<Option.Option<FinalMutationState<K, A, E>>>,
) {
super()
}
mutate(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
mutate(key: K): Effect.Effect<FinalMutationState<K, A, E>> {
return Lens.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(this.start(key)),
Effect.flatMap(state => this.watch(state)),
Effect.provide(this.context),
)
}
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>> {
mutateView(key: K): Effect.Effect<View.View<MutationState<K, A, E>>> {
return Lens.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(this.start(key)),
Effect.tap(state => Effect.forkScoped(this.watch(state))),
@@ -58,54 +73,81 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
}
start(key: K): Effect.Effect<
View.View<AsyncResult.AsyncResult<A, E>>,
View.View<MutationState<K, A, E>>,
never,
Scope.Scope | R
> {
return Effect.gen({ self: this }, function*() {
const previous = yield* Lens.get(this.latestFinalResult)
const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make<AsyncResult.AsyncResult<A, E>>(
Option.getOrElse(previous, () => AsyncResult.initial(false))
))
const previous: MutationState<K, A, E> = Option.getOrElse(yield* Lens.get(this.latestFinalState), () => ({
key: Option.some(key) as Option.Some<K>,
result: AsyncResult.initial(),
}))
const state = yield* makeMutationStateLens(previous)
const fiber = yield* Effect.forkScoped(Effect.andThen(
Lens.update(state, AsyncResult.match({
onInitial: () => AsyncResult.initial(true),
onSuccess: v => AsyncResult.success(v.value, {
waiting: true,
}),
onFailure: v => AsyncResult.failure(v.cause, {
waiting: true,
previousSuccess: v.previousSuccess,
})
})),
Effect.onExit(this.f(key), exit => Lens.update(
Lens.update<MutationState<K, A, E>, never, never, never, never>(
state,
previous => Exit.match(exit, {
onSuccess: v => AsyncResult.success(v),
onFailure: c => AsyncResult.match(previous, {
onInitial: () => AsyncResult.failure(c),
onSuccess: v => AsyncResult.failure(c, {
previousSuccess: Option.some(v),
}),
onFailure: v => AsyncResult.failure(c, {
previousSuccess: v.previousSuccess,
})
previous => AsyncResult.match(previous.result, {
onInitial: () => ({
key: previous.key,
result: AsyncResult.initial(true),
}),
}),
).pipe(
Effect.andThen(Effect.all([
Effect.fiberId,
Lens.get(this.fiber),
])),
Effect.flatMap(([fiberId, fiber]) => Option.match(fiber, {
onSome: v => Equal.equals(fiberId, v.id)
? Lens.set(this.fiber, Option.none())
: Effect.void,
onNone: () => Effect.void,
})),
onSuccess: result => ({
key: previous.key,
result: AsyncResult.success(result.value, {
waiting: true,
}),
}),
onFailure: result => ({
key: previous.key,
result: AsyncResult.failure(result.cause, {
waiting: true,
previousSuccess: result.previousSuccess,
}),
}),
}
)),
Effect.onExit(this.f(previous.key.value), exit => Effect.gen({ self: this }, function*() {
const fiberId = yield* Effect.fiberId
const fiber = yield* Lens.get(this.fiber)
if (Option.isSome(fiber) && fiberId === fiber.value.id)
yield* Lens.set(this.fiber, Option.none())
const finalState = (yield* Lens.updateAndGet<MutationState<K, A, E>, never, never, never, never>(
state,
previous => Exit.match(exit, {
onSuccess: v => ({
key: previous.key,
result: AsyncResult.success(v),
}),
onFailure: c => Cause.hasInterruptsOnly(c)
? previous
: AsyncResult.match(previous.result, {
onInitial: () => ({
key: previous.key,
result: AsyncResult.failure(c),
}),
onSuccess: v => ({
key: previous.key,
result: AsyncResult.failure(c, {
previousSuccess: Option.some(v),
}),
}),
onFailure: v => ({
key: previous.key,
result: AsyncResult.failure(c, {
previousSuccess: v.previousSuccess,
}),
}),
}),
}),
)) as FinalMutationState<K, A, E>
yield* Lens.set(this.latestFinalState, Option.some(finalState))
yield* PubSub.shutdown(state.pubsub)
}))
))
yield* Lens.set(this.fiber, Option.some(fiber))
@@ -114,15 +156,15 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
}
watch(
state: View.View<AsyncResult.AsyncResult<A, E>>
): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
state: View.View<MutationState<K, A, E>>
): Effect.Effect<FinalMutationState<K, A, E>> {
return View.get(state).pipe(
Effect.andThen(initial => Stream.runFoldEffect(
View.changes(state),
() => initial,
(_, result) => Effect.as(Lens.set(this.state, result), result),
) as Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>),
Effect.tap(result => Lens.set(this.latestFinalResult, Option.some(result))),
) as Effect.Effect<FinalMutationState<K, A, E>>),
Effect.tap(result => Lens.set(this.latestFinalState, Option.some(result))),
)
}
}
@@ -147,7 +189,51 @@ export const make = Effect.fnUntraced(function* <K = never, A = void, E = never,
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<K>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<AsyncResult.AsyncResult<A, E>>(AsyncResult.initial())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<LatestMutationState<K, A, E>>({
key: Option.none(),
result: AsyncResult.initial(),
})),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<FinalMutationState<K, A, E>>())),
)
})
export class MutationStateLens<in out K, in out A, in out E = never>
extends Lens.LensImpl<MutationState<K, A, E>, never, never, never, never> {
constructor(
readonly ref: Ref.Ref<MutationState<K, A, E>>,
readonly pubsub: PubSub.PubSub<MutationState<K, A, E>>,
readonly semaphore: Semaphore.Semaphore,
) {
super()
}
get resolve(): Effect.Effect<Lens.LensImpl.Resolved<MutationState<K, A, E>>, never, never> {
return Effect.map(
Ref.get(this.ref),
value => ({
value,
commit: next => Effect.flatMap(
next,
value => Effect.andThen(
Ref.set(this.ref, value),
PubSub.publish(this.pubsub, value),
),
),
}),
)
}
get changes() { return Stream.fromPubSub(this.pubsub) }
get lock() { return Effect.succeed(this.semaphore.withPermit) }
}
export const makeMutationStateLens = <K, A, E = never>(
initial: MutationState<K, A, E>,
) => Effect.all([
Ref.make(initial),
PubSub.unbounded<MutationState<K, A, E>>({ replay: 1 }),
Semaphore.make(1),
]).pipe(
Effect.tap(([, pubsub]) => PubSub.publish(pubsub, initial)),
Effect.map(([ref, pubsub, semaphore]) => new MutationStateLens(ref, pubsub, semaphore)),
)
+32 -8
View File
@@ -23,7 +23,14 @@ extends Form.Form<readonly [], A, I, never, never> {
readonly validationFiber: View.View<Option.Option<Fiber.Fiber<A, Schema.SchemaError>>, never, never>
readonly run: Effect.Effect<void>
readonly submit: Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, Cause.NoSuchElementError>
readonly submit: Effect.Effect<
Option.Option<Mutation.FinalMutationState<
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
MA, ME
>>,
Cause.NoSuchElementError,
never
>
}
export class MutationFormImpl<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, in out MR = never>
@@ -79,17 +86,20 @@ extends Pipeable.Class implements MutationForm<A, I, RD, RE, MA, ME, MR> {
this.canCommit = Effect.succeed(this).pipe(
Effect.map(self => View.map(
View.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.state),
([value, issues, validationFiber, result]) => (
([value, issues, validationFiber, state]) => (
Option.isSome(value) &&
Array.isReadonlyArrayEmpty(issues) &&
Option.isNone(validationFiber) &&
!AsyncResult.isWaiting(result)
!AsyncResult.isWaiting(state.result)
),
)),
View.unwrap,
)
this.isCommitting = Effect.succeed(this).pipe(
Effect.map(self => View.map(self.mutation.state, AsyncResult.isWaiting)),
Effect.map(self => View.map(
self.mutation.state,
state => AsyncResult.isWaiting(state.result),
)),
View.unwrap,
)
}
@@ -130,21 +140,35 @@ extends Pipeable.Class implements MutationForm<A, I, RD, RE, MA, ME, MR> {
)
}
get submit(): Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, Cause.NoSuchElementError, never> {
get submit(): Effect.Effect<
Option.Option<Mutation.FinalMutationState<
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
MA, ME
>>,
Cause.NoSuchElementError,
never
> {
return Lens.get(this.value).pipe(
Effect.flatMap(Effect.fromOption),
Effect.flatMap(value => this.submitValue(value)),
)
}
submitValue(value: A): Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, never, never> {
submitValue(value: A): Effect.Effect<
Option.Option<Mutation.FinalMutationState<
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
MA, ME
>>,
never,
never
> {
return Effect.when(
Effect.tap(
this.mutation.mutate([value, this as any]),
result => AsyncResult.isFailure(result)
state => AsyncResult.isFailure(state.result)
? Option.match(
Array.findFirst(
result.cause.reasons,
state.result.cause.reasons,
reason => Cause.isFailReason(reason) && Schema.isSchemaError(reason.error)
? Option.some(reason.error)
: Option.none(),