From 74fa30cf4f06ff8321fb82e60d36d8ef28982d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 28 Mar 2025 21:24:41 +0100 Subject: [PATCH] 0.1.5 (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Julien Valverdé Reviewed-on: https://gitea:3000/Thilawyn/reffuse/pulls/7 --- packages/example/src/routeTree.gen.ts | 26 ++++ packages/example/src/routes/index.tsx | 27 +--- packages/example/src/routes/todos.tsx | 35 ++++++ packages/reffuse/package.json | 2 +- packages/reffuse/src/Reffuse.ts | 7 +- packages/reffuse/src/ReffuseContext.ts | 160 ++++++++++++++++++++++++ packages/reffuse/src/ReffuseContext.tsx | 111 ---------------- packages/reffuse/src/ReffuseHelpers.ts | 65 +++++----- packages/reffuse/src/ReffuseRuntime.ts | 16 +++ packages/reffuse/src/ReffuseRuntime.tsx | 15 --- 10 files changed, 280 insertions(+), 184 deletions(-) create mode 100644 packages/example/src/routes/todos.tsx create mode 100644 packages/reffuse/src/ReffuseContext.ts delete mode 100644 packages/reffuse/src/ReffuseContext.tsx create mode 100644 packages/reffuse/src/ReffuseRuntime.ts delete mode 100644 packages/reffuse/src/ReffuseRuntime.tsx diff --git a/packages/example/src/routeTree.gen.ts b/packages/example/src/routeTree.gen.ts index fd6ea54..7080a30 100644 --- a/packages/example/src/routeTree.gen.ts +++ b/packages/example/src/routeTree.gen.ts @@ -11,6 +11,7 @@ // Import Routes import { Route as rootRoute } from './routes/__root' +import { Route as TodosImport } from './routes/todos' import { Route as TimeImport } from './routes/time' import { Route as TestsImport } from './routes/tests' import { Route as PromiseImport } from './routes/promise' @@ -24,6 +25,12 @@ import { Route as QueryServiceImport } from './routes/query/service' // Create/Update Routes +const TodosRoute = TodosImport.update({ + id: '/todos', + path: '/todos', + getParentRoute: () => rootRoute, +} as any) + const TimeRoute = TimeImport.update({ id: '/time', path: '/time', @@ -137,6 +144,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof TimeImport parentRoute: typeof rootRoute } + '/todos': { + id: '/todos' + path: '/todos' + fullPath: '/todos' + preLoaderRoute: typeof TodosImport + parentRoute: typeof rootRoute + } '/query/service': { id: '/query/service' path: '/query/service' @@ -171,6 +185,7 @@ export interface FileRoutesByFullPath { '/promise': typeof PromiseRoute '/tests': typeof TestsRoute '/time': typeof TimeRoute + '/todos': typeof TodosRoute '/query/service': typeof QueryServiceRoute '/query/usemutation': typeof QueryUsemutationRoute '/query/usequery': typeof QueryUsequeryRoute @@ -184,6 +199,7 @@ export interface FileRoutesByTo { '/promise': typeof PromiseRoute '/tests': typeof TestsRoute '/time': typeof TimeRoute + '/todos': typeof TodosRoute '/query/service': typeof QueryServiceRoute '/query/usemutation': typeof QueryUsemutationRoute '/query/usequery': typeof QueryUsequeryRoute @@ -198,6 +214,7 @@ export interface FileRoutesById { '/promise': typeof PromiseRoute '/tests': typeof TestsRoute '/time': typeof TimeRoute + '/todos': typeof TodosRoute '/query/service': typeof QueryServiceRoute '/query/usemutation': typeof QueryUsemutationRoute '/query/usequery': typeof QueryUsequeryRoute @@ -213,6 +230,7 @@ export interface FileRouteTypes { | '/promise' | '/tests' | '/time' + | '/todos' | '/query/service' | '/query/usemutation' | '/query/usequery' @@ -225,6 +243,7 @@ export interface FileRouteTypes { | '/promise' | '/tests' | '/time' + | '/todos' | '/query/service' | '/query/usemutation' | '/query/usequery' @@ -237,6 +256,7 @@ export interface FileRouteTypes { | '/promise' | '/tests' | '/time' + | '/todos' | '/query/service' | '/query/usemutation' | '/query/usequery' @@ -251,6 +271,7 @@ export interface RootRouteChildren { PromiseRoute: typeof PromiseRoute TestsRoute: typeof TestsRoute TimeRoute: typeof TimeRoute + TodosRoute: typeof TodosRoute QueryServiceRoute: typeof QueryServiceRoute QueryUsemutationRoute: typeof QueryUsemutationRoute QueryUsequeryRoute: typeof QueryUsequeryRoute @@ -264,6 +285,7 @@ const rootRouteChildren: RootRouteChildren = { PromiseRoute: PromiseRoute, TestsRoute: TestsRoute, TimeRoute: TimeRoute, + TodosRoute: TodosRoute, QueryServiceRoute: QueryServiceRoute, QueryUsemutationRoute: QueryUsemutationRoute, QueryUsequeryRoute: QueryUsequeryRoute, @@ -286,6 +308,7 @@ export const routeTree = rootRoute "/promise", "/tests", "/time", + "/todos", "/query/service", "/query/usemutation", "/query/usequery" @@ -312,6 +335,9 @@ export const routeTree = rootRoute "/time": { "filePath": "time.tsx" }, + "/todos": { + "filePath": "todos.tsx" + }, "/query/service": { "filePath": "query/service.tsx" }, diff --git a/packages/example/src/routes/index.tsx b/packages/example/src/routes/index.tsx index 6de5c78..58b96d2 100644 --- a/packages/example/src/routes/index.tsx +++ b/packages/example/src/routes/index.tsx @@ -1,29 +1,10 @@ -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 { Layer } from "effect" -import { useMemo } from "react" -export const Route = createFileRoute("/")({ - component: Index +export const Route = createFileRoute('/')({ + component: RouteComponent }) -function Index() { - - const todosLayer = useMemo(() => Layer.empty.pipe( - Layer.provideMerge(TodosState.make("todos")) - ), []) - - - return ( - - - - - - ) - +function RouteComponent() { + return
Hello "/"!
} diff --git a/packages/example/src/routes/todos.tsx b/packages/example/src/routes/todos.tsx new file mode 100644 index 0000000..17aa88f --- /dev/null +++ b/packages/example/src/routes/todos.tsx @@ -0,0 +1,35 @@ +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 ( + + + + + + ) + +} diff --git a/packages/reffuse/package.json b/packages/reffuse/package.json index 3ceb63e..27bc91a 100644 --- a/packages/reffuse/package.json +++ b/packages/reffuse/package.json @@ -1,6 +1,6 @@ { "name": "reffuse", - "version": "0.1.4", + "version": "0.1.5", "type": "module", "files": [ "./README.md", diff --git a/packages/reffuse/src/Reffuse.ts b/packages/reffuse/src/Reffuse.ts index 87d13e4..ccfcfe1 100644 --- a/packages/reffuse/src/Reffuse.ts +++ b/packages/reffuse/src/Reffuse.ts @@ -9,7 +9,7 @@ export class Reffuse extends ReffuseHelpers.make() {} export const withContexts = >( ...contexts: [...{ [K in keyof R2]: ReffuseContext.ReffuseContext }] -) => +) => ( < BaseClass extends ReffuseHelpers.ReffuseHelpersClass, R1 @@ -29,9 +29,9 @@ export const withContexts = >( ) => class extends self { static readonly contexts = [...self.contexts, ...contexts] } as any +) - -export const withExtension = (extension: ReffuseExtension.ReffuseExtension) => +export const withExtension = (extension: ReffuseExtension.ReffuseExtension) => ( < BaseClass extends ReffuseHelpers.ReffuseHelpersClass, R @@ -45,3 +45,4 @@ export const withExtension = (extension: ReffuseExtension.Reff Object.assign(class_.prototype, extension()) return class_ as any } +) diff --git a/packages/reffuse/src/ReffuseContext.ts b/packages/reffuse/src/ReffuseContext.ts new file mode 100644 index 0000000..4470cfa --- /dev/null +++ b/packages/reffuse/src/ReffuseContext.ts @@ -0,0 +1,160 @@ +import { Array, Context, Effect, ExecutionStrategy, Exit, Layer, Ref, Runtime, Scope } from "effect" +import * as React from "react" +import * as ReffuseRuntime from "./ReffuseRuntime.js" + + +export class ReffuseContext { + readonly Context = React.createContext>(null!) + readonly Provider = makeProvider(this.Context) + readonly AsyncProvider = makeAsyncProvider(this.Context) + + + useContext(): Context.Context { + return React.useContext(this.Context) + } + + useLayer(): Layer.Layer { + const context = this.useContext() + return React.useMemo(() => Layer.effectContext(Effect.succeed(context)), [context]) + } +} + +export type R = T extends ReffuseContext ? R : never + + +export type ReactProvider = React.FC<{ + readonly layer: Layer.Layer + readonly scope?: Scope.Scope + readonly children?: React.ReactNode +}> + +const makeProvider = (Context: React.Context>): ReactProvider => { + return function ReffuseContextReactProvider(props) { + const runtime = ReffuseRuntime.useRuntime() + const runSync = React.useMemo(() => Runtime.runSync(runtime), [runtime]) + + const makeScope = React.useMemo(() => props.scope + ? Scope.fork(props.scope, ExecutionStrategy.sequential) + : Scope.make(), + [props.scope]) + + const makeContext = React.useCallback((scope: Scope.CloseableScope) => Effect.context().pipe( + Effect.provide(props.layer), + Effect.provideService(Scope.Scope, scope), + ), [props.layer]) + + const [isInitialRun, initialScope, initialValue] = React.useMemo(() => Effect.Do.pipe( + Effect.bind("isInitialRun", () => Ref.make(true)), + Effect.bind("scope", () => makeScope), + Effect.bind("context", ({ scope }) => makeContext(scope)), + Effect.map(({ isInitialRun, scope, context }) => [isInitialRun, scope, context] as const), + runSync, + ), []) + + const [value, setValue] = React.useState(initialValue) + + React.useEffect(() => isInitialRun.pipe( + Effect.if({ + onTrue: () => Ref.set(isInitialRun, false).pipe( + Effect.map(() => + () => runSync(Scope.close(initialScope, Exit.void)) + ) + ), + + onFalse: () => Effect.Do.pipe( + Effect.bind("scope", () => makeScope), + Effect.bind("context", ({ scope }) => makeContext(scope)), + Effect.tap(({ context }) => + Effect.sync(() => setValue(context)) + ), + Effect.map(({ scope }) => + () => runSync(Scope.close(scope, Exit.void)) + ), + ), + }), + + runSync, + ), [makeScope, makeContext, runSync]) + + return React.createElement(Context, { ...props, value }) + } +} + +export type AsyncReactProvider = React.FC<{ + readonly layer: Layer.Layer + readonly scope?: Scope.Scope + readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy + readonly fallback?: React.ReactNode + readonly children?: React.ReactNode +}> + +const makeAsyncProvider = (Context: React.Context>): AsyncReactProvider => { + function ReffuseContextAsyncReactProviderInner({ promise, children }: { + readonly promise: Promise> + readonly children?: React.ReactNode + }) { + return React.createElement(Context, { + value: React.use(promise), + children, + }) + } + + return function ReffuseContextAsyncReactProvider(props) { + const runtime = ReffuseRuntime.useRuntime() + const runSync = React.useMemo(() => Runtime.runSync(runtime), [runtime]) + const runFork = React.useMemo(() => Runtime.runFork(runtime), [runtime]) + + const [promise, setPromise] = React.useState(Promise.withResolvers>().promise) + + React.useEffect(() => { + const { promise, resolve, reject } = Promise.withResolvers>() + setPromise(promise) + + const scope = runSync(props.scope + ? Scope.fork(props.scope, props.finalizerExecutionStrategy ?? ExecutionStrategy.sequential) + : Scope.make(props.finalizerExecutionStrategy) + ) + + Effect.context().pipe( + Effect.match({ + onSuccess: resolve, + onFailure: reject, + }), + + Effect.provide(props.layer), + Effect.provideService(Scope.Scope, scope), + effect => runFork(effect, { ...props, scope }), + ) + + return () => { runFork(Scope.close(scope, Exit.void)) } + }, [props.layer, runSync, runFork]) + + return React.createElement(React.Suspense, { + children: React.createElement(ReffuseContextAsyncReactProviderInner, { ...props, promise }), + fallback: props.fallback, + }) + } +} + + +export const make = () => new ReffuseContext() + +export const useMergeAll = >( + ...contexts: [...{ [K in keyof T]: ReffuseContext }] +): Context.Context => { + const values = contexts.map(v => React.use(v.Context)) + return React.useMemo(() => Context.mergeAll(...values), values) +} + +export const useMergeAllLayers = >( + ...contexts: [...{ [K in keyof T]: ReffuseContext }] +): Layer.Layer => { + const values = contexts.map(v => React.use(v.Context)) + + return React.useMemo(() => Array.isNonEmptyArray(values) + ? Layer.mergeAll( + ...Array.map(values, context => Layer.effectContext(Effect.succeed(context))) + ) + : Layer.empty as Layer.Layer, + values) +} diff --git a/packages/reffuse/src/ReffuseContext.tsx b/packages/reffuse/src/ReffuseContext.tsx deleted file mode 100644 index 89d1394..0000000 --- a/packages/reffuse/src/ReffuseContext.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { Array, Context, Effect, Layer, Runtime } from "effect" -import * as React from "react" -import * as ReffuseRuntime from "./ReffuseRuntime.js" - - -export class ReffuseContext { - readonly Context = React.createContext>(null!) - readonly Provider = makeProvider(this.Context) - readonly AsyncProvider = makeAsyncProvider(this.Context) - - - useContext(): Context.Context { - return React.useContext(this.Context) - } - - useLayer(): Layer.Layer { - const context = this.useContext() - return React.useMemo(() => Layer.effectContext(Effect.succeed(context)), [context]) - } -} - -export type R = T extends ReffuseContext ? R : never - - -export type ReactProvider = React.FC<{ - readonly layer: Layer.Layer - readonly children?: React.ReactNode -}> - -function makeProvider(Context: React.Context>): ReactProvider { - return function ReffuseContextReactProvider(props) { - const runtime = ReffuseRuntime.useRuntime() - - const value = React.useMemo(() => Effect.context().pipe( - Effect.provide(props.layer), - Runtime.runSync(runtime), - ), [props.layer, runtime]) - - return ( - - ) - } -} - -export type AsyncReactProvider = React.FC<{ - readonly layer: Layer.Layer - readonly fallback?: React.ReactNode - readonly children?: React.ReactNode -}> - -function makeAsyncProvider(Context: React.Context>): AsyncReactProvider { - function Inner({ promise, children }: { - readonly promise: Promise> - readonly children?: React.ReactNode - }) { - const value = React.use(promise) - - return ( - - ) - } - - return function ReffuseContextAsyncReactProvider(props) { - const runtime = ReffuseRuntime.useRuntime() - - const promise = React.useMemo(() => Effect.context().pipe( - Effect.provide(props.layer), - Runtime.runPromise(runtime), - ), [props.layer, runtime]) - - return ( - - - - ) - } -} - - -export function make() { - return new ReffuseContext() -} - -export function useMergeAll>( - ...contexts: [...{ [K in keyof T]: ReffuseContext }] -): Context.Context { - const values = contexts.map(v => React.use(v.Context)) - return React.useMemo(() => Context.mergeAll(...values), values) -} - -export function useMergeAllLayers>( - ...contexts: [...{ [K in keyof T]: ReffuseContext }] -): Layer.Layer { - const values = contexts.map(v => React.use(v.Context)) - - return React.useMemo(() => Array.isNonEmptyArray(values) - ? Layer.mergeAll( - ...Array.map(values, context => Layer.effectContext(Effect.succeed(context))) - ) - : Layer.empty as Layer.Layer, - values) -} diff --git a/packages/reffuse/src/ReffuseHelpers.ts b/packages/reffuse/src/ReffuseHelpers.ts index 81dae91..b66c241 100644 --- a/packages/reffuse/src/ReffuseHelpers.ts +++ b/packages/reffuse/src/ReffuseHelpers.ts @@ -108,41 +108,38 @@ export abstract class ReffuseHelpers { ): 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), - )), - + const [isInitialRun, initialScope, initialValue] = React.useMemo(() => Effect.Do.pipe( + Effect.bind("isInitialRun", () => Ref.make(true)), + Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy)), + Effect.bind("value", ({ scope }) => Effect.provideService(effect(), Scope.Scope, scope)), + Effect.map(({ isInitialRun, scope, value }) => [isInitialRun, 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 closeInitialScopeIfNeeded = Scope.close(initialScope, Exit.void).pipe( - Effect.andThen(Effect.sync(() => { initialScopeClosed.current = true })), - Effect.when(() => !initialScopeClosed.current), - ) + React.useEffect(() => isInitialRun.pipe( + Effect.if({ + onTrue: () => Ref.set(isInitialRun, false).pipe( + Effect.map(() => + () => runSync(Scope.close(initialScope, Exit.void)) + ) + ), - const [scope, value] = closeInitialScopeIfNeeded.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), - )) - )), + onFalse: () => Effect.Do.pipe( + Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy)), + Effect.bind("value", ({ scope }) => Effect.provideService(effect(), Scope.Scope, scope)), + Effect.tap(({ value }) => + Effect.sync(() => setValue(value)) + ), + Effect.map(({ scope }) => + () => runSync(Scope.close(scope, Exit.void)) + ), + ), + }), - runSync, - ) - - setValue(value) - return () => { runSync(Scope.close(scope, Exit.void)) } - }, [ + runSync, + ), [ ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync], ...deps, ]) @@ -428,20 +425,26 @@ export interface ReffuseHelpers extends Pipeable.Pipeable {} ReffuseHelpers.prototype.pipe = function pipe() { return Pipeable.pipeArguments(this, arguments) -} +}; export interface ReffuseHelpersClass extends Pipeable.Pipeable { new(): ReffuseHelpers + make(this: new () => Self): Self readonly contexts: readonly ReffuseContext.ReffuseContext[] } +(ReffuseHelpers as ReffuseHelpersClass).make = function make() { + return new this() +}; + (ReffuseHelpers as ReffuseHelpersClass).pipe = function pipe() { return Pipeable.pipeArguments(this, arguments) -} +}; -export const make = (): ReffuseHelpersClass => +export const make = (): ReffuseHelpersClass => ( class extends (ReffuseHelpers as ReffuseHelpersClass) { static readonly contexts = [] } +) diff --git a/packages/reffuse/src/ReffuseRuntime.ts b/packages/reffuse/src/ReffuseRuntime.ts new file mode 100644 index 0000000..d070bb2 --- /dev/null +++ b/packages/reffuse/src/ReffuseRuntime.ts @@ -0,0 +1,16 @@ +import { Runtime } from "effect" +import * as React from "react" + + +export const Context = React.createContext>(null!) + +export const Provider = function ReffuseRuntimeReactProvider(props: { + readonly children?: React.ReactNode +}) { + return React.createElement(Context, { + ...props, + value: Runtime.defaultRuntime, + }) +} + +export const useRuntime = () => React.useContext(Context) diff --git a/packages/reffuse/src/ReffuseRuntime.tsx b/packages/reffuse/src/ReffuseRuntime.tsx deleted file mode 100644 index 3a6c75e..0000000 --- a/packages/reffuse/src/ReffuseRuntime.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Runtime } from "effect" -import * as React from "react" - - -export const Context = React.createContext>(null!) - -export const Provider = (props: { readonly children?: React.ReactNode }) => ( - -) -Provider.displayName = "ReffuseRuntimeReactProvider" - -export const useRuntime = () => React.useContext(Context)