This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user