0.2.2 (#31)
All checks were successful
Lint / lint (push) Successful in 11s
Publish / publish (push) Successful in 18s

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
2026-01-16 17:05:30 +01:00
parent 89f966d93e
commit 0e8adf8506
14 changed files with 656 additions and 461 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "effect-fc",
"description": "Write React function components with Effect",
"version": "0.2.1",
"version": "0.2.2",
"type": "module",
"files": [
"./README.md",
@@ -37,6 +37,9 @@
"clean:dist": "rm -rf dist",
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect/platform-browser": "^0.74.0"
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "^3.19.0",

View File

@@ -20,7 +20,7 @@ export namespace Async {
}
const SuspenseProto = Object.freeze({
const AsyncProto = Object.freeze({
[TypeId]: TypeId,
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
@@ -63,7 +63,7 @@ export const async = <T extends Component.Component<any, any, any, any>>(
) => Object.setPrototypeOf(
Object.assign(function() {}, self),
Object.freeze(Object.setPrototypeOf(
Object.assign({}, SuspenseProto),
Object.assign({}, AsyncProto),
Object.getPrototypeOf(self),
)),
)

View File

@@ -35,6 +35,8 @@ extends Pipeable.Pipeable {
field<const P extends PropertyPath.Paths<I>>(
path: P
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>>
readonly run: Effect.Effect<void>
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
}
@@ -68,7 +70,7 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
Option.isSome(value) &&
Option.isNone(error) &&
Option.isNone(validationFiber) &&
!(Result.isRunning(result) || Result.isRefreshing(result))
!(Result.isRunning(result) || Result.hasRefreshingFlag(result))
),
)
}
@@ -76,19 +78,62 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
field<const P extends PropertyPath.Paths<I>>(
path: P
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>> {
const key = new FormFieldKey(path)
return this.fieldCache.pipe(
Effect.map(HashMap.get(new FormFieldKey(path))),
Effect.map(HashMap.get(key)),
Effect.flatMap(Option.match({
onSome: v => Effect.succeed(v as FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>),
onNone: () => Effect.tap(
Effect.succeed(makeFormField(this as Form<A, I, R, MA, ME, MR, MP>, path)),
v => Ref.update(this.fieldCache, HashMap.set(new FormFieldKey(path), v as FormField<unknown, unknown>)),
v => Ref.update(this.fieldCache, HashMap.set(key, v as FormField<unknown, unknown>)),
),
})),
)
}
readonly canSubmit: Subscribable.Subscribable<boolean, never, never>
readonly canSubmit: Subscribable.Subscribable<boolean>
get run(): Effect.Effect<void> {
return this.runSemaphore.withPermits(1)(Stream.runForEach(
this.encodedValue.changes.pipe(
Option.isSome(this.debounce) ? Stream.debounce(this.debounce.value) : identity
),
encodedValue => this.validationFiber.pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(
Effect.forkScoped(Effect.onExit(
Schema.decode(this.schema, { errors: "all" })(encodedValue),
exit => Effect.andThen(
Exit.matchEffect(exit, {
onSuccess: v => Effect.andThen(
Ref.set(this.value, Option.some(v)),
Ref.set(this.error, Option.none()),
),
onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), {
onSome: e => Ref.set(this.error, Option.some(e)),
onNone: () => Effect.void,
}),
}),
Ref.set(this.validationFiber, Option.none()),
),
)).pipe(
Effect.tap(fiber => Ref.set(this.validationFiber, Option.some(fiber))),
Effect.andThen(Fiber.join),
Effect.andThen(value => this.autosubmit
? Effect.asVoid(Effect.forkScoped(this.submitValue(value)))
: Effect.void
),
Effect.forkScoped,
)
),
Effect.provide(this.context),
),
))
}
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException> {
return this.value.pipe(
@@ -96,6 +141,7 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
Effect.andThen(value => this.submitValue(value)),
)
}
submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>> {
return Effect.whenEffect(
Effect.tap(
@@ -120,7 +166,7 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
export const isForm = (u: unknown): u is Form<unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
export namespace make {
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: Form<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>],
@@ -157,52 +203,7 @@ export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void,
)
})
export const run = <A, I, R, MA, ME, MR, MP>(
self: Form<A, I, R, MA, ME, MR, MP>
): Effect.Effect<void> => {
const _self = self as FormImpl<A, I, R, MA, ME, MR, MP>
return _self.runSemaphore.withPermits(1)(Stream.runForEach(
_self.encodedValue.changes.pipe(
Option.isSome(_self.debounce) ? Stream.debounce(_self.debounce.value) : identity
),
encodedValue => _self.validationFiber.pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(
Effect.forkScoped(Effect.onExit(
Schema.decode(_self.schema, { errors: "all" })(encodedValue),
exit => Effect.andThen(
Exit.matchEffect(exit, {
onSuccess: v => Effect.andThen(
Ref.set(_self.value, Option.some(v)),
Ref.set(_self.error, Option.none()),
),
onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), {
onSome: e => Ref.set(_self.error, Option.some(e)),
onNone: () => Effect.void,
}),
}),
Ref.set(_self.validationFiber, Option.none()),
),
)).pipe(
Effect.tap(fiber => Ref.set(_self.validationFiber, Option.some(fiber))),
Effect.andThen(Fiber.join),
Effect.andThen(value => _self.autosubmit
? Effect.asVoid(Effect.forkScoped(_self.submitValue(value)))
: Effect.void
),
Effect.forkScoped,
)
),
Effect.provide(_self.context),
),
))
}
export namespace service {
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> {}
}
@@ -215,7 +216,7 @@ export const service = <A, I = A, R = never, MA = void, ME = never, MR = never,
Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP>
> => Effect.tap(
make(options),
form => Effect.forkScoped(run(form)),
form => Effect.forkScoped(form.run),
)
@@ -259,7 +260,7 @@ class FormFieldKey implements Equal.Equal {
return isFormFieldKey(that) && PropertyPath.equivalence(this.path, that.path)
}
[Hash.symbol]() {
return 0
return Hash.array(this.path)
}
}
@@ -270,22 +271,21 @@ export const makeFormField = <A, I, R, MA, ME, MR, MP, const P extends PropertyP
self: Form<A, I, R, MA, ME, MR, MP>,
path: P,
): FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>> => {
const _self = self as FormImpl<A, I, R, MA, ME, MR, MP>
return new FormFieldImpl(
Subscribable.mapEffect(_self.value, Option.match({
Subscribable.mapEffect(self.value, Option.match({
onSome: v => Option.map(PropertyPath.get(v, path), Option.some),
onNone: () => Option.some(Option.none()),
})),
SubscriptionSubRef.makeFromPath(_self.encodedValue, path),
Subscribable.mapEffect(_self.error, Option.match({
SubscriptionSubRef.makeFromPath(self.encodedValue, path),
Subscribable.mapEffect(self.error, Option.match({
onSome: flow(
ParseResult.ArrayFormatter.formatError,
Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))),
),
onNone: () => Effect.succeed([]),
})),
Subscribable.map(_self.validationFiber, Option.isSome),
Subscribable.map(_self.mutation.result, result => Result.isRunning(result) || Result.isRefreshing(result)),
Subscribable.map(self.validationFiber, Option.isSome),
Subscribable.map(self.mutation.result, result => Result.isRunning(result) || Result.hasRefreshingFlag(result)),
)
}
@@ -295,7 +295,7 @@ export namespace useInput {
readonly debounce?: Duration.DurationInput
}
export interface Result<T> {
export interface Success<T> {
readonly value: T
readonly setValue: React.Dispatch<React.SetStateAction<T>>
}
@@ -304,7 +304,7 @@ export namespace useInput {
export const useInput = Effect.fnUntraced(function* <A, I>(
field: FormField<A, I>,
options?: useInput.Options,
): Effect.fn.Return<useInput.Result<I>, Cause.NoSuchElementException, Scope.Scope> {
): Effect.fn.Return<useInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
const internalValueRef = yield* Component.useOnChange(() => Effect.tap(
Effect.andThen(field.encodedValue, SubscriptionRef.make),
internalValueRef => Effect.forkScoped(Effect.all([
@@ -336,7 +336,7 @@ export namespace useOptionalInput {
readonly defaultValue: T
}
export interface Result<T> extends useInput.Result<T> {
export interface Success<T> extends useInput.Success<T> {
readonly enabled: boolean
readonly setEnabled: React.Dispatch<React.SetStateAction<boolean>>
}
@@ -345,7 +345,7 @@ export namespace useOptionalInput {
export const useOptionalInput = Effect.fnUntraced(function* <A, I>(
field: FormField<A, Option.Option<I>>,
options: useOptionalInput.Options<I>,
): Effect.fn.Return<useOptionalInput.Result<I>, Cause.NoSuchElementException, Scope.Scope> {
): Effect.fn.Return<useOptionalInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap(
Effect.andThen(
field.encodedValue,

View File

@@ -5,7 +5,7 @@ import * as Result from "./Result.js"
export const MutationTypeId: unique symbol = Symbol.for("@effect-fc/Mutation/Mutation")
export type MutationTypeId = typeof MutationTypeId
export interface Mutation<in out K extends readonly any[], in out A, in out E = never, in out R = never, in out P = never>
export interface Mutation<in out K extends Mutation.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
extends Pipeable.Pipeable {
readonly [MutationTypeId]: MutationTypeId
@@ -16,37 +16,45 @@ extends Pipeable.Pipeable {
readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
readonly result: Subscribable.Subscribable<Result.Result<A, E, P>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Final<A, E, P>>>
mutate(key: K): Effect.Effect<Result.Final<A, E, P>>
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>>
}
export class MutationImpl<in out K extends readonly any[], in out A, in out E = never, in out R = never, in out P = never>
export declare namespace Mutation {
export type AnyKey = readonly any[]
}
export class MutationImpl<in out K extends Mutation.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
readonly [MutationTypeId]: MutationTypeId = MutationTypeId
constructor(
readonly context: Context.Context<Scope.Scope | NoInfer<R>>,
readonly context: Context.Context<Scope.Scope | R>,
readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly initialProgress: P,
readonly latestKey: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
readonly fiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
readonly result: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>,
readonly latestFinalResult: SubscriptionRef.SubscriptionRef<Option.Option<Result.Final<A, E, P>>>,
) {
super()
}
mutate(key: K): Effect.Effect<Result.Final<A, E, P>> {
return SubscriptionRef.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(Effect.provide(this.start(key), this.context)),
Effect.andThen(this.start(key)),
Effect.andThen(sub => this.watch(sub)),
Effect.provide(this.context),
)
}
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> {
return Effect.andThen(
SubscriptionRef.set(this.latestKey, Option.some(key)),
Effect.provide(this.start(key), this.context)
return SubscriptionRef.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(this.start(key)),
Effect.tap(sub => Effect.forkScoped(this.watch(sub))),
Effect.provide(this.context),
)
}
@@ -55,12 +63,8 @@ extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
never,
Scope.Scope | R
> {
return this.result.pipe(
Effect.map(previous => Result.isFinal(previous)
? previous
: undefined
),
Effect.andThen(previous => Result.unsafeForkEffect(
return this.latestFinalResult.pipe(
Effect.andThen(initial => Result.unsafeForkEffect(
Effect.onExit(this.f(key), () => Effect.andThen(
Effect.all([Effect.fiberId, this.fiber]),
([currentFiberId, fiber]) => Option.match(fiber, {
@@ -68,12 +72,12 @@ extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
? SubscriptionRef.set(this.fiber, Option.none())
: Effect.void,
onNone: () => Effect.void,
})
}),
)),
{
initial: Option.isSome(initial) ? Result.willFetch(initial.value) : Result.initial(),
initialProgress: this.initialProgress,
previous,
} as Result.unsafeForkEffect.Options<A, E, P>,
)),
Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))),
@@ -84,27 +88,27 @@ extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
watch(
sub: Subscribable.Subscribable<Result.Result<A, E, P>>
): Effect.Effect<Result.Final<A, E, P>> {
return Effect.andThen(
sub.get,
initial => Stream.runFoldEffect(
Stream.filter(sub.changes, Predicate.not(Result.isInitial)),
return sub.get.pipe(
Effect.andThen(initial => Stream.runFoldEffect(
sub.changes,
initial,
(_, result) => Effect.as(SubscriptionRef.set(this.result, result), result),
),
) as Effect.Effect<Result.Final<A, E, P>>
) as Effect.Effect<Result.Final<A, E, P>>),
Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
)
}
}
export const isMutation = (u: unknown): u is Mutation<unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
export const isMutation = (u: unknown): u is Mutation<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
export declare namespace make {
export interface Options<K extends readonly any[] = never, A = void, E = never, R = never, P = never> {
export interface Options<K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never> {
readonly f: (key: K) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
readonly initialProgress?: P
}
}
export const make = Effect.fnUntraced(function* <const K extends readonly any[] = never, A = void, E = never, R = never, P = never>(
export const make = Effect.fnUntraced(function* <const K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never>(
options: make.Options<K, A, E, R, P>
): Effect.fn.Return<
Mutation<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
@@ -119,5 +123,6 @@ export const make = Effect.fnUntraced(function* <const K extends readonly any[]
yield* SubscriptionRef.make(Option.none<K>()),
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>()),
yield* SubscriptionRef.make(Result.initial<A, E, P>()),
yield* SubscriptionRef.make(Option.none<Result.Final<A, E, P>>()),
)
})

View File

@@ -1,50 +1,85 @@
import { type Cause, type Context, Effect, Fiber, identity, Option, Pipeable, Predicate, type Scope, Stream, type Subscribable, SubscriptionRef } from "effect"
import { type Cause, type Context, DateTime, type Duration, Effect, Equal, Equivalence, Fiber, HashMap, identity, Option, Pipeable, Predicate, type Scope, Stream, Subscribable, SubscriptionRef } from "effect"
import * as QueryClient from "./QueryClient.js"
import * as Result from "./Result.js"
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
export type QueryTypeId = typeof QueryTypeId
export interface Query<in out K extends readonly any[], in out A, in out E = never, in out R = never, in out P = never>
export interface Query<in out K extends Query.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
extends Pipeable.Pipeable {
readonly [QueryTypeId]: QueryTypeId
readonly context: Context.Context<Scope.Scope | R>
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>
readonly key: Stream.Stream<K>
readonly f: (key: K) => Effect.Effect<A, E, R>
readonly initialProgress: P
readonly staleTime: Duration.DurationInput
readonly refreshOnWindowFocus: boolean
readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
readonly result: Subscribable.Subscribable<Result.Result<A, E, P>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Final<A, E, P>>>
readonly run: Effect.Effect<void>
fetch(key: K): Effect.Effect<Result.Final<A, E, P>>
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>>
readonly refetch: Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException>
readonly refetchSubscribable: Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException>
readonly refresh: Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException>
readonly refreshSubscribable: Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException>
readonly invalidateCache: Effect.Effect<void>
invalidateCacheEntry(key: K): Effect.Effect<void>
}
export class QueryImpl<in out K extends readonly any[], in out A, in out E = never, in out R = never, in out P = never>
export declare namespace Query {
export type AnyKey = readonly any[]
}
export class QueryImpl<in out K extends Query.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
extends Pipeable.Class() implements Query<K, A, E, R, P> {
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
constructor(
readonly context: Context.Context<Scope.Scope | NoInfer<R>>,
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>,
readonly key: Stream.Stream<K>,
readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly initialProgress: P,
readonly staleTime: Duration.DurationInput,
readonly refreshOnWindowFocus: boolean,
readonly latestKey: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
readonly fiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
readonly result: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>,
readonly latestFinalResult: SubscriptionRef.SubscriptionRef<Option.Option<Result.Final<A, E, P>>>,
readonly runSemaphore: Effect.Semaphore,
) {
super()
}
get run(): Effect.Effect<void> {
return Effect.all([
Stream.runForEach(this.key, key => this.fetchSubscribable(key)),
Effect.promise(() => import("@effect/platform-browser")).pipe(
Effect.andThen(({ BrowserStream }) => this.refreshOnWindowFocus
? Stream.runForEach(
BrowserStream.fromEventListenerWindow("focus"),
() => this.refreshSubscribable,
)
: Effect.void
),
Effect.catchAllDefect(() => Effect.void),
),
], { concurrency: "unbounded" }).pipe(
Effect.ignore,
this.runSemaphore.withPermits(1),
)
}
get interrupt(): Effect.Effect<void, never, never> {
return Effect.andThen(this.fiber, Option.match({
onSome: Fiber.interrupt,
@@ -55,138 +90,230 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
fetch(key: K): Effect.Effect<Result.Final<A, E, P>> {
return this.interrupt.pipe(
Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))),
Effect.andThen(Effect.provide(this.start(key), this.context)),
Effect.andThen(sub => this.watch(sub)),
Effect.andThen(this.latestFinalResult),
Effect.andThen(previous => this.startCached(key, Option.isSome(previous)
? Result.willFetch(previous.value) as Result.Final<A, E, P>
: Result.initial()
)),
Effect.andThen(sub => this.watch(key, sub)),
Effect.provide(this.context),
)
}
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> {
return this.interrupt.pipe(
Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))),
Effect.andThen(Effect.provide(this.start(key), this.context)),
)
}
get refetch(): Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException> {
return this.interrupt.pipe(
Effect.andThen(this.latestKey),
Effect.andThen(identity),
Effect.andThen(key => Effect.provide(this.start(key), this.context)),
Effect.andThen(sub => this.watch(sub)),
)
}
get refetchSubscribable(): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException> {
return this.interrupt.pipe(
Effect.andThen(this.latestKey),
Effect.andThen(identity),
Effect.andThen(key => Effect.provide(this.start(key), this.context)),
Effect.andThen(this.latestFinalResult),
Effect.andThen(previous => this.startCached(key, Option.isSome(previous)
? Result.willFetch(previous.value) as Result.Final<A, E, P>
: Result.initial()
)),
Effect.tap(sub => Effect.forkScoped(this.watch(key, sub))),
Effect.provide(this.context),
)
}
get refresh(): Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException> {
return this.interrupt.pipe(
Effect.andThen(this.latestKey),
Effect.andThen(identity),
Effect.andThen(key => Effect.provide(this.start(key, true), this.context)),
Effect.andThen(sub => this.watch(sub)),
Effect.andThen(Effect.Do),
Effect.bind("latestKey", () => Effect.andThen(this.latestKey, identity)),
Effect.bind("latestFinalResult", () => this.latestFinalResult),
Effect.bind("subscribable", ({ latestKey, latestFinalResult }) =>
this.startCached(latestKey, Option.isSome(latestFinalResult)
? Result.willRefresh(latestFinalResult.value) as Result.Final<A, E, P>
: Result.initial()
)
),
Effect.andThen(({ latestKey, subscribable }) => this.watch(latestKey, subscribable)),
Effect.provide(this.context),
)
}
get refreshSubscribable(): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException> {
get refreshSubscribable(): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E, P>>,
Cause.NoSuchElementException
> {
return this.interrupt.pipe(
Effect.andThen(this.latestKey),
Effect.andThen(identity),
Effect.andThen(key => Effect.provide(this.start(key, true), this.context)),
Effect.andThen(Effect.Do),
Effect.bind("latestKey", () => Effect.andThen(this.latestKey, identity)),
Effect.bind("latestFinalResult", () => this.latestFinalResult),
Effect.bind("subscribable", ({ latestKey, latestFinalResult }) =>
this.startCached(latestKey, Option.isSome(latestFinalResult)
? Result.willRefresh(latestFinalResult.value) as Result.Final<A, E, P>
: Result.initial()
)
),
Effect.tap(({ latestKey, subscribable }) => Effect.forkScoped(this.watch(latestKey, subscribable))),
Effect.map(({ subscribable }) => subscribable),
Effect.provide(this.context),
)
}
startCached(
key: K,
initial: Result.Initial | Result.Final<A, E, P>,
): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E, P>>,
never,
Scope.Scope | QueryClient.QueryClient | R
> {
return Effect.andThen(this.getCacheEntry(key), Option.match({
onSome: entry => Effect.andThen(
QueryClient.isQueryClientCacheEntryStale(entry, this.staleTime),
isStale => isStale
? this.start(key, Result.willRefresh(entry.result) as Result.Final<A, E, P>)
: Effect.succeed(Subscribable.make({
get: Effect.succeed(entry.result as Result.Result<A, E, P>),
get changes() { return Stream.make(entry.result as Result.Result<A, E, P>) },
})),
),
onNone: () => this.start(key, initial),
}))
}
start(
key: K,
refresh?: boolean,
initial: Result.Initial | Result.Final<A, E, P>,
): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E, P>>,
never,
Scope.Scope | R
> {
return this.result.pipe(
Effect.map(previous => Result.isFinal(previous)
? previous
: undefined
),
Effect.andThen(previous => Result.unsafeForkEffect(
Effect.onExit(this.f(key), () => SubscriptionRef.set(this.fiber, Option.none())),
{
initialProgress: this.initialProgress,
refresh: refresh && previous,
previous,
} as Result.unsafeForkEffect.Options<A, E, P>,
return Result.unsafeForkEffect(
Effect.onExit(this.f(key), () => Effect.andThen(
Effect.all([Effect.fiberId, this.fiber]),
([currentFiberId, fiber]) => Option.match(fiber, {
onSome: v => Equal.equals(currentFiberId, v.id())
? SubscriptionRef.set(this.fiber, Option.none())
: Effect.void,
onNone: () => Effect.void,
}),
)),
{
initial,
initialProgress: this.initialProgress,
} as Result.unsafeForkEffect.Options<A, E, P>,
).pipe(
Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))),
Effect.map(([sub]) => sub),
)
}
watch(
key: K,
sub: Subscribable.Subscribable<Result.Result<A, E, P>>
): Effect.Effect<Result.Final<A, E, P>> {
return Effect.andThen(
sub.get,
initial => Stream.runFoldEffect(
Stream.filter(sub.changes, Predicate.not(Result.isInitial)),
): Effect.Effect<Result.Final<A, E, P>, never, QueryClient.QueryClient> {
return sub.get.pipe(
Effect.andThen(initial => Stream.runFoldEffect(
sub.changes,
initial,
(_, result) => Effect.as(SubscriptionRef.set(this.result, result), result),
) as Effect.Effect<Result.Final<A, E, P>>),
Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
Effect.tap(result => Result.isSuccess(result)
? this.updateCacheEntry(key, result)
: Effect.void
),
) as Effect.Effect<Result.Final<A, E, P>>
)
}
makeCacheKey(key: K): QueryClient.QueryClientCacheKey {
return new QueryClient.QueryClientCacheKey(key, this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>)
}
getCacheEntry(
key: K
): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> {
return QueryClient.QueryClient.pipe(
Effect.andThen(client => client.cache),
Effect.map(HashMap.get(this.makeCacheKey(key))),
)
}
updateCacheEntry(
key: K,
result: Result.Success<A>,
): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> {
return Effect.Do.pipe(
Effect.bind("client", () => QueryClient.QueryClient),
Effect.bind("now", () => DateTime.now),
Effect.let("entry", ({ now }) => new QueryClient.QueryClientCacheEntry(result, now)),
Effect.tap(({ client, entry }) => SubscriptionRef.update(
client.cache,
HashMap.set(this.makeCacheKey(key), entry),
)),
Effect.map(({ entry }) => entry),
)
}
get invalidateCache(): Effect.Effect<void> {
return QueryClient.QueryClient.pipe(
Effect.andThen(client => SubscriptionRef.update(
client.cache,
HashMap.filter((_, key) => !Equivalence.strict()(key.f, this.f)),
)),
Effect.provide(this.context),
)
}
invalidateCacheEntry(key: K): Effect.Effect<void> {
return QueryClient.QueryClient.pipe(
Effect.andThen(client => SubscriptionRef.update(
client.cache,
HashMap.remove(this.makeCacheKey(key)),
)),
Effect.provide(this.context),
)
}
}
export const isQuery = (u: unknown): u is Query<unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId)
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId)
export declare namespace make {
export interface Options<K extends readonly any[], A, E = never, R = never, P = never> {
export interface Options<K extends Query.AnyKey, A, E = never, R = never, P = never> {
readonly key: Stream.Stream<K>
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
readonly initialProgress?: P
readonly staleTime?: Duration.DurationInput
readonly refreshOnWindowFocus?: boolean
}
}
export const make = Effect.fnUntraced(function* <K extends readonly any[], A, E = never, R = never, P = never>(
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, E = never, R = never, P = never>(
options: make.Options<K, A, E, R, P>
): Effect.fn.Return<
Query<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
never,
Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>
> {
const client = yield* QueryClient.QueryClient
return new QueryImpl(
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>>(),
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>>(),
options.key,
options.f as any,
options.initialProgress as P,
options.staleTime ?? client.defaultStaleTime,
options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus,
yield* SubscriptionRef.make(Option.none<K>()),
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>()),
yield* SubscriptionRef.make(Result.initial<A, E, P>()),
yield* SubscriptionRef.make(Option.none<Result.Final<A, E, P>>()),
yield* Effect.makeSemaphore(1),
)
})
export const service = <K extends readonly any[], A, E = never, R = never, P = never>(
export const service = <K extends Query.AnyKey, A, E = never, R = never, P = never>(
options: make.Options<K, A, E, R, P>
): Effect.Effect<
Query<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
never,
Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>
> => Effect.tap(
make(options),
query => Effect.forkScoped(run(query)),
query => Effect.forkScoped(query.run),
)
export const run = <K extends readonly any[], A, E, R, P>(
self: Query<K, A, E, R, P>
): Effect.Effect<void> => {
const _self = self as QueryImpl<K, A, E, R, P>
return Stream.runForEach(_self.key, key => _self.interrupt.pipe(
Effect.andThen(SubscriptionRef.set(_self.latestKey, Option.some(key))),
Effect.andThen(_self.start(key)),
Effect.andThen(sub => Effect.forkScoped(_self.watch(sub))),
Effect.provide(_self.context),
_self.runSemaphore.withPermits(1),
))
}

View File

@@ -0,0 +1,119 @@
import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, Pipeable, Predicate, type Scope, SubscriptionRef } from "effect"
import type * as Query from "./Query.js"
import type * as Result from "./Result.js"
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientServiceTypeId")
export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
export interface QueryClientService extends Pipeable.Pipeable {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly gcTime: Duration.DurationInput
readonly defaultStaleTime: Duration.DurationInput
readonly defaultRefreshOnWindowFocus: boolean
}
export class QueryClient extends Effect.Service<QueryClient>()("@effect-fc/QueryClient/QueryClient", {
scoped: Effect.suspend(() => service())
}) {}
export class QueryClientServiceImpl
extends Pipeable.Class()
implements QueryClientService {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId
constructor(
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
readonly gcTime: Duration.DurationInput,
readonly defaultStaleTime: Duration.DurationInput,
readonly defaultRefreshOnWindowFocus: boolean,
readonly runSemaphore: Effect.Semaphore,
) {
super()
}
}
export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId)
export declare namespace make {
export interface Options {
readonly gcTime?: Duration.DurationInput
readonly defaultStaleTime?: Duration.DurationInput
readonly defaultRefreshOnWindowFocus?: boolean
}
}
export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return<QueryClientService> {
return new QueryClientServiceImpl(
yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()),
options.gcTime ?? "5 minutes",
options.defaultStaleTime ?? "0 minutes",
options.defaultRefreshOnWindowFocus ?? true,
yield* Effect.makeSemaphore(1),
)
})
export const run = (_self: QueryClientService): Effect.Effect<void> => Effect.void
export declare namespace service {
export interface Options extends make.Options {}
}
export const service = (options?: service.Options): Effect.Effect<QueryClientService, never, Scope.Scope> => Effect.tap(
make(options),
client => Effect.forkScoped(run(client)),
)
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheKey")
export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId
export class QueryClientCacheKey
extends Pipeable.Class()
implements Pipeable.Pipeable, Equal.Equal {
readonly [QueryClientCacheKeyTypeId]: QueryClientCacheKeyTypeId = QueryClientCacheKeyTypeId
constructor(
readonly key: Query.Query.AnyKey,
readonly f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>,
) {
super()
}
[Equal.symbol](that: Equal.Equal) {
return isQueryClientCacheKey(that) && Equivalence.array(Equal.equivalence())(this.key, that.key) && Equivalence.strict()(this.f, that.f)
}
[Hash.symbol]() {
return Hash.combine(Hash.hash(this.f))(Hash.array(this.key))
}
}
export const isQueryClientCacheKey = (u: unknown): u is QueryClientCacheKey => Predicate.hasProperty(u, QueryClientCacheKeyTypeId)
export const QueryClientCacheEntryTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheEntry")
export type QueryClientCacheEntryTypeId = typeof QueryClientCacheEntryTypeId
export class QueryClientCacheEntry
extends Pipeable.Class()
implements Pipeable.Pipeable {
readonly [QueryClientCacheEntryTypeId]: QueryClientCacheEntryTypeId = QueryClientCacheEntryTypeId
constructor(
readonly result: Result.Success<unknown>,
readonly createdAt: DateTime.DateTime,
) {
super()
}
}
export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId)
export const isQueryClientCacheEntryStale = (
self: QueryClientCacheEntry,
staleTime: Duration.DurationInput,
): Effect.Effect<boolean> => Effect.andThen(
DateTime.now,
now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), staleTime),
)

View File

@@ -3,6 +3,7 @@ import { Effect, Layer, ManagedRuntime, Predicate, Runtime, Scope } 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 TypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime")
@@ -17,9 +18,14 @@ export interface ReactRuntime<R, ER> {
const ReactRuntimeProto = Object.freeze({ [TypeId]: TypeId } as const)
export const Prelude: Layer.Layer<Component.ScopeMap | ErrorObserver.ErrorObserver> = Layer.mergeAll(
export const Prelude: Layer.Layer<
| Component.ScopeMap
| ErrorObserver.ErrorObserver
| QueryClient.QueryClient
> = Layer.mergeAll(
Component.ScopeMap.Default,
ErrorObserver.layer,
QueryClient.QueryClient.Default,
)

View File

@@ -1,4 +1,4 @@
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Option, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, Subscribable } from "effect"
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, Subscribable } from "effect"
export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result")
@@ -10,14 +10,11 @@ export type Result<A, E = never, P = never> = (
| Final<A, E, P>
)
export type Final<A, E = never, P = never> = (
| Success<A>
| (Success<A> & Refreshing<P>)
| Failure<A, E>
| (Failure<A, E> & Refreshing<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 namespace Result {
export declare namespace Result {
export interface Prototype extends Pipeable.Pipeable, Equal.Equal {
readonly [ResultTypeId]: ResultTypeId
}
@@ -27,6 +24,10 @@ export namespace Result {
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 Result.Prototype {
readonly _tag: "Initial"
}
@@ -41,14 +42,21 @@ export interface Success<A> extends Result.Prototype {
readonly value: A
}
export interface Failure<A, E = never> extends Result.Prototype {
export interface Failure<E = never> extends Result.Prototype {
readonly _tag: "Failure"
readonly cause: Cause.Cause<E>
readonly previousSuccess: Option.Option<Success<A>>
}
export interface WillFetch {
readonly _flag: "WillFetch"
}
export interface WillRefresh {
readonly _flag: "WillRefresh"
}
export interface Refreshing<P = never> {
readonly refreshing: true
readonly _flag: "Refreshing"
readonly progress: P
}
@@ -58,41 +66,32 @@ const ResultPrototype = Object.freeze({
[ResultTypeId]: ResultTypeId,
[Equal.symbol](this: Result<any, any, any>, that: Result<any, any, any>): boolean {
if (this._tag !== that._tag)
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) &&
(isRefreshing(self) ? self.refreshing : false) === (isRefreshing(that) ? that.refreshing : false) &&
Equal.equals(isRefreshing(self) ? self.progress : undefined, isRefreshing(that) ? that.progress : undefined)
),
Match.tag("Failure", self =>
Equal.equals(self.cause, (that as Failure<any, any>).cause) &&
(isRefreshing(self) ? self.refreshing : false) === (isRefreshing(that) ? that.refreshing : false) &&
Equal.equals(isRefreshing(self) ? self.progress : undefined, isRefreshing(that) ? that.progress : undefined)
),
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 {
const tagHash = Hash.string(this._tag)
return Match.value(this).pipe(
Match.tag("Initial", () => tagHash),
Match.tag("Running", self => Hash.combine(Hash.hash(self.progress))(tagHash)),
Match.tag("Success", self => pipe(tagHash,
Hash.combine(Hash.hash(self.value)),
Hash.combine(Hash.hash(isRefreshing(self) ? self.progress : undefined)),
)),
Match.tag("Failure", self => pipe(tagHash,
Hash.combine(Hash.hash(self.cause)),
Hash.combine(Hash.hash(isRefreshing(self) ? self.progress : undefined)),
)),
Match.exhaustive,
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,
Hash.cached(this),
)
},
@@ -104,8 +103,11 @@ export const isFinal = (u: unknown): u is Final<unknown, unknown, unknown> => is
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, unknown> => isResult(u) && u._tag === "Failure"
export const isRefreshing = (u: unknown): u is Refreshing<unknown> => isResult(u) && Predicate.hasProperty(u, "refreshing") && u.refreshing
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
@@ -113,34 +115,42 @@ export const initial: {
} = (): 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 fail = <E, A = never>(
cause: Cause.Cause<E>,
previousSuccess?: Success<NoInfer<A>>,
): Failure<A, E> => Object.setPrototypeOf({
_tag: "Failure",
cause,
previousSuccess: Option.fromNullable(previousSuccess),
}, ResultPrototype)
export const refreshing = <R extends Success<any> | Failure<any, any>, P = never>(
result: R,
progress?: P,
): Omit<R, keyof Refreshing<Result.Progress<R>>> & Refreshing<P> => Object.setPrototypeOf(
Object.assign({}, result, { refreshing: true, progress }),
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 fromExit = <A, E>(
exit: Exit.Exit<A, E>,
previousSuccess?: Success<NoInfer<A>>,
): Success<A> | Failure<A, E> => exit._tag === "Success"
? succeed(exit.value)
: fail(exit.cause, previousSuccess)
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 toExit = <A, E, P>(
self: Result<A, E, P>
): Exit.Exit<A, E | Cause.NoSuchElementException> => {
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.NoSuchElementException>
} = <A, E, P>(self: Result<A, E, P>): any => {
switch (self._tag) {
case "Success":
return Exit.succeed(self.value)
@@ -179,17 +189,17 @@ export const makeProgressLayer = <A, E, P = never>(): Layer.Layer<
const state = yield* State<A, E, P>()
return {
update: <E, R>(f: (previous: P) => Effect.Effect<P, E, R>) => Effect.Do.pipe(
update: <FE, FR>(f: (previous: P) => Effect.Effect<P, FE, FR>) => Effect.Do.pipe(
Effect.bind("previous", () => Effect.andThen(state.get, previous =>
isRunning(previous) || isRefreshing(previous)
(isRunning(previous) || hasRefreshingFlag(previous))
? Effect.succeed(previous)
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous })),
)),
Effect.bind("progress", ({ previous }) => f(previous.progress)),
Effect.let("next", ({ previous, progress }) => Object.setPrototypeOf(
Object.assign({}, previous, { progress }),
Object.getPrototypeOf(previous),
)),
Effect.let("next", ({ previous, progress }) => isRunning(previous)
? running(progress)
: refreshing(previous, progress) as Final<A, E, P> & Refreshing<P>
),
Effect.andThen(({ next }) => state.set(next)),
),
}
@@ -199,18 +209,10 @@ export const makeProgressLayer = <A, E, P = never>(): Layer.Layer<
export namespace unsafeForkEffect {
export type OutputContext<A, E, R, P> = Exclude<R, State<A, E, P> | Progress<P> | Progress<never>>
export type Options<A, E, P> = {
export interface Options<A, E, P> {
readonly initial?: Initial | Final<A, E, P>
readonly initialProgress?: P
readonly previous?: Final<A, E, P>
} & (
| {
readonly refresh: true
readonly previous: Final<A, E, P>
}
| {
readonly refresh?: false
}
)
}
}
export const unsafeForkEffect = <A, E, R, P = never>(
@@ -221,16 +223,17 @@ export const unsafeForkEffect = <A, E, R, P = never>(
never,
Scope.Scope | unsafeForkEffect.OutputContext<A, E, R, P>
> => Effect.Do.pipe(
Effect.bind("ref", () => Ref.make(initial<A, E, P>())),
Effect.bind("ref", () => Ref.make(options?.initial ?? initial<A, E, P>())),
Effect.bind("pubsub", () => PubSub.unbounded<Result<A, E, P>>()),
Effect.bind("fiber", ({ ref, pubsub }) => Effect.forkScoped(State<A, E, P>().pipe(
Effect.andThen(state => state.set(options?.refresh
? refreshing(options.previous, options?.initialProgress) as Result<A, E, P>
: running(options?.initialProgress)
Effect.andThen(state => state.set(
(isFinal(options?.initial) && hasWillRefreshFlag(options?.initial))
? refreshing(options.initial, options?.initialProgress) as Result<A, E, P>
: running(options?.initialProgress)
).pipe(
Effect.andThen(effect),
Effect.onExit(exit => Effect.andThen(
state.set(fromExit(exit, (options?.previous && isSuccess(options.previous)) ? options.previous : undefined)),
state.set(fromExit(exit)),
Effect.forkScoped(PubSub.shutdown(pubsub)),
)),
)),
@@ -261,7 +264,7 @@ export const unsafeForkEffect = <A, E, R, P = never>(
export namespace forkEffect {
export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R
export type OutputContext<A, E, R, P> = unsafeForkEffect.OutputContext<A, E, R, P>
export type Options<A, E, P> = unsafeForkEffect.Options<A, E, P>
export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {}
}
export const forkEffect: {

View File

@@ -7,6 +7,7 @@ export * as Mutation from "./Mutation.js"
export * as PropertyPath from "./PropertyPath.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"

View File

@@ -19,15 +19,15 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"globals": "^16.5.0",
"globals": "^17.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"type-fest": "^5.2.0",
"vite": "^7.2.6"
},
"dependencies": {
"@effect/platform": "^0.93.6",
"@effect/platform-browser": "^0.73.0",
"@effect/platform": "^0.94.0",
"@effect/platform-browser": "^0.74.0",
"@radix-ui/themes": "^3.2.1",
"@typed/id": "^0.17.2",
"effect": "^3.19.8",

View File

@@ -20,8 +20,8 @@ export type TextFieldFormInputProps = Props | OptionalProps
export class TextFieldFormInput extends Component.makeUntraced("TextFieldFormInput")(function*(props: TextFieldFormInputProps) {
const input: (
| { readonly optional: true } & Form.useOptionalInput.Result<string>
| { readonly optional: false } & Form.useInput.Result<string>
| { readonly optional: true } & Form.useOptionalInput.Success<string>
| { readonly optional: false } & Form.useInput.Success<string>
) = props.optional
// biome-ignore lint/correctness/useHookAtTopLevel: "optional" reactivity not supported
? { optional: true, ...yield* Form.useOptionalInput(props.field, props) }

View File

@@ -18,16 +18,16 @@ const ResultView = Component.makeUntraced("Result")(function*() {
const [idRef, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
const idRef = yield* SubscriptionRef.make(1)
const key = Stream.zipLatest(Stream.make("posts" as const), idRef.changes)
const query = yield* Query.service({
key,
key: Stream.zipLatest(Stream.make("posts" as const), idRef.changes),
f: ([, id]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)),
),
staleTime: "10 seconds",
})
const mutation = yield* Mutation.make({
@@ -74,7 +74,7 @@ const ResultView = Component.makeUntraced("Result")(function*() {
Match.tag("Success", result => <>
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
{Result.isRefreshing(result) && <Text>Refreshing...</Text>}
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
</>),
Match.tag("Failure", result =>
<Text>An error has occured: {result.cause.toString()}</Text>
@@ -85,7 +85,7 @@ const ResultView = Component.makeUntraced("Result")(function*() {
<Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runPromise(query.refresh)}>Refresh</Button>
<Button onClick={() => runPromise(query.refetch)}>Refetch</Button>
<Button onClick={() => runPromise(query.invalidateCache)}>Invalidate cache</Button>
</Flex>
<div>
@@ -94,7 +94,7 @@ const ResultView = Component.makeUntraced("Result")(function*() {
Match.tag("Success", result => <>
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
{Result.isRefreshing(result) && <Text>Refreshing...</Text>}
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
</>),
Match.tag("Failure", result =>
<Text>An error has occured: {result.cause.toString()}</Text>