12 Commits

Author SHA1 Message Date
renovate-bot 599546788d Update dependency typescript to v7
Lint / lint (push) Failing after 10s
Test build / test-build (pull_request) Failing after 11s
2026-07-19 12:03:06 +00:00
Julien Valverdé 31d87ffcab Fix
Lint / lint (push) Successful in 38s
2026-07-19 04:00:46 +02:00
Julien Valverdé 885cff74b3 Example fix
Lint / lint (push) Successful in 38s
2026-07-19 03:27:41 +02:00
Julien Valverdé cbb050673b Fix
Lint / lint (push) Successful in 38s
2026-07-19 03:20:24 +02:00
Julien Valverdé 9b25ef4b57 Fix
Lint / lint (push) Successful in 39s
2026-07-19 03:15:55 +02:00
Julien Valverdé be1038fc8d Rename Form subtypes
Lint / lint (push) Failing after 40s
2026-07-19 03:01:38 +02:00
Julien Valverdé 956c77d45b Fix
Lint / lint (push) Failing after 38s
2026-07-19 02:56:32 +02:00
Julien Valverdé 985556b430 Fix
Lint / lint (push) Failing after 39s
2026-07-19 02:50:37 +02:00
Julien Valverdé feced4732f Refactor Form subtypes
Lint / lint (push) Failing after 1m43s
2026-07-19 02:43:48 +02:00
Julien Valverdé cc64ae785d Refactor Form
Lint / lint (push) Failing after 39s
2026-07-19 00:46:36 +02:00
Julien Valverdé 3fc3904c6c Upgrade effect-lens
Lint / lint (push) Failing after 1m44s
2026-07-17 21:25:07 +02:00
Julien Valverdé 23cdfb3894 Refactor Query
Lint / lint (push) Failing after 37s
2026-07-17 01:15:53 +02:00
37 changed files with 1611 additions and 1287 deletions
+334 -324
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -39,17 +39,19 @@
"clean:modules": "rm -rf node_modules" "clean:modules": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@effect/platform-browser": "^4.0.0-beta.85", "@effect/platform-browser": "4.0.0-beta.98",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.98",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"effect": "4.0.0-beta.85", "effect": "4.0.0-beta.98",
"react": "^19.2.0" "react": "^19.2.0"
}, },
"dependencies": { "dependencies": {
"effect-lens": "2.0.0-beta.0" "@standard-schema/spec": "^1.1.0",
"effect-lens": "^2.0.0-beta.1"
} }
} }
+47 -50
View File
@@ -1,58 +1,55 @@
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect" import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
import type * as React from "react" import type * as React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
import * as Lens from "./Lens.js" import * as Lens from "./Lens.js"
import * as Subscribable from "./Subscribable.js" import * as View from "./View.js"
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form") export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
export type FormTypeId = typeof FormTypeId export type FormTypeId = typeof FormTypeId
export interface FormIssue { export interface Form<out P extends readonly PropertyKey[], out A, in out I = A, out ER = never, out EW = never>
readonly path: readonly PropertyKey[]
readonly message: string
}
export interface Form<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
extends Pipeable.Pipeable { extends Pipeable.Pipeable {
readonly [FormTypeId]: FormTypeId readonly [FormTypeId]: FormTypeId
readonly path: P readonly path: P
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never> readonly value: View.View<Option.Option<A>, ER, never>
readonly encodedValue: Lens.Lens<I, ER, EW, never, never> readonly encodedValue: Lens.Lens<I, ER, EW, never, never>
readonly issues: Subscribable.Subscribable<readonly FormIssue[], never, never> readonly issues: View.View<readonly StandardSchemaV1.Issue[], never, never>
readonly isValidating: Subscribable.Subscribable<boolean, never, never> readonly isValidating: View.View<boolean, ER, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never> readonly canCommit: View.View<boolean, never, never>
readonly isCommitting: Subscribable.Subscribable<boolean, never, never> readonly isCommitting: View.View<boolean, never, never>
} }
export class FormImpl<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never> export class FormImpl<out P extends readonly PropertyKey[], out A, in out I = A, out ER = never, out EW = never>
extends Pipeable.Class implements Form<P, A, I, ER, EW> { extends Pipeable.Class implements Form<P, A, I, ER, EW> {
readonly [FormTypeId]: FormTypeId = FormTypeId readonly [FormTypeId]: FormTypeId = FormTypeId
constructor( constructor(
readonly path: P, readonly path: P,
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>, readonly value: View.View<Option.Option<A>, ER, never>,
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>, readonly encodedValue: Lens.Lens<I, ER, EW, never, never>,
readonly issues: Subscribable.Subscribable<readonly FormIssue[], never, never>, readonly issues: View.View<readonly StandardSchemaV1.Issue[], never, never>,
readonly isValidating: Subscribable.Subscribable<boolean, never, never>, readonly isValidating: View.View<boolean, never, never>,
readonly canCommit: Subscribable.Subscribable<boolean, never, never>, readonly canCommit: View.View<boolean, never, never>,
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>, readonly isCommitting: View.View<boolean, never, never>,
) { ) {
super() super()
} }
} }
export const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
export const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
const filterIssuesByPath = ( const filterIssuesByPath = (
issues: readonly FormIssue[], issues: readonly StandardSchemaV1.Issue[],
path: readonly PropertyKey[], path: readonly PropertyKey[],
): readonly FormIssue[] => Array.filter(issues, issue => ): readonly StandardSchemaV1.Issue[] => Array.filter(issues, issue => {
issue.path.length >= path.length && Array.every(path, (p, i) => p === issue.path[i]) const issuePath = issue.path
) if (!issuePath) return false
return issuePath.length >= path.length && Array.every(path, (p, i) => p === issuePath[i])
})
export const focusObjectOn: { export const focusObjectOn: {
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>( <P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
@@ -66,14 +63,14 @@ export const focusObjectOn: {
self: Form<P, A, I, ER, EW>, self: Form<P, A, I, ER, EW>,
key: K, key: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW> => { ): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
const form = self as unknown as FormImpl<P, A, I, ER, EW> const form = self as FormImpl<P, A, I, ER, EW>
const path = [...form.path, key] as const const path = [...form.path, key] as const
return new FormImpl( return new FormImpl(
path, path,
Subscribable.mapOption(form.value, a => a[key]), View.mapOption(form.value, a => a[key]),
Lens.focusObjectOn(form.encodedValue, key), Lens.focusObjectOn(form.encodedValue, key),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)), View.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating, form.isValidating,
form.canCommit, form.canCommit,
form.isCommitting, form.isCommitting,
@@ -92,14 +89,14 @@ export const focusArrayAt: {
self: Form<P, A, I, ER, EW>, self: Form<P, A, I, ER, EW>,
index: number, index: number,
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementError, EW | Cause.NoSuchElementError> => { ): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementError, EW | Cause.NoSuchElementError> => {
const form = self as unknown as FormImpl<P, A, I, ER, EW> const form = self as FormImpl<P, A, I, ER, EW>
const path = [...form.path, index] as const const path = [...form.path, index] as const
return new FormImpl( return new FormImpl(
path, path,
Subscribable.mapOptionEffect(form.value, values => Effect.fromOption(Array.get(values, index))), View.mapOptionEffect(form.value, value => Effect.fromOption(Array.get(value, index))),
Lens.focusArrayAt(form.encodedValue, index), Lens.focusArrayAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)), View.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating, form.isValidating,
form.canCommit, form.canCommit,
form.isCommitting, form.isCommitting,
@@ -118,14 +115,14 @@ export const focusTupleAt: {
self: Form<P, A, I, ER, EW>, self: Form<P, A, I, ER, EW>,
index: K, index: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW> => { ): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
const form = self as unknown as FormImpl<P, A, I, ER, EW> const form = self as FormImpl<P, A, I, ER, EW>
const path = [...form.path, index] as const const path = [...form.path, index] as const
return new FormImpl( return new FormImpl(
path, path,
Subscribable.mapOption(form.value, values => values[index]), View.mapOption(form.value, Array.getUnsafe(index)),
Lens.focusTupleAt(form.encodedValue, index), Lens.focusTupleAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)), View.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating, form.isValidating,
form.canCommit, form.canCommit,
form.isCommitting, form.isCommitting,
@@ -144,14 +141,14 @@ export const focusChunkAt: {
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>, self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
index: number, index: number,
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementError, EW> => { ): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementError, EW> => {
const form = self as unknown as FormImpl<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW> const form = self as FormImpl<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>
const path = [...form.path, index] as const const path = [...form.path, index] as const
return new FormImpl( return new FormImpl(
path, path,
Subscribable.mapOptionEffect(form.value, values => Effect.fromOption(Chunk.get(values, index))), View.mapOptionEffect(form.value, value => Effect.fromOption(Chunk.get(value, index))),
Lens.focusChunkAt(form.encodedValue, index), Lens.focusChunkAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)), View.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating, form.isValidating,
form.canCommit, form.canCommit,
form.isCommitting, form.isCommitting,
@@ -182,17 +179,15 @@ export const useInput = Effect.fnUntraced(function* <P extends readonly Property
yield* Effect.forkScoped(Effect.all([ yield* Effect.forkScoped(Effect.all([
Stream.runForEach( Stream.runForEach(
Stream.drop(form.encodedValue.changes, 1), Stream.drop(Lens.changes(form.encodedValue), 1),
upstreamEncodedValue => Effect.flatMap( upstreamEncodedValue => Effect.when(
Lens.get(internalValueLens), Lens.set(internalValueLens, upstreamEncodedValue),
internalValue => !Equal.equals(upstreamEncodedValue, internalValue) Effect.map(Lens.get(internalValueLens), internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
? Lens.set(internalValueLens, upstreamEncodedValue)
: Effect.succeed(undefined),
), ),
), ),
Stream.runForEach( Stream.runForEach(
internalValueLens.changes.pipe( Lens.changes(internalValueLens).pipe(
Stream.drop(1), Stream.drop(1),
Stream.changesWith(Equal.asEquivalence()), Stream.changesWith(Equal.asEquivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity, options?.debounce ? Stream.debounce(options.debounce) : identity,
@@ -240,13 +235,10 @@ export const useOptionalInput = Effect.fnUntraced(function* <P extends readonly
yield* Effect.forkScoped(Effect.all([ yield* Effect.forkScoped(Effect.all([
Stream.runForEach( Stream.runForEach(
Stream.drop(field.encodedValue.changes, 1), Stream.drop(Lens.changes(field.encodedValue), 1),
upstreamEncodedValue => Effect.flatMap( upstreamEncodedValue => Effect.when(
Effect.all([Lens.get(enabledLens), Lens.get(internalValueLens)]), Option.match(upstreamEncodedValue, {
([enabled, internalValue]) => Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none())
? Effect.succeed(undefined)
: Option.match(upstreamEncodedValue, {
onSome: v => Effect.andThen( onSome: v => Effect.andThen(
Lens.set(enabledLens, true), Lens.set(enabledLens, true),
Lens.set(internalValueLens, v), Lens.set(internalValueLens, v),
@@ -256,11 +248,16 @@ export const useOptionalInput = Effect.fnUntraced(function* <P extends readonly
Lens.set(internalValueLens, options.defaultValue), Lens.set(internalValueLens, options.defaultValue),
), ),
}), }),
Effect.map(
Effect.all([Lens.get(enabledLens), Lens.get(internalValueLens)]),
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
),
), ),
), ),
Stream.runForEach( Stream.runForEach(
enabledLens.changes.pipe( Lens.changes(enabledLens).pipe(
Stream.zipLatest(internalValueLens.changes), Stream.zipLatest(internalValueLens.changes),
Stream.drop(1), Stream.drop(1),
Stream.changesWith(Equal.asEquivalence()), Stream.changesWith(Equal.asEquivalence()),
+206
View File
@@ -0,0 +1,206 @@
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { Array, type Context, Effect, Equal, Fiber, Option, Pipeable, Predicate, Schema, SchemaIssue, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
import * as Form from "./Form.js"
import * as Lens from "./Lens.js"
import * as View from "./View.js"
export const LensFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/LensForm")
export type LensFormTypeId = typeof LensFormTypeId
export interface LensForm<in out A, in out I = A, in out RD = never, in out RE = never, out TER = never, out TEW = never, in out TRR = never, in out TRW = never>
extends Form.Form<readonly [], A, I, TER, TER | TEW> {
readonly [LensFormTypeId]: LensFormTypeId
readonly schema: Schema.ConstraintCodec<A, I, RD, RE>
readonly context: Context.Context<Scope.Scope | RD | RE | TRR | TRW>
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
readonly validationFiber: View.View<Option.Option<Fiber.Fiber<A, Schema.SchemaError>>, never, never>
readonly run: Effect.Effect<void, TER>
}
export class LensFormImpl<in out A, in out I = A, in out RD = never, in out RE = never, out TER = never, out TEW = never, in out TRR = never, in out TRW = never>
extends Pipeable.Class implements LensForm<A, I, RD, RE, TER, TEW, TRR, TRW> {
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
readonly [LensFormTypeId]: LensFormTypeId = LensFormTypeId
readonly path = [] as const
readonly value: View.View<Option.Option<A>, never, never>
readonly encodedValue: Lens.Lens<I, TER, TER | TEW, never, never>
readonly isValidating: View.View<boolean, never, never>
readonly canCommit: View.View<boolean, never, never>
constructor(
readonly schema: Schema.ConstraintCodec<A, I, RD, RE>,
readonly context: Context.Context<Scope.Scope | RD | RE | TRR | TRW>,
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>,
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
readonly issues: Lens.Lens<readonly StandardSchemaV1.Issue[], never, never, never, never>,
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, Schema.SchemaError>>, never, never, never, never>,
readonly isCommitting: Lens.Lens<boolean, never, never>,
readonly runSemaphore: Semaphore.Semaphore,
) {
super()
this.value = Effect.succeed(this).pipe(
Effect.map(self => View.make({
get: Effect.provide(Effect.option(self.target.get), self.context),
get changes() {
return Stream.provideContext(
self.target.changes.pipe(
Stream.map(Option.some),
Stream.catch(() => Stream.make(Option.none())),
),
self.context,
)
},
})),
View.unwrap,
)
this.encodedValue = Effect.all([
Effect.succeed(this),
Effect.succeed(Lens.asLensImpl(this.internalEncodedValue)),
]).pipe(
Effect.map(([self, parent]) => Lens.make({
get: parent.get,
get changes() { return parent.changes },
commit: a => Effect.andThen(
Effect.flatMap(
parent.resolve,
resolved => resolved.commit(Effect.succeed(a)),
),
self.synchronizeEncodedValue(a),
),
lock: parent.lock,
})),
Lens.unwrap,
)
this.isValidating = Effect.succeed(this).pipe(
Effect.map(self => View.map(self.validationFiber, Option.isSome)),
View.unwrap,
)
this.canCommit = Effect.succeed(this).pipe(
Effect.map(self => View.map(
View.zipLatestAll(self.issues, self.validationFiber, self.isCommitting),
([issues, validationFiber, isCommitting]) => (
Array.isReadonlyArrayEmpty(issues) &&
Option.isNone(validationFiber) &&
!isCommitting
),
)),
View.unwrap,
)
}
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, TER | TEW, never> {
return Lens.get(this.validationFiber).pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(Effect.forkScoped(
Effect.ensuring(
Schema.decodeEffect(this.schema, { errors: "all" })(encodedValue),
Lens.set(this.validationFiber, Option.none()),
)
)),
Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
Effect.flatMap(Fiber.join),
Effect.flatMap(value => Effect.ensuring(
Lens.set(this.isCommitting, true).pipe(
Effect.andThen(Lens.set(this.issues, Array.empty())),
Effect.andThen(Lens.set(this.target, value)),
),
Lens.set(this.isCommitting, false),
)),
Effect.catchIf(
Schema.isSchemaError,
error => Lens.set(this.issues, SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues),
),
Effect.provide(this.context),
)
}
get run(): Effect.Effect<void, TER, never> {
return this.runSemaphore.withPermits(1)(Effect.provide(
Stream.runForEach(
Stream.drop(Lens.changes(this.target), 1),
targetValue => Schema.encodeEffect(this.schema, { errors: "all" })(targetValue).pipe(
Effect.flatMap(encodedValue => Effect.when(
Effect.andThen(
Lens.set(this.issues, Array.empty()),
Lens.set(this.internalEncodedValue, encodedValue),
),
Effect.map(
Lens.get(this.internalEncodedValue),
currentEncodedValue => !Equal.equals(encodedValue, currentEncodedValue),
),
)),
Effect.ignore,
),
),
this.context,
))
}
}
export const isLensForm = (u: unknown): u is LensForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, LensFormTypeId)
export declare namespace make {
export interface Options<in out A, out I = A, out RD = never, out RE = never, out TER = never, out TEW = never, out TRR = never, out TRW = never> {
readonly schema: Schema.ConstraintCodec<A, I, RD, RE>
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
readonly initialEncodedValue?: NoInfer<I>
}
}
export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: make.Options<A, I, RD, RE, TER, TEW, TRR, TRW>
): Effect.fn.Return<
LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>,
Schema.SchemaError | TER,
Scope.Scope | RD | RE | TRR | TRW
> {
const initialEncodedValue = options.initialEncodedValue !== undefined
? options.initialEncodedValue
: yield* Effect.flatMap(
Lens.get(options.target),
Schema.encodeEffect(options.schema),
)
return new LensFormImpl(
options.schema,
yield* Effect.context<Scope.Scope | RD | RE | TRR | TRW>(),
options.target,
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(initialEncodedValue)),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly StandardSchemaV1.Issue[]>(Array.empty())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, Schema.SchemaError>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(false)),
yield* Semaphore.make(1),
)
})
export declare namespace service {
export interface Options<in out A, out I = A, out RD = never, out RE = never, out TER = never, out TEW = never, out TRR = never, out TRW = never>
extends make.Options<A, I, RD, RE, TER, TEW, TRR, TRW> {}
}
export const service = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: service.Options<A, I, RD, RE, TER, TEW, TRR, TRW>
): Effect.Effect<
LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>,
Schema.SchemaError | TER,
Scope.Scope | RD | RE | TRR | TRW
> => Effect.tap(
make(options),
form => Effect.forkScoped(form.run),
)
+14 -14
View File
@@ -1,26 +1,26 @@
import { type Context, Effect, Equal, Exit, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect" import { type Context, Effect, Equal, Exit, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/unstable/reactivity" import { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js" import * as Lens from "./Lens.js"
import type * as Subscribable from "./Subscribable.js" import * as View from "./View.js"
export const MutationTypeId: unique symbol = Symbol.for("@effect-fc/Mutation/Mutation") export const MutationTypeId: unique symbol = Symbol.for("@effect-fc/Mutation/Mutation")
export type MutationTypeId = typeof MutationTypeId export type MutationTypeId = typeof MutationTypeId
export interface Mutation<in out K, in out A, in out E = never, in out R = never> export interface Mutation<in out K, out A, out E = never, in out R = never>
extends Pipeable.Pipeable { extends Pipeable.Pipeable {
readonly [MutationTypeId]: MutationTypeId readonly [MutationTypeId]: MutationTypeId
readonly context: Context.Context<Scope.Scope | R> readonly context: Context.Context<Scope.Scope | R>
readonly f: (key: K) => Effect.Effect<A, E, R> readonly f: (key: K) => Effect.Effect<A, E, R>
readonly latestKey: Subscribable.Subscribable<Option.Option<K>> readonly latestKey: View.View<Option.Option<K>>
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>> readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
readonly result: Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>> readonly state: View.View<AsyncResult.AsyncResult<A, E>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>> readonly latestFinalResult: View.View<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>
mutate(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> mutate(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>>> mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>>
} }
export const isMutation = (u: unknown): u is Mutation<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId) export const isMutation = (u: unknown): u is Mutation<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
@@ -36,7 +36,7 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
readonly latestKey: Lens.Lens<Option.Option<K>>, readonly latestKey: Lens.Lens<Option.Option<K>>,
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>, readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
readonly result: Lens.Lens<AsyncResult.AsyncResult<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 latestFinalResult: Lens.Lens<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>,
) { ) {
super() super()
@@ -49,7 +49,7 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
Effect.provide(this.context), Effect.provide(this.context),
) )
} }
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>>> { mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>> {
return Lens.set(this.latestKey, Option.some(key)).pipe( return Lens.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(this.start(key)), Effect.andThen(this.start(key)),
Effect.tap(state => Effect.forkScoped(this.watch(state))), Effect.tap(state => Effect.forkScoped(this.watch(state))),
@@ -58,7 +58,7 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
} }
start(key: K): Effect.Effect< start(key: K): Effect.Effect<
Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>>, View.View<AsyncResult.AsyncResult<A, E>>,
never, never,
Scope.Scope | R Scope.Scope | R
> { > {
@@ -114,13 +114,13 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
} }
watch( watch(
state: Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>> state: View.View<AsyncResult.AsyncResult<A, E>>
): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> { ): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
return state.get.pipe( return View.get(state).pipe(
Effect.andThen(initial => Stream.runFoldEffect( Effect.andThen(initial => Stream.runFoldEffect(
state.changes, View.changes(state),
() => initial, () => initial,
(_, result) => Effect.as(Lens.set(this.result, result), result), (_, result) => Effect.as(Lens.set(this.state, result), result),
) as Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>), ) as Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>),
Effect.tap(result => Lens.set(this.latestFinalResult, Option.some(result))), Effect.tap(result => Lens.set(this.latestFinalResult, Option.some(result))),
) )
+213
View File
@@ -0,0 +1,213 @@
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { Array, Cause, type Context, Effect, Fiber, Option, Pipeable, Predicate, Schema, SchemaError, SchemaIssue, type Scope, Semaphore, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import * as Form from "./Form.js"
import * as Lens from "./Lens.js"
import * as Mutation from "./Mutation.js"
import * as View from "./View.js"
export const MutationFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/MutationForm")
export type MutationFormTypeId = typeof MutationFormTypeId
export interface MutationForm<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>
extends Form.Form<readonly [], A, I, never, never> {
readonly [MutationFormTypeId]: MutationFormTypeId
readonly schema: Schema.ConstraintCodec<A, I, RD, RE>
readonly context: Context.Context<Scope.Scope | RD | RE>
readonly mutation: Mutation.Mutation<
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
MA, ME, MR
>
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>
}
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>
extends Pipeable.Class implements MutationForm<A, I, RD, RE, MA, ME, MR> {
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
readonly [MutationFormTypeId]: MutationFormTypeId = MutationFormTypeId
readonly path = [] as const
readonly encodedValue: Lens.Lens<I, never, never, never, never>
readonly isValidating: View.View<boolean, never, never>
readonly canCommit: View.View<boolean, never, never>
readonly isCommitting: View.View<boolean, never, never>
constructor(
readonly schema: Schema.ConstraintCodec<A, I, RD, RE>,
readonly context: Context.Context<Scope.Scope | RD | RE>,
readonly mutation: Mutation.Mutation<
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
MA, ME, MR
>,
readonly value: Lens.Lens<Option.Option<A>, never, never, never, never>,
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
readonly issues: Lens.Lens<readonly StandardSchemaV1.Issue[], never, never, never, never>,
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, Schema.SchemaError>>, never, never, never, never>,
readonly runSemaphore: Semaphore.Semaphore,
) {
super()
this.encodedValue = Effect.all([
Effect.succeed(this),
Effect.succeed(Lens.asLensImpl(this.internalEncodedValue)),
]).pipe(
Effect.map(([self, parent]) => Lens.make({
get: parent.get,
get changes() { return parent.changes },
commit: a => Effect.andThen(
Effect.flatMap(
parent.resolve,
resolved => resolved.commit(Effect.succeed(a)),
),
self.synchronizeEncodedValue(a),
),
lock: parent.lock,
})),
Lens.unwrap,
)
this.isValidating = Effect.succeed(this).pipe(
Effect.map(self => View.map(self.validationFiber, Option.isSome)),
View.unwrap,
)
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]) => (
Option.isSome(value) &&
Array.isReadonlyArrayEmpty(issues) &&
Option.isNone(validationFiber) &&
!AsyncResult.isWaiting(result)
),
)),
View.unwrap,
)
this.isCommitting = Effect.succeed(this).pipe(
Effect.map(self => View.map(self.mutation.state, AsyncResult.isWaiting)),
View.unwrap,
)
}
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, never, never> {
return Lens.get(this.validationFiber).pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(Effect.forkScoped(
Effect.ensuring(
Schema.decodeEffect(this.schema, { errors: "all" })(encodedValue),
Lens.set(this.validationFiber, Option.none()),
)
)),
Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
Effect.flatMap(Fiber.join),
Effect.tap(() => Lens.set(this.issues, Array.empty())),
Effect.flatMap(value => Lens.set(this.value, Option.some(value))),
Effect.catchIf(
SchemaError.isSchemaError,
error => Lens.set(this.issues, SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues),
),
Effect.provide(this.context),
)
}
get run(): Effect.Effect<void, never, never> {
return Lens.get(this.encodedValue).pipe(
Effect.flatMap(v => Schema.decodeEffect(this.schema)(v)),
Effect.option,
Effect.flatMap(v => Lens.set(this.value, v)),
Effect.provide(this.context),
this.runSemaphore.withPermits(1),
)
}
get submit(): Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<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> {
return Effect.when(
Effect.tap(
this.mutation.mutate([value, this as any]),
result => AsyncResult.isFailure(result)
? Option.match(
Array.findFirst(
result.cause.reasons,
reason => Cause.isFailReason(reason) && SchemaError.isSchemaError(reason.error)
? Option.some(reason.error)
: Option.none(),
),
{
onSome: error => Lens.set(this.issues, SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues),
onNone: () => Effect.void,
},
)
: Effect.void,
),
View.get(this.canCommit),
)
}
}
export const isMutationForm = (u: unknown): u is MutationForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationFormTypeId)
export declare namespace make {
export interface Options<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, out MR = never>
extends Mutation.make.Options<
readonly [value: NoInfer<A>, form: MutationForm<NoInfer<A>, NoInfer<I>, NoInfer<RD>, NoInfer<RE>, unknown, unknown, unknown>],
MA, ME, MR
> {
readonly schema: Schema.ConstraintCodec<A, I, RD, RE>
readonly initialEncodedValue: NoInfer<I>
}
}
export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never>(
options: make.Options<A, I, RD, RE, MA, ME, MR>
): Effect.fn.Return<
MutationForm<A, I, RD, RE, MA, ME, MR>,
never,
Scope.Scope | RD | RE | MR
> {
return new MutationFormImpl(
options.schema,
yield* Effect.context<Scope.Scope | RD | RE | MR>(),
yield* Mutation.make(options),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<A>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly StandardSchemaV1.Issue[]>(Array.empty())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, Schema.SchemaError>>())),
yield* Semaphore.make(1),
)
})
export declare namespace service {
export interface Options<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, out MR = never>
extends make.Options<A, I, RD, RE, MA, ME, MR> {}
}
export const service = <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never>(
options: service.Options<A, I, RD, RE, MA, ME, MR>
): Effect.Effect<
MutationForm<A, I, RD, RE, MA, ME, MR>,
never,
Scope.Scope | RD | RE | MR
> => Effect.tap(
make(options),
form => Effect.forkScoped(form.run),
)
+2 -2
View File
@@ -3,6 +3,8 @@ import type * as React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
export * from "effect/PubSub"
export const useFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>( export const useFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
values: A values: A
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> { ): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
@@ -13,5 +15,3 @@ export const useFromReactiveValues = Effect.fnUntraced(function* <const A extend
), values) ), values)
return pubsub return pubsub
}) })
export * from "effect/PubSub"
+50 -46
View File
@@ -2,44 +2,44 @@ import { type Cause, type Context, Duration, Effect, Equal, type Equivalence, Ex
import { AsyncResult } from "effect/unstable/reactivity" import { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js" import * as Lens from "./Lens.js"
import * as QueryClient from "./QueryClient.js" import * as QueryClient from "./QueryClient.js"
import * as Subscribable from "./Subscribable.js" import * as View from "./View.js"
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query") export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
export type QueryTypeId = typeof QueryTypeId export type QueryTypeId = typeof QueryTypeId
export interface Query<in out K, in out A, in out E = never, in out R = never> export interface Query<in out K, out A, out E = never, in out R = never>
extends Pipeable.Pipeable { extends Pipeable.Pipeable {
readonly [QueryTypeId]: QueryTypeId readonly [QueryTypeId]: QueryTypeId
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R> readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>
readonly key: Subscribable.Subscribable<K> readonly key: View.View<K>
readonly keyEquivalence: Equivalence.Equivalence<K> readonly keyEquivalence: Equivalence.Equivalence<K>
readonly f: (key: K) => Effect.Effect<A, E, R> readonly f: (key: K) => Effect.Effect<A, E, R>
readonly staleTime: Duration.Duration readonly staleTime: Duration.Duration
readonly refreshOnWindowFocus: boolean readonly refreshOnWindowFocus: boolean
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>> readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
readonly state: Subscribable.Subscribable<QueryState<K, A, E>> readonly state: View.View<QueryState<K, A, E>>
readonly latestFinalState: Subscribable.Subscribable<Option.Option<FinalQueryState<K, A, E>>> readonly latestFinalState: View.View<Option.Option<FinalQueryState<K, A, E>>>
readonly run: Effect.Effect<void> readonly run: Effect.Effect<void>
fetch(key: K): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> fetch(key: K): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError>
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<QueryState<K, A, E>>, Cause.NoSuchElementError> fetchView(key: K): Effect.Effect<View.View<QueryState<K, A, E>>, Cause.NoSuchElementError>
readonly refresh: Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> readonly refresh: Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError>
readonly refreshSubscribable: Effect.Effect<Subscribable.Subscribable<QueryState<K, A, E>>, Cause.NoSuchElementError> readonly refreshView: Effect.Effect<View.View<QueryState<K, A, E>>, Cause.NoSuchElementError>
readonly invalidateCache: Effect.Effect<void> readonly invalidateCache: Effect.Effect<void>
invalidateCacheEntry(key: K): Effect.Effect<void> invalidateCacheEntry(key: K): Effect.Effect<void>
} }
export interface QueryState<in out K, in out A, in out E = never> { export interface QueryState<out K, out A, out E = never> {
readonly key: K readonly key: K
readonly result: AsyncResult.AsyncResult<A, E> readonly result: AsyncResult.AsyncResult<A, E>
} }
export interface FinalQueryState<in out K, in out A, in out E = never> { export interface FinalQueryState<out K, out A, out E = never> {
readonly key: K readonly key: K
readonly result: AsyncResult.Success<A, E> | AsyncResult.Failure<A, E> readonly result: AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>
} }
@@ -53,7 +53,7 @@ extends Pipeable.Class implements Query<K, A, E, R> {
constructor( constructor(
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>, readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>,
readonly key: Lens.Lens<K>, readonly key: View.View<K>,
readonly keyEquivalence: Equivalence.Equivalence<K>, readonly keyEquivalence: Equivalence.Equivalence<K>,
readonly f: (key: K) => Effect.Effect<A, E, R>, readonly f: (key: K) => Effect.Effect<A, E, R>,
@@ -71,22 +71,30 @@ extends Pipeable.Class implements Query<K, A, E, R> {
get run(): Effect.Effect<void> { get run(): Effect.Effect<void> {
return Effect.all([ return Effect.all([
Stream.runFoldEffect( Stream.runForEach(
this.key.changes, this.key.changes,
() => Option.none<K>(), key => Effect.gen({ self: this }, function*() {
(previous, key) => Effect.as( yield* this.interrupt
Option.isSome(previous) && this.keyEquivalence(key, previous.value) const latestFinalState = yield* Lens.get(this.latestFinalState)
? this.refreshSubscribable
: this.fetchSubscribable(key), const state = yield* this.startCached(
Option.some(key), Option.isSome(latestFinalState) && this.keyEquivalence(key, latestFinalState.value.key)
), ? latestFinalState.value
: {
key,
result: AsyncResult.initial(false),
}
)
yield* Effect.forkScoped(this.watch(state))
}),
), ),
Effect.promise(() => import("@effect/platform-browser")).pipe( Effect.promise(() => import("@effect/platform-browser")).pipe(
Effect.flatMap(({ BrowserStream }) => this.refreshOnWindowFocus Effect.flatMap(({ BrowserStream }) => this.refreshOnWindowFocus
? Stream.runForEach( ? Stream.runForEach(
BrowserStream.fromEventListenerWindow("focus"), BrowserStream.fromEventListenerWindow("focus"),
() => this.refreshSubscribable, () => this.refreshView,
) )
: Effect.void : Effect.void
), ),
@@ -109,8 +117,6 @@ extends Pipeable.Class implements Query<K, A, E, R> {
fetch(key: K): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> { fetch(key: K): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> {
return Effect.gen({ self: this }, function*() { return Effect.gen({ self: this }, function*() {
yield* this.interrupt yield* this.interrupt
yield* Lens.set(this.key, key)
const state = yield* this.startCached({ const state = yield* this.startCached({
key, key,
result: AsyncResult.initial(false), result: AsyncResult.initial(false),
@@ -121,14 +127,12 @@ extends Pipeable.Class implements Query<K, A, E, R> {
) )
} }
fetchSubscribable(key: K): Effect.Effect< fetchView(key: K): Effect.Effect<
Subscribable.Subscribable<QueryState<K, A, E>>, View.View<QueryState<K, A, E>>,
Cause.NoSuchElementError Cause.NoSuchElementError
> { > {
return Effect.gen({ self: this }, function*() { return Effect.gen({ self: this }, function*() {
yield* this.interrupt yield* this.interrupt
yield* Lens.set(this.key, key)
const state = yield* this.startCached({ const state = yield* this.startCached({
key, key,
result: AsyncResult.initial(false), result: AsyncResult.initial(false),
@@ -144,14 +148,14 @@ extends Pipeable.Class implements Query<K, A, E, R> {
get refresh(): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> { get refresh(): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> {
return Effect.gen({ self: this }, function*() { return Effect.gen({ self: this }, function*() {
yield* this.interrupt yield* this.interrupt
const latestKey = yield* Lens.get(this.key) const latestState = yield* Lens.get(this.state)
const latestFinalState = yield* Lens.get(this.latestFinalState) const latestFinalState = yield* Lens.get(this.latestFinalState)
const state = yield* this.startCached( const state = yield* this.startCached(
Option.isSome(latestFinalState) && this.keyEquivalence(latestKey, latestFinalState.value.key) Option.isSome(latestFinalState) && this.keyEquivalence(latestState.key, latestFinalState.value.key)
? latestFinalState.value ? latestFinalState.value
: { : {
key: latestKey, key: latestState.key,
result: AsyncResult.initial(false), result: AsyncResult.initial(false),
} }
) )
@@ -162,20 +166,20 @@ extends Pipeable.Class implements Query<K, A, E, R> {
) )
} }
get refreshSubscribable(): Effect.Effect< get refreshView(): Effect.Effect<
Subscribable.Subscribable<QueryState<K, A, E>>, View.View<QueryState<K, A, E>>,
Cause.NoSuchElementError Cause.NoSuchElementError
> { > {
return Effect.gen({ self: this }, function*() { return Effect.gen({ self: this }, function*() {
yield* this.interrupt yield* this.interrupt
const latestKey = yield* Lens.get(this.key) const latestState = yield* Lens.get(this.state)
const latestFinalState = yield* Lens.get(this.latestFinalState) const latestFinalState = yield* Lens.get(this.latestFinalState)
const state = yield* this.startCached( const state = yield* this.startCached(
Option.isSome(latestFinalState) && this.keyEquivalence(latestKey, latestFinalState.value.key) Option.isSome(latestFinalState) && this.keyEquivalence(latestState.key, latestFinalState.value.key)
? latestFinalState.value ? latestFinalState.value
: { : {
key: latestKey, key: latestState.key,
result: AsyncResult.initial(false), result: AsyncResult.initial(false),
} }
) )
@@ -190,7 +194,7 @@ extends Pipeable.Class implements Query<K, A, E, R> {
startCached( startCached(
previous: QueryState<K, A, E>, previous: QueryState<K, A, E>,
): Effect.Effect< ): Effect.Effect<
Subscribable.Subscribable<QueryState<K, A, E>>, View.View<QueryState<K, A, E>>,
Cause.NoSuchElementError, Cause.NoSuchElementError,
Scope.Scope | QueryClient.QueryClient | R Scope.Scope | QueryClient.QueryClient | R
> { > {
@@ -202,7 +206,7 @@ extends Pipeable.Class implements Query<K, A, E, R> {
key: previous.key, key: previous.key,
result: entry.result as AsyncResult.AsyncResult<A, E>, result: entry.result as AsyncResult.AsyncResult<A, E>,
}) })
: Effect.succeed(Subscribable.make({ : Effect.succeed(View.make({
get: Effect.succeed({ get: Effect.succeed({
key: previous.key, key: previous.key,
result: entry.result as AsyncResult.AsyncResult<A, E>, result: entry.result as AsyncResult.AsyncResult<A, E>,
@@ -222,15 +226,15 @@ extends Pipeable.Class implements Query<K, A, E, R> {
start( start(
previous: QueryState<K, A, E>, previous: QueryState<K, A, E>,
): Effect.Effect< ): Effect.Effect<
Subscribable.Subscribable<QueryState<K, A, E>>, View.View<QueryState<K, A, E>>,
never, never,
Scope.Scope | R Scope.Scope | R
> { > {
return Effect.gen({ self: this }, function*() { return Effect.gen({ self: this }, function*() {
const subscribable = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(previous)) const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(previous))
const fiber = yield* Effect.forkScoped(Effect.andThen( const fiber = yield* Effect.forkScoped(Effect.andThen(
Lens.update(subscribable, previous => AsyncResult.match(previous.result, { Lens.update(state, previous => AsyncResult.match(previous.result, {
onInitial: () => ({ onInitial: () => ({
key: previous.key, key: previous.key,
result: AsyncResult.initial(true), result: AsyncResult.initial(true),
@@ -251,7 +255,7 @@ extends Pipeable.Class implements Query<K, A, E, R> {
})), })),
Effect.onExit(this.f(previous.key), exit => Lens.update( Effect.onExit(this.f(previous.key), exit => Lens.update(
subscribable, state,
previous => Exit.match(exit, { previous => Exit.match(exit, {
onSuccess: v => ({ onSuccess: v => ({
key: previous.key, key: previous.key,
@@ -291,17 +295,17 @@ extends Pipeable.Class implements Query<K, A, E, R> {
)) ))
yield* Lens.set(this.fiber, Option.some(fiber)) yield* Lens.set(this.fiber, Option.some(fiber))
return subscribable return state
}) })
} }
watch( watch(
subscribable: Subscribable.Subscribable<QueryState<K, A, E>> view: View.View<QueryState<K, A, E>>
): Effect.Effect<FinalQueryState<K, A, E>, never, QueryClient.QueryClient> { ): Effect.Effect<FinalQueryState<K, A, E>, never, QueryClient.QueryClient> {
return Effect.gen({ self: this }, function*() { return Effect.gen({ self: this }, function*() {
const initial = yield* subscribable.get const initial = yield* View.get(view)
const final = yield* Stream.runFoldEffect( const final = yield* Stream.runFoldEffect(
subscribable.changes, View.changes(view),
() => initial, () => initial,
(_, state) => Effect.as(Lens.set(this.state, state), state), (_, state) => Effect.as(Lens.set(this.state, state), state),
) as Effect.Effect<FinalQueryState<K, A, E>> ) as Effect.Effect<FinalQueryState<K, A, E>>
@@ -363,7 +367,7 @@ extends Pipeable.Class implements Query<K, A, E, R> {
export declare namespace make { export declare namespace make {
export interface Options<K, A, E = never, R = never> { export interface Options<K, A, E = never, R = never> {
readonly key: Lens.Lens<K>, readonly key: View.View<K>,
readonly keyEquivalence?: Equivalence.Equivalence<K>, readonly keyEquivalence?: Equivalence.Equivalence<K>,
readonly f: (key: K) => Effect.Effect<A, E, R> readonly f: (key: K) => Effect.Effect<A, E, R>
@@ -392,7 +396,7 @@ export const make = Effect.fnUntraced(function* <K, A, E = never, R = never>(
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>())), Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<QueryState<K, A, E>>({ Lens.fromSubscriptionRef(yield* SubscriptionRef.make<QueryState<K, A, E>>({
key: yield* Lens.get(options.key), key: yield* View.get(options.key),
result: AsyncResult.initial(false), result: AsyncResult.initial(false),
})), })),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<FinalQueryState<K, A, E>>())), Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<FinalQueryState<K, A, E>>())),
+5 -3
View File
@@ -1,7 +1,7 @@
import { type Cause, Context, DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, type Option, Pipeable, Predicate, Schedule, type Scope, Semaphore, SubscriptionRef } from "effect" import { type Cause, Context, DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, Layer, type Option, Pipeable, Predicate, Schedule, type Scope, Semaphore, SubscriptionRef } from "effect"
import type { AsyncResult } from "effect/unstable/reactivity" import type { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js" import * as Lens from "./Lens.js"
import type * as Subscribable from "./Subscribable.js" import type * as View from "./View.js"
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService") export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService")
@@ -10,7 +10,7 @@ export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
export interface QueryClientService extends Pipeable.Pipeable { export interface QueryClientService extends Pipeable.Pipeable {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>> readonly cache: View.View<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly cacheGcTime: Duration.Duration readonly cacheGcTime: Duration.Duration
readonly defaultStaleTime: Duration.Duration readonly defaultStaleTime: Duration.Duration
readonly defaultRefreshOnWindowFocus: boolean readonly defaultRefreshOnWindowFocus: boolean
@@ -126,6 +126,8 @@ export const service = (
client => Effect.forkScoped(client.run), client => Effect.forkScoped(client.run),
) )
export const layer = (options?: service.Options) => Layer.effect(QueryClient, service(options))
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheKey") export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheKey")
export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId
+5 -16
View File
@@ -2,8 +2,6 @@
import { type Context, Effect, Layer, ManagedRuntime, Predicate } from "effect" import { type Context, Effect, Layer, ManagedRuntime, Predicate } from "effect"
import * as React from "react" import * as React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
import * as ErrorObserver from "./ErrorObserver.js"
import * as QueryClient from "./QueryClient.js"
export const ReactRuntimeTypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime") export const ReactRuntimeTypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime")
@@ -16,18 +14,9 @@ export interface ReactRuntime<R, ER> {
readonly context: React.Context<Context.Context<R>> readonly context: React.Context<Context.Context<R>>
} }
const ReactRuntimeProto = Object.freeze({ [ReactRuntimeTypeId]: ReactRuntimeTypeId } as const) const ReactRuntimePrototype = Object.freeze({ [ReactRuntimeTypeId]: ReactRuntimeTypeId } as const)
export const preludeLayer: Layer.Layer<
| Component.ScopeMap
| ErrorObserver.ErrorObserver
| QueryClient.QueryClient
> = Layer.mergeAll(
Component.ScopeMap.layer,
ErrorObserver.layer,
QueryClient.QueryClient.Default,
)
export const preludeLayer: Layer.Layer<Component.ScopeMap> = Component.ScopeMap.layer
export const isReactRuntime = (u: unknown): u is ReactRuntime<unknown, unknown> => Predicate.hasProperty(u, ReactRuntimeTypeId) export const isReactRuntime = (u: unknown): u is ReactRuntime<unknown, unknown> => Predicate.hasProperty(u, ReactRuntimeTypeId)
@@ -37,13 +26,13 @@ export const make = <R, ER>(
): ReactRuntime<Layer.Success<typeof preludeLayer> | R, ER> => Object.setPrototypeOf( ): ReactRuntime<Layer.Success<typeof preludeLayer> | R, ER> => Object.setPrototypeOf(
Object.assign(function() {}, { Object.assign(function() {}, {
runtime: ManagedRuntime.make( runtime: ManagedRuntime.make(
Layer.merge(layer, preludeLayer), Layer.mergeAll(preludeLayer, layer),
{ memoMap }, { memoMap },
), ),
// biome-ignore lint/style/noNonNullAssertion: context initialization // biome-ignore lint/style/noNonNullAssertion: context initialization
context: React.createContext<Context.Context<R>>(null!), context: React.createContext<Context.Context<Layer.Success<typeof preludeLayer> | R>>(null!),
}), }),
ReactRuntimeProto, ReactRuntimePrototype,
) )
-249
View File
@@ -1,249 +0,0 @@
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, pipe, type Scope, SubscriptionRef } from "effect"
import { Lens, Subscribable } from "effect-lens"
export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result")
export type ResultTypeId = typeof ResultTypeId
export type Result<A, E = never, P = never> = (
| Initial
| Running<P>
| Final<A, E, P>
)
// biome-ignore lint/complexity/noBannedTypes: "{}" is relevant here
export type Final<A, E = never, P = never> = (Success<A> | Failure<E>) & ({} | Flags<P>)
export type Flags<P = never> = WillFetch | WillRefresh | Refreshing<P>
export declare namespace Result {
export type Success<R extends Result<any, any, any>> = [R] extends [Result<infer A, infer _E, infer _P>] ? A : never
export type Failure<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer E, infer _P>] ? E : never
export type Progress<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer _E, infer P>] ? P : never
}
export declare namespace Flags {
export type Keys = keyof WillFetch & WillRefresh & Refreshing<any>
}
export interface Initial extends ResultPrototype {
readonly _tag: "Initial"
}
export interface Running<P = never> extends ResultPrototype {
readonly _tag: "Running"
readonly progress: P
}
export interface Success<A> extends ResultPrototype {
readonly _tag: "Success"
readonly value: A
}
export interface Failure<E = never> extends ResultPrototype {
readonly _tag: "Failure"
readonly cause: Cause.Cause<E>
}
export interface WillFetch {
readonly _flag: "WillFetch"
}
export interface WillRefresh {
readonly _flag: "WillRefresh"
}
export interface Refreshing<P = never> {
readonly _flag: "Refreshing"
readonly progress: P
}
export interface ResultPrototype extends Pipeable.Pipeable, Equal.Equal {
readonly [ResultTypeId]: ResultTypeId
}
export const ResultPrototype: ResultPrototype = Object.freeze({
...Pipeable.Prototype,
[ResultTypeId]: ResultTypeId,
[Equal.symbol](this: Result<any, any, any>, that: Result<any, any, any>): boolean {
if (this._tag !== that._tag || (this as Flags)._flag !== (that as Flags)._flag)
return false
if (hasRefreshingFlag(this) && !Equal.equals(this.progress, (that as Refreshing<any>).progress))
return false
return Match.value(this).pipe(
Match.tag("Initial", () => true),
Match.tag("Running", self => Equal.equals(self.progress, (that as Running<any>).progress)),
Match.tag("Success", self => Equal.equals(self.value, (that as Success<any>).value)),
Match.tag("Failure", self => Equal.equals(self.cause, (that as Failure<any>).cause)),
Match.exhaustive,
)
},
[Hash.symbol](this: Result<any, any, any>): number {
return pipe(Hash.string(this._tag),
tagHash => Match.value(this).pipe(
Match.tag("Initial", () => tagHash),
Match.tag("Running", self => Hash.combine(Hash.hash(self.progress))(tagHash)),
Match.tag("Success", self => Hash.combine(Hash.hash(self.value))(tagHash)),
Match.tag("Failure", self => Hash.combine(Hash.hash(self.cause))(tagHash)),
Match.exhaustive,
),
Hash.combine(Hash.hash((this as Flags)._flag)),
hash => hasRefreshingFlag(this)
? Hash.combine(Hash.hash(this.progress))(hash)
: hash,
)
},
} as const)
export const isResult = (u: unknown): u is Result<unknown, unknown, unknown> => Predicate.hasProperty(u, ResultTypeId)
export const isFinal = (u: unknown): u is Final<unknown, unknown, unknown> => isResult(u) && (isSuccess(u) || isFailure(u))
export const isInitial = (u: unknown): u is Initial => isResult(u) && u._tag === "Initial"
export const isRunning = (u: unknown): u is Running<unknown> => isResult(u) && u._tag === "Running"
export const isSuccess = (u: unknown): u is Success<unknown> => isResult(u) && u._tag === "Success"
export const isFailure = (u: unknown): u is Failure<unknown> => isResult(u) && u._tag === "Failure"
export const hasFlag = (u: unknown): u is Flags => isResult(u) && Predicate.hasProperty(u, "_flag")
export const hasWillFetchFlag = (u: unknown): u is WillFetch => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "WillFetch"
export const hasWillRefreshFlag = (u: unknown): u is WillRefresh => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "WillRefresh"
export const hasRefreshingFlag = (u: unknown): u is Refreshing<unknown> => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "Refreshing"
export const initial: {
(): Initial
<A, E = never, P = never>(): Result<A, E, P>
} = (): Initial => Object.setPrototypeOf({ _tag: "Initial" }, ResultPrototype)
export const running = <P = never>(progress?: P): Running<P> => Object.setPrototypeOf({ _tag: "Running", progress }, ResultPrototype)
export const succeed = <A>(value: A): Success<A> => Object.setPrototypeOf({ _tag: "Success", value }, ResultPrototype)
export const fail = <E>(cause: Cause.Cause<E> ): Failure<E> => Object.setPrototypeOf({ _tag: "Failure", cause }, ResultPrototype)
export const willFetch = <R extends Final<any, any, any>>(
result: R
): Omit<R, keyof Flags.Keys> & WillFetch => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "WillFetch" }),
Object.getPrototypeOf(result),
)
export const willRefresh = <R extends Final<any, any, any>>(
result: R
): Omit<R, keyof Flags.Keys> & WillRefresh => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "WillRefresh" }),
Object.getPrototypeOf(result),
)
export const refreshing = <R extends Final<any, any, any>, P = never>(
result: R,
progress?: P,
): Omit<R, keyof Flags.Keys> & Refreshing<P> => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "Refreshing", progress }),
Object.getPrototypeOf(result),
)
export const fromExit: {
<A, E>(exit: Exit.Success<A, E>): Success<A>
<A, E>(exit: Exit.Failure<A, E>): Failure<E>
<A, E>(exit: Exit.Exit<A, E>): Success<A> | Failure<E>
} = exit => (exit._tag === "Success" ? succeed(exit.value) : fail(exit.cause)) as any
export const toExit: {
<A>(self: Success<A>): Exit.Success<A, never>
<E>(self: Failure<E>): Exit.Failure<never, E>
<A, E, P>(self: Final<A, E, P>): Exit.Exit<A, E>
<A, E, P>(self: Result<A, E, P>): Exit.Exit<A, E | Cause.NoSuchElementError>
} = <A, E, P>(self: Result<A, E, P>): any => {
switch (self._tag) {
case "Success":
return Exit.succeed(self.value)
case "Failure":
return Exit.failCause(self.cause)
default:
return Exit.fail(new Cause.NoSuchElementError())
}
}
export interface Progress<P = never> {
readonly progress: Lens.Lens<P, PreviousResultNotRunningNorRefreshing, never, never, never>
}
export const Progress = <P = never>() => Context.Service<Progress<P>>("@effect-fc/Result/Progress")
export class PreviousResultNotRunningNorRefreshing extends Data.TaggedError("@effect-fc/Result/PreviousResultNotRunningNorRefreshing")<{
readonly previous: Result<unknown, unknown, unknown>
}> {}
export const makeProgressLayer = <A, E, P = never>(
state: Lens.Lens<Result<A, E, P>, never, never, never, never>
): Layer.Layer<Progress<P> | Progress<never>, never, never> => Layer.succeed(
Progress<P>() as Context.Service<Progress<P> | Progress<never>, Progress<P> | Progress<never>>,
{
progress: state.pipe(
Lens.mapEffect(
a => (isRunning(a) || hasRefreshingFlag(a))
? Effect.succeed(a)
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous: a })),
(_, b) => Effect.succeed(b),
),
Lens.map(
a => a.progress,
(a, b) => isRunning(a)
? running(b)
: refreshing(a, b) as Final<A, E, P> & Refreshing<P>,
),
)
},
)
export namespace unsafeForkEffect {
export type OutputContext<R, P> = Exclude<R, Progress<P> | Progress<never>>
export interface Options<A, E, P> {
readonly initial?: Initial | Final<A, E, P>
readonly initialProgress?: P
}
}
export const unsafeForkEffect = Effect.fnUntraced(function* <A, E, R, P = never>(
effect: Effect.Effect<A, E, R>,
options?: unsafeForkEffect.Options<NoInfer<A>, NoInfer<E>, P>,
): Effect.fn.Return<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
Scope.Scope | unsafeForkEffect.OutputContext<R, P>
> {
const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make<Result<A, E, P>>(
options?.initial ?? initial<A, E, P>(),
))
const fiber = yield* Effect.gen(function*() {
yield* Lens.set(
state,
(isFinal(options?.initial) && hasWillRefreshFlag(options?.initial))
? refreshing(options.initial, options?.initialProgress) as Result<A, E, P>
: running(options?.initialProgress),
)
return yield* Effect.onExit(effect, exit => Lens.set(state, fromExit(exit)))
}).pipe(
Effect.forkScoped,
Effect.provide(makeProgressLayer(state)),
)
return [state, fiber] as const
})
export namespace forkEffect {
export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R
export type OutputContext<R, P> = unsafeForkEffect.OutputContext<R, P>
export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {}
}
export const forkEffect: {
<A, E, R, P = never>(
effect: Effect.Effect<A, E, forkEffect.InputContext<R, NoInfer<P>>>,
options?: forkEffect.Options<NoInfer<A>, NoInfer<E>, P>,
): Effect.Effect<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
Scope.Scope | forkEffect.OutputContext<R, P>
>
} = unsafeForkEffect
+2 -2
View File
@@ -3,6 +3,8 @@ import * as React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
export * from "effect/Stream"
export const use: { export const use: {
<A, E, R>( <A, E, R>(
stream: Stream.Stream<A, E, R> stream: Stream.Stream<A, E, R>
@@ -29,5 +31,3 @@ export const use: {
return reactStateValue as Option.Some<A> return reactStateValue as Option.Some<A>
}) })
export * from "effect/Stream"
@@ -1,212 +0,0 @@
import {
Array,
Cause,
type Context,
Effect,
Fiber,
Option,
Pipeable,
Predicate,
Schema,
SchemaIssue,
SchemaParser,
type Scope,
Semaphore,
SubscriptionRef,
} from "effect"
import * as Form from "./Form.js"
import * as Lens from "./Lens.js"
import * as Mutation from "./Mutation.js"
import * as Result from "./Result.js"
import * as Subscribable from "./Subscribable.js"
type FormSchema<A, I, R> = Schema.Top & {
readonly Type: A
readonly Encoded: I
readonly DecodingServices: R
}
export const SubmittableFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SubmittableForm")
export type SubmittableFormTypeId = typeof SubmittableFormTypeId
export interface SubmittableForm<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Form.Form<readonly [], A, I, never, never> {
readonly [SubmittableFormTypeId]: SubmittableFormTypeId
readonly schema: FormSchema<A, I, R>
readonly context: Context.Context<Scope.Scope | R>
readonly mutation: Mutation.Mutation<
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>, never, never>
readonly run: Effect.Effect<void>
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementError>
}
export class SubmittableFormImpl<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Pipeable.Class implements SubmittableForm<A, I, R, MA, ME, MR, MP> {
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
readonly [SubmittableFormTypeId]: SubmittableFormTypeId = SubmittableFormTypeId
readonly path = [] as const
readonly encodedValue: Lens.Lens<I, never, never, never, never>
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
constructor(
readonly schema: FormSchema<A, I, R>,
readonly context: Context.Context<Scope.Scope | R>,
readonly mutation: Mutation.Mutation<
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>,
readonly value: Lens.Lens<Option.Option<A>, never, never, never, never>,
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
readonly issues: Lens.Lens<readonly Form.FormIssue[], never, never, never, never>,
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>, never, never, never, never>,
readonly runSemaphore: Semaphore.Semaphore,
) {
super()
this.encodedValue = Lens.make({
get: Lens.get(internalEncodedValue),
changes: internalEncodedValue.changes,
commit: encoded => Effect.andThen(
Lens.set(internalEncodedValue, encoded),
this.synchronizeEncodedValue(encoded),
),
lock: Lens.asLensImpl(internalEncodedValue).lock,
})
this.isValidating = Subscribable.map(validationFiber, Option.isSome)
const commitState = Subscribable.zipLatestAll(
value as any,
issues as any,
validationFiber as any,
mutation.result as any,
) as unknown as Subscribable.Subscribable<readonly [
Option.Option<A>,
readonly Form.FormIssue[],
Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>,
Result.Result<MA, ME, MP>,
]>
this.canCommit = Subscribable.map(
commitState,
([current, currentIssues, fiber, result]: readonly [Option.Option<A>, readonly Form.FormIssue[], Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>, Result.Result<MA, ME, MP>]) => Option.isSome(current)
&& currentIssues.length === 0
&& Option.isNone(fiber)
&& !(Result.isRunning(result) || Result.hasRefreshingFlag(result)),
)
this.isCommitting = Subscribable.map(
mutation.result,
result => Result.isRunning(result) || Result.hasRefreshingFlag(result),
)
}
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void> {
const self = this
return Effect.gen(function*() {
const current = yield* Lens.get(self.validationFiber)
if (Option.isSome(current)) yield* Fiber.interrupt(current.value)
const fiber = yield* Effect.forkScoped(
Effect.ensuring(
SchemaParser.decodeEffect(self.schema)(encodedValue),
Lens.set(self.validationFiber, Option.none()),
),
)
yield* Lens.set(self.validationFiber, Option.some(fiber))
const decoded = yield* Fiber.join(fiber).pipe(
Effect.tap(value => Effect.andThen(
Lens.set(self.issues, Array.empty()),
Lens.set(self.value, Option.some(value)),
)),
Effect.catchIf(SchemaIssue.isIssue, issue => Lens.set(self.issues, formatIssue(issue))),
)
void decoded
}).pipe(Effect.provide(this.context)) as Effect.Effect<void>
}
get run(): Effect.Effect<void> {
return Effect.flatMap(
Lens.get(this.encodedValue),
SchemaParser.decodeEffect(this.schema),
).pipe(
Effect.option,
Effect.flatMap(value => Lens.set(this.value, value)),
Effect.provide(this.context),
this.runSemaphore.withPermits(1),
)
}
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementError> {
return Effect.flatMap(Lens.get(this.value), value => Effect.flatMap(Effect.fromOption(value), decoded => this.submitValue(decoded)))
}
submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>> {
return Effect.flatMap(this.canCommit.get, canCommit => {
if (!canCommit) return Effect.succeed(Option.none())
return Effect.map(
Effect.tap(this.mutation.mutate([value, this as any]), result => {
if (!Result.isFailure(result)) return Effect.succeed(undefined)
const issue = Cause.findErrorOption(result.cause)
return Option.isSome(issue) && SchemaIssue.isIssue(issue.value)
? Lens.set(this.issues, formatIssue(issue.value))
: Effect.succeed(undefined)
}),
Option.some,
)
})
}
}
const formatIssue = (issue: SchemaIssue.Issue): readonly Form.FormIssue[] => {
const formatted = SchemaIssue.makeFormatterStandardSchemaV1()(issue)
return formatted.issues.map(item => ({
path: (item.path ?? []) as readonly PropertyKey[],
message: item.message,
}))
}
export const isSubmittableForm = (u: unknown): u is SubmittableForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SubmittableFormTypeId)
export declare namespace make {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Mutation.make.Options<
readonly [value: NoInfer<A>, form: SubmittableForm<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>],
MA, ME, MR, MP
> {
readonly schema: FormSchema<A, I, R>
readonly initialEncodedValue: NoInfer<I>
}
}
export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: make.Options<A, I, R, MA, ME, MR, MP>,
): Effect.fn.Return<
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
never,
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
> {
return new SubmittableFormImpl(
options.schema,
yield* Effect.context<Scope.Scope | R>(),
yield* Mutation.make(options),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<A>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly Form.FormIssue[]>(Array.empty())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, SchemaIssue.Issue>>())),
yield* Semaphore.make(1),
)
})
export declare namespace service {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends make.Options<A, I, R, MA, ME, MR, MP> {}
}
export const service = <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: service.Options<A, I, R, MA, ME, MR, MP>,
): Effect.Effect<
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
never,
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
> => Effect.tap(make(options), form => Effect.asVoid(Effect.forkScoped(form.run)))
@@ -1,53 +0,0 @@
import { Effect, Equivalence, Stream } from "effect"
import { Subscribable } from "effect-lens"
import * as React from "react"
import * as Component from "./Component.js"
export * from "effect-lens/Subscribable"
export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
...elements: T
): Subscribable.Subscribable<
[T[number]] extends [never]
? never
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never },
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer E, infer _R> ? E : never,
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
> => Subscribable.make({
get: Effect.all(elements.map(v => v.get)),
changes: Stream.zipLatestAll(...elements.map(v => v.changes)),
}) as any
export declare namespace useAll {
export type Success<T extends readonly Subscribable.Subscribable<any, any, any>[]> = [T[number]] extends [never]
? never
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never }
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useAll = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
elements: T,
options?: useAll.Options<useAll.Success<NoInfer<T>>>,
): Effect.fn.Return<
useAll.Success<T>,
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer E, infer _R> ? E : never,
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
> {
const [reactStateValue, setReactStateValue] = React.useState(
yield* Component.useOnMount(() => Effect.all(elements.map(v => v.get)))
)
yield* Component.useReactEffect(() => Stream.zipLatestAll(...elements.map(ref => ref.changes)).pipe(
Stream.changesWith((options?.equivalence as Equivalence.Equivalence<any[]> | undefined) ?? Equivalence.Array(Equivalence.strictEqual())),
Stream.runForEach(v =>
Effect.sync(() => setReactStateValue(v))
),
Effect.forkScoped,
), elements)
return reactStateValue as any
})
@@ -1,204 +0,0 @@
import {
Array,
type Context,
Effect,
Equal,
Fiber,
Option,
Pipeable,
Predicate,
Schema,
SchemaIssue,
SchemaParser,
type Scope,
Semaphore,
Stream,
SubscriptionRef,
} from "effect"
import * as Form from "./Form.js"
import * as Lens from "./Lens.js"
import * as Subscribable from "./Subscribable.js"
type FormSchema<A, I, R> = Schema.Top & {
readonly Type: A
readonly Encoded: I
readonly DecodingServices: R
readonly EncodingServices: R
}
export const SynchronizedFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SynchronizedForm")
export type SynchronizedFormTypeId = typeof SynchronizedFormTypeId
export interface SynchronizedForm<
in out A,
in out I = A,
in out R = never,
in out TER = never,
in out TEW = never,
in out TRR = never,
in out TRW = never,
> extends Form.Form<readonly [], A, I, TER, TER | TEW> {
readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId
readonly schema: FormSchema<A, I, R>
readonly context: Context.Context<Scope.Scope | R | TRR | TRW>
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>, never, never>
readonly run: Effect.Effect<void, TER>
}
export class SynchronizedFormImpl<
in out A,
in out I = A,
in out R = never,
in out TER = never,
in out TEW = never,
in out TRR = never,
in out TRW = never,
> extends Pipeable.Class implements SynchronizedForm<A, I, R, TER, TEW, TRR, TRW> {
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId = SynchronizedFormTypeId
readonly path = [] as const
readonly value: Subscribable.Subscribable<Option.Option<A>, TER, never>
readonly encodedValue: Lens.Lens<I, TER, TER | TEW, never, never>
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
constructor(
readonly schema: FormSchema<A, I, R>,
readonly context: Context.Context<Scope.Scope | R | TRR | TRW>,
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>,
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
readonly issues: Lens.Lens<readonly Form.FormIssue[], never, never, never, never>,
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>, never, never, never, never>,
readonly isCommitting: Lens.Lens<boolean, never, never, never, never>,
readonly runSemaphore: Semaphore.Semaphore,
) {
super()
this.value = Subscribable.make({
get: Effect.provide(Effect.map(target.get, Option.some), context),
changes: Stream.provideContext(
target.changes.pipe(
Stream.map(Option.some),
Stream.catchCause(() => Stream.make(Option.none<A>())),
),
context,
),
})
this.encodedValue = Lens.make({
get: Lens.get(internalEncodedValue),
changes: internalEncodedValue.changes,
commit: encoded => Effect.andThen(
Lens.set(internalEncodedValue, encoded),
this.synchronizeEncodedValue(encoded),
),
lock: Lens.asLensImpl(internalEncodedValue).lock,
}) as unknown as Lens.Lens<I, TER, TER | TEW, never, never>
this.isValidating = Subscribable.map(validationFiber, Option.isSome)
const commitState = Subscribable.zipLatestAll(issues as any, validationFiber as any, isCommitting as any) as unknown as Subscribable.Subscribable<readonly [
readonly Form.FormIssue[],
Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>,
boolean,
]>
this.canCommit = Subscribable.map(
commitState,
([currentIssues, fiber, committing]) => currentIssues.length === 0 && Option.isNone(fiber) && !committing,
)
}
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, TER | TEW> {
const self = this
return Effect.gen(function*() {
const current = yield* Lens.get(self.validationFiber)
if (Option.isSome(current)) yield* Fiber.interrupt(current.value)
const fiber = yield* Effect.forkScoped(
Effect.ensuring(
SchemaParser.decodeEffect(self.schema)(encodedValue),
Lens.set(self.validationFiber, Option.none()),
),
)
yield* Lens.set(self.validationFiber, Option.some(fiber))
yield* Fiber.join(fiber).pipe(
Effect.flatMap(value => Effect.ensuring(
Effect.andThen(
Lens.set(self.isCommitting, true),
Effect.andThen(Lens.set(self.issues, Array.empty()), Lens.set(self.target, value)),
),
Lens.set(self.isCommitting, false),
)),
Effect.catchIf(SchemaIssue.isIssue, issue => Lens.set(self.issues, formatIssue(issue))),
)
}).pipe(Effect.provide(this.context)) as Effect.Effect<void, TER | TEW>
}
get run(): Effect.Effect<void, TER> {
return this.runSemaphore.withPermits(1)(Effect.provide(
Stream.runForEach(Stream.drop(this.target.changes, 1), targetValue => Effect.ignore(
Effect.flatMap(SchemaParser.encodeEffect(this.schema)(targetValue), encodedValue => Effect.flatMap(
Lens.get(this.internalEncodedValue),
current => Equal.equals(encodedValue, current)
? Effect.succeed(undefined)
: Effect.andThen(
Lens.set(this.issues, Array.empty()),
Lens.set(this.internalEncodedValue, encodedValue),
),
)),
)),
this.context,
))
}
}
const formatIssue = (issue: SchemaIssue.Issue): readonly Form.FormIssue[] => {
const formatted = SchemaIssue.makeFormatterStandardSchemaV1()(issue)
return formatted.issues.map(item => ({
path: (item.path ?? []) as readonly PropertyKey[],
message: item.message,
}))
}
export const isSynchronizedForm = (u: unknown): u is SynchronizedForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SynchronizedFormTypeId)
export declare namespace make {
export interface Options<in out A, in out I = A, in out R = never, in out TER = never, in out TEW = never, in out TRR = never, in out TRW = never> {
readonly schema: FormSchema<A, I, R>
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
readonly initialEncodedValue?: NoInfer<I>
}
}
export const make = Effect.fnUntraced(function* <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: make.Options<A, I, R, TER, TEW, TRR, TRW>,
): Effect.fn.Return<
SynchronizedForm<A, I, R, TER, TEW, TRR, TRW>,
SchemaIssue.Issue | TER,
Scope.Scope | R | TRR | TRW
> {
const initialEncodedValue = options.initialEncodedValue !== undefined
? options.initialEncodedValue
: yield* Effect.flatMap(Lens.get(options.target), SchemaParser.encodeEffect(options.schema))
return new SynchronizedFormImpl(
options.schema,
yield* Effect.context<Scope.Scope | R | TRR | TRW>(),
options.target,
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(initialEncodedValue)),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly Form.FormIssue[]>(Array.empty())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, SchemaIssue.Issue>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(false)),
yield* Semaphore.make(1),
)
})
export declare namespace service {
export interface Options<in out A, in out I = A, in out R = never, in out TER = never, in out TEW = never, in out TRR = never, in out TRW = never>
extends make.Options<A, I, R, TER, TEW, TRR, TRW> {}
}
export const service = <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: service.Options<A, I, R, TER, TEW, TRR, TRW>,
): Effect.Effect<
SynchronizedForm<A, I, R, TER, TEW, TRR, TRW>,
SchemaIssue.Issue | TER,
Scope.Scope | R | TRR | TRW
> => Effect.tap(make(options), form => Effect.asVoid(Effect.forkScoped(form.run)))
+40
View File
@@ -0,0 +1,40 @@
import { Effect, Equivalence, Stream } from "effect"
import { View } from "effect-lens"
import * as React from "react"
import * as Component from "./Component.js"
export * from "effect-lens/View"
export declare namespace useAll {
export type Success<T extends readonly View.View<any, any, any>[]> = [T[number]] extends [never]
? never
: { [K in keyof T]: T[K] extends View.View<infer A, infer _E, infer _R> ? A : never }
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useAll = Effect.fnUntraced(function* <const T extends readonly View.View<any, any, any>[]>(
elements: T,
options?: useAll.Options<useAll.Success<NoInfer<T>>>,
): Effect.fn.Return<
useAll.Success<T>,
[T[number]] extends [never] ? never : T[number] extends View.View<infer _A, infer E, infer _R> ? E : never,
[T[number]] extends [never] ? never : T[number] extends View.View<infer _A, infer _E, infer R> ? R : never
> {
const [reactStateValue, setReactStateValue] = React.useState(
yield* Component.useOnMount(() => Effect.all(elements.map(View.get)))
)
yield* Component.useReactEffect(() => View.changes(View.zipLatestAll(...elements)).pipe(
Stream.changesWith((options?.equivalence as Equivalence.Equivalence<any[]> | undefined) ?? Equivalence.Array(Equivalence.strictEqual())),
Stream.runForEach(v =>
Effect.sync(() => setReactStateValue(v))
),
Effect.forkScoped,
), elements)
return reactStateValue as any
})
+3 -4
View File
@@ -3,15 +3,14 @@ export * as Component from "./Component.js"
export * as ErrorObserver from "./ErrorObserver.js" export * as ErrorObserver from "./ErrorObserver.js"
export * as Form from "./Form.js" export * as Form from "./Form.js"
export * as Lens from "./Lens.js" export * as Lens from "./Lens.js"
export * as LensForm from "./LensForm.js"
export * as Memoized from "./Memoized.js" export * as Memoized from "./Memoized.js"
export * as Mutation from "./Mutation.js" export * as Mutation from "./Mutation.js"
export * as MutationForm from "./MutationForm.js"
export * as PubSub from "./PubSub.js" export * as PubSub from "./PubSub.js"
export * as Query from "./Query.js" export * as Query from "./Query.js"
export * as QueryClient from "./QueryClient.js" export * as QueryClient from "./QueryClient.js"
export * as ReactRuntime from "./ReactRuntime.js" export * as ReactRuntime from "./ReactRuntime.js"
export * as Result from "./Result.js"
export * as SetStateAction from "./SetStateAction.js" export * as SetStateAction from "./SetStateAction.js"
export * as Stream from "./Stream.js" export * as Stream from "./Stream.js"
export * as SubmittableForm from "./SubmittableForm.js" export * as View from "./View.js"
export * as Subscribable from "./Subscribable.js"
export * as SynchronizedForm from "./SynchronizedForm.js"
-3
View File
@@ -1,3 +0,0 @@
export type ExcludeKeys<T, K extends PropertyKey> = K extends keyof T ? (
{ [P in K]?: never } & Omit<T, K>
) : T
@@ -1,10 +1,10 @@
import { render, screen, waitFor } from "@testing-library/react" import { render, screen, waitFor } from "@testing-library/react"
import { Effect, Fiber, Layer, Stream, SubscriptionRef } from "effect" import { Effect, Layer, SubscriptionRef } from "effect"
import { Lens } from "effect-lens" import { Lens } from "effect-lens"
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import * as Component from "../src/Component.js" import * as Component from "../src/Component.js"
import * as ReactRuntime from "../src/ReactRuntime.js" import * as ReactRuntime from "../src/ReactRuntime.js"
import * as Subscribable from "../src/Subscribable.js" import * as View from "../src/View.js"
const makeRuntime = async () => { const makeRuntime = async () => {
@@ -18,42 +18,7 @@ const makeRuntime = async () => {
} }
} }
describe("Subscribable", () => { describe("View", () => {
it("zipLatestAll reads current values from all inputs", async () => {
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
const left = Lens.fromSubscriptionRef(leftRef)
const right = Lens.fromSubscriptionRef(rightRef)
const zipped = Subscribable.zipLatestAll(left, right)
expect(await Effect.runPromise(zipped.get)).toEqual([1, "a"])
})
it("zipLatestAll emits updates when any input changes", async () => {
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
const left = Lens.fromSubscriptionRef(leftRef)
const right = Lens.fromSubscriptionRef(rightRef)
const zipped = Subscribable.zipLatestAll(left, right)
const values: Array<readonly [number, string]> = []
const collector = Effect.runFork(Effect.scoped(zipped.changes.pipe(
Stream.runForEach(value => Effect.sync(() => {
values.push(value as readonly [number, string])
})),
)))
await Effect.runPromise(Lens.set(left, 2))
await waitFor(() => expect(values).toContainEqual([2, "a"]))
await Effect.runPromise(Lens.set(right, "b"))
await waitFor(() => expect(values).toContainEqual([2, "b"]))
await Effect.runPromise(Fiber.interrupt(collector))
})
it("useAll returns the latest values and rerenders when any input changes", async () => { it("useAll returns the latest values and rerenders when any input changes", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime() const { runtime, effectRuntime, dispose } = await makeRuntime()
const countRef = await Effect.runPromise(SubscriptionRef.make(1)) const countRef = await Effect.runPromise(SubscriptionRef.make(1))
@@ -61,8 +26,8 @@ describe("Subscribable", () => {
const count = Lens.fromSubscriptionRef(countRef) const count = Lens.fromSubscriptionRef(countRef)
const label = Lens.fromSubscriptionRef(labelRef) const label = Lens.fromSubscriptionRef(labelRef)
const Probe = Component.makeUntraced("SubscribableUseAllProbe")(function*() { const Probe = Component.makeUntraced("ViewUseAllProbe")(function*() {
const [currentCount, currentLabel] = yield* Subscribable.useAll([count, label]) const [currentCount, currentLabel] = yield* View.useAll([count, label])
return <div>{`${currentCount}:${currentLabel}`}</div> return <div>{`${currentCount}:${currentLabel}`}</div>
}).pipe( }).pipe(
@@ -94,8 +59,8 @@ describe("Subscribable", () => {
const item = Lens.fromSubscriptionRef(itemRef) const item = Lens.fromSubscriptionRef(itemRef)
const flag = Lens.fromSubscriptionRef(flagRef) const flag = Lens.fromSubscriptionRef(flagRef)
const Probe = Component.makeUntraced("SubscribableUseAllEquivalenceProbe")(function*() { const Probe = Component.makeUntraced("ViewUseAllEquivalenceProbe")(function*() {
const [currentItem, currentFlag] = yield* Subscribable.useAll([item, flag], { const [currentItem, currentFlag] = yield* View.useAll([item, flag], {
equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) => equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) =>
selfItem.id === thatItem.id && selfFlag === thatFlag, selfItem.id === thatItem.id && selfFlag === thatFlag,
}) })
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Effect FC Next Example</title> <title>Vite + React + TS</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+9 -3
View File
@@ -14,6 +14,9 @@
"clean:modules": "rm -rf node_modules" "clean:modules": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/react-router": "^1.170.10",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.13",
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
@@ -23,12 +26,15 @@
"vite": "^8.0.16" "vite": "^8.0.16"
}, },
"dependencies": { "dependencies": {
"effect": "4.0.0-beta.85", "@effect/platform-browser": "4.0.0-beta.98",
"effect-fc-next": "workspace:*" "@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.98",
"effect-fc-next": "workspace:*",
"react-icons": "^5.6.0"
}, },
"overrides": { "overrides": {
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"effect": "4.0.0-beta.85", "effect": "4.0.0-beta.98",
"react": "^19.2.6" "react": "^19.2.6"
} }
} }
-28
View File
@@ -1,28 +0,0 @@
:root {
color: #17202a;
font-family: system-ui, sans-serif;
background: #f4f6f7;
}
body {
display: grid;
min-width: 320px;
min-height: 100vh;
margin: 0;
place-items: center;
}
main {
padding: 2rem;
text-align: center;
}
button {
padding: 0.65rem 1rem;
border: 0;
border-radius: 0.5rem;
color: white;
font: inherit;
background: #7d3c98;
cursor: pointer;
}
@@ -0,0 +1,56 @@
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, View } from "effect-fc-next"
import type * as React from "react"
export declare namespace TextFieldFormInputView {
export interface Props<out P extends readonly PropertyKey[], A, ER, EW>
extends Omit<TextField.RootProps, "form">, Form.useInput.Options {
readonly form: Form.Form<P, A, string, ER, EW>
}
export type Signature = <P extends readonly PropertyKey[], A, ER, EW>(props: Props<P, A, ER, EW>) => React.ReactNode
}
export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(function*(
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
) {
const input = yield* Form.useInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* View.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
])
return (
<Flex direction="column" gap="1">
<TextField.Root
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={isCommitting}
{...Struct.omit(props, ["form"])}
>
{isValidating &&
<TextField.Slot side="right">
<Spinner />
</TextField.Slot>
}
{props.children}
</TextField.Root>
{Option.match(Array.head(issues), {
onSome: issue => (
<Callout.Root>
<Callout.Text>{issue.message}</Callout.Text>
</Callout.Root>
),
onNone: () => <></>,
})}
</Flex>
)
}).pipe(
Component.withSignature<TextFieldFormInputView.Signature>()
)
@@ -0,0 +1,64 @@
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, View } from "effect-fc-next"
import type * as React from "react"
export declare namespace TextFieldOptionalFormInputView {
export interface Props<out P extends readonly PropertyKey[], A, ER, EW>
extends Omit<TextField.RootProps, "form" | "defaultValue">, Form.useOptionalInput.Options<string> {
readonly form: Form.Form<P, A, Option.Option<string>, ER, EW>
}
export type Signature = <P extends readonly PropertyKey[], A, ER, EW>(props: Props<P, A, ER, EW>) => React.ReactNode
}
export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalFormInputView")(function*(
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
) {
const input = yield* Form.useOptionalInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* View.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
])
return (
<Flex direction="column" gap="1">
<TextField.Root
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={!input.enabled || isCommitting}
{...Struct.omit(props, ["form", "defaultValue"])}
>
<TextField.Slot side="left">
<Switch
size="1"
checked={input.enabled}
onCheckedChange={input.setEnabled}
/>
</TextField.Slot>
{isValidating &&
<TextField.Slot side="right">
<Spinner />
</TextField.Slot>
}
{props.children}
</TextField.Root>
{Option.match(Array.head(issues), {
onSome: issue => (
<Callout.Root>
<Callout.Text>{issue.message}</Callout.Text>
</Callout.Root>
),
onNone: () => <></>,
})}
</Flex>
)
}).pipe(
Component.withSignature<TextFieldOptionalFormInputView.Signature>()
)
+9 -28
View File
@@ -1,40 +1,21 @@
import { Effect, Layer, SubscriptionRef } from "effect" import { createRouter, RouterProvider } from "@tanstack/react-router"
import { Component, Lens, ReactRuntime, Subscribable } from "effect-fc-next" import { ReactRuntime } from "effect-fc-next"
import { StrictMode } from "react" import { StrictMode } from "react"
import { createRoot } from "react-dom/client" import { createRoot } from "react-dom/client"
import "./index.css" import { routeTree } from "./routeTree.gen"
import { runtime } from "./runtime"
const router = createRouter({ routeTree })
const Counter = Component.make("Counter")(function*() { declare module "@tanstack/react-router" {
const count = yield* Component.useOnMount(() => Effect.map( interface Register { router: typeof router }
SubscriptionRef.make(0), }
Lens.fromSubscriptionRef,
))
const [value] = yield* Subscribable.useAll([count])
const increment = yield* Component.useCallbackSync(
() => Lens.update(count, n => n + 1),
[count],
)
return (
<main>
<h1>Effect FC Next</h1>
<p>Running on Effect V4.</p>
<button type="button" onClick={increment}>
Count: {value}
</button>
</main>
)
})
const runtime = ReactRuntime.make(Layer.empty)
const CounterApp = Counter.pipe(Component.withRuntime(runtime.context))
// biome-ignore lint/style/noNonNullAssertion: the Vite template provides this element // biome-ignore lint/style/noNonNullAssertion: the Vite template provides this element
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ReactRuntime.Provider runtime={runtime}> <ReactRuntime.Provider runtime={runtime}>
<CounterApp /> <RouterProvider router={router} />
</ReactRuntime.Provider> </ReactRuntime.Provider>
</StrictMode>, </StrictMode>,
) )
+15
View File
@@ -0,0 +1,15 @@
import { Effect } from "effect"
export interface Post {
readonly title: string
readonly body: string
}
export const fetchPost = (id: number) => Effect.tryPromise({
try: () =>
fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
.then(response => response.json() as Promise<Post>),
catch: () => new Error("Unable to fetch post"),
})
+149
View File
@@ -0,0 +1,149 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AsyncRouteImport } from './routes/async'
import { Route as BlankRouteImport } from './routes/blank'
import { Route as FormRouteImport } from './routes/form'
import { Route as QueryRouteImport } from './routes/query'
import { Route as ResultRouteImport } from './routes/result'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AsyncRoute = AsyncRouteImport.update({
id: '/async',
path: '/async',
getParentRoute: () => rootRouteImport,
} as any)
const BlankRoute = BlankRouteImport.update({
id: '/blank',
path: '/blank',
getParentRoute: () => rootRouteImport,
} as any)
const FormRoute = FormRouteImport.update({
id: '/form',
path: '/form',
getParentRoute: () => rootRouteImport,
} as any)
const QueryRoute = QueryRouteImport.update({
id: '/query',
path: '/query',
getParentRoute: () => rootRouteImport,
} as any)
const ResultRoute = ResultRouteImport.update({
id: '/result',
path: '/result',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/query': typeof QueryRoute
'/result': typeof ResultRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/query': typeof QueryRoute
'/result': typeof ResultRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/query': typeof QueryRoute
'/result': typeof ResultRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/async' | '/blank' | '/form' | '/query' | '/result'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/async' | '/blank' | '/form' | '/query' | '/result'
id: '__root__' | '/' | '/async' | '/blank' | '/form' | '/query' | '/result'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AsyncRoute: typeof AsyncRoute
BlankRoute: typeof BlankRoute
FormRoute: typeof FormRoute
QueryRoute: typeof QueryRoute
ResultRoute: typeof ResultRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/async': {
id: '/async'
path: '/async'
fullPath: '/async'
preLoaderRoute: typeof AsyncRouteImport
parentRoute: typeof rootRouteImport
}
'/blank': {
id: '/blank'
path: '/blank'
fullPath: '/blank'
preLoaderRoute: typeof BlankRouteImport
parentRoute: typeof rootRouteImport
}
'/form': {
id: '/form'
path: '/form'
fullPath: '/form'
preLoaderRoute: typeof FormRouteImport
parentRoute: typeof rootRouteImport
}
'/query': {
id: '/query'
path: '/query'
fullPath: '/query'
preLoaderRoute: typeof QueryRouteImport
parentRoute: typeof rootRouteImport
}
'/result': {
id: '/result'
path: '/result'
fullPath: '/result'
preLoaderRoute: typeof ResultRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AsyncRoute: AsyncRoute,
BlankRoute: BlankRoute,
FormRoute: FormRoute,
QueryRoute: QueryRoute,
ResultRoute: ResultRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
@@ -0,0 +1,32 @@
import { Container, Flex, Theme } from "@radix-ui/themes"
import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import "@radix-ui/themes/styles.css"
import "../index.css"
export const Route = createRootRoute({
component: Root,
})
function Root() {
return (
<Theme>
<Container mb="4">
<Flex direction="row" justify="center" align="center" gap="2">
<Link to="/">Index</Link>
<Link to="/blank">Blank</Link>
<Link to="/async">Async</Link>
<Link to="/query">Query</Link>
<Link to="/result">Result</Link>
<Link to="/form">Form</Link>
</Flex>
</Container>
<Outlet />
<TanStackRouterDevtools />
</Theme>
)
}
@@ -0,0 +1,64 @@
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Async, Component, Memoized } from "effect-fc-next"
import * as React from "react"
import { fetchPost } from "@/post"
import { runtime } from "@/runtime"
interface AsyncFetchPostViewProps {
readonly id: number
}
const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*(
props: AsyncFetchPostViewProps,
) {
const post = yield* Component.useOnChange(
() => fetchPost(props.id),
[props.id],
)
return (
<div>
<Heading>{post.title}</Heading>
<Text>{post.body}</Text>
</div>
)
}).pipe(
Async.async,
Async.withOptions({ defaultFallback: <Text>Loading post...</Text> }),
Memoized.memoized,
)
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
const [text, setText] = React.useState("Typing here should not trigger a refetch of the post")
const [id, setId] = React.useState(1)
const AsyncFetchPost = yield* AsyncFetchPostView.use
return (
<Container>
<Flex direction="column" align="stretch" gap="2">
<TextField.Root
value={text}
onChange={event => setText(event.currentTarget.value)}
/>
<Slider
value={[id]}
min={1}
max={10}
onValueChange={([value]) => setId(value ?? 1)}
/>
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
</Flex>
</Container>
)
}).pipe(
Component.withRuntime(runtime.context),
)
export const Route = createFileRoute("/async")({
component: AsyncRouteComponent,
})
@@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/blank")({
component: RouteComponent
})
function RouteComponent() {
return <div>Hello "/blank"!</div>
}
+63
View File
@@ -0,0 +1,63 @@
import { Button, Container, Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Effect, Schema } from "effect"
import { Component, Form, MutationForm, View } from "effect-fc-next"
import { runtime } from "@/runtime"
const RegisterSchema = Schema.Struct({
email: Schema.String,
password: Schema.String,
})
const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() {
const form = yield* Component.useOnMount(() => MutationForm.service({
schema: RegisterSchema,
initialEncodedValue: { email: "", password: "" },
f: ([value]) => Effect.log(`Registered ${value.email}`),
}))
const emailField = yield* Form.useInput(
Form.focusObjectOn(form, "email"),
)
const passwordField = yield* Form.useInput(
Form.focusObjectOn(form, "password"),
)
const [canCommit, isCommitting] = yield* View.useAll([
form.canCommit,
form.isCommitting,
])
const runPromise = yield* Component.useRunPromise()
return (
<Container width="300">
<form onSubmit={(event) => {
event.preventDefault()
void runPromise(form.submit)
}}>
<Flex direction="column" gap="2">
<TextField.Root
value={emailField.value}
onChange={event => emailField.setValue(event.currentTarget.value)}
placeholder="Email"
/>
<TextField.Root
value={passwordField.value}
onChange={event => passwordField.setValue(event.currentTarget.value)}
placeholder="Password"
type="password"
/>
<Button disabled={!canCommit || isCommitting}>
{isCommitting ? "Submitting…" : "Submit"}
</Button>
</Flex>
</form>
<Text size="2">A MutationForm validates local input, then submits it.</Text>
</Container>
)
}).pipe(
Component.withRuntime(runtime.context),
)
export const Route = createFileRoute("/form")({
component: RegisterRouteComponent,
})
@@ -0,0 +1,56 @@
import { Button, Container, Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Effect, SubscriptionRef } from "effect"
import { Component, Lens, View } from "effect-fc-next"
import { runtime } from "@/runtime"
const TodoRouteComponent = Component.make("TodoRouteView")(function*() {
const todosLens = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make<readonly string[]>([]),
Lens.fromSubscriptionRef,
))
const draftLens = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make(""),
Lens.fromSubscriptionRef,
))
const [todos] = yield* View.useAll([todosLens])
const [draft, setDraft] = yield* Lens.useState(draftLens)
const runPromise = yield* Component.useRunPromise()
const addTodo = Lens.update(todosLens, todos =>
draft.trim() === ""
? todos
: [...todos, draft.trim()],
).pipe(
Effect.andThen(Lens.set(draftLens, "")),
)
return (
<Container width="480">
<Flex direction="column" gap="3">
<Text size="2">A small Effect v4 todo state example backed by a Lens.</Text>
<Flex gap="2">
<TextField.Root
value={draft}
onChange={event => setDraft(event.currentTarget.value)}
/>
<Button onClick={() => void runPromise(addTodo)}>
Add
</Button>
</Flex>
{todos.map(todo => <Text key={todo}> {todo}</Text>)}
</Flex>
</Container>
)
}).pipe(
Component.withRuntime(runtime.context),
)
export const Route = createFileRoute("/")({
component: TodoRouteComponent,
})
@@ -0,0 +1,85 @@
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Effect, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import { Component, Lens, Mutation, Query, View } from "effect-fc-next"
import { fetchPost, type Post } from "@/post"
import { runtime } from "@/runtime"
interface PostResultViewProps {
readonly result: AsyncResult.AsyncResult<Post, Error>
}
const PostResultView = (props: PostResultViewProps) =>
AsyncResult.match(props.result, {
onInitial: () => <Text>Loading...</Text>,
onFailure: () => <Text>Request failed.</Text>,
onSuccess: result => (
<>
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
</>
),
})
const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
const query = yield* Query.service({
key: idLens,
f: fetchPost,
staleTime: "10 seconds",
})
const mutation = yield* Mutation.make({
f: fetchPost,
})
return [idLens, query, mutation] as const
}))
const [id] = yield* View.useAll([idLens])
const [queryState, mutationResult] = yield* View.useAll([
query.state,
mutation.state,
])
const runPromise = yield* Component.useRunPromise()
return (
<Container>
<Flex direction="column" align="center" gap="2">
<Slider
value={[id]}
min={1}
max={10}
onValueChange={([value]) =>
void runPromise(Lens.set(idLens, value ?? 1))
}
/>
<PostResultView result={queryState.result} />
<Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => void runPromise(query.refresh)}>
Refresh
</Button>
<Button onClick={() => void runPromise(query.invalidateCache)}>
Invalidate cache
</Button>
</Flex>
<PostResultView result={mutationResult} />
<Button onClick={() => void runPromise(mutation.mutate(id))}>
Mutate
</Button>
</Flex>
</Container>
)
}).pipe(
Component.withRuntime(runtime.context),
)
export const Route = createFileRoute("/query")({
component: QueryRouteComponent,
})
@@ -0,0 +1,45 @@
import { Button, Container, Flex, Heading, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { AsyncResult } from "effect/unstable/reactivity"
import { Component, Mutation, View } from "effect-fc-next"
import { fetchPost, type Post } from "@/post"
import { runtime } from "@/runtime"
const PostResultView = (props: { readonly result: AsyncResult.AsyncResult<Post, Error> }) =>
AsyncResult.match(props.result, {
onInitial: () => <Text>Ready to load.</Text>,
onFailure: () => <Text>Request failed.</Text>,
onSuccess: result => (
<>
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
</>
),
})
const ResultRouteComponent = Component.make("ResultRouteView")(function*() {
const mutation = yield* Component.useOnMount(() => Mutation.make({
f: (_: undefined) => fetchPost(1),
}))
const [result] = yield* View.useAll([mutation.state])
const runPromise = yield* Component.useRunPromise()
return (
<Container>
<Flex direction="column" gap="2">
<Button onClick={() => void runPromise(mutation.mutate(undefined))}>
Load post
</Button>
<PostResultView result={result} />
</Flex>
</Container>
)
}).pipe(
Component.withRuntime(runtime.context),
)
export const Route = createFileRoute("/result")({
component: ResultRouteComponent,
})
+4
View File
@@ -0,0 +1,4 @@
import { QueryClient, ReactRuntime } from "effect-fc-next"
export const runtime = ReactRuntime.make(QueryClient.layer())
+4
View File
@@ -22,6 +22,10 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,
"paths": {
"@/*": ["./src/*"]
},
"plugins": [ "plugins": [
{ "name": "@effect/language-service" } { "name": "@effect/language-service" }
] ]
+12
View File
@@ -1,10 +1,22 @@
import { tanstackRouter } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react" import react from "@vitejs/plugin-react"
import path from "node:path"
import { defineConfig } from "vite" import { defineConfig } from "vite"
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
tanstackRouter({
target: "react",
autoCodeSplitting: true,
}),
react(), react(),
], ],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
}) })