diff --git a/packages/reffuse/src/Reffuse.ts b/packages/reffuse/src/Reffuse.ts index 5f899f5..7aac7eb 100644 --- a/packages/reffuse/src/Reffuse.ts +++ b/packages/reffuse/src/Reffuse.ts @@ -1,399 +1,29 @@ -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" +import * as ReffuseHelpers from "./ReffuseHelpers.js" +import type { Merge, StaticType } from "./types.js" -export class Reffuse { +export class Reffuse extends ReffuseHelpers.make() {} - constructor( - 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 Reffuse extends Pipeable.Pipeable {} - -Reffuse.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 -} - - -export const make = >( - ...contexts: [...{ [K in keyof T]: ReffuseContext.ReffuseContext }] -): Reffuse => - new Reffuse(contexts) - -// export const make = (): Reffuse => new Reffuse([]) - -// export const withContexts = >( -// ...contexts: [...{ [K in keyof R2]: ReffuseContext.ReffuseContext }] -// ) => -// , R1>(self: T & Reffuse): ( -// Reffuse & Exclude> -// ) => -// new Reffuse([...self.contexts, ...contexts as any]) as any +export const withContexts = >( + ...contexts: [...{ [K in keyof R2]: ReffuseContext.ReffuseContext }] +) => + < + BaseClass extends ReffuseHelpers.ReffuseHelpersClass, + R1 + >( + self: BaseClass & ReffuseHelpers.ReffuseHelpersClass + ): ( + { + new(): Merge< + InstanceType, + { constructor: ReffuseHelpers.ReffuseHelpersClass } + > + } & + Merge< + StaticType, + StaticType> + > + ) => class extends self { + readonly contexts = [...self.contexts, ...contexts] + } as any diff --git a/packages/reffuse/src/ReffuseExtension.ts b/packages/reffuse/src/ReffuseExtension.ts index 4e16d61..2e0d69a 100644 --- a/packages/reffuse/src/ReffuseExtension.ts +++ b/packages/reffuse/src/ReffuseExtension.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" +import * as Reffuse from "./Reffuse.js" import * as ReffuseContext from "./ReffuseContext.js" import * as ReffuseHelpers from "./ReffuseHelpers.js" import type { Merge, StaticType } from "./types.js" -class Reffuse extends ReffuseHelpers.make([]) {} - class MyService extends Effect.Service()("MyService", { succeed: {} }) {} @@ -28,32 +27,9 @@ const make = (extension: Ext) => return class_ as any } -export const withContexts = >( - ...contexts: [...{ [K in keyof R2]: ReffuseContext.ReffuseContext }] -) => - < - BaseClass extends ReffuseHelpers.ReffuseHelpersClass, - R1 - >( - self: BaseClass & ReffuseHelpers.ReffuseHelpersClass - ): ( - { - new(): Merge< - InstanceType, - ReffuseHelpers.ReffuseHelpers - > - } & - Merge< - StaticType, - StaticType> - > - ) => class extends self { - readonly contexts = [...self.contexts, ...contexts] - } as any - -const withMyContext = withContexts(MyContext) -const clsWithMyContext = withMyContext(Reffuse) +const withMyContext = Reffuse.withContexts(MyContext) +const clsWithMyContext = withMyContext(Reffuse.Reffuse) class ReffuseWithMyContext extends clsWithMyContext {} @@ -61,9 +37,9 @@ const withProut = make({ prout(this: ReffuseHelpers.ReffuseHelpers) {} }) -class MyReffuse extends Reffuse.pipe( +class MyReffuse extends Reffuse.Reffuse.pipe( withProut, - withContexts(MyContext), + Reffuse.withContexts(MyContext), ) {} new MyReffuse().useFork() diff --git a/packages/reffuse/src/ReffuseHelpers.ts b/packages/reffuse/src/ReffuseHelpers.ts index 47a0160..1f967ff 100644 --- a/packages/reffuse/src/ReffuseHelpers.ts +++ b/packages/reffuse/src/ReffuseHelpers.ts @@ -16,16 +16,15 @@ export interface ScopeOptions { export abstract class ReffuseHelpers { - declare ["constructor"]: ReffuseHelpersClass - useContext(): Context.Context { + useContext(this: ReffuseHelpers): Context.Context { return ReffuseContext.useMergeAll(...this.constructor.contexts) } - useRunSync() { + useRunSync(this: ReffuseHelpers) { const runtime = ReffuseRuntime.useRuntime() const context = this.useContext() @@ -37,7 +36,7 @@ export abstract class ReffuseHelpers { ), [runtime, context]) } - useRunPromise() { + useRunPromise(this: ReffuseHelpers) { const runtime = ReffuseRuntime.useRuntime() const context = this.useContext() @@ -50,7 +49,7 @@ export abstract class ReffuseHelpers { ), [runtime, context]) } - useRunFork() { + useRunFork(this: ReffuseHelpers) { const runtime = ReffuseRuntime.useRuntime() const context = this.useContext() @@ -63,7 +62,7 @@ export abstract class ReffuseHelpers { ), [runtime, context]) } - useRunCallback() { + useRunCallback(this: ReffuseHelpers) { const runtime = ReffuseRuntime.useRuntime() const context = this.useContext() @@ -86,7 +85,8 @@ export abstract class ReffuseHelpers { * 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( + useMemo( + this: ReffuseHelpers, effect: Effect.Effect, deps?: React.DependencyList, options?: RenderOptions, @@ -99,7 +99,8 @@ export abstract class ReffuseHelpers { ]) } - useMemoScoped( + useMemoScoped( + this: ReffuseHelpers, effect: Effect.Effect, deps?: React.DependencyList, options?: RenderOptions & ScopeOptions, @@ -174,7 +175,8 @@ export abstract class ReffuseHelpers { * }) * ``` */ - useEffect( + useEffect( + this: ReffuseHelpers, effect: Effect.Effect, deps?: React.DependencyList, options?: RenderOptions & ScopeOptions, @@ -221,7 +223,8 @@ export abstract class ReffuseHelpers { * }) * ``` */ - useLayoutEffect( + useLayoutEffect( + this: ReffuseHelpers, effect: Effect.Effect, deps?: React.DependencyList, options?: RenderOptions & ScopeOptions, @@ -268,7 +271,8 @@ export abstract class ReffuseHelpers { * const [time] = useRefState(timeRef) * ``` */ - useFork( + useFork( + this: ReffuseHelpers, effect: Effect.Effect, deps?: React.DependencyList, options?: Runtime.RunForkOptions & RenderOptions & ScopeOptions, @@ -290,7 +294,8 @@ export abstract class ReffuseHelpers { ]) } - usePromise( + usePromise( + this: ReffuseHelpers, effect: Effect.Effect, deps?: React.DependencyList, options?: { readonly signal?: AbortSignal } & Runtime.RunForkOptions & RenderOptions & ScopeOptions, @@ -337,7 +342,10 @@ export abstract class ReffuseHelpers { } - useRef(value: A): SubscriptionRef.SubscriptionRef { + useRef( + this: ReffuseHelpers, + value: A, + ): SubscriptionRef.SubscriptionRef { return this.useMemo( SubscriptionRef.make(value), [], @@ -352,7 +360,10 @@ export abstract class ReffuseHelpers { * * 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>] { + useRefState( + this: ReffuseHelpers, + ref: SubscriptionRef.SubscriptionRef, + ): [A, React.Dispatch>] { const runSync = this.useRunSync() const initialState = React.useMemo(() => runSync(ref), []) @@ -370,7 +381,6 @@ export abstract class ReffuseHelpers { return [reactStateValue, setValue] } - } @@ -391,7 +401,7 @@ export interface ReffuseHelpersClass extends Pipeable.Pipeable { } -export const make = (contexts: readonly ReffuseContext.ReffuseContext[]): ReffuseHelpersClass => - class extends (ReffuseHelpers as ReffuseHelpersClass) { - static readonly contexts = contexts +export const make = (): ReffuseHelpersClass => + class extends (ReffuseHelpers as ReffuseHelpersClass) { + static readonly contexts = [] }