From 922b963809f6881babce34e82e133ce78f3a260d Mon Sep 17 00:00:00 2001 From: Yuval Datner <22598347+datner@users.noreply.github.com> Date: Sun, 17 Sep 2023 19:04:07 +0300 Subject: [PATCH 1/5] feat: add useValue and useService simpler hooks for simpler folk Signed-off-by: Yuval Datner <22598347+datner@users.noreply.github.com> --- src/RuntimeProvider.ts | 33 +++++++++++++++++++++++++--- src/internal/hooks/useService.ts | 14 ++++++++++++ src/internal/hooks/useValue.ts | 18 ++++++++++++++++ test/hooks/useService.ts | 19 ++++++++++++++++ test/hooks/useValue.ts | 37 ++++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 src/internal/hooks/useService.ts create mode 100644 src/internal/hooks/useValue.ts create mode 100644 test/hooks/useService.ts create mode 100644 test/hooks/useValue.ts diff --git a/src/RuntimeProvider.ts b/src/RuntimeProvider.ts index f6982d9..1843ea6 100644 --- a/src/RuntimeProvider.ts +++ b/src/RuntimeProvider.ts @@ -1,4 +1,5 @@ "use client" +import type * as Context from "@effect/data/Context" import type { LazyArg } from "@effect/data/Function" import { pipe } from "@effect/data/Function" import * as Effect from "@effect/io/Effect" @@ -8,6 +9,8 @@ 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 * as internalUseService from "effect-react/internal/hooks/useService" +import * as internalUseValue from "effect-react/internal/hooks/useValue" import type * as ResultBag from "effect-react/ResultBag" import type { DependencyList } from "react" import { createContext } from "react" @@ -35,6 +38,22 @@ export type UseResultCallback = , R0 extends R, E, A> f: (...args: Args) => Stream.Stream ) => readonly [ResultBag.ResultBag, (...args: Args) => void] +/** + * @since 1.0.0 + * @category hooks + */ +export type UseValue = ( + stream: Stream.Stream, + initial: A, + deps: DependencyList +) => A + +/** + * @since 1.0.0 + * @category hooks + */ +export type UseService = >(tag: Tag) => Context.Tag.Service + /** * @since 1.0.0 * @category models @@ -43,6 +62,8 @@ export interface ReactEffectBag { readonly RuntimeContext: React.Context> readonly useResultCallback: UseResultCallback readonly useResult: UseResult + readonly useValue: UseValue + readonly useService: UseService } /** @@ -65,7 +86,9 @@ export const makeFromLayer = ( return { RuntimeContext, useResultCallback: internalUseResultCallback.make(RuntimeContext), - useResult: internalUseResult.make(RuntimeContext) + useResult: internalUseResult.make(RuntimeContext), + useValue: internalUseValue.make(RuntimeContext), + useService: internalUseService.make(RuntimeContext) } } @@ -81,7 +104,9 @@ export const makeFromRuntime = ( return { RuntimeContext, useResultCallback: internalUseResultCallback.make(RuntimeContext), - useResult: internalUseResult.make(RuntimeContext) + useResult: internalUseResult.make(RuntimeContext), + useValue: internalUseValue.make(RuntimeContext), + useService: internalUseService.make(RuntimeContext) } } @@ -95,6 +120,8 @@ export const makeFromRuntimeContext = ( return { RuntimeContext, useResultCallback: internalUseResultCallback.make(RuntimeContext), - useResult: internalUseResult.make(RuntimeContext) + useResult: internalUseResult.make(RuntimeContext), + useValue: internalUseValue.make(RuntimeContext), + useService: internalUseService.make(RuntimeContext) } } diff --git a/src/internal/hooks/useService.ts b/src/internal/hooks/useService.ts new file mode 100644 index 0000000..3c2721e --- /dev/null +++ b/src/internal/hooks/useService.ts @@ -0,0 +1,14 @@ +import * as Context from "@effect/data/Context" +import type * as RuntimeProvider from "effect-react/RuntimeProvider" +import { useContext, useRef } from "react" + +export const make: ( + runtimeContext: RuntimeProvider.RuntimeContext +) => RuntimeProvider.UseService = (runtimeContext: RuntimeProvider.RuntimeContext) => { + return >(tag: Tag) => { + const runtime = useContext(runtimeContext) + const serviceRef = useRef(Context.get(runtime.context, tag)) + + return serviceRef.current + } +} diff --git a/src/internal/hooks/useValue.ts b/src/internal/hooks/useValue.ts new file mode 100644 index 0000000..ce4dcdd --- /dev/null +++ b/src/internal/hooks/useValue.ts @@ -0,0 +1,18 @@ +import * as Option from "@effect/data/Option" +import type * as Stream from "@effect/stream/Stream" +import * as internalUseResult from "effect-react/internal/hooks/useResult" +import * as Result from "effect-react/Result" +import type * as RuntimeProvider from "effect-react/RuntimeProvider" +import { type DependencyList, useRef } from "react" + +export const make: ( + runtimeContext: RuntimeProvider.RuntimeContext +) => RuntimeProvider.UseValue = (runtimeContext: RuntimeProvider.RuntimeContext) => { + const useResult = internalUseResult.make(runtimeContext) + return (stream: Stream.Stream, initial: A, deps: DependencyList) => { + const { result } = useResult(() => stream, deps) + const initialRef = useRef(initial) + + return Option.getOrElse(Result.getValue(result), () => initialRef.current) + } +} diff --git a/test/hooks/useService.ts b/test/hooks/useService.ts new file mode 100644 index 0000000..e3173a1 --- /dev/null +++ b/test/hooks/useService.ts @@ -0,0 +1,19 @@ +import * as Context from "@effect/data/Context" +import * as Layer from "@effect/io/Layer" +import { renderHook } from "@testing-library/react" +import * as RuntimeProvider from "effect-react/RuntimeProvider" +import { describe, expect, it } from "vitest" + +interface Foo { + value: number +} +const foo = Context.Tag() + +const { useService } = RuntimeProvider.makeFromLayer(Layer.succeed(foo, { value: 1 })) + +describe("useService", () => { + it("should get service", async () => { + const { result } = renderHook(() => useService(foo)) + expect(result.current.value).toBe(1) + }) +}) diff --git a/test/hooks/useValue.ts b/test/hooks/useValue.ts new file mode 100644 index 0000000..653d7e3 --- /dev/null +++ b/test/hooks/useValue.ts @@ -0,0 +1,37 @@ +import * as Context from "@effect/data/Context" +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 RuntimeProvider from "effect-react/RuntimeProvider" +import { describe, expect, it } from "vitest" + +interface Foo { + value: number +} +const foo = Context.Tag() + +const { useValue } = RuntimeProvider.makeFromLayer(Layer.succeed(foo, { value: 1 })) + +describe("useValue", () => { + it("should run effects", async () => { + const testEffect = Effect.succeed(1) + const { result } = renderHook(() => useValue(testEffect, 0, [])) + expect(result.current).toBe(0) + await waitFor(() => expect(result.current).toBe(1)) + }) + + it("should provide context", async () => { + const testEffect = Effect.map(foo, (_) => _.value) + const { result } = renderHook(() => useValue(testEffect, 0, [])) + expect(result.current).toBe(0) + await waitFor(() => expect(result.current).toBe(1)) + }) + + it("should run streams", async () => { + const testStream = Stream.succeed(1) + const { result } = renderHook(() => useValue(testStream, 0, [])) + expect(result.current).toBe(0) + await waitFor(() => expect(result.current).toBe(1)) + }) +}) From e8f2cd054c6c463624f6a248a783ab1bc8ee5808 Mon Sep 17 00:00:00 2001 From: Yuval Datner <22598347+datner@users.noreply.github.com> Date: Sun, 17 Sep 2023 19:04:41 +0300 Subject: [PATCH 2/5] test: refine tests also remove some patching around behavior Signed-off-by: Yuval Datner <22598347+datner@users.noreply.github.com> --- test/hooks/useResult.ts | 25 +++++++++++-------- test/hooks/useResultCallback.ts | 44 +++++++++++++++++++++++++++------ 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/test/hooks/useResult.ts b/test/hooks/useResult.ts index 04351c9..33d797a 100644 --- a/test/hooks/useResult.ts +++ b/test/hooks/useResult.ts @@ -17,23 +17,28 @@ const { useResult } = RuntimeProvider.makeFromLayer(Layer.succeed(foo, { value: describe("useResult", () => { it("should run effects", async () => { const testEffect = Effect.succeed(1) - const { result } = await waitFor(async () => renderHook(() => useResult(() => testEffect, []))) - expect(Result.isSuccess(result.current.result)).toBe(true) + const { result } = renderHook(() => useResult(() => testEffect, [])) + expect(Result.isInitial(result.current.result)).toBe(true) + await waitFor(() => expect(Result.isSuccess(result.current.result)).toBe(true)) }) it("should provide context", async () => { const testEffect = Effect.map(foo, (_) => _.value) - const { result } = await waitFor(async () => renderHook(() => useResult(() => testEffect, []))) - await waitFor(() => expect(Result.isSuccess(result.current.result)).toBe(true)) - assert(Result.isSuccess(result.current.result)) - expect(result.current.result.value).toBe(1) + const { result } = renderHook(() => useResult(() => testEffect, [])) + expect(Result.isInitial(result.current.result)).toBe(true) + await waitFor(() => { + assert(Result.isSuccess(result.current.result)) + return expect(result.current.result.value).toBe(1) + }) }) it("should run streams", async () => { const testStream = Stream.succeed(1) - const { result } = await waitFor(async () => renderHook(() => useResult(() => testStream, []))) - await waitFor(() => expect(Result.isSuccess(result.current.result)).toBe(true)) - assert(Result.isSuccess(result.current.result)) - expect(result.current.result.value).toBe(1) + const { result } = renderHook(() => useResult(() => testStream, [])) + expect(Result.isInitial(result.current.result)).toBe(true) + await waitFor(() => { + assert(Result.isSuccess(result.current.result)) + return expect(result.current.result.value).toBe(1) + }) }) }) diff --git a/test/hooks/useResultCallback.ts b/test/hooks/useResultCallback.ts index 48c121a..58b6ab0 100644 --- a/test/hooks/useResultCallback.ts +++ b/test/hooks/useResultCallback.ts @@ -17,20 +17,48 @@ describe("useResultCallback", () => { it("should do good", async () => { const testEffect = (value: number) => Effect.succeed(value) - const { result } = await waitFor(async () => renderHook(() => useResultCallback(testEffect))) + const { result } = renderHook(() => useResultCallback(testEffect)) - expect(Result.isInitial(result.current[0].result)).toBe(true) + assert.isTrue(Result.isInitial(result.current[0].result)) act(() => { result.current[1](1) }) - await waitFor(() => expect(Result.isSuccess(result.current[0].result)).toBe(true)) - assert(Result.isSuccess(result.current[0].result)) - expect(result.current[0].result.value).toBe(1) + await waitFor(() => { + assert(Result.isSuccess(result.current[0].result)) + return expect(result.current[0].result.value).toBe(1) + }) act(() => { result.current[1](2) }) - await waitFor(() => expect(Result.isSuccess(result.current[0].result)).toBe(true)) - assert(Result.isSuccess(result.current[0].result)) - expect(result.current[0].result.value).toBe(2) + await waitFor(() => { + assert(Result.isSuccess(result.current[0].result)) + return expect(result.current[0].result.value).toBe(2) + }) + }) + + it("should do good async", async () => { + const testEffect = (value: number) => + Effect.async((cb) => { + setTimeout(() => cb(Effect.succeed(value)), 100) + }) + const { result } = renderHook(() => useResultCallback(testEffect)) + + assert.isTrue(Result.isInitial(result.current[0].result)) + act(() => { + result.current[1](1) + }) + await waitFor(() => assert.isTrue(result.current[0].isLoading)) + await waitFor(() => { + assert(Result.isSuccess(result.current[0].result)) + return expect(result.current[0].result.value).toBe(1) + }) + act(() => { + result.current[1](2) + }) + await waitFor(() => assert.isTrue(result.current[0].isRefreshing)) + await waitFor(() => { + assert(Result.isSuccess(result.current[0].result)) + return expect(result.current[0].result.value).toBe(2) + }) }) }) From 1ccb727b1bf0976abd227dd7bdbf00696670c5bf Mon Sep 17 00:00:00 2001 From: Yuval Datner <22598347+datner@users.noreply.github.com> Date: Sun, 17 Sep 2023 19:04:53 +0300 Subject: [PATCH 3/5] style: change import order Signed-off-by: Yuval Datner <22598347+datner@users.noreply.github.com> --- test/Result.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Result.ts b/test/Result.ts index 7b6ba9b..c2dae23 100644 --- a/test/Result.ts +++ b/test/Result.ts @@ -3,9 +3,9 @@ import * as O from "@effect/data/Option" import * as S from "@effect/data/String" import * as Cause from "@effect/io/Cause" import * as Exit from "@effect/io/Exit" -import * as fc from "fast-check" import * as Result from "effect-react/Result" import { result } from "effect-react/test/utils/result" +import * as fc from "fast-check" import { inspect } from "util" import { assert, describe, it } from "vitest" From 99680a4358bfa6c7db6758c64eaa0f5a8c35a9f6 Mon Sep 17 00:00:00 2001 From: Yuval Datner <22598347+datner@users.noreply.github.com> Date: Sun, 17 Sep 2023 19:05:05 +0300 Subject: [PATCH 4/5] build: upgrade pnpm lol Signed-off-by: Yuval Datner <22598347+datner@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4006bef..580fdc9 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "require": "./build/cjs/*.js" } }, - "packageManager": "pnpm@8.6.10", + "packageManager": "pnpm@8.7.5", "peerDependencies": { "@effect/data": "^0.18.4", "@effect/io": "^0.40.0", From bc0240bf6b9d149be4b23ddaa15df5d60bd0dd54 Mon Sep 17 00:00:00 2001 From: Yuval Datner <22598347+datner@users.noreply.github.com> Date: Sun, 17 Sep 2023 19:28:25 +0300 Subject: [PATCH 5/5] chore: changeset --- .changeset/brown-moose-draw.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/brown-moose-draw.md diff --git a/.changeset/brown-moose-draw.md b/.changeset/brown-moose-draw.md new file mode 100644 index 0000000..a46cfc5 --- /dev/null +++ b/.changeset/brown-moose-draw.md @@ -0,0 +1,5 @@ +--- +"effect-react": patch +--- + +add useValue and useService