0.1.3 (#4)
All checks were successful
Publish / publish (push) Successful in 14s
Lint / lint (push) Successful in 11s

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: https://gitea:3000/Thilawyn/effect-fc/pulls/4
This commit was merged in pull request #4.
This commit is contained in:
Julien Valverdé
2025-08-23 03:07:28 +02:00
parent 16fa750b30
commit 831a808568
55 changed files with 1539 additions and 859 deletions

View File

@@ -11,25 +11,26 @@ Documentation is currently being written. In the meantime, you can take a look a
- `react` & `@types/react` 19+
## Known issues
- React Refresh replacement doesn't work for Effect FC's yet. Page reload is required to view changes. Regular React components are unaffected.
- React Refresh doesn't work for Effect FC's yet. Page reload is required to view changes. Regular React components are unaffected.
## What writing components looks like
```typescript
import { Component, Hook, Memoized } from "effect-fc"
import { Component } from "effect-fc"
import { useOnce, useSubscribe } from "effect-fc/hooks"
import { Todo } from "./Todo"
import { TodosState } from "./TodosState.service"
import { runtime } from "@/runtime"
class Todos extends Component.make(function* Todos() {
export class Todos extends Component.makeUntraced(function* Todos() {
const state = yield* TodosState
const [todos] = yield* Hook.useSubscribeRefs(state.ref)
const [todos] = yield* useSubscribe(state.ref)
yield* Hook.useOnce(() => Effect.andThen(
yield* useOnce(() => Effect.andThen(
Console.log("Todos mounted"),
Effect.addFinalizer(() => Console.log("Todos unmounted")),
))
const TodoFC = yield* Component.useFC(Todo)
const TodoFC = yield* Todo
return (
<Container>
@@ -38,22 +39,26 @@ class Todos extends Component.make(function* Todos() {
<Flex direction="column" align="stretch" gap="2" mt="2">
<TodoFC _tag="new" />
{Chunk.map(todos, (v, k) =>
<TodoFC key={v.id} _tag="edit" index={k} />
{Chunk.map(todos, todo =>
<TodoFC key={todo.id} _tag="edit" id={todo.id} />
)}
</Flex>
</Container>
)
}).pipe(
Memoized.memo
) {}
}) {}
const TodosEntrypoint = Component.make(function* TodosEntrypoint() {
const context = yield* Hook.useContext(TodosState.Default, { finalizerExecutionMode: "fork" })
const TodosFC = yield* Effect.provide(Component.useFC(Todos), context)
const TodosStateLive = TodosState.Default("todos")
const Index = Component.makeUntraced(function* Index() {
const context = yield* useContext(TodosStateLive, { finalizerExecutionMode: "fork" })
const TodosFC = yield* Effect.provide(Todos, context)
return <TodosFC />
}).pipe(
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/")({
component: Index
})
```

View File

@@ -1,7 +1,7 @@
{
"name": "effect-fc",
"description": "Write React function components with Effect",
"version": "0.1.2",
"version": "0.1.3",
"type": "module",
"files": [
"./README.md",
@@ -17,6 +17,10 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./hooks": {
"types": "./dist/hooks/index.d.ts",
"default": "./dist/hooks/index.js"
},
"./types": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
@@ -40,6 +44,6 @@
"react": "^19.0.0"
},
"devDependencies": {
"@effect/language-service": "^0.23.3"
"@effect/language-service": "^0.35.2"
}
}

View File

@@ -1,26 +1,41 @@
import { Context, Effect, Effectable, ExecutionStrategy, Function, Predicate, Runtime, Scope, String, Tracer, type Utils } from "effect"
import { Context, Effect, Effectable, ExecutionStrategy, Function, Predicate, Runtime, Scope, String, Tracer, type Types, type Utils } from "effect"
import * as React from "react"
import * as Hook from "./Hook.js"
import * as Memoized from "./Memoized.js"
import { Hooks } from "./hooks/index.js"
import * as Memo from "./Memo.js"
export const TypeId: unique symbol = Symbol.for("effect-fc/Component")
export type TypeId = typeof TypeId
export interface Component<P extends {} = {}, E = never, R = never>
extends Effect.Effect<React.FC<P>, never, Exclude<R, Scope.Scope>>, Component.Options {
export interface Component<P extends {}, A extends React.ReactNode, E, R>
extends
Effect.Effect<(props: P) => A, never, Exclude<R, Scope.Scope>>,
Component.Options
{
new(_: never): {}
readonly [TypeId]: TypeId
readonly ["~Props"]: P
readonly ["~Success"]: A
readonly ["~Error"]: E
readonly ["~Context"]: R
/** @internal */
makeFunctionComponent(runtimeRef: React.Ref<Runtime.Runtime<Exclude<R, Scope.Scope>>>, scope: Scope.Scope): React.FC<P>
readonly body: (props: P) => Effect.Effect<A, E, R>
/** @internal */
readonly body: (props: P) => Effect.Effect<React.ReactNode, E, R>
makeFunctionComponent(
runtimeRef: React.Ref<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
scope: Scope.Scope,
): (props: P) => A
}
export namespace Component {
export type Props<T> = T extends Component<infer P, infer _E, infer _R> ? P : never
export type Error<T> = T extends Component<infer _P, infer E, infer _R> ? E : never
export type Context<T> = T extends Component<infer _P, infer _E, infer R> ? R : never
export type Props<T extends Component<any, any, any, any>> = [T] extends [Component<infer P, infer _A, infer _E, infer _R>] ? P : never
export type Success<T extends Component<any, any, any, any>> = [T] extends [Component<infer _P, infer A, infer _E, infer _R>] ? A : never
export type Error<T extends Component<any, any, any, any>> = [T] extends [Component<infer _P, infer _A, infer E, infer _R>] ? E : never
export type Context<T extends Component<any, any, any, any>> = [T] extends [Component<infer _P, infer _A, infer _E, infer R>] ? R : never
export type AsComponent<T extends Component<any, any, any, any>> = Component<Props<T>, Success<T>, Error<T>, Context<T>>
export interface Options {
readonly displayName?: string
@@ -34,13 +49,15 @@ const ComponentProto = Object.freeze({
...Effectable.CommitPrototype,
[TypeId]: TypeId,
commit: Effect.fn("Component")(function* <P extends {}, E, R>(this: Component<P, E, R>) {
commit: Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode, E, R>(
this: Component<P, A, E, R>
) {
const self = this
const runtimeRef = React.useRef<Runtime.Runtime<Exclude<R, Scope.Scope>>>(null!)
runtimeRef.current = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
return React.useCallback(function ScopeProvider(props: P) {
const scope = Runtime.runSync(runtimeRef.current)(Hook.useScope(
return React.useRef(function ScopeProvider(props: P) {
const scope = Runtime.runSync(runtimeRef.current)(Hooks.useScope(
Array.from(
Context.omit(...nonReactiveTags)(runtimeRef.current.context).unsafeMap.values()
),
@@ -48,22 +65,22 @@ const ComponentProto = Object.freeze({
))
const FC = React.useMemo(() => {
const f = self.makeFunctionComponent(runtimeRef, scope)
const f: React.FC<P> = self.makeFunctionComponent(runtimeRef, scope)
f.displayName = self.displayName ?? "Anonymous"
return Memoized.isMemoized(self)
return Memo.isMemo(self)
? React.memo(f, self.propsAreEqual)
: f
}, [scope])
return React.createElement(FC, props)
}, [])
}).current
}),
makeFunctionComponent <P extends {}, E, R>(
this: Component<P, E, R>,
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
this: Component<P, A, E, R>,
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
scope: Scope.Scope,
): React.FC<P> {
) {
return (props: P) => Runtime.runSync(runtimeRef.current)(
Effect.provideService(this.body(props), Scope.Scope, scope)
)
@@ -78,14 +95,14 @@ const defaultOptions = {
const nonReactiveTags = [Tracer.ParentSpan] as const
export const isComponent = (u: unknown): u is Component<{}, unknown, unknown> => Predicate.hasProperty(u, TypeId)
export const isComponent = (u: unknown): u is Component<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, TypeId)
export namespace make {
export type Gen = {
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, P extends {} = {}>(
body: (props: P) => Generator<Eff, React.ReactNode, never>,
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A extends React.ReactNode, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>
): Component<
P,
P, A,
[Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap<Effect.Effect<infer _A, infer E, infer _R>>] ? E : never,
[Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap<Effect.Effect<infer _A, infer _E, infer R>>] ? R : never
>
@@ -98,8 +115,8 @@ export namespace make {
[Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap<Effect.Effect<infer _A, infer _E, infer R>>] ? R : never
>,
props: NoInfer<P>,
) => B
): Component<P, Effect.Effect.Error<B>, Effect.Effect.Context<B>>
) => B,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<B>>, Effect.Effect.Error<B>, Effect.Effect.Context<B>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>,
a: (
@@ -111,7 +128,7 @@ export namespace make {
props: NoInfer<P>,
) => B,
b: (_: B, props: NoInfer<P>) => C,
): Component<P, Effect.Effect.Error<C>, Effect.Effect.Context<C>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<C>>, Effect.Effect.Error<C>, Effect.Effect.Context<C>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>,
a: (
@@ -124,7 +141,7 @@ export namespace make {
) => B,
b: (_: B, props: NoInfer<P>) => C,
c: (_: C, props: NoInfer<P>) => D,
): Component<P, Effect.Effect.Error<D>, Effect.Effect.Context<D>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<D>>, Effect.Effect.Error<D>, Effect.Effect.Context<D>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>,
a: (
@@ -138,7 +155,7 @@ export namespace make {
b: (_: B, props: NoInfer<P>) => C,
c: (_: C, props: NoInfer<P>) => D,
d: (_: D, props: NoInfer<P>) => E,
): Component<P, Effect.Effect.Error<E>, Effect.Effect.Context<E>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<E>>, Effect.Effect.Error<E>, Effect.Effect.Context<E>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>,
a: (
@@ -153,7 +170,7 @@ export namespace make {
c: (_: C, props: NoInfer<P>) => D,
d: (_: D, props: NoInfer<P>) => E,
e: (_: E, props: NoInfer<P>) => F,
): Component<P, Effect.Effect.Error<F>, Effect.Effect.Context<F>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<F>>, Effect.Effect.Error<F>, Effect.Effect.Context<F>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>,
a: (
@@ -169,7 +186,7 @@ export namespace make {
d: (_: D, props: NoInfer<P>) => E,
e: (_: E, props: NoInfer<P>) => F,
f: (_: F, props: NoInfer<P>) => G,
): Component<P, Effect.Effect.Error<G>, Effect.Effect.Context<G>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<G>>, Effect.Effect.Error<G>, Effect.Effect.Context<G>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G, H extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>,
a: (
@@ -186,7 +203,7 @@ export namespace make {
e: (_: E, props: NoInfer<P>) => F,
f: (_: F, props: NoInfer<P>) => G,
g: (_: G, props: NoInfer<P>) => H,
): Component<P, Effect.Effect.Error<H>, Effect.Effect.Context<H>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<H>>, Effect.Effect.Error<H>, Effect.Effect.Context<H>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G, H, I extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>,
a: (
@@ -204,7 +221,7 @@ export namespace make {
f: (_: F, props: NoInfer<P>) => G,
g: (_: G, props: NoInfer<P>) => H,
h: (_: H, props: NoInfer<P>) => I,
): Component<P, Effect.Effect.Error<I>, Effect.Effect.Context<I>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<I>>, Effect.Effect.Error<I>, Effect.Effect.Context<I>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G, H, I, J extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>,
a: (
@@ -223,35 +240,35 @@ export namespace make {
g: (_: G, props: NoInfer<P>) => H,
h: (_: H, props: NoInfer<P>) => I,
i: (_: I, props: NoInfer<P>) => J,
): Component<P, Effect.Effect.Error<J>, Effect.Effect.Context<J>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<J>>, Effect.Effect.Error<J>, Effect.Effect.Context<J>>
}
export type NonGen = {
<Eff extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Eff
): Component<P, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, P extends {} = {}>(
body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, P extends {} = {}>(
body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B,
b: (_: B, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, P extends {} = {}>(
body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B,
b: (_: B, props: NoInfer<P>) => C,
c: (_: C, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, P extends {} = {}>(
body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B,
b: (_: B, props: NoInfer<P>) => C,
c: (_: C, props: NoInfer<P>) => D,
d: (_: D, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, P extends {} = {}>(
body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B,
@@ -259,7 +276,7 @@ export namespace make {
c: (_: C, props: NoInfer<P>) => D,
d: (_: D, props: NoInfer<P>) => E,
e: (_: E, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, P extends {} = {}>(
body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B,
@@ -268,7 +285,7 @@ export namespace make {
d: (_: D, props: NoInfer<P>) => E,
e: (_: E, props: NoInfer<P>) => F,
f: (_: F, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, G, P extends {} = {}>(
body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B,
@@ -278,7 +295,7 @@ export namespace make {
e: (_: E, props: NoInfer<P>) => F,
f: (_: F, props: NoInfer<P>) => G,
g: (_: G, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, G, H, P extends {} = {}>(
body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B,
@@ -289,7 +306,7 @@ export namespace make {
f: (_: F, props: NoInfer<P>) => G,
g: (_: G, props: NoInfer<P>) => H,
h: (_: H, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, G, H, I, P extends {} = {}>(
body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B,
@@ -301,7 +318,7 @@ export namespace make {
g: (_: G, props: NoInfer<P>) => H,
h: (_: H, props: NoInfer<P>) => I,
i: (_: I, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
}
}
@@ -351,14 +368,14 @@ export const makeUntraced: make.Gen & make.NonGen = (
const displayNameFromBody = (body: Function) => !String.isEmpty(body.name) ? body.name : undefined
export const withOptions: {
<T extends Component<any, any, any>>(
<T extends Component<any, any, any, any>>(
options: Partial<Component.Options>
): (self: T) => T
<T extends Component<any, any, any>>(
<T extends Component<any, any, any, any>>(
self: T,
options: Partial<Component.Options>,
): T
} = Function.dual(2, <T extends Component<any, any, any>>(
} = Function.dual(2, <T extends Component<any, any, any, any>>(
self: T,
options: Partial<Component.Options>,
): T => Object.setPrototypeOf(
@@ -367,17 +384,17 @@ export const withOptions: {
))
export const withRuntime: {
<P extends {}, E, R>(
<P extends {}, A extends React.ReactNode, E, R>(
context: React.Context<Runtime.Runtime<R>>,
): (self: Component<P, E, R>) => React.FC<P>
<P extends {}, E, R>(
self: Component<P, E, R>,
): (self: Component<P, A, E, Types.NoInfer<R>>) => (props: P) => A
<P extends {}, A extends React.ReactNode, E, R>(
self: Component<P, A, E, Types.NoInfer<R>>,
context: React.Context<Runtime.Runtime<R>>,
): React.FC<P>
} = Function.dual(2, <P extends {}, E, R>(
self: Component<P, E, R>,
): (props: P) => A
} = Function.dual(2, <P extends {}, A extends React.ReactNode, E, R>(
self: Component<P, A, E, R>,
context: React.Context<Runtime.Runtime<R>>,
): React.FC<P> => function WithRuntime(props) {
) => function WithRuntime(props: P) {
return React.createElement(
Runtime.runSync(React.useContext(context))(self),
props,

View File

@@ -1,302 +0,0 @@
import { type Context, Effect, Equivalence, ExecutionStrategy, Exit, type Layer, Option, pipe, PubSub, Ref, Runtime, Scope, Stream, SubscriptionRef } from "effect"
import * as React from "react"
import { SetStateAction } from "./types/index.js"
export interface ScopeOptions {
readonly finalizerExecutionMode?: "sync" | "fork"
readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy
}
export const useScope: {
(
deps: React.DependencyList,
options?: ScopeOptions,
): Effect.Effect<Scope.Scope>
} = Effect.fn("useScope")(function*(deps, options) {
const runtime = yield* Effect.runtime()
const [isInitialRun, initialScope] = React.useMemo(() => Runtime.runSync(runtime)(Effect.all([
Ref.make(true),
Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential),
])), [])
const [scope, setScope] = React.useState(initialScope)
React.useEffect(() => Runtime.runSync(runtime)(
Effect.if(isInitialRun, {
onTrue: () => Effect.as(
Ref.set(isInitialRun, false),
() => closeScope(scope, runtime, options),
),
onFalse: () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential).pipe(
Effect.tap(scope => Effect.sync(() => setScope(scope))),
Effect.map(scope => () => closeScope(scope, runtime, options)),
),
})
), deps)
return scope
})
const closeScope = (
scope: Scope.CloseableScope,
runtime: Runtime.Runtime<never>,
options?: ScopeOptions,
) => {
switch (options?.finalizerExecutionMode ?? "sync") {
case "sync":
Runtime.runSync(runtime)(Scope.close(scope, Exit.void))
break
case "fork":
Runtime.runFork(runtime)(Scope.close(scope, Exit.void))
break
}
}
export const useCallbackSync: {
<Args extends unknown[], A, E, R>(
callback: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
): Effect.Effect<(...args: Args) => A, never, R>
} = Effect.fn("useCallbackSync")(function* <Args extends unknown[], A, E, R>(
callback: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
) {
const runtime = yield* Effect.runtime<R>()
return React.useCallback((...args: Args) => Runtime.runSync(runtime)(callback(...args)), deps)
})
export const useCallbackPromise: {
<Args extends unknown[], A, E, R>(
callback: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
): Effect.Effect<(...args: Args) => Promise<A>, never, R>
} = Effect.fn("useCallbackPromise")(function* <Args extends unknown[], A, E, R>(
callback: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
) {
const runtime = yield* Effect.runtime<R>()
return React.useCallback((...args: Args) => Runtime.runPromise(runtime)(callback(...args)), deps)
})
export const useMemo: {
<A, E, R>(
factory: () => Effect.Effect<A, E, R>,
deps: React.DependencyList,
): Effect.Effect<A, E, R>
} = Effect.fn("useMemo")(function* <A, E, R>(
factory: () => Effect.Effect<A, E, R>,
deps: React.DependencyList,
) {
const runtime = yield* Effect.runtime()
return yield* React.useMemo(() => Runtime.runSync(runtime)(Effect.cached(factory())), deps)
})
export const useOnce: {
<A, E, R>(factory: () => Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
} = Effect.fn("useOnce")(function* <A, E, R>(
factory: () => Effect.Effect<A, E, R>
) {
return yield* useMemo(factory, [])
})
export const useEffect: {
<E, R>(
effect: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: ScopeOptions,
): Effect.Effect<void, never, Exclude<R, Scope.Scope>>
} = Effect.fn("useEffect")(function* <E, R>(
effect: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: ScopeOptions,
) {
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
React.useEffect(() => Effect.Do.pipe(
Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)),
Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(effect(), Scope.Scope, scope))),
Effect.map(({ scope }) =>
() => closeScope(scope, runtime, options)
),
Runtime.runSync(runtime),
), deps)
})
export const useLayoutEffect: {
<E, R>(
effect: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: ScopeOptions,
): Effect.Effect<void, never, Exclude<R, Scope.Scope>>
} = Effect.fn("useLayoutEffect")(function* <E, R>(
effect: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: ScopeOptions,
) {
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
React.useLayoutEffect(() => Effect.Do.pipe(
Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)),
Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(effect(), Scope.Scope, scope))),
Effect.map(({ scope }) =>
() => closeScope(scope, runtime, options)
),
Runtime.runSync(runtime),
), deps)
})
export const useFork: {
<E, R>(
effect: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: Runtime.RunForkOptions & ScopeOptions,
): Effect.Effect<void, never, Exclude<R, Scope.Scope>>
} = Effect.fn("useFork")(function* <E, R>(
effect: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: Runtime.RunForkOptions & ScopeOptions,
) {
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
React.useEffect(() => {
const scope = Runtime.runSync(runtime)(options?.scope
? Scope.fork(options.scope, options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
: Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
)
Runtime.runFork(runtime)(Effect.provideService(effect(), Scope.Scope, scope), { ...options, scope })
return () => closeScope(scope, runtime, {
...options,
finalizerExecutionMode: options?.finalizerExecutionMode ?? "fork",
})
}, deps)
})
export const useContext: {
<ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>,
options?: ScopeOptions,
): Effect.Effect<Context.Context<ROut>, E, Exclude<RIn, Scope.Scope>>
} = Effect.fn("useContext")(function* <ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>,
options?: ScopeOptions,
) {
const scope = yield* useScope([layer], options)
return yield* useMemo(() => Effect.provideService(
Effect.provide(Effect.context<ROut>(), layer),
Scope.Scope,
scope,
), [scope])
})
export const useRefFromReactiveValue: {
<A>(value: A): Effect.Effect<SubscriptionRef.SubscriptionRef<A>>
} = Effect.fn("useRefFromReactiveValue")(function*(value) {
const ref = yield* useOnce(() => SubscriptionRef.make(value))
yield* useEffect(() => Ref.set(ref, value), [value])
return ref
})
export const useSubscribeRefs: {
<const Refs extends readonly SubscriptionRef.SubscriptionRef<any>[]>(
...refs: Refs
): Effect.Effect<{ [K in keyof Refs]: Effect.Effect.Success<Refs[K]> }>
} = Effect.fn("useSubscribeRefs")(function* <const Refs extends readonly SubscriptionRef.SubscriptionRef<any>[]>(
...refs: Refs
) {
const [reactStateValue, setReactStateValue] = React.useState(yield* useOnce(() =>
Effect.all(refs as readonly SubscriptionRef.SubscriptionRef<any>[])
))
yield* useFork(() => pipe(
refs.map(ref => Stream.changesWith(ref.changes, Equivalence.strict())),
streams => Stream.zipLatestAll(...streams),
Stream.runForEach(v =>
Effect.sync(() => setReactStateValue(v))
),
), refs)
return reactStateValue as any
})
export const useRefState: {
<A>(
ref: SubscriptionRef.SubscriptionRef<A>
): Effect.Effect<readonly [A, React.Dispatch<React.SetStateAction<A>>]>
} = Effect.fn("useRefState")(function* <A>(ref: SubscriptionRef.SubscriptionRef<A>) {
const [reactStateValue, setReactStateValue] = React.useState(yield* useOnce(() => ref))
yield* useFork(() => Stream.runForEach(
Stream.changesWith(ref.changes, Equivalence.strict()),
v => Effect.sync(() => setReactStateValue(v)),
), [ref])
const setValue = yield* useCallbackSync((setStateAction: React.SetStateAction<A>) =>
Ref.update(ref, prevState =>
SetStateAction.value(setStateAction, prevState)
),
[ref])
return [reactStateValue, setValue]
})
export const useStreamFromReactiveValues: {
<const A extends React.DependencyList>(
values: A
): Effect.Effect<Stream.Stream<A>, never, Scope.Scope>
} = Effect.fn("useStreamFromReactiveValues")(function* <const A extends React.DependencyList>(values: A) {
const { latest, pubsub, stream } = yield* useOnce(() => Effect.Do.pipe(
Effect.bind("latest", () => Ref.make(values)),
Effect.bind("pubsub", () => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown)),
Effect.let("stream", ({ latest, pubsub }) => latest.pipe(
Effect.flatMap(a => Effect.map(
Stream.fromPubSub(pubsub, { scoped: true }),
s => Stream.concat(Stream.make(a), s),
)),
Stream.unwrapScoped,
)),
))
yield* useEffect(() => Ref.set(latest, values).pipe(
Effect.andThen(PubSub.publish(pubsub, values)),
Effect.unlessEffect(PubSub.isShutdown(pubsub)),
), values)
return stream
})
export const useSubscribeStream: {
<A, E, R>(
stream: Stream.Stream<A, E, R>
): Effect.Effect<Option.Option<A>, never, R>
<A extends NonNullable<unknown>, E, R>(
stream: Stream.Stream<A, E, R>,
initialValue: A,
): Effect.Effect<Option.Some<A>, never, R>
} = Effect.fn("useSubscribeStream")(function* <A extends NonNullable<unknown>, E, R>(
stream: Stream.Stream<A, E, R>,
initialValue?: A,
) {
const [reactStateValue, setReactStateValue] = React.useState(
React.useMemo(() => initialValue
? Option.some(initialValue)
: Option.none(),
[])
)
yield* useFork(() => Stream.runForEach(
Stream.changesWith(stream, Equivalence.strict()),
v => Effect.sync(() => setReactStateValue(Option.some(v))),
), [stream])
return reactStateValue as Option.Some<A>
})

View File

@@ -0,0 +1,50 @@
import { type Equivalence, Function, Predicate } from "effect"
import type * as Component from "./Component.js"
export const TypeId: unique symbol = Symbol.for("effect-fc/Memo")
export type TypeId = typeof TypeId
export interface Memo<P> extends Memo.Options<P> {
readonly [TypeId]: TypeId
}
export namespace Memo {
export interface Options<P> {
readonly propsAreEqual?: Equivalence.Equivalence<P>
}
}
const MemoProto = Object.freeze({
[TypeId]: TypeId
} as const)
export const isMemo = (u: unknown): u is Memo<unknown> => Predicate.hasProperty(u, TypeId)
export const memo = <T extends Component.Component<any, any, any, any>>(
self: T
): T & Memo<Component.Component.Props<T>> => Object.setPrototypeOf(
Object.assign(function() {}, self),
Object.freeze(Object.setPrototypeOf(
Object.assign({}, MemoProto),
Object.getPrototypeOf(self),
)),
)
export const withOptions: {
<T extends Component.Component<any, any, any, any> & Memo<any>>(
options: Partial<Memo.Options<Component.Component.Props<T>>>
): (self: T) => T
<T extends Component.Component<any, any, any, any> & Memo<any>>(
self: T,
options: Partial<Memo.Options<Component.Component.Props<T>>>,
): T
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memo<any>>(
self: T,
options: Partial<Memo.Options<Component.Component.Props<T>>>,
): T => Object.setPrototypeOf(
Object.assign(function() {}, self, options),
Object.getPrototypeOf(self),
))

View File

@@ -1,47 +0,0 @@
import { type Equivalence, Function, Predicate } from "effect"
import type * as Component from "./Component.js"
export const TypeId: unique symbol = Symbol.for("effect-fc/Memoized")
export type TypeId = typeof TypeId
export interface Memoized<P> extends Memoized.Options<P> {
readonly [TypeId]: TypeId
}
export namespace Memoized {
export interface Options<P> {
readonly propsAreEqual?: Equivalence.Equivalence<P>
}
}
const MemoizedProto = Object.freeze({
[TypeId]: TypeId
} as const)
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, TypeId)
export const memo = <T extends Component.Component<any, any, any>>(
self: T
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
Object.assign(function() {}, self, MemoizedProto),
Object.getPrototypeOf(self),
)
export const withOptions: {
<T extends Component.Component<any, any, any> & Memoized<any>>(
options: Partial<Memoized.Options<Component.Component.Props<T>>>
): (self: T) => T
<T extends Component.Component<any, any, any> & Memoized<any>>(
self: T,
options: Partial<Memoized.Options<Component.Component.Props<T>>>,
): T
} = Function.dual(2, <T extends Component.Component<any, any, any> & Memoized<any>>(
self: T,
options: Partial<Memoized.Options<Component.Component.Props<T>>>,
): T => Object.setPrototypeOf(
Object.assign(function() {}, self, options),
Object.getPrototypeOf(self),
))

View File

@@ -1,47 +0,0 @@
import { Effect, type Layer, ManagedRuntime, type Runtime } from "effect"
import * as React from "react"
export interface ReactManagedRuntime<R, ER> {
readonly runtime: ManagedRuntime.ManagedRuntime<R, ER>
readonly context: React.Context<Runtime.Runtime<R>>
}
export const make = <R, ER>(
layer: Layer.Layer<R, ER>,
memoMap?: Layer.MemoMap,
): ReactManagedRuntime<R, ER> => ({
runtime: ManagedRuntime.make(layer, memoMap),
context: React.createContext<Runtime.Runtime<R>>(null!),
})
export interface ProviderProps<R, ER> extends React.SuspenseProps {
readonly runtime: ReactManagedRuntime<R, ER>
readonly children?: React.ReactNode
}
export function Provider<R, ER>(
{ runtime, children, ...suspenseProps }: ProviderProps<R, ER>
): React.ReactNode {
const promise = React.useMemo(() => Effect.runPromise(runtime.runtime.runtimeEffect), [runtime])
return React.createElement(
React.Suspense,
suspenseProps,
React.createElement(ProviderInner<R, ER>, { runtime, promise, children }),
)
}
interface ProviderInnerProps<R, ER> {
readonly runtime: ReactManagedRuntime<R, ER>
readonly promise: Promise<Runtime.Runtime<R>>
readonly children?: React.ReactNode
}
function ProviderInner<R, ER>(
{ runtime, promise, children }: ProviderInnerProps<R, ER>
): React.ReactNode {
const value = React.use(promise)
return React.createElement(runtime.context, { value }, children)
}

View File

@@ -0,0 +1,65 @@
import { Effect, type Layer, ManagedRuntime, Predicate, type Runtime } from "effect"
import * as React from "react"
export const TypeId: unique symbol = Symbol.for("effect-fc/ReactRuntime")
export type TypeId = typeof TypeId
export interface ReactRuntime<R, ER> {
new(_: never): {}
readonly [TypeId]: TypeId
readonly runtime: ManagedRuntime.ManagedRuntime<R, ER>
readonly context: React.Context<Runtime.Runtime<R>>
}
const ReactRuntimeProto = Object.freeze({ [TypeId]: TypeId } as const)
export const isReactRuntime = (u: unknown): u is ReactRuntime<unknown, unknown> => Predicate.hasProperty(u, TypeId)
export const make = <R, ER>(
layer: Layer.Layer<R, ER>,
memoMap?: Layer.MemoMap,
): ReactRuntime<R, ER> => Object.setPrototypeOf(
Object.assign(function() {}, {
runtime: ManagedRuntime.make(layer, memoMap),
context: React.createContext<Runtime.Runtime<R>>(null!),
}),
ReactRuntimeProto,
)
export namespace Provider {
export interface Props<R, ER> extends React.SuspenseProps {
readonly runtime: ReactRuntime<R, ER>
readonly children?: React.ReactNode
}
}
export const Provider = <R, ER>(
{ runtime, children, ...suspenseProps }: Provider.Props<R, ER>
): React.ReactNode => {
const promise = React.useMemo(() => Effect.runPromise(runtime.runtime.runtimeEffect), [runtime])
return React.createElement(
React.Suspense,
suspenseProps,
React.createElement(ProviderInner<R, ER>, { runtime, promise, children }),
)
}
namespace ProviderInner {
export interface Props<R, ER> {
readonly runtime: ReactRuntime<R, ER>
readonly promise: Promise<Runtime.Runtime<R>>
readonly children?: React.ReactNode
}
}
const ProviderInner = <R, ER>(
{ runtime, promise, children }: ProviderInner.Props<R, ER>
): React.ReactNode => React.createElement(
runtime.context,
{ value: React.use(promise) },
children,
)

View File

@@ -1,7 +1,6 @@
import { Effect, Function, Predicate, Runtime, Scope } from "effect"
import * as React from "react"
import type * as Component from "./Component.js"
import type { ExcludeKeys } from "./utils.js"
export const TypeId: unique symbol = Symbol.for("effect-fc/Suspense")
@@ -22,16 +21,17 @@ export namespace Suspense {
const SuspenseProto = Object.freeze({
[TypeId]: TypeId,
makeFunctionComponent(
this: Component.Component<any, any, any> & Suspense,
runtimeRef: React.RefObject<Runtime.Runtime<any>>,
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
this: Component.Component<P, A, E, R> & Suspense,
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
scope: Scope.Scope,
): React.FC<any> {
) {
const SuspenseInner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
return ({ fallback, name, ...props }: Suspense.Props) => {
const promise = Runtime.runPromise(runtimeRef.current)(
Effect.provideService(this.body(props), Scope.Scope, scope)
Effect.provideService(this.body(props as P), Scope.Scope, scope)
)
return React.createElement(
@@ -46,26 +46,34 @@ const SuspenseProto = Object.freeze({
export const isSuspense = (u: unknown): u is Suspense => Predicate.hasProperty(u, TypeId)
export const suspense = <T extends Component.Component<P, any, any>, P extends {}>(
self: T & Component.Component<ExcludeKeys<P, keyof Suspense.Props>, any, any>
export const suspense = <T extends Component.Component<any, any, any, any>>(
self: T
): (
& Omit<T, keyof Component.Component<P, Component.Component.Error<T>, Component.Component.Context<T>>>
& Component.Component<P & Suspense.Props, Component.Component.Error<T>, Component.Component.Context<T>>
& Omit<T, keyof Component.Component.AsComponent<T>>
& Component.Component<
Component.Component.Props<T> & Suspense.Props,
Component.Component.Success<T>,
Component.Component.Error<T>,
Component.Component.Context<T>
>
& Suspense
) => Object.setPrototypeOf(
Object.assign(function() {}, self, SuspenseProto),
Object.getPrototypeOf(self),
Object.assign(function() {}, self),
Object.freeze(Object.setPrototypeOf(
Object.assign({}, SuspenseProto),
Object.getPrototypeOf(self),
)),
)
export const withOptions: {
<T extends Component.Component<any, any, any> & Suspense>(
<T extends Component.Component<any, any, any, any> & Suspense>(
options: Partial<Suspense.Options>
): (self: T) => T
<T extends Component.Component<any, any, any> & Suspense>(
<T extends Component.Component<any, any, any, any> & Suspense>(
self: T,
options: Partial<Suspense.Options>,
): T
} = Function.dual(2, <T extends Component.Component<any, any, any> & Suspense>(
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Suspense>(
self: T,
options: Partial<Suspense.Options>,
): T => Object.setPrototypeOf(

View File

@@ -0,0 +1,7 @@
import type { ExecutionStrategy } from "effect"
export interface ScopeOptions {
readonly finalizerExecutionMode?: "sync" | "fork"
readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy
}

View File

@@ -0,0 +1,16 @@
export * from "./input/index.js"
export * from "./ScopeOptions.js"
export * from "./useCallbackPromise.js"
export * from "./useCallbackSync.js"
export * from "./useContext.js"
export * from "./useEffect.js"
export * from "./useFork.js"
export * from "./useLayoutEffect.js"
export * from "./useMemo.js"
export * from "./useOnce.js"
export * from "./useRefFromState.js"
export * from "./useRefState.js"
export * from "./useScope.js"
export * from "./useStreamFromReactiveValues.js"
export * from "./useSubscribe.js"
export * from "./useSubscribeStream.js"

View File

@@ -0,0 +1,2 @@
export * from "./useInput.js"
export * from "./useOptionalInput.js"

View File

@@ -0,0 +1,67 @@
import { type Duration, Effect, Equal, Equivalence, flow, identity, Option, type ParseResult, Ref, Schema, Stream, SubscriptionRef } from "effect"
import * as React from "react"
import { useFork } from "../useFork.js"
import { useOnce } from "../useOnce.js"
import { useRefState } from "../useRefState.js"
export namespace useInput {
export interface Options<A, R> {
readonly schema: Schema.Schema<A, string, R>
readonly equivalence?: Equivalence.Equivalence<A>
readonly ref: SubscriptionRef.SubscriptionRef<A>
readonly debounce?: Duration.DurationInput
}
export interface Result {
readonly value: string
readonly setValue: React.Dispatch<React.SetStateAction<string>>
readonly error: Option.Option<ParseResult.ParseError>
}
}
export const useInput: {
<A, R>(options: useInput.Options<A, R>): Effect.Effect<useInput.Result, ParseResult.ParseError, R>
} = Effect.fnUntraced(function* <A, R>(options: useInput.Options<A, R>) {
const internalRef = yield* useOnce(() => options.ref.pipe(
Effect.andThen(Schema.encode(options.schema)),
Effect.andThen(SubscriptionRef.make),
))
const [error, setError] = React.useState(Option.none<ParseResult.ParseError>())
yield* useFork(() => Effect.all([
// Sync the upstream state with the internal state
// Only mutate the internal state if the upstream value is actually different. This avoids infinite re-render loops.
Stream.runForEach(Stream.changesWith(options.ref.changes, Equivalence.strict()), upstreamValue =>
Effect.whenEffect(
Effect.andThen(
Schema.encode(options.schema)(upstreamValue),
encodedUpstreamValue => Ref.set(internalRef, encodedUpstreamValue),
),
internalRef.pipe(
Effect.andThen(Schema.decode(options.schema)),
Effect.andThen(decodedInternalValue => !(options.equivalence ?? Equal.equals)(upstreamValue, decodedInternalValue)),
Effect.catchTag("ParseError", () => Effect.succeed(false)),
),
)
),
// Sync all changes to the internal state with upstream
Stream.runForEach(
internalRef.changes.pipe(
Stream.changesWith(Equivalence.strict()),
options.debounce ? Stream.debounce(options.debounce) : identity,
Stream.drop(1),
),
flow(
Schema.decode(options.schema),
Effect.andThen(v => Ref.set(options.ref, v)),
Effect.andThen(() => setError(Option.none())),
Effect.catchTag("ParseError", e => Effect.sync(() => setError(Option.some(e)))),
),
),
], { concurrency: "unbounded" }), [options.schema, options.equivalence, options.ref, options.debounce, internalRef])
const [value, setValue] = yield* useRefState(internalRef)
return { value, setValue, error }
})

View File

@@ -0,0 +1,107 @@
import { type Duration, Effect, Equal, Equivalence, flow, identity, Option, type ParseResult, Ref, Schema, Stream, SubscriptionRef } from "effect"
import * as React from "react"
import { SetStateAction } from "../../../types/index.js"
import { useCallbackSync } from "../useCallbackSync.js"
import { useFork } from "../useFork.js"
import { useOnce } from "../useOnce.js"
import { useRefState } from "../useRefState.js"
import { useSubscribe } from "../useSubscribe.js"
export namespace useOptionalInput {
export interface Options<A, R> {
readonly schema: Schema.Schema<A, string, R>
readonly defaultValue?: A
readonly equivalence?: Equivalence.Equivalence<A>
readonly ref: SubscriptionRef.SubscriptionRef<Option.Option<A>>
readonly debounce?: Duration.DurationInput
}
export interface Result {
readonly value: string
readonly setValue: React.Dispatch<React.SetStateAction<string>>
readonly enabled: boolean
readonly setEnabled: React.Dispatch<React.SetStateAction<boolean>>
readonly error: Option.Option<ParseResult.ParseError>
}
}
export const useOptionalInput: {
<A, R>(options: useOptionalInput.Options<A, R>): Effect.Effect<useOptionalInput.Result, ParseResult.ParseError, R>
} = Effect.fnUntraced(function* <A, R>(options: useOptionalInput.Options<A, R>) {
const [internalRef, enabledRef] = yield* useOnce(() => Effect.andThen(options.ref, upstreamValue =>
Effect.all([
Effect.andThen(
Option.match(upstreamValue, {
onSome: Schema.encode(options.schema),
onNone: () => options.defaultValue
? Schema.encode(options.schema)(options.defaultValue)
: Effect.succeed(""),
}),
SubscriptionRef.make,
),
SubscriptionRef.make(Option.isSome(upstreamValue)),
])
))
const [error, setError] = React.useState(Option.none<ParseResult.ParseError>())
yield* useFork(() => Effect.all([
// Sync the upstream state with the internal state
// Only mutate the internal state if the upstream value is actually different. This avoids infinite re-render loops.
Stream.runForEach(Stream.changesWith(options.ref.changes, Equivalence.strict()), Option.match({
onSome: upstreamValue => Effect.andThen(
Ref.set(enabledRef, true),
Effect.whenEffect(
Effect.andThen(
Schema.encode(options.schema)(upstreamValue),
encodedUpstreamValue => Ref.set(internalRef, encodedUpstreamValue),
),
internalRef.pipe(
Effect.andThen(Schema.decode(options.schema)),
Effect.andThen(decodedInternalValue => !(options.equivalence ?? Equal.equals)(upstreamValue, decodedInternalValue)),
Effect.catchTag("ParseError", () => Effect.succeed(false)),
),
),
),
onNone: () => Ref.set(enabledRef, false),
})),
// Sync all changes to the internal state with upstream
Stream.runForEach(
internalRef.changes.pipe(
Stream.changesWith(Equivalence.strict()),
options.debounce ? Stream.debounce(options.debounce) : identity,
Stream.drop(1),
),
flow(
Schema.decode(options.schema),
Effect.andThen(v => Ref.set(options.ref, Option.some(v))),
Effect.andThen(() => setError(Option.none())),
Effect.catchTag("ParseError", e => Effect.sync(() => setError(Option.some(e)))),
),
),
], { concurrency: "unbounded" }), [options.schema, options.equivalence, options.ref, options.debounce, internalRef])
const setEnabled = yield* useCallbackSync(
(setStateAction: React.SetStateAction<boolean>) => Effect.andThen(
Ref.updateAndGet(enabledRef, prevState => SetStateAction.value(setStateAction, prevState)),
enabled => enabled
? internalRef.pipe(
Effect.andThen(Schema.decode(options.schema)),
Effect.andThen(v => Ref.set(options.ref, Option.some(v))),
Effect.andThen(() => setError(Option.none())),
Effect.catchTag("ParseError", e => Effect.sync(() => setError(Option.some(e)))),
)
: Ref.set(options.ref, Option.none()),
),
[options.schema, options.ref, internalRef, enabledRef],
)
const [enabled] = yield* useSubscribe(enabledRef)
const [value, setValue] = yield* useRefState(internalRef)
return { value, setValue, enabled, setEnabled, error }
})

View File

@@ -0,0 +1,18 @@
import { Exit, Runtime, Scope } from "effect"
import type { ScopeOptions } from "./ScopeOptions.js"
export const closeScope = (
scope: Scope.CloseableScope,
runtime: Runtime.Runtime<never>,
options?: ScopeOptions,
) => {
switch (options?.finalizerExecutionMode ?? "sync") {
case "sync":
Runtime.runSync(runtime)(Scope.close(scope, Exit.void))
break
case "fork":
Runtime.runFork(runtime)(Scope.close(scope, Exit.void))
break
}
}

View File

@@ -0,0 +1,18 @@
import { Effect, Runtime } from "effect"
import * as React from "react"
export const useCallbackPromise: {
<Args extends unknown[], A, E, R>(
callback: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
): Effect.Effect<(...args: Args) => Promise<A>, never, R>
} = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
callback: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
) {
const runtimeRef = React.useRef<Runtime.Runtime<R>>(null!)
runtimeRef.current = yield* Effect.runtime<R>()
return React.useCallback((...args: Args) => Runtime.runPromise(runtimeRef.current)(callback(...args)), deps)
})

View File

@@ -0,0 +1,18 @@
import { Effect, Runtime } from "effect"
import * as React from "react"
export const useCallbackSync: {
<Args extends unknown[], A, E, R>(
callback: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
): Effect.Effect<(...args: Args) => A, never, R>
} = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
callback: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
) {
const runtimeRef = React.useRef<Runtime.Runtime<R>>(null!)
runtimeRef.current = yield* Effect.runtime<R>()
return React.useCallback((...args: Args) => Runtime.runSync(runtimeRef.current)(callback(...args)), deps)
})

View File

@@ -0,0 +1,25 @@
import { type Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
import type { ScopeOptions } from "./ScopeOptions.js"
import { useMemo } from "./useMemo.js"
import { useScope } from "./useScope.js"
export const useContext: {
<ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>,
options?: ScopeOptions,
): Effect.Effect<Context.Context<ROut>, E, RIn>
} = Effect.fnUntraced(function* <ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>,
options?: ScopeOptions,
) {
const scope = yield* useScope([layer], options)
return yield* useMemo(() => Effect.context<RIn>().pipe(
Effect.map(context => ManagedRuntime.make(Layer.provide(layer, Layer.succeedContext(context)))),
Effect.tap(runtime => Effect.addFinalizer(() => runtime.disposeEffect)),
Effect.andThen(runtime => runtime.runtimeEffect),
Effect.andThen(runtime => runtime.context),
Effect.provideService(Scope.Scope, scope),
), [scope])
})

View File

@@ -0,0 +1,28 @@
import { Effect, ExecutionStrategy, Runtime, Scope } from "effect"
import * as React from "react"
import type { ScopeOptions } from "./ScopeOptions.js"
import { closeScope } from "./internal.js"
export const useEffect: {
<E, R>(
effect: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: ScopeOptions,
): Effect.Effect<void, never, Exclude<R, Scope.Scope>>
} = Effect.fnUntraced(function* <E, R>(
effect: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: ScopeOptions,
) {
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
React.useEffect(() => Effect.Do.pipe(
Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)),
Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(effect(), Scope.Scope, scope))),
Effect.map(({ scope }) =>
() => closeScope(scope, runtime, options)
),
Runtime.runSync(runtime),
), deps)
})

View File

@@ -0,0 +1,31 @@
import { Effect, ExecutionStrategy, Runtime, Scope } from "effect"
import * as React from "react"
import { closeScope } from "./internal.js"
import type { ScopeOptions } from "./ScopeOptions.js"
export const useFork: {
<E, R>(
effect: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: Runtime.RunForkOptions & ScopeOptions,
): Effect.Effect<void, never, Exclude<R, Scope.Scope>>
} = Effect.fnUntraced(function* <E, R>(
effect: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: Runtime.RunForkOptions & ScopeOptions,
) {
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
React.useEffect(() => {
const scope = Runtime.runSync(runtime)(options?.scope
? Scope.fork(options.scope, options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
: Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
)
Runtime.runFork(runtime)(Effect.provideService(effect(), Scope.Scope, scope), { ...options, scope })
return () => closeScope(scope, runtime, {
...options,
finalizerExecutionMode: options?.finalizerExecutionMode ?? "fork",
})
}, deps)
})

View File

@@ -0,0 +1,28 @@
import { Effect, ExecutionStrategy, Runtime, Scope } from "effect"
import * as React from "react"
import type { ScopeOptions } from "./ScopeOptions.js"
import { closeScope } from "./internal.js"
export const useLayoutEffect: {
<E, R>(
effect: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: ScopeOptions,
): Effect.Effect<void, never, Exclude<R, Scope.Scope>>
} = Effect.fnUntraced(function* <E, R>(
effect: () => Effect.Effect<void, E, R>,
deps?: React.DependencyList,
options?: ScopeOptions,
) {
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
React.useLayoutEffect(() => Effect.Do.pipe(
Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)),
Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(effect(), Scope.Scope, scope))),
Effect.map(({ scope }) =>
() => closeScope(scope, runtime, options)
),
Runtime.runSync(runtime),
), deps)
})

View File

@@ -0,0 +1,16 @@
import { Effect, Runtime } from "effect"
import * as React from "react"
export const useMemo: {
<A, E, R>(
factory: () => Effect.Effect<A, E, R>,
deps: React.DependencyList,
): Effect.Effect<A, E, R>
} = Effect.fnUntraced(function* <A, E, R>(
factory: () => Effect.Effect<A, E, R>,
deps: React.DependencyList,
) {
const runtime = yield* Effect.runtime()
return yield* React.useMemo(() => Runtime.runSync(runtime)(Effect.cached(factory())), deps)
})

View File

@@ -0,0 +1,11 @@
import { Effect } from "effect"
import { useMemo } from "./useMemo.js"
export const useOnce: {
<A, E, R>(factory: () => Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
} = Effect.fnUntraced(function* <A, E, R>(
factory: () => Effect.Effect<A, E, R>
) {
return yield* useMemo(factory, [])
})

View File

@@ -0,0 +1,20 @@
import { Effect, Equivalence, Ref, Stream, SubscriptionRef } from "effect"
import type * as React from "react"
import { useEffect } from "./useEffect.js"
import { useFork } from "./useFork.js"
import { useOnce } from "./useOnce.js"
export const useRefFromState: {
<A>(state: readonly [A, React.Dispatch<React.SetStateAction<A>>]): Effect.Effect<SubscriptionRef.SubscriptionRef<A>>
} = Effect.fnUntraced(function*([value, setValue]) {
const ref = yield* useOnce(() => SubscriptionRef.make(value))
yield* useEffect(() => Ref.set(ref, value), [value])
yield* useFork(() => Stream.runForEach(
Stream.changesWith(ref.changes, Equivalence.strict()),
v => Effect.sync(() => setValue(v)),
), [setValue])
return ref
})

View File

@@ -0,0 +1,29 @@
import { Effect, Equivalence, Ref, Stream, SubscriptionRef } from "effect"
import * as React from "react"
import { SetStateAction } from "../../types/index.js"
import { useCallbackSync } from "./useCallbackSync.js"
import { useFork } from "./useFork.js"
import { useOnce } from "./useOnce.js"
export const useRefState: {
<A>(
ref: SubscriptionRef.SubscriptionRef<A>
): Effect.Effect<readonly [A, React.Dispatch<React.SetStateAction<A>>]>
} = Effect.fnUntraced(function* <A>(ref: SubscriptionRef.SubscriptionRef<A>) {
const [reactStateValue, setReactStateValue] = React.useState(yield* useOnce(() => ref))
yield* useFork(() => Stream.runForEach(
Stream.changesWith(ref.changes, Equivalence.strict()),
v => Effect.sync(() => setReactStateValue(v)),
), [ref])
const setValue = yield* useCallbackSync((setStateAction: React.SetStateAction<A>) =>
Effect.andThen(
Ref.updateAndGet(ref, prevState => SetStateAction.value(setStateAction, prevState)),
v => setReactStateValue(v),
),
[ref])
return [reactStateValue, setValue]
})

View File

@@ -0,0 +1,36 @@
import { Effect, ExecutionStrategy, Ref, Runtime, Scope } from "effect"
import * as React from "react"
import type { ScopeOptions } from "./ScopeOptions.js"
import { closeScope } from "./internal.js"
export const useScope: {
(
deps: React.DependencyList,
options?: ScopeOptions,
): Effect.Effect<Scope.Scope>
} = Effect.fnUntraced(function*(deps, options) {
const runtime = yield* Effect.runtime()
const [isInitialRun, initialScope] = React.useMemo(() => Runtime.runSync(runtime)(Effect.all([
Ref.make(true),
Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential),
])), [])
const [scope, setScope] = React.useState(initialScope)
React.useEffect(() => Runtime.runSync(runtime)(
Effect.if(isInitialRun, {
onTrue: () => Effect.as(
Ref.set(isInitialRun, false),
() => closeScope(scope, runtime, options),
),
onFalse: () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential).pipe(
Effect.tap(scope => Effect.sync(() => setScope(scope))),
Effect.map(scope => () => closeScope(scope, runtime, options)),
),
})
), deps)
return scope
})

View File

@@ -0,0 +1,30 @@
import { Effect, PubSub, Ref, type Scope, Stream } from "effect"
import type * as React from "react"
import { useEffect } from "./useEffect.js"
import { useOnce } from "./useOnce.js"
export const useStreamFromReactiveValues: {
<const A extends React.DependencyList>(
values: A
): Effect.Effect<Stream.Stream<A>, never, Scope.Scope>
} = Effect.fnUntraced(function* <const A extends React.DependencyList>(values: A) {
const { latest, pubsub, stream } = yield* useOnce(() => Effect.Do.pipe(
Effect.bind("latest", () => Ref.make(values)),
Effect.bind("pubsub", () => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown)),
Effect.let("stream", ({ latest, pubsub }) => latest.pipe(
Effect.flatMap(a => Effect.map(
Stream.fromPubSub(pubsub, { scoped: true }),
s => Stream.concat(Stream.make(a), s),
)),
Stream.unwrapScoped,
)),
))
yield* useEffect(() => Ref.set(latest, values).pipe(
Effect.andThen(PubSub.publish(pubsub, values)),
Effect.unlessEffect(PubSub.isShutdown(pubsub)),
), values)
return stream
})

View File

@@ -0,0 +1,31 @@
import { Effect, Equivalence, pipe, Stream, type Subscribable } from "effect"
import * as React from "react"
import { useFork } from "./useFork.js"
import { useOnce } from "./useOnce.js"
export const useSubscribe: {
<const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
...elements: T
): Effect.Effect<
{ [K in keyof T]: Effect.Effect.Success<T[K]["get"]> | Stream.Stream.Success<T[K]["changes"]> },
Effect.Effect.Error<T[number]["get"]> | Stream.Stream.Error<T[number]["changes"]>,
Effect.Effect.Context<T[number]["get"]> | Stream.Stream.Context<T[number]["changes"]>
>
} = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
...elements: T
) {
const [reactStateValue, setReactStateValue] = React.useState(yield* useOnce(() =>
Effect.all(elements.map(v => v.get))
))
yield* useFork(() => pipe(
elements.map(ref => Stream.changesWith(ref.changes, Equivalence.strict())),
streams => Stream.zipLatestAll(...streams),
Stream.runForEach(v =>
Effect.sync(() => setReactStateValue(v))
),
), elements)
return reactStateValue as any
})

View File

@@ -0,0 +1,31 @@
import { Effect, Equivalence, Option, Stream } from "effect"
import * as React from "react"
import { useFork } from "./useFork.js"
export const useSubscribeStream: {
<A, E, R>(
stream: Stream.Stream<A, E, R>
): Effect.Effect<Option.Option<A>, never, R>
<A extends NonNullable<unknown>, E, R>(
stream: Stream.Stream<A, E, R>,
initialValue: A,
): Effect.Effect<Option.Some<A>, never, R>
} = Effect.fnUntraced(function* <A extends NonNullable<unknown>, E, R>(
stream: Stream.Stream<A, E, R>,
initialValue?: A,
) {
const [reactStateValue, setReactStateValue] = React.useState(
React.useMemo(() => initialValue
? Option.some(initialValue)
: Option.none(),
[])
)
yield* useFork(() => Stream.runForEach(
Stream.changesWith(stream, Equivalence.strict()),
v => Effect.sync(() => setReactStateValue(Option.some(v))),
), [stream])
return reactStateValue as Option.Some<A>
})

View File

@@ -0,0 +1,2 @@
export * from "./Hooks/index.js"
export * as Hooks from "./Hooks/index.js"

View File

@@ -1,5 +1,4 @@
export * as Component from "./Component.js"
export * as Hook from "./Hook.js"
export * as Memoized from "./Memoized.js"
export * as ReactManagedRuntime from "./ReactManagedRuntime.js"
export * as Memo from "./Memo.js"
export * as ReactRuntime from "./ReactRuntime.js"
export * as Suspense from "./Suspense.js"

View File

@@ -38,8 +38,7 @@ export type ValueFromPath<T, P extends any[]> = P extends [infer Head, ...infer
: never
: T
export type AnyKey = string | number | symbol
export type AnyPath = readonly AnyKey[]
export type AnyPath = readonly PropertyKey[]
export const unsafeGet: {

View File

@@ -0,0 +1,24 @@
import { type Effect, Effectable, Readable, type Stream, Subscribable } from "effect"
class SubscribableImpl<A, E, R>
extends Effectable.Class<A, E, R> implements Subscribable.Subscribable<A, E, R> {
readonly [Readable.TypeId]: Readable.TypeId = Readable.TypeId
readonly [Subscribable.TypeId]: Subscribable.TypeId = Subscribable.TypeId
constructor(
readonly get: Effect.Effect<A, E, R>,
readonly changes: Stream.Stream<A, E, R>,
) {
super()
}
commit() {
return this.get
}
}
export const make = <A, E, R>(values: {
readonly get: Effect.Effect<A, E, R>
readonly changes: Stream.Stream<A, E, R>
}): Subscribable.Subscribable<A, E, R> => new SubscribableImpl(values.get, values.changes)

View File

@@ -1,12 +1,13 @@
import { Chunk, Effect, Effectable, Option, Readable, Ref, Stream, Subscribable, SubscriptionRef, SynchronizedRef, type Types, type Unify } from "effect"
import { Chunk, Effect, Effectable, Option, Predicate, 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("effect-fc/types/SubscriptionSubRef")
export type SubscriptionSubRefTypeId = typeof SubscriptionSubRefTypeId
export interface SubscriptionSubRef<in out A, in out B> extends SubscriptionSubRef.Variance<A, B>, SubscriptionRef.SubscriptionRef<A> {
readonly parent: SubscriptionRef.SubscriptionRef<B>
export interface SubscriptionSubRef<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
extends SubscriptionSubRef.Variance<A, B>, SubscriptionRef.SubscriptionRef<A> {
readonly parent: B
readonly [Unify.typeSymbol]?: unknown
readonly [Unify.unifySymbol]?: SubscriptionSubRefUnify<this>
@@ -36,7 +37,8 @@ const synchronizedRefVariance = { _A: (_: any) => _ }
const subscriptionRefVariance = { _A: (_: any) => _ }
const subscriptionSubRefVariance = { _A: (_: any) => _, _B: (_: any) => _ }
class SubscriptionSubRefImpl<in out A, in out B> extends Effectable.Class<A> implements SubscriptionSubRef<A, B> {
class SubscriptionSubRefImpl<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
extends Effectable.Class<A> implements SubscriptionSubRef<A, B> {
readonly [Readable.TypeId]: Readable.TypeId = Readable.TypeId
readonly [Subscribable.TypeId]: Subscribable.TypeId = Subscribable.TypeId
readonly [Ref.RefTypeId] = refVariance
@@ -47,9 +49,9 @@ class SubscriptionSubRefImpl<in out A, in out B> extends Effectable.Class<A> imp
readonly get: Effect.Effect<A>
constructor(
readonly parent: SubscriptionRef.SubscriptionRef<B>,
readonly getter: (parentValue: B) => A,
readonly setter: (parentValue: B, value: A) => B,
readonly parent: B,
readonly getter: (parentValue: Effect.Effect.Success<B>) => A,
readonly setter: (parentValue: Effect.Effect.Success<B>, value: A) => Effect.Effect.Success<B>,
) {
super()
this.get = Effect.map(this.parent, this.getter)
@@ -60,12 +62,11 @@ class SubscriptionSubRefImpl<in out A, in out B> extends Effectable.Class<A> imp
}
get changes(): Stream.Stream<A> {
return this.get.pipe(
Effect.map(a => this.parent.changes.pipe(
Stream.map(this.getter),
s => Stream.concat(Stream.make(a), s),
)),
Stream.unwrap,
return Stream.unwrap(
Effect.map(this.get, a => Stream.concat(
Stream.make(a),
Stream.map(this.parent.changes, this.getter),
))
)
}
@@ -75,7 +76,7 @@ class SubscriptionSubRefImpl<in out A, in out B> extends Effectable.Class<A> imp
modifyEffect<C, E, R>(f: (a: A) => Effect.Effect<readonly [C, A], E, R>): Effect.Effect<C, E, R> {
return Effect.Do.pipe(
Effect.bind("b", () => Ref.get(this.parent)),
Effect.bind("b", (): Effect.Effect<Effect.Effect.Success<B>> => 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),
@@ -84,28 +85,102 @@ class SubscriptionSubRefImpl<in out A, in out B> extends Effectable.Class<A> imp
}
export const makeFromGetSet = <A, B>(
parent: SubscriptionRef.SubscriptionRef<B>,
export const isSubscriptionSubRef = (u: unknown): u is SubscriptionSubRef<unknown, SubscriptionRef.SubscriptionRef<unknown>> => Predicate.hasProperty(u, SubscriptionSubRefTypeId)
export const makeFromGetSet = <A, B extends SubscriptionRef.SubscriptionRef<any>>(
parent: B,
options: {
readonly get: (parentValue: B) => A
readonly set: (parentValue: B, value: A) => B
readonly get: (parentValue: Effect.Effect.Success<B>) => A
readonly set: (parentValue: Effect.Effect.Success<B>, value: A) => Effect.Effect.Success<B>
},
): SubscriptionSubRef<A, B> => new SubscriptionSubRefImpl(parent, options.get, options.set)
export const makeFromPath = <B, const P extends PropertyPath.Paths<B>>(
parent: SubscriptionRef.SubscriptionRef<B>,
export const makeFromPath = <
B extends SubscriptionRef.SubscriptionRef<any>,
const P extends PropertyPath.Paths<Effect.Effect.Success<B>>,
>(
parent: B,
path: P,
): SubscriptionSubRef<PropertyPath.ValueFromPath<B, P>, B> => new SubscriptionSubRefImpl(
): SubscriptionSubRef<PropertyPath.ValueFromPath<Effect.Effect.Success<B>, P>, B> => new SubscriptionSubRefImpl(
parent,
parentValue => Option.getOrThrow(PropertyPath.get(parentValue, path)),
(parentValue, value) => Option.getOrThrow(PropertyPath.immutableSet(parentValue, path, value)),
)
export const makeFromChunkRef = <A>(
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<A>>,
export const makeFromChunkIndex: {
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
parent: B,
index: number,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
B
>
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
parent: B,
index: number,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
B
>
} = (
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>,
index: number,
): SubscriptionSubRef<A, Chunk.Chunk<A>> => new SubscriptionSubRefImpl(
) => new SubscriptionSubRefImpl(
parent,
parentValue => Chunk.unsafeGet(parentValue, index),
(parentValue, value) => Chunk.replace(parentValue, index, value),
)
) as any
export const makeFromChunkFindFirst: {
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
parent: B,
findFirstPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
B
>
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
parent: B,
findFirstPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
B
>
} = (
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<never>>,
findFirstPredicate: Predicate.Predicate.Any,
) => new SubscriptionSubRefImpl(
parent,
parentValue => Option.getOrThrow(Chunk.findFirst(parentValue, findFirstPredicate)),
(parentValue, value) => Option.getOrThrow(Option.andThen(
Chunk.findFirstIndex(parentValue, findFirstPredicate),
index => Chunk.replace(parentValue, index, value),
)),
) as any
export const makeFromChunkFindLast: {
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
parent: B,
findLastPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
B
>
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
parent: B,
findLastPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
B
>
} = (
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<never>>,
findLastPredicate: Predicate.Predicate.Any,
) => new SubscriptionSubRefImpl(
parent,
parentValue => Option.getOrThrow(Chunk.findLast(parentValue, findLastPredicate)),
(parentValue, value) => Option.getOrThrow(Option.andThen(
Chunk.findLastIndex(parentValue, findLastPredicate),
index => Chunk.replace(parentValue, index, value),
)),
) as any

View File

@@ -1,3 +1,4 @@
export * as PropertyPath from "./PropertyPath.js"
export * as SetStateAction from "./SetStateAction.js"
export * as Subscribable from "./Subscribable.js"
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"

View File

@@ -11,42 +11,37 @@
"preview": "vite preview"
},
"devDependencies": {
"@effect/language-service": "^0.23.4",
"@eslint/js": "^9.26.0",
"@tanstack/react-router": "^1.120.3",
"@tanstack/react-router-devtools": "^1.120.3",
"@tanstack/router-plugin": "^1.120.3",
"@thilawyn/thilaschema": "^0.1.4",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.26.0",
"@effect/language-service": "^0.35.2",
"@eslint/js": "^9.34.0",
"@tanstack/react-router": "^1.131.27",
"@tanstack/react-router-devtools": "^1.131.27",
"@tanstack/router-plugin": "^1.131.27",
"@types/react": "^19.1.11",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.1",
"eslint": "^9.34.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"typescript-eslint": "^8.32.1",
"vite": "^6.3.5"
"globals": "^16.3.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"typescript-eslint": "^8.40.0",
"vite": "^7.1.3"
},
"dependencies": {
"@effect/platform": "^0.82.1",
"@effect/platform-browser": "^0.62.1",
"@effect/platform": "^0.90.6",
"@effect/platform-browser": "^0.70.0",
"@radix-ui/themes": "^3.2.1",
"@typed/async-data": "^0.13.1",
"@typed/id": "^0.17.2",
"@typed/lazy-ref": "^0.3.3",
"effect": "^3.15.1",
"effect": "^3.17.9",
"effect-fc": "workspace:*",
"lucide-react": "^0.510.0",
"mobx": "^6.13.7",
"react-icons": "^5.5.0"
},
"overrides": {
"effect": "^3.15.1",
"@effect/platform": "^0.82.1",
"@effect/platform-browser": "^0.62.1",
"@typed/lazy-ref": "^0.3.3",
"@typed/async-data": "^0.13.1"
"@types/react": "^19.1.11",
"effect": "^3.17.9",
"react": "^19.1.1"
}
}

View File

@@ -1,4 +1,4 @@
import { ThSchema } from "@thilawyn/thilaschema"
import { assertEncodedJsonifiable } from "@/lib/schema"
import { Schema } from "effect"
@@ -14,7 +14,7 @@ export const TodoFromJsonStruct = Schema.Struct({
...Todo.fields,
completedAt: Schema.Option(Schema.DateTimeUtc),
}).pipe(
ThSchema.assertEncodedJsonifiable
assertEncodedJsonifiable
)
export const TodoFromJson = Schema.compose(TodoFromJsonStruct, Todo)

View File

@@ -0,0 +1,40 @@
import { Callout, Flex, TextArea, TextAreaProps } from "@radix-ui/themes"
import { Array, Equivalence, Option, ParseResult, Schema, Struct } from "effect"
import { Component } from "effect-fc"
import { useInput } from "effect-fc/hooks"
import * as React from "react"
export type TextAreaInputProps<A, R> = Omit<useInput.Options<A, R>, "schema" | "equivalence"> & Omit<TextAreaProps, "ref">
export const TextAreaInput = <A, R>(options: {
readonly schema: Schema.Schema<A, string, R>
readonly equivalence?: Equivalence.Equivalence<A>
}): Component.Component<
TextAreaInputProps<A, R>,
React.JSX.Element,
ParseResult.ParseError,
R
> => Component.makeUntraced(function* TextFieldInput(props) {
const input = yield* useInput({ ...options, ...props })
const issue = React.useMemo(() => input.error.pipe(
Option.map(ParseResult.ArrayFormatter.formatErrorSync),
Option.flatMap(Array.head),
), [input.error])
return (
<Flex direction="column" gap="1">
<TextArea
value={input.value}
onChange={e => input.setValue(e.target.value)}
{...Struct.omit(props, "ref")}
/>
{Option.isSome(issue) &&
<Callout.Root color="red" role="alert">
<Callout.Text>{issue.value.message}</Callout.Text>
</Callout.Root>
}
</Flex>
)
})

View File

@@ -0,0 +1,70 @@
import { Callout, Checkbox, Flex, TextField } from "@radix-ui/themes"
import { Array, Equivalence, Option, ParseResult, Schema, Struct } from "effect"
import { Component, Memo } from "effect-fc"
import { useInput, useOptionalInput } from "effect-fc/hooks"
import * as React from "react"
export type TextFieldInputProps<A, R> = (
& Omit<useInput.Options<A, R>, "schema" | "equivalence">
& Omit<TextField.RootProps, "ref">
)
export type TextFieldOptionalInputProps<A, R> = (
& Omit<useOptionalInput.Options<A, R>, "schema" | "equivalence">
& Omit<TextField.RootProps, "ref" | "defaultValue">
)
export const TextFieldInput = <A, R, O extends boolean = false>(options: {
readonly optional?: O
readonly schema: Schema.Schema<A, string, R>
readonly equivalence?: Equivalence.Equivalence<A>
}) => Component.makeUntraced(function* TextFieldInput(props: O extends true
? TextFieldOptionalInputProps<A, R>
: TextFieldInputProps<A, R>
) {
const input: (
| { readonly optional: true } & useOptionalInput.Result
| { readonly optional: false } & useInput.Result
) = options.optional
? {
optional: true,
// eslint-disable-next-line react-hooks/rules-of-hooks
...yield* useOptionalInput({ ...options, ...props as TextFieldOptionalInputProps<A, R> }),
}
: {
optional: false,
// eslint-disable-next-line react-hooks/rules-of-hooks
...yield* useInput({ ...options, ...props as TextFieldInputProps<A, R> }),
}
const issue = React.useMemo(() => input.error.pipe(
Option.map(ParseResult.ArrayFormatter.formatErrorSync),
Option.flatMap(Array.head),
), [input.error])
return (
<Flex direction="column" gap="1">
<Flex direction="row" align="center" gap="1">
{input.optional &&
<Checkbox
checked={input.enabled}
onCheckedChange={checked => input.setEnabled(checked !== "indeterminate" && checked)}
/>
}
<TextField.Root
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={input.optional ? !input.enabled : undefined}
{...Struct.omit(props as TextFieldOptionalInputProps<A, R> | TextFieldInputProps<A, R>, "ref", "defaultValue")}
/>
</Flex>
{(!(input.optional && !input.enabled) && Option.isSome(issue)) &&
<Callout.Root color="red" role="alert">
<Callout.Text>{issue.value.message}</Callout.Text>
</Callout.Root>
}
</Flex>
)
}).pipe(Memo.memo)

View File

@@ -0,0 +1,38 @@
import { DateTime, Effect, Option, ParseResult, Schema } from "effect"
export class DateTimeUtcFromZoned extends Schema.transformOrFail(
Schema.DateTimeZonedFromSelf,
Schema.DateTimeUtcFromSelf,
{
strict: true,
encode: DateTime.setZoneCurrent,
decode: i => ParseResult.succeed(DateTime.toUtc(i)),
},
) {}
export class DateTimeZonedFromUtc extends Schema.transformOrFail(
Schema.DateTimeUtcFromSelf,
Schema.DateTimeZonedFromSelf,
{
strict: true,
encode: a => ParseResult.succeed(DateTime.toUtc(a)),
decode: DateTime.setZoneCurrent,
},
) {}
export class DateTimeUtcFromZonedInput extends Schema.transformOrFail(
Schema.String,
DateTimeUtcFromZoned,
{
strict: true,
encode: a => ParseResult.succeed(DateTime.formatIsoZoned(a).slice(0, 16)),
decode: (i, _, ast) => Effect.flatMap(
DateTime.CurrentTimeZone,
timeZone => Option.match(DateTime.makeZoned(i, { timeZone, adjustForTimeZone: true }), {
onSome: ParseResult.succeed,
onNone: () => ParseResult.fail(new ParseResult.Type(ast, i, `Unable to decode ${JSON.stringify(i)} into a DateTime.Zoned`)),
}),
),
},
) {}

View File

@@ -0,0 +1,2 @@
export * from "./datetime"
export * from "./json"

View File

@@ -0,0 +1,6 @@
import type { Schema } from "effect"
import type { JsonValue } from "type-fest"
export const assertEncodedJsonifiable = <S extends Schema.Schema<A, I, R>, A, I extends JsonValue, R>(schema: S & Schema.Schema<A, I, R>): S => schema
export const assertTypeJsonifiable = <S extends Schema.Schema<A, I, R>, A extends JsonValue, I, R>(schema: S & Schema.Schema<A, I, R>): S => schema

View File

@@ -1,5 +1,5 @@
import { createRouter, RouterProvider } from "@tanstack/react-router"
import { ReactManagedRuntime } from "effect-fc"
import { ReactRuntime } from "effect-fc"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { routeTree } from "./routeTree.gen"
@@ -16,8 +16,8 @@ declare module "@tanstack/react-router" {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReactManagedRuntime.Provider runtime={runtime}>
<ReactRuntime.Provider runtime={runtime}>
<RouterProvider router={router} />
</ReactManagedRuntime.Provider>
</ReactRuntime.Provider>
</StrictMode>
)

View File

@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as BlankRouteImport } from './routes/blank'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DevMemoRouteImport } from './routes/dev/memo'
import { Route as DevInputRouteImport } from './routes/dev/input'
import { Route as DevAsyncRenderingRouteImport } from './routes/dev/async-rendering'
const BlankRoute = BlankRouteImport.update({
@@ -29,6 +30,11 @@ const DevMemoRoute = DevMemoRouteImport.update({
path: '/dev/memo',
getParentRoute: () => rootRouteImport,
} as any)
const DevInputRoute = DevInputRouteImport.update({
id: '/dev/input',
path: '/dev/input',
getParentRoute: () => rootRouteImport,
} as any)
const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({
id: '/dev/async-rendering',
path: '/dev/async-rendering',
@@ -39,12 +45,14 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/input': typeof DevInputRoute
'/dev/memo': typeof DevMemoRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/input': typeof DevInputRoute
'/dev/memo': typeof DevMemoRoute
}
export interface FileRoutesById {
@@ -52,20 +60,33 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/input': typeof DevInputRoute
'/dev/memo': typeof DevMemoRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/blank' | '/dev/async-rendering' | '/dev/memo'
fullPaths:
| '/'
| '/blank'
| '/dev/async-rendering'
| '/dev/input'
| '/dev/memo'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/blank' | '/dev/async-rendering' | '/dev/memo'
id: '__root__' | '/' | '/blank' | '/dev/async-rendering' | '/dev/memo'
to: '/' | '/blank' | '/dev/async-rendering' | '/dev/input' | '/dev/memo'
id:
| '__root__'
| '/'
| '/blank'
| '/dev/async-rendering'
| '/dev/input'
| '/dev/memo'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
BlankRoute: typeof BlankRoute
DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute
DevInputRoute: typeof DevInputRoute
DevMemoRoute: typeof DevMemoRoute
}
@@ -92,6 +113,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DevMemoRouteImport
parentRoute: typeof rootRouteImport
}
'/dev/input': {
id: '/dev/input'
path: '/dev/input'
fullPath: '/dev/input'
preLoaderRoute: typeof DevInputRouteImport
parentRoute: typeof rootRouteImport
}
'/dev/async-rendering': {
id: '/dev/async-rendering'
path: '/dev/async-rendering'
@@ -106,6 +134,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
BlankRoute: BlankRoute,
DevAsyncRenderingRoute: DevAsyncRenderingRoute,
DevInputRoute: DevInputRoute,
DevMemoRoute: DevMemoRoute,
}
export const routeTree = rootRouteImport

View File

@@ -3,12 +3,13 @@ import { Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Effect } from "effect"
import { Component, Hook, Memoized, Suspense } from "effect-fc"
import { Component, Memo, Suspense } from "effect-fc"
import { Hooks } from "effect-fc/hooks"
import * as React from "react"
// Generator version
const RouteComponent = Component.make(function* AsyncRendering() {
const RouteComponent = Component.makeUntraced(function* AsyncRendering() {
const MemoizedAsyncComponentFC = yield* MemoizedAsyncComponent
const AsyncComponentFC = yield* AsyncComponent
const [input, setInput] = React.useState("")
@@ -50,7 +51,7 @@ const RouteComponent = Component.make(function* AsyncRendering() {
// )
class AsyncComponent extends Component.make(function* AsyncComponent() {
class AsyncComponent extends Component.makeUntraced(function* AsyncComponent() {
const SubComponentFC = yield* SubComponent
yield* Effect.sleep("500 millis") // Async operation
@@ -66,10 +67,10 @@ class AsyncComponent extends Component.make(function* AsyncComponent() {
Suspense.suspense,
Suspense.withOptions({ defaultFallback: <p>Loading...</p> }),
) {}
class MemoizedAsyncComponent extends Memoized.memo(AsyncComponent) {}
class MemoizedAsyncComponent extends Memo.memo(AsyncComponent) {}
class SubComponent extends Component.make(function* SubComponent() {
const [state] = React.useState(yield* Hook.useOnce(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
class SubComponent extends Component.makeUntraced(function* SubComponent() {
const [state] = React.useState(yield* Hooks.useOnce(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
return <Text>{state}</Text>
}) {}

View File

@@ -0,0 +1,42 @@
import { TextFieldInput } from "@/lib/input/TextFieldInput"
import { runtime } from "@/runtime"
import { Container } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Schema, SubscriptionRef } from "effect"
import { Component, Memo } from "effect-fc"
import { useInput, useOnce, useRefState } from "effect-fc/hooks"
const IntFromString = Schema.NumberFromString.pipe(Schema.int())
const IntTextFieldInput = TextFieldInput({ schema: IntFromString })
const StringTextFieldInput = TextFieldInput({ schema: Schema.String })
const Input = Component.makeUntraced(function* Input() {
const IntTextFieldInputFC = yield* IntTextFieldInput
const StringTextFieldInputFC = yield* StringTextFieldInput
const intRef1 = yield* useOnce(() => SubscriptionRef.make(0))
const intRef2 = yield* useOnce(() => SubscriptionRef.make(0))
const stringRef = yield* useOnce(() => SubscriptionRef.make(""))
// yield* useFork(() => Stream.runForEach(intRef1.changes, Console.log), [intRef1])
const input2 = yield* useInput({ schema: IntFromString, ref: intRef2 })
const [str, setStr] = yield* useRefState(stringRef)
return (
<Container>
<IntTextFieldInputFC ref={intRef1} />
<StringTextFieldInputFC ref={stringRef} />
<StringTextFieldInputFC ref={stringRef} />
</Container>
)
}).pipe(
Memo.memo,
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/dev/input")({
component: Input,
})

View File

@@ -3,11 +3,11 @@ import { Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Effect } from "effect"
import { Component, Memoized } from "effect-fc"
import { Component, Memo } from "effect-fc"
import * as React from "react"
const RouteComponent = Component.make(function* RouteComponent() {
const RouteComponent = Component.makeUntraced(function* RouteComponent() {
const [value, setValue] = React.useState("")
return (
@@ -25,12 +25,12 @@ const RouteComponent = Component.make(function* RouteComponent() {
Component.withRuntime(runtime.context)
)
class SubComponent extends Component.make(function* SubComponent() {
class SubComponent extends Component.makeUntraced(function* SubComponent() {
const id = yield* makeUuid4.pipe(Effect.provide(GetRandomValues.CryptoRandom))
return <Text>{id}</Text>
}) {}
class MemoizedSubComponent extends Memoized.memo(SubComponent) {}
class MemoizedSubComponent extends Memo.memo(SubComponent) {}
export const Route = createFileRoute("/dev/memo")({
component: RouteComponent,

View File

@@ -3,18 +3,21 @@ import { Todos } from "@/todo/Todos"
import { TodosState } from "@/todo/TodosState.service"
import { createFileRoute } from "@tanstack/react-router"
import { Effect } from "effect"
import { Component, Hook } from "effect-fc"
import { Component } from "effect-fc"
import { useContext } from "effect-fc/hooks"
const TodosStateLive = TodosState.Default("todos")
const Index = Component.makeUntraced(function* Index() {
const context = yield* useContext(TodosStateLive, { finalizerExecutionMode: "fork" })
const TodosFC = yield* Effect.provide(Todos, context)
return <TodosFC />
}).pipe(
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/")({
component: Component.make(function* Index() {
return yield* Todos.pipe(
Effect.map(FC => <FC />),
Effect.provide(yield* Hook.useContext(TodosStateLive, { finalizerExecutionMode: "fork" })),
)
}).pipe(
Component.withRuntime(runtime.context)
)
component: Index
})

View File

@@ -1,14 +1,15 @@
import { FetchHttpClient } from "@effect/platform"
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
import { Layer } from "effect"
import { ReactManagedRuntime } from "effect-fc"
import { DateTime, Layer } from "effect"
import { ReactRuntime } from "effect-fc"
export const AppLive = Layer.empty.pipe(
Layer.provideMerge(DateTime.layerCurrentZoneLocal),
Layer.provideMerge(Clipboard.layer),
Layer.provideMerge(Geolocation.layer),
Layer.provideMerge(Permissions.layer),
Layer.provideMerge(FetchHttpClient.layer),
)
export const runtime = ReactManagedRuntime.make(AppLive)
export const runtime = ReactRuntime.make(AppLive)

View File

@@ -1,14 +1,21 @@
import * as Domain from "@/domain"
import { Box, Button, Flex, IconButton, TextArea } from "@radix-ui/themes"
import { TextAreaInput } from "@/lib/input/TextAreaInput"
import { TextFieldInput } from "@/lib/input/TextFieldInput"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { Box, Button, Flex, IconButton } from "@radix-ui/themes"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Chunk, Effect, Match, Option, Ref, Runtime, SubscriptionRef } from "effect"
import { Component, Hook, Memoized } from "effect-fc"
import { SubscriptionSubRef } from "effect-fc/types"
import { Chunk, DateTime, Effect, Match, Option, Ref, Runtime, Schema, Stream, SubscriptionRef } from "effect"
import { Component, Memo } from "effect-fc"
import { useMemo, useOnce, useSubscribe } from "effect-fc/hooks"
import { Subscribable, SubscriptionSubRef } from "effect-fc/types"
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
import { FaDeleteLeft } from "react-icons/fa6"
import { TodosState } from "./TodosState.service"
const StringTextAreaInput = TextAreaInput({ schema: Schema.String })
const OptionalDateTimeInput = TextFieldInput({ optional: true, schema: DateTimeUtcFromZonedInput })
const makeTodo = makeUuid4.pipe(
Effect.map(id => Domain.Todo.Todo.make({
id,
@@ -20,96 +27,86 @@ const makeTodo = makeUuid4.pipe(
export type TodoProps = (
| { readonly _tag: "new", readonly index?: never }
| { readonly _tag: "edit", readonly index: number }
| { readonly _tag: "new" }
| { readonly _tag: "edit", readonly id: string }
)
export class Todo extends Component.make(function* Todo(props: TodoProps) {
export class Todo extends Component.makeUntraced(function* Todo(props: TodoProps) {
const runtime = yield* Effect.runtime()
const state = yield* TodosState
const [ref, contentRef] = yield* Hook.useMemo(() => Match.value(props).pipe(
Match.tag("new", () => Effect.andThen(makeTodo, SubscriptionRef.make)),
Match.tag("edit", ({ index }) => Effect.succeed(SubscriptionSubRef.makeFromChunkRef(state.ref, index))),
const { ref, indexRef, contentRef, completedAtRef } = yield* useMemo(() => Match.value(props).pipe(
Match.tag("new", () => Effect.Do.pipe(
Effect.bind("ref", () => Effect.andThen(makeTodo, SubscriptionRef.make)),
Effect.let("indexRef", () => Subscribable.make({ get: Effect.succeed(-1), changes: Stream.empty })),
)),
Match.tag("edit", ({ id }) => Effect.Do.pipe(
Effect.let("ref", () => state.getElementRef(id)),
Effect.let("indexRef", () => state.getIndexSubscribable(id)),
)),
Match.exhaustive,
Effect.map(ref => [
ref,
SubscriptionSubRef.makeFromPath(ref, ["content"]),
] as const),
), [props._tag, props.index])
Effect.let("contentRef", ({ ref }) => SubscriptionSubRef.makeFromPath(ref, ["content"])),
Effect.let("completedAtRef", ({ ref }) => SubscriptionSubRef.makeFromPath(ref, ["completedAt"])),
// eslint-disable-next-line react-hooks/exhaustive-deps
), [props._tag, props._tag === "edit" ? props.id : undefined])
const [index, size] = yield* useSubscribe(indexRef, state.sizeSubscribable)
const StringTextAreaInputFC = yield* StringTextAreaInput
const OptionalDateTimeInputFC = yield* OptionalDateTimeInput
const [content, size] = yield* Hook.useSubscribeRefs(contentRef, state.sizeRef)
return (
<Flex direction="column" align="stretch" gap="2">
<Flex direction="row" align="center" gap="2">
<Box flexGrow="1">
<TextArea
value={content}
onChange={e => Runtime.runSync(runtime)(Ref.set(contentRef, e.target.value))}
/>
</Box>
<Flex direction="row" align="center" gap="2">
<Box flexGrow="1">
<Flex direction="column" align="stretch" gap="2">
<StringTextAreaInputFC ref={contentRef} />
{props._tag === "edit" &&
<Flex direction="column" justify="center" align="center" gap="1">
<IconButton
disabled={props.index <= 0}
onClick={() => Runtime.runSync(runtime)(
SubscriptionRef.updateEffect(state.ref, todos => Effect.gen(function*() {
if (props.index <= 0) return yield* Option.none()
return todos.pipe(
Chunk.replace(props.index, yield* Chunk.get(todos, props.index - 1)),
Chunk.replace(props.index - 1, yield* ref),
)
}))
)}
>
<FaArrowUp />
</IconButton>
<Flex direction="row" justify="center" align="center" gap="2">
<OptionalDateTimeInputFC
type="datetime-local"
ref={completedAtRef}
defaultValue={yield* useOnce(() => DateTime.now)}
/>
<IconButton
disabled={props.index >= size - 1}
onClick={() => Runtime.runSync(runtime)(
SubscriptionRef.updateEffect(state.ref, todos => Effect.gen(function*() {
if (props.index >= size - 1) return yield* Option.none()
return todos.pipe(
Chunk.replace(props.index, yield* Chunk.get(todos, props.index + 1)),
Chunk.replace(props.index + 1, yield* ref),
)
}))
)}
>
<FaArrowDown />
</IconButton>
<IconButton
onClick={() => Runtime.runSync(runtime)(
Ref.update(state.ref, Chunk.remove(props.index))
)}
>
<FaDeleteLeft />
</IconButton>
{props._tag === "new" &&
<Button
onClick={() => ref.pipe(
Effect.andThen(todo => Ref.update(state.ref, Chunk.prepend(todo))),
Effect.andThen(makeTodo),
Effect.andThen(todo => Ref.set(ref, todo)),
Runtime.runSync(runtime),
)}
>
Add
</Button>
}
</Flex>
}
</Flex>
</Flex>
</Box>
{props._tag === "new" &&
<Flex direction="row" justify="center">
<Button
onClick={() => ref.pipe(
Effect.andThen(todo => Ref.update(state.ref, Chunk.prepend(todo))),
Effect.andThen(makeTodo),
Effect.andThen(todo => Ref.set(ref, todo)),
Runtime.runSync(runtime),
)}
{props._tag === "edit" &&
<Flex direction="column" justify="center" align="center" gap="1">
<IconButton
disabled={index <= 0}
onClick={() => Runtime.runSync(runtime)(state.moveLeft(props.id))}
>
Add
</Button>
<FaArrowUp />
</IconButton>
<IconButton
disabled={index >= size - 1}
onClick={() => Runtime.runSync(runtime)(state.moveRight(props.id))}
>
<FaArrowDown />
</IconButton>
<IconButton onClick={() => Runtime.runSync(runtime)(state.remove(props.id))}>
<FaDeleteLeft />
</IconButton>
</Flex>
}
</Flex>
)
}).pipe(
Memoized.memo
) {}
}).pipe(Memo.memo) {}

View File

@@ -1,15 +1,16 @@
import { Container, Flex, Heading } from "@radix-ui/themes"
import { Chunk, Console, Effect } from "effect"
import { Component, Hook } from "effect-fc"
import { Component } from "effect-fc"
import { useOnce, useSubscribe } from "effect-fc/hooks"
import { Todo } from "./Todo"
import { TodosState } from "./TodosState.service"
export class Todos extends Component.make(function* Todos() {
export class Todos extends Component.makeUntraced(function* Todos() {
const state = yield* TodosState
const [todos] = yield* Hook.useSubscribeRefs(state.ref)
const [todos] = yield* useSubscribe(state.ref)
yield* Hook.useOnce(() => Effect.andThen(
yield* useOnce(() => Effect.andThen(
Console.log("Todos mounted"),
Effect.addFinalizer(() => Console.log("Todos unmounted")),
))
@@ -23,8 +24,8 @@ export class Todos extends Component.make(function* Todos() {
<Flex direction="column" align="stretch" gap="2" mt="2">
<TodoFC _tag="new" />
{Chunk.map(todos, (v, k) =>
<TodoFC key={v.id} _tag="edit" index={k} />
{Chunk.map(todos, todo =>
<TodoFC key={todo.id} _tag="edit" id={todo.id} />
)}
</Flex>
</Container>

View File

@@ -2,11 +2,11 @@ import { Todo } from "@/domain"
import { KeyValueStore } from "@effect/platform"
import { BrowserKeyValueStore } from "@effect/platform-browser"
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
import { SubscriptionSubRef } from "effect-fc/types"
import { Subscribable, SubscriptionSubRef } from "effect-fc/types"
export class TodosState extends Effect.Service<TodosState>()("TodosState", {
effect: Effect.fn("TodosState")(function*(key: string) {
scoped: Effect.fnUntraced(function*(key: string) {
const kv = yield* KeyValueStore.KeyValueStore
const readFromLocalStorage = Console.log("Reading todos from local storage...").pipe(
@@ -18,7 +18,6 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
onNone: () => Effect.succeed(Chunk.empty()),
}))
)
const saveToLocalStorage = (todos: Chunk.Chunk<Todo.Todo>) => Effect.andThen(
Console.log("Saving todos to local storage..."),
Chunk.isNonEmpty(todos)
@@ -32,8 +31,6 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
)
const ref = yield* SubscriptionRef.make(yield* readFromLocalStorage)
const sizeRef = SubscriptionSubRef.makeFromPath(ref, ["length"])
yield* Effect.forkScoped(ref.changes.pipe(
Stream.debounce("500 millis"),
Stream.runForEach(saveToLocalStorage),
@@ -43,7 +40,54 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
Effect.ignore,
))
return { ref, sizeRef } as const
const sizeSubscribable = Subscribable.make({
get: Effect.andThen(ref, Chunk.size),
get changes() { return Stream.map(ref.changes, Chunk.size) },
})
const getElementRef = (id: string) => SubscriptionSubRef.makeFromChunkFindFirst(ref, v => v.id === id)
const getIndexSubscribable = (id: string) => Subscribable.make({
get: Effect.flatMap(ref, Chunk.findFirstIndex(v => v.id === id)),
get changes() { return Stream.flatMap(ref.changes, Chunk.findFirstIndex(v => v.id === id)) },
})
const moveLeft = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
Effect.bind("previous", ({ index }) => Chunk.get(todos, index - 1)),
Effect.andThen(({ todo, index, previous }) => index > 0
? todos.pipe(
Chunk.replace(index, previous),
Chunk.replace(index - 1, todo),
)
: todos
),
))
const moveRight = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
Effect.bind("next", ({ index }) => Chunk.get(todos, index + 1)),
Effect.andThen(({ todo, index, next }) => index < Chunk.size(todos) - 1
? todos.pipe(
Chunk.replace(index, next),
Chunk.replace(index + 1, todo),
)
: todos
),
))
const remove = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.andThen(
Chunk.findFirstIndex(todos, v => v.id === id),
index => Chunk.remove(todos, index),
))
return {
ref,
sizeSubscribable,
getElementRef,
getIndexSubscribable,
moveLeft,
moveRight,
remove,
} as const
}),
dependencies: [BrowserKeyValueStore.layerLocalStorage],