diff --git a/bun.lockb b/bun.lockb index ea85420..fa0518b 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 6d4beab..6c1b407 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "clean:node": "rm -rf node_modules" }, "devDependencies": { - "npm-check-updates": "^17.1.13", + "npm-check-updates": "^17.1.14", "npm-sort": "^0.0.4", "typescript": "^5.7.3" } diff --git a/packages/example/package.json b/packages/example/package.json index d73afec..ee4ebd4 100644 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -11,31 +11,32 @@ "preview": "vite preview" }, "devDependencies": { - "@eslint/js": "^9.17.0", - "@tanstack/react-router": "^1.95.3", - "@tanstack/router-devtools": "^1.95.3", - "@tanstack/router-plugin": "^1.95.3", + "@eslint/js": "^9.18.0", + "@tanstack/react-router": "^1.97.3", + "@tanstack/router-devtools": "^1.97.3", + "@tanstack/router-plugin": "^1.97.3", "@thilawyn/thilaschema": "^0.1.4", - "@types/react": "^19.0.4", - "@types/react-dom": "^19.0.2", + "@types/react": "^19.0.7", + "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.4", - "effect": "^3.12.1", - "eslint": "^9.17.0", - "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-react-refresh": "^0.4.16", + "effect": "^3.12.5", + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.18", "globals": "^15.14.0", "react": "^19.0.0", "react-dom": "^19.0.0", "reffuse": "workspace:*", - "typescript-eslint": "^8.18.2", - "vite": "^6.0.5" + "typescript-eslint": "^8.21.0", + "vite": "^6.0.11" }, "dependencies": { - "@effect/platform": "^0.73.1", - "@effect/platform-browser": "^0.52.1", + "@effect/platform": "^0.74.0", + "@effect/platform-browser": "^0.53.0", "@radix-ui/themes": "^3.1.6", "@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" } } diff --git a/packages/example/src/main.tsx b/packages/example/src/main.tsx index f3e3987..8fe1e6d 100644 --- a/packages/example/src/main.tsx +++ b/packages/example/src/main.tsx @@ -1,10 +1,10 @@ import { FetchHttpClient } from "@effect/platform" import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" import { createRouter, RouterProvider } from "@tanstack/react-router" -import { ReffuseRuntime } from "@thilawyn/reffuse" import { Layer } from "effect" import { StrictMode } from "react" import { createRoot } from "react-dom/client" +import { ReffuseRuntime } from "reffuse" import { GlobalContext } from "./reffuse" import { routeTree } from "./routeTree.gen" diff --git a/packages/example/src/routeTree.gen.ts b/packages/example/src/routeTree.gen.ts index 2cec328..92e0a99 100644 --- a/packages/example/src/routeTree.gen.ts +++ b/packages/example/src/routeTree.gen.ts @@ -12,7 +12,9 @@ import { Route as rootRoute } from './routes/__root' import { Route as TimeImport } from './routes/time' +import { Route as TestsImport } from './routes/tests' import { Route as CountImport } from './routes/count' +import { Route as BlankImport } from './routes/blank' import { Route as IndexImport } from './routes/index' // Create/Update Routes @@ -23,12 +25,24 @@ const TimeRoute = TimeImport.update({ getParentRoute: () => rootRoute, } as any) +const TestsRoute = TestsImport.update({ + id: '/tests', + path: '/tests', + getParentRoute: () => rootRoute, +} as any) + const CountRoute = CountImport.update({ id: '/count', path: '/count', getParentRoute: () => rootRoute, } as any) +const BlankRoute = BlankImport.update({ + id: '/blank', + path: '/blank', + getParentRoute: () => rootRoute, +} as any) + const IndexRoute = IndexImport.update({ id: '/', path: '/', @@ -46,6 +60,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexImport parentRoute: typeof rootRoute } + '/blank': { + id: '/blank' + path: '/blank' + fullPath: '/blank' + preLoaderRoute: typeof BlankImport + parentRoute: typeof rootRoute + } '/count': { id: '/count' path: '/count' @@ -53,6 +74,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CountImport parentRoute: typeof rootRoute } + '/tests': { + id: '/tests' + path: '/tests' + fullPath: '/tests' + preLoaderRoute: typeof TestsImport + parentRoute: typeof rootRoute + } '/time': { id: '/time' path: '/time' @@ -67,41 +95,51 @@ declare module '@tanstack/react-router' { export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/blank': typeof BlankRoute '/count': typeof CountRoute + '/tests': typeof TestsRoute '/time': typeof TimeRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/blank': typeof BlankRoute '/count': typeof CountRoute + '/tests': typeof TestsRoute '/time': typeof TimeRoute } export interface FileRoutesById { __root__: typeof rootRoute '/': typeof IndexRoute + '/blank': typeof BlankRoute '/count': typeof CountRoute + '/tests': typeof TestsRoute '/time': typeof TimeRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/count' | '/time' + fullPaths: '/' | '/blank' | '/count' | '/tests' | '/time' fileRoutesByTo: FileRoutesByTo - to: '/' | '/count' | '/time' - id: '__root__' | '/' | '/count' | '/time' + to: '/' | '/blank' | '/count' | '/tests' | '/time' + id: '__root__' | '/' | '/blank' | '/count' | '/tests' | '/time' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + BlankRoute: typeof BlankRoute CountRoute: typeof CountRoute + TestsRoute: typeof TestsRoute TimeRoute: typeof TimeRoute } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + BlankRoute: BlankRoute, CountRoute: CountRoute, + TestsRoute: TestsRoute, TimeRoute: TimeRoute, } @@ -116,16 +154,24 @@ export const routeTree = rootRoute "filePath": "__root.tsx", "children": [ "/", + "/blank", "/count", + "/tests", "/time" ] }, "/": { "filePath": "index.tsx" }, + "/blank": { + "filePath": "blank.tsx" + }, "/count": { "filePath": "count.tsx" }, + "/tests": { + "filePath": "tests.tsx" + }, "/time": { "filePath": "time.tsx" } diff --git a/packages/example/src/routes/__root.tsx b/packages/example/src/routes/__root.tsx index beef033..b9e5fa0 100644 --- a/packages/example/src/routes/__root.tsx +++ b/packages/example/src/routes/__root.tsx @@ -1,7 +1,8 @@ import { Container, Flex, Theme } from "@radix-ui/themes" -import "@radix-ui/themes/styles.css" import { createRootRoute, Link, Outlet } from "@tanstack/react-router" import { TanStackRouterDevtools } from "@tanstack/router-devtools" + +import "@radix-ui/themes/styles.css" import "../index.css" @@ -17,6 +18,8 @@ function Root() { Index Time Count + Tests + Blank diff --git a/packages/example/src/routes/blank.tsx b/packages/example/src/routes/blank.tsx new file mode 100644 index 0000000..3d1cd68 --- /dev/null +++ b/packages/example/src/routes/blank.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/blank')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/blank"!
+} diff --git a/packages/example/src/routes/index.tsx b/packages/example/src/routes/index.tsx index c4caacd..6de5c78 100644 --- a/packages/example/src/routes/index.tsx +++ b/packages/example/src/routes/index.tsx @@ -1,10 +1,9 @@ -import { R } from "@/reffuse" 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 { Layer } from "effect" import { useMemo } from "react" @@ -18,10 +17,6 @@ function Index() { Layer.provideMerge(TodosState.make("todos")) ), []) - R.useEffect(Effect.addFinalizer(() => Console.log("Effect cleanup")).pipe( - Effect.flatMap(() => Console.log("Effect recalculated")) - )) - return ( diff --git a/packages/example/src/routes/tests.tsx b/packages/example/src/routes/tests.tsx new file mode 100644 index 0000000..a019c4b --- /dev/null +++ b/packages/example/src/routes/tests.tsx @@ -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
Hello "/tests"!
+} diff --git a/packages/example/src/routes/time.tsx b/packages/example/src/routes/time.tsx index e2ba3d8..702a5ad 100644 --- a/packages/example/src/routes/time.tsx +++ b/packages/example/src/routes/time.tsx @@ -1,6 +1,6 @@ import { R } from "@/reffuse" 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( @@ -16,21 +16,7 @@ export const Route = createFileRoute("/time")({ function Time() { const timeRef = R.useRefFromEffect(DateTime.now) - - 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]) - + R.useFork(Stream.runForEach(timeEverySecond, v => Ref.set(timeRef, v)), [timeRef]) const [time] = R.useRefState(timeRef) diff --git a/packages/reffuse/package.json b/packages/reffuse/package.json index e07c985..d729b00 100644 --- a/packages/reffuse/package.json +++ b/packages/reffuse/package.json @@ -1,6 +1,6 @@ { "name": "reffuse", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "files": [ "./README.md", @@ -29,8 +29,9 @@ "clean:node": "rm -rf node_modules" }, "devDependencies": { - "@types/react": "^19.0.4", - "effect": "^3.12.1", + "@typed/lazy-ref": "^0.3.3", + "@types/react": "^19.0.7", + "effect": "^3.12.5", "react": "^19.0.0" } } diff --git a/packages/reffuse/src/Reffuse.ts b/packages/reffuse/src/Reffuse.ts index fd42420..9b1f114 100644 --- a/packages/reffuse/src/Reffuse.ts +++ b/packages/reffuse/src/Reffuse.ts @@ -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 * as ReffuseContext from "./ReffuseContext.js" import * as ReffuseRuntime from "./ReffuseRuntime.js" @@ -55,6 +56,19 @@ export class Reffuse { ), [runtime, context]) } + useRunCallback() { + const runtime = ReffuseRuntime.useRuntime() + const context = this.useContext() + + return React.useCallback(( + effect: Effect.Effect, + options?: Runtime.RunCallbackOptions, + ): Runtime.Cancel => effect.pipe( + Effect.provide(context), + effect => Runtime.runCallback(runtime)(effect, options), + ), [runtime, context]) + } + /** * Reffuse equivalent to `React.useMemo`. @@ -78,6 +92,55 @@ export class Reffuse { ]) } + useMemoScoped( + effect: Effect.Effect, + 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`. * @@ -171,20 +234,6 @@ export class Reffuse { ]) } - useSuspense( - effect: Effect.Effect, - 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`. * @@ -226,7 +275,7 @@ export class Reffuse { return () => { Fiber.interrupt(fiber).pipe( - Effect.flatMap(() => Scope.close(scope, Exit.void)), + Effect.andThen(Scope.close(scope, Exit.void)), runFork, ) } @@ -236,6 +285,44 @@ export class Reffuse { ]) } + // useSuspense( + // effect: Effect.Effect, + // 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( + // effect: Effect.Effect, + // 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(value: A): SubscriptionRef.SubscriptionRef { return this.useMemo( @@ -256,14 +343,14 @@ export class Reffuse { /** * 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. */ useRefState(ref: SubscriptionRef.SubscriptionRef): [A, React.Dispatch>] { const runSync = this.useRunSync() - const initialState = React.useMemo(() => runSync(ref), [ref]) + const initialState = React.useMemo(() => runSync(ref), []) const [reactStateValue, setReactStateValue] = React.useState(initialState) this.useFork(Stream.runForEach(ref.changes, v => Effect.sync(() => @@ -271,23 +358,38 @@ export class Reffuse { )), [ref]) const setValue = React.useCallback((setStateAction: React.SetStateAction) => - runSync(Ref.update(ref, previousState => - SetStateAction.value(setStateAction, previousState) + runSync(Ref.update(ref, prevState => + SetStateAction.value(setStateAction, prevState) )), [ref]) 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(ref: LazyRef.LazyRef): [A, React.Dispatch>] { + const runSync = this.useRunSync() - useStreamState(stream: Stream.Stream): Option.Option { - const [reactStateValue, setReactStateValue] = React.useState(Option.none()) + const initialState = React.useMemo(() => runSync(ref), []) + const [reactStateValue, setReactStateValue] = React.useState(initialState) - this.useFork(Stream.runForEach(stream, v => Effect.sync(() => - setReactStateValue(Option.some(v)) - )), [stream]) + this.useFork(Stream.runForEach(ref.changes, v => Effect.sync(() => + setReactStateValue(v) + )), [ref]) - return reactStateValue + const setValue = React.useCallback((setStateAction: React.SetStateAction) => + runSync(LazyRef.update(ref, prevState => + SetStateAction.value(setStateAction, prevState) + )), + [ref]) + + return [reactStateValue, setValue] } } diff --git a/packages/reffuse/src/ReffuseContext.tsx b/packages/reffuse/src/ReffuseContext.tsx index 7d4a17c..dbca682 100644 --- a/packages/reffuse/src/ReffuseContext.tsx +++ b/packages/reffuse/src/ReffuseContext.tsx @@ -9,6 +9,7 @@ export class ReffuseContext { readonly Provider: ReffuseContextReactProvider constructor() { + // TODO: scope the layer creation this.Provider = (props) => { const runtime = ReffuseRuntime.useRuntime()