1 Commits

Author SHA1 Message Date
renovate-bot 52542b3a27 Update dependency jsdom to v29
Lint / lint (push) Failing after 7s
Test build / test-build (pull_request) Failing after 8s
2026-06-22 12:01:16 +00:00
18 changed files with 939 additions and 669 deletions
+5 -15
View File
@@ -58,18 +58,16 @@
"name": "effect-fc-next",
"version": "0.1.0-beta.0",
"dependencies": {
"effect-lens": "^2.0.0-beta.1",
"effect-lens": "2.0.0-beta.0",
},
"devDependencies": {
"@effect/platform-browser": "4.0.0-beta.98",
"@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.98",
"jsdom": "^26.1.0",
"vitest": "^3.2.4",
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "4.0.0-beta.98",
"effect": "4.0.0-beta.85",
"react": "^19.2.0",
},
},
@@ -3064,11 +3062,9 @@
"dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
"effect-fc-next/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.98", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-Y0BEe0N8clKmJTHqmZpS85pLZbqAxP5uV3IYF0tDw/1dcs8AbTRHjbRmOLdyLtmU32NBVAiaUD3Vopb5Et5R/g=="],
"effect-fc-next/effect": ["effect@4.0.0-beta.85", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Cjv9YQyv4CiIccmRAQIWAoeESCpCpiuHYY8zb5vqiYs3Ac2yE5RQAnKq7z4Ir/3VJYZb8kxh6rS8czDDnpTdkQ=="],
"effect-fc-next/effect": ["effect@4.0.0-beta.98", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-oz+bsG5h+6RNrw4t5GMfQrk/xBS8ROoqkYsuvRhBr5O7mCOrpvH/hbw+QrDzvKIpX4HJClwm86F94c87W0sJxg=="],
"effect-fc-next/effect-lens": ["effect-lens@2.0.0-beta.1", "", { "peerDependencies": { "effect": "4.0.0-beta.98" } }, "sha512-3MwsrezPczZHH4Tl2SvliZdiNRq9e/XbH+Vr1ckr/FPCEp6Wv8IYQL4Ie/hhI+XBiLlxbQERbgL8FlHW21avtw=="],
"effect-fc-next/effect-lens": ["effect-lens@2.0.0-beta.0", "", { "peerDependencies": { "effect": "4.0.0-beta.85" } }, "sha512-ntsRQSrzoX+GsNpkNVEGqebl/g1YOEDPIFRAHigf+4P6AtGnnOFcklqDHLrbm4zqnaxuWEgQvvn1J6S3eyC4yw=="],
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
@@ -3374,16 +3370,10 @@
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
"effect-fc-next/@effect/platform-browser/multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="],
"effect-fc-next/effect/fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
"effect-fc-next/effect/fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
"effect-fc-next/effect/msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
"effect-fc-next/effect/multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="],
"effect-fc-next/effect/toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"file-loader/schema-utils/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
+2 -4
View File
@@ -39,18 +39,16 @@
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect/platform-browser": "4.0.0-beta.98",
"@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.98",
"jsdom": "^29.0.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "4.0.0-beta.98",
"effect": "4.0.0-beta.85",
"react": "^19.2.0"
},
"dependencies": {
"effect-lens": "^2.0.0-beta.1"
"effect-lens": "2.0.0-beta.0"
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { type Context, Effect, type Equivalence, Function, Predicate, Scope } from "effect"
import { Context, Effect, type Equivalence, Function, Predicate, Scope } from "effect"
import * as React from "react"
import * as Component from "./Component.js"
@@ -45,7 +45,7 @@ export const AsyncPrototype: AsyncPrototype = Object.freeze({
return ({ fallback, name, ...props }: AsyncProps) => {
const promise = Effect.runPromiseWith(contextRef.current)(
Effect.flatMap(
Effect.andThen(
Component.useScope([], this),
scope => Effect.provideService(this.body(props as P), Scope.Scope, scope),
)
@@ -0,0 +1,385 @@
/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */
import {
type 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(() => {}, 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(() => {}, 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,
)
+19 -29
View File
@@ -1,6 +1,6 @@
/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Context, type Duration, Effect, Equivalence, Exit, Fiber, Function, HashMap, identity, Layer, Option, Pipeable, Predicate, Ref, Scope, Tracer } from "effect"
import { Context, type Duration, Effect, Equivalence, ExecutionStrategy, Exit, Fiber, Function, HashMap, identity, Layer, Option, Pipeable, Predicate, Ref, Runtime, Scope, Tracer } from "effect"
import * as React from "react"
@@ -59,7 +59,7 @@ export const ComponentImplPrototype: ComponentImplPrototype<any, any> = Object.f
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>,
) {
return (props: P) => Effect.runSyncWith(contextRef.current)(
Effect.flatMap(
Effect.andThen(
useScope([], this),
scope => Effect.provideService(this.body(props), Scope.Scope, scope),
)
@@ -83,26 +83,16 @@ const use = Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode
const contextRef = React.useRef<Context.Context<Exclude<R, Scope.Scope>>>(null!)
contextRef.current = yield* Effect.context<Exclude<R, Scope.Scope>>()
const componentRef = React.useRef<F | null>(null)
const previousServicesRef = React.useRef<readonly any[] | null>(null)
const services = Array.from(
Context.omit(...self.nonReactiveTags)(contextRef.current).mapUnsafe.values()
)
if (
!componentRef.current ||
!previousServicesRef.current ||
services.length !== previousServicesRef.current.length ||
!Equivalence.Array(Equivalence.strictEqual())(services, previousServicesRef.current)
) {
previousServicesRef.current = services
return yield* React.useState(() => Effect.runSyncWith(contextRef.current)(Effect.cachedFunction(
(_services: readonly any[]) => Effect.sync(() => {
const f = self.asFunctionComponent(contextRef)
self.setFunctionComponentName(f)
componentRef.current = self.transformFunctionComponent(f)
}
return componentRef.current
return self.transformFunctionComponent(f)
}),
Equivalence.array(Equivalence.strictEqual()),
)))[0](Array.from(
Context.omit(...self.nonReactiveTags)(contextRef.current).mapUnsafe.values()
))
})
@@ -140,9 +130,9 @@ export interface ComponentOptions {
* Specifies the execution strategy for finalizers when the component unmounts or its scope closes.
* Determines whether finalizers execute sequentially or in parallel.
*
* @default "sequential"
* @default ExecutionStrategy.sequential
*/
readonly finalizerExecutionStrategy: "sequential" | "parallel"
readonly finalizerExecutionStrategy: ExecutionStrategy.ExecutionStrategy
/**
* Debounce duration before executing finalizers after component unmount.
@@ -156,7 +146,7 @@ export interface ComponentOptions {
export const defaultOptions: ComponentOptions = {
nonReactiveTags: [Tracer.ParentSpan],
finalizerExecutionStrategy: "sequential",
finalizerExecutionStrategy: ExecutionStrategy.sequential,
finalizerExecutionDebounce: "100 millis",
}
@@ -619,18 +609,18 @@ export const withOptions: {
*/
export const withRuntime: {
<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
context: React.Context<Context.Context<R>>,
context: React.Context<Runtime.Runtime<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>>,
context: React.Context<Runtime.Runtime<R>>,
): F
} = Function.dual(2, <P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
self: Component<P, A, E, R, F>,
context: React.Context<Context.Context<R>>,
context: React.Context<Runtime.Runtime<R>>,
) => function WithRuntime(props: P) {
return React.createElement(
Effect.runSyncWith(React.useContext(context))(self.use) as React.FC<P>,
Runtime.runSync(React.useContext(context))(self.use) as React.FC<P>,
props,
)
})
@@ -663,7 +653,7 @@ export declare namespace ScopeMap {
export declare namespace useScope {
export interface Options {
readonly finalizerExecutionStrategy?: "sequential" | "parallel"
readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy
readonly finalizerExecutionDebounce?: Duration.Input
}
}
@@ -815,7 +805,7 @@ export const useOnChange = Effect.fnUntraced(function* <A, E, R>(
export declare namespace useReactEffect {
export interface Options {
readonly finalizerExecutionMode?: "sync" | "fork"
readonly finalizerExecutionStrategy?: "sequential" | "parallel"
readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy
}
}
+19 -19
View File
@@ -2,7 +2,7 @@ import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, ident
import type * as React from "react"
import * as Component from "./Component.js"
import * as Lens from "./Lens.js"
import * as View from "./View.js"
import * as Subscribable from "./Subscribable.js"
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
@@ -18,12 +18,12 @@ extends Pipeable.Pipeable {
readonly [FormTypeId]: FormTypeId
readonly path: P
readonly value: View.View<Option.Option<A>, ER, never>
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>
readonly issues: View.View<readonly FormIssue[], never, never>
readonly isValidating: View.View<boolean, never, never>
readonly canCommit: View.View<boolean, never, never>
readonly isCommitting: View.View<boolean, 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>
@@ -32,12 +32,12 @@ extends Pipeable.Class implements Form<P, A, I, ER, EW> {
constructor(
readonly path: P,
readonly value: View.View<Option.Option<A>, ER, never>,
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>,
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>,
readonly issues: View.View<readonly FormIssue[], never, never>,
readonly isValidating: View.View<boolean, never, never>,
readonly canCommit: View.View<boolean, never, never>,
readonly isCommitting: View.View<boolean, 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()
}
@@ -71,9 +71,9 @@ export const focusObjectOn: {
return new FormImpl(
path,
View.mapOption(form.value, a => a[key]),
Subscribable.mapOption(form.value, a => a[key]),
Lens.focusObjectOn(form.encodedValue, key),
View.map(form.issues, issues => filterIssuesByPath(issues, path)),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
@@ -97,9 +97,9 @@ export const focusArrayAt: {
return new FormImpl(
path,
View.mapOptionEffect(form.value, values => Effect.fromOption(Array.get(values, index))),
Subscribable.mapOptionEffect(form.value, values => Effect.fromOption(Array.get(values, index))),
Lens.focusArrayAt(form.encodedValue, index),
View.map(form.issues, issues => filterIssuesByPath(issues, path)),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
@@ -123,9 +123,9 @@ export const focusTupleAt: {
return new FormImpl(
path,
View.mapOption(form.value, values => values[index]),
Subscribable.mapOption(form.value, values => values[index]),
Lens.focusTupleAt(form.encodedValue, index),
View.map(form.issues, issues => filterIssuesByPath(issues, path)),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
@@ -149,9 +149,9 @@ export const focusChunkAt: {
return new FormImpl(
path,
View.mapOptionEffect(form.value, values => Effect.fromOption(Chunk.get(values, index))),
Subscribable.mapOptionEffect(form.value, values => Effect.fromOption(Chunk.get(values, index))),
Lens.focusChunkAt(form.encodedValue, index),
View.map(form.issues, issues => filterIssuesByPath(issues, path)),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
+83 -90
View File
@@ -1,153 +1,146 @@
import { type Context, Effect, Equal, Exit, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js"
import * as View from "./View.js"
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, out A, out E = never, in out R = never>
export interface Mutation<in out K extends Mutation.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
extends Pipeable.Pipeable {
readonly [MutationTypeId]: MutationTypeId
readonly context: Context.Context<Scope.Scope | R>
readonly f: (key: K) => Effect.Effect<A, E, R>
readonly initialProgress: P
readonly latestKey: View.View<Option.Option<K>>
readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
readonly state: View.View<AsyncResult.AsyncResult<A, E>>
readonly latestFinalResult: View.View<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>
readonly 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<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>>
mutate(key: K): Effect.Effect<Result.Final<A, E, P>>
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>>
}
export const isMutation = (u: unknown): u is Mutation<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
export declare namespace Mutation {
export type AnyKey = readonly any[]
}
export class MutationImpl<in out K, in out A, in out E = never, in out R = never>
extends Pipeable.Class implements Mutation<K, A, E, R> {
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 latestKey: Lens.Lens<Option.Option<K>>,
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
readonly state: Lens.Lens<AsyncResult.AsyncResult<A, E>>,
readonly latestFinalResult: Lens.Lens<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>,
readonly 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<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
return Lens.set(this.latestKey, Option.some(key)).pipe(
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.flatMap(state => this.watch(state)),
Effect.andThen(sub => this.watch(sub)),
Effect.provide(this.context),
)
}
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>> {
return Lens.set(this.latestKey, Option.some(key)).pipe(
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(state => Effect.forkScoped(this.watch(state))),
Effect.tap(sub => Effect.forkScoped(this.watch(sub))),
Effect.provide(this.context),
)
}
start(key: K): Effect.Effect<
View.View<AsyncResult.AsyncResult<A, E>>,
Subscribable.Subscribable<Result.Result<A, E, P>>,
never,
Scope.Scope | R
> {
return Effect.gen({ self: this }, function*() {
const previous = yield* Lens.get(this.latestFinalResult)
const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make<AsyncResult.AsyncResult<A, E>>(
Option.getOrElse(previous, () => AsyncResult.initial(false))
))
const fiber = yield* Effect.forkScoped(Effect.andThen(
Lens.update(state, AsyncResult.match({
onInitial: () => AsyncResult.initial(true),
onSuccess: v => AsyncResult.success(v.value, {
waiting: true,
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),
}),
onFailure: v => AsyncResult.failure(v.cause, {
waiting: true,
previousSuccess: v.previousSuccess,
})
})),
Effect.onExit(this.f(key), exit => Lens.update(
state,
previous => Exit.match(exit, {
onSuccess: v => AsyncResult.success(v),
onFailure: c => AsyncResult.match(previous, {
onInitial: () => AsyncResult.failure(c),
onSuccess: v => AsyncResult.failure(c, {
previousSuccess: Option.some(v),
}),
onFailure: v => AsyncResult.failure(c, {
previousSuccess: v.previousSuccess,
})
}),
}),
).pipe(
Effect.andThen(Effect.all([
Effect.fiberId,
Lens.get(this.fiber),
])),
Effect.flatMap(([fiberId, fiber]) => Option.match(fiber, {
onSome: v => Equal.equals(fiberId, v.id)
? Lens.set(this.fiber, Option.none())
: Effect.void,
onNone: () => Effect.void,
})),
)),
))
yield* Lens.set(this.fiber, Option.some(fiber))
return state
{
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(
state: View.View<AsyncResult.AsyncResult<A, E>>
): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
return View.get(state).pipe(
sub: Subscribable.Subscribable<Result.Result<A, E, P>>
): Effect.Effect<Result.Final<A, E, P>> {
return sub.get.pipe(
Effect.andThen(initial => Stream.runFoldEffect(
View.changes(state),
Stream.takeUntil(sub.changes, result => Result.isFinal(result) && !Result.hasFlag(result)),
() => initial,
(_, result) => Effect.as(Lens.set(this.state, result), result),
) as Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>),
Effect.tap(result => Lens.set(this.latestFinalResult, Option.some(result))),
(_, 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 = never, A = void, E = never, R = never> {
readonly f: (key: K) => Effect.Effect<A, E, R>
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* <K = never, A = void, E = never, R = never>(
options: make.Options<K, A, E, R>
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, R>,
Mutation<K, A, E, Result.forkEffect.OutputContext<R, P>, P>,
never,
Scope.Scope | R
Scope.Scope | Result.forkEffect.OutputContext<R, P>
> {
return new MutationImpl(
yield* Effect.context<Scope.Scope | R>(),
options.f,
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<R, P>>(),
options.f as any,
options.initialProgress as P,
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<K>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<AsyncResult.AsyncResult<A, E>>(AsyncResult.initial())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>())),
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),
})
+185 -319
View File
@@ -1,106 +1,88 @@
import { type Cause, type Context, Duration, Effect, Equal, type Equivalence, Exit, Fiber, Option, Pipeable, Predicate, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js"
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 View from "./View.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, out A, out E = never, in out R = never>
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 | R>
readonly key: View.View<K>
readonly keyEquivalence: Equivalence.Equivalence<K>
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 staleTime: Duration.Duration
readonly initialProgress: P
readonly staleTime: Duration.Input
readonly refreshOnWindowFocus: boolean
readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
readonly state: View.View<QueryState<K, A, E>>
readonly latestFinalState: View.View<Option.Option<FinalQueryState<K, A, E>>>
readonly 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<FinalQueryState<K, A, E>, Cause.NoSuchElementError>
fetchView(key: K): Effect.Effect<View.View<QueryState<K, A, E>>, Cause.NoSuchElementError>
readonly refresh: Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError>
readonly refreshView: Effect.Effect<View.View<QueryState<K, A, E>>, Cause.NoSuchElementError>
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 interface QueryState<out K, out A, out E = never> {
readonly key: K
readonly result: AsyncResult.AsyncResult<A, E>
export declare namespace Query {
export type AnyKey = readonly any[]
}
export interface FinalQueryState<out K, out A, out E = never> {
readonly key: K
readonly result: AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>
}
export const isQuery = (u: unknown): u is Query<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId)
export class QueryImpl<in out K, in out A, in out E = never, in out R = never>
extends Pipeable.Class implements Query<K, A, E, R> {
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 | R>,
readonly key: View.View<K>,
readonly keyEquivalence: Equivalence.Equivalence<K>,
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 staleTime: Duration.Duration,
readonly initialProgress: P,
readonly staleTime: Duration.Input,
readonly refreshOnWindowFocus: boolean,
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
readonly state: Lens.Lens<QueryState<K, A, E>>,
readonly latestFinalState: Lens.Lens<Option.Option<FinalQueryState<K, A, E>>>,
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.changes,
key => Effect.gen({ self: this }, function*() {
yield* this.interrupt
const latestFinalState = yield* Lens.get(this.latestFinalState)
const state = yield* this.startCached(
Option.isSome(latestFinalState) && this.keyEquivalence(key, latestFinalState.value.key)
? latestFinalState.value
: {
key,
result: AsyncResult.initial(false),
}
)
yield* Effect.forkScoped(this.watch(state))
}),
),
Effect.promise(() => import("@effect/platform-browser")).pipe(
Effect.flatMap(({ BrowserStream }) => this.refreshOnWindowFocus
? Stream.runForEach(
BrowserStream.fromEventListenerWindow("focus"),
() => this.refreshView,
)
: Effect.void
),
Effect.catchDefect(() => Effect.void),
),
], { concurrency: "unbounded" }).pipe(
Stream.runForEach(this.key, key => this.fetchSubscribable(key)),
focus,
], { concurrency: "unbounded", discard: true }).pipe(
Effect.ignore,
this.runSemaphore.withPermits(1),
Effect.provide(this.context),
@@ -108,310 +90,194 @@ extends Pipeable.Class implements Query<K, A, E, R> {
}
get interrupt(): Effect.Effect<void> {
return Effect.flatMap(Lens.get(this.fiber), Option.match({
return Effect.flatMap(SubscriptionRef.get(this.fiberRef), Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
onNone: () => Effect.succeed(undefined),
}))
}
fetch(key: K): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> {
return Effect.gen({ self: this }, function*() {
yield* this.interrupt
const state = yield* this.startCached({
key,
result: AsyncResult.initial(false),
})
return yield* this.watch(state)
}).pipe(
Effect.provide(this.context),
)
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))
}
fetchView(key: K): Effect.Effect<
View.View<QueryState<K, A, E>>,
Cause.NoSuchElementError
> {
return Effect.gen({ self: this }, function*() {
yield* this.interrupt
const state = yield* this.startCached({
key,
result: AsyncResult.initial(false),
})
yield* Effect.forkScoped(this.watch(state))
return state
}).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<FinalQueryState<K, A, E>, Cause.NoSuchElementError> {
return Effect.gen({ self: this }, function*() {
yield* this.interrupt
const latestState = yield* Lens.get(this.state)
const latestFinalState = yield* Lens.get(this.latestFinalState)
const state = yield* this.startCached(
Option.isSome(latestFinalState) && this.keyEquivalence(latestState.key, latestFinalState.value.key)
? latestFinalState.value
: {
key: latestState.key,
result: AsyncResult.initial(false),
}
)
return yield* this.watch(state)
}).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 refreshView(): Effect.Effect<
View.View<QueryState<K, A, E>>,
Cause.NoSuchElementError
> {
return Effect.gen({ self: this }, function*() {
yield* this.interrupt
const latestState = yield* Lens.get(this.state)
const latestFinalState = yield* Lens.get(this.latestFinalState)
const state = yield* this.startCached(
Option.isSome(latestFinalState) && this.keyEquivalence(latestState.key, latestFinalState.value.key)
? latestFinalState.value
: {
key: latestState.key,
result: AsyncResult.initial(false),
}
)
yield* Effect.forkScoped(this.watch(state))
return state
}).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(
previous: QueryState<K, A, E>,
): Effect.Effect<
View.View<QueryState<K, A, E>>,
Cause.NoSuchElementError,
Scope.Scope | QueryClient.QueryClient | R
> {
return Effect.flatMap(this.getCacheEntry(previous.key), Option.match({
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: previous.key,
result: entry.result as AsyncResult.AsyncResult<A, E>,
})
: Effect.succeed(View.make({
get: Effect.succeed({
key: previous.key,
result: entry.result as AsyncResult.AsyncResult<A, E>,
}),
get changes() {
return Stream.make({
key: previous.key,
result: entry.result as AsyncResult.AsyncResult<A, E>,
})
},
? 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(previous),
onNone: () => this.start(key, initial),
}))
}
start(
previous: QueryState<K, A, E>,
): Effect.Effect<
View.View<QueryState<K, A, E>>,
never,
Scope.Scope | R
> {
return Effect.gen({ self: this }, function*() {
const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(previous))
const fiber = yield* Effect.forkScoped(Effect.andThen(
Lens.update(state, previous => AsyncResult.match(previous.result, {
onInitial: () => ({
key: previous.key,
result: AsyncResult.initial(true),
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),
}),
onSuccess: result => ({
key: previous.key,
result: AsyncResult.success(result.value, {
waiting: true,
}),
}),
onFailure: result => ({
key: previous.key,
result: AsyncResult.failure(result.cause, {
waiting: true,
previousSuccess: result.previousSuccess,
}),
}),
})),
Effect.onExit(this.f(previous.key), exit => Lens.update(
state,
previous => Exit.match(exit, {
onSuccess: v => ({
key: previous.key,
result: AsyncResult.success(v),
}),
onFailure: c => AsyncResult.match(previous.result, {
onInitial: () => ({
key: previous.key,
result: AsyncResult.failure(c),
}),
onSuccess: v => ({
key: previous.key,
result: AsyncResult.failure(c, {
previousSuccess: Option.some(v),
}),
}),
onFailure: v => ({
key: previous.key,
result: AsyncResult.failure(c, {
previousSuccess: v.previousSuccess,
}),
}),
}),
}),
).pipe(
Effect.andThen(Effect.all([
Effect.fiberId,
Lens.get(this.fiber),
])),
Effect.flatMap(([fiberId, fiber]) => Option.match(fiber, {
onSome: v => fiberId === v.id
? Lens.set(this.fiber, Option.none())
: Effect.void,
onNone: () => Effect.void,
})),
)),
))
yield* Lens.set(this.fiber, Option.some(fiber))
return state
{ initial, initialProgress: self.initialProgress },
)
yield* SubscriptionRef.set(self.fiberRef, Option.some(fiber))
return sub
})
}
watch(
view: View.View<QueryState<K, A, E>>
): Effect.Effect<FinalQueryState<K, A, E>, never, QueryClient.QueryClient> {
return Effect.gen({ self: this }, function*() {
const initial = yield* View.get(view)
const final = yield* Stream.runFoldEffect(
View.changes(view),
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,
(_, state) => Effect.as(Lens.set(this.state, state), state),
) as Effect.Effect<FinalQueryState<K, A, E>>
yield* Lens.set(this.latestFinalState, Option.some(final))
if (AsyncResult.isSuccess(final.result))
yield* this.setCacheEntry(final.key, final.result)
return final
})
(_, 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: unknown) => Effect.Effect<unknown, unknown, unknown>)
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.andThen(
Effect.all([
Effect.succeed(this.makeCacheKey(key)),
QueryClient.QueryClient,
]),
([key, client]) => client.getCacheEntry(key),
)
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: AsyncResult.Success<A, E>,
): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> {
return Effect.flatMap(
Effect.all([
Effect.succeed(this.makeCacheKey(key)),
QueryClient.QueryClient,
]),
([key, client]) => client.setCacheEntry(key, result, this.staleTime),
)
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 QueryClient.QueryClient.pipe(
Effect.andThen(client => client.invalidateCacheEntries(this.f as (key: unknown) => Effect.Effect<unknown, unknown, unknown>)),
Effect.provide(this.context),
)
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.all([
Effect.succeed(this.makeCacheKey(key)),
return Effect.flatMap(
QueryClient.QueryClient,
]).pipe(
Effect.andThen(([key, client]) => client.invalidateCacheEntry(key)),
Effect.provide(this.context),
)
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, A, E = never, R = never> {
readonly key: View.View<K>,
readonly keyEquivalence?: Equivalence.Equivalence<K>,
readonly f: (key: K) => Effect.Effect<A, E, R>
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, A, E = never, R = never>(
options: make.Options<K, A, E, R>
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, E, R>,
Cause.NoSuchElementError,
Scope.Scope | QueryClient.QueryClient | R
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(
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | R>(),
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.keyEquivalence ?? Equal.asEquivalence(),
options.f,
options.staleTime ? yield* Effect.fromOption(Duration.fromInput(options.staleTime)) : client.defaultStaleTime,
options.f as any,
options.initialProgress as P,
options.staleTime ?? client.defaultStaleTime,
options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus,
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<QueryState<K, A, E>>({
key: yield* View.get(options.key),
result: AsyncResult.initial(false),
})),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<FinalQueryState<K, A, E>>())),
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, A, E = never, R = never>(
options: make.Options<K, A, E, R>
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, E, R>,
Cause.NoSuchElementError,
Scope.Scope | QueryClient.QueryClient | R
> => Effect.tap(
make(options),
query => Effect.forkScoped(query.run),
)
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),
})
+91 -81
View File
@@ -1,7 +1,24 @@
import { type Cause, Context, DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, type Option, Pipeable, Predicate, Schedule, type Scope, Semaphore, SubscriptionRef } from "effect"
import type { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js"
import type * as View from "./View.js"
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")
@@ -9,87 +26,89 @@ export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
export interface QueryClientService extends Pipeable.Pipeable {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
readonly cache: View.View<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly cacheGcTime: Duration.Duration
readonly defaultStaleTime: Duration.Duration
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly cacheGcTime: Duration.Input
readonly defaultStaleTime: Duration.Input
readonly defaultRefreshOnWindowFocus: boolean
readonly run: Effect.Effect<void, Cause.NoSuchElementError>
readonly run: Effect.Effect<void>
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>>
setCacheEntry(
key: QueryClientCacheKey,
result: AsyncResult.Success<unknown, unknown>,
staleTime: Duration.Duration,
): Effect.Effect<QueryClientCacheEntry>
invalidateCacheEntries(f: (key: unknown) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void>
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"
) {}
"@effect-fc/QueryClient/QueryClient",
) {
static get Default(): Layer.Layer<QueryClient> {
return Layer.effect(QueryClient)(service())
}
}
export class QueryClientServiceImpl
extends Pipeable.Class
implements QueryClientService {
export class QueryClientServiceImpl extends Pipeable.Class implements QueryClientService {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
constructor(
readonly cache: Lens.Lens<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
readonly cacheGcTime: Duration.Duration,
readonly defaultStaleTime: Duration.Duration,
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, Cause.NoSuchElementError> {
get run(): Effect.Effect<void> {
return this.runSemaphore.withPermits(1)(Effect.repeat(
Effect.flatMap(
DateTime.now,
now => Lens.update(this.cache, HashMap.filter(entry =>
Effect.flatMap(DateTime.now, now => SubscriptionRef.update(this.cacheRef, HashMap.filter(entry =>
Duration.isLessThan(
DateTime.distance(entry.lastAccessedAt, now),
Duration.sum(entry.staleTime, this.cacheGcTime),
Duration.sum(Duration.fromInputUnsafe(entry.staleTime), Duration.fromInputUnsafe(this.cacheGcTime)),
)
)),
),
Schedule.spaced("30 second"),
))),
Schedule.spaced("30 seconds"),
))
}
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>> {
return Effect.all([
DateTime.now,
Effect.flatMap(
Effect.map(Lens.get(this.cache), HashMap.get(key)),
Effect.fromOption,
),
]).pipe(
Effect.map(([now, entry]) => new QueryClientCacheEntry(entry.result, entry.staleTime, entry.createdAt, now)),
Effect.tap(entry => Lens.update(this.cache, HashMap.set(key, entry))),
Effect.option,
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: AsyncResult.Success<unknown, unknown>,
staleTime: Duration.Duration,
result: Result.Success<unknown>,
staleTime: Duration.Input,
): Effect.Effect<QueryClientCacheEntry> {
return DateTime.now.pipe(
Effect.map(now => new QueryClientCacheEntry(result, staleTime, now, now)),
Effect.tap(entry => Lens.update(this.cache, HashMap.set(key, entry))),
)
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: unknown) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void> {
return Lens.update(this.cache, HashMap.filter((_, key) => !Equivalence.strictEqual()(key.f, f)))
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 Lens.update(this.cache, HashMap.remove(key))
return SubscriptionRef.update(this.cacheRef, HashMap.remove(key))
}
}
@@ -103,13 +122,11 @@ export declare namespace make {
}
}
export const make = Effect.fnUntraced(function* (
options: make.Options = {}
): Effect.fn.Return<QueryClientService, Cause.NoSuchElementError, never> {
export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return<QueryClientService> {
return new QueryClientServiceImpl(
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>())),
yield* Effect.fromOption(Duration.fromInput(options.cacheGcTime ?? "5 minutes")),
yield* Effect.fromOption(Duration.fromInput(options.defaultStaleTime ?? "0 minutes")),
yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()),
options.cacheGcTime ?? "5 minutes",
options.defaultStaleTime ?? "0 minutes",
options.defaultRefreshOnWindowFocus ?? true,
yield* Semaphore.make(1),
)
@@ -119,51 +136,46 @@ export declare namespace service {
export interface Options extends make.Options {}
}
export const service = (
options?: service.Options
): Effect.Effect<QueryClientService, Cause.NoSuchElementError, Scope.Scope> => Effect.tap(
export const service = (options?: service.Options): Effect.Effect<QueryClientService, never, Scope.Scope> => Effect.tap(
make(options),
client => Effect.forkScoped(client.run),
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 Pipeable.Pipeable, Equal.Equal {
export class QueryClientCacheKey extends Pipeable.Class implements Equal.Equal {
readonly [QueryClientCacheKeyTypeId]: QueryClientCacheKeyTypeId = QueryClientCacheKeyTypeId
constructor(
readonly key: unknown,
readonly f: (key: unknown) => Effect.Effect<unknown, unknown, unknown>,
readonly key: Query.Query.AnyKey,
readonly f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>,
) {
super()
}
[Equal.symbol](that: Equal.Equal) {
return isQueryClientCacheKey(that) && Equal.equals(this.key, that.key) && Equivalence.strictEqual()(this.f, that.f)
[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]() {
return Hash.combine(Hash.hash(this.f))(Hash.hash(this.key))
[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
implements Pipeable.Pipeable {
export class QueryClientCacheEntry extends Pipeable.Class {
readonly [QueryClientCacheEntryTypeId]: QueryClientCacheEntryTypeId = QueryClientCacheEntryTypeId
constructor(
readonly result: AsyncResult.Success<unknown, unknown>,
readonly staleTime: Duration.Duration,
readonly result: Result.Success<unknown>,
readonly staleTime: Duration.Input,
readonly createdAt: DateTime.DateTime,
readonly lastAccessedAt: DateTime.DateTime,
) {
@@ -173,9 +185,7 @@ implements Pipeable.Pipeable {
export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId)
export const isQueryClientCacheEntryStale = (
self: QueryClientCacheEntry
): Effect.Effect<boolean, Cause.NoSuchElementError> => Effect.map(
export const isQueryClientCacheEntryStale = (self: QueryClientCacheEntry): Effect.Effect<boolean> => Effect.map(
DateTime.now,
now => Duration.isGreaterThanOrEqualTo(DateTime.distance(self.createdAt, now), self.staleTime),
now => Duration.isGreaterThanOrEqualTo(DateTime.distance(self.createdAt, now), Duration.fromInputUnsafe(self.staleTime)),
)
+21 -30
View File
@@ -1,52 +1,41 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { type Context, Effect, Layer, ManagedRuntime, Predicate } from "effect"
/** 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 Component from "./Component.js"
import * as ErrorObserver from "./ErrorObserver.js"
import * as QueryClient from "./QueryClient.js"
export const ReactRuntimeTypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime")
export type ReactRuntimeTypeId = typeof ReactRuntimeTypeId
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 [ReactRuntimeTypeId]: ReactRuntimeTypeId
readonly [TypeId]: TypeId
readonly runtime: ManagedRuntime.ManagedRuntime<R, ER>
readonly context: React.Context<Context.Context<R>>
}
const ReactRuntimeProto = Object.freeze({ [ReactRuntimeTypeId]: ReactRuntimeTypeId } as const)
const ReactRuntimeProto = Object.freeze({ [TypeId]: TypeId } as const)
export const preludeLayer: Layer.Layer<
| Component.ScopeMap
| ErrorObserver.ErrorObserver
| QueryClient.QueryClient
> = Layer.mergeAll(
Component.ScopeMap.layer,
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, ReactRuntimeTypeId)
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<Layer.Success<typeof preludeLayer> | R, ER> => Object.setPrototypeOf(
): ReactRuntime<R | ErrorObserver.ErrorObserver | QueryClient.QueryClient, ER> => Object.setPrototypeOf(
Object.assign(function() {}, {
runtime: ManagedRuntime.make(
Layer.merge(layer, preludeLayer),
{ memoMap },
),
// biome-ignore lint/style/noNonNullAssertion: context initialization
context: React.createContext<Context.Context<R>>(null!),
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>
@@ -55,13 +44,8 @@ export namespace Provider {
}
export const Provider = <R, ER>(
{ runtime, children, ...suspenseProps }: Provider.Props<R, ER>
{ runtime, children, ...suspenseProps }: Provider.Props<R, ER>,
): React.ReactNode => {
Effect.runSync(Component.useOnChange(
() => Effect.addFinalizer(() => runtime.runtime.disposeEffect),
[runtime],
))
const promise = React.useMemo(() => runtime.runtime.context(), [runtime])
return React.createElement(
@@ -76,5 +60,12 @@ const ProviderInner = <R, ER>(
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)
}
): React.ReactNode => React.createElement(runtime.context, { value: React.use(promise) }, children)
+3 -4
View File
@@ -1,6 +1,5 @@
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, pipe, type Scope, SubscriptionRef } from "effect"
import { Lens } from "effect-lens"
import type { View } from "effect-lens/View"
import { Lens, Subscribable } from "effect-lens"
export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result")
@@ -208,7 +207,7 @@ 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: View<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
Scope.Scope | unsafeForkEffect.OutputContext<R, P>
> {
@@ -243,7 +242,7 @@ export const forkEffect: {
effect: Effect.Effect<A, E, forkEffect.InputContext<R, NoInfer<P>>>,
options?: forkEffect.Options<NoInfer<A>, NoInfer<E>, P>,
): Effect.Effect<
readonly [result: View<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
Scope.Scope | forkEffect.OutputContext<R, P>
>
+10 -10
View File
@@ -18,7 +18,7 @@ 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 View from "./View.js"
import * as Subscribable from "./Subscribable.js"
type FormSchema<A, I, R> = Schema.Top & {
@@ -39,7 +39,7 @@ extends Form.Form<readonly [], A, I, never, never> {
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>
readonly validationFiber: View.View<Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>, never, never>
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>
}
@@ -50,9 +50,9 @@ extends Pipeable.Class implements SubmittableForm<A, I, R, MA, ME, MR, MP> {
readonly [SubmittableFormTypeId]: SubmittableFormTypeId = SubmittableFormTypeId
readonly path = [] as const
readonly encodedValue: Lens.Lens<I, never, never, never, never>
readonly isValidating: View.View<boolean, never, never>
readonly canCommit: View.View<boolean, never, never>
readonly isCommitting: View.View<boolean, never, never>
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>,
@@ -77,26 +77,26 @@ extends Pipeable.Class implements SubmittableForm<A, I, R, MA, ME, MR, MP> {
),
lock: Lens.asLensImpl(internalEncodedValue).lock,
})
this.isValidating = View.map(validationFiber, Option.isSome)
const commitState = View.zipLatestAll(
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 View.View<readonly [
) 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 = View.map(
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 = View.map(
this.isCommitting = Subscribable.map(
mutation.result,
result => Result.isRunning(result) || Result.hasRefreshingFlag(result),
)
@@ -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
})
@@ -17,7 +17,7 @@ import {
} from "effect"
import * as Form from "./Form.js"
import * as Lens from "./Lens.js"
import * as View from "./View.js"
import * as Subscribable from "./Subscribable.js"
type FormSchema<A, I, R> = Schema.Top & {
@@ -43,7 +43,7 @@ export interface SynchronizedForm<
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: View.View<Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>, never, never>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>, never, never>
readonly run: Effect.Effect<void, TER>
}
@@ -59,10 +59,10 @@ export class SynchronizedFormImpl<
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId = SynchronizedFormTypeId
readonly path = [] as const
readonly value: View.View<Option.Option<A>, TER, never>
readonly value: Subscribable.Subscribable<Option.Option<A>, TER, never>
readonly encodedValue: Lens.Lens<I, TER, TER | TEW, never, never>
readonly isValidating: View.View<boolean, never, never>
readonly canCommit: View.View<boolean, never, never>
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
constructor(
readonly schema: FormSchema<A, I, R>,
@@ -75,7 +75,7 @@ export class SynchronizedFormImpl<
readonly runSemaphore: Semaphore.Semaphore,
) {
super()
this.value = View.make({
this.value = Subscribable.make({
get: Effect.provide(Effect.map(target.get, Option.some), context),
changes: Stream.provideContext(
target.changes.pipe(
@@ -94,13 +94,13 @@ export class SynchronizedFormImpl<
),
lock: Lens.asLensImpl(internalEncodedValue).lock,
}) as unknown as Lens.Lens<I, TER, TER | TEW, never, never>
this.isValidating = View.map(validationFiber, Option.isSome)
const commitState = View.zipLatestAll(issues as any, validationFiber as any, isCommitting as any) as unknown as View.View<readonly [
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 = View.map(
this.canCommit = Subscribable.map(
commitState,
([currentIssues, fiber, committing]) => currentIssues.length === 0 && Option.isNone(fiber) && !committing,
)
-40
View File
@@ -1,40 +0,0 @@
import { Effect, Equivalence, Stream } from "effect"
import { View } from "effect-lens"
import * as React from "react"
import * as Component from "./Component.js"
export * from "effect-lens/View"
export declare namespace useAll {
export type Success<T extends readonly View.View<any, any, any>[]> = [T[number]] extends [never]
? never
: { [K in keyof T]: T[K] extends View.View<infer A, infer _E, infer _R> ? A : never }
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useAll = Effect.fnUntraced(function* <const T extends readonly View.View<any, any, any>[]>(
elements: T,
options?: useAll.Options<useAll.Success<NoInfer<T>>>,
): Effect.fn.Return<
useAll.Success<T>,
[T[number]] extends [never] ? never : T[number] extends View.View<infer _A, infer E, infer _R> ? E : never,
[T[number]] extends [never] ? never : T[number] extends View.View<infer _A, infer _E, infer R> ? R : never
> {
const [reactStateValue, setReactStateValue] = React.useState(
yield* Component.useOnMount(() => Effect.all(elements.map(View.get)))
)
yield* Component.useReactEffect(() => View.changes(View.zipLatestAll(...elements)).pipe(
Stream.changesWith((options?.equivalence as Equivalence.Equivalence<any[]> | undefined) ?? Equivalence.Array(Equivalence.strictEqual())),
Stream.runForEach(v =>
Effect.sync(() => setReactStateValue(v))
),
Effect.forkScoped,
), elements)
return reactStateValue as any
})
+1 -1
View File
@@ -13,5 +13,5 @@ export * as Result from "./Result.js"
export * as SetStateAction from "./SetStateAction.js"
export * as Stream from "./Stream.js"
export * as SubmittableForm from "./SubmittableForm.js"
export * as Subscribable from "./Subscribable.js"
export * as SynchronizedForm from "./SynchronizedForm.js"
export * as View from "./View.js"
@@ -1,10 +1,10 @@
import { render, screen, waitFor } from "@testing-library/react"
import { Effect, Layer, SubscriptionRef } from "effect"
import { Effect, Fiber, Layer, Stream, SubscriptionRef } from "effect"
import { Lens } from "effect-lens"
import { describe, expect, it } from "vitest"
import * as Component from "../src/Component.js"
import * as ReactRuntime from "../src/ReactRuntime.js"
import * as View from "../src/View.js"
import * as Subscribable from "../src/Subscribable.js"
const makeRuntime = async () => {
@@ -18,7 +18,42 @@ const makeRuntime = async () => {
}
}
describe("View", () => {
describe("Subscribable", () => {
it("zipLatestAll reads current values from all inputs", async () => {
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
const left = Lens.fromSubscriptionRef(leftRef)
const right = Lens.fromSubscriptionRef(rightRef)
const zipped = Subscribable.zipLatestAll(left, right)
expect(await Effect.runPromise(zipped.get)).toEqual([1, "a"])
})
it("zipLatestAll emits updates when any input changes", async () => {
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
const left = Lens.fromSubscriptionRef(leftRef)
const right = Lens.fromSubscriptionRef(rightRef)
const zipped = Subscribable.zipLatestAll(left, right)
const values: Array<readonly [number, string]> = []
const collector = Effect.runFork(Effect.scoped(zipped.changes.pipe(
Stream.runForEach(value => Effect.sync(() => {
values.push(value as readonly [number, string])
})),
)))
await Effect.runPromise(Lens.set(left, 2))
await waitFor(() => expect(values).toContainEqual([2, "a"]))
await Effect.runPromise(Lens.set(right, "b"))
await waitFor(() => expect(values).toContainEqual([2, "b"]))
await Effect.runPromise(Fiber.interrupt(collector))
})
it("useAll returns the latest values and rerenders when any input changes", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
const countRef = await Effect.runPromise(SubscriptionRef.make(1))
@@ -26,8 +61,8 @@ describe("View", () => {
const count = Lens.fromSubscriptionRef(countRef)
const label = Lens.fromSubscriptionRef(labelRef)
const Probe = Component.makeUntraced("ViewUseAllProbe")(function*() {
const [currentCount, currentLabel] = yield* View.useAll([count, label])
const Probe = Component.makeUntraced("SubscribableUseAllProbe")(function*() {
const [currentCount, currentLabel] = yield* Subscribable.useAll([count, label])
return <div>{`${currentCount}:${currentLabel}`}</div>
}).pipe(
@@ -59,8 +94,8 @@ describe("View", () => {
const item = Lens.fromSubscriptionRef(itemRef)
const flag = Lens.fromSubscriptionRef(flagRef)
const Probe = Component.makeUntraced("ViewUseAllEquivalenceProbe")(function*() {
const [currentItem, currentFlag] = yield* View.useAll([item, flag], {
const Probe = Component.makeUntraced("SubscribableUseAllEquivalenceProbe")(function*() {
const [currentItem, currentFlag] = yield* Subscribable.useAll([item, flag], {
equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) =>
selfItem.id === thatItem.id && selfFlag === thatFlag,
})
+2 -2
View File
@@ -1,5 +1,5 @@
import { Effect, Layer, SubscriptionRef } from "effect"
import { Component, Lens, ReactRuntime, View } from "effect-fc-next"
import { Component, Lens, ReactRuntime, Subscribable } from "effect-fc-next"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import "./index.css"
@@ -10,7 +10,7 @@ const Counter = Component.make("Counter")(function*() {
SubscriptionRef.make(0),
Lens.fromSubscriptionRef,
))
const [value] = yield* View.useAll([count])
const [value] = yield* Subscribable.useAll([count])
const increment = yield* Component.useCallbackSync(
() => Lens.update(count, n => n + 1),
[count],