0.1.13 #18

Merged
Thilawyn merged 359 commits from next into master 2025-06-18 00:12:19 +02:00
3 changed files with 58 additions and 442 deletions
Showing only changes of commit 60274266da - Show all commits

View File

@@ -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<R> {
export class Reffuse extends ReffuseHelpers.make() {}
constructor(
readonly contexts: readonly ReffuseContext.ReffuseContext<R>[]
) {}
useContext(): Context.Context<R> {
return ReffuseContext.useMergeAll(...this.contexts)
}
useRunSync() {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
return React.useCallback(<A, E>(
effect: Effect.Effect<A, E, R>
): A => effect.pipe(
Effect.provide(context),
Runtime.runSync(runtime),
), [runtime, context])
}
useRunPromise() {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
return React.useCallback(<A, E>(
effect: Effect.Effect<A, E, R>,
options?: { readonly signal?: AbortSignal },
): Promise<A> => 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(<A, E>(
effect: Effect.Effect<A, E, R>,
options?: Runtime.RunForkOptions,
): Fiber.RuntimeFiber<A, E> => 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(<A, E>(
effect: Effect.Effect<A, E, R>,
options?: Runtime.RunCallbackOptions<A, E>,
): Runtime.Cancel<A, E> => 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<A, E>(
effect: Effect.Effect<A, E, R>,
deps?: React.DependencyList,
options?: RenderOptions,
): A {
const runSync = this.useRunSync()
return React.useMemo(() => runSync(effect), [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
...(deps ?? []),
])
}
useMemoScoped<A, E>(
effect: Effect.Effect<A, E, R | Scope.Scope>,
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<A, E>(
effect: Effect.Effect<A, E, R | Scope.Scope>,
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<A, E>(
effect: Effect.Effect<A, E, R | Scope.Scope>,
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<A, E>(
effect: Effect.Effect<A, E, R | Scope.Scope>,
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<A, E>(
effect: Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: { readonly signal?: AbortSignal } & Runtime.RunForkOptions & RenderOptions & ScopeOptions,
): Promise<A> {
const runSync = this.useRunSync()
const runFork = this.useRunFork()
const [value, setValue] = React.useState(Promise.withResolvers<A>().promise)
React.useEffect(() => {
const { promise, resolve, reject } = Promise.withResolvers<A>()
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<A>(value: A): SubscriptionRef.SubscriptionRef<A> {
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<A>(ref: SubscriptionRef.SubscriptionRef<A>): [A, React.Dispatch<React.SetStateAction<A>>] {
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<A>) =>
runSync(Ref.update(ref, prevState =>
SetStateAction.value(setStateAction, prevState)
)),
[ref])
return [reactStateValue, setValue]
}
}
export interface Reffuse<R> 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 = <T extends Array<unknown>>(
...contexts: [...{ [K in keyof T]: ReffuseContext.ReffuseContext<T[K]> }]
): Reffuse<T[number]> =>
new Reffuse(contexts)
// export const make = (): Reffuse<never> => new Reffuse([])
// export const withContexts = <R2 extends Array<unknown>>(
// ...contexts: [...{ [K in keyof R2]: ReffuseContext.ReffuseContext<R2[K]> }]
// ) =>
// <T extends Reffuse<R1>, R1>(self: T & Reffuse<R1>): (
// Reffuse<R1 | R2[number]> & Exclude<T, Reffuse<R1>>
// ) =>
// new Reffuse([...self.contexts, ...contexts as any]) as any
export const withContexts = <R2 extends Array<unknown>>(
...contexts: [...{ [K in keyof R2]: ReffuseContext.ReffuseContext<R2[K]> }]
) =>
<
BaseClass extends ReffuseHelpers.ReffuseHelpersClass<R1>,
R1
>(
self: BaseClass & ReffuseHelpers.ReffuseHelpersClass<R1>
): (
{
new(): Merge<
InstanceType<BaseClass>,
{ constructor: ReffuseHelpers.ReffuseHelpersClass<R1 | R2[number]> }
>
} &
Merge<
StaticType<BaseClass>,
StaticType<ReffuseHelpers.ReffuseHelpersClass<R1 | R2[number]>>
>
) => class extends self {
readonly contexts = [...self.contexts, ...contexts]
} as any

View File

@@ -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>()("MyService", {
succeed: {}
}) {}
@@ -28,32 +27,9 @@ const make = <Ext extends object>(extension: Ext) =>
return class_ as any
}
export const withContexts = <R2 extends Array<unknown>>(
...contexts: [...{ [K in keyof R2]: ReffuseContext.ReffuseContext<R2[K]> }]
) =>
<
BaseClass extends ReffuseHelpers.ReffuseHelpersClass<R1>,
R1
>(
self: BaseClass & ReffuseHelpers.ReffuseHelpersClass<R1>
): (
{
new(): Merge<
InstanceType<BaseClass>,
ReffuseHelpers.ReffuseHelpers<R1 | R2[number]>
>
} &
Merge<
StaticType<BaseClass>,
StaticType<ReffuseHelpers.ReffuseHelpersClass<R1 | R2[number]>>
>
) => 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<R>(this: ReffuseHelpers.ReffuseHelpers<R>) {}
})
class MyReffuse extends Reffuse.pipe(
class MyReffuse extends Reffuse.Reffuse.pipe(
withProut,
withContexts(MyContext),
Reffuse.withContexts(MyContext),
) {}
new MyReffuse().useFork()

View File

@@ -16,16 +16,15 @@ export interface ScopeOptions {
export abstract class ReffuseHelpers<R> {
declare ["constructor"]: ReffuseHelpersClass<R>
useContext(): Context.Context<R> {
useContext<R>(this: ReffuseHelpers<R>): Context.Context<R> {
return ReffuseContext.useMergeAll(...this.constructor.contexts)
}
useRunSync() {
useRunSync<R>(this: ReffuseHelpers<R>) {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
@@ -37,7 +36,7 @@ export abstract class ReffuseHelpers<R> {
), [runtime, context])
}
useRunPromise() {
useRunPromise<R>(this: ReffuseHelpers<R>) {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
@@ -50,7 +49,7 @@ export abstract class ReffuseHelpers<R> {
), [runtime, context])
}
useRunFork() {
useRunFork<R>(this: ReffuseHelpers<R>) {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
@@ -63,7 +62,7 @@ export abstract class ReffuseHelpers<R> {
), [runtime, context])
}
useRunCallback() {
useRunCallback<R>(this: ReffuseHelpers<R>) {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
@@ -86,7 +85,8 @@ export abstract class ReffuseHelpers<R> {
* 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<A, E>(
useMemo<A, E, R>(
this: ReffuseHelpers<R>,
effect: Effect.Effect<A, E, R>,
deps?: React.DependencyList,
options?: RenderOptions,
@@ -99,7 +99,8 @@ export abstract class ReffuseHelpers<R> {
])
}
useMemoScoped<A, E>(
useMemoScoped<A, E, R>(
this: ReffuseHelpers<R>,
effect: Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: RenderOptions & ScopeOptions,
@@ -174,7 +175,8 @@ export abstract class ReffuseHelpers<R> {
* })
* ```
*/
useEffect<A, E>(
useEffect<A, E, R>(
this: ReffuseHelpers<R>,
effect: Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: RenderOptions & ScopeOptions,
@@ -221,7 +223,8 @@ export abstract class ReffuseHelpers<R> {
* })
* ```
*/
useLayoutEffect<A, E>(
useLayoutEffect<A, E, R>(
this: ReffuseHelpers<R>,
effect: Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: RenderOptions & ScopeOptions,
@@ -268,7 +271,8 @@ export abstract class ReffuseHelpers<R> {
* const [time] = useRefState(timeRef)
* ```
*/
useFork<A, E>(
useFork<A, E, R>(
this: ReffuseHelpers<R>,
effect: Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: Runtime.RunForkOptions & RenderOptions & ScopeOptions,
@@ -290,7 +294,8 @@ export abstract class ReffuseHelpers<R> {
])
}
usePromise<A, E>(
usePromise<A, E, R>(
this: ReffuseHelpers<R>,
effect: Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: { readonly signal?: AbortSignal } & Runtime.RunForkOptions & RenderOptions & ScopeOptions,
@@ -337,7 +342,10 @@ export abstract class ReffuseHelpers<R> {
}
useRef<A>(value: A): SubscriptionRef.SubscriptionRef<A> {
useRef<A, R>(
this: ReffuseHelpers<R>,
value: A,
): SubscriptionRef.SubscriptionRef<A> {
return this.useMemo(
SubscriptionRef.make(value),
[],
@@ -352,7 +360,10 @@ export abstract class ReffuseHelpers<R> {
*
* 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<A>(ref: SubscriptionRef.SubscriptionRef<A>): [A, React.Dispatch<React.SetStateAction<A>>] {
useRefState<A, R>(
this: ReffuseHelpers<R>,
ref: SubscriptionRef.SubscriptionRef<A>,
): [A, React.Dispatch<React.SetStateAction<A>>] {
const runSync = this.useRunSync()
const initialState = React.useMemo(() => runSync(ref), [])
@@ -370,7 +381,6 @@ export abstract class ReffuseHelpers<R> {
return [reactStateValue, setValue]
}
}
@@ -391,7 +401,7 @@ export interface ReffuseHelpersClass<R> extends Pipeable.Pipeable {
}
export const make = <R = never>(contexts: readonly ReffuseContext.ReffuseContext<R>[]): ReffuseHelpersClass<R> =>
class extends (ReffuseHelpers<R> as ReffuseHelpersClass<R>) {
static readonly contexts = contexts
export const make = (): ReffuseHelpersClass<never> =>
class extends (ReffuseHelpers<never> as ReffuseHelpersClass<never>) {
static readonly contexts = []
}