Merge branch 'next' into i18n
Lint / lint (push) Successful in 59s

This commit is contained in:
Julien Valverdé
2026-08-23 03:33:07 +02:00
16 changed files with 444 additions and 100 deletions
+2
View File
@@ -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
+2
View File
@@ -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
+3 -1
View File
@@ -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
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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=="],
+2 -2
View File
@@ -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",
+45 -10
View File
@@ -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 <Greeting name="Effect" />
},
+1 -1
View File
@@ -49,6 +49,6 @@
]
},
"engines": {
"node": ">=20.0"
"node": ">=24.0"
}
}
+1 -1
View File
@@ -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",
@@ -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)
+53 -2
View File
@@ -607,6 +607,57 @@ 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),
* )
* ```
*/
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: (props: Component.Props<T>) => 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* <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 +1163,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))),
+108
View File
@@ -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 = <A, E>(effect: Effect.Effect<A, E, Scope.Scope>) =>
Effect.runPromise(Effect.scoped(effect))
const expectSuccessValue = <A, E>(state: { readonly result: AsyncResult.AsyncResult<A, E> }): 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<string>()
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")
})
})
+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(),
+4 -15
View File
@@ -41,20 +41,7 @@ class AppState extends Context.Service<AppState, {
}
const LensFormPageView = Component.make("LensFormPageView")(function*() {
yield* Component.useOnMount(() => 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 <UserProfileEditor />
})
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*(
</form>
</Container>
)
})
}).pipe(
Component.provide(AppState.layer),
)
export const Route = createFileRoute("/lensform")({
+1 -1
View File
@@ -87,7 +87,7 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
</Button>
</Flex>
<PostResultView result={mutationState} />
<PostResultView result={mutationState.result} />
<Button onClick={() => runSync(mutation.mutateView([id]))}>
Mutate