0.1.0 (#1)
All checks were successful
Publish / publish (push) Successful in 21s
Lint / lint (push) Successful in 12s

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: https://gitea:3000/Thilawyn/effect-fc/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
Julien Valverdé
2025-07-17 21:17:57 +02:00
parent 7524094a56
commit 3cb0964a48
52 changed files with 1055 additions and 1507 deletions

View File

@@ -1,4 +1,3 @@
import { VQueryErrorHandler } from "@/VQueryErrorHandler"
import { Container, Flex, Theme } from "@radix-ui/themes"
import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
@@ -14,21 +13,15 @@ export const Route = createRootRoute({
function Root() {
return (
<Theme>
<Container>
<Container mb="4">
<Flex direction="row" justify="center" align="center" gap="2">
<Link to="/">Index</Link>
<Link to="/time">Time</Link>
<Link to="/count">Count</Link>
<Link to="/tests">Tests</Link>
<Link to="/promise">Promise</Link>
<Link to="/query/usequery">Query</Link>
<Link to="/blank">Blank</Link>
</Flex>
</Container>
<Outlet />
<VQueryErrorHandler />
<TanStackRouterDevtools />
</Theme>
)

View File

@@ -1,9 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute('/blank')({
component: RouteComponent,
export const Route = createFileRoute("/blank")({
component: RouteComponent
})
function RouteComponent() {
return <div>Hello "/blank"!</div>
return <div>Hello "/blank"!</div>
}

View File

@@ -1,26 +0,0 @@
import { R } from "@/reffuse"
import { createFileRoute } from "@tanstack/react-router"
import { Effect, Ref } from "effect"
export const Route = createFileRoute("/count")({
component: Count
})
function Count() {
const runSync = R.useRunSync()
const countRef = R.useRef(() => Effect.succeed(0))
const [count] = R.useSubscribeRefs(countRef)
return (
<div className="container mx-auto">
<button onClick={() => runSync(Ref.update(countRef, count => count + 1))}>
count is {count}
</button>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { runtime } from "@/runtime"
import { Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Console, Effect } from "effect"
import { Component, Hook } from "effect-fc"
import * as React from "react"
const RouteComponent = Component.make(function* AsyncRendering() {
const VMemoizedAsyncComponent = yield* Component.useFC(MemoizedAsyncComponent)
const VAsyncComponent = yield* Component.useFC(AsyncComponent)
const [input, setInput] = React.useState("")
return (
<Flex direction="column" align="stretch" gap="2">
<TextField.Root
value={input}
onChange={e => setInput(e.target.value)}
/>
<VMemoizedAsyncComponent />
<VAsyncComponent />
</Flex>
)
}).pipe(
Component.withRuntime(runtime.context)
)
const AsyncComponent = Component.make(function* AsyncComponent() {
yield* Console.log("rendering")
const VSubComponent = yield* Component.useFC(SubComponent)
yield* Effect.sleep("500 millis")
return (
<Flex direction="column" align="stretch">
<Text>Rendered!</Text>
<VSubComponent />
</Flex>
)
}).pipe(
Component.suspense
)
const MemoizedAsyncComponent = Component.memo(AsyncComponent)
const SubComponent = Component.make(function* SubComponent() {
const [state] = React.useState(yield* Hook.useOnce(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
return <Text>{state}</Text>
})
export const Route = createFileRoute("/dev/async-rendering")({
component: RouteComponent
})

View File

@@ -0,0 +1,40 @@
import { runtime } from "@/runtime"
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 } from "effect-fc"
import * as React from "react"
const RouteComponent = Component.make(function* RouteComponent() {
const VSubComponent = yield* Component.useFC(SubComponent)
const VMemoizedSubComponent = yield* Component.useFC(MemoizedSubComponent)
const [value, setValue] = React.useState("")
return (
<Flex direction="column" gap="2">
<TextField.Root
value={value}
onChange={e => setValue(e.target.value)}
/>
<VSubComponent />
<VMemoizedSubComponent />
</Flex>
)
}).pipe(
Component.withRuntime(runtime.context)
)
const SubComponent = Component.make(function* SubComponent() {
const id = yield* makeUuid4.pipe(Effect.provide(GetRandomValues.CryptoRandom))
return <Text>{id}</Text>
})
const MemoizedSubComponent = Component.memo(SubComponent)
export const Route = createFileRoute("/dev/memo")({
component: RouteComponent,
})

View File

@@ -1,105 +0,0 @@
import { Box, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Array, Console, Effect, Layer, pipe, Ref, Runtime, SubscriptionRef } from "effect"
import { ReactComponent, ReactHook, ReactManagedRuntime } from "effect-components"
const LogLive = Layer.scopedDiscard(Effect.acquireRelease(
Console.log("Runtime built."),
() => Console.log("Runtime destroyed."),
))
class TestService extends Effect.Service<TestService>()("TestService", {
effect: Effect.bind(Effect.Do, "ref", () => SubscriptionRef.make("value")),
}) {}
class SubService extends Effect.Service<SubService>()("SubService", {
effect: Effect.bind(Effect.Do, "ref", () => SubscriptionRef.make("subvalue")),
}) {}
const runtime = ReactManagedRuntime.make(Layer.empty.pipe(
Layer.provideMerge(LogLive),
Layer.provideMerge(TestService.Default),
))
export const Route = createFileRoute("/effect-component-tests")({
component: RouteComponent,
})
function RouteComponent() {
return (
<ReactManagedRuntime.AsyncProvider runtime={runtime}>
<MyRoute />
</ReactManagedRuntime.AsyncProvider>
)
}
const MyRoute = pipe(
Effect.fn(function*() {
const runtime = yield* Effect.runtime()
const service = yield* TestService
const [value] = yield* ReactHook.useSubscribeRefs(service.ref)
// const MyTestComponentFC = yield* Effect.provide(
// ReactComponent.useFC(MyTestComponent),
// yield* ReactHook.useMemoLayer(SubService.Default),
// )
return <>
<Box>
<TextField.Root
value={value}
onChange={e => Runtime.runSync(runtime)(Ref.set(service.ref, e.target.value))}
/>
</Box>
{/* {yield* ReactComponent.use(MyTestComponent, C => <C />).pipe(
Effect.provide(yield* ReactHook.useMemoLayer(SubService.Default))
)} */}
{/* {Array.range(0, 3).map(k =>
<MyTestComponentFC key={k} />
)} */}
{yield* pipe(
Array.range(0, 3),
Array.map(k => ReactComponent.use(MyTestComponent, FC =>
<FC key={k} />
)),
Effect.all,
Effect.provide(yield* ReactHook.useMemoLayer(SubService.Default)),
)}
</>
}),
ReactComponent.withDisplayName("MyRoute"),
ReactComponent.withRuntime(runtime.context),
)
const MyTestComponent = pipe(
Effect.fn(function*() {
const runtime = yield* Effect.runtime()
const service = yield* SubService
const [value] = yield* ReactHook.useSubscribeRefs(service.ref)
// yield* ReactHook.useMemo(() => Effect.andThen(
// Effect.addFinalizer(() => Console.log("MyTestComponent umounted")),
// Console.log("MyTestComponent mounted"),
// ), [])
return <>
<Box>
<TextField.Root
value={value}
onChange={e => Runtime.runSync(runtime)(Ref.set(service.ref, e.target.value))}
/>
</Box>
</>
}),
ReactComponent.withDisplayName("MyTestComponent"),
)

View File

@@ -1,10 +1,25 @@
import { runtime } from "@/runtime"
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"
export const Route = createFileRoute('/')({
component: RouteComponent
const TodosStateLive = TodosState.Default("todos")
export const Route = createFileRoute("/")({
component: Component.make(function* Index() {
const context = yield* Hook.useContext(TodosStateLive, { finalizerExecutionMode: "fork" })
return yield* Effect.provide(Component.use(Todos, Todos => <Todos />), context)
}).pipe(
Component.withRuntime(runtime.context)
)
// component: Component.make("Index")(
// () => Hook.useContext(TodosStateLive, { finalizerExecutionMode: "fork" }),
// Effect.andThen(context => Effect.provide(Component.use(Todos, Todos => <Todos />), context)),
// ).pipe(
// Component.withRuntime(runtime.context)
// )
})
function RouteComponent() {
return <div>Hello "/"!</div>
}

View File

@@ -1,31 +0,0 @@
import { R } from "@/reffuse"
import { Button, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import * as LazyRef from "@typed/lazy-ref"
import { Suspense, use } from "react"
export const Route = createFileRoute("/lazyref")({
component: RouteComponent
})
function RouteComponent() {
const promise = R.usePromise(() => LazyRef.of(0), [])
return (
<Suspense fallback={<Text>Loading...</Text>}>
<LazyRefComponent promise={promise} />
</Suspense>
)
}
function LazyRefComponent({ promise }: { readonly promise: Promise<LazyRef.LazyRef<number>> }) {
const ref = use(promise)
const [value, setValue] = R.useLazyRefState(ref)
return (
<Button onClick={() => setValue(prev => prev + 1)}>
{value}
</Button>
)
}

View File

@@ -1,35 +0,0 @@
import { R } from "@/reffuse"
import { HttpClient } from "@effect/platform"
import { Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Schema } from "effect"
import { Suspense, use } from "react"
export const Route = createFileRoute("/promise")({
component: RouteComponent
})
const Result = Schema.Tuple(Schema.String)
type Result = typeof Result.Type
function RouteComponent() {
const promise = R.usePromise(() => Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
Effect.andThen(HttpClient.get("https://www.uuidtools.com/api/generate/v4")),
HttpClient.withTracerPropagation(false),
Effect.flatMap(res => res.json),
Effect.flatMap(Schema.decodeUnknown(Result)),
), [])
return (
<Suspense fallback={<Text>Loading...</Text>}>
<AsyncComponent promise={promise} />
</Suspense>
)
}
function AsyncComponent({ promise }: { readonly promise: Promise<Result> }) {
const [uuid] = use(promise)
return <Text>{uuid}</Text>
}

View File

@@ -1,38 +0,0 @@
import { QueryContext } from "@/query/reffuse"
import { Uuid4Query } from "@/query/services"
import { Uuid4QueryService } from "@/query/views/Uuid4QueryService"
import { R } from "@/reffuse"
import { HttpClient } from "@effect/platform"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Layer, Schema } from "effect"
import { useMemo } from "react"
export const Route = createFileRoute("/query/service")({
component: RouteComponent
})
function RouteComponent() {
const query = R.useQuery({
key: R.useStreamFromReactiveValues(["uuid4", 10 as number]),
query: ([, count]) => Console.log(`Querying ${ count } IDs...`).pipe(
Effect.andThen(Effect.sleep("500 millis")),
Effect.andThen(Effect.map(
HttpClient.HttpClient,
HttpClient.withTracerPropagation(false),
)),
Effect.flatMap(client => client.get(`https://www.uuidtools.com/api/generate/v4/count/${ count }`)),
Effect.flatMap(res => res.json),
Effect.flatMap(Schema.decodeUnknown(Uuid4Query.Result)),
Effect.scoped,
),
})
const layer = useMemo(() => Layer.succeed(Uuid4Query.Uuid4Query, query), [query])
return (
<QueryContext.Provider layer={layer}>
<Uuid4QueryService />
</QueryContext.Provider>
)
}

View File

@@ -1,84 +0,0 @@
import { R } from "@/reffuse"
import { HttpClient } from "@effect/platform"
import { Button, Container, Flex, Slider, Text } from "@radix-ui/themes"
import { QueryProgress } from "@reffuse/extension-query"
import { createFileRoute } from "@tanstack/react-router"
import * as AsyncData from "@typed/async-data"
import { Array, Console, Effect, flow, Option, Schema, Stream } from "effect"
import { useState } from "react"
export const Route = createFileRoute("/query/usemutation")({
component: RouteComponent
})
const Result = Schema.Array(Schema.String)
function RouteComponent() {
const runFork = R.useRunFork()
const [count, setCount] = useState(1)
const mutation = R.useMutation({
mutation: ([count]: readonly [count: number]) => Console.log(`Querying ${ count } IDs...`).pipe(
Effect.andThen(QueryProgress.QueryProgress.update(() =>
AsyncData.Progress.make({ loaded: 0, total: Option.some(100) })
)),
Effect.andThen(Effect.sleep("500 millis")),
Effect.tap(() => QueryProgress.QueryProgress.update(() =>
AsyncData.Progress.make({ loaded: 50, total: Option.some(100) })
)),
Effect.andThen(Effect.map(
HttpClient.HttpClient,
HttpClient.withTracerPropagation(false),
)),
Effect.flatMap(client => client.get(`https://www.uuidtools.com/api/generate/v4/count/${ count }`)),
Effect.flatMap(res => res.json),
Effect.flatMap(Schema.decodeUnknown(Result)),
Effect.scoped,
)
})
const [state] = R.useSubscribeRefs(mutation.stateRef)
return (
<Container>
<Flex direction="column" align="center" gap="2">
<Slider
min={1}
max={100}
value={[count]}
onValueChange={flow(
Array.head,
Option.getOrThrow,
setCount,
)}
/>
<Text>
{AsyncData.match(state, {
NoData: () => "No data yet",
Loading: progress =>
`Loading...
${ Option.match(progress, {
onSome: ({ loaded, total }) => ` (${ loaded }/${ Option.getOrElse(total, () => "unknown") })`,
onNone: () => "",
}) }`,
Success: value => `Value: ${ value }`,
Failure: cause => `Error: ${ cause }`,
})}
</Text>
<Button onClick={() => mutation.forkMutate(count).pipe(
Effect.flatMap(([, state]) => Stream.runForEach(state, Console.log)),
Effect.andThen(Console.log("Mutation done.")),
runFork,
)}>
Get
</Button>
</Flex>
</Container>
)
}

View File

@@ -1,77 +0,0 @@
import { R } from "@/reffuse"
import { HttpClient } from "@effect/platform"
import { Button, Container, Flex, Slider, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import * as AsyncData from "@typed/async-data"
import { Array, Console, Effect, flow, Option, Schema, Stream } from "effect"
import { useState } from "react"
export const Route = createFileRoute("/query/usequery")({
component: RouteComponent
})
const Result = Schema.Array(Schema.String)
function RouteComponent() {
const runFork = R.useRunFork()
const [count, setCount] = useState(1)
const query = R.useQuery({
key: R.useStreamFromReactiveValues(["uuid4", count]),
query: ([, count]) => Console.log(`Querying ${ count } IDs...`).pipe(
Effect.andThen(Effect.sleep("500 millis")),
Effect.andThen(Effect.map(
HttpClient.HttpClient,
HttpClient.withTracerPropagation(false),
)),
Effect.flatMap(client => client.get(`https://www.uuidtools.com/api/generate/v4/count/${ count }`)),
Effect.flatMap(res => res.json),
Effect.flatMap(Schema.decodeUnknown(Result)),
Effect.scoped,
),
})
const [state] = R.useSubscribeRefs(query.stateRef)
return (
<Container>
<Flex direction="column" align="center" gap="2">
<Slider
min={1}
max={100}
value={[count]}
onValueChange={flow(
Array.head,
Option.getOrThrow,
setCount,
)}
/>
<Text>
{AsyncData.match(state, {
NoData: () => "No data yet",
Loading: () => "Loading...",
Success: (value, { isRefreshing, isOptimistic }) =>
`Value: ${value} ${isRefreshing ? "(refreshing)" : ""} ${isOptimistic ? "(optimistic)" : ""}`,
Failure: (cause, { isRefreshing }) =>
`Error: ${cause} ${isRefreshing ? "(refreshing)" : ""}`,
})}
</Text>
<Button
onClick={() => query.forkRefresh.pipe(
Effect.flatMap(([, state]) => Stream.runForEach(state, Console.log)),
Effect.andThen(Console.log("Refresh finished or stopped")),
runFork,
)}
>
Refresh
</Button>
</Flex>
</Container>
)
}

View File

@@ -1,34 +0,0 @@
import { R } from "@/reffuse"
import { Button, Flex, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Chunk, Effect, Exit, Option, Queue, Random, Scope, Stream } from "effect"
import { useMemo, useState } from "react"
export const Route = createFileRoute("/streams/pull")({
component: RouteComponent
})
function RouteComponent() {
const stream = useMemo(() => Stream.repeatEffect(Random.nextInt), [])
const streamScope = R.useScope([stream], { finalizerExecutionMode: "fork" })
const queue = R.useMemo(() => Effect.provideService(Stream.toQueueOfElements(stream), Scope.Scope, streamScope), [streamScope])
const [value, setValue] = useState(Option.none<number>())
const pullLatest = R.useCallbackSync(() => Queue.takeAll(queue).pipe(
Effect.flatMap(Chunk.last),
Effect.flatMap(Exit.matchEffect({
onSuccess: Effect.succeed,
onFailure: Effect.fail,
})),
Effect.tap(v => Effect.sync(() => setValue(Option.some(v)))),
), [queue])
return (
<Flex direction="column" align="center" gap="2">
{Option.isSome(value) && <Text>{value.value}</Text>}
<Button onClick={pullLatest}>Pull latest</Button>
</Flex>
)
}

View File

@@ -1,62 +0,0 @@
import { R } from "@/reffuse"
import { Button, Flex, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Console, Effect, Option } from "effect"
import { useEffect, useState } from "react"
interface Node {
value: string
left?: Leaf
right?: Leaf
}
interface Leaf {
node: Node
}
const makeUuid = Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)
export const Route = createFileRoute("/tests")({
component: RouteComponent
})
function RouteComponent() {
const runSync = R.useRunSync()
const [uuid, setUuid] = useState(R.useMemo(() => makeUuid, []))
const generateUuid = R.useCallbackSync(() => makeUuid.pipe(
Effect.tap(v => Effect.sync(() => setUuid(v)))
), [])
const uuidStream = R.useStreamFromReactiveValues([uuid])
const uuidStreamLatestValue = R.useSubscribeStream(uuidStream)
const [, scopeLayer] = R.useScope([uuid])
useEffect(() => Effect.addFinalizer(() => Console.log("Scope cleanup!")).pipe(
Effect.andThen(Console.log("Scope changed")),
Effect.provide(scopeLayer),
runSync,
), [scopeLayer, runSync])
const nodeRef = R.useRef(() => Effect.succeed<Node>({ value: "prout" }))
const nodeValueRef = R.useSubRefFromPath(nodeRef, ["value"])
return (
<Flex direction="column" justify="center" align="center" gap="2">
<Text>{uuid}</Text>
<Button onClick={generateUuid}>Generate UUID</Button>
<Text>
{Option.match(uuidStreamLatestValue, {
onSome: ([v]) => v,
onNone: () => <></>,
})}
</Text>
</Flex>
)
}

View File

@@ -1,39 +0,0 @@
import { R } from "@/reffuse"
import { createFileRoute } from "@tanstack/react-router"
import { Console, DateTime, Effect, Ref, Schedule, Stream, SubscriptionRef } from "effect"
const timeEverySecond = Stream.repeatEffectWithSchedule(
DateTime.now,
Schedule.intersect(Schedule.forever, Schedule.spaced("1 second")),
)
export const Route = createFileRoute("/time")({
component: Time
})
function Time() {
const timeRef = R.useMemo(() => DateTime.now.pipe(Effect.flatMap(SubscriptionRef.make)), [])
R.useFork(() => Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
Effect.andThen(Stream.runForEach(timeEverySecond, v => Ref.set(timeRef, v)))
), [timeRef])
const [time] = R.useRefState(timeRef)
return (
<div className="container mx-auto">
<p className="text-center">
{DateTime.format(time, {
hour: "numeric",
minute: "numeric",
second: "numeric",
})}
</p>
</div>
)
}

View File

@@ -1,35 +0,0 @@
import { TodosContext } from "@/todos/reffuse"
import { TodosState } from "@/todos/services"
import { VTodos } from "@/todos/views/VTodos"
import { Container } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Layer } from "effect"
import { useMemo } from "react"
export const Route = createFileRoute("/todos")({
component: Todos
})
function Todos() {
const todosLayer = useMemo(() => Layer.empty.pipe(
Layer.provideMerge(TodosState.make("todos")),
Layer.merge(Layer.effectDiscard(
Effect.addFinalizer(() => Console.log("TodosContext cleaned up")).pipe(
Effect.andThen(Console.log("TodosContext constructed"))
)
)),
), [])
return (
<Container>
<TodosContext.Provider layer={todosLayer} finalizerExecutionMode="fork">
<VTodos />
</TodosContext.Provider>
</Container>
)
}