0.1.5 (#7)
All checks were successful
Publish / publish (push) Successful in 26s
Lint / lint (push) Successful in 12s

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: https://gitea:3000/Thilawyn/reffuse/pulls/7
This commit was merged in pull request #7.
This commit is contained in:
Julien Valverdé
2025-03-28 21:24:41 +01:00
parent d01152bdcf
commit 74fa30cf4f
10 changed files with 280 additions and 184 deletions

View File

@@ -11,6 +11,7 @@
// Import Routes // Import Routes
import { Route as rootRoute } from './routes/__root' import { Route as rootRoute } from './routes/__root'
import { Route as TodosImport } from './routes/todos'
import { Route as TimeImport } from './routes/time' import { Route as TimeImport } from './routes/time'
import { Route as TestsImport } from './routes/tests' import { Route as TestsImport } from './routes/tests'
import { Route as PromiseImport } from './routes/promise' import { Route as PromiseImport } from './routes/promise'
@@ -24,6 +25,12 @@ import { Route as QueryServiceImport } from './routes/query/service'
// Create/Update Routes // Create/Update Routes
const TodosRoute = TodosImport.update({
id: '/todos',
path: '/todos',
getParentRoute: () => rootRoute,
} as any)
const TimeRoute = TimeImport.update({ const TimeRoute = TimeImport.update({
id: '/time', id: '/time',
path: '/time', path: '/time',
@@ -137,6 +144,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TimeImport preLoaderRoute: typeof TimeImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/todos': {
id: '/todos'
path: '/todos'
fullPath: '/todos'
preLoaderRoute: typeof TodosImport
parentRoute: typeof rootRoute
}
'/query/service': { '/query/service': {
id: '/query/service' id: '/query/service'
path: '/query/service' path: '/query/service'
@@ -171,6 +185,7 @@ export interface FileRoutesByFullPath {
'/promise': typeof PromiseRoute '/promise': typeof PromiseRoute
'/tests': typeof TestsRoute '/tests': typeof TestsRoute
'/time': typeof TimeRoute '/time': typeof TimeRoute
'/todos': typeof TodosRoute
'/query/service': typeof QueryServiceRoute '/query/service': typeof QueryServiceRoute
'/query/usemutation': typeof QueryUsemutationRoute '/query/usemutation': typeof QueryUsemutationRoute
'/query/usequery': typeof QueryUsequeryRoute '/query/usequery': typeof QueryUsequeryRoute
@@ -184,6 +199,7 @@ export interface FileRoutesByTo {
'/promise': typeof PromiseRoute '/promise': typeof PromiseRoute
'/tests': typeof TestsRoute '/tests': typeof TestsRoute
'/time': typeof TimeRoute '/time': typeof TimeRoute
'/todos': typeof TodosRoute
'/query/service': typeof QueryServiceRoute '/query/service': typeof QueryServiceRoute
'/query/usemutation': typeof QueryUsemutationRoute '/query/usemutation': typeof QueryUsemutationRoute
'/query/usequery': typeof QueryUsequeryRoute '/query/usequery': typeof QueryUsequeryRoute
@@ -198,6 +214,7 @@ export interface FileRoutesById {
'/promise': typeof PromiseRoute '/promise': typeof PromiseRoute
'/tests': typeof TestsRoute '/tests': typeof TestsRoute
'/time': typeof TimeRoute '/time': typeof TimeRoute
'/todos': typeof TodosRoute
'/query/service': typeof QueryServiceRoute '/query/service': typeof QueryServiceRoute
'/query/usemutation': typeof QueryUsemutationRoute '/query/usemutation': typeof QueryUsemutationRoute
'/query/usequery': typeof QueryUsequeryRoute '/query/usequery': typeof QueryUsequeryRoute
@@ -213,6 +230,7 @@ export interface FileRouteTypes {
| '/promise' | '/promise'
| '/tests' | '/tests'
| '/time' | '/time'
| '/todos'
| '/query/service' | '/query/service'
| '/query/usemutation' | '/query/usemutation'
| '/query/usequery' | '/query/usequery'
@@ -225,6 +243,7 @@ export interface FileRouteTypes {
| '/promise' | '/promise'
| '/tests' | '/tests'
| '/time' | '/time'
| '/todos'
| '/query/service' | '/query/service'
| '/query/usemutation' | '/query/usemutation'
| '/query/usequery' | '/query/usequery'
@@ -237,6 +256,7 @@ export interface FileRouteTypes {
| '/promise' | '/promise'
| '/tests' | '/tests'
| '/time' | '/time'
| '/todos'
| '/query/service' | '/query/service'
| '/query/usemutation' | '/query/usemutation'
| '/query/usequery' | '/query/usequery'
@@ -251,6 +271,7 @@ export interface RootRouteChildren {
PromiseRoute: typeof PromiseRoute PromiseRoute: typeof PromiseRoute
TestsRoute: typeof TestsRoute TestsRoute: typeof TestsRoute
TimeRoute: typeof TimeRoute TimeRoute: typeof TimeRoute
TodosRoute: typeof TodosRoute
QueryServiceRoute: typeof QueryServiceRoute QueryServiceRoute: typeof QueryServiceRoute
QueryUsemutationRoute: typeof QueryUsemutationRoute QueryUsemutationRoute: typeof QueryUsemutationRoute
QueryUsequeryRoute: typeof QueryUsequeryRoute QueryUsequeryRoute: typeof QueryUsequeryRoute
@@ -264,6 +285,7 @@ const rootRouteChildren: RootRouteChildren = {
PromiseRoute: PromiseRoute, PromiseRoute: PromiseRoute,
TestsRoute: TestsRoute, TestsRoute: TestsRoute,
TimeRoute: TimeRoute, TimeRoute: TimeRoute,
TodosRoute: TodosRoute,
QueryServiceRoute: QueryServiceRoute, QueryServiceRoute: QueryServiceRoute,
QueryUsemutationRoute: QueryUsemutationRoute, QueryUsemutationRoute: QueryUsemutationRoute,
QueryUsequeryRoute: QueryUsequeryRoute, QueryUsequeryRoute: QueryUsequeryRoute,
@@ -286,6 +308,7 @@ export const routeTree = rootRoute
"/promise", "/promise",
"/tests", "/tests",
"/time", "/time",
"/todos",
"/query/service", "/query/service",
"/query/usemutation", "/query/usemutation",
"/query/usequery" "/query/usequery"
@@ -312,6 +335,9 @@ export const routeTree = rootRoute
"/time": { "/time": {
"filePath": "time.tsx" "filePath": "time.tsx"
}, },
"/todos": {
"filePath": "todos.tsx"
},
"/query/service": { "/query/service": {
"filePath": "query/service.tsx" "filePath": "query/service.tsx"
}, },

View File

@@ -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 { createFileRoute } from "@tanstack/react-router"
import { Layer } from "effect"
import { useMemo } from "react"
export const Route = createFileRoute("/")({ export const Route = createFileRoute('/')({
component: Index component: RouteComponent
}) })
function Index() { function RouteComponent() {
return <div>Hello "/"!</div>
const todosLayer = useMemo(() => Layer.empty.pipe(
Layer.provideMerge(TodosState.make("todos"))
), [])
return (
<Container>
<TodosContext.Provider layer={todosLayer}>
<VTodos />
</TodosContext.Provider>
</Container>
)
} }

View File

@@ -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 (
<Container>
<TodosContext.Provider layer={todosLayer}>
<VTodos />
</TodosContext.Provider>
</Container>
)
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "reffuse", "name": "reffuse",
"version": "0.1.4", "version": "0.1.5",
"type": "module", "type": "module",
"files": [ "files": [
"./README.md", "./README.md",

View File

@@ -9,7 +9,7 @@ export class Reffuse extends ReffuseHelpers.make() {}
export const withContexts = <R2 extends Array<unknown>>( export const withContexts = <R2 extends Array<unknown>>(
...contexts: [...{ [K in keyof R2]: ReffuseContext.ReffuseContext<R2[K]> }] ...contexts: [...{ [K in keyof R2]: ReffuseContext.ReffuseContext<R2[K]> }]
) => ) => (
< <
BaseClass extends ReffuseHelpers.ReffuseHelpersClass<R1>, BaseClass extends ReffuseHelpers.ReffuseHelpersClass<R1>,
R1 R1
@@ -29,9 +29,9 @@ export const withContexts = <R2 extends Array<unknown>>(
) => class extends self { ) => class extends self {
static readonly contexts = [...self.contexts, ...contexts] static readonly contexts = [...self.contexts, ...contexts]
} as any } as any
)
export const withExtension = <A extends object>(extension: ReffuseExtension.ReffuseExtension<A>) => (
export const withExtension = <A extends object>(extension: ReffuseExtension.ReffuseExtension<A>) =>
< <
BaseClass extends ReffuseHelpers.ReffuseHelpersClass<R>, BaseClass extends ReffuseHelpers.ReffuseHelpersClass<R>,
R R
@@ -45,3 +45,4 @@ export const withExtension = <A extends object>(extension: ReffuseExtension.Reff
Object.assign(class_.prototype, extension()) Object.assign(class_.prototype, extension())
return class_ as any return class_ as any
} }
)

View File

@@ -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<R> {
readonly Context = React.createContext<Context.Context<R>>(null!)
readonly Provider = makeProvider(this.Context)
readonly AsyncProvider = makeAsyncProvider(this.Context)
useContext(): Context.Context<R> {
return React.useContext(this.Context)
}
useLayer(): Layer.Layer<R> {
const context = this.useContext()
return React.useMemo(() => Layer.effectContext(Effect.succeed(context)), [context])
}
}
export type R<T> = T extends ReffuseContext<infer R> ? R : never
export type ReactProvider<R> = React.FC<{
readonly layer: Layer.Layer<R, unknown, Scope.Scope>
readonly scope?: Scope.Scope
readonly children?: React.ReactNode
}>
const makeProvider = <R>(Context: React.Context<Context.Context<R>>): ReactProvider<R> => {
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<R>().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<R> = React.FC<{
readonly layer: Layer.Layer<R, unknown, Scope.Scope>
readonly scope?: Scope.Scope
readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy
readonly fallback?: React.ReactNode
readonly children?: React.ReactNode
}>
const makeAsyncProvider = <R>(Context: React.Context<Context.Context<R>>): AsyncReactProvider<R> => {
function ReffuseContextAsyncReactProviderInner({ promise, children }: {
readonly promise: Promise<Context.Context<R>>
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<Context.Context<R>>().promise)
React.useEffect(() => {
const { promise, resolve, reject } = Promise.withResolvers<Context.Context<R>>()
setPromise(promise)
const scope = runSync(props.scope
? Scope.fork(props.scope, props.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
: Scope.make(props.finalizerExecutionStrategy)
)
Effect.context<R>().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 = <R = never>() => new ReffuseContext<R>()
export const useMergeAll = <T extends Array<unknown>>(
...contexts: [...{ [K in keyof T]: ReffuseContext<T[K]> }]
): Context.Context<T[number]> => {
const values = contexts.map(v => React.use(v.Context))
return React.useMemo(() => Context.mergeAll(...values), values)
}
export const useMergeAllLayers = <T extends Array<unknown>>(
...contexts: [...{ [K in keyof T]: ReffuseContext<T[K]> }]
): Layer.Layer<T[number]> => {
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<T[number]>,
values)
}

View File

@@ -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<R> {
readonly Context = React.createContext<Context.Context<R>>(null!)
readonly Provider = makeProvider(this.Context)
readonly AsyncProvider = makeAsyncProvider(this.Context)
useContext(): Context.Context<R> {
return React.useContext(this.Context)
}
useLayer(): Layer.Layer<R> {
const context = this.useContext()
return React.useMemo(() => Layer.effectContext(Effect.succeed(context)), [context])
}
}
export type R<T> = T extends ReffuseContext<infer R> ? R : never
export type ReactProvider<R> = React.FC<{
readonly layer: Layer.Layer<R, unknown>
readonly children?: React.ReactNode
}>
function makeProvider<R>(Context: React.Context<Context.Context<R>>): ReactProvider<R> {
return function ReffuseContextReactProvider(props) {
const runtime = ReffuseRuntime.useRuntime()
const value = React.useMemo(() => Effect.context<R>().pipe(
Effect.provide(props.layer),
Runtime.runSync(runtime),
), [props.layer, runtime])
return (
<Context
{...props}
value={value}
/>
)
}
}
export type AsyncReactProvider<R> = React.FC<{
readonly layer: Layer.Layer<R, unknown>
readonly fallback?: React.ReactNode
readonly children?: React.ReactNode
}>
function makeAsyncProvider<R>(Context: React.Context<Context.Context<R>>): AsyncReactProvider<R> {
function Inner({ promise, children }: {
readonly promise: Promise<Context.Context<R>>
readonly children?: React.ReactNode
}) {
const value = React.use(promise)
return (
<Context
value={value}
children={children}
/>
)
}
return function ReffuseContextAsyncReactProvider(props) {
const runtime = ReffuseRuntime.useRuntime()
const promise = React.useMemo(() => Effect.context<R>().pipe(
Effect.provide(props.layer),
Runtime.runPromise(runtime),
), [props.layer, runtime])
return (
<React.Suspense fallback={props.fallback}>
<Inner
{...props}
promise={promise}
/>
</React.Suspense>
)
}
}
export function make<R = never>() {
return new ReffuseContext<R>()
}
export function useMergeAll<T extends Array<unknown>>(
...contexts: [...{ [K in keyof T]: ReffuseContext<T[K]> }]
): Context.Context<T[number]> {
const values = contexts.map(v => React.use(v.Context))
return React.useMemo(() => Context.mergeAll(...values), values)
}
export function useMergeAllLayers<T extends Array<unknown>>(
...contexts: [...{ [K in keyof T]: ReffuseContext<T[K]> }]
): Layer.Layer<T[number]> {
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<T[number]>,
values)
}

View File

@@ -108,41 +108,38 @@ export abstract class ReffuseHelpers<R> {
): A { ): A {
const runSync = this.useRunSync() const runSync = this.useRunSync()
// Calculate an initial version of the value so that it can be accessed during the first render const [isInitialRun, initialScope, initialValue] = React.useMemo(() => Effect.Do.pipe(
const [initialScope, initialValue] = React.useMemo(() => Scope.make(options?.finalizerExecutionStrategy).pipe( Effect.bind("isInitialRun", () => Ref.make(true)),
Effect.flatMap(scope => effect().pipe( Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy)),
Effect.provideService(Scope.Scope, scope), Effect.bind("value", ({ scope }) => Effect.provideService(effect(), Scope.Scope, scope)),
Effect.map(value => [scope, value] as const), Effect.map(({ isInitialRun, scope, value }) => [isInitialRun, scope, value] as const),
)),
runSync, runSync,
), []) ), [])
// Keep track of the state of the initial scope
const initialScopeClosed = React.useRef(false)
const [value, setValue] = React.useState(initialValue) const [value, setValue] = React.useState(initialValue)
React.useEffect(() => { React.useEffect(() => isInitialRun.pipe(
const closeInitialScopeIfNeeded = Scope.close(initialScope, Exit.void).pipe( Effect.if({
Effect.andThen(Effect.sync(() => { initialScopeClosed.current = true })), onTrue: () => Ref.set(isInitialRun, false).pipe(
Effect.when(() => !initialScopeClosed.current), Effect.map(() =>
() => runSync(Scope.close(initialScope, Exit.void))
) )
),
const [scope, value] = closeInitialScopeIfNeeded.pipe( onFalse: () => Effect.Do.pipe(
Effect.andThen(Scope.make(options?.finalizerExecutionStrategy).pipe( Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy)),
Effect.flatMap(scope => effect().pipe( Effect.bind("value", ({ scope }) => Effect.provideService(effect(), Scope.Scope, scope)),
Effect.provideService(Scope.Scope, scope), Effect.tap(({ value }) =>
Effect.map(value => [scope, value] as const), Effect.sync(() => setValue(value))
)) ),
)), Effect.map(({ scope }) =>
() => runSync(Scope.close(scope, Exit.void))
),
),
}),
runSync, runSync,
) ), [
setValue(value)
return () => { runSync(Scope.close(scope, Exit.void)) }
}, [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync], ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
...deps, ...deps,
]) ])
@@ -428,20 +425,26 @@ export interface ReffuseHelpers<R> extends Pipeable.Pipeable {}
ReffuseHelpers.prototype.pipe = function pipe() { ReffuseHelpers.prototype.pipe = function pipe() {
return Pipeable.pipeArguments(this, arguments) return Pipeable.pipeArguments(this, arguments)
} };
export interface ReffuseHelpersClass<R> extends Pipeable.Pipeable { export interface ReffuseHelpersClass<R> extends Pipeable.Pipeable {
new(): ReffuseHelpers<R> new(): ReffuseHelpers<R>
make<Self>(this: new () => Self): Self
readonly contexts: readonly ReffuseContext.ReffuseContext<R>[] readonly contexts: readonly ReffuseContext.ReffuseContext<R>[]
} }
(ReffuseHelpers as ReffuseHelpersClass<any>).make = function make() {
return new this()
};
(ReffuseHelpers as ReffuseHelpersClass<any>).pipe = function pipe() { (ReffuseHelpers as ReffuseHelpersClass<any>).pipe = function pipe() {
return Pipeable.pipeArguments(this, arguments) return Pipeable.pipeArguments(this, arguments)
} };
export const make = (): ReffuseHelpersClass<never> => export const make = (): ReffuseHelpersClass<never> => (
class extends (ReffuseHelpers<never> as ReffuseHelpersClass<never>) { class extends (ReffuseHelpers<never> as ReffuseHelpersClass<never>) {
static readonly contexts = [] static readonly contexts = []
} }
)

View File

@@ -0,0 +1,16 @@
import { Runtime } from "effect"
import * as React from "react"
export const Context = React.createContext<Runtime.Runtime<never>>(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)

View File

@@ -1,15 +0,0 @@
import { Runtime } from "effect"
import * as React from "react"
export const Context = React.createContext<Runtime.Runtime<never>>(null!)
export const Provider = (props: { readonly children?: React.ReactNode }) => (
<Context
{...props}
value={Runtime.defaultRuntime}
/>
)
Provider.displayName = "ReffuseRuntimeReactProvider"
export const useRuntime = () => React.useContext(Context)