Compare commits
12 Commits
3c048f1dae
...
599546788d
| Author | SHA1 | Date | |
|---|---|---|---|
| 599546788d | |||
| 31d87ffcab | |||
| 885cff74b3 | |||
| cbb050673b | |||
| 9b25ef4b57 | |||
| be1038fc8d | |||
| 956c77d45b | |||
| 985556b430 | |||
| feced4732f | |||
| cc64ae785d | |||
| 3fc3904c6c | |||
| 23cdfb3894 |
+1
-1
@@ -21,6 +21,6 @@
|
||||
"npm-check-updates": "^22.2.1",
|
||||
"npm-sort": "^0.0.4",
|
||||
"turbo": "^2.9.16",
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^7.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@docusaurus/types": "3.10.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "~6.0.3"
|
||||
"typescript": "~7.0.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -39,17 +39,19 @@
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-browser": "^4.0.0-beta.85",
|
||||
"@effect/platform-browser": "4.0.0-beta.98",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"effect": "4.0.0-beta.98",
|
||||
"jsdom": "^26.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
"effect": "4.0.0-beta.85",
|
||||
"effect": "4.0.0-beta.98",
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect-lens": "2.0.0-beta.0"
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"effect-lens": "^2.0.0-beta.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 type * as React from "react"
|
||||
import * as Component from "./Component.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 type FormTypeId = typeof FormTypeId
|
||||
|
||||
export interface FormIssue {
|
||||
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>
|
||||
export interface Form<out P extends readonly PropertyKey[], out A, in out I = A, out ER = never, out EW = never>
|
||||
extends Pipeable.Pipeable {
|
||||
readonly [FormTypeId]: FormTypeId
|
||||
|
||||
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 issues: Subscribable.Subscribable<readonly FormIssue[], never, never>
|
||||
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
|
||||
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
|
||||
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
|
||||
readonly issues: View.View<readonly StandardSchemaV1.Issue[], never, never>
|
||||
readonly isValidating: View.View<boolean, ER, never>
|
||||
readonly canCommit: View.View<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> {
|
||||
readonly [FormTypeId]: FormTypeId = FormTypeId
|
||||
|
||||
constructor(
|
||||
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 issues: Subscribable.Subscribable<readonly FormIssue[], never, never>,
|
||||
readonly isValidating: Subscribable.Subscribable<boolean, never, never>,
|
||||
readonly canCommit: Subscribable.Subscribable<boolean, never, never>,
|
||||
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>,
|
||||
readonly issues: View.View<readonly StandardSchemaV1.Issue[], never, never>,
|
||||
readonly isValidating: View.View<boolean, never, never>,
|
||||
readonly canCommit: View.View<boolean, never, never>,
|
||||
readonly isCommitting: View.View<boolean, never, never>,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
|
||||
export const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
|
||||
|
||||
|
||||
const filterIssuesByPath = (
|
||||
issues: readonly FormIssue[],
|
||||
issues: readonly StandardSchemaV1.Issue[],
|
||||
path: readonly PropertyKey[],
|
||||
): readonly FormIssue[] => Array.filter(issues, issue =>
|
||||
issue.path.length >= path.length && Array.every(path, (p, i) => p === issue.path[i])
|
||||
)
|
||||
): readonly StandardSchemaV1.Issue[] => Array.filter(issues, issue => {
|
||||
const issuePath = issue.path
|
||||
if (!issuePath) return false
|
||||
return issuePath.length >= path.length && Array.every(path, (p, i) => p === issuePath[i])
|
||||
})
|
||||
|
||||
export const focusObjectOn: {
|
||||
<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>,
|
||||
key: K,
|
||||
): 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
|
||||
|
||||
return new FormImpl(
|
||||
path,
|
||||
Subscribable.mapOption(form.value, a => a[key]),
|
||||
View.mapOption(form.value, a => a[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.canCommit,
|
||||
form.isCommitting,
|
||||
@@ -92,14 +89,14 @@ export const focusArrayAt: {
|
||||
self: Form<P, A, I, ER, EW>,
|
||||
index: number,
|
||||
): 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
|
||||
|
||||
return new FormImpl(
|
||||
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),
|
||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||
View.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||
form.isValidating,
|
||||
form.canCommit,
|
||||
form.isCommitting,
|
||||
@@ -118,14 +115,14 @@ export const focusTupleAt: {
|
||||
self: Form<P, A, I, ER, EW>,
|
||||
index: K,
|
||||
): 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
|
||||
|
||||
return new FormImpl(
|
||||
path,
|
||||
Subscribable.mapOption(form.value, values => values[index]),
|
||||
View.mapOption(form.value, Array.getUnsafe(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.canCommit,
|
||||
form.isCommitting,
|
||||
@@ -144,14 +141,14 @@ export const focusChunkAt: {
|
||||
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
|
||||
index: number,
|
||||
): 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
|
||||
|
||||
return new FormImpl(
|
||||
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),
|
||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||
View.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||
form.isValidating,
|
||||
form.canCommit,
|
||||
form.isCommitting,
|
||||
@@ -182,17 +179,15 @@ export const useInput = Effect.fnUntraced(function* <P extends readonly Property
|
||||
|
||||
yield* Effect.forkScoped(Effect.all([
|
||||
Stream.runForEach(
|
||||
Stream.drop(form.encodedValue.changes, 1),
|
||||
upstreamEncodedValue => Effect.flatMap(
|
||||
Lens.get(internalValueLens),
|
||||
internalValue => !Equal.equals(upstreamEncodedValue, internalValue)
|
||||
? Lens.set(internalValueLens, upstreamEncodedValue)
|
||||
: Effect.succeed(undefined),
|
||||
Stream.drop(Lens.changes(form.encodedValue), 1),
|
||||
upstreamEncodedValue => Effect.when(
|
||||
Lens.set(internalValueLens, upstreamEncodedValue),
|
||||
Effect.map(Lens.get(internalValueLens), internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
|
||||
),
|
||||
),
|
||||
|
||||
Stream.runForEach(
|
||||
internalValueLens.changes.pipe(
|
||||
Lens.changes(internalValueLens).pipe(
|
||||
Stream.drop(1),
|
||||
Stream.changesWith(Equal.asEquivalence()),
|
||||
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([
|
||||
Stream.runForEach(
|
||||
Stream.drop(field.encodedValue.changes, 1),
|
||||
Stream.drop(Lens.changes(field.encodedValue), 1),
|
||||
|
||||
upstreamEncodedValue => Effect.flatMap(
|
||||
Effect.all([Lens.get(enabledLens), Lens.get(internalValueLens)]),
|
||||
([enabled, internalValue]) => Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none())
|
||||
? Effect.succeed(undefined)
|
||||
: Option.match(upstreamEncodedValue, {
|
||||
upstreamEncodedValue => Effect.when(
|
||||
Option.match(upstreamEncodedValue, {
|
||||
onSome: v => Effect.andThen(
|
||||
Lens.set(enabledLens, true),
|
||||
Lens.set(internalValueLens, v),
|
||||
@@ -256,11 +248,16 @@ export const useOptionalInput = Effect.fnUntraced(function* <P extends readonly
|
||||
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(
|
||||
enabledLens.changes.pipe(
|
||||
Lens.changes(enabledLens).pipe(
|
||||
Stream.zipLatest(internalValueLens.changes),
|
||||
Stream.drop(1),
|
||||
Stream.changesWith(Equal.asEquivalence()),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -1,26 +1,26 @@
|
||||
import { type Context, Effect, Equal, Exit, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
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 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 {
|
||||
readonly [MutationTypeId]: MutationTypeId
|
||||
|
||||
readonly context: Context.Context<Scope.Scope | R>
|
||||
readonly f: (key: K) => Effect.Effect<A, E, R>
|
||||
|
||||
readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
|
||||
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
|
||||
readonly result: Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>>
|
||||
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>
|
||||
readonly latestKey: View.View<Option.Option<K>>
|
||||
readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
|
||||
readonly state: View.View<AsyncResult.AsyncResult<A, E>>
|
||||
readonly latestFinalResult: View.View<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>
|
||||
|
||||
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)
|
||||
@@ -36,7 +36,7 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
|
||||
|
||||
readonly latestKey: Lens.Lens<Option.Option<K>>,
|
||||
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
|
||||
readonly 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>>>,
|
||||
) {
|
||||
super()
|
||||
@@ -49,7 +49,7 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
|
||||
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(
|
||||
Effect.andThen(this.start(key)),
|
||||
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<
|
||||
Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>>,
|
||||
View.View<AsyncResult.AsyncResult<A, E>>,
|
||||
never,
|
||||
Scope.Scope | R
|
||||
> {
|
||||
@@ -114,13 +114,13 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
|
||||
}
|
||||
|
||||
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>> {
|
||||
return state.get.pipe(
|
||||
return View.get(state).pipe(
|
||||
Effect.andThen(initial => Stream.runFoldEffect(
|
||||
state.changes,
|
||||
View.changes(state),
|
||||
() => 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>>),
|
||||
Effect.tap(result => Lens.set(this.latestFinalResult, Option.some(result))),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -3,6 +3,8 @@ import type * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
|
||||
|
||||
export * from "effect/PubSub"
|
||||
|
||||
export const useFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
|
||||
values: A
|
||||
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
|
||||
@@ -13,5 +15,3 @@ export const useFromReactiveValues = Effect.fnUntraced(function* <const A extend
|
||||
), values)
|
||||
return pubsub
|
||||
})
|
||||
|
||||
export * from "effect/PubSub"
|
||||
|
||||
@@ -2,44 +2,44 @@ import { type Cause, type Context, Duration, Effect, Equal, type Equivalence, Ex
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
import * as Lens from "./Lens.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 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 {
|
||||
readonly [QueryTypeId]: QueryTypeId
|
||||
|
||||
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 f: (key: K) => Effect.Effect<A, E, R>
|
||||
|
||||
readonly staleTime: Duration.Duration
|
||||
readonly refreshOnWindowFocus: boolean
|
||||
|
||||
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
|
||||
readonly state: Subscribable.Subscribable<QueryState<K, A, E>>
|
||||
readonly latestFinalState: Subscribable.Subscribable<Option.Option<FinalQueryState<K, A, E>>>
|
||||
readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
|
||||
readonly state: View.View<QueryState<K, A, E>>
|
||||
readonly latestFinalState: View.View<Option.Option<FinalQueryState<K, A, E>>>
|
||||
|
||||
readonly run: Effect.Effect<void>
|
||||
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 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>
|
||||
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 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 result: AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>
|
||||
}
|
||||
@@ -53,7 +53,7 @@ extends Pipeable.Class implements Query<K, A, E, R> {
|
||||
|
||||
constructor(
|
||||
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 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> {
|
||||
return Effect.all([
|
||||
Stream.runFoldEffect(
|
||||
Stream.runForEach(
|
||||
this.key.changes,
|
||||
() => Option.none<K>(),
|
||||
(previous, key) => Effect.as(
|
||||
Option.isSome(previous) && this.keyEquivalence(key, previous.value)
|
||||
? this.refreshSubscribable
|
||||
: this.fetchSubscribable(key),
|
||||
Option.some(key),
|
||||
),
|
||||
key => Effect.gen({ self: this }, function*() {
|
||||
yield* this.interrupt
|
||||
const latestFinalState = yield* Lens.get(this.latestFinalState)
|
||||
|
||||
const state = yield* this.startCached(
|
||||
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.flatMap(({ BrowserStream }) => this.refreshOnWindowFocus
|
||||
? Stream.runForEach(
|
||||
BrowserStream.fromEventListenerWindow("focus"),
|
||||
() => this.refreshSubscribable,
|
||||
() => this.refreshView,
|
||||
)
|
||||
: 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> {
|
||||
return Effect.gen({ self: this }, function*() {
|
||||
yield* this.interrupt
|
||||
yield* Lens.set(this.key, key)
|
||||
|
||||
const state = yield* this.startCached({
|
||||
key,
|
||||
result: AsyncResult.initial(false),
|
||||
@@ -121,14 +127,12 @@ extends Pipeable.Class implements Query<K, A, E, R> {
|
||||
)
|
||||
}
|
||||
|
||||
fetchSubscribable(key: K): Effect.Effect<
|
||||
Subscribable.Subscribable<QueryState<K, A, E>>,
|
||||
fetchView(key: K): Effect.Effect<
|
||||
View.View<QueryState<K, A, E>>,
|
||||
Cause.NoSuchElementError
|
||||
> {
|
||||
return Effect.gen({ self: this }, function*() {
|
||||
yield* this.interrupt
|
||||
yield* Lens.set(this.key, key)
|
||||
|
||||
const state = yield* this.startCached({
|
||||
key,
|
||||
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> {
|
||||
return Effect.gen({ self: this }, function*() {
|
||||
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 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
|
||||
: {
|
||||
key: latestKey,
|
||||
key: latestState.key,
|
||||
result: AsyncResult.initial(false),
|
||||
}
|
||||
)
|
||||
@@ -162,20 +166,20 @@ extends Pipeable.Class implements Query<K, A, E, R> {
|
||||
)
|
||||
}
|
||||
|
||||
get refreshSubscribable(): Effect.Effect<
|
||||
Subscribable.Subscribable<QueryState<K, A, E>>,
|
||||
get refreshView(): Effect.Effect<
|
||||
View.View<QueryState<K, A, E>>,
|
||||
Cause.NoSuchElementError
|
||||
> {
|
||||
return Effect.gen({ self: this }, function*() {
|
||||
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 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
|
||||
: {
|
||||
key: latestKey,
|
||||
key: latestState.key,
|
||||
result: AsyncResult.initial(false),
|
||||
}
|
||||
)
|
||||
@@ -190,7 +194,7 @@ extends Pipeable.Class implements Query<K, A, E, R> {
|
||||
startCached(
|
||||
previous: QueryState<K, A, E>,
|
||||
): Effect.Effect<
|
||||
Subscribable.Subscribable<QueryState<K, A, E>>,
|
||||
View.View<QueryState<K, A, E>>,
|
||||
Cause.NoSuchElementError,
|
||||
Scope.Scope | QueryClient.QueryClient | R
|
||||
> {
|
||||
@@ -202,7 +206,7 @@ extends Pipeable.Class implements Query<K, A, E, R> {
|
||||
key: previous.key,
|
||||
result: entry.result as AsyncResult.AsyncResult<A, E>,
|
||||
})
|
||||
: Effect.succeed(Subscribable.make({
|
||||
: Effect.succeed(View.make({
|
||||
get: Effect.succeed({
|
||||
key: previous.key,
|
||||
result: entry.result as AsyncResult.AsyncResult<A, E>,
|
||||
@@ -222,15 +226,15 @@ extends Pipeable.Class implements Query<K, A, E, R> {
|
||||
start(
|
||||
previous: QueryState<K, A, E>,
|
||||
): Effect.Effect<
|
||||
Subscribable.Subscribable<QueryState<K, A, E>>,
|
||||
View.View<QueryState<K, A, E>>,
|
||||
never,
|
||||
Scope.Scope | R
|
||||
> {
|
||||
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(
|
||||
Lens.update(subscribable, previous => AsyncResult.match(previous.result, {
|
||||
Lens.update(state, previous => AsyncResult.match(previous.result, {
|
||||
onInitial: () => ({
|
||||
key: previous.key,
|
||||
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(
|
||||
subscribable,
|
||||
state,
|
||||
previous => Exit.match(exit, {
|
||||
onSuccess: v => ({
|
||||
key: previous.key,
|
||||
@@ -291,17 +295,17 @@ extends Pipeable.Class implements Query<K, A, E, R> {
|
||||
))
|
||||
|
||||
yield* Lens.set(this.fiber, Option.some(fiber))
|
||||
return subscribable
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
subscribable: Subscribable.Subscribable<QueryState<K, A, E>>
|
||||
view: View.View<QueryState<K, A, E>>
|
||||
): Effect.Effect<FinalQueryState<K, A, E>, never, QueryClient.QueryClient> {
|
||||
return Effect.gen({ self: this }, function*() {
|
||||
const initial = yield* subscribable.get
|
||||
const initial = yield* View.get(view)
|
||||
const final = yield* Stream.runFoldEffect(
|
||||
subscribable.changes,
|
||||
View.changes(view),
|
||||
() => initial,
|
||||
(_, state) => Effect.as(Lens.set(this.state, state), state),
|
||||
) 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 interface Options<K, A, E = never, R = never> {
|
||||
readonly key: Lens.Lens<K>,
|
||||
readonly key: View.View<K>,
|
||||
readonly keyEquivalence?: Equivalence.Equivalence<K>,
|
||||
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<QueryState<K, A, E>>({
|
||||
key: yield* Lens.get(options.key),
|
||||
key: yield* View.get(options.key),
|
||||
result: AsyncResult.initial(false),
|
||||
})),
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<FinalQueryState<K, A, E>>())),
|
||||
|
||||
@@ -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 * 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")
|
||||
@@ -10,7 +10,7 @@ export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
|
||||
export interface QueryClientService extends Pipeable.Pipeable {
|
||||
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
|
||||
|
||||
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
|
||||
readonly cache: View.View<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
|
||||
readonly cacheGcTime: Duration.Duration
|
||||
readonly defaultStaleTime: Duration.Duration
|
||||
readonly defaultRefreshOnWindowFocus: boolean
|
||||
@@ -126,6 +126,8 @@ export const service = (
|
||||
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 type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import { type Context, Effect, Layer, ManagedRuntime, Predicate } from "effect"
|
||||
import * as React from "react"
|
||||
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")
|
||||
@@ -16,18 +14,9 @@ export interface ReactRuntime<R, ER> {
|
||||
readonly context: React.Context<Context.Context<R>>
|
||||
}
|
||||
|
||||
const ReactRuntimeProto = 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,
|
||||
)
|
||||
const ReactRuntimePrototype = Object.freeze({ [ReactRuntimeTypeId]: ReactRuntimeTypeId } as const)
|
||||
|
||||
export const preludeLayer: Layer.Layer<Component.ScopeMap> = Component.ScopeMap.layer
|
||||
|
||||
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(
|
||||
Object.assign(function() {}, {
|
||||
runtime: ManagedRuntime.make(
|
||||
Layer.merge(layer, preludeLayer),
|
||||
Layer.mergeAll(preludeLayer, layer),
|
||||
{ memoMap },
|
||||
),
|
||||
// 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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -3,6 +3,8 @@ import * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
|
||||
|
||||
export * from "effect/Stream"
|
||||
|
||||
export const use: {
|
||||
<A, E, R>(
|
||||
stream: Stream.Stream<A, E, R>
|
||||
@@ -29,5 +31,3 @@ export const use: {
|
||||
|
||||
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)))
|
||||
@@ -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,15 +3,14 @@ export * as Component from "./Component.js"
|
||||
export * as ErrorObserver from "./ErrorObserver.js"
|
||||
export * as Form from "./Form.js"
|
||||
export * as Lens from "./Lens.js"
|
||||
export * as LensForm from "./LensForm.js"
|
||||
export * as Memoized from "./Memoized.js"
|
||||
export * as Mutation from "./Mutation.js"
|
||||
export * as MutationForm from "./MutationForm.js"
|
||||
export * as PubSub from "./PubSub.js"
|
||||
export * as Query from "./Query.js"
|
||||
export * as QueryClient from "./QueryClient.js"
|
||||
export * as ReactRuntime from "./ReactRuntime.js"
|
||||
export * as Result from "./Result.js"
|
||||
export * as SetStateAction from "./SetStateAction.js"
|
||||
export * as Stream from "./Stream.js"
|
||||
export * as SubmittableForm from "./SubmittableForm.js"
|
||||
export * as Subscribable from "./Subscribable.js"
|
||||
export * as SynchronizedForm from "./SynchronizedForm.js"
|
||||
export * as View from "./View.js"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export type ExcludeKeys<T, K extends PropertyKey> = K extends keyof T ? (
|
||||
{ [P in K]?: never } & Omit<T, K>
|
||||
) : T
|
||||
+7
-42
@@ -1,10 +1,10 @@
|
||||
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 { describe, expect, it } from "vitest"
|
||||
import * as Component from "../src/Component.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 () => {
|
||||
@@ -18,42 +18,7 @@ const makeRuntime = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
describe("Subscribable", () => {
|
||||
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))
|
||||
})
|
||||
|
||||
describe("View", () => {
|
||||
it("useAll returns the latest values and rerenders when any input changes", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
const countRef = await Effect.runPromise(SubscriptionRef.make(1))
|
||||
@@ -61,8 +26,8 @@ describe("Subscribable", () => {
|
||||
const count = Lens.fromSubscriptionRef(countRef)
|
||||
const label = Lens.fromSubscriptionRef(labelRef)
|
||||
|
||||
const Probe = Component.makeUntraced("SubscribableUseAllProbe")(function*() {
|
||||
const [currentCount, currentLabel] = yield* Subscribable.useAll([count, label])
|
||||
const Probe = Component.makeUntraced("ViewUseAllProbe")(function*() {
|
||||
const [currentCount, currentLabel] = yield* View.useAll([count, label])
|
||||
|
||||
return <div>{`${currentCount}:${currentLabel}`}</div>
|
||||
}).pipe(
|
||||
@@ -94,8 +59,8 @@ describe("Subscribable", () => {
|
||||
const item = Lens.fromSubscriptionRef(itemRef)
|
||||
const flag = Lens.fromSubscriptionRef(flagRef)
|
||||
|
||||
const Probe = Component.makeUntraced("SubscribableUseAllEquivalenceProbe")(function*() {
|
||||
const [currentItem, currentFlag] = yield* Subscribable.useAll([item, flag], {
|
||||
const Probe = Component.makeUntraced("ViewUseAllEquivalenceProbe")(function*() {
|
||||
const [currentItem, currentFlag] = yield* View.useAll([item, flag], {
|
||||
equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) =>
|
||||
selfItem.id === thatItem.id && selfFlag === thatFlag,
|
||||
})
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Effect FC Next Example</title>
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"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-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
@@ -23,12 +26,15 @@
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "4.0.0-beta.85",
|
||||
"effect-fc-next": "workspace:*"
|
||||
"@effect/platform-browser": "4.0.0-beta.98",
|
||||
"@radix-ui/themes": "^3.3.0",
|
||||
"effect": "4.0.0-beta.98",
|
||||
"effect-fc-next": "workspace:*",
|
||||
"react-icons": "^5.6.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "^19.2.15",
|
||||
"effect": "4.0.0-beta.85",
|
||||
"effect": "4.0.0-beta.98",
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
)
|
||||
@@ -1,40 +1,21 @@
|
||||
import { Effect, Layer, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, ReactRuntime, Subscribable } from "effect-fc-next"
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||
import { ReactRuntime } from "effect-fc-next"
|
||||
import { StrictMode } from "react"
|
||||
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*() {
|
||||
const count = yield* Component.useOnMount(() => Effect.map(
|
||||
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))
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register { router: typeof router }
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: the Vite template provides this element
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ReactRuntime.Provider runtime={runtime}>
|
||||
<CounterApp />
|
||||
<RouterProvider router={router} />
|
||||
</ReactRuntime.Provider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
import { QueryClient, ReactRuntime } from "effect-fc-next"
|
||||
|
||||
|
||||
export const runtime = ReactRuntime.make(QueryClient.layer())
|
||||
@@ -22,6 +22,10 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
"plugins": [
|
||||
{ "name": "@effect/language-service" }
|
||||
]
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import path from "node:path"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tanstackRouter({
|
||||
target: "react",
|
||||
autoCodeSplitting: true,
|
||||
}),
|
||||
react(),
|
||||
],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user