From 837dcbb1cb5a4aaf51d45c7edcb7ca8e8d8a3126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 21 Feb 2025 15:27:11 +0100 Subject: [PATCH] Extension work --- packages/reffuse/src/ReffuseExtension.ts | 56 ++- packages/reffuse/src/ReffuseHelpers.ts | 426 +++++++++++++++++++++++ 2 files changed, 475 insertions(+), 7 deletions(-) create mode 100644 packages/reffuse/src/ReffuseHelpers.ts diff --git a/packages/reffuse/src/ReffuseExtension.ts b/packages/reffuse/src/ReffuseExtension.ts index 28987cb..8038e61 100644 --- a/packages/reffuse/src/ReffuseExtension.ts +++ b/packages/reffuse/src/ReffuseExtension.ts @@ -1,13 +1,24 @@ -import * as Reffuse from "./Reffuse.js" +import { Effect } from "effect" +import * as ReffuseContext from "./ReffuseContext.js" +import * as ReffuseHelpers from "./ReffuseHelpers.js" import type { Merge, StaticType } from "./types.js" +class Reffuse extends ReffuseHelpers.ReffuseHelpers {} + +class MyService extends Effect.Service()("MyService", { + succeed: {} +}) {} + +const MyContext = ReffuseContext.make() + + const make = (extension: Ext) => < - BaseClass extends typeof Reffuse.Reffuse, + BaseClass extends typeof Reffuse, R, >( - base: BaseClass & typeof Reffuse.Reffuse + base: BaseClass & typeof Reffuse ): ( { new(): Merge, Ext> } & StaticType @@ -16,12 +27,43 @@ const make = (extension: Ext) => return class_ } +export const withContexts = >( + ...contexts: [...{ [K in keyof R2]: ReffuseContext.ReffuseContext }] +) => + < + BaseClass extends typeof ReffuseHelpers.ReffuseHelpers, + R1 + >( + self: BaseClass & typeof ReffuseHelpers.ReffuseHelpers + ): ( + { + new(): Merge< + InstanceType, + { readonly contexts: readonly ReffuseContext.ReffuseContext[] } + > + } & + StaticType + ) => { + const instance = new self() -const cls = make({ - prout(this: Reffuse.Reffuse) {} -})(Reffuse.Reffuse) + return class extends self { + readonly contexts = [...instance.contexts, ...contexts] as const + } as any + } -class Cls extends cls {} + +const withMyContext = withContexts(MyContext) +const clsWithMyContext = withMyContext(Reffuse) +class ReffuseWithMyContext extends clsWithMyContext {} + +const t = new ReffuseWithMyContext() + + +const cls1 = make({ + prout(this: ReffuseHelpers.ReffuseHelpers) {} +})(Reffuse) + +class Cls1 extends cls1 {} const cls2 = make({ aya() {} diff --git a/packages/reffuse/src/ReffuseHelpers.ts b/packages/reffuse/src/ReffuseHelpers.ts new file mode 100644 index 0000000..d1016bf --- /dev/null +++ b/packages/reffuse/src/ReffuseHelpers.ts @@ -0,0 +1,426 @@ +import { Context, Effect, ExecutionStrategy, Exit, Fiber, Pipeable, Ref, Runtime, Scope, Stream, SubscriptionRef } from "effect" +import * as React from "react" +import * as ReffuseContext from "./ReffuseContext.js" +import * as ReffuseRuntime from "./ReffuseRuntime.js" +import * as SetStateAction from "./SetStateAction.js" + + +export interface ReffuseHelper extends Pipeable.Pipeable { + useContext(): Context.Context + + useRunSync(): (effect: Effect.Effect) => A + useRunPromise(): (effect: Effect.Effect, options?: { + readonly signal?: AbortSignal + }) => Promise + useRunFork(): (effect: Effect.Effect, options?: Runtime.RunForkOptions) => Fiber.RuntimeFiber + useRunCallback(): (effect: Effect.Effect, options?: Runtime.RunCallbackOptions) => Runtime.Cancel + + useMemo( + effect: Effect.Effect, + deps?: React.DependencyList, + options?: RenderOptions, + ): A + + useMemoScoped( + effect: Effect.Effect, + deps?: React.DependencyList, + options?: RenderOptions & ScopeOptions, + ): A + + useLayoutEffect( + effect: Effect.Effect, + deps?: React.DependencyList, + options?: RenderOptions & ScopeOptions, + ): void + + useFork( + effect: Effect.Effect, + deps?: React.DependencyList, + options?: Runtime.RunForkOptions & RenderOptions & ScopeOptions, + ): void + + usePromise( + effect: Effect.Effect, + deps?: React.DependencyList, + options?: { readonly signal?: AbortSignal } & Runtime.RunForkOptions & RenderOptions & ScopeOptions, + ): Promise + + useRef(value: A): SubscriptionRef.SubscriptionRef + useRefState(ref: SubscriptionRef.SubscriptionRef): readonly [A, React.Dispatch>] +} + + +export class ReffuseHelpers { + + readonly contexts: readonly ReffuseContext.ReffuseContext[] = [] + + + useContext(): Context.Context { + return ReffuseContext.useMergeAll(...this.contexts) + } + + + useRunSync() { + const runtime = ReffuseRuntime.useRuntime() + const context = this.useContext() + + return React.useCallback(( + effect: Effect.Effect + ): A => effect.pipe( + Effect.provide(context), + Runtime.runSync(runtime), + ), [runtime, context]) + } + + useRunPromise() { + const runtime = ReffuseRuntime.useRuntime() + const context = this.useContext() + + return React.useCallback(( + effect: Effect.Effect, + options?: { readonly signal?: AbortSignal }, + ): Promise => effect.pipe( + Effect.provide(context), + effect => Runtime.runPromise(runtime)(effect, options), + ), [runtime, context]) + } + + useRunFork() { + const runtime = ReffuseRuntime.useRuntime() + const context = this.useContext() + + return React.useCallback(( + effect: Effect.Effect, + options?: Runtime.RunForkOptions, + ): Fiber.RuntimeFiber => effect.pipe( + Effect.provide(context), + effect => Runtime.runFork(runtime)(effect, options), + ), [runtime, context]) + } + + useRunCallback() { + const runtime = ReffuseRuntime.useRuntime() + const context = this.useContext() + + return React.useCallback(( + effect: Effect.Effect, + options?: Runtime.RunCallbackOptions, + ): Runtime.Cancel => effect.pipe( + Effect.provide(context), + effect => Runtime.runCallback(runtime)(effect, options), + ), [runtime, context]) + } + + + /** + * Reffuse equivalent to `React.useMemo`. + * + * `useMemo` will only recompute the memoized value by running the given synchronous effect when one of the deps has changed. \ + * Trying to run an asynchronous effect will throw. + * + * Changes to the Reffuse runtime or context will recompute the value in addition to the deps. + * You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`. + */ + useMemo( + effect: Effect.Effect, + deps?: React.DependencyList, + options?: RenderOptions, + ): A { + const runSync = this.useRunSync() + + return React.useMemo(() => runSync(effect), [ + ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync], + ...(deps ?? []), + ]) + } + + useMemoScoped( + effect: Effect.Effect, + deps?: React.DependencyList, + options?: RenderOptions & ScopeOptions, + ): A { + const runSync = this.useRunSync() + + // Calculate an initial version of the value so that it can be accessed during the first render + const [initialScope, initialValue] = React.useMemo(() => Scope.make(options?.finalizerExecutionStrategy).pipe( + Effect.flatMap(scope => effect.pipe( + Effect.provideService(Scope.Scope, scope), + Effect.map(value => [scope, value] as const), + )), + + runSync, + ), []) + + // Keep track of the state of the initial scope + const initialScopeClosed = React.useRef(false) + + const [value, setValue] = React.useState(initialValue) + + React.useEffect(() => { + const closeInitialScopeIfNeeded = Scope.close(initialScope, Exit.void).pipe( + Effect.andThen(Effect.sync(() => { initialScopeClosed.current = true })), + Effect.when(() => !initialScopeClosed.current), + ) + + const [scope, value] = closeInitialScopeIfNeeded.pipe( + Effect.andThen(Scope.make(options?.finalizerExecutionStrategy).pipe( + Effect.flatMap(scope => effect.pipe( + Effect.provideService(Scope.Scope, scope), + Effect.map(value => [scope, value] as const), + )) + )), + + runSync, + ) + + setValue(value) + return () => { runSync(Scope.close(scope, Exit.void)) } + }, [ + ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync], + ...(deps ?? []), + ]) + + return value + } + + /** + * Reffuse equivalent to `React.useEffect`. + * + * Executes a synchronous effect wrapped into a Scope when one of the deps has changed. Trying to run an asynchronous effect will throw. + * + * The Scope is closed on every cleanup, i.e. when one of the deps has changed and the effect needs to be re-executed. \ + * Add finalizers to the Scope to handle cleanup logic. + * + * Changes to the Reffuse runtime or context will re-execute the effect in addition to the deps. + * You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`. + * + * ### Example + * ``` + * useEffect(Effect.addFinalizer(() => Console.log("Component unmounted")).pipe( + * Effect.flatMap(() => Console.log("Component mounted")) + * )) + * ``` + * + * Plain React equivalent: + * ``` + * React.useEffect(() => { + * console.log("Component mounted") + * return () => { console.log("Component unmounted") } + * }) + * ``` + */ + useEffect( + effect: Effect.Effect, + deps?: React.DependencyList, + options?: RenderOptions & ScopeOptions, + ): void { + const runSync = this.useRunSync() + + return React.useEffect(() => { + const scope = Scope.make(options?.finalizerExecutionStrategy).pipe( + Effect.tap(scope => Effect.provideService(effect, Scope.Scope, scope)), + runSync, + ) + + return () => { runSync(Scope.close(scope, Exit.void)) } + }, [ + ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync], + ...(deps ?? []), + ]) + } + + /** + * Reffuse equivalent to `React.useLayoutEffect`. + * + * Executes a synchronous effect wrapped into a Scope when one of the deps has changed. Fires synchronously after all DOM mutations. \ + * Trying to run an asynchronous effect will throw. + * + * The Scope is closed on every cleanup, i.e. when one of the deps has changed and the effect needs to be re-executed. \ + * Add finalizers to the Scope to handle cleanup logic. + * + * Changes to the Reffuse runtime or context will re-execute the effect in addition to the deps. + * You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`. + * + * ### Example + * ``` + * useLayoutEffect(Effect.addFinalizer(() => Console.log("Component unmounted")).pipe( + * Effect.flatMap(() => Console.log("Component mounted")) + * )) + * ``` + * + * Plain React equivalent: + * ``` + * React.useLayoutEffect(() => { + * console.log("Component mounted") + * return () => { console.log("Component unmounted") } + * }) + * ``` + */ + useLayoutEffect( + effect: Effect.Effect, + deps?: React.DependencyList, + options?: RenderOptions & ScopeOptions, + ): void { + const runSync = this.useRunSync() + + return React.useLayoutEffect(() => { + const scope = Scope.make(options?.finalizerExecutionStrategy).pipe( + Effect.tap(scope => Effect.provideService(effect, Scope.Scope, scope)), + runSync, + ) + + return () => { runSync(Scope.close(scope, Exit.void)) } + }, [ + ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync], + ...(deps ?? []), + ]) + } + + /** + * An asynchronous and non-blocking alternative to `React.useEffect`. + * + * Forks an effect wrapped into a Scope in the background when one of the deps has changed. + * + * The Scope is closed on every cleanup, i.e. when one of the deps has changed and the effect needs to be re-executed. \ + * Add finalizers to the Scope to handle cleanup logic. + * + * Changes to the Reffuse runtime or context will re-execute the effect in addition to the deps. + * You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`. + * + * ### Example + * ``` + * const timeRef = useRefFromEffect(DateTime.now) + * + * useFork(Effect.addFinalizer(() => Console.log("Cleanup")).pipe( + * Effect.map(() => Stream.repeatEffectWithSchedule( + * DateTime.now, + * Schedule.intersect(Schedule.forever, Schedule.spaced("1 second")), + * )), + * + * Effect.flatMap(Stream.runForEach(time => Ref.set(timeRef, time)), + * )), [timeRef]) + * + * const [time] = useRefState(timeRef) + * ``` + */ + useFork( + effect: Effect.Effect, + deps?: React.DependencyList, + options?: Runtime.RunForkOptions & RenderOptions & ScopeOptions, + ): void { + const runSync = this.useRunSync() + const runFork = this.useRunFork() + + return React.useEffect(() => { + const scope = runSync(options?.scope + ? Scope.fork(options.scope, options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential) + : Scope.make(options?.finalizerExecutionStrategy) + ) + runFork(Effect.provideService(effect, Scope.Scope, scope), { ...options, scope }) + + return () => { runFork(Scope.close(scope, Exit.void)) } + }, [ + ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync, runFork], + ...(deps ?? []), + ]) + } + + usePromise( + effect: Effect.Effect, + deps?: React.DependencyList, + options?: { readonly signal?: AbortSignal } & Runtime.RunForkOptions & RenderOptions & ScopeOptions, + ): Promise { + const runSync = this.useRunSync() + const runFork = this.useRunFork() + + const [value, setValue] = React.useState(Promise.withResolvers().promise) + + React.useEffect(() => { + const { promise, resolve, reject } = Promise.withResolvers() + setValue(promise) + + const scope = runSync(options?.scope + ? Scope.fork(options.scope, options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential) + : Scope.make(options?.finalizerExecutionStrategy) + ) + + const cleanup = () => { runFork(Scope.close(scope, Exit.void)) } + if (options?.signal) + options.signal.addEventListener("abort", cleanup) + + effect.pipe( + Effect.provideService(Scope.Scope, scope), + Effect.match({ + onSuccess: resolve, + onFailure: reject, + }), + effect => runFork(effect, { ...options, scope }), + ) + + return () => { + if (options?.signal) + options.signal.removeEventListener("abort", cleanup) + + cleanup() + } + }, [ + ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync, runFork], + ...(deps ?? []), + ]) + + return value + } + + + useRef(value: A): SubscriptionRef.SubscriptionRef { + return this.useMemo( + SubscriptionRef.make(value), + [], + { doNotReExecuteOnRuntimeOrContextChange: true }, // Do not recreate the ref when the context changes + ) + } + + /** + * Binds the state of a `SubscriptionRef` to the state of the React component. + * + * Returns a [value, setter] tuple just like `React.useState` and triggers a re-render everytime the value held by the ref changes. + * + * Note that the rules of React's immutable state still apply: updating a ref with the same value will not trigger a re-render. + */ + useRefState(ref: SubscriptionRef.SubscriptionRef): [A, React.Dispatch>] { + const runSync = this.useRunSync() + + const initialState = React.useMemo(() => runSync(ref), []) + const [reactStateValue, setReactStateValue] = React.useState(initialState) + + this.useFork(Stream.runForEach(ref.changes, v => Effect.sync(() => + setReactStateValue(v) + )), [ref]) + + const setValue = React.useCallback((setStateAction: React.SetStateAction) => + runSync(Ref.update(ref, prevState => + SetStateAction.value(setStateAction, prevState) + )), + [ref]) + + return [reactStateValue, setValue] + } + +} + + +export interface ReffuseHelpers extends Pipeable.Pipeable {} + +ReffuseHelpers.prototype.pipe = function pipe() { + return Pipeable.pipeArguments(this, arguments) +} + + +export interface RenderOptions { + /** Prevents re-executing the effect when the Effect runtime or context changes. Defaults to `false`. */ + readonly doNotReExecuteOnRuntimeOrContextChange?: boolean +} + +export interface ScopeOptions { + readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy +}