diff --git a/.gitea/workflows/lint.yaml b/.gitea/workflows/lint.yaml index f0a85fa..261cf36 100644 --- a/.gitea/workflows/lint.yaml +++ b/.gitea/workflows/lint.yaml @@ -8,6 +8,8 @@ jobs: steps: - name: Setup Bun uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4" - name: Checkout uses: actions/checkout@v6 - name: Install dependencies diff --git a/.gitea/workflows/publish.yaml b/.gitea/workflows/publish.yaml index 302d6cd..3783919 100644 --- a/.gitea/workflows/publish.yaml +++ b/.gitea/workflows/publish.yaml @@ -11,6 +11,8 @@ jobs: steps: - name: Setup Bun uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Container Registry diff --git a/.gitea/workflows/test-build.yaml b/.gitea/workflows/test-build.yaml index ccb9743..1048606 100644 --- a/.gitea/workflows/test-build.yaml +++ b/.gitea/workflows/test-build.yaml @@ -9,10 +9,12 @@ jobs: steps: - name: Setup Bun uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4" - name: Setup Node uses: actions/setup-node@v6 with: - node-version: "22" + node-version: "24" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/Dockerfile b/Dockerfile index 328a633..1955701 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ -FROM oven/bun:1.3.12-debian@sha256:1b709c9dd883fc1af38c210f7ea5222c552a8d470ea73efbd4b8fcfee798a64b AS bun +FROM oven/bun:1.4.0-debian@sha256:5bb0f9be3a1a36a03e27c9a9dd894a3b1ad26657155c7df4dda771e17bf872ef AS bun -FROM node:22.21.1-trixie-slim@sha256:98e1429d1a0b99378b4de43fa385f0746fd6276faf4feeb6104d91f6bad290f9 +FROM node:24.19.0-trixie-slim@sha256:0711b541c1c33a8a530ac4f0d391baa9a15b3d804695b1b24a47daa5fb60e74d COPY --from=bun /usr/local/bin/bun /usr/local/bin/bunx /usr/local/bin/ COPY . /app WORKDIR /app diff --git a/bun.lock b/bun.lock index 854ac25..ac27269 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "devDependencies": { "@biomejs/biome": "^2.5.5", "@effect/tsgo": "0.36.4", - "@types/bun": "^1.3.14", + "@types/bun": "^1.4.0", "npm-check-updates": "^23.0.0", "npm-sort": "^0.0.4", "turbo": "^2.10.7", @@ -1166,7 +1166,7 @@ "@types/bonjour": ["@types/bonjour@3.5.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ=="], - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -1440,7 +1440,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], diff --git a/package.json b/package.json index 4b53245..a43b6fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@effect-view/monorepo", - "packageManager": "bun@1.3.14", + "packageManager": "bun@1.4.0", "private": true, "workspaces": [ "./packages/*" @@ -19,7 +19,7 @@ "devDependencies": { "@biomejs/biome": "^2.5.5", "@effect/tsgo": "0.36.4", - "@types/bun": "^1.3.14", + "@types/bun": "^1.4.0", "npm-check-updates": "^23.0.0", "npm-sort": "^0.0.4", "turbo": "^2.10.7", diff --git a/packages/docs/docs/getting-started.md b/packages/docs/docs/getting-started.md index 71f7806..2f571c7 100644 --- a/packages/docs/docs/getting-started.md +++ b/packages/docs/docs/getting-started.md @@ -469,24 +469,59 @@ Prefer regular React state for simple, component-local UI concerns. Use `Lens`/`View` when state needs Effect integration, subscriptions, focusing, or sharing. The [State Management guide](./state-management) covers that model. -## Provide services to a subtree +## Provide services to a component -Use `Component.useLayer` when only one Effect View subtree needs extra -services. It builds the layer in a scope and returns the resulting Effect -context: +Use `Component.provide` to give a component a static layer: -```tsx title="src/GreetingPageView.tsx" -import { Effect } from "effect" +```tsx title="src/GreetingViewLive.tsx" import { Component } from "effect-view" import { GreetingView } from "./GreetingView" import { GreetingService } from "./GreetingService" +export const GreetingViewLive = Component.provide( + GreetingView, + GreetingService.layer, +) +``` + +The pipeline form is equivalent: + +```tsx +export const GreetingViewLive = GreetingView.pipe( + Component.provide(GreetingService.layer), +) +``` + +`Component.provide` builds the layer for each mounted component instance and +releases its resources when that instance unmounts. + +The layer is constructed during render and must acquire synchronously unless +the component is enhanced with [`Async.async`](./async). + +## Provide services to a subtree + +Use `Component.useLayer` to provide a layer from a component to its Effect View +subcomponents. This is also the right choice when the layer depends on props, +state, or context read inside React: memoize the layer and provide its context +manually. + +```tsx title="src/GreetingPageView.tsx" +import { Effect, Layer } from "effect" +import { Component } from "effect-view" +import * as React from "react" +import { GreetingView } from "./GreetingView" +import { GreetingService } from "./GreetingService" + export const GreetingPageView = Component.make("GreetingPage")( - function* () { - const context = yield* Component.useLayer(GreetingService.layer) - const Greeting = yield* GreetingView.use.pipe( - Effect.provide(context), + function* (props: { readonly greeting: string }) { + const layer = React.useMemo( + () => Layer.succeed(GreetingService, { + greet: (name) => `${props.greeting}, ${name}`, + }), + [props.greeting], ) + const context = yield* Component.useLayer(layer) + const Greeting = yield* Effect.provide(GreetingView.use, context) return }, diff --git a/packages/docs/package.json b/packages/docs/package.json index 726f33d..45deb44 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -49,6 +49,6 @@ ] }, "engines": { - "node": ">=20.0" + "node": ">=24.0" } } diff --git a/packages/effect-view/package.json b/packages/effect-view/package.json index 08626fe..1074b1f 100644 --- a/packages/effect-view/package.json +++ b/packages/effect-view/package.json @@ -1,7 +1,7 @@ { "name": "effect-view", "description": "Write React function components with Effect", - "version": "0.1.3", + "version": "0.1.4", "type": "module", "files": [ "./README.md", diff --git a/packages/effect-view/src/Component.test.tsx b/packages/effect-view/src/Component.test.tsx index 27f344c..a9bfd31 100644 --- a/packages/effect-view/src/Component.test.tsx +++ b/packages/effect-view/src/Component.test.tsx @@ -10,11 +10,56 @@ import * as ScopeRegistry from "./ScopeRegistry.js" class ValueService extends Context.Service()("ValueService") {} +class ParentService extends Context.Service()("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
{service.value}
+ }).pipe( + Component.provide(serviceLayer), + Component.withContext(runtime.context), + ) + + const view = render( + + + + ) + + expect(await screen.findByText("provided value")).toBeTruthy() + expect(setup).toHaveBeenCalledTimes(1) + + view.rerender( + + + + ) + + 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) diff --git a/packages/effect-view/src/Component.ts b/packages/effect-view/src/Component.ts index cb90f3d..87b7f7b 100644 --- a/packages/effect-view/src/Component.ts +++ b/packages/effect-view/src/Component.ts @@ -607,6 +607,57 @@ export const withOptions: { Object.getPrototypeOf(self), )) +export declare namespace provide { + export type Result = ( + & Omit> + & Component< + Component.Props, + Component.Success, + Component.Error | E, + Exclude, ROut> | RIn, + Component.Function + > + ) +} + +/** + * 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), + * ) + * ``` + */ +export const provide: { + ( + layer: Layer.Layer, + ): (self: T) => provide.Result + ( + self: T, + layer: Layer.Layer, + ): provide.Result +} = Function.dual(2, ( + self: T, + layer: Layer.Layer, +): provide.Result => Object.setPrototypeOf( + Object.assign(function() {}, self, { + body: (props: Component.Props) => Effect.flatMap( + useLayer(layer), + context => Effect.provide(self.body(props), context), + ), + }), + 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 +1098,7 @@ export const useCallbackPromise = Effect.fnUntraced(function* Effect.runPromiseWith(contextRef.current)(f(...args)), deps) }) -export declare namespace useContext { +export declare namespace useLayer { export interface Options extends useOnChange.Options {} } @@ -1112,7 +1163,7 @@ export declare namespace useContext { */ export const useLayer = ( layer: Layer.Layer, - options?: useContext.Options, + options?: useLayer.Options, ): Effect.Effect, E, RIn | Scope.Scope> => useOnChange(() => Effect.flatMap( Effect.context(), context => Layer.build(Layer.provide(layer, Layer.succeedContext(context))), diff --git a/packages/effect-view/src/Mutation.test.ts b/packages/effect-view/src/Mutation.test.ts new file mode 100644 index 0000000..ad714e2 --- /dev/null +++ b/packages/effect-view/src/Mutation.test.ts @@ -0,0 +1,108 @@ +import { Cause, Deferred, Effect, Option, type Scope } from "effect" +import { AsyncResult } from "effect/unstable/reactivity" +import { describe, expect, it } from "vitest" +import * as Mutation from "./Mutation.js" +import * as View from "./View.js" + + +const runMutationTest = (effect: Effect.Effect) => + Effect.runPromise(Effect.scoped(effect)) + +const expectSuccessValue = (state: { readonly result: AsyncResult.AsyncResult }): A => { + expect(AsyncResult.isSuccess(state.result)).toBe(true) + + if (!AsyncResult.isSuccess(state.result)) + throw new Error(`Expected Success result, received ${state.result._tag}`) + + return state.result.value +} + +describe("Mutation", () => { + it("runs a mutation and exposes its latest completed state", async () => { + const result = await runMutationTest(Effect.gen(function*() { + const mutation = yield* Mutation.make({ + f: (key: number) => Effect.succeed(`value:${key}`), + }) + + const final = yield* mutation.mutate(1) + + return { + isMutation: Mutation.isMutation(mutation), + final, + latestKey: yield* View.get(mutation.latestKey), + state: yield* View.get(mutation.state), + latestFinalState: yield* View.get(mutation.latestFinalState), + fiber: yield* View.get(mutation.fiber), + } + })) + + expect(result.isMutation).toBe(true) + expect(result.final.key.value).toBe(1) + expect(expectSuccessValue(result.final)).toBe("value:1") + expect(result.latestKey).toEqual(Option.some(1)) + expect(result.state.key).toEqual(Option.some(1)) + expect(expectSuccessValue(result.state)).toBe("value:1") + expect(result.latestFinalState).toEqual(Option.some(result.final)) + expect(result.fiber).toEqual(Option.none()) + }) + + it("records failures while retaining the previous successful value", async () => { + const result = await runMutationTest(Effect.gen(function*() { + let calls = 0 + const mutation = yield* Mutation.make({ + f: (_key: "save") => Effect.sync(() => { + calls += 1 + return calls + }).pipe( + Effect.flatMap(call => call === 1 + ? Effect.succeed("saved") + : Effect.fail("could not save")), + ), + }) + + yield* mutation.mutate("save") + return yield* mutation.mutate("save") + })) + + expect(result.key.value).toBe("save") + expect(AsyncResult.isFailure(result.result)).toBe(true) + + if (!AsyncResult.isFailure(result.result)) + throw new Error(`Expected Failure result, received ${result.result._tag}`) + + expect(result.result.cause).toEqual(Cause.fail("could not save")) + expect(Option.isSome(result.result.previousSuccess)).toBe(true) + + if (Option.isSome(result.result.previousSuccess)) + expect(result.result.previousSuccess.value.value).toBe("saved") + }) + + it("mutateView returns a waiting state without waiting for completion", async () => { + const result = await runMutationTest(Effect.gen(function*() { + const deferred = yield* Deferred.make() + const mutation = yield* Mutation.make({ + f: (_key: string) => Deferred.await(deferred), + }) + + const state = yield* mutation.mutateView("save") + yield* Effect.yieldNow + + const pending = yield* View.get(state) + const hasRunningFiber = Option.isSome(yield* View.get(mutation.fiber)) + + yield* Deferred.succeed(deferred, "saved") + yield* Effect.yieldNow + + const final = yield* View.get(mutation.latestFinalState).pipe(Effect.flatMap(Effect.fromOption)) + + return { pending, hasRunningFiber, final } + })) + + expect(result.pending.key.value).toBe("save") + expect(AsyncResult.isInitial(result.pending.result)).toBe(true) + expect(result.pending.result.waiting).toBe(true) + expect(result.hasRunningFiber).toBe(true) + expect(result.final.key.value).toBe("save") + expect(expectSuccessValue(result.final)).toBe("saved") + }) +}) diff --git a/packages/effect-view/src/Mutation.ts b/packages/effect-view/src/Mutation.ts index b4b1c4c..073630d 100644 --- a/packages/effect-view/src/Mutation.ts +++ b/packages/effect-view/src/Mutation.ts @@ -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> readonly fiber: View.View>> - readonly state: View.View> - readonly latestFinalResult: View.View | AsyncResult.Failure>> + readonly state: View.View> + readonly latestFinalState: View.View>> - mutate(key: K): Effect.Effect | AsyncResult.Failure> - mutateView(key: K): Effect.Effect>> + mutate(key: K): Effect.Effect> + mutateView(key: K): Effect.Effect>> +} + +export interface LatestMutationState { + readonly key: Option.Option + readonly result: AsyncResult.AsyncResult +} + +export interface MutationState { + readonly key: Option.Some + readonly result: AsyncResult.AsyncResult +} + +export interface FinalMutationState { + readonly key: Option.Some + readonly result: AsyncResult.Success | AsyncResult.Failure } export const isMutation = (u: unknown): u is Mutation => Predicate.hasProperty(u, MutationTypeId) @@ -36,20 +51,20 @@ extends Pipeable.Class implements Mutation { readonly latestKey: Lens.Lens>, readonly fiber: Lens.Lens>>, - readonly state: Lens.Lens>, - readonly latestFinalResult: Lens.Lens | AsyncResult.Failure>>, + readonly state: Lens.Lens>, + readonly latestFinalState: Lens.Lens>>, ) { super() } - mutate(key: K): Effect.Effect | AsyncResult.Failure> { + mutate(key: K): Effect.Effect> { 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>> { + mutateView(key: K): Effect.Effect>> { 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 { } start(key: K): Effect.Effect< - View.View>, + View.View>, never, Scope.Scope | R > { return Effect.gen({ self: this }, function*() { - const previous = yield* Lens.get(this.latestFinalResult) - const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make>( - Option.getOrElse(previous, () => AsyncResult.initial(false)) - )) + const previous: MutationState = Option.getOrElse(yield* Lens.get(this.latestFinalState), () => ({ + key: Option.some(key) as Option.Some, + 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, 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, 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 + + 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 { } watch( - state: View.View> - ): Effect.Effect | AsyncResult.Failure> { + state: View.View> + ): Effect.Effect> { 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.Failure>), - Effect.tap(result => Lens.set(this.latestFinalResult, Option.some(result))), + ) as Effect.Effect>), + Effect.tap(result => Lens.set(this.latestFinalState, Option.some(result))), ) } } @@ -147,7 +189,51 @@ export const make = Effect.fnUntraced(function* ())), Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())), - Lens.fromSubscriptionRef(yield* SubscriptionRef.make>(AsyncResult.initial())), - Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none | AsyncResult.Failure>())), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make>({ + key: Option.none(), + result: AsyncResult.initial(), + })), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())), ) }) + + +export class MutationStateLens +extends Lens.LensImpl, never, never, never, never> { + constructor( + readonly ref: Ref.Ref>, + readonly pubsub: PubSub.PubSub>, + readonly semaphore: Semaphore.Semaphore, + ) { + super() + } + + get resolve(): Effect.Effect>, 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 = ( + initial: MutationState, +) => Effect.all([ + Ref.make(initial), + PubSub.unbounded>({ replay: 1 }), + Semaphore.make(1), +]).pipe( + Effect.tap(([, pubsub]) => PubSub.publish(pubsub, initial)), + Effect.map(([ref, pubsub, semaphore]) => new MutationStateLens(ref, pubsub, semaphore)), +) diff --git a/packages/effect-view/src/MutationForm.ts b/packages/effect-view/src/MutationForm.ts index cfcc3c8..eb0c0d2 100644 --- a/packages/effect-view/src/MutationForm.ts +++ b/packages/effect-view/src/MutationForm.ts @@ -23,7 +23,14 @@ extends Form.Form { readonly validationFiber: View.View>, never, never> readonly run: Effect.Effect - readonly submit: Effect.Effect | AsyncResult.Failure>, Cause.NoSuchElementError> + readonly submit: Effect.Effect< + Option.Option], + MA, ME + >>, + Cause.NoSuchElementError, + never + > } export class MutationFormImpl @@ -79,17 +86,20 @@ extends Pipeable.Class implements MutationForm { 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 { ) } - get submit(): Effect.Effect | AsyncResult.Failure>, Cause.NoSuchElementError, never> { + get submit(): Effect.Effect< + Option.Option], + 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 | AsyncResult.Failure>, never, never> { + submitValue(value: A): Effect.Effect< + Option.Option], + 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(), diff --git a/packages/example/src/routes/lensform.tsx b/packages/example/src/routes/lensform.tsx index 1a0c4a3..7643b0c 100644 --- a/packages/example/src/routes/lensform.tsx +++ b/packages/example/src/routes/lensform.tsx @@ -41,20 +41,7 @@ class AppState extends Context.Service Effect.gen(function*() { - yield* Effect.addFinalizer(() => Console.log("LensForm route unmounted")) - yield* Console.log("LensForm route mounted") - })) - - const context = yield* Component.useLayer(AppState.layer) - const UserProfileEditor = yield* Effect.provide(UserProfileEditorView.use, context) - - return -}) - - -const UserProfileEditorView = Component.make("UserProfileEditorView")(function*() { +const LensFormPageView = Component.make("LensFormPage")(function*() { const appState = yield* AppState const [ @@ -106,7 +93,9 @@ const UserProfileEditorView = Component.make("UserProfileEditorView")(function*( ) -}) +}).pipe( + Component.provide(AppState.layer), +) export const Route = createFileRoute("/lensform")({ diff --git a/packages/example/src/routes/query.tsx b/packages/example/src/routes/query.tsx index 95edabd..0429a3d 100644 --- a/packages/example/src/routes/query.tsx +++ b/packages/example/src/routes/query.tsx @@ -87,7 +87,7 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() { - +