From c0f3073d203da87d16427794c09d3175f326c4fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 1 Jul 2025 13:30:50 +0200 Subject: [PATCH] Hook work --- packages/effect-components/package.json | 4 + packages/effect-components/src/ReactHook.ts | 53 +++++++++- .../src/types/PropertyPath.ts | 99 +++++++++++++++++ .../src/types/SetStateAction.ts | 12 +++ .../src/types/SubscriptionSubRef.ts | 100 ++++++++++++++++++ packages/effect-components/src/types/index.ts | 3 + 6 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 packages/effect-components/src/types/PropertyPath.ts create mode 100644 packages/effect-components/src/types/SetStateAction.ts create mode 100644 packages/effect-components/src/types/SubscriptionSubRef.ts create mode 100644 packages/effect-components/src/types/index.ts diff --git a/packages/effect-components/package.json b/packages/effect-components/package.json index a9a21be..cef1827 100644 --- a/packages/effect-components/package.json +++ b/packages/effect-components/package.json @@ -16,6 +16,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./types": { + "types": "./dist/types/index.d.ts", + "default": "./dist/types/index.js" + }, "./*": { "types": "./dist/*.d.ts", "default": "./dist/*.js" diff --git a/packages/effect-components/src/ReactHook.ts b/packages/effect-components/src/ReactHook.ts index 5ccfa08..2ef928e 100644 --- a/packages/effect-components/src/ReactHook.ts +++ b/packages/effect-components/src/ReactHook.ts @@ -1,5 +1,6 @@ -import { type Context, Effect, ExecutionStrategy, Exit, type Layer, pipe, Runtime, Scope, Stream, SubscriptionRef } from "effect" +import { type Context, Effect, ExecutionStrategy, Exit, type Layer, pipe, Ref, Runtime, Scope, Stream, SubscriptionRef } from "effect" import * as React from "react" +import { SetStateAction } from "./types/index.js" export interface ScopeOptions { @@ -39,6 +40,34 @@ export const useMemoLayer: { return yield* useMemo(() => Effect.provide(Effect.context(), layer), [layer]) }) + +export const useCallbackSync: { + ( + callback: (...args: Args) => Effect.Effect, + deps: React.DependencyList, + ): Effect.Effect<(...args: Args) => A, never, R> +} = Effect.fnUntraced(function* ( + callback: (...args: Args) => Effect.Effect, + deps: React.DependencyList, +) { + const runtime = yield* Effect.runtime() + return React.useCallback((...args: Args) => Runtime.runSync(runtime)(callback(...args)), deps) +}) + +export const useCallbackPromise: { + ( + callback: (...args: Args) => Effect.Effect, + deps: React.DependencyList, + ): Effect.Effect<(...args: Args) => Promise, never, R> +} = Effect.fnUntraced(function* ( + callback: (...args: Args) => Effect.Effect, + deps: React.DependencyList, +) { + const runtime = yield* Effect.runtime() + return React.useCallback((...args: Args) => Runtime.runPromise(runtime)(callback(...args)), deps) +}) + + export const useEffect: { ( effect: () => Effect.Effect, @@ -138,6 +167,7 @@ export const useFork: { }, deps) }) + export const useSubscribeRefs: { []>( ...refs: Refs @@ -159,3 +189,24 @@ export const useSubscribeRefs: { return reactStateValue as any }) + +export const useRefState: { + ( + ref: SubscriptionRef.SubscriptionRef + ): Effect.Effect>]> +} = Effect.fnUntraced(function* (ref: SubscriptionRef.SubscriptionRef) { + const [reactStateValue, setReactStateValue] = React.useState(yield* useOnce(() => ref)) + + yield* useFork(() => Stream.runForEach( + Stream.changesWith(ref.changes, (x, y) => x === y), + v => Effect.sync(() => setReactStateValue(v)), + ), [ref]) + + const setValue = yield* useCallbackSync((setStateAction: React.SetStateAction) => + Ref.update(ref, prevState => + SetStateAction.value(setStateAction, prevState) + ), + [ref]) + + return [reactStateValue, setValue] +}) diff --git a/packages/effect-components/src/types/PropertyPath.ts b/packages/effect-components/src/types/PropertyPath.ts new file mode 100644 index 0000000..dda0959 --- /dev/null +++ b/packages/effect-components/src/types/PropertyPath.ts @@ -0,0 +1,99 @@ +import { Array, Function, Option, Predicate } from "effect" + + +type Prev = readonly [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + +export type Paths = [] | ( + D extends never ? [] : + T extends Seen ? [] : + T extends readonly any[] ? ArrayPaths : + T extends object ? ObjectPaths : + never +) + +export type ArrayPaths = { + [K in keyof T as K extends number ? K : never]: + | [K] + | [K, ...Paths] +} extends infer O + ? O[keyof O] + : never + +export type ObjectPaths = { + [K in keyof T as K extends string | number | symbol ? K : never]-?: + NonNullable extends infer V + ? [K] | [K, ...Paths] + : never +} extends infer O + ? O[keyof O] + : never + +export type ValueFromPath = P extends [infer Head, ...infer Tail] + ? Head extends keyof T + ? ValueFromPath + : T extends readonly any[] + ? Head extends number + ? ValueFromPath + : never + : never + : T + +export type AnyKey = string | number | symbol +export type AnyPath = readonly AnyKey[] + + +export const unsafeGet: { + >(path: P): (self: T) => ValueFromPath + >(self: T, path: P): ValueFromPath +} = Function.dual(2, >(self: T, path: P): ValueFromPath => + path.reduce((acc: any, key: any) => acc?.[key], self) +) + +export const get: { + >(path: P): (self: T) => Option.Option> + >(self: T, path: P): Option.Option> +} = Function.dual(2, >(self: T, path: P): Option.Option> => + path.reduce( + (acc: Option.Option, key: any): Option.Option => Option.isSome(acc) + ? Predicate.hasProperty(acc.value, key) + ? Option.some(acc.value[key]) + : Option.none() + : acc, + + Option.some(self), + ) +) + +export const immutableSet: { + >(path: P, value: ValueFromPath): (self: T) => ValueFromPath + >(self: T, path: P, value: ValueFromPath): Option.Option +} = Function.dual(3, >(self: T, path: P, value: ValueFromPath): Option.Option => { + const key = Array.head(path as AnyPath) + if (Option.isNone(key)) + return Option.some(value as T) + if (!Predicate.hasProperty(self, key.value)) + return Option.none() + + const child = immutableSet(self[key.value], Option.getOrThrow(Array.tail(path as AnyPath)), value) + if (Option.isNone(child)) + return child + + if (Array.isArray(self)) + return typeof key.value === "number" + ? Option.some([ + ...self.slice(0, key.value), + child.value, + ...self.slice(key.value + 1), + ] as T) + : Option.none() + + if (typeof self === "object") + return Option.some( + Object.assign( + Object.create(Object.getPrototypeOf(self)), + { ...self, [key.value]: child.value }, + ) + ) + + return Option.none() +}) diff --git a/packages/effect-components/src/types/SetStateAction.ts b/packages/effect-components/src/types/SetStateAction.ts new file mode 100644 index 0000000..10a596b --- /dev/null +++ b/packages/effect-components/src/types/SetStateAction.ts @@ -0,0 +1,12 @@ +import { Function } from "effect" +import type * as React from "react" + + +export const value: { + (prevState: S): (self: React.SetStateAction) => S + (self: React.SetStateAction, prevState: S): S +} = Function.dual(2, (self: React.SetStateAction, prevState: S): S => + typeof self === "function" + ? (self as (prevState: S) => S)(prevState) + : self +) diff --git a/packages/effect-components/src/types/SubscriptionSubRef.ts b/packages/effect-components/src/types/SubscriptionSubRef.ts new file mode 100644 index 0000000..b32f45d --- /dev/null +++ b/packages/effect-components/src/types/SubscriptionSubRef.ts @@ -0,0 +1,100 @@ +import { Effect, Effectable, Option, Readable, Ref, Stream, Subscribable, SubscriptionRef, SynchronizedRef, type Types, type Unify } from "effect" +import * as PropertyPath from "./PropertyPath.js" + + +export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("reffuse/types/SubscriptionSubRef") +export type SubscriptionSubRefTypeId = typeof SubscriptionSubRefTypeId + +export interface SubscriptionSubRef extends SubscriptionSubRef.Variance, SubscriptionRef.SubscriptionRef { + readonly parent: SubscriptionRef.SubscriptionRef + + readonly [Unify.typeSymbol]?: unknown + readonly [Unify.unifySymbol]?: SubscriptionSubRefUnify + readonly [Unify.ignoreSymbol]?: SubscriptionSubRefUnifyIgnore +} + +export declare namespace SubscriptionSubRef { + export interface Variance { + readonly [SubscriptionSubRefTypeId]: { + readonly _A: Types.Invariant + readonly _B: Types.Invariant + } + } +} + +export interface SubscriptionSubRefUnify extends SubscriptionRef.SubscriptionRefUnify { + SubscriptionSubRef?: () => Extract> +} + +export interface SubscriptionSubRefUnifyIgnore extends SubscriptionRef.SubscriptionRefUnifyIgnore { + SubscriptionRef?: true +} + + +const refVariance = { _A: (_: any) => _ } +const synchronizedRefVariance = { _A: (_: any) => _ } +const subscriptionRefVariance = { _A: (_: any) => _ } +const subscriptionSubRefVariance = { _A: (_: any) => _, _B: (_: any) => _ } + +class SubscriptionSubRefImpl extends Effectable.Class implements SubscriptionSubRef { + readonly [Readable.TypeId]: Readable.TypeId = Readable.TypeId + readonly [Subscribable.TypeId]: Subscribable.TypeId = Subscribable.TypeId + readonly [Ref.RefTypeId] = refVariance + readonly [SynchronizedRef.SynchronizedRefTypeId] = synchronizedRefVariance + readonly [SubscriptionRef.SubscriptionRefTypeId] = subscriptionRefVariance + readonly [SubscriptionSubRefTypeId] = subscriptionSubRefVariance + + readonly get: Effect.Effect + + constructor( + readonly parent: SubscriptionRef.SubscriptionRef, + readonly getter: (parentValue: B) => A, + readonly setter: (parentValue: B, value: A) => B, + ) { + super() + this.get = Effect.map(Ref.get(this.parent), this.getter) + } + + commit() { + return this.get + } + + get changes(): Stream.Stream { + return this.get.pipe( + Effect.map(a => this.parent.changes.pipe( + Stream.map(this.getter), + s => Stream.concat(Stream.make(a), s), + )), + Stream.unwrap, + ) + } + + modify(f: (a: A) => readonly [C, A]): Effect.Effect { + return this.modifyEffect(a => Effect.succeed(f(a))) + } + + modifyEffect(f: (a: A) => Effect.Effect): Effect.Effect { + return Effect.Do.pipe( + Effect.bind("b", () => Ref.get(this.parent)), + Effect.bind("ca", ({ b }) => f(this.getter(b))), + Effect.tap(({ b, ca: [, a] }) => Ref.set(this.parent, this.setter(b, a))), + Effect.map(({ ca: [c] }) => c), + ) + } +} + + +export const makeFromGetSet = ( + parent: SubscriptionRef.SubscriptionRef, + getter: (parentValue: B) => A, + setter: (parentValue: B, value: A) => B, +): SubscriptionSubRef => new SubscriptionSubRefImpl(parent, getter, setter) + +export const makeFromPath = >( + parent: SubscriptionRef.SubscriptionRef, + path: P, +): SubscriptionSubRef, B> => new SubscriptionSubRefImpl( + parent, + parentValue => Option.getOrThrow(PropertyPath.get(parentValue, path)), + (parentValue, value) => Option.getOrThrow(PropertyPath.immutableSet(parentValue, path, value)), +) diff --git a/packages/effect-components/src/types/index.ts b/packages/effect-components/src/types/index.ts new file mode 100644 index 0000000..e8b2633 --- /dev/null +++ b/packages/effect-components/src/types/index.ts @@ -0,0 +1,3 @@ +export * as PropertyPath from "./PropertyPath.js" +export * as SetStateAction from "./SetStateAction.js" +export * as SubscriptionSubRef from "./SubscriptionSubRef.js"