10 Commits

Author SHA1 Message Date
2dcea4c81d Update dependency @effect/language-service to ^0.72.0
All checks were successful
Lint / lint (push) Successful in 13s
Test build / test-build (pull_request) Successful in 18s
2026-01-23 01:23:50 +01:00
Julien Valverdé
581bab027c Fix useScope
All checks were successful
Lint / lint (push) Successful in 41s
2026-01-23 01:20:11 +01:00
Julien Valverdé
3be9d94aa8 Add Component doc
All checks were successful
Lint / lint (push) Successful in 42s
2026-01-22 02:28:18 +01:00
Julien Valverdé
49c79295d6 Add doc comments
All checks were successful
Lint / lint (push) Successful in 13s
2026-01-18 19:01:00 +01:00
Julien Valverdé
929e062d0c Fix
All checks were successful
Lint / lint (push) Successful in 12s
2026-01-18 17:26:07 +01:00
Julien Valverdé
c9dd4e6aa9 Fix
All checks were successful
Lint / lint (push) Successful in 13s
2026-01-18 17:10:56 +01:00
Julien Valverdé
1779eebe3b Fix
All checks were successful
Lint / lint (push) Successful in 12s
2026-01-18 17:07:06 +01:00
Julien Valverdé
f88daeefd4 Fix
All checks were successful
Lint / lint (push) Successful in 13s
2026-01-18 16:10:53 +01:00
Julien Valverdé
bbacee7ad4 Refactor Query
All checks were successful
Lint / lint (push) Successful in 13s
2026-01-18 16:08:39 +01:00
Julien Valverdé
636beedd13 Query work
All checks were successful
Lint / lint (push) Successful in 13s
2026-01-18 14:31:38 +01:00
5 changed files with 252 additions and 74 deletions

View File

@@ -6,7 +6,7 @@
"name": "@effect-fc/monorepo", "name": "@effect-fc/monorepo",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.8", "@biomejs/biome": "^2.3.8",
"@effect/language-service": "^0.65.0", "@effect/language-service": "^0.72.0",
"@types/bun": "^1.3.3", "@types/bun": "^1.3.3",
"npm-check-updates": "^19.1.2", "npm-check-updates": "^19.1.2",
"npm-sort": "^0.0.4", "npm-sort": "^0.0.4",
@@ -116,7 +116,7 @@
"@effect-fc/example": ["@effect-fc/example@workspace:packages/example"], "@effect-fc/example": ["@effect-fc/example@workspace:packages/example"],
"@effect/language-service": ["@effect/language-service@0.65.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-eHcpLNCZa1XEDRrXLZqTdky6jAQojL6zQEW53Ba6vJL35j77tJTnV9BFkk34G3rxKoplNo39U0Mum3RfuH9rsg=="], "@effect/language-service": ["@effect/language-service@0.72.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-MWkyTPCXSs5Q3OIBWR3q24SA+ipkdWW7EBJBt6EPUzlzZxjJLXtLBhXpMoCFheSEM0FTWOHT4BRLh5lufsmjVw=="],
"@effect/platform": ["@effect/platform@0.94.1", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.14" } }, "sha512-SlL8OMTogHmMNnFLnPAHHo3ua1yrB1LNQOVQMiZsqYu9g3216xjr0gn5WoDgCxUyOdZcseegMjWJ7dhm/2vnfg=="], "@effect/platform": ["@effect/platform@0.94.1", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.14" } }, "sha512-SlL8OMTogHmMNnFLnPAHHo3ua1yrB1LNQOVQMiZsqYu9g3216xjr0gn5WoDgCxUyOdZcseegMjWJ7dhm/2vnfg=="],

View File

@@ -16,7 +16,7 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.8", "@biomejs/biome": "^2.3.8",
"@effect/language-service": "^0.65.0", "@effect/language-service": "^0.72.0",
"@types/bun": "^1.3.3", "@types/bun": "^1.3.3",
"npm-check-updates": "^19.1.2", "npm-check-updates": "^19.1.2",
"npm-sort": "^0.0.4", "npm-sort": "^0.0.4",

View File

@@ -8,6 +8,13 @@ import { Memoized } from "./index.js"
export const TypeId: unique symbol = Symbol.for("@effect-fc/Component/Component") export const TypeId: unique symbol = Symbol.for("@effect-fc/Component/Component")
export type TypeId = typeof TypeId export type TypeId = typeof TypeId
/**
* Interface representing an Effect-based React Component.
*
* This is both:
* - an Effect that produces a React function component
* - a constructor-like object with component metadata and options
*/
export interface Component<P extends {}, A extends React.ReactNode, E, R> export interface Component<P extends {}, A extends React.ReactNode, E, R>
extends extends
Effect.Effect<(props: P) => A, never, Exclude<R, Scope.Scope>>, Effect.Effect<(props: P) => A, never, Exclude<R, Scope.Scope>>,
@@ -20,7 +27,6 @@ extends
readonly "~Error": E readonly "~Error": E
readonly "~Context": R readonly "~Context": R
/** @internal */
readonly body: (props: P) => Effect.Effect<A, E, R> readonly body: (props: P) => Effect.Effect<A, E, R>
/** @internal */ /** @internal */
@@ -37,9 +43,24 @@ export declare namespace Component {
export type AsComponent<T extends Component<any, any, any, any>> = Component<Props<T>, Success<T>, Error<T>, Context<T>> export type AsComponent<T extends Component<any, any, any, any>> = Component<Props<T>, Success<T>, Error<T>, Context<T>>
/**
* Options that can be set on the component
*/
export interface Options { export interface Options {
/** Custom displayName for React DevTools and debugging. */
readonly displayName?: string readonly displayName?: string
/**
* Strategy used when executing finalizers on unmount/scope close.
* @default ExecutionStrategy.sequential
*/
readonly finalizerExecutionStrategy: ExecutionStrategy.ExecutionStrategy readonly finalizerExecutionStrategy: ExecutionStrategy.ExecutionStrategy
/**
* Debounce time before executing finalizers after component unmount.
* Helps avoid unnecessary work during fast remount/remount cycles.
* @default "100 millis"
*/
readonly finalizerExecutionDebounce: Duration.DurationInput readonly finalizerExecutionDebounce: Duration.DurationInput
} }
} }
@@ -318,6 +339,19 @@ export declare namespace make {
} }
} }
/**
* Creates an Effect-FC Component following the same overloads and pipeline style as `Effect.fn`.
*
* This is the **recommended** way to define components. It supports:
* - Generator syntax (yield* style) — most ergonomic and readable
* - Direct Effect return (non-generator)
* - Chained transformation functions (like Effect.fn pipelines)
* - Optional tracing span with automatic `displayName`
*
* When you provide a `spanName` as the first argument, two things happen automatically:
* 1. A tracing span is created with that name (unless using `makeUntraced`)
* 2. The resulting React component gets `displayName = spanName`
*/
export const make: ( export const make: (
& make.Gen & make.Gen
& make.NonGen & make.NonGen
@@ -346,6 +380,17 @@ export const make: (
} }
} }
/**
* Same as `make`, but creates an **untraced** version — no automatic tracing span is created.
*
* Follows the exact same API shape as `Effect.fnUntraced`.
* Useful for:
* - Components where you want full manual control over tracing
* - Avoiding span noise in deeply nested UI
*
* When a string is provided as first argument, it is **only** used as the React component's `displayName`
* (no tracing span is created).
*/
export const makeUntraced: ( export const makeUntraced: (
& make.Gen & make.Gen
& make.NonGen & make.NonGen
@@ -367,6 +412,9 @@ export const makeUntraced: (
) )
) )
/**
* Creates a new component with modified options while preserving original behavior.
*/
export const withOptions: { export const withOptions: {
<T extends Component<any, any, any, any>>( <T extends Component<any, any, any, any>>(
options: Partial<Component.Options> options: Partial<Component.Options>
@@ -383,6 +431,39 @@ export const withOptions: {
Object.getPrototypeOf(self), Object.getPrototypeOf(self),
)) ))
/**
* Wraps an Effect-FC `Component` and turns it into a regular React function component
* that serves as an **entrypoint** into an Effect-FC component hierarchy.
*
* This is the recommended way to connect Effect-FC components to the rest of your React app,
* especially when using routers (TanStack Router, React Router, etc.), lazy-loaded routes,
* or any place where a standard React component is expected.
*
* The runtime is obtained from the provided React Context, allowing you to:
* - Provide dependencies once at a high level
* - Use the same runtime across an entire route tree or feature
*
* @example Using TanStack Router
* ```tsx
* // Main
* export const runtime = ReactRuntime.make(Layer.empty)
* function App() {
* return (
* <ReactRuntime.Provider runtime={runtime}>
* <RouterProvider router={router} />
* </ReactRuntime.Provider>
* )
* }
*
* // Route
* export const Route = createFileRoute("/")({
* component: Component.withRuntime(HomePage, runtime.context)
* })
* ```
*
* @param self - The Effect-FC Component you want to render as a regular React component.
* @param context - React Context that holds the Runtime to use for this component tree. See the `ReactRuntime` module to create one.
*/
export const withRuntime: { export const withRuntime: {
<P extends {}, A extends React.ReactNode, E, R>( <P extends {}, A extends React.ReactNode, E, R>(
context: React.Context<Runtime.Runtime<R>>, context: React.Context<Runtime.Runtime<R>>,
@@ -402,6 +483,10 @@ export const withRuntime: {
}) })
/**
* Service that keeps track of scopes associated with React components
* (used internally by the `useScope` hook).
*/
export class ScopeMap extends Effect.Service<ScopeMap>()("@effect-fc/Component/ScopeMap", { export class ScopeMap extends Effect.Service<ScopeMap>()("@effect-fc/Component/ScopeMap", {
effect: Effect.bind(Effect.Do, "ref", () => Ref.make(HashMap.empty<object, ScopeMap.Entry>())) effect: Effect.bind(Effect.Do, "ref", () => Ref.make(HashMap.empty<object, ScopeMap.Entry>()))
}) {} }) {}
@@ -421,6 +506,14 @@ export declare namespace useScope {
} }
} }
/**
* Hook that creates and manages a `Scope` for the current component instance.
*
* Automatically closes the scope whenever `deps` changes or the component unmounts.
*
* @param deps - dependency array like in `React.useEffect`
* @param options - finalizer execution control
*/
export const useScope = Effect.fnUntraced(function*( export const useScope = Effect.fnUntraced(function*(
deps: React.DependencyList, deps: React.DependencyList,
options?: useScope.Options, options?: useScope.Options,
@@ -429,43 +522,40 @@ export const useScope = Effect.fnUntraced(function*(
const runtimeRef = React.useRef<Runtime.Runtime<never>>(null!) const runtimeRef = React.useRef<Runtime.Runtime<never>>(null!)
runtimeRef.current = yield* Effect.runtime() runtimeRef.current = yield* Effect.runtime()
const scopeMap = yield* ScopeMap as unknown as Effect.Effect<ScopeMap> const { key, scope } = React.useMemo(() => Runtime.runSync(runtimeRef.current)(Effect.Do.pipe(
Effect.bind("scopeMapRef", () => Effect.map(
const [key, scope] = React.useMemo(() => Runtime.runSync(runtimeRef.current)(Effect.andThen( ScopeMap as unknown as Effect.Effect<ScopeMap>,
Effect.all([Effect.succeed({}), scopeMap.ref]), scopeMap => scopeMap.ref,
([key, map]) => Effect.andThen( )),
Option.match(HashMap.get(map, key), { Effect.let("key", () => ({})),
onSome: entry => Effect.succeed(entry.scope), Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy)),
onNone: () => Effect.tap( Effect.tap(({ scopeMapRef, key, scope }) =>
Scope.make(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy), Ref.update(scopeMapRef, HashMap.set(key, {
scope => Ref.update(scopeMap.ref, HashMap.set(key, {
scope, scope,
closeFiber: Option.none(), closeFiber: Option.none(),
})), }))
),
}),
scope => [key, scope] as const,
), ),
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
)), deps) )), deps)
// biome-ignore lint/correctness/useExhaustiveDependencies: only reactive on "key" // biome-ignore lint/correctness/useExhaustiveDependencies: only reactive on "key"
React.useEffect(() => Runtime.runSync(runtimeRef.current)(scopeMap.ref.pipe( React.useEffect(() => Runtime.runSync(runtimeRef.current)((ScopeMap as unknown as Effect.Effect<ScopeMap>).pipe(
Effect.map(scopeMap => scopeMap.ref),
Effect.tap(ref => ref.pipe(
Effect.andThen(HashMap.get(key)), Effect.andThen(HashMap.get(key)),
Effect.tap(entry => Option.match(entry.closeFiber, { Effect.andThen(entry => Option.match(entry.closeFiber, {
onSome: fiber => Effect.andThen( onSome: Fiber.interruptFork,
Ref.update(scopeMap.ref, HashMap.set(key, { ...entry, closeFiber: Option.none() })),
Fiber.interruptFork(fiber),
),
onNone: () => Effect.void, onNone: () => Effect.void,
})), })),
Effect.map(({ scope }) =>
() => Runtime.runSync(runtimeRef.current)(Effect.andThen(
Effect.forkDaemon(Effect.sleep(options?.finalizerExecutionDebounce ?? defaultOptions.finalizerExecutionDebounce).pipe(
Effect.andThen(Scope.close(scope, Exit.void)),
Effect.andThen(Ref.update(scopeMap.ref, HashMap.remove(key))),
)), )),
fiber => Ref.update(scopeMap.ref, HashMap.set(key, { Effect.map(ref =>
() => Runtime.runSync(runtimeRef.current)(Effect.andThen(
Effect.sleep(options?.finalizerExecutionDebounce ?? defaultOptions.finalizerExecutionDebounce).pipe(
Effect.andThen(Scope.close(scope, Exit.void)),
Effect.onExit(() => Ref.update(ref, HashMap.remove(key))),
Effect.forkDaemon,
),
fiber => Ref.update(ref, HashMap.set(key, {
scope, scope,
closeFiber: Option.some(fiber), closeFiber: Option.some(fiber),
})), })),
@@ -476,6 +566,9 @@ export const useScope = Effect.fnUntraced(function*(
return scope return scope
}) })
/**
* Runs an effect and returns its result only once on component mount.
*/
export const useOnMount = Effect.fnUntraced(function* <A, E, R>( export const useOnMount = Effect.fnUntraced(function* <A, E, R>(
f: () => Effect.Effect<A, E, R> f: () => Effect.Effect<A, E, R>
): Effect.fn.Return<A, E, R> { ): Effect.fn.Return<A, E, R> {
@@ -487,6 +580,11 @@ export declare namespace useOnChange {
export interface Options extends useScope.Options {} export interface Options extends useScope.Options {}
} }
/**
* Runs an effect and returns its result whenever dependencies change.
*
* Provides its own `Scope` which closes whenever `deps` changes or the component unmounts.
*/
export const useOnChange = Effect.fnUntraced(function* <A, E, R>( export const useOnChange = Effect.fnUntraced(function* <A, E, R>(
f: () => Effect.Effect<A, E, R>, f: () => Effect.Effect<A, E, R>,
deps: React.DependencyList, deps: React.DependencyList,
@@ -508,6 +606,11 @@ export declare namespace useReactEffect {
} }
} }
/**
* Like `React.useEffect` but accepts an effect.
*
* Cleanup logic is handled through the `Scope` API rather than using imperative cleanup.
*/
export const useReactEffect = Effect.fnUntraced(function* <E, R>( export const useReactEffect = Effect.fnUntraced(function* <E, R>(
f: () => Effect.Effect<void, E, R>, f: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList, deps?: React.DependencyList,
@@ -544,6 +647,11 @@ export declare namespace useReactLayoutEffect {
export interface Options extends useReactEffect.Options {} export interface Options extends useReactEffect.Options {}
} }
/**
* Like `React.useReactLayoutEffect` but accepts an effect.
*
* Cleanup logic is handled through the `Scope` API rather than using imperative cleanup.
*/
export const useReactLayoutEffect = Effect.fnUntraced(function* <E, R>( export const useReactLayoutEffect = Effect.fnUntraced(function* <E, R>(
f: () => Effect.Effect<void, E, R>, f: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList, deps?: React.DependencyList,
@@ -554,18 +662,27 @@ export const useReactLayoutEffect = Effect.fnUntraced(function* <E, R>(
React.useLayoutEffect(() => runReactEffect(runtime, f, options), deps) React.useLayoutEffect(() => runReactEffect(runtime, f, options), deps)
}) })
/**
* Get a synchronous run function for the current runtime context.
*/
export const useRunSync = <R = never>(): Effect.Effect< export const useRunSync = <R = never>(): Effect.Effect<
<A, E = never>(effect: Effect.Effect<A, E, Scope.Scope | R>) => A, <A, E = never>(effect: Effect.Effect<A, E, Scope.Scope | R>) => A,
never, never,
Scope.Scope | R Scope.Scope | R
> => Effect.andThen(Effect.runtime(), Runtime.runSync) > => Effect.andThen(Effect.runtime(), Runtime.runSync)
/**
* Get a Promise-based run function for the current runtime context.
*/
export const useRunPromise = <R = never>(): Effect.Effect< export const useRunPromise = <R = never>(): Effect.Effect<
<A, E = never>(effect: Effect.Effect<A, E, Scope.Scope | R>) => Promise<A>, <A, E = never>(effect: Effect.Effect<A, E, Scope.Scope | R>) => Promise<A>,
never, never,
Scope.Scope | R Scope.Scope | R
> => Effect.andThen(Effect.runtime(), context => Runtime.runPromise(context)) > => Effect.andThen(Effect.runtime(), context => Runtime.runPromise(context))
/**
* Turns a function returning an effect into a memoized synchronous function.
*/
export const useCallbackSync = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>( export const useCallbackSync = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
f: (...args: Args) => Effect.Effect<A, E, R>, f: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList, deps: React.DependencyList,
@@ -578,6 +695,9 @@ export const useCallbackSync = Effect.fnUntraced(function* <Args extends unknown
return React.useCallback((...args: Args) => Runtime.runSync(runtimeRef.current)(f(...args)), deps) return React.useCallback((...args: Args) => Runtime.runSync(runtimeRef.current)(f(...args)), deps)
}) })
/**
* Turns a function returning an effect into a memoized Promise-based asynchronous function.
*/
export const useCallbackPromise = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>( export const useCallbackPromise = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
f: (...args: Args) => Effect.Effect<A, E, R>, f: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList, deps: React.DependencyList,
@@ -594,10 +714,17 @@ export declare namespace useContext {
export interface Options extends useOnChange.Options {} export interface Options extends useOnChange.Options {}
} }
/**
* Hook that constructs a layer and returns the created context.
*
* The layer gets reconstructed everytime `layer` changes, so make sure its value is stable.
*
* Building a layer containing asynchronous effects require the component calling this hook to be made async using `Async.async`.
*/
export const useContext = <ROut, E, RIn>( export const useContext = <ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>, layer: Layer.Layer<ROut, E, RIn>,
options?: useContext.Options, options?: useContext.Options,
): Effect.Effect<Context.Context<ROut>, E, Scope.Scope | RIn> => useOnChange(() => Effect.context<RIn>().pipe( ): Effect.Effect<Context.Context<ROut>, E, Exclude<RIn, Scope.Scope>> => useOnChange(() => Effect.context<RIn>().pipe(
Effect.map(context => ManagedRuntime.make(Layer.provide(layer, Layer.succeedContext(context)))), Effect.map(context => ManagedRuntime.make(Layer.provide(layer, Layer.succeedContext(context)))),
Effect.tap(runtime => Effect.addFinalizer(() => runtime.disposeEffect)), Effect.tap(runtime => Effect.addFinalizer(() => runtime.disposeEffect)),
Effect.andThen(runtime => runtime.runtimeEffect), Effect.andThen(runtime => runtime.runtimeEffect),

View File

@@ -1,4 +1,4 @@
import { type Cause, type Context, DateTime, type Duration, Effect, Equal, Equivalence, Fiber, HashMap, identity, Option, Pipeable, Predicate, type Scope, Stream, Subscribable, SubscriptionRef } from "effect" import { type Cause, type Context, type Duration, Effect, Equal, Fiber, identity, Option, Pipeable, Predicate, type Scope, Stream, Subscribable, SubscriptionRef } from "effect"
import * as QueryClient from "./QueryClient.js" import * as QueryClient from "./QueryClient.js"
import * as Result from "./Result.js" import * as Result from "./Result.js"
@@ -80,7 +80,7 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
) )
} }
get interrupt(): Effect.Effect<void, never, never> { get interrupt(): Effect.Effect<void> {
return Effect.andThen(this.fiber, Option.match({ return Effect.andThen(this.fiber, Option.match({
onSome: Fiber.interrupt, onSome: Fiber.interrupt,
onNone: () => Effect.void, onNone: () => Effect.void,
@@ -159,7 +159,7 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
> { > {
return Effect.andThen(this.getCacheEntry(key), Option.match({ return Effect.andThen(this.getCacheEntry(key), Option.match({
onSome: entry => Effect.andThen( onSome: entry => Effect.andThen(
QueryClient.isQueryClientCacheEntryStale(entry, this.staleTime), QueryClient.isQueryClientCacheEntryStale(entry),
isStale => isStale isStale => isStale
? this.start(key, Result.willRefresh(entry.result) as Result.Final<A, E, P>) ? this.start(key, Result.willRefresh(entry.result) as Result.Final<A, E, P>)
: Effect.succeed(Subscribable.make({ : Effect.succeed(Subscribable.make({
@@ -212,7 +212,7 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
) as Effect.Effect<Result.Final<A, E, P>>), ) as Effect.Effect<Result.Final<A, E, P>>),
Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))), Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
Effect.tap(result => Result.isSuccess(result) Effect.tap(result => Result.isSuccess(result)
? this.updateCacheEntry(key, result) ? this.setCacheEntry(key, result)
: Effect.void : Effect.void
), ),
) )
@@ -225,44 +225,41 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
getCacheEntry( getCacheEntry(
key: K key: K
): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> { ): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> {
return QueryClient.QueryClient.pipe( return Effect.andThen(
Effect.andThen(client => client.cache), Effect.all([
Effect.map(HashMap.get(this.makeCacheKey(key))), Effect.succeed(this.makeCacheKey(key)),
QueryClient.QueryClient,
]),
([key, client]) => client.getCacheEntry(key),
) )
} }
updateCacheEntry( setCacheEntry(
key: K, key: K,
result: Result.Success<A>, result: Result.Success<A>,
): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> { ): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> {
return Effect.Do.pipe( return Effect.andThen(
Effect.bind("client", () => QueryClient.QueryClient), Effect.all([
Effect.bind("now", () => DateTime.now), Effect.succeed(this.makeCacheKey(key)),
Effect.let("entry", ({ now }) => new QueryClient.QueryClientCacheEntry(result, now)), QueryClient.QueryClient,
Effect.tap(({ client, entry }) => SubscriptionRef.update( ]),
client.cache, ([key, client]) => client.setCacheEntry(key, result, this.staleTime),
HashMap.set(this.makeCacheKey(key), entry),
)),
Effect.map(({ entry }) => entry),
) )
} }
get invalidateCache(): Effect.Effect<void> { get invalidateCache(): Effect.Effect<void> {
return QueryClient.QueryClient.pipe( return QueryClient.QueryClient.pipe(
Effect.andThen(client => SubscriptionRef.update( Effect.andThen(client => client.invalidateCacheEntries(this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>)),
client.cache,
HashMap.filter((_, key) => !Equivalence.strict()(key.f, this.f)),
)),
Effect.provide(this.context), Effect.provide(this.context),
) )
} }
invalidateCacheEntry(key: K): Effect.Effect<void> { invalidateCacheEntry(key: K): Effect.Effect<void> {
return QueryClient.QueryClient.pipe( return Effect.all([
Effect.andThen(client => SubscriptionRef.update( Effect.succeed(this.makeCacheKey(key)),
client.cache, QueryClient.QueryClient,
HashMap.remove(this.makeCacheKey(key)), ]).pipe(
)), Effect.andThen(([key, client]) => client.invalidateCacheEntry(key)),
Effect.provide(this.context), Effect.provide(this.context),
) )
} }

View File

@@ -1,17 +1,28 @@
import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, Pipeable, Predicate, type Scope, SubscriptionRef } from "effect" import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, type Option, Pipeable, Predicate, Schedule, type Scope, type Subscribable, SubscriptionRef } from "effect"
import type * as Query from "./Query.js" import type * as Query from "./Query.js"
import type * as Result from "./Result.js" import type * as Result from "./Result.js"
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientServiceTypeId") export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService")
export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
export interface QueryClientService extends Pipeable.Pipeable { export interface QueryClientService extends Pipeable.Pipeable {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly gcTime: Duration.DurationInput readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly cacheGcTime: Duration.DurationInput
readonly defaultStaleTime: Duration.DurationInput readonly defaultStaleTime: Duration.DurationInput
readonly defaultRefreshOnWindowFocus: boolean 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.DurationInput,
): 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 Effect.Service<QueryClient>()("@effect-fc/QueryClient/QueryClient", { export class QueryClient extends Effect.Service<QueryClient>()("@effect-fc/QueryClient/QueryClient", {
@@ -25,20 +36,64 @@ implements QueryClientService {
constructor( constructor(
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>, readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
readonly gcTime: Duration.DurationInput, readonly cacheGcTime: Duration.DurationInput,
readonly defaultStaleTime: Duration.DurationInput, readonly defaultStaleTime: Duration.DurationInput,
readonly defaultRefreshOnWindowFocus: boolean, readonly defaultRefreshOnWindowFocus: boolean,
readonly runSemaphore: Effect.Semaphore, readonly runSemaphore: Effect.Semaphore,
) { ) {
super() super()
} }
get run(): Effect.Effect<void> {
return this.runSemaphore.withPermits(1)(Effect.repeat(
Effect.andThen(
DateTime.now,
now => SubscriptionRef.update(this.cache, HashMap.filter(entry =>
Duration.lessThan(
DateTime.distanceDuration(entry.lastAccessedAt, now),
Duration.sum(entry.staleTime, this.cacheGcTime),
)
)),
),
Schedule.spaced("30 second"),
))
}
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>> {
return Effect.all([
Effect.andThen(this.cache, HashMap.get(key)),
DateTime.now,
]).pipe(
Effect.map(([entry, now]) => new QueryClientCacheEntry(entry.result, entry.staleTime, entry.createdAt, now)),
Effect.tap(entry => SubscriptionRef.update(this.cache, HashMap.set(key, entry))),
Effect.option,
)
}
setCacheEntry(
key: QueryClientCacheKey,
result: Result.Success<unknown>,
staleTime: Duration.DurationInput,
): Effect.Effect<QueryClientCacheEntry> {
return DateTime.now.pipe(
Effect.map(now => new QueryClientCacheEntry(result, staleTime, now, now)),
Effect.tap(entry => SubscriptionRef.update(this.cache, HashMap.set(key, entry))),
)
}
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void> {
return SubscriptionRef.update(this.cache, HashMap.filter((_, key) => !Equivalence.strict()(key.f, f)))
}
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void> {
return SubscriptionRef.update(this.cache, HashMap.remove(key))
}
} }
export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId) export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId)
export declare namespace make { export declare namespace make {
export interface Options { export interface Options {
readonly gcTime?: Duration.DurationInput readonly cacheGcTime?: Duration.DurationInput
readonly defaultStaleTime?: Duration.DurationInput readonly defaultStaleTime?: Duration.DurationInput
readonly defaultRefreshOnWindowFocus?: boolean readonly defaultRefreshOnWindowFocus?: boolean
} }
@@ -47,22 +102,20 @@ export declare namespace make {
export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return<QueryClientService> { export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return<QueryClientService> {
return new QueryClientServiceImpl( return new QueryClientServiceImpl(
yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()), yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()),
options.gcTime ?? "5 minutes", options.cacheGcTime ?? "5 minutes",
options.defaultStaleTime ?? "0 minutes", options.defaultStaleTime ?? "0 minutes",
options.defaultRefreshOnWindowFocus ?? true, options.defaultRefreshOnWindowFocus ?? true,
yield* Effect.makeSemaphore(1), yield* Effect.makeSemaphore(1),
) )
}) })
export const run = (_self: QueryClientService): Effect.Effect<void> => Effect.void
export declare namespace service { export declare namespace service {
export interface Options extends make.Options {} export interface Options extends make.Options {}
} }
export const service = (options?: service.Options): Effect.Effect<QueryClientService, never, Scope.Scope> => Effect.tap( export const service = (options?: service.Options): Effect.Effect<QueryClientService, never, Scope.Scope> => Effect.tap(
make(options), make(options),
client => Effect.forkScoped(run(client)), client => Effect.forkScoped(client.run),
) )
@@ -102,7 +155,9 @@ implements Pipeable.Pipeable {
constructor( constructor(
readonly result: Result.Success<unknown>, readonly result: Result.Success<unknown>,
readonly staleTime: Duration.DurationInput,
readonly createdAt: DateTime.DateTime, readonly createdAt: DateTime.DateTime,
readonly lastAccessedAt: DateTime.DateTime,
) { ) {
super() super()
} }
@@ -111,9 +166,8 @@ implements Pipeable.Pipeable {
export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId) export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId)
export const isQueryClientCacheEntryStale = ( export const isQueryClientCacheEntryStale = (
self: QueryClientCacheEntry, self: QueryClientCacheEntry
staleTime: Duration.DurationInput,
): Effect.Effect<boolean> => Effect.andThen( ): Effect.Effect<boolean> => Effect.andThen(
DateTime.now, DateTime.now,
now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), staleTime), now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), self.staleTime),
) )