diff --git a/src/FiberStore.ts b/src/FiberStore.ts index fcbfe96..48ac637 100644 --- a/src/FiberStore.ts +++ b/src/FiberStore.ts @@ -1,7 +1,10 @@ -import type * as Runtime from "@effect/io/Runtime" +/** + * @since 1.0.0 + */ import type * as Stream from "@effect/stream/Stream" import * as internal from "effect-react/internal/fiberStore" import type * as ResultBag from "effect-react/ResultBag" +import type * as RuntimeContext from "effect-react/RuntimeContext" /** * @since 1.0.0 @@ -18,4 +21,4 @@ export interface FiberStore { * @since 1.0.0 * @category constructors */ -export const make: (runtime: Runtime.Runtime) => FiberStore = internal.make +export const make: (runtime: RuntimeContext.RuntimeEffect) => FiberStore = internal.make diff --git a/src/Hooks.ts b/src/Hooks.ts new file mode 100644 index 0000000..7de372f --- /dev/null +++ b/src/Hooks.ts @@ -0,0 +1,43 @@ +/** + * @since 1.0.0 + */ +import type * as Stream from "@effect/stream/Stream" +import * as internal from "effect-react/internal/hooks" +import type * as ResultBag from "effect-react/ResultBag" +import type * as RuntimeContext from "effect-react/RuntimeContext" +import type { DependencyList } from "react" + +/** + * @since 1.0.0 + * @category constructors + */ +export const make: ( + runtimeContext: RuntimeContext.ReactContext +) => { + useResult: ( + evaluate: () => Stream.Stream, + deps: DependencyList + ) => ResultBag.ResultBag + useResultCallback: , R0 extends R, E, A>( + f: (...args: Args) => Stream.Stream + ) => readonly [ResultBag.ResultBag, (...args: Args) => void] +} = internal.make + +/** + * @since 1.0.0 + * @category constructors + */ +export const makeUseResult: ( + runtimeContext: RuntimeContext.ReactContext +) => (evaluate: () => Stream.Stream, deps: DependencyList) => ResultBag.ResultBag = + internal.makeUseResult + +/** + * @since 1.0.0 + * @category constructors + */ +export const makeUseResultCallback: ( + runtimeContext: RuntimeContext.ReactContext +) => , R0 extends R, E, A>( + f: (...args: Args) => Stream.Stream +) => readonly [ResultBag.ResultBag, (...args: Args) => void] = internal.makeUseResultCallback diff --git a/src/Result.ts b/src/Result.ts index 256717c..3ae34a8 100644 --- a/src/Result.ts +++ b/src/Result.ts @@ -1,3 +1,6 @@ +/** + * @since 1.0.0 + */ import type * as Data from "@effect/data/Data" import type * as Option from "@effect/data/Option" import type { Pipeable } from "@effect/data/Pipeable" diff --git a/src/ResultBag.ts b/src/ResultBag.ts index 30cec18..997f060 100644 --- a/src/ResultBag.ts +++ b/src/ResultBag.ts @@ -1,8 +1,11 @@ +/** + * @since 1.0.0 + */ import type * as Option from "@effect/data/Option" import type * as Cause from "@effect/io/Cause" import * as internal from "effect-react/internal/resultBag" import type * as Result from "effect-react/Result" -import type * as TrackedProperties from "effect-react/TrackedProperties" +import type * as TrackedProperties from "effect-react/ResultBag/TrackedProperties" /** * @since 1.0.0 diff --git a/src/TrackedProperties.ts b/src/ResultBag/TrackedProperties.ts similarity index 89% rename from src/TrackedProperties.ts rename to src/ResultBag/TrackedProperties.ts index 910e4c3..522ea58 100644 --- a/src/TrackedProperties.ts +++ b/src/ResultBag/TrackedProperties.ts @@ -1,6 +1,9 @@ +/** + * @since 1.0.0 + */ import type * as Option from "@effect/data/Option" import type * as Cause from "@effect/io/Cause" -import * as internal from "effect-react/internal/trackedProperties" +import * as internal from "effect-react/internal/resultBag/trackedProperties" import type * as Result from "effect-react/Result" /** diff --git a/src/RuntimeContext.ts b/src/RuntimeContext.ts new file mode 100644 index 0000000..30b1b3c --- /dev/null +++ b/src/RuntimeContext.ts @@ -0,0 +1,187 @@ +/** + * @since 1.0.0 + */ +import type * as Context from "@effect/data/Context" +import { dual, pipe } from "@effect/data/Function" +import * as Effect from "@effect/io/Effect" +import * as Exit from "@effect/io/Exit" +import * as Fiber from "@effect/io/Fiber" +import * as Layer from "@effect/io/Layer" +import * as Runtime from "@effect/io/Runtime" +import * as Scope from "@effect/io/Scope" +import * as React from "react" + +/** + * @since 1.0.0 + * @category type ids + */ +export const RuntimeContextTypeId = Symbol.for("@effect/react/RuntimeContext") + +/** + * @since 1.0.0 + * @category type ids + */ +export type RuntimeContextTypeId = typeof RuntimeContextTypeId + +/** + * @since 1.0.0 + * @category models + */ +export interface RuntimeContext extends ReactContext { + readonly [RuntimeContextTypeId]: { + readonly scope: Scope.CloseableScope + readonly context: Effect.Effect> + } +} + +/** + * @since 1.0.0 + * @category models + */ +export type ReactContext = React.Context>> + +/** + * @since 1.0.0 + * @category models + */ +export type RuntimeEffect = Effect.Effect> + +/** + * @since 1.0.0 + * @category constructors + */ +export const fromContext = ( + context: Context.Context +): RuntimeContext => fromContextEffect(Effect.succeed(context)) + +const makeRuntimeEffect = (context: Effect.Effect>): RuntimeEffect => { + const runtime = pipe( + context, + Effect.flatMap((context) => Effect.provideContext(Effect.runtime(), context)), + Effect.cached, + Effect.runSync + ) + Effect.runFork(runtime) // prime cache + return runtime +} + +/** + * @since 1.0.0 + * @category constructors + */ +export const fromContextEffect = ( + effect: Effect.Effect> +): RuntimeContext => { + const scope = Effect.runSync(Scope.make()) + const error = new Error() + const context = Scope.use( + Effect.orDieWith(effect, (e) => { + error.message = `Could not build RuntimeContext: ${e}` + return error + }), + scope + ) + const runtime = makeRuntimeEffect(context) + const RuntimeContext = React.createContext(runtime) + return new Proxy(RuntimeContext, { + has(target, p) { + return p === RuntimeContextTypeId || p in target + }, + get(target, p, _receiver) { + if (p === RuntimeContextTypeId) { + return { + scope, + context + } + } + return (target as any)[p] + } + }) as any +} + +/** + * @since 1.0.0 + * @category constructors + */ +export const fromLayer = (layer: Layer.Layer): RuntimeContext => + fromContextEffect(Effect.runSync(Effect.cached(Layer.build(layer)))) + +/** + * @since 1.0.0 + * @category combinators + */ +export const provideMerge = dual< + ( + layer: Layer.Layer + ) => (self: RuntimeContext) => RuntimeContext, + ( + self: RuntimeContext, + layer: Layer.Layer + ) => RuntimeContext +>(2, (self, layer) => { + const context = self[RuntimeContextTypeId].context + return fromLayer(Layer.provideMerge(Layer.effectContext(context), layer)) +}) + +/** + * @since 1.0.0 + * @category combinators + */ +export const toRuntimeEffect = ( + self: RuntimeContext +): RuntimeEffect => makeRuntimeEffect(self[RuntimeContextTypeId].context) + +/** + * @since 1.0.0 + * @category combinators + */ +export const overrideLayer = dual< + ( + layer: Layer.Layer + ) => (self: RuntimeContext) => readonly [React.ExoticComponent, Scope.CloseableScope], + ( + self: RuntimeContext, + layer: Layer.Layer + ) => readonly [React.ExoticComponent, Scope.CloseableScope] +>(2, (self, layer) => { + const context = fromLayer(layer) + const runtime = toRuntimeEffect(context) + return [ + (props: React.PropsWithChildren) => React.createElement(self.Provider, { value: runtime }, props.children), + context[RuntimeContextTypeId].scope + ] as any +}) + +/** + * @since 1.0.0 + * @category combinators + */ +export const closeEffect = (self: RuntimeContext): Effect.Effect => + Scope.close(self[RuntimeContextTypeId].scope, Exit.unit) + +/** + * @since 1.0.0 + * @category combinators + */ +export const close = (self: RuntimeContext): () => void => { + const effect = closeEffect(self) + return () => { + Effect.runFork(effect) + } +} + +/** + * @since 1.0.0 + * @category execution + */ +export const runForkJoin = ( + runtime: RuntimeEffect +) => + (effect: Effect.Effect): Effect.Effect => + Effect.flatMap( + runtime, + (runtime) => { + const fiber = Runtime.runFork(runtime)(effect) + return Fiber.join(fiber) + } + ) diff --git a/src/RuntimeProvider.ts b/src/RuntimeProvider.ts deleted file mode 100644 index f6982d9..0000000 --- a/src/RuntimeProvider.ts +++ /dev/null @@ -1,100 +0,0 @@ -"use client" -import type { LazyArg } from "@effect/data/Function" -import { pipe } from "@effect/data/Function" -import * as Effect from "@effect/io/Effect" -import * as Layer from "@effect/io/Layer" -import type * as Runtime from "@effect/io/Runtime" -import * as Scope from "@effect/io/Scope" -import type * as Stream from "@effect/stream/Stream" -import * as internalUseResult from "effect-react/internal/hooks/useResult" -import * as internalUseResultCallback from "effect-react/internal/hooks/useResultCallback" -import type * as ResultBag from "effect-react/ResultBag" -import type { DependencyList } from "react" -import { createContext } from "react" - -/** - * @since 1.0.0 - * @category models - */ -export type RuntimeContext = React.Context> - -/** - * @since 1.0.0 - * @category hooks - */ -export type UseResult = ( - evaluate: LazyArg>, - deps: DependencyList -) => ResultBag.ResultBag - -/** - * @since 1.0.0 - * @category hooks - */ -export type UseResultCallback = , R0 extends R, E, A>( - f: (...args: Args) => Stream.Stream -) => readonly [ResultBag.ResultBag, (...args: Args) => void] - -/** - * @since 1.0.0 - * @category models - */ -export interface ReactEffectBag { - readonly RuntimeContext: React.Context> - readonly useResultCallback: UseResultCallback - readonly useResult: UseResult -} - -/** - * @since 1.0.0 - * @category constructors - */ -export const makeFromLayer = ( - layer: Layer.Layer -): ReactEffectBag => { - const scope = Effect.runSync(Scope.make()) - - const runtime = pipe( - Layer.toRuntime(layer), - Effect.provideService(Scope.Scope, scope), - Effect.runSync - ) - - const RuntimeContext = createContext(runtime) - - return { - RuntimeContext, - useResultCallback: internalUseResultCallback.make(RuntimeContext), - useResult: internalUseResult.make(RuntimeContext) - } -} - -/** - * @since 1.0.0 - * @category constructors - */ -export const makeFromRuntime = ( - runtime: Runtime.Runtime -): ReactEffectBag => { - const RuntimeContext = createContext(runtime) - - return { - RuntimeContext, - useResultCallback: internalUseResultCallback.make(RuntimeContext), - useResult: internalUseResult.make(RuntimeContext) - } -} - -/** - * @since 1.0.0 - * @category constructors - */ -export const makeFromRuntimeContext = ( - RuntimeContext: React.Context> -): ReactEffectBag => { - return { - RuntimeContext, - useResultCallback: internalUseResultCallback.make(RuntimeContext), - useResult: internalUseResult.make(RuntimeContext) - } -} diff --git a/src/internal/fiberStore.ts b/src/internal/fiberStore.ts index 88ebd96..6220bef 100644 --- a/src/internal/fiberStore.ts +++ b/src/internal/fiberStore.ts @@ -2,21 +2,21 @@ import { pipe } from "@effect/data/Function" import * as Effect from "@effect/io/Effect" import type * as Fiber from "@effect/io/Fiber" import * as Ref from "@effect/io/Ref" -import * as Runtime from "@effect/io/Runtime" import * as Stream from "@effect/stream/Stream" import type * as FiberStore from "effect-react/FiberStore" import * as Result from "effect-react/Result" import * as ResultBag from "effect-react/ResultBag" -import * as TrackedProperties from "effect-react/TrackedProperties" +import * as TrackedProperties from "effect-react/ResultBag/TrackedProperties" +import * as RuntimeContext from "effect-react/RuntimeContext" /** @internal */ export const make = ( - runtime: Runtime.Runtime + runtime: RuntimeContext.RuntimeEffect ): FiberStore.FiberStore => new FiberStoreImpl(runtime) class FiberStoreImpl implements FiberStore.FiberStore { constructor( - readonly runtime: Runtime.Runtime + readonly runtime: RuntimeContext.RuntimeEffect ) {} // listeners @@ -82,8 +82,9 @@ class FiberStoreImpl implements FiberStore.FiberStore { return stream }), Stream.runForEach((_) => maybeSetResult(Result.success(_))), + RuntimeContext.runForkJoin(this.runtime), Effect.catchAllCause((cause) => maybeSetResult(Result.failCause(cause))), - Runtime.runFork(this.runtime) + Effect.runFork ) this.fiberState = { diff --git a/src/internal/hooks.ts b/src/internal/hooks.ts new file mode 100644 index 0000000..55e1dac --- /dev/null +++ b/src/internal/hooks.ts @@ -0,0 +1,71 @@ +import * as Hash from "@effect/data/Hash" +import type * as Stream from "@effect/stream/Stream" +import * as FiberStore from "effect-react/FiberStore" +import type * as ResultBag from "effect-react/ResultBag" +import type * as RuntimeContext from "effect-react/RuntimeContext" +import type { DependencyList } from "react" +import { useCallback, useContext, useRef, useSyncExternalStore } from "react" + +/** @internal */ +export const make = ( + runtimeContext: RuntimeContext.ReactContext +): { + useResult: ( + evaluate: () => Stream.Stream, + deps: DependencyList + ) => ResultBag.ResultBag + useResultCallback: , R0 extends R, E, A>( + f: (...args: Args) => Stream.Stream + ) => readonly [ResultBag.ResultBag, (...args: Args) => void] +} => ({ + useResult: makeUseResult(runtimeContext), + useResultCallback: makeUseResultCallback(runtimeContext) +}) + +/** @internal */ +export const makeUseResult = ( + runtimeContext: RuntimeContext.ReactContext +) => + ( + evaluate: () => Stream.Stream, + deps: DependencyList + ): ResultBag.ResultBag => { + const runtime = useContext(runtimeContext) + const storeRef = useRef>(undefined as any) + if (storeRef.current === undefined) { + storeRef.current = FiberStore.make(runtime) + } + const resultBag = useSyncExternalStore( + storeRef.current.subscribe, + storeRef.current.snapshot + ) + const depsHash = useRef(null as any) + const currentDepsHash = Hash.array(deps) + if (depsHash.current !== currentDepsHash) { + depsHash.current = currentDepsHash + storeRef.current.run(evaluate()) + } + return resultBag + } + +/** @internal */ +export const makeUseResultCallback = ( + runtimeContext: RuntimeContext.ReactContext +) => + , R0 extends R, E, A>( + f: (...args: Args) => Stream.Stream + ): readonly [ResultBag.ResultBag, (...args: Args) => void] => { + const runtime = useContext(runtimeContext) + const storeRef = useRef>(undefined as any) + if (storeRef.current === undefined) { + storeRef.current = FiberStore.make(runtime) + } + const resultBag = useSyncExternalStore( + storeRef.current.subscribe, + storeRef.current.snapshot + ) + const run = useCallback((...args: Args) => { + storeRef.current.run(f(...args)) + }, [f]) + return [resultBag, run] as const + } diff --git a/src/internal/hooks/useResult.ts b/src/internal/hooks/useResult.ts deleted file mode 100644 index 5985b5d..0000000 --- a/src/internal/hooks/useResult.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as Hash from "@effect/data/Hash" -import type * as Stream from "@effect/stream/Stream" -import * as FiberStore from "effect-react/FiberStore" -import type * as ResultBag from "effect-react/ResultBag" -import type * as RuntimeProvider from "effect-react/RuntimeProvider" -import type { DependencyList } from "react" -import { useContext, useRef, useSyncExternalStore } from "react" - -export const make = ( - runtimeContext: RuntimeProvider.RuntimeContext -): RuntimeProvider.UseResult => - ( - evaluate: () => Stream.Stream, - deps: DependencyList - ): ResultBag.ResultBag => { - const runtime = useContext(runtimeContext) - const storeRef = useRef>(undefined as any) - if (storeRef.current === undefined) { - storeRef.current = FiberStore.make(runtime) - } - const resultBag = useSyncExternalStore( - storeRef.current.subscribe, - storeRef.current.snapshot - ) - const depsHash = useRef(null as any) - const currentDepsHash = Hash.array(deps) - if (depsHash.current !== currentDepsHash) { - depsHash.current = currentDepsHash - storeRef.current.run(evaluate()) - } - return resultBag - } diff --git a/src/internal/hooks/useResultCallback.ts b/src/internal/hooks/useResultCallback.ts deleted file mode 100644 index 8f4568f..0000000 --- a/src/internal/hooks/useResultCallback.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type * as Stream from "@effect/stream/Stream" -import * as FiberStore from "effect-react/FiberStore" -import type * as ResultBag from "effect-react/ResultBag" -import type * as RuntimeProvider from "effect-react/RuntimeProvider" -import { useCallback, useContext, useRef, useSyncExternalStore } from "react" - -/** @internal */ -export const make = ( - runtimeContext: RuntimeProvider.RuntimeContext -): RuntimeProvider.UseResultCallback => - , R0 extends R, E, A>( - f: (...args: Args) => Stream.Stream - ): readonly [ResultBag.ResultBag, (...args: Args) => void] => { - const runtime = useContext(runtimeContext) - const storeRef = useRef>(undefined as any) - if (storeRef.current === undefined) { - storeRef.current = FiberStore.make(runtime) - } - const resultBag = useSyncExternalStore( - storeRef.current.subscribe, - storeRef.current.snapshot - ) - const run = useCallback((...args: Args) => { - storeRef.current.run(f(...args)) - }, [f]) - return [resultBag, run] as const - } diff --git a/src/internal/resultBag.ts b/src/internal/resultBag.ts index bfe16af..a02605f 100644 --- a/src/internal/resultBag.ts +++ b/src/internal/resultBag.ts @@ -5,7 +5,7 @@ import * as Order from "@effect/data/Order" import type * as Cause from "@effect/io/Cause" import * as Result from "effect-react/Result" import type * as ResultBag from "effect-react/ResultBag" -import type * as TrackedProperties from "effect-react/TrackedProperties" +import type * as TrackedProperties from "effect-react/ResultBag/TrackedProperties" const optionDateGreaterThan = pipe( N.Order, diff --git a/src/internal/trackedProperties.ts b/src/internal/resultBag/trackedProperties.ts similarity index 94% rename from src/internal/trackedProperties.ts rename to src/internal/resultBag/trackedProperties.ts index b4f5d6c..dfed889 100644 --- a/src/internal/trackedProperties.ts +++ b/src/internal/resultBag/trackedProperties.ts @@ -1,7 +1,7 @@ import * as Option from "@effect/data/Option" import * as Cause from "@effect/io/Cause" import type * as Result from "effect-react/Result" -import type * as TrackedProperties from "effect-react/TrackedProperties" +import type * as TrackedProperties from "effect-react/ResultBag/TrackedProperties" /** @internal */ export const initial = (): TrackedProperties.TrackedProperties => ({ diff --git a/test/hooks/useResult.ts b/test/hooks/useResult.ts index 04351c9..91f1e2f 100644 --- a/test/hooks/useResult.ts +++ b/test/hooks/useResult.ts @@ -3,8 +3,9 @@ import * as Effect from "@effect/io/Effect" import * as Layer from "@effect/io/Layer" import * as Stream from "@effect/stream/Stream" import { renderHook, waitFor } from "@testing-library/react" +import * as Hooks from "effect-react/Hooks" import * as Result from "effect-react/Result" -import * as RuntimeProvider from "effect-react/RuntimeProvider" +import * as RuntimeContext from "effect-react/RuntimeContext" import { describe, expect, it } from "vitest" interface Foo { @@ -12,7 +13,8 @@ interface Foo { } const foo = Context.Tag() -const { useResult } = RuntimeProvider.makeFromLayer(Layer.succeed(foo, { value: 1 })) +const context = RuntimeContext.fromLayer(Layer.succeed(foo, { value: 1 })) +const useResult = Hooks.makeUseResult(context) describe("useResult", () => { it("should run effects", async () => { @@ -21,6 +23,19 @@ describe("useResult", () => { expect(Result.isSuccess(result.current.result)).toBe(true) }) + it("override Provider value", async () => { + const [Override] = RuntimeContext.overrideLayer(context, Layer.succeed(foo, { value: 2 })) + const testEffect = Effect.map(foo, (_) => _.value) + const { result } = await waitFor(async () => + renderHook(() => useResult(() => testEffect, []), { + wrapper: Override + }) + ) + await waitFor(() => expect(Result.isSuccess(result.current.result)).toBe(true)) + assert(Result.isSuccess(result.current.result)) + expect(result.current.result.value).toBe(2) + }) + it("should provide context", async () => { const testEffect = Effect.map(foo, (_) => _.value) const { result } = await waitFor(async () => renderHook(() => useResult(() => testEffect, []))) diff --git a/test/hooks/useResultCallback.ts b/test/hooks/useResultCallback.ts index 48c121a..18bf0c6 100644 --- a/test/hooks/useResultCallback.ts +++ b/test/hooks/useResultCallback.ts @@ -2,8 +2,9 @@ import * as Context from "@effect/data/Context" import * as Effect from "@effect/io/Effect" import * as Layer from "@effect/io/Layer" import { act, renderHook, waitFor } from "@testing-library/react" +import * as Hooks from "effect-react/Hooks" import * as Result from "effect-react/Result" -import * as RuntimeProvider from "effect-react/RuntimeProvider" +import * as RuntimeContext from "effect-react/RuntimeContext" import { describe, expect, it } from "vitest" interface Foo { @@ -11,7 +12,8 @@ interface Foo { } const foo = Context.Tag() -const { useResultCallback } = RuntimeProvider.makeFromLayer(Layer.succeed(foo, { value: 1 })) +const context = RuntimeContext.fromLayer(Layer.succeed(foo, { value: 1 })) +const useResultCallback = Hooks.makeUseResultCallback(context) describe("useResultCallback", () => { it("should do good", async () => { diff --git a/tsconfig.base.json b/tsconfig.base.json index c4ccf8b..ebd072f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -23,6 +23,7 @@ "noUnusedLocals": true, "noUnusedParameters": false, "noFallthroughCasesInSwitch": true, + "jsx": "react", "noEmitOnError": false, "noErrorTruncation": false, "allowJs": false,