Refactor Hooks
Lint / lint (push) Successful in 41s

This commit is contained in:
Julien Valverdé
2025-10-21 06:16:54 +02:00
parent 1f14e8be6b
commit 3695128923
11 changed files with 324 additions and 253 deletions
+159 -4
View File
@@ -1,8 +1,7 @@
/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Context, Effect, Effectable, ExecutionStrategy, Function, Predicate, Runtime, Scope, Tracer, type Types, type Utils } from "effect"
import { Context, Effect, Effectable, ExecutionStrategy, Exit, Function, Layer, ManagedRuntime, Predicate, Ref, Runtime, Scope, Tracer, type Types, type Utils } from "effect"
import * as React from "react"
import * as Hooks from "./Hooks/index.js"
import { Memoized } from "./index.js"
@@ -46,6 +45,11 @@ export namespace Component {
}
}
export interface ScopeOptions {
readonly finalizerExecutionMode?: "sync" | "fork"
readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy
}
const ComponentProto = Object.freeze({
...Effectable.CommitPrototype,
@@ -60,7 +64,7 @@ const ComponentProto = Object.freeze({
runtimeRef.current = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
return React.useRef(function ScopeProvider(props: P) {
const scope = Runtime.runSync(runtimeRef.current)(Hooks.useScope(
const scope = Runtime.runSync(runtimeRef.current)(useScope(
Array.from(
Context.omit(...nonReactiveTags)(runtimeRef.current.context).unsafeMap.values()
),
@@ -408,6 +412,55 @@ export const withRuntime: {
)
})
export const useScope: {
(
deps: React.DependencyList,
options?: ScopeOptions,
): Effect.Effect<Scope.Scope>
} = Effect.fnUntraced(function*(deps, options) {
const runtime = yield* Effect.runtime()
// biome-ignore lint/correctness/useExhaustiveDependencies: no reactivity needed
const [isInitialRun, initialScope] = React.useMemo(() => Runtime.runSync(runtime)(Effect.all([
Ref.make(true),
Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential),
])), [])
const [scope, setScope] = React.useState(initialScope)
React.useEffect(() => Runtime.runSync(runtime)(
Effect.if(isInitialRun, {
onTrue: () => Effect.as(
Ref.set(isInitialRun, false),
() => closeScope(scope, runtime, options),
),
onFalse: () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential).pipe(
Effect.tap(scope => Effect.sync(() => setScope(scope))),
Effect.map(scope => () => closeScope(scope, runtime, options)),
),
})
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
), deps)
return scope
})
const closeScope = (
scope: Scope.CloseableScope,
runtime: Runtime.Runtime<never>,
options?: ScopeOptions,
) => {
switch (options?.finalizerExecutionMode ?? "sync") {
case "sync":
Runtime.runSync(runtime)(Scope.close(scope, Exit.void))
break
case "fork":
Runtime.runFork(runtime)(Scope.close(scope, Exit.void))
break
}
}
export const useOnMount: {
<A, E, R>(
f: () => Effect.Effect<A, E, R>
@@ -430,6 +483,108 @@ export const useOnChange: {
deps: React.DependencyList,
) {
const runtime = yield* Effect.runtime<R>()
// biome-ignore lint/correctness/useExhaustiveDependencies: "f" is non-reactive
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
return yield* React.useMemo(() => Runtime.runSync(runtime)(Effect.cached(f())), deps)
})
export const useReactEffect: {
<E, R>(
f: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: ScopeOptions,
): Effect.Effect<void, never, Exclude<R, Scope.Scope>>
} = Effect.fnUntraced(function* <E, R>(
f: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: ScopeOptions,
) {
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
React.useEffect(() => Effect.Do.pipe(
Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)),
Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(f(), Scope.Scope, scope))),
Effect.map(({ scope }) =>
() => closeScope(scope, runtime, options)
),
Runtime.runSync(runtime),
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
), deps)
})
export const useReactLayoutEffect: {
<E, R>(
f: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: ScopeOptions,
): Effect.Effect<void, never, Exclude<R, Scope.Scope>>
} = Effect.fnUntraced(function* <E, R>(
f: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: ScopeOptions,
) {
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
React.useLayoutEffect(() => Effect.Do.pipe(
Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)),
Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(f(), Scope.Scope, scope))),
Effect.map(({ scope }) =>
() => closeScope(scope, runtime, options)
),
Runtime.runSync(runtime),
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
), deps)
})
export const useCallbackSync: {
<Args extends unknown[], A, E, R>(
f: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
): Effect.Effect<(...args: Args) => A, never, R>
} = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
f: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
) {
// biome-ignore lint/style/noNonNullAssertion: context initialization
const runtimeRef = React.useRef<Runtime.Runtime<R>>(null!)
runtimeRef.current = yield* Effect.runtime<R>()
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
return React.useCallback((...args: Args) => Runtime.runSync(runtimeRef.current)(f(...args)), deps)
})
export const useCallbackPromise: {
<Args extends unknown[], A, E, R>(
f: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
): Effect.Effect<(...args: Args) => Promise<A>, never, R>
} = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
f: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
) {
// biome-ignore lint/style/noNonNullAssertion: context initialization
const runtimeRef = React.useRef<Runtime.Runtime<R>>(null!)
runtimeRef.current = yield* Effect.runtime<R>()
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
return React.useCallback((...args: Args) => Runtime.runPromise(runtimeRef.current)(f(...args)), deps)
})
export const useContext: {
<ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>,
options?: ScopeOptions,
): Effect.Effect<Context.Context<ROut>, E, RIn>
} = Effect.fnUntraced(function* <ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>,
options?: ScopeOptions,
) {
const scope = yield* useScope([layer], options)
return yield* useOnChange(() => Effect.context<RIn>().pipe(
Effect.map(context => ManagedRuntime.make(Layer.provide(layer, Layer.succeedContext(context)))),
Effect.tap(runtime => Effect.addFinalizer(() => runtime.disposeEffect)),
Effect.andThen(runtime => runtime.runtimeEffect),
Effect.andThen(runtime => runtime.context),
Effect.provideService(Scope.Scope, scope),
), [scope])
})
+63 -78
View File
@@ -1,10 +1,11 @@
import * as AsyncData from "@typed/async-data"
import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream, SubscriptionRef } from "effect"
import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect"
import type { NoSuchElementException } from "effect/Cause"
import * as React from "react"
import * as Hooks from "./Hooks/index.js"
import * as Component from "./Component.js"
import * as PropertyPath from "./PropertyPath.js"
import * as Subscribable from "./Subscribable.js"
import * as SubscriptionRef from "./SubscriptionRef.js"
import * as SubscriptionSubRef from "./SubscriptionSubRef.js"
@@ -163,7 +164,7 @@ export namespace service {
export const service = <A, I = A, R = never, SA = void, SE = A, SR = never>(
options: service.Options<A, I, R, SA, SE, SR>
): Effect.Effect<Form<A, I, R, SA, SE, SR>, never, R | Scope.Scope> => Effect.tap(
): Effect.Effect<Form<A, I, R, SA, SE, SR>, never, Scope.Scope | R> => Effect.tap(
make(options),
form => Effect.forkScoped(run(form)),
)
@@ -220,24 +221,6 @@ extends Pipeable.Class() implements FormField<A, I> {
export const isFormField = (u: unknown): u is FormField<unknown, unknown> => Predicate.hasProperty(u, FormFieldTypeId)
export namespace useForm {
export interface Options<in out A, in out I, out R, in out SA = void, in out SE = A, out SR = never>
extends make.Options<A, I, R, SA, SE, SR> {}
}
export const useForm: {
<A, I = A, R = never, SA = void, SE = A, SR = never>(
options: make.Options<A, I, R, SA, SE, SR>,
deps: React.DependencyList,
): Effect.Effect<Form<A, I, R, SA, SE, SR>, never, R>
} = Effect.fnUntraced(function* <A, I = A, R = never, SA = void, SE = A, SR = never>(
options: make.Options<A, I, R, SA, SE, SR>,
deps: React.DependencyList,
) {
const form = yield* Hooks.useMemo(() => make(options), [options.debounce, ...deps])
yield* Hooks.useFork(() => run(form), [form])
return form
})
export const useSubmit = <A, I, R, SA, SE, SR>(
self: Form<A, I, R, SA, SE, SR>
@@ -245,7 +228,7 @@ export const useSubmit = <A, I, R, SA, SE, SR>(
() => Promise<Option.Option<AsyncData.AsyncData<SA, SE>>>,
never,
SR
> => Hooks.useCallbackPromise(() => submit(self), [self])
> => Component.useCallbackPromise(() => submit(self), [self])
export const useField = <A, I, R, SA, SE, SR, const P extends PropertyPath.Paths<NoInfer<I>>>(
self: Form<A, I, R, SA, SE, SR>,
@@ -271,33 +254,34 @@ export const useInput: {
<A, I>(
field: FormField<A, I>,
options?: useInput.Options,
): Effect.Effect<useInput.Result<I>, NoSuchElementException>
): Effect.Effect<useInput.Result<I>, NoSuchElementException, Scope.Scope>
} = Effect.fnUntraced(function* <A, I>(
field: FormField<A, I>,
options?: useInput.Options,
) {
const internalValueRef = yield* Hooks.useMemo(() => Effect.andThen(field.encodedValueRef, SubscriptionRef.make), [field])
const [value, setValue] = yield* Hooks.useRefState(internalValueRef)
yield* Hooks.useFork(() => Effect.all([
Stream.runForEach(
Stream.drop(field.encodedValueRef, 1),
upstreamEncodedValue => Effect.whenEffect(
Ref.set(internalValueRef, upstreamEncodedValue),
Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
const internalValueRef = yield* Component.useOnChange(() => Effect.tap(
Effect.andThen(field.encodedValueRef, SubscriptionRef.make),
internalValueRef => Effect.forkScoped(Effect.all([
Stream.runForEach(
Stream.drop(field.encodedValueRef, 1),
upstreamEncodedValue => Effect.whenEffect(
Ref.set(internalValueRef, upstreamEncodedValue),
Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
),
),
),
Stream.runForEach(
internalValueRef.changes.pipe(
Stream.drop(1),
Stream.changesWith(Equal.equivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity,
Stream.runForEach(
internalValueRef.changes.pipe(
Stream.drop(1),
Stream.changesWith(Equal.equivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity,
),
internalValue => Ref.set(field.encodedValueRef, internalValue),
),
internalValue => Ref.set(field.encodedValueRef, internalValue),
),
], { concurrency: "unbounded" }), [field, internalValueRef, options?.debounce])
], { concurrency: "unbounded" })),
), [field, options?.debounce])
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
return { value, setValue }
})
@@ -316,55 +300,56 @@ export const useOptionalInput: {
<A, I>(
field: FormField<A, Option.Option<I>>,
options: useOptionalInput.Options<I>,
): Effect.Effect<useOptionalInput.Result<I>, NoSuchElementException>
): Effect.Effect<useOptionalInput.Result<I>, NoSuchElementException, Scope.Scope>
} = Effect.fnUntraced(function* <A, I>(
field: FormField<A, Option.Option<I>>,
options: useOptionalInput.Options<I>,
) {
const [enabledRef, internalValueRef] = yield* Hooks.useMemo(() => Effect.andThen(
field.encodedValueRef,
Option.match({
onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]),
onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]),
}),
), [field])
const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap(
Effect.andThen(
field.encodedValueRef,
Option.match({
onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]),
onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]),
}),
),
const [enabled, setEnabled] = yield* Hooks.useRefState(enabledRef)
const [value, setValue] = yield* Hooks.useRefState(internalValueRef)
([enabledRef, internalValueRef]) => Effect.forkScoped(Effect.all([
Stream.runForEach(
Stream.drop(field.encodedValueRef, 1),
yield* Hooks.useFork(() => Effect.all([
Stream.runForEach(
Stream.drop(field.encodedValueRef, 1),
upstreamEncodedValue => Effect.whenEffect(
Option.match(upstreamEncodedValue, {
onSome: v => Effect.andThen(
Ref.set(enabledRef, true),
Ref.set(internalValueRef, v),
),
onNone: () => Effect.andThen(
Ref.set(enabledRef, false),
Ref.set(internalValueRef, options.defaultValue),
),
}),
upstreamEncodedValue => Effect.whenEffect(
Option.match(upstreamEncodedValue, {
onSome: v => Effect.andThen(
Ref.set(enabledRef, true),
Ref.set(internalValueRef, v),
Effect.andThen(
Effect.all([enabledRef, internalValueRef]),
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
),
onNone: () => Effect.andThen(
Ref.set(enabledRef, false),
Ref.set(internalValueRef, options.defaultValue),
),
}),
Effect.andThen(
Effect.all([enabledRef, internalValueRef]),
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
),
),
),
Stream.runForEach(
enabledRef.changes.pipe(
Stream.zipLatest(internalValueRef.changes),
Stream.drop(1),
Stream.changesWith(Equal.equivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity,
Stream.runForEach(
enabledRef.changes.pipe(
Stream.zipLatest(internalValueRef.changes),
Stream.drop(1),
Stream.changesWith(Equal.equivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity,
),
([enabled, internalValue]) => Ref.set(field.encodedValueRef, enabled ? Option.some(internalValue) : Option.none()),
),
([enabled, internalValue]) => Ref.set(field.encodedValueRef, enabled ? Option.some(internalValue) : Option.none()),
),
], { concurrency: "unbounded" }), [field, enabledRef, internalValueRef, options.debounce])
], { concurrency: "unbounded" })),
), [field, options.debounce])
const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef)
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
return { enabled, setEnabled, value, setValue }
})
+37 -7
View File
@@ -1,17 +1,47 @@
import { Effect, Stream, Subscribable } from "effect"
import { Effect, Equivalence, pipe, type Scope, Stream, Subscribable } from "effect"
import * as React from "react"
import * as Component from "./Component.js"
export const zipLatestAll = <T extends ReadonlyArray<Subscribable.Subscribable<any, any, any>>>(
...subscribables: T
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
[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(subscribables.map(v => v.get)),
changes: Stream.zipLatestAll(...subscribables.map(v => v.changes)),
get: Effect.all(elements.map(v => v.get)),
changes: Stream.zipLatestAll(...elements.map(v => v.changes)),
}) as any
export const useSubscribables: {
<const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
...elements: T
): Effect.Effect<
[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) | Scope.Scope
>
} = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
...elements: T
) {
const [reactStateValue, setReactStateValue] = React.useState(
yield* Component.useOnMount(() => Effect.all(elements.map(v => v.get)))
)
yield* Component.useOnChange(() => Effect.forkScoped(pipe(
elements.map(ref => Stream.changesWith(ref.changes, Equivalence.strict())),
streams => Stream.zipLatestAll(...streams),
Stream.runForEach(v =>
Effect.sync(() => setReactStateValue(v))
),
)), elements)
return reactStateValue as any
})
export * from "effect/Subscribable"
+43
View File
@@ -0,0 +1,43 @@
import { Effect, Equivalence, type Scope, Stream, SubscriptionRef } from "effect"
import * as React from "react"
import * as Component from "./Component.js"
import * as SetStateAction from "./SetStateAction.js"
export const useSubscriptionRefState: {
<A>(
ref: SubscriptionRef.SubscriptionRef<A>
): Effect.Effect<readonly [A, React.Dispatch<React.SetStateAction<A>>], never, Scope.Scope>
} = Effect.fnUntraced(function* <A>(ref: SubscriptionRef.SubscriptionRef<A>) {
const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => ref))
yield* Component.useOnChange(() => Effect.forkScoped(Stream.runForEach(
Stream.changesWith(ref.changes, Equivalence.strict()),
v => Effect.sync(() => setReactStateValue(v)),
)), [ref])
const setValue = yield* Component.useCallbackSync((setStateAction: React.SetStateAction<A>) =>
Effect.andThen(
SubscriptionRef.updateAndGet(ref, prevState => SetStateAction.value(setStateAction, prevState)),
v => setReactStateValue(v),
),
[ref])
return [reactStateValue, setValue]
})
export const useSubscriptionRefFromState: {
<A>(state: readonly [A, React.Dispatch<React.SetStateAction<A>>]): Effect.Effect<SubscriptionRef.SubscriptionRef<A>, never, Scope.Scope>
} = Effect.fnUntraced(function*([value, setValue]) {
const ref = yield* Component.useOnMount(() => SubscriptionRef.make(value))
yield* Component.useOnChange(() => Effect.forkScoped(Stream.runForEach(
Stream.changesWith(ref.changes, Equivalence.strict()),
v => Effect.sync(() => setValue(v)),
)), [setValue])
yield* Component.useReactEffect(() => SubscriptionRef.set(ref, value), [value])
return ref
})
export * from "effect/SubscriptionRef"
+1
View File
@@ -7,4 +7,5 @@ export * as PropertyPath from "./PropertyPath.js"
export * as ReactRuntime from "./ReactRuntime.js"
export * as SetStateAction from "./SetStateAction.js"
export * as Subscribable from "./Subscribable.js"
export * as SubscriptionRef from "./SubscriptionRef.js"
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"