Add Effect v4 version
Lint / lint (push) Successful in 48s

This commit is contained in:
Julien Valverdé
2026-06-22 02:04:20 +02:00
parent b7ea35006d
commit 091e102b23
41 changed files with 3832 additions and 2 deletions
+56
View File
@@ -0,0 +1,56 @@
# Effect FC Next
[Effect-TS](https://effect.website/) integration for React 19.2+ that allows you to write function components using Effect generators.
This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome.
Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
## Peer dependencies
- `effect` 3.19+
- `react` & `@types/react` 19.2+
## Known issues
- React Refresh doesn't work for Effect FC's yet. Page reload is required to view changes. Regular React components are unaffected.
## What writing components looks like
```typescript
export class TodosView extends Component.make("TodosView")(function*() {
const state = yield* TodosState
const [todos] = yield* Component.useSubscribables([state.subscriptionRef])
yield* Component.useOnMount(() => Effect.andThen(
Console.log("Todos mounted"),
Effect.addFinalizer(() => Console.log("Todos unmounted")),
))
const Todo = yield* TodoView.use
return (
<Container>
<Heading align="center">Todos</Heading>
<Flex direction="column" align="stretch" gap="2" mt="2">
<Todo _tag="new" />
{Chunk.map(todos, todo =>
<Todo key={todo.id} _tag="edit" id={todo.id} />
)}
</Flex>
</Container>
)
}) {}
const Index = Component.make("IndexView")(function*() {
const context = yield* Component.useContextFromLayer(TodosState.Default)
const Todos = yield* Effect.provide(TodosView.use, context)
return <Todos />
}).pipe(
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/")({
component: Index
})
```
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"root": false,
"extends": "//",
"files": {
"includes": ["./src/**"]
}
}
+54
View File
@@ -0,0 +1,54 @@
{
"name": "effect-fc-next",
"description": "Write React function components with Effect",
"version": "0.1.0-beta.0",
"type": "module",
"files": [
"./README.md",
"./dist"
],
"license": "MIT",
"repository": {
"url": "git+https://github.com/Thiladev/effect-fc.git"
},
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./*": [
{
"types": "./dist/*/index.d.ts",
"default": "./dist/*/index.js"
},
{
"types": "./dist/*.d.ts",
"default": "./dist/*.js"
}
]
},
"scripts": {
"build": "tsc",
"lint:tsc": "tsc --noEmit",
"lint:biome": "biome lint",
"test": "vitest run",
"pack": "npm pack",
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
"clean:dist": "rm -rf dist",
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@testing-library/react": "^16.3.0",
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "4.0.0-beta.85",
"react": "^19.2.0"
},
"dependencies": {
"effect-lens": "2.0.0-beta.0"
}
}
+171
View File
@@ -0,0 +1,171 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Context, Effect, type Equivalence, Function, Predicate, Scope } from "effect"
import * as React from "react"
import * as Component from "./Component.js"
export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
export type AsyncTypeId = typeof AsyncTypeId
/**
* A trait for `Component`'s that allows them running asynchronous effects.
*/
export interface Async extends AsyncPrototype, AsyncOptions {}
export interface AsyncPrototype {
readonly [AsyncTypeId]: AsyncTypeId
}
/**
* Configuration options for `Async` components.
*/
export interface AsyncOptions {
/**
* The default fallback React node to display while the async operation is pending.
* Used if no fallback is provided to the component when rendering.
*/
readonly defaultFallback?: React.ReactNode
}
/**
* Props for `Async` components.
*/
export type AsyncProps = Omit<React.SuspenseProps, "children">
export const AsyncPrototype: AsyncPrototype = Object.freeze({
[AsyncTypeId]: AsyncTypeId,
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Component.Signature>(
this: Component.Component<P, A, E, R, F> & Async,
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>,
) {
const Inner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
return ({ fallback, name, ...props }: AsyncProps) => {
const promise = Effect.runPromiseWith(contextRef.current)(
Effect.andThen(
Component.useScope([], this),
scope => Effect.provideService(this.body(props as P), Scope.Scope, scope),
)
)
return React.createElement(
React.Suspense,
{ fallback: fallback ?? this.defaultFallback, name },
React.createElement(Inner, { promise }),
)
}
},
} as const)
/**
* An equivalence function for comparing `AsyncProps` that ignores the `fallback` property.
* Used by default by async components with `Memoized.memoized` applied.
*/
export const defaultPropsEquivalence: Equivalence.Equivalence<AsyncProps> = (
self: Record<string, unknown>,
that: Record<string, unknown>,
) => {
if (self === that)
return true
for (const key in self) {
if (key === "fallback")
continue
if (!(key in that) || !Object.is(self[key], that[key]))
return false
}
for (const key in that) {
if (key === "fallback")
continue
if (!(key in self))
return false
}
return true
}
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, AsyncTypeId)
/**
* Converts a Component into an `Async` component that supports running asynchronous effects.
*
* Note: The component cannot have a prop named "promise" as it's reserved for internal use.
*
* @param self - The component to convert to an Async component
* @returns A new `Async` component with the same body, error, and context types as the input
*
* @example
* ```ts
* const MyAsyncComponent = MyComponent.pipe(
* Async.async,
* )
* ```
*/
export const async = <T extends Component.Component.Any>(
self: T & (
"promise" extends keyof Component.Component.Props<T>
? "The 'promise' prop name is restricted for Async components. Please rename the 'promise' prop to something else."
: T
)
): (
& Omit<T, keyof Component.Component.AsComponent<T>>
& Component.Component<
Component.Component.Props<T> & AsyncProps,
Component.Component.Success<T>,
Component.Component.Error<T>,
Component.Component.Context<T>,
Component.Component.DefaultSignature<Component.Component.Props<T> & AsyncProps, Component.Component.Success<T>>
>
& Async
) => Object.setPrototypeOf(
Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }),
Object.freeze(Object.setPrototypeOf(
Object.assign({}, AsyncPrototype),
Object.getPrototypeOf(self),
)),
)
/**
* Applies options to an Async component, returning a new Async component with the updated configuration.
*
* Supports both curried and uncurried application styles.
*
* @param self - The Async component to apply options to (in uncurried form)
* @param options - The options to apply to the component
* @returns An Async component with the applied options
*
* @example
* ```ts
* // Curried
* const MyAsyncComponent = MyComponent.pipe(
* Async.async,
* Async.withOptions({ defaultFallback: <p>Loading...</p> }),
* )
*
* // Uncurried
* const MyAsyncComponent = Async.withOptions(
* Async.async(MyComponent),
* { defaultFallback: <p>Loading...</p> },
* )
* ```
*/
export const withOptions: {
<T extends Component.Component.Any & Async>(
options: Partial<AsyncOptions>
): (self: T) => T
<T extends Component.Component.Any & Async>(
self: T,
options: Partial<AsyncOptions>,
): T
} = Function.dual(2, <T extends Component.Component.Any & Async>(
self: T,
options: Partial<AsyncOptions>,
): T => Object.setPrototypeOf(
Object.assign(function() {}, self, options),
Object.getPrototypeOf(self),
))
+385
View File
@@ -0,0 +1,385 @@
/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */
import {
Context,
type Duration,
Effect,
Exit,
Function,
identity,
Layer,
Pipeable,
Predicate,
Scope,
Tracer,
} from "effect"
import * as React from "react"
export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component")
export type ComponentTypeId = typeof ComponentTypeId
export interface Component<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>
extends ComponentPrototype<R, F>, ComponentOptions {
new(_: never): Record<string, never>
readonly [ComponentTypeId]: ComponentTypeId
readonly "~Props": P
readonly "~Success": A
readonly "~Error": E
readonly "~Context": R
readonly "~Function": F
readonly body: (props: P) => Effect.Effect<A, E, R>
}
export declare namespace Component {
export type Default<P extends {}, A extends React.ReactNode, E, R> = Component<P, A, E, R, DefaultSignature<P, A>>
export type Any = Component<any, any, any, any, any>
export type Signature = (props: any) => React.ReactNode
export type DefaultSignature<P extends {}, A extends React.ReactNode> = (props: P) => A
export type Props<T extends Any> = T["~Props"]
export type Success<T extends Any> = T["~Success"]
export type Error<T extends Any> = T["~Error"]
export type Context<T extends Any> = T["~Context"]
export type Function<T extends Any> = T["~Function"]
export type AsComponent<T extends Any> = Component<Props<T>, Success<T>, Error<T>, Context<T>, Function<T>>
}
export interface ComponentOptions {
readonly displayName?: string
readonly nonReactiveTags: readonly Context.Key<any, any>[]
readonly finalizerExecutionStrategy: "sequential" | "parallel"
readonly finalizerExecutionDebounce: Duration.Input
}
export const defaultOptions: ComponentOptions = {
nonReactiveTags: [Tracer.ParentSpan],
finalizerExecutionStrategy: "sequential",
finalizerExecutionDebounce: "100 millis",
}
export interface ComponentPrototype<R, F extends Component.Signature> extends Pipeable.Pipeable {
readonly [ComponentTypeId]: ComponentTypeId
readonly use: Effect.Effect<F, never, Exclude<R, Scope.Scope>>
}
type ComponentImpl = Component<any, React.ReactNode, any, any, Component.Signature>
const makeFunctionComponent = (
self: ComponentImpl,
contextRef: React.RefObject<Context.Context<any>>,
): Component.Signature => {
if ("asFunctionComponent" in self && typeof self.asFunctionComponent === "function") {
return self.asFunctionComponent(contextRef)
}
const FunctionComponent = (props: {}) => Effect.runSyncWith(contextRef.current)(
Effect.flatMap(
useScope([], self),
scope => Effect.provideService(self.body(props), Scope.Scope, scope),
),
)
FunctionComponent.displayName = self.displayName ?? "Anonymous"
return "transformFunctionComponent" in self && typeof self.transformFunctionComponent === "function"
? self.transformFunctionComponent(FunctionComponent)
: FunctionComponent
}
const use = Effect.fnUntraced(function* (self: ComponentImpl) {
const context = yield* Effect.context<any>()
const cached = componentCache.get(self)
if (cached !== undefined) {
cached.contextRef.current = context
return cached.component
}
const contextRef = { current: context }
const component = makeFunctionComponent(self, contextRef)
componentCache.set(self, { contextRef, component })
return component
})
const componentCache = new WeakMap<ComponentImpl, {
readonly contextRef: { current: Context.Context<any> }
readonly component: Component.Signature
}>()
export const ComponentPrototype = Object.freeze({
[ComponentTypeId]: ComponentTypeId,
...Pipeable.Prototype,
get use() {
return use(this as ComponentImpl)
},
}) as unknown as ComponentPrototype<any, any>
export const isComponent = (u: unknown): u is Component.Any => Predicate.hasProperty(u, ComponentTypeId)
type GeneratorBody<P extends {}, A extends React.ReactNode, E, R> = (
props: P,
) => Effect.fn.Return<A, E, R>
type EffectBody<P extends {}, A extends React.ReactNode, E, R> = (
props: P,
) => Effect.Effect<A, E, R>
export interface Make {
<P extends {}, A extends React.ReactNode, E = never, R = never>(
body: GeneratorBody<P, A, E, R> | EffectBody<P, A, E, R>,
...pipeables: readonly Function[]
): Component.Default<P, A, E, R>
(name: string, options?: Tracer.SpanOptionsNoTrace): <P extends {}, A extends React.ReactNode, E = never, R = never>(
body: GeneratorBody<P, A, E, R> | EffectBody<P, A, E, R>,
...pipeables: readonly Function[]
) => Component.Default<P, A, E, R>
}
const component = (
body: Function,
displayName: string | undefined,
traced: boolean,
pipeables: readonly Function[],
): Component.Any => Object.setPrototypeOf(
Object.assign(function() {}, defaultOptions, {
body: traced && displayName
? Effect.fn(displayName)(body as never, ...pipeables as [])
: Effect.fnUntraced(body as never, ...pipeables as []),
displayName,
}),
ComponentPrototype,
)
export const make: Make = ((nameOrBody: string | Function, ...args: readonly unknown[]) => {
if (typeof nameOrBody === "string") {
return (body: Function, ...pipeables: readonly Function[]) => component(body, nameOrBody, true, pipeables)
}
return component(nameOrBody, undefined, true, args as readonly Function[])
}) as Make
export const makeUntraced: Make = ((nameOrBody: string | Function, ...args: readonly unknown[]) => {
if (typeof nameOrBody === "string") {
return (body: Function, ...pipeables: readonly Function[]) => component(body, nameOrBody, false, pipeables)
}
return component(nameOrBody, undefined, false, args as readonly Function[])
}) as Make
export declare namespace withSignature {
export type Result<T extends Component.Any, F extends Component.Signature> = (
& Omit<T, keyof Component.AsComponent<T>>
& Component<Component.Props<T>, Component.Success<T>, Component.Error<T>, Component.Context<T>, F>
)
}
export const withSignature: {
<F extends Component.Signature>(): <T extends Component.Any>(self: T) => withSignature.Result<T, F>
<F extends Component.Signature, T extends Component.Any>(self: T): withSignature.Result<T, F>
} = (self?: Component.Any): any => self === undefined ? identity : self
export const withOptions: {
<T extends Component.Any>(options: Partial<ComponentOptions>): (self: T) => T
<T extends Component.Any>(self: T, options: Partial<ComponentOptions>): T
} = Function.dual(2, <T extends Component.Any>(self: T, options: Partial<ComponentOptions>): T => Object.setPrototypeOf(
Object.assign(function() {}, self, options),
Object.getPrototypeOf(self),
))
export const withRuntime: {
<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
context: React.Context<Context.Context<R>>,
): (self: Component<P, A, E, Scope.Scope | NoInfer<R>, F>) => F
<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
self: Component<P, A, E, Scope.Scope | NoInfer<R>, F>,
context: React.Context<Context.Context<R>>,
): F
} = Function.dual(2, <P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
self: Component<P, A, E, Scope.Scope | R, F>,
context: React.Context<Context.Context<R>>,
) => function WithRuntime(props: P) {
return React.createElement(
Effect.runSyncWith(React.useContext(context))(self.use) as React.FC<P>,
props,
)
})
export declare namespace useScope {
export interface Options {
readonly finalizerExecutionStrategy?: "sequential" | "parallel"
readonly finalizerExecutionDebounce?: Duration.Input
}
}
export const useScope = Effect.fnUntraced(function* (
deps: React.DependencyList,
options?: useScope.Options,
): Effect.fn.Return<Scope.Scope> {
const context = yield* Effect.context<never>()
const contextRef = React.useRef(context)
contextRef.current = context
const scope = React.useMemo(
() => Scope.makeUnsafe(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy),
// biome-ignore lint/correctness/useExhaustiveDependencies: caller controls scope lifetime
deps,
)
React.useEffect(() => {
const pending = scopeCleanupTimers.get(scope)
if (pending !== undefined) clearTimeout(pending)
return () => {
const timer = setTimeout(() => {
Effect.runSyncWith(contextRef.current)(Scope.close(scope, Exit.succeed(undefined)))
scopeCleanupTimers.delete(scope)
}, durationMillis(options?.finalizerExecutionDebounce ?? defaultOptions.finalizerExecutionDebounce))
scopeCleanupTimers.set(scope, timer)
}
}, [scope, options?.finalizerExecutionDebounce])
return scope
})
const scopeCleanupTimers = new WeakMap<object, ReturnType<typeof setTimeout>>()
const durationMillis = (input: Duration.Input): number => {
if (typeof input === "number") return input
return Number(Effect.runSync(Effect.map(Effect.succeed(input), value => {
const match = typeof value === "string" ? /([\d.]+)\s*(millis|seconds?)/.exec(value) : undefined
if (!match) return 0
return Number(match[1]) * (match[2].startsWith("second") ? 1_000 : 1)
})))
}
export const useOnMount = Effect.fnUntraced(function* <A, E, R>(
f: () => Effect.Effect<A, E, R>,
): Effect.fn.Return<A, E, R> {
const context = yield* Effect.context<R>()
const id = React.useId()
let cached = mountCache.get(id)
if (cached === undefined) {
cached = {
effect: Effect.runSyncWith(context)(Effect.cached(f())),
}
mountCache.set(id, cached)
}
React.useEffect(() => {
if (cached?.cleanup !== undefined) clearTimeout(cached.cleanup)
const entry = cached
return () => {
entry.cleanup = setTimeout(() => mountCache.delete(id), 0)
}
}, [id, cached])
return yield* cached.effect as Effect.Effect<A, E, R>
})
const mountCache = new Map<string, {
readonly effect: Effect.Effect<unknown, unknown, unknown>
cleanup?: ReturnType<typeof setTimeout>
}>()
export declare namespace useOnChange {
export interface Options extends useScope.Options {}
}
export const useOnChange = Effect.fnUntraced(function* <A, E, R>(
f: () => Effect.Effect<A, E, R>,
deps: React.DependencyList,
options?: useOnChange.Options,
): Effect.fn.Return<A, E, Exclude<R, Scope.Scope>> {
const context = yield* Effect.context<Exclude<R, Scope.Scope>>()
const scope = yield* useScope(deps, options)
const cached =
// biome-ignore lint/correctness/useExhaustiveDependencies: scope tracks the caller-provided dependency list
React.useMemo(
() => Effect.runSyncWith(context)(Effect.cached(Effect.provideService(f(), Scope.Scope, scope))),
[scope],
)
return yield* cached
})
export declare namespace useReactEffect {
export interface Options {
readonly finalizerExecutionMode?: "sync" | "fork"
readonly finalizerExecutionStrategy?: "sequential" | "parallel"
}
}
const runReactEffect = <E, R>(
context: Context.Context<Exclude<R, Scope.Scope>>,
f: () => Effect.Effect<void, E, R>,
options?: useReactEffect.Options,
): (() => void) => {
const scope = Scope.makeUnsafe(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy)
Effect.runSyncWith(context)(Effect.exit(Effect.provideService(f(), Scope.Scope, scope)))
return () => {
const close = Scope.close(scope, Exit.succeed(undefined))
if ((options?.finalizerExecutionMode ?? "fork") === "sync") Effect.runSyncWith(context)(close)
else Effect.runForkWith(context)(close)
}
}
export const useReactEffect = Effect.fnUntraced(function* <E, R>(
f: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: useReactEffect.Options,
): Effect.fn.Return<void, never, Exclude<R, Scope.Scope>> {
const context = yield* Effect.context<Exclude<R, Scope.Scope>>()
// biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies
React.useEffect(() => runReactEffect(context, f, options), deps)
})
export declare namespace useReactLayoutEffect {
export interface Options extends useReactEffect.Options {}
}
export const useReactLayoutEffect = Effect.fnUntraced(function* <E, R>(
f: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: useReactLayoutEffect.Options,
): Effect.fn.Return<void, never, Exclude<R, Scope.Scope>> {
const context = yield* Effect.context<Exclude<R, Scope.Scope>>()
// biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies
React.useLayoutEffect(() => runReactEffect(context, f, options), deps)
})
export const useRunSync = <R = never>(): Effect.Effect<
<A, E = never>(effect: Effect.Effect<A, E, R>) => A,
never,
R
> => Effect.map(Effect.context<R>(), Effect.runSyncWith)
export const useRunPromise = <R = never>(): Effect.Effect<
<A, E = never>(effect: Effect.Effect<A, E, R>) => Promise<A>,
never,
R
> => Effect.map(Effect.context<R>(), Effect.runPromiseWith)
export const useCallbackSync = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
f: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
): Effect.fn.Return<(...args: Args) => A, never, R> {
const context = yield* Effect.context<R>()
const contextRef = React.useRef(context)
contextRef.current = context
// biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies
return React.useCallback((...args: Args) => Effect.runSyncWith(contextRef.current)(f(...args)), deps)
})
export const useCallbackPromise = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
f: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
): Effect.fn.Return<(...args: Args) => Promise<A>, never, R> {
const context = yield* Effect.context<R>()
const contextRef = React.useRef(context)
contextRef.current = context
// biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies
return React.useCallback((...args: Args) => Effect.runPromiseWith(contextRef.current)(f(...args)), deps)
})
export declare namespace useContext {
export interface Options extends useOnChange.Options {}
}
export const useContextFromLayer = <ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>,
options?: useContext.Options,
): Effect.Effect<Context.Context<ROut>, E, RIn | Scope.Scope> => useOnChange(
() => Effect.flatMap(
Effect.context<RIn>(),
context => Layer.build(Layer.provide(layer, Layer.succeedContext(context))),
),
[layer],
options,
)
@@ -0,0 +1,47 @@
import { type Cause, Context, Effect, Layer, Option, Pipeable, Predicate, PubSub, type Scope } from "effect"
export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
export type ErrorObserverTypeId = typeof ErrorObserverTypeId
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
readonly [ErrorObserverTypeId]: ErrorObserverTypeId
handle<A, E1, R>(effect: Effect.Effect<A, E1, R>): Effect.Effect<A, E1, R>
readonly subscribe: Effect.Effect<PubSub.Subscription<Cause.Cause<E>>, never, Scope.Scope>
}
export const ErrorObserver = <E = never>() => Context.Service<ErrorObserver<E>>(
"@effect-fc/ErrorObserver/ErrorObserver",
)
export class ErrorObserverImpl<in out E = never> extends Pipeable.Class implements ErrorObserver<E> {
readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<E>>) {
super()
}
get subscribe(): Effect.Effect<PubSub.Subscription<Cause.Cause<E>>, never, Scope.Scope> {
return PubSub.subscribe(this.pubsub)
}
handle<A, E1, R>(effect: Effect.Effect<A, E1, R>): Effect.Effect<A, E1, R> {
return Effect.tapCause(effect, cause => Effect.asVoid(
PubSub.publish(this.pubsub, cause as unknown as Cause.Cause<E>),
))
}
}
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, ErrorObserverTypeId)
export const layer: Layer.Layer<ErrorObserver> = Layer.effect(ErrorObserver())(
Effect.map(PubSub.unbounded<Cause.Cause<never>>(), pubsub => new ErrorObserverImpl(pubsub)),
)
export const handle = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => Effect.flatMap(
Effect.serviceOption(ErrorObserver()),
Option.match({
onSome: observer => observer.handle(effect),
onNone: () => effect,
}),
)
+279
View File
@@ -0,0 +1,279 @@
import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
import type * as React from "react"
import * as Component from "./Component.js"
import * as Lens from "./Lens.js"
import * as Subscribable from "./Subscribable.js"
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
export type FormTypeId = typeof FormTypeId
export interface FormIssue {
readonly path: readonly PropertyKey[]
readonly message: string
}
export interface Form<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
extends Pipeable.Pipeable {
readonly [FormTypeId]: FormTypeId
readonly path: P
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>
readonly issues: Subscribable.Subscribable<readonly FormIssue[], never, never>
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
}
export class FormImpl<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
extends Pipeable.Class implements Form<P, A, I, ER, EW> {
readonly [FormTypeId]: FormTypeId = FormTypeId
constructor(
readonly path: P,
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>,
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>,
readonly issues: Subscribable.Subscribable<readonly FormIssue[], never, never>,
readonly isValidating: Subscribable.Subscribable<boolean, never, never>,
readonly canCommit: Subscribable.Subscribable<boolean, never, never>,
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>,
) {
super()
}
}
export const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
const filterIssuesByPath = (
issues: readonly FormIssue[],
path: readonly PropertyKey[],
): readonly FormIssue[] => Array.filter(issues, issue =>
issue.path.length >= path.length && Array.every(path, (p, i) => p === issue.path[i])
)
export const focusObjectOn: {
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
self: Form<P, A, I, ER, EW>,
key: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW>
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
key: K,
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
} = Function.dual(2, <P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
self: Form<P, A, I, ER, EW>,
key: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
const form = self as unknown as FormImpl<P, A, I, ER, EW>
const path = [...form.path, key] as const
return new FormImpl(
path,
Subscribable.mapOption(form.value, a => a[key]),
Lens.focusObjectOn(form.encodedValue, key),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export const focusArrayAt: {
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
self: Form<P, A, I, ER, EW>,
index: number,
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementError, EW | Cause.NoSuchElementError>
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
index: number,
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementError, EW | Cause.NoSuchElementError>
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
self: Form<P, A, I, ER, EW>,
index: number,
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementError, EW | Cause.NoSuchElementError> => {
const form = self as unknown as FormImpl<P, A, I, ER, EW>
const path = [...form.path, index] as const
return new FormImpl(
path,
Subscribable.mapOptionEffect(form.value, values => Effect.fromOption(Array.get(values, index))),
Lens.focusArrayAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export const focusTupleAt: {
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
self: Form<P, A, I, ER, EW>,
index: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW>
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
index: K,
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
self: Form<P, A, I, ER, EW>,
index: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
const form = self as unknown as FormImpl<P, A, I, ER, EW>
const path = [...form.path, index] as const
return new FormImpl(
path,
Subscribable.mapOption(form.value, values => values[index]),
Lens.focusTupleAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export const focusChunkAt: {
<P extends readonly PropertyKey[], A, I, ER, EW>(
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
index: number,
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementError, EW>
<P extends readonly PropertyKey[], A, I, ER, EW>(
index: number,
): (self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>) => Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementError, EW>
} = Function.dual(2, <P extends readonly PropertyKey[], A, I, ER, EW>(
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
index: number,
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementError, EW> => {
const form = self as unknown as FormImpl<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>
const path = [...form.path, index] as const
return new FormImpl(
path,
Subscribable.mapOptionEffect(form.value, values => Effect.fromOption(Chunk.get(values, index))),
Lens.focusChunkAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export namespace useInput {
export interface Options {
readonly debounce?: Duration.Input
}
export interface Success<T> {
readonly value: T
readonly setValue: React.Dispatch<React.SetStateAction<T>>
}
}
export const useInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
form: Form<P, A, I, ER, EW>,
options?: useInput.Options,
): Effect.fn.Return<useInput.Success<I>, ER, Scope.Scope> {
const internalValueLens = yield* Component.useOnChange(() => Effect.gen(function*() {
const internalValueLens = yield* Lens.get(form.encodedValue).pipe(
Effect.flatMap(SubscriptionRef.make),
Effect.map(Lens.fromSubscriptionRef),
)
yield* Effect.forkScoped(Effect.all([
Stream.runForEach(
Stream.drop(form.encodedValue.changes, 1),
upstreamEncodedValue => Effect.flatMap(
Lens.get(internalValueLens),
internalValue => !Equal.equals(upstreamEncodedValue, internalValue)
? Lens.set(internalValueLens, upstreamEncodedValue)
: Effect.succeed(undefined),
),
),
Stream.runForEach(
internalValueLens.changes.pipe(
Stream.drop(1),
Stream.changesWith(Equal.asEquivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity,
),
internalValue => Lens.set(form.encodedValue, internalValue),
),
], { concurrency: "unbounded", discard: true }))
return internalValueLens
}), [form, options?.debounce])
const [value, setValue] = yield* Lens.useState(internalValueLens)
return { value, setValue }
})
export namespace useOptionalInput {
export interface Options<T> extends useInput.Options {
readonly defaultValue: T
}
export interface Success<T> extends useInput.Success<T> {
readonly enabled: boolean
readonly setEnabled: React.Dispatch<React.SetStateAction<boolean>>
}
}
export const useOptionalInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
field: Form<P, A, Option.Option<I>, ER, EW>,
options: useOptionalInput.Options<I>,
): Effect.fn.Return<useOptionalInput.Success<I>, ER, Scope.Scope> {
const [enabledLens, internalValueLens] = yield* Component.useOnChange(() => Effect.gen(function*() {
const [enabledLens, internalValueLens] = yield* Effect.flatMap(
Lens.get(field.encodedValue),
Option.match({
onSome: v => Effect.all([
Effect.map(SubscriptionRef.make(true), Lens.fromSubscriptionRef),
Effect.map(SubscriptionRef.make(v), Lens.fromSubscriptionRef),
]),
onNone: () => Effect.all([
Effect.map(SubscriptionRef.make(false), Lens.fromSubscriptionRef),
Effect.map(SubscriptionRef.make(options.defaultValue), Lens.fromSubscriptionRef),
]),
}),
)
yield* Effect.forkScoped(Effect.all([
Stream.runForEach(
Stream.drop(field.encodedValue.changes, 1),
upstreamEncodedValue => Effect.flatMap(
Effect.all([Lens.get(enabledLens), Lens.get(internalValueLens)]),
([enabled, internalValue]) => Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none())
? Effect.succeed(undefined)
: Option.match(upstreamEncodedValue, {
onSome: v => Effect.andThen(
Lens.set(enabledLens, true),
Lens.set(internalValueLens, v),
),
onNone: () => Effect.andThen(
Lens.set(enabledLens, false),
Lens.set(internalValueLens, options.defaultValue),
),
}),
),
),
Stream.runForEach(
enabledLens.changes.pipe(
Stream.zipLatest(internalValueLens.changes),
Stream.drop(1),
Stream.changesWith(Equal.asEquivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity,
),
([enabled, internalValue]) => Lens.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
),
], { concurrency: "unbounded" }))
return [enabledLens, internalValueLens] as const
}), [field, options.debounce])
const [enabled, setEnabled] = yield* Lens.useState(enabledLens)
const [value, setValue] = yield* Lens.useState(internalValueLens)
return { enabled, setEnabled, value, setValue }
})
+62
View File
@@ -0,0 +1,62 @@
import { Effect, Equivalence, Stream, SubscriptionRef } from "effect"
import { Lens } from "effect-lens"
import * as React from "react"
import * as Component from "./Component.js"
import * as SetStateAction from "./SetStateAction.js"
export * from "effect-lens/Lens"
export declare namespace useState {
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useState = Effect.fnUntraced(function* <A, ER, EW, RR, RW>(
lens: Lens.Lens<A, ER, EW, RR, RW>,
options?: useState.Options<NoInfer<A>>,
): Effect.fn.Return<readonly [A, React.Dispatch<React.SetStateAction<A>>], ER | EW, RR | RW> {
const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => Lens.get(lens)))
yield* Component.useReactEffect(() => Effect.forkScoped(
Stream.runForEach(
Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strictEqual()),
v => Effect.sync(() => setReactStateValue(v)),
)
), [lens])
const setValue = yield* Component.useCallbackSync(
(setStateAction: React.SetStateAction<A>) => Effect.tap(
Lens.updateAndGet(lens, prevState => SetStateAction.value(setStateAction, prevState)),
v => Effect.sync(() => setReactStateValue(v)),
),
[lens],
)
return [reactStateValue, setValue]
})
export declare namespace useFromReactState {
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useFromReactState = Effect.fnUntraced(function* <A>(
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
options?: useFromReactState.Options<NoInfer<A>>,
): Effect.fn.Return<Lens.Lens<A, never, never, never, never>> {
const lens = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make(value),
Lens.fromSubscriptionRef,
))
yield* Component.useReactEffect(() => Effect.forkScoped(Stream.runForEach(
Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strictEqual()),
v => Effect.sync(() => setValue(v)),
)), [setValue])
yield* Component.useReactEffect(() => Lens.set(lens, value), [value])
return lens
})
+112
View File
@@ -0,0 +1,112 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { type Equivalence, Function, Predicate } from "effect"
import * as React from "react"
import type * as Component from "./Component.js"
export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
export type MemoizedTypeId = typeof MemoizedTypeId
/**
* A trait for `Component`'s that uses `React.memo` to optimize re-renders based on prop equality.
*
* @template P The props type of the component
*/
export interface Memoized<P> extends MemoizedPrototype, MemoizedOptions<P> {}
export interface MemoizedPrototype {
readonly [MemoizedTypeId]: MemoizedTypeId
}
/**
* Configuration options for Memoized components.
*
* @template P The props type of the component
*/
export interface MemoizedOptions<P> {
/**
* An optional equivalence function for comparing component props.
* If provided, this function is used by React.memo to determine if props have changed.
* Returns `true` if props are equivalent (no re-render), `false` if they differ (re-render).
*/
readonly propsEquivalence?: Equivalence.Equivalence<P>
}
export const MemoizedPrototype: MemoizedPrototype = Object.freeze({
[MemoizedTypeId]: MemoizedTypeId,
transformFunctionComponent<P extends {}>(
this: Memoized<P>,
f: React.FC<P>,
) {
return React.memo(f, this.propsEquivalence)
},
} as const)
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, MemoizedTypeId)
/**
* Converts a Component into a `Memoized` component that optimizes re-renders using `React.memo`.
*
* @param self - The component to convert to a Memoized component
* @returns A new `Memoized` component with the same body, error, and context types as the input
*
* @example
* ```ts
* const MyMemoizedComponent = MyComponent.pipe(
* Memoized.memoized,
* )
* ```
*/
export const memoized = <T extends Component.Component.Any>(
self: T
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
Object.assign(function() {}, self),
Object.freeze(Object.setPrototypeOf(
Object.assign({}, MemoizedPrototype),
Object.getPrototypeOf(self),
)),
)
/**
* Applies options to a Memoized component, returning a new Memoized component with the updated configuration.
*
* Supports both curried and uncurried application styles.
*
* @param self - The Memoized component to apply options to (in uncurried form)
* @param options - The options to apply to the component
* @returns A Memoized component with the applied options
*
* @example
* ```ts
* // Curried
* const MyMemoizedComponent = MyComponent.pipe(
* Memoized.memoized,
* Memoized.withOptions({ propsEquivalence: (a, b) => a.id === b.id }),
* )
*
* // Uncurried
* const MyMemoizedComponent = Memoized.withOptions(
* Memoized.memoized(MyComponent),
* { propsEquivalence: (a, b) => a.id === b.id },
* )
* ```
*/
export const withOptions: {
<T extends Component.Component.Any & Memoized<any>>(
options: Partial<MemoizedOptions<Component.Component.Props<T>>>
): (self: T) => T
<T extends Component.Component.Any & Memoized<any>>(
self: T,
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
): T
} = Function.dual(2, <T extends Component.Component.Any & Memoized<any>>(
self: T,
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
): T => Object.setPrototypeOf(
Object.assign(function() {}, self, options),
Object.getPrototypeOf(self),
))
+146
View File
@@ -0,0 +1,146 @@
import { type Context, Effect, Equal, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
import { Subscribable } from "effect-lens"
import * as Result from "./Result.js"
export const MutationTypeId: unique symbol = Symbol.for("@effect-fc/Mutation/Mutation")
export type MutationTypeId = typeof MutationTypeId
export interface Mutation<in out K extends Mutation.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
extends Pipeable.Pipeable {
readonly [MutationTypeId]: MutationTypeId
readonly context: Context.Context<Scope.Scope | R>
readonly f: (key: K) => Effect.Effect<A, E, R>
readonly initialProgress: P
readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
readonly result: Subscribable.Subscribable<Result.Result<A, E, P>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Final<A, E, P>>>
mutate(key: K): Effect.Effect<Result.Final<A, E, P>>
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>>
}
export declare namespace Mutation {
export type AnyKey = readonly any[]
}
export class MutationImpl<in out K extends Mutation.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
extends Pipeable.Class implements Mutation<K, A, E, R, P> {
readonly [MutationTypeId]: MutationTypeId = MutationTypeId
readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
readonly result: Subscribable.Subscribable<Result.Result<A, E, P>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Final<A, E, P>>>
constructor(
readonly context: Context.Context<Scope.Scope | R>,
readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly initialProgress: P,
readonly latestKeyRef: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
readonly fiberRef: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
readonly resultRef: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>,
readonly latestFinalResultRef: SubscriptionRef.SubscriptionRef<Option.Option<Result.Final<A, E, P>>>,
) {
super()
this.latestKey = fromSubscriptionRef(latestKeyRef)
this.fiber = fromSubscriptionRef(fiberRef)
this.result = fromSubscriptionRef(resultRef)
this.latestFinalResult = fromSubscriptionRef(latestFinalResultRef)
}
mutate(key: K): Effect.Effect<Result.Final<A, E, P>> {
return SubscriptionRef.set(this.latestKeyRef, Option.some(key)).pipe(
Effect.andThen(this.start(key)),
Effect.andThen(sub => this.watch(sub)),
Effect.provide(this.context),
)
}
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> {
return SubscriptionRef.set(this.latestKeyRef, Option.some(key)).pipe(
Effect.andThen(this.start(key)),
Effect.tap(sub => Effect.forkScoped(this.watch(sub))),
Effect.provide(this.context),
)
}
start(key: K): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E, P>>,
never,
Scope.Scope | R
> {
const self = this
return Effect.gen(function*() {
const initial = yield* SubscriptionRef.get(self.latestFinalResultRef)
const [sub, fiber] = yield* Result.unsafeForkEffect<A, E, R, P>(
Effect.onExit(self.f(key), () => Effect.andThen(
Effect.all([Effect.fiberId, SubscriptionRef.get(self.fiberRef)]),
([currentFiberId, fiber]) => Option.match(fiber, {
onSome: v => Equal.equals(currentFiberId, v.id)
? SubscriptionRef.set(self.fiberRef, Option.none())
: Effect.succeed(undefined),
onNone: () => Effect.succeed(undefined),
}),
)),
{
initial: Option.isSome(initial) ? Result.willFetch(initial.value) : Result.initial(),
initialProgress: self.initialProgress,
} as Result.unsafeForkEffect.Options<A, E, P>,
)
yield* SubscriptionRef.set(self.fiberRef, Option.some(fiber))
return sub
})
}
watch(
sub: Subscribable.Subscribable<Result.Result<A, E, P>>
): Effect.Effect<Result.Final<A, E, P>> {
return sub.get.pipe(
Effect.andThen(initial => Stream.runFoldEffect(
Stream.takeUntil(sub.changes, result => Result.isFinal(result) && !Result.hasFlag(result)),
() => initial,
(_, result) => Effect.as(SubscriptionRef.set(this.resultRef, result), result),
) as Effect.Effect<Result.Final<A, E, P>>),
Effect.tap(result => SubscriptionRef.set(this.latestFinalResultRef, Option.some(result))),
)
}
}
export const isMutation = (u: unknown): u is Mutation<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
export declare namespace make {
export interface Options<K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never> {
readonly f: (key: K) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
readonly initialProgress?: P
}
}
export const make = Effect.fnUntraced(function* <const K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never>(
options: make.Options<K, A, E, R, P>
): Effect.fn.Return<
Mutation<K, A, E, Result.forkEffect.OutputContext<R, P>, P>,
never,
Scope.Scope | Result.forkEffect.OutputContext<R, P>
> {
return new MutationImpl(
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<R, P>>(),
options.f as any,
options.initialProgress as P,
yield* SubscriptionRef.make(Option.none<K>()),
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>()),
yield* SubscriptionRef.make(Result.initial<A, E, P>()),
yield* SubscriptionRef.make(Option.none<Result.Final<A, E, P>>()),
)
})
const fromSubscriptionRef = <A>(ref: SubscriptionRef.SubscriptionRef<A>): Subscribable.Subscribable<A> => Subscribable.make({
get: SubscriptionRef.get(ref),
changes: SubscriptionRef.changes(ref),
})
+17
View File
@@ -0,0 +1,17 @@
import { Effect, PubSub, type Scope } from "effect"
import type * as React from "react"
import * as Component from "./Component.js"
export const useFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
values: A
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
yield* Component.useReactEffect(() => Effect.flatMap(
PubSub.isShutdown(pubsub),
shutdown => shutdown ? Effect.succeed(undefined) : Effect.asVoid(PubSub.publish(pubsub, values)),
), values)
return pubsub
})
export * from "effect/PubSub"
+283
View File
@@ -0,0 +1,283 @@
import {
type Cause,
type Context,
type Duration,
Effect,
Equal,
Fiber,
Option,
Pipeable,
Predicate,
type Scope,
Semaphore,
Stream,
SubscriptionRef,
} from "effect"
import { Subscribable } from "effect-lens"
import * as QueryClient from "./QueryClient.js"
import * as Result from "./Result.js"
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
export type QueryTypeId = typeof QueryTypeId
export interface Query<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never, in out P = never>
extends Pipeable.Pipeable {
readonly [QueryTypeId]: QueryTypeId
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>
readonly key: Stream.Stream<K, KE, KR>
readonly f: (key: K) => Effect.Effect<A, E, R>
readonly initialProgress: P
readonly staleTime: Duration.Input
readonly refreshOnWindowFocus: boolean
readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
readonly result: Subscribable.Subscribable<Result.Result<A, E, P>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Final<A, E, P>>>
readonly run: Effect.Effect<void>
fetch(key: K): Effect.Effect<Result.Final<A, E, P>>
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>>
readonly refresh: Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementError>
readonly refreshSubscribable: Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementError>
readonly invalidateCache: Effect.Effect<void>
invalidateCacheEntry(key: K): Effect.Effect<void>
}
export declare namespace Query {
export type AnyKey = readonly any[]
}
export class QueryImpl<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never, in out P = never>
extends Pipeable.Class implements Query<K, A, KE, KR, E, R, P> {
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
readonly result: Subscribable.Subscribable<Result.Result<A, E, P>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Final<A, E, P>>>
constructor(
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>,
readonly key: Stream.Stream<K, KE, KR>,
readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly initialProgress: P,
readonly staleTime: Duration.Input,
readonly refreshOnWindowFocus: boolean,
readonly latestKeyRef: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
readonly fiberRef: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
readonly resultRef: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>,
readonly latestFinalResultRef: SubscriptionRef.SubscriptionRef<Option.Option<Result.Final<A, E, P>>>,
readonly runSemaphore: Semaphore.Semaphore,
) {
super()
this.latestKey = fromSubscriptionRef(latestKeyRef)
this.fiber = fromSubscriptionRef(fiberRef)
this.result = fromSubscriptionRef(resultRef)
this.latestFinalResult = fromSubscriptionRef(latestFinalResultRef)
}
get run(): Effect.Effect<void> {
const focus = this.refreshOnWindowFocus && typeof window !== "undefined"
? Stream.runForEach(Stream.fromEventListener<FocusEvent>(window, "focus"), () => this.refreshSubscribable)
: Effect.succeed(undefined)
return Effect.all([
Stream.runForEach(this.key, key => this.fetchSubscribable(key)),
focus,
], { concurrency: "unbounded", discard: true }).pipe(
Effect.ignore,
this.runSemaphore.withPermits(1),
Effect.provide(this.context),
)
}
get interrupt(): Effect.Effect<void> {
return Effect.flatMap(SubscriptionRef.get(this.fiberRef), Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.succeed(undefined),
}))
}
fetch(key: K): Effect.Effect<Result.Final<A, E, P>> {
const self = this
return Effect.gen(function*() {
yield* self.interrupt
yield* SubscriptionRef.set(self.latestKeyRef, Option.some(key))
const previous = yield* SubscriptionRef.get(self.latestFinalResultRef)
const sub = yield* self.startCached(key, Option.isSome(previous)
? Result.willFetch(previous.value) as Result.Final<A, E, P>
: Result.initial())
return yield* self.watch(key, sub)
}).pipe(Effect.provide(this.context))
}
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> {
const self = this
return Effect.gen(function*() {
yield* self.interrupt
yield* SubscriptionRef.set(self.latestKeyRef, Option.some(key))
const previous = yield* SubscriptionRef.get(self.latestFinalResultRef)
const sub = yield* self.startCached(key, Option.isSome(previous)
? Result.willFetch(previous.value) as Result.Final<A, E, P>
: Result.initial())
yield* Effect.forkScoped(self.watch(key, sub))
return sub
}).pipe(Effect.provide(this.context))
}
get refresh(): Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementError> {
const self = this
return Effect.gen(function*() {
yield* self.interrupt
const key = yield* Effect.fromOption(yield* SubscriptionRef.get(self.latestKeyRef))
const previous = yield* SubscriptionRef.get(self.latestFinalResultRef)
const sub = yield* self.startCached(key, Option.isSome(previous)
? Result.willRefresh(previous.value) as Result.Final<A, E, P>
: Result.initial())
return yield* self.watch(key, sub)
}).pipe(Effect.provide(this.context))
}
get refreshSubscribable(): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementError> {
const self = this
return Effect.gen(function*() {
yield* self.interrupt
const key = yield* Effect.fromOption(yield* SubscriptionRef.get(self.latestKeyRef))
const previous = yield* SubscriptionRef.get(self.latestFinalResultRef)
const sub = yield* self.startCached(key, Option.isSome(previous)
? Result.willRefresh(previous.value) as Result.Final<A, E, P>
: Result.initial())
yield* Effect.forkScoped(self.watch(key, sub))
return sub
}).pipe(Effect.provide(this.context))
}
startCached(
key: K,
initial: Result.Initial | Result.Final<A, E, P>,
): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, never, Scope.Scope | QueryClient.QueryClient | R> {
return Effect.flatMap(this.getCacheEntry(key), Option.match({
onSome: entry => Effect.flatMap(
QueryClient.isQueryClientCacheEntryStale(entry),
isStale => isStale
? this.start(key, Result.willRefresh(entry.result) as Result.Final<A, E, P>)
: Effect.succeed(Subscribable.make({
get: Effect.succeed(entry.result as Result.Result<A, E, P>),
changes: Stream.make(entry.result as Result.Result<A, E, P>),
})),
),
onNone: () => this.start(key, initial),
}))
}
start(
key: K,
initial: Result.Initial | Result.Final<A, E, P>,
): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, never, Scope.Scope | R> {
const self = this
return Effect.gen(function*() {
const [sub, fiber] = yield* Result.unsafeForkEffect<A, E, R, P>(
Effect.onExit(self.f(key), () => Effect.flatMap(
Effect.all([Effect.fiberId, SubscriptionRef.get(self.fiberRef)]),
([currentFiberId, current]) => Option.match(current, {
onSome: value => Equal.equals(currentFiberId, value.id)
? SubscriptionRef.set(self.fiberRef, Option.none())
: Effect.succeed(undefined),
onNone: () => Effect.succeed(undefined),
}),
)),
{ initial, initialProgress: self.initialProgress },
)
yield* SubscriptionRef.set(self.fiberRef, Option.some(fiber))
return sub
})
}
watch(
key: K,
sub: Subscribable.Subscribable<Result.Result<A, E, P>>,
): Effect.Effect<Result.Final<A, E, P>, never, QueryClient.QueryClient> {
return Effect.flatMap(sub.get, initial => Stream.runFoldEffect(
Stream.takeUntil(sub.changes, result => Result.isFinal(result) && !Result.hasFlag(result)),
() => initial,
(_, result) => Effect.as(SubscriptionRef.set(this.resultRef, result), result),
) as Effect.Effect<Result.Final<A, E, P>>).pipe(
Effect.tap(result => SubscriptionRef.set(this.latestFinalResultRef, Option.some(result))),
Effect.tap(result => Result.isSuccess(result)
? Effect.asVoid(this.setCacheEntry(key, result))
: Effect.succeed(undefined)),
)
}
makeCacheKey(key: K): QueryClient.QueryClientCacheKey {
return new QueryClient.QueryClientCacheKey(key, this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>)
}
getCacheEntry(key: K): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> {
return Effect.flatMap(QueryClient.QueryClient, client => client.getCacheEntry(this.makeCacheKey(key)))
}
setCacheEntry(key: K, result: Result.Success<A>): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> {
return Effect.flatMap(QueryClient.QueryClient, client => client.setCacheEntry(this.makeCacheKey(key), result, this.staleTime))
}
get invalidateCache(): Effect.Effect<void> {
return Effect.flatMap(
QueryClient.QueryClient,
client => client.invalidateCacheEntries(this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>),
).pipe(Effect.provide(this.context))
}
invalidateCacheEntry(key: K): Effect.Effect<void> {
return Effect.flatMap(
QueryClient.QueryClient,
client => client.invalidateCacheEntry(this.makeCacheKey(key)),
).pipe(Effect.provide(this.context))
}
}
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown> => Predicate.hasProperty(u, QueryTypeId)
export declare namespace make {
export interface Options<K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never> {
readonly key: Stream.Stream<K, KE, KR>
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
readonly initialProgress?: P
readonly staleTime?: Duration.Input
readonly refreshOnWindowFocus?: boolean
}
}
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
options: make.Options<K, A, KE, KR, E, R, P>,
): Effect.fn.Return<
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
never,
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
> {
const client = yield* QueryClient.QueryClient
return new QueryImpl<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>(
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>>(),
options.key,
options.f as any,
options.initialProgress as P,
options.staleTime ?? client.defaultStaleTime,
options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus,
yield* SubscriptionRef.make(Option.none<K>()),
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>()),
yield* SubscriptionRef.make(Result.initial<A, E, P>()),
yield* SubscriptionRef.make(Option.none<Result.Final<A, E, P>>()),
yield* Semaphore.make(1),
)
})
export const service = <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
options: make.Options<K, A, KE, KR, E, R, P>,
): Effect.Effect<
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
never,
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
> => Effect.tap(make(options), query => Effect.asVoid(Effect.forkScoped(query.run)))
const fromSubscriptionRef = <A>(ref: SubscriptionRef.SubscriptionRef<A>): Subscribable.Subscribable<A> => Subscribable.make({
get: SubscriptionRef.get(ref),
changes: SubscriptionRef.changes(ref),
})
+191
View File
@@ -0,0 +1,191 @@
import {
Context,
DateTime,
Duration,
Effect,
Equal,
Equivalence,
Hash,
HashMap,
Layer,
Option,
Pipeable,
Predicate,
Schedule,
Scope,
Semaphore,
SubscriptionRef,
} from "effect"
import { Subscribable } from "effect-lens"
import type * as Query from "./Query.js"
import type * as Result from "./Result.js"
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService")
export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
export interface QueryClientService extends Pipeable.Pipeable {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly cacheGcTime: Duration.Input
readonly defaultStaleTime: Duration.Input
readonly defaultRefreshOnWindowFocus: boolean
readonly run: Effect.Effect<void>
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>>
setCacheEntry(key: QueryClientCacheKey, result: Result.Success<unknown>, staleTime: Duration.Input): Effect.Effect<QueryClientCacheEntry>
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void>
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void>
}
export class QueryClient extends Context.Service<QueryClient, QueryClientService>()(
"@effect-fc/QueryClient/QueryClient",
) {
static get Default(): Layer.Layer<QueryClient> {
return Layer.effect(QueryClient)(service())
}
}
export class QueryClientServiceImpl extends Pipeable.Class implements QueryClientService {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
constructor(
readonly cacheRef: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
readonly cacheGcTime: Duration.Input,
readonly defaultStaleTime: Duration.Input,
readonly defaultRefreshOnWindowFocus: boolean,
readonly runSemaphore: Semaphore.Semaphore,
) {
super()
this.cache = Subscribable.make({
get: SubscriptionRef.get(cacheRef),
changes: SubscriptionRef.changes(cacheRef),
})
}
get run(): Effect.Effect<void> {
return this.runSemaphore.withPermits(1)(Effect.repeat(
Effect.flatMap(DateTime.now, now => SubscriptionRef.update(this.cacheRef, HashMap.filter(entry =>
Duration.isLessThan(
DateTime.distance(entry.lastAccessedAt, now),
Duration.sum(Duration.fromInputUnsafe(entry.staleTime), Duration.fromInputUnsafe(this.cacheGcTime)),
)
))),
Schedule.spaced("30 seconds"),
))
}
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>> {
const self = this
return Effect.gen(function*() {
const entry = HashMap.get(yield* SubscriptionRef.get(self.cacheRef), key)
if (Option.isNone(entry)) return Option.none()
const now = yield* DateTime.now
const accessed = new QueryClientCacheEntry(
entry.value.result,
entry.value.staleTime,
entry.value.createdAt,
now,
)
yield* SubscriptionRef.update(self.cacheRef, HashMap.set(key, accessed))
return Option.some(accessed)
})
}
setCacheEntry(
key: QueryClientCacheKey,
result: Result.Success<unknown>,
staleTime: Duration.Input,
): Effect.Effect<QueryClientCacheEntry> {
return Effect.flatMap(DateTime.now, now => {
const entry = new QueryClientCacheEntry(result, staleTime, now, now)
return Effect.as(SubscriptionRef.update(this.cacheRef, HashMap.set(key, entry)), entry)
})
}
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void> {
return SubscriptionRef.update(this.cacheRef, HashMap.filter((_, key) => !Equivalence.strictEqual()(key.f, f)))
}
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void> {
return SubscriptionRef.update(this.cacheRef, HashMap.remove(key))
}
}
export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId)
export declare namespace make {
export interface Options {
readonly cacheGcTime?: Duration.Input
readonly defaultStaleTime?: Duration.Input
readonly defaultRefreshOnWindowFocus?: boolean
}
}
export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return<QueryClientService> {
return new QueryClientServiceImpl(
yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()),
options.cacheGcTime ?? "5 minutes",
options.defaultStaleTime ?? "0 minutes",
options.defaultRefreshOnWindowFocus ?? true,
yield* Semaphore.make(1),
)
})
export declare namespace service {
export interface Options extends make.Options {}
}
export const service = (options?: service.Options): Effect.Effect<QueryClientService, never, Scope.Scope> => Effect.tap(
make(options),
client => Effect.asVoid(Effect.forkScoped(client.run)),
)
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheKey")
export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId
export class QueryClientCacheKey extends Pipeable.Class implements Equal.Equal {
readonly [QueryClientCacheKeyTypeId]: QueryClientCacheKeyTypeId = QueryClientCacheKeyTypeId
constructor(
readonly key: Query.Query.AnyKey,
readonly f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>,
) {
super()
}
[Equal.symbol](that: Equal.Equal): boolean {
return isQueryClientCacheKey(that)
&& Equivalence.Array(Equal.asEquivalence())(this.key, that.key)
&& Equivalence.strictEqual()(this.f, that.f)
}
[Hash.symbol](): number {
return Hash.combine(Hash.hash(this.f))(Hash.array(this.key))
}
}
export const isQueryClientCacheKey = (u: unknown): u is QueryClientCacheKey => Predicate.hasProperty(u, QueryClientCacheKeyTypeId)
export const QueryClientCacheEntryTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheEntry")
export type QueryClientCacheEntryTypeId = typeof QueryClientCacheEntryTypeId
export class QueryClientCacheEntry extends Pipeable.Class {
readonly [QueryClientCacheEntryTypeId]: QueryClientCacheEntryTypeId = QueryClientCacheEntryTypeId
constructor(
readonly result: Result.Success<unknown>,
readonly staleTime: Duration.Input,
readonly createdAt: DateTime.DateTime,
readonly lastAccessedAt: DateTime.DateTime,
) {
super()
}
}
export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId)
export const isQueryClientCacheEntryStale = (self: QueryClientCacheEntry): Effect.Effect<boolean> => Effect.map(
DateTime.now,
now => Duration.isGreaterThanOrEqualTo(DateTime.distance(self.createdAt, now), Duration.fromInputUnsafe(self.staleTime)),
)
@@ -0,0 +1,71 @@
/** biome-ignore-all lint/complexity/useArrowFunction: React component names are intentional */
import { Context, Layer, ManagedRuntime, Predicate } from "effect"
import * as React from "react"
import * as ErrorObserver from "./ErrorObserver.js"
import * as QueryClient from "./QueryClient.js"
export const TypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime")
export type TypeId = typeof TypeId
export interface ReactRuntime<R, ER> {
new(_: never): Record<string, never>
readonly [TypeId]: TypeId
readonly runtime: ManagedRuntime.ManagedRuntime<R, ER>
readonly context: React.Context<Context.Context<R>>
}
const ReactRuntimeProto = Object.freeze({ [TypeId]: TypeId } as const)
export const Prelude: Layer.Layer<ErrorObserver.ErrorObserver | QueryClient.QueryClient> = Layer.merge(
ErrorObserver.layer,
QueryClient.QueryClient.Default,
)
export const isReactRuntime = (u: unknown): u is ReactRuntime<unknown, unknown> => Predicate.hasProperty(u, TypeId)
export const make = <R, ER>(
layer: Layer.Layer<R, ER>,
memoMap?: Layer.MemoMap,
): ReactRuntime<R | ErrorObserver.ErrorObserver | QueryClient.QueryClient, ER> => Object.setPrototypeOf(
Object.assign(function() {}, {
runtime: ManagedRuntime.make(Layer.merge(layer, Prelude), { memoMap }),
// biome-ignore lint/style/noNonNullAssertion: initialized by Provider before consumers render
context: React.createContext<Context.Context<R | ErrorObserver.ErrorObserver | QueryClient.QueryClient>>(null!),
}),
ReactRuntimeProto,
)
export namespace Provider {
export interface Props<R, ER> extends React.SuspenseProps {
readonly runtime: ReactRuntime<R, ER>
readonly children?: React.ReactNode
}
}
export const Provider = <R, ER>(
{ runtime, children, ...suspenseProps }: Provider.Props<R, ER>,
): React.ReactNode => {
const promise = React.useMemo(() => runtime.runtime.context(), [runtime])
return React.createElement(
React.Suspense,
suspenseProps,
React.createElement(ProviderInner<R, ER>, { runtime, promise, children }),
)
}
const ProviderInner = <R, ER>(
{ runtime, promise, children }: {
readonly runtime: ReactRuntime<R, ER>
readonly promise: Promise<Context.Context<R>>
readonly children?: React.ReactNode
},
): React.ReactNode => {
const context = React.use(promise)
React.useEffect(() => () => {
void runtime.runtime.dispose()
}, [runtime])
return React.createElement(runtime.context, { value: context }, children)
}
+249
View File
@@ -0,0 +1,249 @@
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, pipe, type Scope, SubscriptionRef } from "effect"
import { Lens, Subscribable } from "effect-lens"
export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result")
export type ResultTypeId = typeof ResultTypeId
export type Result<A, E = never, P = never> = (
| Initial
| Running<P>
| Final<A, E, P>
)
// biome-ignore lint/complexity/noBannedTypes: "{}" is relevant here
export type Final<A, E = never, P = never> = (Success<A> | Failure<E>) & ({} | Flags<P>)
export type Flags<P = never> = WillFetch | WillRefresh | Refreshing<P>
export declare namespace Result {
export type Success<R extends Result<any, any, any>> = [R] extends [Result<infer A, infer _E, infer _P>] ? A : never
export type Failure<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer E, infer _P>] ? E : never
export type Progress<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer _E, infer P>] ? P : never
}
export declare namespace Flags {
export type Keys = keyof WillFetch & WillRefresh & Refreshing<any>
}
export interface Initial extends ResultPrototype {
readonly _tag: "Initial"
}
export interface Running<P = never> extends ResultPrototype {
readonly _tag: "Running"
readonly progress: P
}
export interface Success<A> extends ResultPrototype {
readonly _tag: "Success"
readonly value: A
}
export interface Failure<E = never> extends ResultPrototype {
readonly _tag: "Failure"
readonly cause: Cause.Cause<E>
}
export interface WillFetch {
readonly _flag: "WillFetch"
}
export interface WillRefresh {
readonly _flag: "WillRefresh"
}
export interface Refreshing<P = never> {
readonly _flag: "Refreshing"
readonly progress: P
}
export interface ResultPrototype extends Pipeable.Pipeable, Equal.Equal {
readonly [ResultTypeId]: ResultTypeId
}
export const ResultPrototype: ResultPrototype = Object.freeze({
...Pipeable.Prototype,
[ResultTypeId]: ResultTypeId,
[Equal.symbol](this: Result<any, any, any>, that: Result<any, any, any>): boolean {
if (this._tag !== that._tag || (this as Flags)._flag !== (that as Flags)._flag)
return false
if (hasRefreshingFlag(this) && !Equal.equals(this.progress, (that as Refreshing<any>).progress))
return false
return Match.value(this).pipe(
Match.tag("Initial", () => true),
Match.tag("Running", self => Equal.equals(self.progress, (that as Running<any>).progress)),
Match.tag("Success", self => Equal.equals(self.value, (that as Success<any>).value)),
Match.tag("Failure", self => Equal.equals(self.cause, (that as Failure<any>).cause)),
Match.exhaustive,
)
},
[Hash.symbol](this: Result<any, any, any>): number {
return pipe(Hash.string(this._tag),
tagHash => Match.value(this).pipe(
Match.tag("Initial", () => tagHash),
Match.tag("Running", self => Hash.combine(Hash.hash(self.progress))(tagHash)),
Match.tag("Success", self => Hash.combine(Hash.hash(self.value))(tagHash)),
Match.tag("Failure", self => Hash.combine(Hash.hash(self.cause))(tagHash)),
Match.exhaustive,
),
Hash.combine(Hash.hash((this as Flags)._flag)),
hash => hasRefreshingFlag(this)
? Hash.combine(Hash.hash(this.progress))(hash)
: hash,
)
},
} as const)
export const isResult = (u: unknown): u is Result<unknown, unknown, unknown> => Predicate.hasProperty(u, ResultTypeId)
export const isFinal = (u: unknown): u is Final<unknown, unknown, unknown> => isResult(u) && (isSuccess(u) || isFailure(u))
export const isInitial = (u: unknown): u is Initial => isResult(u) && u._tag === "Initial"
export const isRunning = (u: unknown): u is Running<unknown> => isResult(u) && u._tag === "Running"
export const isSuccess = (u: unknown): u is Success<unknown> => isResult(u) && u._tag === "Success"
export const isFailure = (u: unknown): u is Failure<unknown> => isResult(u) && u._tag === "Failure"
export const hasFlag = (u: unknown): u is Flags => isResult(u) && Predicate.hasProperty(u, "_flag")
export const hasWillFetchFlag = (u: unknown): u is WillFetch => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "WillFetch"
export const hasWillRefreshFlag = (u: unknown): u is WillRefresh => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "WillRefresh"
export const hasRefreshingFlag = (u: unknown): u is Refreshing<unknown> => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "Refreshing"
export const initial: {
(): Initial
<A, E = never, P = never>(): Result<A, E, P>
} = (): Initial => Object.setPrototypeOf({ _tag: "Initial" }, ResultPrototype)
export const running = <P = never>(progress?: P): Running<P> => Object.setPrototypeOf({ _tag: "Running", progress }, ResultPrototype)
export const succeed = <A>(value: A): Success<A> => Object.setPrototypeOf({ _tag: "Success", value }, ResultPrototype)
export const fail = <E>(cause: Cause.Cause<E> ): Failure<E> => Object.setPrototypeOf({ _tag: "Failure", cause }, ResultPrototype)
export const willFetch = <R extends Final<any, any, any>>(
result: R
): Omit<R, keyof Flags.Keys> & WillFetch => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "WillFetch" }),
Object.getPrototypeOf(result),
)
export const willRefresh = <R extends Final<any, any, any>>(
result: R
): Omit<R, keyof Flags.Keys> & WillRefresh => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "WillRefresh" }),
Object.getPrototypeOf(result),
)
export const refreshing = <R extends Final<any, any, any>, P = never>(
result: R,
progress?: P,
): Omit<R, keyof Flags.Keys> & Refreshing<P> => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "Refreshing", progress }),
Object.getPrototypeOf(result),
)
export const fromExit: {
<A, E>(exit: Exit.Success<A, E>): Success<A>
<A, E>(exit: Exit.Failure<A, E>): Failure<E>
<A, E>(exit: Exit.Exit<A, E>): Success<A> | Failure<E>
} = exit => (exit._tag === "Success" ? succeed(exit.value) : fail(exit.cause)) as any
export const toExit: {
<A>(self: Success<A>): Exit.Success<A, never>
<E>(self: Failure<E>): Exit.Failure<never, E>
<A, E, P>(self: Final<A, E, P>): Exit.Exit<A, E>
<A, E, P>(self: Result<A, E, P>): Exit.Exit<A, E | Cause.NoSuchElementError>
} = <A, E, P>(self: Result<A, E, P>): any => {
switch (self._tag) {
case "Success":
return Exit.succeed(self.value)
case "Failure":
return Exit.failCause(self.cause)
default:
return Exit.fail(new Cause.NoSuchElementError())
}
}
export interface Progress<P = never> {
readonly progress: Lens.Lens<P, PreviousResultNotRunningNorRefreshing, never, never, never>
}
export const Progress = <P = never>() => Context.Service<Progress<P>>("@effect-fc/Result/Progress")
export class PreviousResultNotRunningNorRefreshing extends Data.TaggedError("@effect-fc/Result/PreviousResultNotRunningNorRefreshing")<{
readonly previous: Result<unknown, unknown, unknown>
}> {}
export const makeProgressLayer = <A, E, P = never>(
state: Lens.Lens<Result<A, E, P>, never, never, never, never>
): Layer.Layer<Progress<P> | Progress<never>, never, never> => Layer.succeed(
Progress<P>() as Context.Service<Progress<P> | Progress<never>, Progress<P> | Progress<never>>,
{
progress: state.pipe(
Lens.mapEffect(
a => (isRunning(a) || hasRefreshingFlag(a))
? Effect.succeed(a)
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous: a })),
(_, b) => Effect.succeed(b),
),
Lens.map(
a => a.progress,
(a, b) => isRunning(a)
? running(b)
: refreshing(a, b) as Final<A, E, P> & Refreshing<P>,
),
)
},
)
export namespace unsafeForkEffect {
export type OutputContext<R, P> = Exclude<R, Progress<P> | Progress<never>>
export interface Options<A, E, P> {
readonly initial?: Initial | Final<A, E, P>
readonly initialProgress?: P
}
}
export const unsafeForkEffect = Effect.fnUntraced(function* <A, E, R, P = never>(
effect: Effect.Effect<A, E, R>,
options?: unsafeForkEffect.Options<NoInfer<A>, NoInfer<E>, P>,
): Effect.fn.Return<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
Scope.Scope | unsafeForkEffect.OutputContext<R, P>
> {
const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make<Result<A, E, P>>(
options?.initial ?? initial<A, E, P>(),
))
const fiber = yield* Effect.gen(function*() {
yield* Lens.set(
state,
(isFinal(options?.initial) && hasWillRefreshFlag(options?.initial))
? refreshing(options.initial, options?.initialProgress) as Result<A, E, P>
: running(options?.initialProgress),
)
return yield* Effect.onExit(effect, exit => Lens.set(state, fromExit(exit)))
}).pipe(
Effect.forkScoped,
Effect.provide(makeProgressLayer(state)),
)
return [state, fiber] as const
})
export namespace forkEffect {
export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R
export type OutputContext<R, P> = unsafeForkEffect.OutputContext<R, P>
export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {}
}
export const forkEffect: {
<A, E, R, P = never>(
effect: Effect.Effect<A, E, forkEffect.InputContext<R, NoInfer<P>>>,
options?: forkEffect.Options<NoInfer<A>, NoInfer<E>, P>,
): Effect.Effect<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
Scope.Scope | forkEffect.OutputContext<R, P>
>
} = unsafeForkEffect
@@ -0,0 +1,12 @@
import { Function } from "effect"
import type * as React from "react"
export const value: {
<S>(self: React.SetStateAction<S>, prevState: S): S
<S>(prevState: S): (self: React.SetStateAction<S>) => S
} = Function.dual(2, <S>(self: React.SetStateAction<S>, prevState: S): S =>
typeof self === "function"
? (self as (prevState: S) => S)(prevState)
: self
)
+33
View File
@@ -0,0 +1,33 @@
import { Effect, Equivalence, Option, Stream } from "effect"
import * as React from "react"
import * as Component from "./Component.js"
export const use: {
<A, E, R>(
stream: Stream.Stream<A, E, R>
): Effect.Effect<Option.Option<A>, never, R>
<A extends NonNullable<unknown>, E, R>(
stream: Stream.Stream<A, E, R>,
initialValue: A,
): Effect.Effect<Option.Some<A>, never, R>
} = Effect.fnUntraced(function* <A extends NonNullable<unknown>, E, R>(
stream: Stream.Stream<A, E, R>,
initialValue?: A,
) {
const [reactStateValue, setReactStateValue] = React.useState(() => initialValue
? Option.some(initialValue)
: Option.none()
)
yield* Component.useReactEffect(() => Effect.forkScoped(
Stream.runForEach(
Stream.changesWith(stream, Equivalence.strictEqual()),
v => Effect.sync(() => setReactStateValue(Option.some(v))),
)
), [stream])
return reactStateValue as Option.Some<A>
})
export * from "effect/Stream"
@@ -0,0 +1,212 @@
import {
Array,
Cause,
type Context,
Effect,
Fiber,
Option,
Pipeable,
Predicate,
Schema,
SchemaIssue,
SchemaParser,
type Scope,
Semaphore,
SubscriptionRef,
} from "effect"
import * as Form from "./Form.js"
import * as Lens from "./Lens.js"
import * as Mutation from "./Mutation.js"
import * as Result from "./Result.js"
import * as Subscribable from "./Subscribable.js"
type FormSchema<A, I, R> = Schema.Top & {
readonly Type: A
readonly Encoded: I
readonly DecodingServices: R
}
export const SubmittableFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SubmittableForm")
export type SubmittableFormTypeId = typeof SubmittableFormTypeId
export interface SubmittableForm<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Form.Form<readonly [], A, I, never, never> {
readonly [SubmittableFormTypeId]: SubmittableFormTypeId
readonly schema: FormSchema<A, I, R>
readonly context: Context.Context<Scope.Scope | R>
readonly mutation: Mutation.Mutation<
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>, never, never>
readonly run: Effect.Effect<void>
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementError>
}
export class SubmittableFormImpl<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Pipeable.Class implements SubmittableForm<A, I, R, MA, ME, MR, MP> {
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
readonly [SubmittableFormTypeId]: SubmittableFormTypeId = SubmittableFormTypeId
readonly path = [] as const
readonly encodedValue: Lens.Lens<I, never, never, never, never>
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
constructor(
readonly schema: FormSchema<A, I, R>,
readonly context: Context.Context<Scope.Scope | R>,
readonly mutation: Mutation.Mutation<
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>,
readonly value: Lens.Lens<Option.Option<A>, never, never, never, never>,
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
readonly issues: Lens.Lens<readonly Form.FormIssue[], never, never, never, never>,
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>, never, never, never, never>,
readonly runSemaphore: Semaphore.Semaphore,
) {
super()
this.encodedValue = Lens.make({
get: Lens.get(internalEncodedValue),
changes: internalEncodedValue.changes,
commit: encoded => Effect.andThen(
Lens.set(internalEncodedValue, encoded),
this.synchronizeEncodedValue(encoded),
),
lock: Lens.asLensImpl(internalEncodedValue).lock,
})
this.isValidating = Subscribable.map(validationFiber, Option.isSome)
const commitState = Subscribable.zipLatestAll(
value as any,
issues as any,
validationFiber as any,
mutation.result as any,
) as unknown as Subscribable.Subscribable<readonly [
Option.Option<A>,
readonly Form.FormIssue[],
Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>,
Result.Result<MA, ME, MP>,
]>
this.canCommit = Subscribable.map(
commitState,
([current, currentIssues, fiber, result]: readonly [Option.Option<A>, readonly Form.FormIssue[], Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>, Result.Result<MA, ME, MP>]) => Option.isSome(current)
&& currentIssues.length === 0
&& Option.isNone(fiber)
&& !(Result.isRunning(result) || Result.hasRefreshingFlag(result)),
)
this.isCommitting = Subscribable.map(
mutation.result,
result => Result.isRunning(result) || Result.hasRefreshingFlag(result),
)
}
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void> {
const self = this
return Effect.gen(function*() {
const current = yield* Lens.get(self.validationFiber)
if (Option.isSome(current)) yield* Fiber.interrupt(current.value)
const fiber = yield* Effect.forkScoped(
Effect.ensuring(
SchemaParser.decodeEffect(self.schema)(encodedValue),
Lens.set(self.validationFiber, Option.none()),
),
)
yield* Lens.set(self.validationFiber, Option.some(fiber))
const decoded = yield* Fiber.join(fiber).pipe(
Effect.tap(value => Effect.andThen(
Lens.set(self.issues, Array.empty()),
Lens.set(self.value, Option.some(value)),
)),
Effect.catchIf(SchemaIssue.isIssue, issue => Lens.set(self.issues, formatIssue(issue))),
)
void decoded
}).pipe(Effect.provide(this.context)) as Effect.Effect<void>
}
get run(): Effect.Effect<void> {
return Effect.flatMap(
Lens.get(this.encodedValue),
SchemaParser.decodeEffect(this.schema),
).pipe(
Effect.option,
Effect.flatMap(value => Lens.set(this.value, value)),
Effect.provide(this.context),
this.runSemaphore.withPermits(1),
)
}
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementError> {
return Effect.flatMap(Lens.get(this.value), value => Effect.flatMap(Effect.fromOption(value), decoded => this.submitValue(decoded)))
}
submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>> {
return Effect.flatMap(this.canCommit.get, canCommit => {
if (!canCommit) return Effect.succeed(Option.none())
return Effect.map(
Effect.tap(this.mutation.mutate([value, this as any]), result => {
if (!Result.isFailure(result)) return Effect.succeed(undefined)
const issue = Cause.findErrorOption(result.cause)
return Option.isSome(issue) && SchemaIssue.isIssue(issue.value)
? Lens.set(this.issues, formatIssue(issue.value))
: Effect.succeed(undefined)
}),
Option.some,
)
})
}
}
const formatIssue = (issue: SchemaIssue.Issue): readonly Form.FormIssue[] => {
const formatted = SchemaIssue.makeFormatterStandardSchemaV1()(issue)
return formatted.issues.map(item => ({
path: (item.path ?? []) as readonly PropertyKey[],
message: item.message,
}))
}
export const isSubmittableForm = (u: unknown): u is SubmittableForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SubmittableFormTypeId)
export declare namespace make {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Mutation.make.Options<
readonly [value: NoInfer<A>, form: SubmittableForm<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>],
MA, ME, MR, MP
> {
readonly schema: FormSchema<A, I, R>
readonly initialEncodedValue: NoInfer<I>
}
}
export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: make.Options<A, I, R, MA, ME, MR, MP>,
): Effect.fn.Return<
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
never,
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
> {
return new SubmittableFormImpl(
options.schema,
yield* Effect.context<Scope.Scope | R>(),
yield* Mutation.make(options),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<A>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly Form.FormIssue[]>(Array.empty())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, SchemaIssue.Issue>>())),
yield* Semaphore.make(1),
)
})
export declare namespace service {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends make.Options<A, I, R, MA, ME, MR, MP> {}
}
export const service = <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: service.Options<A, I, R, MA, ME, MR, MP>,
): Effect.Effect<
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
never,
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
> => Effect.tap(make(options), form => Effect.asVoid(Effect.forkScoped(form.run)))
@@ -0,0 +1,53 @@
import { Effect, Equivalence, Stream } from "effect"
import { Subscribable } from "effect-lens"
import * as React from "react"
import * as Component from "./Component.js"
export * from "effect-lens/Subscribable"
export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
...elements: T
): Subscribable.Subscribable<
[T[number]] extends [never]
? never
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never },
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer E, infer _R> ? E : never,
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
> => Subscribable.make({
get: Effect.all(elements.map(v => v.get)),
changes: Stream.zipLatestAll(...elements.map(v => v.changes)),
}) as any
export declare namespace useAll {
export type Success<T extends readonly Subscribable.Subscribable<any, any, any>[]> = [T[number]] extends [never]
? never
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never }
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useAll = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
elements: T,
options?: useAll.Options<useAll.Success<NoInfer<T>>>,
): Effect.fn.Return<
useAll.Success<T>,
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer E, infer _R> ? E : never,
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
> {
const [reactStateValue, setReactStateValue] = React.useState(
yield* Component.useOnMount(() => Effect.all(elements.map(v => v.get)))
)
yield* Component.useReactEffect(() => Stream.zipLatestAll(...elements.map(ref => ref.changes)).pipe(
Stream.changesWith((options?.equivalence as Equivalence.Equivalence<any[]> | undefined) ?? Equivalence.Array(Equivalence.strictEqual())),
Stream.runForEach(v =>
Effect.sync(() => setReactStateValue(v))
),
Effect.forkScoped,
), elements)
return reactStateValue as any
})
@@ -0,0 +1,204 @@
import {
Array,
type Context,
Effect,
Equal,
Fiber,
Option,
Pipeable,
Predicate,
Schema,
SchemaIssue,
SchemaParser,
type Scope,
Semaphore,
Stream,
SubscriptionRef,
} from "effect"
import * as Form from "./Form.js"
import * as Lens from "./Lens.js"
import * as Subscribable from "./Subscribable.js"
type FormSchema<A, I, R> = Schema.Top & {
readonly Type: A
readonly Encoded: I
readonly DecodingServices: R
readonly EncodingServices: R
}
export const SynchronizedFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SynchronizedForm")
export type SynchronizedFormTypeId = typeof SynchronizedFormTypeId
export interface SynchronizedForm<
in out A,
in out I = A,
in out R = never,
in out TER = never,
in out TEW = never,
in out TRR = never,
in out TRW = never,
> extends Form.Form<readonly [], A, I, TER, TER | TEW> {
readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId
readonly schema: FormSchema<A, I, R>
readonly context: Context.Context<Scope.Scope | R | TRR | TRW>
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>, never, never>
readonly run: Effect.Effect<void, TER>
}
export class SynchronizedFormImpl<
in out A,
in out I = A,
in out R = never,
in out TER = never,
in out TEW = never,
in out TRR = never,
in out TRW = never,
> extends Pipeable.Class implements SynchronizedForm<A, I, R, TER, TEW, TRR, TRW> {
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId = SynchronizedFormTypeId
readonly path = [] as const
readonly value: Subscribable.Subscribable<Option.Option<A>, TER, never>
readonly encodedValue: Lens.Lens<I, TER, TER | TEW, never, never>
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
constructor(
readonly schema: FormSchema<A, I, R>,
readonly context: Context.Context<Scope.Scope | R | TRR | TRW>,
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>,
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
readonly issues: Lens.Lens<readonly Form.FormIssue[], never, never, never, never>,
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>, never, never, never, never>,
readonly isCommitting: Lens.Lens<boolean, never, never, never, never>,
readonly runSemaphore: Semaphore.Semaphore,
) {
super()
this.value = Subscribable.make({
get: Effect.provide(Effect.map(target.get, Option.some), context),
changes: Stream.provideContext(
target.changes.pipe(
Stream.map(Option.some),
Stream.catchCause(() => Stream.make(Option.none<A>())),
),
context,
),
})
this.encodedValue = Lens.make({
get: Lens.get(internalEncodedValue),
changes: internalEncodedValue.changes,
commit: encoded => Effect.andThen(
Lens.set(internalEncodedValue, encoded),
this.synchronizeEncodedValue(encoded),
),
lock: Lens.asLensImpl(internalEncodedValue).lock,
}) as unknown as Lens.Lens<I, TER, TER | TEW, never, never>
this.isValidating = Subscribable.map(validationFiber, Option.isSome)
const commitState = Subscribable.zipLatestAll(issues as any, validationFiber as any, isCommitting as any) as unknown as Subscribable.Subscribable<readonly [
readonly Form.FormIssue[],
Option.Option<Fiber.Fiber<A, SchemaIssue.Issue>>,
boolean,
]>
this.canCommit = Subscribable.map(
commitState,
([currentIssues, fiber, committing]) => currentIssues.length === 0 && Option.isNone(fiber) && !committing,
)
}
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, TER | TEW> {
const self = this
return Effect.gen(function*() {
const current = yield* Lens.get(self.validationFiber)
if (Option.isSome(current)) yield* Fiber.interrupt(current.value)
const fiber = yield* Effect.forkScoped(
Effect.ensuring(
SchemaParser.decodeEffect(self.schema)(encodedValue),
Lens.set(self.validationFiber, Option.none()),
),
)
yield* Lens.set(self.validationFiber, Option.some(fiber))
yield* Fiber.join(fiber).pipe(
Effect.flatMap(value => Effect.ensuring(
Effect.andThen(
Lens.set(self.isCommitting, true),
Effect.andThen(Lens.set(self.issues, Array.empty()), Lens.set(self.target, value)),
),
Lens.set(self.isCommitting, false),
)),
Effect.catchIf(SchemaIssue.isIssue, issue => Lens.set(self.issues, formatIssue(issue))),
)
}).pipe(Effect.provide(this.context)) as Effect.Effect<void, TER | TEW>
}
get run(): Effect.Effect<void, TER> {
return this.runSemaphore.withPermits(1)(Effect.provide(
Stream.runForEach(Stream.drop(this.target.changes, 1), targetValue => Effect.ignore(
Effect.flatMap(SchemaParser.encodeEffect(this.schema)(targetValue), encodedValue => Effect.flatMap(
Lens.get(this.internalEncodedValue),
current => Equal.equals(encodedValue, current)
? Effect.succeed(undefined)
: Effect.andThen(
Lens.set(this.issues, Array.empty()),
Lens.set(this.internalEncodedValue, encodedValue),
),
)),
)),
this.context,
))
}
}
const formatIssue = (issue: SchemaIssue.Issue): readonly Form.FormIssue[] => {
const formatted = SchemaIssue.makeFormatterStandardSchemaV1()(issue)
return formatted.issues.map(item => ({
path: (item.path ?? []) as readonly PropertyKey[],
message: item.message,
}))
}
export const isSynchronizedForm = (u: unknown): u is SynchronizedForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SynchronizedFormTypeId)
export declare namespace make {
export interface Options<in out A, in out I = A, in out R = never, in out TER = never, in out TEW = never, in out TRR = never, in out TRW = never> {
readonly schema: FormSchema<A, I, R>
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
readonly initialEncodedValue?: NoInfer<I>
}
}
export const make = Effect.fnUntraced(function* <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: make.Options<A, I, R, TER, TEW, TRR, TRW>,
): Effect.fn.Return<
SynchronizedForm<A, I, R, TER, TEW, TRR, TRW>,
SchemaIssue.Issue | TER,
Scope.Scope | R | TRR | TRW
> {
const initialEncodedValue = options.initialEncodedValue !== undefined
? options.initialEncodedValue
: yield* Effect.flatMap(Lens.get(options.target), SchemaParser.encodeEffect(options.schema))
return new SynchronizedFormImpl(
options.schema,
yield* Effect.context<Scope.Scope | R | TRR | TRW>(),
options.target,
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(initialEncodedValue)),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly Form.FormIssue[]>(Array.empty())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, SchemaIssue.Issue>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(false)),
yield* Semaphore.make(1),
)
})
export declare namespace service {
export interface Options<in out A, in out I = A, in out R = never, in out TER = never, in out TEW = never, in out TRR = never, in out TRW = never>
extends make.Options<A, I, R, TER, TEW, TRR, TRW> {}
}
export const service = <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: service.Options<A, I, R, TER, TEW, TRR, TRW>,
): Effect.Effect<
SynchronizedForm<A, I, R, TER, TEW, TRR, TRW>,
SchemaIssue.Issue | TER,
Scope.Scope | R | TRR | TRW
> => Effect.tap(make(options), form => Effect.asVoid(Effect.forkScoped(form.run)))
+17
View File
@@ -0,0 +1,17 @@
export * as Async from "./Async.js"
export * as Component from "./Component.js"
export * as ErrorObserver from "./ErrorObserver.js"
export * as Form from "./Form.js"
export * as Lens from "./Lens.js"
export * as Memoized from "./Memoized.js"
export * as Mutation from "./Mutation.js"
export * as PubSub from "./PubSub.js"
export * as Query from "./Query.js"
export * as QueryClient from "./QueryClient.js"
export * as ReactRuntime from "./ReactRuntime.js"
export * as Result from "./Result.js"
export * as SetStateAction from "./SetStateAction.js"
export * as Stream from "./Stream.js"
export * as SubmittableForm from "./SubmittableForm.js"
export * as Subscribable from "./Subscribable.js"
export * as SynchronizedForm from "./SynchronizedForm.js"
+3
View File
@@ -0,0 +1,3 @@
export type ExcludeKeys<T, K extends PropertyKey> = K extends keyof T ? (
{ [P in K]?: never } & Omit<T, K>
) : T
@@ -0,0 +1,354 @@
import { render, screen, waitFor } from "@testing-library/react"
import { Context, Effect, Layer } from "effect"
import * as React from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
import * as Component from "../src/Component.js"
import * as ReactRuntime from "../src/ReactRuntime.js"
class ValueService extends Context.Service<ValueService, { readonly value: string }>()("ValueService") {}
afterEach(() => {
vi.useRealTimers()
})
describe("Component", () => {
it("runs useOnMount only once across rerenders", async () => {
const onMount = vi.fn(() => Effect.succeed("mounted"))
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
const Probe = Component.makeUntraced("UseOnMountProbe")(function*() {
const value = yield* Component.useOnMount(onMount)
return <div>{value}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("mounted")
expect(onMount).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
expect(await screen.findByText("mounted")).toBeTruthy()
expect(onMount).toHaveBeenCalledTimes(1)
view.unmount()
await runtime.runtime.dispose()
})
it("recomputes useOnChange only when dependencies change", async () => {
const onChange = vi.fn((value: number) => Effect.succeed(`value:${value}`))
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
const Probe = Component.makeUntraced("UseOnChangeProbe")(function*(props: { readonly value: number }) {
const result = yield* Component.useOnChange(() => onChange(props.value), [props.value], {
finalizerExecutionDebounce: 0,
})
return <div>{result}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value={1} />
</runtime.context.Provider>
)
await screen.findByText("value:1")
expect(onChange).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value={1} />
</runtime.context.Provider>
)
expect(await screen.findByText("value:1")).toBeTruthy()
expect(onChange).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value={2} />
</runtime.context.Provider>
)
await screen.findByText("value:2")
expect(onChange).toHaveBeenCalledTimes(2)
view.unmount()
await runtime.runtime.dispose()
})
it("closes the previous scope on dependency changes and unmount", async () => {
const cleanup = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
const Probe = Component.makeUntraced("ScopeCleanupProbe")(function*(props: { readonly value: string }) {
const result = yield* Component.useOnChange(
() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
return props.value
}),
[props.value],
{ finalizerExecutionDebounce: 0 },
)
return <div>{result}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
expect(cleanup).not.toHaveBeenCalled()
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value="second" />
</runtime.context.Provider>
)
await screen.findByText("second")
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"))
expect(cleanup).toHaveBeenCalledTimes(1)
view.unmount()
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"))
expect(cleanup).toHaveBeenCalledTimes(2)
await runtime.runtime.dispose()
})
it("runs useReactEffect setup and cleanup when dependencies change", async () => {
const lifecycle = vi.fn<(message: string) => void>()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
const Probe = Component.makeUntraced("UseReactEffectProbe")(function*(props: { readonly value: string }) {
yield* Component.useReactEffect(() =>
Effect.gen(function*() {
yield* Effect.sync(() => lifecycle(`mount:${props.value}`))
yield* Effect.addFinalizer(() => Effect.sync(() => lifecycle(`cleanup:${props.value}`)))
}),
[props.value])
return <div>{props.value}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:first"))
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value="second" />
</runtime.context.Provider>
)
await screen.findByText("second")
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:first"))
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:second"))
view.unmount()
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:second"))
expect(lifecycle.mock.calls.map(([message]) => message)).toEqual([
"mount:first",
"cleanup:first",
"mount:second",
"cleanup:second",
])
await runtime.runtime.dispose()
})
it("keeps useCallbackSync stable until dependencies change", async () => {
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
const seenCallbacks: Array<(value: number) => string> = []
const Probe = Component.makeUntraced("UseCallbackSyncProbe")(function*(props: { readonly prefix: string }) {
const callback = yield* Component.useCallbackSync(
(value: number) => Effect.succeed(`${props.prefix}:${value}`),
[props.prefix],
)
yield* Component.useOnMount(() => Effect.sync(() => {
seenCallbacks.push(callback)
}))
React.useEffect(() => {
seenCallbacks.push(callback)
}, [callback])
return <div>{callback(1)}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe prefix="a" />
</runtime.context.Provider>
)
await screen.findByText("a:1")
expect(seenCallbacks).toHaveLength(2)
expect(seenCallbacks[0]).toBe(seenCallbacks[1])
expect(seenCallbacks[0]?.(2)).toBe("a:2")
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe prefix="a" />
</runtime.context.Provider>
)
await screen.findByText("a:1")
expect(seenCallbacks).toHaveLength(2)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe prefix="b" />
</runtime.context.Provider>
)
await screen.findByText("b:1")
await waitFor(() => expect(seenCallbacks).toHaveLength(3))
expect(seenCallbacks[2]).not.toBe(seenCallbacks[1])
expect(seenCallbacks[2]?.(2)).toBe("b:2")
view.unmount()
await runtime.runtime.dispose()
})
it("delays cleanup according to finalizerExecutionDebounce", async () => {
const cleanup = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
const Probe = Component.makeUntraced("DebouncedCleanupProbe")(function*(props: { readonly value: string }) {
const result = yield* Component.useOnChange(
() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
return props.value
}),
[props.value],
{ finalizerExecutionDebounce: "20 millis" },
)
return <div>{result}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value="second" />
</runtime.context.Provider>
)
await screen.findByText("second")
expect(cleanup).not.toHaveBeenCalled()
await new Promise(resolve => setTimeout(resolve, 5))
expect(cleanup).not.toHaveBeenCalled()
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"), { timeout: 100 })
view.unmount()
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"), { timeout: 100 })
await runtime.runtime.dispose()
})
it("does not remount a component when only nonReactiveTags change", async () => {
const mounts = vi.fn()
const unmounts = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
const SubComponent = Component.makeUntraced("NonReactiveSubComponent")(function*() {
const service = yield* ValueService
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.sync(() => mounts())
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
}))
return <div>{service.value}</div>
}).pipe(
Component.withOptions({ nonReactiveTags: [ValueService] })
)
const Parent = Component.makeUntraced("NonReactiveParent")(function*(props: { readonly value: string }) {
const serviceLayer = React.useMemo(
() => Layer.succeed(ValueService, { value: props.value }),
[props.value],
)
const context = yield* Component.useContextFromLayer(serviceLayer, {
finalizerExecutionDebounce: 0,
})
const Child = yield* Effect.provide(SubComponent.use, context)
return <Child />
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Parent value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
expect(mounts).toHaveBeenCalledTimes(1)
expect(unmounts).not.toHaveBeenCalled()
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Parent value="second" />
</runtime.context.Provider>
)
await screen.findByText("second")
expect(mounts).toHaveBeenCalledTimes(1)
expect(unmounts).not.toHaveBeenCalled()
view.unmount()
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
await runtime.runtime.dispose()
})
})
+165
View File
@@ -0,0 +1,165 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { Effect, Layer, SubscriptionRef } from "effect"
import * as React from "react"
import { describe, expect, it } from "vitest"
import * as Component from "../src/Component.js"
import * as Lens from "../src/Lens.js"
import * as ReactRuntime from "../src/ReactRuntime.js"
const makeRuntime = async () => {
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
return {
runtime,
effectRuntime,
dispose: () => runtime.runtime.dispose(),
}
}
describe("Lens", () => {
it("useState stays in sync with lens updates in both directions", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
const ref = await Effect.runPromise(SubscriptionRef.make(0))
const lens = Lens.fromSubscriptionRef(ref)
const Probe = Component.makeUntraced("LensUseStateProbe")(function*() {
const [value, setValue] = yield* Lens.useState(lens)
return (
<>
<div>{value}</div>
<button onClick={() => setValue(previous => previous + 1)}>increment</button>
</>
)
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("0")
await Effect.runPromise(Lens.set(lens, 5))
await screen.findByText("5")
fireEvent.click(screen.getByRole("button", { name: "increment" }))
await screen.findByText("6")
expect(await Effect.runPromise(Lens.get(lens))).toBe(6)
view.unmount()
await dispose()
})
it("useState respects the provided equivalence when subscribing to lens changes", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
const ref = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
const lens = Lens.fromSubscriptionRef(ref)
const Probe = Component.makeUntraced("LensUseStateEquivalenceProbe")(function*() {
const [value] = yield* Lens.useState(lens, {
equivalence: (self, that) => self.id === that.id,
})
return <div>{value.label}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("first")
await Effect.runPromise(Lens.set(lens, { id: 1, label: "ignored" }))
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
expect(screen.queryByText("ignored")).toBeNull()
await Effect.runPromise(Lens.set(lens, { id: 2, label: "updated" }))
await screen.findByText("updated")
view.unmount()
await dispose()
})
it("useFromReactState writes React state changes into the returned lens", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
let lens: Lens.Lens<string, never, never, never, never> | undefined
const Probe = Component.makeUntraced("LensUseFromReactStateProbe")(function*() {
const [value, setValue] = React.useState("hello")
const reactLens = yield* Lens.useFromReactState([value, setValue])
yield* Component.useOnMount(() => Effect.sync(() => {
lens = reactLens
}))
return <button onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("hello")
await waitFor(() => expect(lens).toBeDefined())
fireEvent.click(screen.getByRole("button", { name: "hello" }))
await screen.findByText("hello!")
await waitFor(async () => expect(await Effect.runPromise(Lens.get(lens!))).toBe("hello!"))
view.unmount()
await dispose()
})
it("useFromReactState respects equivalence when lens updates flow back into React state", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
let lens: Lens.Lens<{ readonly id: number; readonly label: string }, never, never, never, never> | undefined
const Probe = Component.makeUntraced("LensUseFromReactStateEquivalenceProbe")(function*() {
const [value, setValue] = React.useState({ id: 1, label: "first" })
const reactLens = yield* Lens.useFromReactState([value, setValue], {
equivalence: (self, that) => self.id === that.id,
})
yield* Component.useOnMount(() => Effect.sync(() => {
lens = reactLens
}))
return <div>{value.label}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("first")
await waitFor(() => expect(lens).toBeDefined())
await Effect.runPromise(Lens.set(lens!, { id: 1, label: "ignored" }))
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
expect(screen.queryByText("ignored")).toBeNull()
await Effect.runPromise(Lens.set(lens!, { id: 2, label: "updated" }))
await screen.findByText("updated")
view.unmount()
await dispose()
})
})
+169
View File
@@ -0,0 +1,169 @@
import { Effect, Option, type Scope, Stream } from "effect"
import { describe, expect, it } from "vitest"
import * as Query from "../src/Query.js"
import * as QueryClient from "../src/QueryClient.js"
import * as Result from "../src/Result.js"
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
Effect.runPromise(Effect.scoped(effect.pipe(
Effect.provide(QueryClient.QueryClient.Default),
)))
const expectSuccessValue = <A, E, P>(
result: Result.Result<A, E, P>,
): A => {
expect(Result.isSuccess(result)).toBe(true)
if (!Result.isSuccess(result))
throw new Error(`Expected Success result, received ${result._tag}`)
return result.value
}
const expectSomeValue = <A>(option: Option.Option<A>): A => {
expect(Option.isSome(option)).toBe(true)
if (!Option.isSome(option))
throw new Error("Expected Some option, received None")
return option.value
}
describe("Query", () => {
it("fetch caches successful results until they are invalidated or stale", async () => {
let calls = 0
const key = Stream.empty as Stream.Stream<readonly [number]>
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
const first = yield* query.fetch([1])
const second = yield* query.fetch([1])
return [first, second] as const
}))
expect(calls).toBe(1)
expect(result[0]._tag).toBe("Success")
expect(result[1]._tag).toBe("Success")
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:1")
})
it("refresh reruns the latest query key", async () => {
let calls = 0
const key = Stream.empty as Stream.Stream<readonly [number]>
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "0 millis",
})
const first = yield* query.fetch([1])
yield* Effect.sleep("1 millis")
const refreshed = yield* query.refresh
return [first, refreshed] as const
}))
expect(calls).toBe(2)
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:2")
})
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
let calls = 0
const key = Stream.empty as Stream.Stream<readonly [number]>
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
const first = yield* query.fetch([1])
yield* query.invalidateCacheEntry([1])
const second = yield* query.fetch([1])
return [first, second] as const
}))
expect(calls).toBe(2)
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:2")
})
it("invalidateCache clears cached entries for the query function", async () => {
let calls = 0
const key = Stream.empty as Stream.Stream<readonly [number]>
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
const first = yield* query.fetch([1])
yield* query.invalidateCache
const second = yield* query.fetch([1])
return [first, second] as const
}))
expect(calls).toBe(2)
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:2")
})
it("service starts the key stream automatically and updates latest state", async () => {
let calls = 0
const key = Stream.make([1] as const) as Stream.Stream<readonly [number]>
const effect = Effect.gen(function*() {
const query = yield* Query.service({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
yield* Effect.sleep("10 millis")
return {
final: yield* query.result.get,
latestKey: yield* query.latestKey.get,
latestFinalResult: yield* query.latestFinalResult.get,
}
})
const result = await runQueryTest(effect)
expect(calls).toBe(1)
expect(expectSuccessValue(result.final)).toBe("value:1:1")
expect(expectSomeValue(result.latestKey)).toEqual([1])
expect(expectSuccessValue(expectSomeValue(result.latestFinalResult))).toBe("value:1:1")
})
})
@@ -0,0 +1,129 @@
import { render, screen, waitFor } from "@testing-library/react"
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 Subscribable from "../src/Subscribable.js"
const makeRuntime = async () => {
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
return {
runtime,
effectRuntime,
dispose: () => runtime.runtime.dispose(),
}
}
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))
const labelRef = await Effect.runPromise(SubscriptionRef.make("a"))
const count = Lens.fromSubscriptionRef(countRef)
const label = Lens.fromSubscriptionRef(labelRef)
const Probe = Component.makeUntraced("SubscribableUseAllProbe")(function*() {
const [currentCount, currentLabel] = yield* Subscribable.useAll([count, label])
return <div>{`${currentCount}:${currentLabel}`}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("1:a")
await Effect.runPromise(Lens.set(count, 2))
await screen.findByText("2:a")
await Effect.runPromise(Lens.set(label, "b"))
await screen.findByText("2:b")
view.unmount()
await dispose()
})
it("useAll respects the provided equivalence when processing updates", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
const itemRef = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
const flagRef = await Effect.runPromise(SubscriptionRef.make(true))
const item = Lens.fromSubscriptionRef(itemRef)
const flag = Lens.fromSubscriptionRef(flagRef)
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,
})
return <div>{`${currentItem.label}:${currentFlag ? "on" : "off"}`}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("first:on")
await Effect.runPromise(Lens.set(item, { id: 1, label: "ignored" }))
await waitFor(() => expect(screen.getByText("first:on")).toBeTruthy())
expect(screen.queryByText("ignored:on")).toBeNull()
await Effect.runPromise(Lens.set(flag, false))
await screen.findByText("ignored:off")
await Effect.runPromise(Lens.set(item, { id: 2, label: "updated" }))
await screen.findByText("updated:off")
view.unmount()
await dispose()
})
})
+40
View File
@@ -0,0 +1,40 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "NodeNext",
"moduleDetection": "force",
"jsx": "react-jsx",
// "allowJs": true,
// Bundler mode
"moduleResolution": "NodeNext",
// "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
// "noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
// Build
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"sourceMap": true,
"plugins": [
{ "name": "@effect/language-service" }
]
},
"include": ["./src"],
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "jsdom",
include: ["test/**/*.test.ts?(x)"],
},
})
+26
View File
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.tanstack
+10
View File
@@ -0,0 +1,10 @@
# Effect FC Next Example
Minimal React example for `effect-fc-next`, Effect V4, and `effect-lens@2`.
```bash
bun run dev
```
The counter demonstrates a V4 `SubscriptionRef` exposed as an Effect Lens and
rendered through an Effect-FC component.
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"root": false,
"extends": "//",
"files": {
"includes": ["./src/**", "!src/routeTree.gen.ts"]
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Effect FC Next Example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@effect-fc/example-next",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite",
"lint:tsc": "tsc --noEmit",
"lint:biome": "biome lint",
"preview": "vite preview",
"clean:cache": "rm -rf .turbo node_modules/.tmp node_modules/.vite* .tanstack",
"clean:dist": "rm -rf dist",
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"globals": "^17.6.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"vite": "^8.0.16"
},
"dependencies": {
"effect": "4.0.0-beta.85",
"effect-fc-next": "workspace:*"
},
"overrides": {
"@types/react": "^19.2.15",
"effect": "4.0.0-beta.85",
"react": "^19.2.6"
}
}
+28
View File
@@ -0,0 +1,28 @@
:root {
color: #17202a;
font-family: system-ui, sans-serif;
background: #f4f6f7;
}
body {
display: grid;
min-width: 320px;
min-height: 100vh;
margin: 0;
place-items: center;
}
main {
padding: 2rem;
text-align: center;
}
button {
padding: 0.65rem 1rem;
border: 0;
border-radius: 0.5rem;
color: white;
font: inherit;
background: #7d3c98;
cursor: pointer;
}
+40
View File
@@ -0,0 +1,40 @@
import { Effect, Layer, SubscriptionRef } from "effect"
import { Component, Lens, ReactRuntime, Subscribable } from "effect-fc-next"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import "./index.css"
const Counter = Component.make("Counter")(function*() {
const count = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make(0),
Lens.fromSubscriptionRef,
))
const [value] = yield* Subscribable.useAll([count])
const increment = yield* Component.useCallbackSync(
() => Lens.update(count, n => n + 1),
[count],
)
return (
<main>
<h1>Effect FC Next</h1>
<p>Running on Effect V4.</p>
<button type="button" onClick={increment}>
Count: {value}
</button>
</main>
)
})
const runtime = ReactRuntime.make(Layer.empty)
const CounterApp = Counter.pipe(Component.withRuntime(runtime.context))
// biome-ignore lint/style/noNonNullAssertion: the Vite template provides this element
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReactRuntime.Provider runtime={runtime}>
<CounterApp />
</ReactRuntime.Provider>
</StrictMode>,
)
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"plugins": [
{ "name": "@effect/language-service" }
]
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
],
})