0.1.1 (#3)
All checks were successful
Publish / publish (push) Successful in 13s
Lint / lint (push) Successful in 9s

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: https://gitea:3000/Thilawyn/reffuse/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
Julien Valverdé
2025-01-22 03:42:21 +01:00
parent 030a032c67
commit 8a9f7ad4c2
13 changed files with 239 additions and 72 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -8,7 +8,7 @@
"clean:node": "rm -rf node_modules" "clean:node": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"npm-check-updates": "^17.1.13", "npm-check-updates": "^17.1.14",
"npm-sort": "^0.0.4", "npm-sort": "^0.0.4",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }

View File

@@ -11,31 +11,32 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.18.0",
"@tanstack/react-router": "^1.95.3", "@tanstack/react-router": "^1.97.3",
"@tanstack/router-devtools": "^1.95.3", "@tanstack/router-devtools": "^1.97.3",
"@tanstack/router-plugin": "^1.95.3", "@tanstack/router-plugin": "^1.97.3",
"@thilawyn/thilaschema": "^0.1.4", "@thilawyn/thilaschema": "^0.1.4",
"@types/react": "^19.0.4", "@types/react": "^19.0.7",
"@types/react-dom": "^19.0.2", "@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"effect": "^3.12.1", "effect": "^3.12.5",
"eslint": "^9.17.0", "eslint": "^9.18.0",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0", "globals": "^15.14.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"reffuse": "workspace:*", "reffuse": "workspace:*",
"typescript-eslint": "^8.18.2", "typescript-eslint": "^8.21.0",
"vite": "^6.0.5" "vite": "^6.0.11"
}, },
"dependencies": { "dependencies": {
"@effect/platform": "^0.73.1", "@effect/platform": "^0.74.0",
"@effect/platform-browser": "^0.52.1", "@effect/platform-browser": "^0.53.0",
"@radix-ui/themes": "^3.1.6", "@radix-ui/themes": "^3.1.6",
"@typed/id": "^0.17.1", "@typed/id": "^0.17.1",
"lucide-react": "^0.471.1", "@typed/lazy-ref": "^0.3.3",
"lucide-react": "^0.473.0",
"mobx": "^6.13.5" "mobx": "^6.13.5"
} }
} }

View File

@@ -1,10 +1,10 @@
import { FetchHttpClient } from "@effect/platform" import { FetchHttpClient } from "@effect/platform"
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
import { createRouter, RouterProvider } from "@tanstack/react-router" import { createRouter, RouterProvider } from "@tanstack/react-router"
import { ReffuseRuntime } from "@thilawyn/reffuse"
import { Layer } from "effect" import { Layer } from "effect"
import { StrictMode } from "react" import { StrictMode } from "react"
import { createRoot } from "react-dom/client" import { createRoot } from "react-dom/client"
import { ReffuseRuntime } from "reffuse"
import { GlobalContext } from "./reffuse" import { GlobalContext } from "./reffuse"
import { routeTree } from "./routeTree.gen" import { routeTree } from "./routeTree.gen"

View File

@@ -12,7 +12,9 @@
import { Route as rootRoute } from './routes/__root' import { Route as rootRoute } from './routes/__root'
import { Route as TimeImport } from './routes/time' import { Route as TimeImport } from './routes/time'
import { Route as TestsImport } from './routes/tests'
import { Route as CountImport } from './routes/count' import { Route as CountImport } from './routes/count'
import { Route as BlankImport } from './routes/blank'
import { Route as IndexImport } from './routes/index' import { Route as IndexImport } from './routes/index'
// Create/Update Routes // Create/Update Routes
@@ -23,12 +25,24 @@ const TimeRoute = TimeImport.update({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const TestsRoute = TestsImport.update({
id: '/tests',
path: '/tests',
getParentRoute: () => rootRoute,
} as any)
const CountRoute = CountImport.update({ const CountRoute = CountImport.update({
id: '/count', id: '/count',
path: '/count', path: '/count',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const BlankRoute = BlankImport.update({
id: '/blank',
path: '/blank',
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({ const IndexRoute = IndexImport.update({
id: '/', id: '/',
path: '/', path: '/',
@@ -46,6 +60,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexImport preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/blank': {
id: '/blank'
path: '/blank'
fullPath: '/blank'
preLoaderRoute: typeof BlankImport
parentRoute: typeof rootRoute
}
'/count': { '/count': {
id: '/count' id: '/count'
path: '/count' path: '/count'
@@ -53,6 +74,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CountImport preLoaderRoute: typeof CountImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/tests': {
id: '/tests'
path: '/tests'
fullPath: '/tests'
preLoaderRoute: typeof TestsImport
parentRoute: typeof rootRoute
}
'/time': { '/time': {
id: '/time' id: '/time'
path: '/time' path: '/time'
@@ -67,41 +95,51 @@ declare module '@tanstack/react-router' {
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/blank': typeof BlankRoute
'/count': typeof CountRoute '/count': typeof CountRoute
'/tests': typeof TestsRoute
'/time': typeof TimeRoute '/time': typeof TimeRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/blank': typeof BlankRoute
'/count': typeof CountRoute '/count': typeof CountRoute
'/tests': typeof TestsRoute
'/time': typeof TimeRoute '/time': typeof TimeRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRoute __root__: typeof rootRoute
'/': typeof IndexRoute '/': typeof IndexRoute
'/blank': typeof BlankRoute
'/count': typeof CountRoute '/count': typeof CountRoute
'/tests': typeof TestsRoute
'/time': typeof TimeRoute '/time': typeof TimeRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/count' | '/time' fullPaths: '/' | '/blank' | '/count' | '/tests' | '/time'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/count' | '/time' to: '/' | '/blank' | '/count' | '/tests' | '/time'
id: '__root__' | '/' | '/count' | '/time' id: '__root__' | '/' | '/blank' | '/count' | '/tests' | '/time'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
BlankRoute: typeof BlankRoute
CountRoute: typeof CountRoute CountRoute: typeof CountRoute
TestsRoute: typeof TestsRoute
TimeRoute: typeof TimeRoute TimeRoute: typeof TimeRoute
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
BlankRoute: BlankRoute,
CountRoute: CountRoute, CountRoute: CountRoute,
TestsRoute: TestsRoute,
TimeRoute: TimeRoute, TimeRoute: TimeRoute,
} }
@@ -116,16 +154,24 @@ export const routeTree = rootRoute
"filePath": "__root.tsx", "filePath": "__root.tsx",
"children": [ "children": [
"/", "/",
"/blank",
"/count", "/count",
"/tests",
"/time" "/time"
] ]
}, },
"/": { "/": {
"filePath": "index.tsx" "filePath": "index.tsx"
}, },
"/blank": {
"filePath": "blank.tsx"
},
"/count": { "/count": {
"filePath": "count.tsx" "filePath": "count.tsx"
}, },
"/tests": {
"filePath": "tests.tsx"
},
"/time": { "/time": {
"filePath": "time.tsx" "filePath": "time.tsx"
} }

View File

@@ -1,7 +1,8 @@
import { Container, Flex, Theme } from "@radix-ui/themes" import { Container, Flex, Theme } from "@radix-ui/themes"
import "@radix-ui/themes/styles.css"
import { createRootRoute, Link, Outlet } from "@tanstack/react-router" import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/router-devtools" import { TanStackRouterDevtools } from "@tanstack/router-devtools"
import "@radix-ui/themes/styles.css"
import "../index.css" import "../index.css"
@@ -17,6 +18,8 @@ function Root() {
<Link to="/">Index</Link> <Link to="/">Index</Link>
<Link to="/time">Time</Link> <Link to="/time">Time</Link>
<Link to="/count">Count</Link> <Link to="/count">Count</Link>
<Link to="/tests">Tests</Link>
<Link to="/blank">Blank</Link>
</Flex> </Flex>
</Container> </Container>

View File

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

View File

@@ -1,10 +1,9 @@
import { R } from "@/reffuse"
import { TodosContext } from "@/todos/reffuse" import { TodosContext } from "@/todos/reffuse"
import { TodosState } from "@/todos/services" import { TodosState } from "@/todos/services"
import { VTodos } from "@/todos/views/VTodos" import { VTodos } from "@/todos/views/VTodos"
import { Container } from "@radix-ui/themes" import { Container } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Layer } from "effect" import { Layer } from "effect"
import { useMemo } from "react" import { useMemo } from "react"
@@ -18,10 +17,6 @@ function Index() {
Layer.provideMerge(TodosState.make("todos")) Layer.provideMerge(TodosState.make("todos"))
), []) ), [])
R.useEffect(Effect.addFinalizer(() => Console.log("Effect cleanup")).pipe(
Effect.flatMap(() => Console.log("Effect recalculated"))
))
return ( return (
<Container> <Container>

View File

@@ -0,0 +1,23 @@
import { R } from "@/reffuse"
import { createFileRoute } from "@tanstack/react-router"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Console, Effect } from "effect"
export const Route = createFileRoute("/tests")({
component: RouteComponent
})
function RouteComponent() {
// R.useMemo(Effect.addFinalizer(() => Console.log("Cleanup!")).pipe(
// Effect.map(() => "test")
// ))
const value = R.useMemoScoped(Effect.addFinalizer(() => Console.log("cleanup")).pipe(
Effect.andThen(makeUuid4),
Effect.provide(GetRandomValues.CryptoRandom),
), [])
console.log(value)
return <div>Hello "/tests"!</div>
}

View File

@@ -1,6 +1,6 @@
import { R } from "@/reffuse" import { R } from "@/reffuse"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Console, DateTime, Effect, Ref, Schedule, Stream } from "effect" import { DateTime, Ref, Schedule, Stream } from "effect"
const timeEverySecond = Stream.repeatEffectWithSchedule( const timeEverySecond = Stream.repeatEffectWithSchedule(
@@ -16,21 +16,7 @@ export const Route = createFileRoute("/time")({
function Time() { function Time() {
const timeRef = R.useRefFromEffect(DateTime.now) const timeRef = R.useRefFromEffect(DateTime.now)
R.useFork(Stream.runForEach(timeEverySecond, v => Ref.set(timeRef, v)), [timeRef])
R.useFork(Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
Effect.flatMap(() =>
Stream.runForEach(timeEverySecond, v => Ref.set(timeRef, v))
)
), [timeRef])
// Reffuse.useFork(Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
// Effect.flatMap(() => DateTime.now),
// Effect.flatMap(v => Ref.set(timeRef, v)),
// Effect.repeat(Schedule.intersect(
// Schedule.forever,
// Schedule.spaced("1 second"),
// )),
// ), [timeRef])
const [time] = R.useRefState(timeRef) const [time] = R.useRefState(timeRef)

View File

@@ -1,6 +1,6 @@
{ {
"name": "reffuse", "name": "reffuse",
"version": "0.1.0", "version": "0.1.1",
"type": "module", "type": "module",
"files": [ "files": [
"./README.md", "./README.md",
@@ -29,8 +29,9 @@
"clean:node": "rm -rf node_modules" "clean:node": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.4", "@typed/lazy-ref": "^0.3.3",
"effect": "^3.12.1", "@types/react": "^19.0.7",
"effect": "^3.12.5",
"react": "^19.0.0" "react": "^19.0.0"
} }
} }

View File

@@ -1,4 +1,5 @@
import { Context, Effect, ExecutionStrategy, Exit, Fiber, Option, Ref, Runtime, Scope, Stream, SubscriptionRef } from "effect" import * as LazyRef from "@typed/lazy-ref"
import { Context, Effect, ExecutionStrategy, Exit, Fiber, Ref, Runtime, Scope, Stream, SubscriptionRef } from "effect"
import React from "react" import React from "react"
import * as ReffuseContext from "./ReffuseContext.js" import * as ReffuseContext from "./ReffuseContext.js"
import * as ReffuseRuntime from "./ReffuseRuntime.js" import * as ReffuseRuntime from "./ReffuseRuntime.js"
@@ -55,6 +56,19 @@ export class Reffuse<R> {
), [runtime, context]) ), [runtime, context])
} }
useRunCallback() {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
return React.useCallback(<A, E>(
effect: Effect.Effect<A, E, R>,
options?: Runtime.RunCallbackOptions<A, E>,
): Runtime.Cancel<A, E> => effect.pipe(
Effect.provide(context),
effect => Runtime.runCallback(runtime)(effect, options),
), [runtime, context])
}
/** /**
* Reffuse equivalent to `React.useMemo`. * Reffuse equivalent to `React.useMemo`.
@@ -78,6 +92,55 @@ export class Reffuse<R> {
]) ])
} }
useMemoScoped<A, E>(
effect: Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: RenderOptions & ScopeOptions,
): A {
const runSync = this.useRunSync()
// Calculate an initial version of the value so that it can be accessed during the first render
const [initialScope, initialValue] = React.useMemo(() => Scope.make(options?.finalizerExecutionStrategy).pipe(
Effect.flatMap(scope => effect.pipe(
Effect.provideService(Scope.Scope, scope),
Effect.map(value => [scope, value] as const),
)),
runSync,
), [])
// Keep track of the state of the initial scope
const initialScopeClosed = React.useRef(false)
const [value, setValue] = React.useState(initialValue)
React.useEffect(() => {
const closeInitialScope = Scope.close(initialScope, Exit.void).pipe(
Effect.andThen(Effect.sync(() => { initialScopeClosed.current = true })),
Effect.when(() => !initialScopeClosed.current),
)
const [scope, value] = closeInitialScope.pipe(
Effect.andThen(Scope.make(options?.finalizerExecutionStrategy).pipe(
Effect.flatMap(scope => effect.pipe(
Effect.provideService(Scope.Scope, scope),
Effect.map(value => [scope, value] as const),
))
)),
runSync,
)
setValue(value)
return () => { runSync(Scope.close(scope, Exit.void)) }
}, [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
...(deps ?? []),
])
return value
}
/** /**
* Reffuse equivalent to `React.useEffect`. * Reffuse equivalent to `React.useEffect`.
* *
@@ -171,20 +234,6 @@ export class Reffuse<R> {
]) ])
} }
useSuspense<A, E>(
effect: Effect.Effect<A, E, R>,
deps?: React.DependencyList,
options?: { readonly signal?: AbortSignal } & RenderOptions,
): A {
const runPromise = this.useRunPromise()
const promise = React.useMemo(() => runPromise(effect, options), [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runPromise],
...(deps ?? []),
])
return React.use(promise)
}
/** /**
* An asynchronous and non-blocking alternative to `React.useEffect`. * An asynchronous and non-blocking alternative to `React.useEffect`.
* *
@@ -226,7 +275,7 @@ export class Reffuse<R> {
return () => { return () => {
Fiber.interrupt(fiber).pipe( Fiber.interrupt(fiber).pipe(
Effect.flatMap(() => Scope.close(scope, Exit.void)), Effect.andThen(Scope.close(scope, Exit.void)),
runFork, runFork,
) )
} }
@@ -236,6 +285,44 @@ export class Reffuse<R> {
]) ])
} }
// useSuspense<A, E>(
// effect: Effect.Effect<A, E, R>,
// deps?: React.DependencyList,
// options?: { readonly signal?: AbortSignal } & RenderOptions,
// ): A {
// const runPromise = this.useRunPromise()
// const promise = React.useMemo(() => runPromise(effect, options), [
// ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runPromise],
// ...(deps ?? []),
// ])
// return React.use(promise)
// }
// useSuspenseScoped<A, E>(
// effect: Effect.Effect<A, E, R | Scope.Scope>,
// deps?: React.DependencyList,
// options?: { readonly signal?: AbortSignal } & RenderOptions & ScopeOptions,
// ): A {
// const runSync = this.useRunSync()
// const runPromise = this.useRunPromise()
// const initialPromise = React.useMemo(() => runPromise(Effect.scoped(effect)), [])
// const [promise, setPromise] = React.useState(initialPromise)
// React.useEffect(() => {
// const scope = runSync(Scope.make())
// setPromise(runPromise(Effect.provideService(effect, Scope.Scope, scope), options))
// return () => { runPromise(Scope.close(scope, Exit.void)) }
// }, [
// ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync, runPromise],
// ...(deps ?? []),
// ])
// return React.use(promise)
// }
useRef<A>(value: A): SubscriptionRef.SubscriptionRef<A> { useRef<A>(value: A): SubscriptionRef.SubscriptionRef<A> {
return this.useMemo( return this.useMemo(
@@ -256,14 +343,14 @@ export class Reffuse<R> {
/** /**
* Binds the state of a `SubscriptionRef` to the state of the React component. * Binds the state of a `SubscriptionRef` to the state of the React component.
* *
* Returns a [value, setter] tuple just like `React.useState` and triggers a re-render everytime the value of the ref changes. * Returns a [value, setter] tuple just like `React.useState` and triggers a re-render everytime the value held by the ref changes.
* *
* Note that the rules of React's immutable state still apply: updating a ref with the same value will not trigger a re-render. * Note that the rules of React's immutable state still apply: updating a ref with the same value will not trigger a re-render.
*/ */
useRefState<A>(ref: SubscriptionRef.SubscriptionRef<A>): [A, React.Dispatch<React.SetStateAction<A>>] { useRefState<A>(ref: SubscriptionRef.SubscriptionRef<A>): [A, React.Dispatch<React.SetStateAction<A>>] {
const runSync = this.useRunSync() const runSync = this.useRunSync()
const initialState = React.useMemo(() => runSync(ref), [ref]) const initialState = React.useMemo(() => runSync(ref), [])
const [reactStateValue, setReactStateValue] = React.useState(initialState) const [reactStateValue, setReactStateValue] = React.useState(initialState)
this.useFork(Stream.runForEach(ref.changes, v => Effect.sync(() => this.useFork(Stream.runForEach(ref.changes, v => Effect.sync(() =>
@@ -271,23 +358,38 @@ export class Reffuse<R> {
)), [ref]) )), [ref])
const setValue = React.useCallback((setStateAction: React.SetStateAction<A>) => const setValue = React.useCallback((setStateAction: React.SetStateAction<A>) =>
runSync(Ref.update(ref, previousState => runSync(Ref.update(ref, prevState =>
SetStateAction.value(setStateAction, previousState) SetStateAction.value(setStateAction, prevState)
)), )),
[ref]) [ref])
return [reactStateValue, setValue] return [reactStateValue, setValue]
} }
/**
* Binds the state of a `LazyRef` from the `@typed/lazy-ref` package to the state of the React component.
*
* Returns a [value, setter] tuple just like `React.useState` and triggers a re-render everytime the value held by the ref changes.
*
* Note that the rules of React's immutable state still apply: updating a ref with the same value will not trigger a re-render.
*/
useLazyRefState<A, E>(ref: LazyRef.LazyRef<A, E, R>): [A, React.Dispatch<React.SetStateAction<A>>] {
const runSync = this.useRunSync()
useStreamState<A, E>(stream: Stream.Stream<A, E, R>): Option.Option<A> { const initialState = React.useMemo(() => runSync(ref), [])
const [reactStateValue, setReactStateValue] = React.useState(Option.none<A>()) const [reactStateValue, setReactStateValue] = React.useState(initialState)
this.useFork(Stream.runForEach(stream, v => Effect.sync(() => this.useFork(Stream.runForEach(ref.changes, v => Effect.sync(() =>
setReactStateValue(Option.some(v)) setReactStateValue(v)
)), [stream]) )), [ref])
return reactStateValue const setValue = React.useCallback((setStateAction: React.SetStateAction<A>) =>
runSync(LazyRef.update(ref, prevState =>
SetStateAction.value(setStateAction, prevState)
)),
[ref])
return [reactStateValue, setValue]
} }
} }

View File

@@ -9,6 +9,7 @@ export class ReffuseContext<R> {
readonly Provider: ReffuseContextReactProvider<R> readonly Provider: ReffuseContextReactProvider<R>
constructor() { constructor() {
// TODO: scope the layer creation
this.Provider = (props) => { this.Provider = (props) => {
const runtime = ReffuseRuntime.useRuntime() const runtime = ReffuseRuntime.useRuntime()