Add Component.provide and improve mutation state handling #73
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
},
|
||||
|
||||
@@ -49,6 +49,6 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
"node": ">=24.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user