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*() {
-
+