@@ -0,0 +1,171 @@
|
||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||
import { Context, Effect, type Equivalence, Function, Predicate, Scope } from "effect"
|
||||
import * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
|
||||
|
||||
export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
|
||||
export type AsyncTypeId = typeof AsyncTypeId
|
||||
|
||||
|
||||
/**
|
||||
* A trait for `Component`'s that allows them running asynchronous effects.
|
||||
*/
|
||||
export interface Async extends AsyncPrototype, AsyncOptions {}
|
||||
|
||||
export interface AsyncPrototype {
|
||||
readonly [AsyncTypeId]: AsyncTypeId
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for `Async` components.
|
||||
*/
|
||||
export interface AsyncOptions {
|
||||
/**
|
||||
* The default fallback React node to display while the async operation is pending.
|
||||
* Used if no fallback is provided to the component when rendering.
|
||||
*/
|
||||
readonly defaultFallback?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for `Async` components.
|
||||
*/
|
||||
export type AsyncProps = Omit<React.SuspenseProps, "children">
|
||||
|
||||
|
||||
export const AsyncPrototype: AsyncPrototype = Object.freeze({
|
||||
[AsyncTypeId]: AsyncTypeId,
|
||||
|
||||
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Component.Signature>(
|
||||
this: Component.Component<P, A, E, R, F> & Async,
|
||||
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>,
|
||||
) {
|
||||
const Inner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
|
||||
|
||||
return ({ fallback, name, ...props }: AsyncProps) => {
|
||||
const promise = Effect.runPromiseWith(contextRef.current)(
|
||||
Effect.andThen(
|
||||
Component.useScope([], this),
|
||||
scope => Effect.provideService(this.body(props as P), Scope.Scope, scope),
|
||||
)
|
||||
)
|
||||
|
||||
return React.createElement(
|
||||
React.Suspense,
|
||||
{ fallback: fallback ?? this.defaultFallback, name },
|
||||
React.createElement(Inner, { promise }),
|
||||
)
|
||||
}
|
||||
},
|
||||
} as const)
|
||||
|
||||
/**
|
||||
* An equivalence function for comparing `AsyncProps` that ignores the `fallback` property.
|
||||
* Used by default by async components with `Memoized.memoized` applied.
|
||||
*/
|
||||
export const defaultPropsEquivalence: Equivalence.Equivalence<AsyncProps> = (
|
||||
self: Record<string, unknown>,
|
||||
that: Record<string, unknown>,
|
||||
) => {
|
||||
if (self === that)
|
||||
return true
|
||||
|
||||
for (const key in self) {
|
||||
if (key === "fallback")
|
||||
continue
|
||||
if (!(key in that) || !Object.is(self[key], that[key]))
|
||||
return false
|
||||
}
|
||||
|
||||
for (const key in that) {
|
||||
if (key === "fallback")
|
||||
continue
|
||||
if (!(key in self))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, AsyncTypeId)
|
||||
|
||||
/**
|
||||
* Converts a Component into an `Async` component that supports running asynchronous effects.
|
||||
*
|
||||
* Note: The component cannot have a prop named "promise" as it's reserved for internal use.
|
||||
*
|
||||
* @param self - The component to convert to an Async component
|
||||
* @returns A new `Async` component with the same body, error, and context types as the input
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const MyAsyncComponent = MyComponent.pipe(
|
||||
* Async.async,
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export const async = <T extends Component.Component.Any>(
|
||||
self: T & (
|
||||
"promise" extends keyof Component.Component.Props<T>
|
||||
? "The 'promise' prop name is restricted for Async components. Please rename the 'promise' prop to something else."
|
||||
: T
|
||||
)
|
||||
): (
|
||||
& Omit<T, keyof Component.Component.AsComponent<T>>
|
||||
& Component.Component<
|
||||
Component.Component.Props<T> & AsyncProps,
|
||||
Component.Component.Success<T>,
|
||||
Component.Component.Error<T>,
|
||||
Component.Component.Context<T>,
|
||||
Component.Component.DefaultSignature<Component.Component.Props<T> & AsyncProps, Component.Component.Success<T>>
|
||||
>
|
||||
& Async
|
||||
) => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }),
|
||||
Object.freeze(Object.setPrototypeOf(
|
||||
Object.assign({}, AsyncPrototype),
|
||||
Object.getPrototypeOf(self),
|
||||
)),
|
||||
)
|
||||
|
||||
/**
|
||||
* Applies options to an Async component, returning a new Async component with the updated configuration.
|
||||
*
|
||||
* Supports both curried and uncurried application styles.
|
||||
*
|
||||
* @param self - The Async component to apply options to (in uncurried form)
|
||||
* @param options - The options to apply to the component
|
||||
* @returns An Async component with the applied options
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Curried
|
||||
* const MyAsyncComponent = MyComponent.pipe(
|
||||
* Async.async,
|
||||
* Async.withOptions({ defaultFallback: <p>Loading...</p> }),
|
||||
* )
|
||||
*
|
||||
* // Uncurried
|
||||
* const MyAsyncComponent = Async.withOptions(
|
||||
* Async.async(MyComponent),
|
||||
* { defaultFallback: <p>Loading...</p> },
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export const withOptions: {
|
||||
<T extends Component.Component.Any & Async>(
|
||||
options: Partial<AsyncOptions>
|
||||
): (self: T) => T
|
||||
<T extends Component.Component.Any & Async>(
|
||||
self: T,
|
||||
options: Partial<AsyncOptions>,
|
||||
): T
|
||||
} = Function.dual(2, <T extends Component.Component.Any & Async>(
|
||||
self: T,
|
||||
options: Partial<AsyncOptions>,
|
||||
): T => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self, options),
|
||||
Object.getPrototypeOf(self),
|
||||
))
|
||||
@@ -0,0 +1,385 @@
|
||||
/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */
|
||||
import {
|
||||
Context,
|
||||
type Duration,
|
||||
Effect,
|
||||
Exit,
|
||||
Function,
|
||||
identity,
|
||||
Layer,
|
||||
Pipeable,
|
||||
Predicate,
|
||||
Scope,
|
||||
Tracer,
|
||||
} from "effect"
|
||||
import * as React from "react"
|
||||
|
||||
|
||||
export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component")
|
||||
export type ComponentTypeId = typeof ComponentTypeId
|
||||
|
||||
export interface Component<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>
|
||||
extends ComponentPrototype<R, F>, ComponentOptions {
|
||||
new(_: never): Record<string, never>
|
||||
readonly [ComponentTypeId]: ComponentTypeId
|
||||
readonly "~Props": P
|
||||
readonly "~Success": A
|
||||
readonly "~Error": E
|
||||
readonly "~Context": R
|
||||
readonly "~Function": F
|
||||
readonly body: (props: P) => Effect.Effect<A, E, R>
|
||||
}
|
||||
|
||||
export declare namespace Component {
|
||||
export type Default<P extends {}, A extends React.ReactNode, E, R> = Component<P, A, E, R, DefaultSignature<P, A>>
|
||||
export type Any = Component<any, any, any, any, any>
|
||||
export type Signature = (props: any) => React.ReactNode
|
||||
export type DefaultSignature<P extends {}, A extends React.ReactNode> = (props: P) => A
|
||||
export type Props<T extends Any> = T["~Props"]
|
||||
export type Success<T extends Any> = T["~Success"]
|
||||
export type Error<T extends Any> = T["~Error"]
|
||||
export type Context<T extends Any> = T["~Context"]
|
||||
export type Function<T extends Any> = T["~Function"]
|
||||
export type AsComponent<T extends Any> = Component<Props<T>, Success<T>, Error<T>, Context<T>, Function<T>>
|
||||
}
|
||||
|
||||
export interface ComponentOptions {
|
||||
readonly displayName?: string
|
||||
readonly nonReactiveTags: readonly Context.Key<any, any>[]
|
||||
readonly finalizerExecutionStrategy: "sequential" | "parallel"
|
||||
readonly finalizerExecutionDebounce: Duration.Input
|
||||
}
|
||||
|
||||
export const defaultOptions: ComponentOptions = {
|
||||
nonReactiveTags: [Tracer.ParentSpan],
|
||||
finalizerExecutionStrategy: "sequential",
|
||||
finalizerExecutionDebounce: "100 millis",
|
||||
}
|
||||
|
||||
export interface ComponentPrototype<R, F extends Component.Signature> extends Pipeable.Pipeable {
|
||||
readonly [ComponentTypeId]: ComponentTypeId
|
||||
readonly use: Effect.Effect<F, never, Exclude<R, Scope.Scope>>
|
||||
}
|
||||
|
||||
type ComponentImpl = Component<any, React.ReactNode, any, any, Component.Signature>
|
||||
|
||||
const makeFunctionComponent = (
|
||||
self: ComponentImpl,
|
||||
contextRef: React.RefObject<Context.Context<any>>,
|
||||
): Component.Signature => {
|
||||
if ("asFunctionComponent" in self && typeof self.asFunctionComponent === "function") {
|
||||
return self.asFunctionComponent(contextRef)
|
||||
}
|
||||
const FunctionComponent = (props: {}) => Effect.runSyncWith(contextRef.current)(
|
||||
Effect.flatMap(
|
||||
useScope([], self),
|
||||
scope => Effect.provideService(self.body(props), Scope.Scope, scope),
|
||||
),
|
||||
)
|
||||
FunctionComponent.displayName = self.displayName ?? "Anonymous"
|
||||
return "transformFunctionComponent" in self && typeof self.transformFunctionComponent === "function"
|
||||
? self.transformFunctionComponent(FunctionComponent)
|
||||
: FunctionComponent
|
||||
}
|
||||
|
||||
const use = Effect.fnUntraced(function* (self: ComponentImpl) {
|
||||
const context = yield* Effect.context<any>()
|
||||
const cached = componentCache.get(self)
|
||||
if (cached !== undefined) {
|
||||
cached.contextRef.current = context
|
||||
return cached.component
|
||||
}
|
||||
const contextRef = { current: context }
|
||||
const component = makeFunctionComponent(self, contextRef)
|
||||
componentCache.set(self, { contextRef, component })
|
||||
return component
|
||||
})
|
||||
|
||||
const componentCache = new WeakMap<ComponentImpl, {
|
||||
readonly contextRef: { current: Context.Context<any> }
|
||||
readonly component: Component.Signature
|
||||
}>()
|
||||
|
||||
export const ComponentPrototype = Object.freeze({
|
||||
[ComponentTypeId]: ComponentTypeId,
|
||||
...Pipeable.Prototype,
|
||||
get use() {
|
||||
return use(this as ComponentImpl)
|
||||
},
|
||||
}) as unknown as ComponentPrototype<any, any>
|
||||
|
||||
export const isComponent = (u: unknown): u is Component.Any => Predicate.hasProperty(u, ComponentTypeId)
|
||||
|
||||
type GeneratorBody<P extends {}, A extends React.ReactNode, E, R> = (
|
||||
props: P,
|
||||
) => Effect.fn.Return<A, E, R>
|
||||
|
||||
type EffectBody<P extends {}, A extends React.ReactNode, E, R> = (
|
||||
props: P,
|
||||
) => Effect.Effect<A, E, R>
|
||||
|
||||
export interface Make {
|
||||
<P extends {}, A extends React.ReactNode, E = never, R = never>(
|
||||
body: GeneratorBody<P, A, E, R> | EffectBody<P, A, E, R>,
|
||||
...pipeables: readonly Function[]
|
||||
): Component.Default<P, A, E, R>
|
||||
(name: string, options?: Tracer.SpanOptionsNoTrace): <P extends {}, A extends React.ReactNode, E = never, R = never>(
|
||||
body: GeneratorBody<P, A, E, R> | EffectBody<P, A, E, R>,
|
||||
...pipeables: readonly Function[]
|
||||
) => Component.Default<P, A, E, R>
|
||||
}
|
||||
|
||||
const component = (
|
||||
body: Function,
|
||||
displayName: string | undefined,
|
||||
traced: boolean,
|
||||
pipeables: readonly Function[],
|
||||
): Component.Any => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, defaultOptions, {
|
||||
body: traced && displayName
|
||||
? Effect.fn(displayName)(body as never, ...pipeables as [])
|
||||
: Effect.fnUntraced(body as never, ...pipeables as []),
|
||||
displayName,
|
||||
}),
|
||||
ComponentPrototype,
|
||||
)
|
||||
|
||||
export const make: Make = ((nameOrBody: string | Function, ...args: readonly unknown[]) => {
|
||||
if (typeof nameOrBody === "string") {
|
||||
return (body: Function, ...pipeables: readonly Function[]) => component(body, nameOrBody, true, pipeables)
|
||||
}
|
||||
return component(nameOrBody, undefined, true, args as readonly Function[])
|
||||
}) as Make
|
||||
|
||||
export const makeUntraced: Make = ((nameOrBody: string | Function, ...args: readonly unknown[]) => {
|
||||
if (typeof nameOrBody === "string") {
|
||||
return (body: Function, ...pipeables: readonly Function[]) => component(body, nameOrBody, false, pipeables)
|
||||
}
|
||||
return component(nameOrBody, undefined, false, args as readonly Function[])
|
||||
}) as Make
|
||||
|
||||
export declare namespace withSignature {
|
||||
export type Result<T extends Component.Any, F extends Component.Signature> = (
|
||||
& Omit<T, keyof Component.AsComponent<T>>
|
||||
& Component<Component.Props<T>, Component.Success<T>, Component.Error<T>, Component.Context<T>, F>
|
||||
)
|
||||
}
|
||||
|
||||
export const withSignature: {
|
||||
<F extends Component.Signature>(): <T extends Component.Any>(self: T) => withSignature.Result<T, F>
|
||||
<F extends Component.Signature, T extends Component.Any>(self: T): withSignature.Result<T, F>
|
||||
} = (self?: Component.Any): any => self === undefined ? identity : self
|
||||
|
||||
export const withOptions: {
|
||||
<T extends Component.Any>(options: Partial<ComponentOptions>): (self: T) => T
|
||||
<T extends Component.Any>(self: T, options: Partial<ComponentOptions>): T
|
||||
} = Function.dual(2, <T extends Component.Any>(self: T, options: Partial<ComponentOptions>): T => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self, options),
|
||||
Object.getPrototypeOf(self),
|
||||
))
|
||||
|
||||
export const withRuntime: {
|
||||
<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
||||
context: React.Context<Context.Context<R>>,
|
||||
): (self: Component<P, A, E, Scope.Scope | NoInfer<R>, F>) => F
|
||||
<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
||||
self: Component<P, A, E, Scope.Scope | NoInfer<R>, F>,
|
||||
context: React.Context<Context.Context<R>>,
|
||||
): F
|
||||
} = Function.dual(2, <P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
||||
self: Component<P, A, E, Scope.Scope | R, F>,
|
||||
context: React.Context<Context.Context<R>>,
|
||||
) => function WithRuntime(props: P) {
|
||||
return React.createElement(
|
||||
Effect.runSyncWith(React.useContext(context))(self.use) as React.FC<P>,
|
||||
props,
|
||||
)
|
||||
})
|
||||
|
||||
export declare namespace useScope {
|
||||
export interface Options {
|
||||
readonly finalizerExecutionStrategy?: "sequential" | "parallel"
|
||||
readonly finalizerExecutionDebounce?: Duration.Input
|
||||
}
|
||||
}
|
||||
|
||||
export const useScope = Effect.fnUntraced(function* (
|
||||
deps: React.DependencyList,
|
||||
options?: useScope.Options,
|
||||
): Effect.fn.Return<Scope.Scope> {
|
||||
const context = yield* Effect.context<never>()
|
||||
const contextRef = React.useRef(context)
|
||||
contextRef.current = context
|
||||
const scope = React.useMemo(
|
||||
() => Scope.makeUnsafe(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy),
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: caller controls scope lifetime
|
||||
deps,
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const pending = scopeCleanupTimers.get(scope)
|
||||
if (pending !== undefined) clearTimeout(pending)
|
||||
return () => {
|
||||
const timer = setTimeout(() => {
|
||||
Effect.runSyncWith(contextRef.current)(Scope.close(scope, Exit.succeed(undefined)))
|
||||
scopeCleanupTimers.delete(scope)
|
||||
}, durationMillis(options?.finalizerExecutionDebounce ?? defaultOptions.finalizerExecutionDebounce))
|
||||
scopeCleanupTimers.set(scope, timer)
|
||||
}
|
||||
}, [scope, options?.finalizerExecutionDebounce])
|
||||
|
||||
return scope
|
||||
})
|
||||
|
||||
const scopeCleanupTimers = new WeakMap<object, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const durationMillis = (input: Duration.Input): number => {
|
||||
if (typeof input === "number") return input
|
||||
return Number(Effect.runSync(Effect.map(Effect.succeed(input), value => {
|
||||
const match = typeof value === "string" ? /([\d.]+)\s*(millis|seconds?)/.exec(value) : undefined
|
||||
if (!match) return 0
|
||||
return Number(match[1]) * (match[2].startsWith("second") ? 1_000 : 1)
|
||||
})))
|
||||
}
|
||||
|
||||
export const useOnMount = Effect.fnUntraced(function* <A, E, R>(
|
||||
f: () => Effect.Effect<A, E, R>,
|
||||
): Effect.fn.Return<A, E, R> {
|
||||
const context = yield* Effect.context<R>()
|
||||
const id = React.useId()
|
||||
let cached = mountCache.get(id)
|
||||
if (cached === undefined) {
|
||||
cached = {
|
||||
effect: Effect.runSyncWith(context)(Effect.cached(f())),
|
||||
}
|
||||
mountCache.set(id, cached)
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (cached?.cleanup !== undefined) clearTimeout(cached.cleanup)
|
||||
const entry = cached
|
||||
return () => {
|
||||
entry.cleanup = setTimeout(() => mountCache.delete(id), 0)
|
||||
}
|
||||
}, [id, cached])
|
||||
return yield* cached.effect as Effect.Effect<A, E, R>
|
||||
})
|
||||
|
||||
const mountCache = new Map<string, {
|
||||
readonly effect: Effect.Effect<unknown, unknown, unknown>
|
||||
cleanup?: ReturnType<typeof setTimeout>
|
||||
}>()
|
||||
|
||||
export declare namespace useOnChange {
|
||||
export interface Options extends useScope.Options {}
|
||||
}
|
||||
|
||||
export const useOnChange = Effect.fnUntraced(function* <A, E, R>(
|
||||
f: () => Effect.Effect<A, E, R>,
|
||||
deps: React.DependencyList,
|
||||
options?: useOnChange.Options,
|
||||
): Effect.fn.Return<A, E, Exclude<R, Scope.Scope>> {
|
||||
const context = yield* Effect.context<Exclude<R, Scope.Scope>>()
|
||||
const scope = yield* useScope(deps, options)
|
||||
const cached =
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scope tracks the caller-provided dependency list
|
||||
React.useMemo(
|
||||
() => Effect.runSyncWith(context)(Effect.cached(Effect.provideService(f(), Scope.Scope, scope))),
|
||||
[scope],
|
||||
)
|
||||
return yield* cached
|
||||
})
|
||||
|
||||
export declare namespace useReactEffect {
|
||||
export interface Options {
|
||||
readonly finalizerExecutionMode?: "sync" | "fork"
|
||||
readonly finalizerExecutionStrategy?: "sequential" | "parallel"
|
||||
}
|
||||
}
|
||||
|
||||
const runReactEffect = <E, R>(
|
||||
context: Context.Context<Exclude<R, Scope.Scope>>,
|
||||
f: () => Effect.Effect<void, E, R>,
|
||||
options?: useReactEffect.Options,
|
||||
): (() => void) => {
|
||||
const scope = Scope.makeUnsafe(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy)
|
||||
Effect.runSyncWith(context)(Effect.exit(Effect.provideService(f(), Scope.Scope, scope)))
|
||||
return () => {
|
||||
const close = Scope.close(scope, Exit.succeed(undefined))
|
||||
if ((options?.finalizerExecutionMode ?? "fork") === "sync") Effect.runSyncWith(context)(close)
|
||||
else Effect.runForkWith(context)(close)
|
||||
}
|
||||
}
|
||||
|
||||
export const useReactEffect = Effect.fnUntraced(function* <E, R>(
|
||||
f: () => Effect.Effect<void, E, R>,
|
||||
deps?: React.DependencyList,
|
||||
options?: useReactEffect.Options,
|
||||
): Effect.fn.Return<void, never, Exclude<R, Scope.Scope>> {
|
||||
const context = yield* Effect.context<Exclude<R, Scope.Scope>>()
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies
|
||||
React.useEffect(() => runReactEffect(context, f, options), deps)
|
||||
})
|
||||
|
||||
export declare namespace useReactLayoutEffect {
|
||||
export interface Options extends useReactEffect.Options {}
|
||||
}
|
||||
|
||||
export const useReactLayoutEffect = Effect.fnUntraced(function* <E, R>(
|
||||
f: () => Effect.Effect<void, E, R>,
|
||||
deps?: React.DependencyList,
|
||||
options?: useReactLayoutEffect.Options,
|
||||
): Effect.fn.Return<void, never, Exclude<R, Scope.Scope>> {
|
||||
const context = yield* Effect.context<Exclude<R, Scope.Scope>>()
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies
|
||||
React.useLayoutEffect(() => runReactEffect(context, f, options), deps)
|
||||
})
|
||||
|
||||
export const useRunSync = <R = never>(): Effect.Effect<
|
||||
<A, E = never>(effect: Effect.Effect<A, E, R>) => A,
|
||||
never,
|
||||
R
|
||||
> => Effect.map(Effect.context<R>(), Effect.runSyncWith)
|
||||
|
||||
export const useRunPromise = <R = never>(): Effect.Effect<
|
||||
<A, E = never>(effect: Effect.Effect<A, E, R>) => Promise<A>,
|
||||
never,
|
||||
R
|
||||
> => Effect.map(Effect.context<R>(), Effect.runPromiseWith)
|
||||
|
||||
export const useCallbackSync = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
|
||||
f: (...args: Args) => Effect.Effect<A, E, R>,
|
||||
deps: React.DependencyList,
|
||||
): Effect.fn.Return<(...args: Args) => A, never, R> {
|
||||
const context = yield* Effect.context<R>()
|
||||
const contextRef = React.useRef(context)
|
||||
contextRef.current = context
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies
|
||||
return React.useCallback((...args: Args) => Effect.runSyncWith(contextRef.current)(f(...args)), deps)
|
||||
})
|
||||
|
||||
export const useCallbackPromise = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
|
||||
f: (...args: Args) => Effect.Effect<A, E, R>,
|
||||
deps: React.DependencyList,
|
||||
): Effect.fn.Return<(...args: Args) => Promise<A>, never, R> {
|
||||
const context = yield* Effect.context<R>()
|
||||
const contextRef = React.useRef(context)
|
||||
contextRef.current = context
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies
|
||||
return React.useCallback((...args: Args) => Effect.runPromiseWith(contextRef.current)(f(...args)), deps)
|
||||
})
|
||||
|
||||
export declare namespace useContext {
|
||||
export interface Options extends useOnChange.Options {}
|
||||
}
|
||||
|
||||
export const useContextFromLayer = <ROut, E, RIn>(
|
||||
layer: Layer.Layer<ROut, E, RIn>,
|
||||
options?: useContext.Options,
|
||||
): Effect.Effect<Context.Context<ROut>, E, RIn | Scope.Scope> => useOnChange(
|
||||
() => Effect.flatMap(
|
||||
Effect.context<RIn>(),
|
||||
context => Layer.build(Layer.provide(layer, Layer.succeedContext(context))),
|
||||
),
|
||||
[layer],
|
||||
options,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
import { type Cause, Context, Effect, Layer, Option, Pipeable, Predicate, PubSub, type Scope } from "effect"
|
||||
|
||||
|
||||
export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
|
||||
export type ErrorObserverTypeId = typeof ErrorObserverTypeId
|
||||
|
||||
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
|
||||
readonly [ErrorObserverTypeId]: ErrorObserverTypeId
|
||||
handle<A, E1, R>(effect: Effect.Effect<A, E1, R>): Effect.Effect<A, E1, R>
|
||||
readonly subscribe: Effect.Effect<PubSub.Subscription<Cause.Cause<E>>, never, Scope.Scope>
|
||||
}
|
||||
|
||||
export const ErrorObserver = <E = never>() => Context.Service<ErrorObserver<E>>(
|
||||
"@effect-fc/ErrorObserver/ErrorObserver",
|
||||
)
|
||||
|
||||
export class ErrorObserverImpl<in out E = never> extends Pipeable.Class implements ErrorObserver<E> {
|
||||
readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId
|
||||
|
||||
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<E>>) {
|
||||
super()
|
||||
}
|
||||
|
||||
get subscribe(): Effect.Effect<PubSub.Subscription<Cause.Cause<E>>, never, Scope.Scope> {
|
||||
return PubSub.subscribe(this.pubsub)
|
||||
}
|
||||
|
||||
handle<A, E1, R>(effect: Effect.Effect<A, E1, R>): Effect.Effect<A, E1, R> {
|
||||
return Effect.tapCause(effect, cause => Effect.asVoid(
|
||||
PubSub.publish(this.pubsub, cause as unknown as Cause.Cause<E>),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, ErrorObserverTypeId)
|
||||
|
||||
export const layer: Layer.Layer<ErrorObserver> = Layer.effect(ErrorObserver())(
|
||||
Effect.map(PubSub.unbounded<Cause.Cause<never>>(), pubsub => new ErrorObserverImpl(pubsub)),
|
||||
)
|
||||
|
||||
export const handle = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => Effect.flatMap(
|
||||
Effect.serviceOption(ErrorObserver()),
|
||||
Option.match({
|
||||
onSome: observer => observer.handle(effect),
|
||||
onNone: () => effect,
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,279 @@
|
||||
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"
|
||||
|
||||
|
||||
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>
|
||||
extends Pipeable.Pipeable {
|
||||
readonly [FormTypeId]: FormTypeId
|
||||
|
||||
readonly path: P
|
||||
readonly value: Subscribable.Subscribable<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>
|
||||
}
|
||||
|
||||
export class FormImpl<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in 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 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>,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
|
||||
|
||||
|
||||
const filterIssuesByPath = (
|
||||
issues: readonly FormIssue[],
|
||||
path: readonly PropertyKey[],
|
||||
): readonly FormIssue[] => Array.filter(issues, issue =>
|
||||
issue.path.length >= path.length && Array.every(path, (p, i) => p === issue.path[i])
|
||||
)
|
||||
|
||||
export const focusObjectOn: {
|
||||
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
||||
self: Form<P, A, I, ER, EW>,
|
||||
key: K,
|
||||
): Form<readonly [...P, K], A[K], I[K], ER, EW>
|
||||
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
||||
key: K,
|
||||
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
|
||||
} = Function.dual(2, <P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
||||
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 path = [...form.path, key] as const
|
||||
|
||||
return new FormImpl(
|
||||
path,
|
||||
Subscribable.mapOption(form.value, a => a[key]),
|
||||
Lens.focusObjectOn(form.encodedValue, key),
|
||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||
form.isValidating,
|
||||
form.canCommit,
|
||||
form.isCommitting,
|
||||
)
|
||||
})
|
||||
|
||||
export const focusArrayAt: {
|
||||
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
|
||||
self: Form<P, A, I, ER, EW>,
|
||||
index: number,
|
||||
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementError, EW | Cause.NoSuchElementError>
|
||||
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
|
||||
index: number,
|
||||
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementError, EW | Cause.NoSuchElementError>
|
||||
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
|
||||
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 path = [...form.path, index] as const
|
||||
|
||||
return new FormImpl(
|
||||
path,
|
||||
Subscribable.mapOptionEffect(form.value, values => Effect.fromOption(Array.get(values, index))),
|
||||
Lens.focusArrayAt(form.encodedValue, index),
|
||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||
form.isValidating,
|
||||
form.canCommit,
|
||||
form.isCommitting,
|
||||
)
|
||||
})
|
||||
|
||||
export const focusTupleAt: {
|
||||
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
|
||||
self: Form<P, A, I, ER, EW>,
|
||||
index: K,
|
||||
): Form<readonly [...P, K], A[K], I[K], ER, EW>
|
||||
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
|
||||
index: K,
|
||||
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
|
||||
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
|
||||
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 path = [...form.path, index] as const
|
||||
|
||||
return new FormImpl(
|
||||
path,
|
||||
Subscribable.mapOption(form.value, values => values[index]),
|
||||
Lens.focusTupleAt(form.encodedValue, index),
|
||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||
form.isValidating,
|
||||
form.canCommit,
|
||||
form.isCommitting,
|
||||
)
|
||||
})
|
||||
|
||||
export const focusChunkAt: {
|
||||
<P extends readonly PropertyKey[], A, I, ER, EW>(
|
||||
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
|
||||
index: number,
|
||||
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementError, EW>
|
||||
<P extends readonly PropertyKey[], A, I, ER, EW>(
|
||||
index: number,
|
||||
): (self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>) => Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementError, EW>
|
||||
} = Function.dual(2, <P extends readonly PropertyKey[], A, I, ER, EW>(
|
||||
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 path = [...form.path, index] as const
|
||||
|
||||
return new FormImpl(
|
||||
path,
|
||||
Subscribable.mapOptionEffect(form.value, values => Effect.fromOption(Chunk.get(values, index))),
|
||||
Lens.focusChunkAt(form.encodedValue, index),
|
||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||
form.isValidating,
|
||||
form.canCommit,
|
||||
form.isCommitting,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
export namespace useInput {
|
||||
export interface Options {
|
||||
readonly debounce?: Duration.Input
|
||||
}
|
||||
|
||||
export interface Success<T> {
|
||||
readonly value: T
|
||||
readonly setValue: React.Dispatch<React.SetStateAction<T>>
|
||||
}
|
||||
}
|
||||
|
||||
export const useInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
|
||||
form: Form<P, A, I, ER, EW>,
|
||||
options?: useInput.Options,
|
||||
): Effect.fn.Return<useInput.Success<I>, ER, Scope.Scope> {
|
||||
const internalValueLens = yield* Component.useOnChange(() => Effect.gen(function*() {
|
||||
const internalValueLens = yield* Lens.get(form.encodedValue).pipe(
|
||||
Effect.flatMap(SubscriptionRef.make),
|
||||
Effect.map(Lens.fromSubscriptionRef),
|
||||
)
|
||||
|
||||
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.runForEach(
|
||||
internalValueLens.changes.pipe(
|
||||
Stream.drop(1),
|
||||
Stream.changesWith(Equal.asEquivalence()),
|
||||
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
||||
),
|
||||
internalValue => Lens.set(form.encodedValue, internalValue),
|
||||
),
|
||||
], { concurrency: "unbounded", discard: true }))
|
||||
|
||||
return internalValueLens
|
||||
}), [form, options?.debounce])
|
||||
|
||||
const [value, setValue] = yield* Lens.useState(internalValueLens)
|
||||
return { value, setValue }
|
||||
})
|
||||
|
||||
export namespace useOptionalInput {
|
||||
export interface Options<T> extends useInput.Options {
|
||||
readonly defaultValue: T
|
||||
}
|
||||
|
||||
export interface Success<T> extends useInput.Success<T> {
|
||||
readonly enabled: boolean
|
||||
readonly setEnabled: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
}
|
||||
|
||||
export const useOptionalInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
|
||||
field: Form<P, A, Option.Option<I>, ER, EW>,
|
||||
options: useOptionalInput.Options<I>,
|
||||
): Effect.fn.Return<useOptionalInput.Success<I>, ER, Scope.Scope> {
|
||||
const [enabledLens, internalValueLens] = yield* Component.useOnChange(() => Effect.gen(function*() {
|
||||
const [enabledLens, internalValueLens] = yield* Effect.flatMap(
|
||||
Lens.get(field.encodedValue),
|
||||
Option.match({
|
||||
onSome: v => Effect.all([
|
||||
Effect.map(SubscriptionRef.make(true), Lens.fromSubscriptionRef),
|
||||
Effect.map(SubscriptionRef.make(v), Lens.fromSubscriptionRef),
|
||||
]),
|
||||
onNone: () => Effect.all([
|
||||
Effect.map(SubscriptionRef.make(false), Lens.fromSubscriptionRef),
|
||||
Effect.map(SubscriptionRef.make(options.defaultValue), Lens.fromSubscriptionRef),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.forkScoped(Effect.all([
|
||||
Stream.runForEach(
|
||||
Stream.drop(field.encodedValue.changes, 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, {
|
||||
onSome: v => Effect.andThen(
|
||||
Lens.set(enabledLens, true),
|
||||
Lens.set(internalValueLens, v),
|
||||
),
|
||||
onNone: () => Effect.andThen(
|
||||
Lens.set(enabledLens, false),
|
||||
Lens.set(internalValueLens, options.defaultValue),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
Stream.runForEach(
|
||||
enabledLens.changes.pipe(
|
||||
Stream.zipLatest(internalValueLens.changes),
|
||||
Stream.drop(1),
|
||||
Stream.changesWith(Equal.asEquivalence()),
|
||||
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
||||
),
|
||||
([enabled, internalValue]) => Lens.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
||||
),
|
||||
], { concurrency: "unbounded" }))
|
||||
|
||||
return [enabledLens, internalValueLens] as const
|
||||
}), [field, options.debounce])
|
||||
|
||||
const [enabled, setEnabled] = yield* Lens.useState(enabledLens)
|
||||
const [value, setValue] = yield* Lens.useState(internalValueLens)
|
||||
return { enabled, setEnabled, value, setValue }
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Effect, Equivalence, Stream, SubscriptionRef } from "effect"
|
||||
import { Lens } from "effect-lens"
|
||||
import * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
import * as SetStateAction from "./SetStateAction.js"
|
||||
|
||||
|
||||
export * from "effect-lens/Lens"
|
||||
|
||||
export declare namespace useState {
|
||||
export interface Options<A> {
|
||||
readonly equivalence?: Equivalence.Equivalence<A>
|
||||
}
|
||||
}
|
||||
|
||||
export const useState = Effect.fnUntraced(function* <A, ER, EW, RR, RW>(
|
||||
lens: Lens.Lens<A, ER, EW, RR, RW>,
|
||||
options?: useState.Options<NoInfer<A>>,
|
||||
): Effect.fn.Return<readonly [A, React.Dispatch<React.SetStateAction<A>>], ER | EW, RR | RW> {
|
||||
const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => Lens.get(lens)))
|
||||
|
||||
yield* Component.useReactEffect(() => Effect.forkScoped(
|
||||
Stream.runForEach(
|
||||
Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strictEqual()),
|
||||
v => Effect.sync(() => setReactStateValue(v)),
|
||||
)
|
||||
), [lens])
|
||||
|
||||
const setValue = yield* Component.useCallbackSync(
|
||||
(setStateAction: React.SetStateAction<A>) => Effect.tap(
|
||||
Lens.updateAndGet(lens, prevState => SetStateAction.value(setStateAction, prevState)),
|
||||
v => Effect.sync(() => setReactStateValue(v)),
|
||||
),
|
||||
[lens],
|
||||
)
|
||||
|
||||
return [reactStateValue, setValue]
|
||||
})
|
||||
|
||||
export declare namespace useFromReactState {
|
||||
export interface Options<A> {
|
||||
readonly equivalence?: Equivalence.Equivalence<A>
|
||||
}
|
||||
}
|
||||
|
||||
export const useFromReactState = Effect.fnUntraced(function* <A>(
|
||||
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
|
||||
options?: useFromReactState.Options<NoInfer<A>>,
|
||||
): Effect.fn.Return<Lens.Lens<A, never, never, never, never>> {
|
||||
const lens = yield* Component.useOnMount(() => Effect.map(
|
||||
SubscriptionRef.make(value),
|
||||
Lens.fromSubscriptionRef,
|
||||
))
|
||||
|
||||
yield* Component.useReactEffect(() => Effect.forkScoped(Stream.runForEach(
|
||||
Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strictEqual()),
|
||||
v => Effect.sync(() => setValue(v)),
|
||||
)), [setValue])
|
||||
yield* Component.useReactEffect(() => Lens.set(lens, value), [value])
|
||||
|
||||
return lens
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||
import { type Equivalence, Function, Predicate } from "effect"
|
||||
import * as React from "react"
|
||||
import type * as Component from "./Component.js"
|
||||
|
||||
|
||||
export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
|
||||
export type MemoizedTypeId = typeof MemoizedTypeId
|
||||
|
||||
|
||||
/**
|
||||
* A trait for `Component`'s that uses `React.memo` to optimize re-renders based on prop equality.
|
||||
*
|
||||
* @template P The props type of the component
|
||||
*/
|
||||
export interface Memoized<P> extends MemoizedPrototype, MemoizedOptions<P> {}
|
||||
|
||||
export interface MemoizedPrototype {
|
||||
readonly [MemoizedTypeId]: MemoizedTypeId
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for Memoized components.
|
||||
*
|
||||
* @template P The props type of the component
|
||||
*/
|
||||
export interface MemoizedOptions<P> {
|
||||
/**
|
||||
* An optional equivalence function for comparing component props.
|
||||
* If provided, this function is used by React.memo to determine if props have changed.
|
||||
* Returns `true` if props are equivalent (no re-render), `false` if they differ (re-render).
|
||||
*/
|
||||
readonly propsEquivalence?: Equivalence.Equivalence<P>
|
||||
}
|
||||
|
||||
|
||||
export const MemoizedPrototype: MemoizedPrototype = Object.freeze({
|
||||
[MemoizedTypeId]: MemoizedTypeId,
|
||||
|
||||
transformFunctionComponent<P extends {}>(
|
||||
this: Memoized<P>,
|
||||
f: React.FC<P>,
|
||||
) {
|
||||
return React.memo(f, this.propsEquivalence)
|
||||
},
|
||||
} as const)
|
||||
|
||||
|
||||
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, MemoizedTypeId)
|
||||
|
||||
/**
|
||||
* Converts a Component into a `Memoized` component that optimizes re-renders using `React.memo`.
|
||||
*
|
||||
* @param self - The component to convert to a Memoized component
|
||||
* @returns A new `Memoized` component with the same body, error, and context types as the input
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const MyMemoizedComponent = MyComponent.pipe(
|
||||
* Memoized.memoized,
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export const memoized = <T extends Component.Component.Any>(
|
||||
self: T
|
||||
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self),
|
||||
Object.freeze(Object.setPrototypeOf(
|
||||
Object.assign({}, MemoizedPrototype),
|
||||
Object.getPrototypeOf(self),
|
||||
)),
|
||||
)
|
||||
|
||||
/**
|
||||
* Applies options to a Memoized component, returning a new Memoized component with the updated configuration.
|
||||
*
|
||||
* Supports both curried and uncurried application styles.
|
||||
*
|
||||
* @param self - The Memoized component to apply options to (in uncurried form)
|
||||
* @param options - The options to apply to the component
|
||||
* @returns A Memoized component with the applied options
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Curried
|
||||
* const MyMemoizedComponent = MyComponent.pipe(
|
||||
* Memoized.memoized,
|
||||
* Memoized.withOptions({ propsEquivalence: (a, b) => a.id === b.id }),
|
||||
* )
|
||||
*
|
||||
* // Uncurried
|
||||
* const MyMemoizedComponent = Memoized.withOptions(
|
||||
* Memoized.memoized(MyComponent),
|
||||
* { propsEquivalence: (a, b) => a.id === b.id },
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export const withOptions: {
|
||||
<T extends Component.Component.Any & Memoized<any>>(
|
||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>
|
||||
): (self: T) => T
|
||||
<T extends Component.Component.Any & Memoized<any>>(
|
||||
self: T,
|
||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
|
||||
): T
|
||||
} = Function.dual(2, <T extends Component.Component.Any & Memoized<any>>(
|
||||
self: T,
|
||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
|
||||
): T => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self, options),
|
||||
Object.getPrototypeOf(self),
|
||||
))
|
||||
@@ -0,0 +1,146 @@
|
||||
import { type Context, Effect, Equal, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
|
||||
import { Subscribable } from "effect-lens"
|
||||
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 Mutation.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
|
||||
extends Pipeable.Pipeable {
|
||||
readonly [MutationTypeId]: MutationTypeId
|
||||
|
||||
readonly context: Context.Context<Scope.Scope | R>
|
||||
readonly f: (key: K) => Effect.Effect<A, E, R>
|
||||
readonly initialProgress: P
|
||||
|
||||
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 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
|
||||
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>>>
|
||||
|
||||
constructor(
|
||||
readonly context: Context.Context<Scope.Scope | R>,
|
||||
readonly f: (key: K) => Effect.Effect<A, E, R>,
|
||||
readonly initialProgress: P,
|
||||
|
||||
readonly latestKeyRef: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
|
||||
readonly fiberRef: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
|
||||
readonly resultRef: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>,
|
||||
readonly latestFinalResultRef: SubscriptionRef.SubscriptionRef<Option.Option<Result.Final<A, E, P>>>,
|
||||
) {
|
||||
super()
|
||||
this.latestKey = fromSubscriptionRef(latestKeyRef)
|
||||
this.fiber = fromSubscriptionRef(fiberRef)
|
||||
this.result = fromSubscriptionRef(resultRef)
|
||||
this.latestFinalResult = fromSubscriptionRef(latestFinalResultRef)
|
||||
}
|
||||
|
||||
mutate(key: K): Effect.Effect<Result.Final<A, E, P>> {
|
||||
return SubscriptionRef.set(this.latestKeyRef, Option.some(key)).pipe(
|
||||
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 SubscriptionRef.set(this.latestKeyRef, Option.some(key)).pipe(
|
||||
Effect.andThen(this.start(key)),
|
||||
Effect.tap(sub => Effect.forkScoped(this.watch(sub))),
|
||||
Effect.provide(this.context),
|
||||
)
|
||||
}
|
||||
|
||||
start(key: K): Effect.Effect<
|
||||
Subscribable.Subscribable<Result.Result<A, E, P>>,
|
||||
never,
|
||||
Scope.Scope | R
|
||||
> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
const initial = yield* SubscriptionRef.get(self.latestFinalResultRef)
|
||||
const [sub, fiber] = yield* Result.unsafeForkEffect<A, E, R, P>(
|
||||
Effect.onExit(self.f(key), () => Effect.andThen(
|
||||
Effect.all([Effect.fiberId, SubscriptionRef.get(self.fiberRef)]),
|
||||
([currentFiberId, fiber]) => Option.match(fiber, {
|
||||
onSome: v => Equal.equals(currentFiberId, v.id)
|
||||
? SubscriptionRef.set(self.fiberRef, Option.none())
|
||||
: Effect.succeed(undefined),
|
||||
onNone: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)),
|
||||
|
||||
{
|
||||
initial: Option.isSome(initial) ? Result.willFetch(initial.value) : Result.initial(),
|
||||
initialProgress: self.initialProgress,
|
||||
} as Result.unsafeForkEffect.Options<A, E, P>,
|
||||
)
|
||||
yield* SubscriptionRef.set(self.fiberRef, Option.some(fiber))
|
||||
return sub
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
sub: Subscribable.Subscribable<Result.Result<A, E, P>>
|
||||
): Effect.Effect<Result.Final<A, E, P>> {
|
||||
return sub.get.pipe(
|
||||
Effect.andThen(initial => Stream.runFoldEffect(
|
||||
Stream.takeUntil(sub.changes, result => Result.isFinal(result) && !Result.hasFlag(result)),
|
||||
() => initial,
|
||||
(_, result) => Effect.as(SubscriptionRef.set(this.resultRef, result), result),
|
||||
) as Effect.Effect<Result.Final<A, E, P>>),
|
||||
Effect.tap(result => SubscriptionRef.set(this.latestFinalResultRef, Option.some(result))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 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 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<R, P>, P>,
|
||||
never,
|
||||
Scope.Scope | Result.forkEffect.OutputContext<R, P>
|
||||
> {
|
||||
return new MutationImpl(
|
||||
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<R, P>>(),
|
||||
options.f as any,
|
||||
options.initialProgress as P,
|
||||
|
||||
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>>()),
|
||||
)
|
||||
})
|
||||
|
||||
const fromSubscriptionRef = <A>(ref: SubscriptionRef.SubscriptionRef<A>): Subscribable.Subscribable<A> => Subscribable.make({
|
||||
get: SubscriptionRef.get(ref),
|
||||
changes: SubscriptionRef.changes(ref),
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Effect, PubSub, type Scope } from "effect"
|
||||
import type * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
|
||||
|
||||
export const useFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
|
||||
values: A
|
||||
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
|
||||
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
|
||||
yield* Component.useReactEffect(() => Effect.flatMap(
|
||||
PubSub.isShutdown(pubsub),
|
||||
shutdown => shutdown ? Effect.succeed(undefined) : Effect.asVoid(PubSub.publish(pubsub, values)),
|
||||
), values)
|
||||
return pubsub
|
||||
})
|
||||
|
||||
export * from "effect/PubSub"
|
||||
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
type Cause,
|
||||
type Context,
|
||||
type Duration,
|
||||
Effect,
|
||||
Equal,
|
||||
Fiber,
|
||||
Option,
|
||||
Pipeable,
|
||||
Predicate,
|
||||
type Scope,
|
||||
Semaphore,
|
||||
Stream,
|
||||
SubscriptionRef,
|
||||
} from "effect"
|
||||
import { Subscribable } from "effect-lens"
|
||||
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 Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never, in out P = never>
|
||||
extends Pipeable.Pipeable {
|
||||
readonly [QueryTypeId]: QueryTypeId
|
||||
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>
|
||||
readonly key: Stream.Stream<K, KE, KR>
|
||||
readonly f: (key: K) => Effect.Effect<A, E, R>
|
||||
readonly initialProgress: P
|
||||
readonly staleTime: Duration.Input
|
||||
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 refresh: Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementError>
|
||||
readonly refreshSubscribable: Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementError>
|
||||
readonly invalidateCache: Effect.Effect<void>
|
||||
invalidateCacheEntry(key: K): Effect.Effect<void>
|
||||
}
|
||||
|
||||
export declare namespace Query {
|
||||
export type AnyKey = readonly any[]
|
||||
}
|
||||
|
||||
export class QueryImpl<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never, in out P = never>
|
||||
extends Pipeable.Class implements Query<K, A, KE, KR, E, R, P> {
|
||||
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
|
||||
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>>>
|
||||
|
||||
constructor(
|
||||
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>,
|
||||
readonly key: Stream.Stream<K, KE, KR>,
|
||||
readonly f: (key: K) => Effect.Effect<A, E, R>,
|
||||
readonly initialProgress: P,
|
||||
readonly staleTime: Duration.Input,
|
||||
readonly refreshOnWindowFocus: boolean,
|
||||
readonly latestKeyRef: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
|
||||
readonly fiberRef: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
|
||||
readonly resultRef: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>,
|
||||
readonly latestFinalResultRef: SubscriptionRef.SubscriptionRef<Option.Option<Result.Final<A, E, P>>>,
|
||||
readonly runSemaphore: Semaphore.Semaphore,
|
||||
) {
|
||||
super()
|
||||
this.latestKey = fromSubscriptionRef(latestKeyRef)
|
||||
this.fiber = fromSubscriptionRef(fiberRef)
|
||||
this.result = fromSubscriptionRef(resultRef)
|
||||
this.latestFinalResult = fromSubscriptionRef(latestFinalResultRef)
|
||||
}
|
||||
|
||||
get run(): Effect.Effect<void> {
|
||||
const focus = this.refreshOnWindowFocus && typeof window !== "undefined"
|
||||
? Stream.runForEach(Stream.fromEventListener<FocusEvent>(window, "focus"), () => this.refreshSubscribable)
|
||||
: Effect.succeed(undefined)
|
||||
return Effect.all([
|
||||
Stream.runForEach(this.key, key => this.fetchSubscribable(key)),
|
||||
focus,
|
||||
], { concurrency: "unbounded", discard: true }).pipe(
|
||||
Effect.ignore,
|
||||
this.runSemaphore.withPermits(1),
|
||||
Effect.provide(this.context),
|
||||
)
|
||||
}
|
||||
|
||||
get interrupt(): Effect.Effect<void> {
|
||||
return Effect.flatMap(SubscriptionRef.get(this.fiberRef), Option.match({
|
||||
onSome: Fiber.interrupt,
|
||||
onNone: () => Effect.succeed(undefined),
|
||||
}))
|
||||
}
|
||||
|
||||
fetch(key: K): Effect.Effect<Result.Final<A, E, P>> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
yield* self.interrupt
|
||||
yield* SubscriptionRef.set(self.latestKeyRef, Option.some(key))
|
||||
const previous = yield* SubscriptionRef.get(self.latestFinalResultRef)
|
||||
const sub = yield* self.startCached(key, Option.isSome(previous)
|
||||
? Result.willFetch(previous.value) as Result.Final<A, E, P>
|
||||
: Result.initial())
|
||||
return yield* self.watch(key, sub)
|
||||
}).pipe(Effect.provide(this.context))
|
||||
}
|
||||
|
||||
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
yield* self.interrupt
|
||||
yield* SubscriptionRef.set(self.latestKeyRef, Option.some(key))
|
||||
const previous = yield* SubscriptionRef.get(self.latestFinalResultRef)
|
||||
const sub = yield* self.startCached(key, Option.isSome(previous)
|
||||
? Result.willFetch(previous.value) as Result.Final<A, E, P>
|
||||
: Result.initial())
|
||||
yield* Effect.forkScoped(self.watch(key, sub))
|
||||
return sub
|
||||
}).pipe(Effect.provide(this.context))
|
||||
}
|
||||
|
||||
get refresh(): Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementError> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
yield* self.interrupt
|
||||
const key = yield* Effect.fromOption(yield* SubscriptionRef.get(self.latestKeyRef))
|
||||
const previous = yield* SubscriptionRef.get(self.latestFinalResultRef)
|
||||
const sub = yield* self.startCached(key, Option.isSome(previous)
|
||||
? Result.willRefresh(previous.value) as Result.Final<A, E, P>
|
||||
: Result.initial())
|
||||
return yield* self.watch(key, sub)
|
||||
}).pipe(Effect.provide(this.context))
|
||||
}
|
||||
|
||||
get refreshSubscribable(): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementError> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
yield* self.interrupt
|
||||
const key = yield* Effect.fromOption(yield* SubscriptionRef.get(self.latestKeyRef))
|
||||
const previous = yield* SubscriptionRef.get(self.latestFinalResultRef)
|
||||
const sub = yield* self.startCached(key, Option.isSome(previous)
|
||||
? Result.willRefresh(previous.value) as Result.Final<A, E, P>
|
||||
: Result.initial())
|
||||
yield* Effect.forkScoped(self.watch(key, sub))
|
||||
return sub
|
||||
}).pipe(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.flatMap(this.getCacheEntry(key), Option.match({
|
||||
onSome: entry => Effect.flatMap(
|
||||
QueryClient.isQueryClientCacheEntryStale(entry),
|
||||
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>),
|
||||
changes: Stream.make(entry.result as Result.Result<A, E, P>),
|
||||
})),
|
||||
),
|
||||
onNone: () => this.start(key, initial),
|
||||
}))
|
||||
}
|
||||
|
||||
start(
|
||||
key: K,
|
||||
initial: Result.Initial | Result.Final<A, E, P>,
|
||||
): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, never, Scope.Scope | R> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
const [sub, fiber] = yield* Result.unsafeForkEffect<A, E, R, P>(
|
||||
Effect.onExit(self.f(key), () => Effect.flatMap(
|
||||
Effect.all([Effect.fiberId, SubscriptionRef.get(self.fiberRef)]),
|
||||
([currentFiberId, current]) => Option.match(current, {
|
||||
onSome: value => Equal.equals(currentFiberId, value.id)
|
||||
? SubscriptionRef.set(self.fiberRef, Option.none())
|
||||
: Effect.succeed(undefined),
|
||||
onNone: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)),
|
||||
{ initial, initialProgress: self.initialProgress },
|
||||
)
|
||||
yield* SubscriptionRef.set(self.fiberRef, Option.some(fiber))
|
||||
return sub
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
key: K,
|
||||
sub: Subscribable.Subscribable<Result.Result<A, E, P>>,
|
||||
): Effect.Effect<Result.Final<A, E, P>, never, QueryClient.QueryClient> {
|
||||
return Effect.flatMap(sub.get, initial => Stream.runFoldEffect(
|
||||
Stream.takeUntil(sub.changes, result => Result.isFinal(result) && !Result.hasFlag(result)),
|
||||
() => initial,
|
||||
(_, result) => Effect.as(SubscriptionRef.set(this.resultRef, result), result),
|
||||
) as Effect.Effect<Result.Final<A, E, P>>).pipe(
|
||||
Effect.tap(result => SubscriptionRef.set(this.latestFinalResultRef, Option.some(result))),
|
||||
Effect.tap(result => Result.isSuccess(result)
|
||||
? Effect.asVoid(this.setCacheEntry(key, result))
|
||||
: Effect.succeed(undefined)),
|
||||
)
|
||||
}
|
||||
|
||||
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 Effect.flatMap(QueryClient.QueryClient, client => client.getCacheEntry(this.makeCacheKey(key)))
|
||||
}
|
||||
|
||||
setCacheEntry(key: K, result: Result.Success<A>): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> {
|
||||
return Effect.flatMap(QueryClient.QueryClient, client => client.setCacheEntry(this.makeCacheKey(key), result, this.staleTime))
|
||||
}
|
||||
|
||||
get invalidateCache(): Effect.Effect<void> {
|
||||
return Effect.flatMap(
|
||||
QueryClient.QueryClient,
|
||||
client => client.invalidateCacheEntries(this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>),
|
||||
).pipe(Effect.provide(this.context))
|
||||
}
|
||||
|
||||
invalidateCacheEntry(key: K): Effect.Effect<void> {
|
||||
return Effect.flatMap(
|
||||
QueryClient.QueryClient,
|
||||
client => client.invalidateCacheEntry(this.makeCacheKey(key)),
|
||||
).pipe(Effect.provide(this.context))
|
||||
}
|
||||
}
|
||||
|
||||
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown> => Predicate.hasProperty(u, QueryTypeId)
|
||||
|
||||
export declare namespace make {
|
||||
export interface Options<K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never> {
|
||||
readonly key: Stream.Stream<K, KE, KR>
|
||||
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
|
||||
readonly initialProgress?: P
|
||||
readonly staleTime?: Duration.Input
|
||||
readonly refreshOnWindowFocus?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
|
||||
options: make.Options<K, A, KE, KR, E, R, P>,
|
||||
): Effect.fn.Return<
|
||||
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
|
||||
never,
|
||||
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
|
||||
> {
|
||||
const client = yield* QueryClient.QueryClient
|
||||
return new QueryImpl<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>(
|
||||
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<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* Semaphore.make(1),
|
||||
)
|
||||
})
|
||||
|
||||
export const service = <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
|
||||
options: make.Options<K, A, KE, KR, E, R, P>,
|
||||
): Effect.Effect<
|
||||
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
|
||||
never,
|
||||
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
|
||||
> => Effect.tap(make(options), query => Effect.asVoid(Effect.forkScoped(query.run)))
|
||||
|
||||
const fromSubscriptionRef = <A>(ref: SubscriptionRef.SubscriptionRef<A>): Subscribable.Subscribable<A> => Subscribable.make({
|
||||
get: SubscriptionRef.get(ref),
|
||||
changes: SubscriptionRef.changes(ref),
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
Context,
|
||||
DateTime,
|
||||
Duration,
|
||||
Effect,
|
||||
Equal,
|
||||
Equivalence,
|
||||
Hash,
|
||||
HashMap,
|
||||
Layer,
|
||||
Option,
|
||||
Pipeable,
|
||||
Predicate,
|
||||
Schedule,
|
||||
Scope,
|
||||
Semaphore,
|
||||
SubscriptionRef,
|
||||
} from "effect"
|
||||
import { Subscribable } from "effect-lens"
|
||||
import type * as Query from "./Query.js"
|
||||
import type * as Result from "./Result.js"
|
||||
|
||||
|
||||
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService")
|
||||
export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
|
||||
|
||||
export interface QueryClientService extends Pipeable.Pipeable {
|
||||
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
|
||||
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
|
||||
readonly cacheGcTime: Duration.Input
|
||||
readonly defaultStaleTime: Duration.Input
|
||||
readonly defaultRefreshOnWindowFocus: boolean
|
||||
readonly run: Effect.Effect<void>
|
||||
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>>
|
||||
setCacheEntry(key: QueryClientCacheKey, result: Result.Success<unknown>, staleTime: Duration.Input): Effect.Effect<QueryClientCacheEntry>
|
||||
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void>
|
||||
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class QueryClient extends Context.Service<QueryClient, QueryClientService>()(
|
||||
"@effect-fc/QueryClient/QueryClient",
|
||||
) {
|
||||
static get Default(): Layer.Layer<QueryClient> {
|
||||
return Layer.effect(QueryClient)(service())
|
||||
}
|
||||
}
|
||||
|
||||
export class QueryClientServiceImpl extends Pipeable.Class implements QueryClientService {
|
||||
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId
|
||||
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
|
||||
|
||||
constructor(
|
||||
readonly cacheRef: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
|
||||
readonly cacheGcTime: Duration.Input,
|
||||
readonly defaultStaleTime: Duration.Input,
|
||||
readonly defaultRefreshOnWindowFocus: boolean,
|
||||
readonly runSemaphore: Semaphore.Semaphore,
|
||||
) {
|
||||
super()
|
||||
this.cache = Subscribable.make({
|
||||
get: SubscriptionRef.get(cacheRef),
|
||||
changes: SubscriptionRef.changes(cacheRef),
|
||||
})
|
||||
}
|
||||
|
||||
get run(): Effect.Effect<void> {
|
||||
return this.runSemaphore.withPermits(1)(Effect.repeat(
|
||||
Effect.flatMap(DateTime.now, now => SubscriptionRef.update(this.cacheRef, HashMap.filter(entry =>
|
||||
Duration.isLessThan(
|
||||
DateTime.distance(entry.lastAccessedAt, now),
|
||||
Duration.sum(Duration.fromInputUnsafe(entry.staleTime), Duration.fromInputUnsafe(this.cacheGcTime)),
|
||||
)
|
||||
))),
|
||||
Schedule.spaced("30 seconds"),
|
||||
))
|
||||
}
|
||||
|
||||
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>> {
|
||||
const self = this
|
||||
return Effect.gen(function*() {
|
||||
const entry = HashMap.get(yield* SubscriptionRef.get(self.cacheRef), key)
|
||||
if (Option.isNone(entry)) return Option.none()
|
||||
const now = yield* DateTime.now
|
||||
const accessed = new QueryClientCacheEntry(
|
||||
entry.value.result,
|
||||
entry.value.staleTime,
|
||||
entry.value.createdAt,
|
||||
now,
|
||||
)
|
||||
yield* SubscriptionRef.update(self.cacheRef, HashMap.set(key, accessed))
|
||||
return Option.some(accessed)
|
||||
})
|
||||
}
|
||||
|
||||
setCacheEntry(
|
||||
key: QueryClientCacheKey,
|
||||
result: Result.Success<unknown>,
|
||||
staleTime: Duration.Input,
|
||||
): Effect.Effect<QueryClientCacheEntry> {
|
||||
return Effect.flatMap(DateTime.now, now => {
|
||||
const entry = new QueryClientCacheEntry(result, staleTime, now, now)
|
||||
return Effect.as(SubscriptionRef.update(this.cacheRef, HashMap.set(key, entry)), entry)
|
||||
})
|
||||
}
|
||||
|
||||
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void> {
|
||||
return SubscriptionRef.update(this.cacheRef, HashMap.filter((_, key) => !Equivalence.strictEqual()(key.f, f)))
|
||||
}
|
||||
|
||||
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void> {
|
||||
return SubscriptionRef.update(this.cacheRef, HashMap.remove(key))
|
||||
}
|
||||
}
|
||||
|
||||
export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId)
|
||||
|
||||
export declare namespace make {
|
||||
export interface Options {
|
||||
readonly cacheGcTime?: Duration.Input
|
||||
readonly defaultStaleTime?: Duration.Input
|
||||
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.cacheGcTime ?? "5 minutes",
|
||||
options.defaultStaleTime ?? "0 minutes",
|
||||
options.defaultRefreshOnWindowFocus ?? true,
|
||||
yield* Semaphore.make(1),
|
||||
)
|
||||
})
|
||||
|
||||
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.asVoid(Effect.forkScoped(client.run)),
|
||||
)
|
||||
|
||||
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheKey")
|
||||
export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId
|
||||
|
||||
export class QueryClientCacheKey extends Pipeable.Class implements 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): boolean {
|
||||
return isQueryClientCacheKey(that)
|
||||
&& Equivalence.Array(Equal.asEquivalence())(this.key, that.key)
|
||||
&& Equivalence.strictEqual()(this.f, that.f)
|
||||
}
|
||||
|
||||
[Hash.symbol](): number {
|
||||
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 {
|
||||
readonly [QueryClientCacheEntryTypeId]: QueryClientCacheEntryTypeId = QueryClientCacheEntryTypeId
|
||||
|
||||
constructor(
|
||||
readonly result: Result.Success<unknown>,
|
||||
readonly staleTime: Duration.Input,
|
||||
readonly createdAt: DateTime.DateTime,
|
||||
readonly lastAccessedAt: DateTime.DateTime,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
|
||||
export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId)
|
||||
|
||||
export const isQueryClientCacheEntryStale = (self: QueryClientCacheEntry): Effect.Effect<boolean> => Effect.map(
|
||||
DateTime.now,
|
||||
now => Duration.isGreaterThanOrEqualTo(DateTime.distance(self.createdAt, now), Duration.fromInputUnsafe(self.staleTime)),
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
/** biome-ignore-all lint/complexity/useArrowFunction: React component names are intentional */
|
||||
import { Context, Layer, ManagedRuntime, Predicate } from "effect"
|
||||
import * as React from "react"
|
||||
import * as ErrorObserver from "./ErrorObserver.js"
|
||||
import * as QueryClient from "./QueryClient.js"
|
||||
|
||||
|
||||
export const TypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime")
|
||||
export type TypeId = typeof TypeId
|
||||
|
||||
export interface ReactRuntime<R, ER> {
|
||||
new(_: never): Record<string, never>
|
||||
readonly [TypeId]: TypeId
|
||||
readonly runtime: ManagedRuntime.ManagedRuntime<R, ER>
|
||||
readonly context: React.Context<Context.Context<R>>
|
||||
}
|
||||
|
||||
const ReactRuntimeProto = Object.freeze({ [TypeId]: TypeId } as const)
|
||||
|
||||
export const Prelude: Layer.Layer<ErrorObserver.ErrorObserver | QueryClient.QueryClient> = Layer.merge(
|
||||
ErrorObserver.layer,
|
||||
QueryClient.QueryClient.Default,
|
||||
)
|
||||
|
||||
export const isReactRuntime = (u: unknown): u is ReactRuntime<unknown, unknown> => Predicate.hasProperty(u, TypeId)
|
||||
|
||||
export const make = <R, ER>(
|
||||
layer: Layer.Layer<R, ER>,
|
||||
memoMap?: Layer.MemoMap,
|
||||
): ReactRuntime<R | ErrorObserver.ErrorObserver | QueryClient.QueryClient, ER> => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, {
|
||||
runtime: ManagedRuntime.make(Layer.merge(layer, Prelude), { memoMap }),
|
||||
// biome-ignore lint/style/noNonNullAssertion: initialized by Provider before consumers render
|
||||
context: React.createContext<Context.Context<R | ErrorObserver.ErrorObserver | QueryClient.QueryClient>>(null!),
|
||||
}),
|
||||
ReactRuntimeProto,
|
||||
)
|
||||
|
||||
export namespace Provider {
|
||||
export interface Props<R, ER> extends React.SuspenseProps {
|
||||
readonly runtime: ReactRuntime<R, ER>
|
||||
readonly children?: React.ReactNode
|
||||
}
|
||||
}
|
||||
|
||||
export const Provider = <R, ER>(
|
||||
{ runtime, children, ...suspenseProps }: Provider.Props<R, ER>,
|
||||
): React.ReactNode => {
|
||||
const promise = React.useMemo(() => runtime.runtime.context(), [runtime])
|
||||
|
||||
return React.createElement(
|
||||
React.Suspense,
|
||||
suspenseProps,
|
||||
React.createElement(ProviderInner<R, ER>, { runtime, promise, children }),
|
||||
)
|
||||
}
|
||||
|
||||
const ProviderInner = <R, ER>(
|
||||
{ runtime, promise, children }: {
|
||||
readonly runtime: ReactRuntime<R, ER>
|
||||
readonly promise: Promise<Context.Context<R>>
|
||||
readonly children?: React.ReactNode
|
||||
},
|
||||
): React.ReactNode => {
|
||||
const context = React.use(promise)
|
||||
React.useEffect(() => () => {
|
||||
void runtime.runtime.dispose()
|
||||
}, [runtime])
|
||||
|
||||
return React.createElement(runtime.context, { value: context }, children)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Function } from "effect"
|
||||
import type * as React from "react"
|
||||
|
||||
|
||||
export const value: {
|
||||
<S>(self: React.SetStateAction<S>, prevState: S): S
|
||||
<S>(prevState: S): (self: React.SetStateAction<S>) => S
|
||||
} = Function.dual(2, <S>(self: React.SetStateAction<S>, prevState: S): S =>
|
||||
typeof self === "function"
|
||||
? (self as (prevState: S) => S)(prevState)
|
||||
: self
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Effect, Equivalence, Option, Stream } from "effect"
|
||||
import * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
|
||||
|
||||
export const use: {
|
||||
<A, E, R>(
|
||||
stream: Stream.Stream<A, E, R>
|
||||
): Effect.Effect<Option.Option<A>, never, R>
|
||||
<A extends NonNullable<unknown>, E, R>(
|
||||
stream: Stream.Stream<A, E, R>,
|
||||
initialValue: A,
|
||||
): Effect.Effect<Option.Some<A>, never, R>
|
||||
} = Effect.fnUntraced(function* <A extends NonNullable<unknown>, E, R>(
|
||||
stream: Stream.Stream<A, E, R>,
|
||||
initialValue?: A,
|
||||
) {
|
||||
const [reactStateValue, setReactStateValue] = React.useState(() => initialValue
|
||||
? Option.some(initialValue)
|
||||
: Option.none()
|
||||
)
|
||||
|
||||
yield* Component.useReactEffect(() => Effect.forkScoped(
|
||||
Stream.runForEach(
|
||||
Stream.changesWith(stream, Equivalence.strictEqual()),
|
||||
v => Effect.sync(() => setReactStateValue(Option.some(v))),
|
||||
)
|
||||
), [stream])
|
||||
|
||||
return reactStateValue as Option.Some<A>
|
||||
})
|
||||
|
||||
export * from "effect/Stream"
|
||||
@@ -0,0 +1,212 @@
|
||||
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)))
|
||||
@@ -0,0 +1,53 @@
|
||||
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
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
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,17 @@
|
||||
export * as Async from "./Async.js"
|
||||
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 Memoized from "./Memoized.js"
|
||||
export * as Mutation from "./Mutation.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"
|
||||
@@ -0,0 +1,3 @@
|
||||
export type ExcludeKeys<T, K extends PropertyKey> = K extends keyof T ? (
|
||||
{ [P in K]?: never } & Omit<T, K>
|
||||
) : T
|
||||
Reference in New Issue
Block a user