16 Commits

Author SHA1 Message Date
Julien Valverdé 6fa73ee33f Tests
Lint / lint (push) Successful in 15s
2025-06-29 19:11:16 +02:00
Julien Valverdé 3ea4c81872 React component refactoring 2025-06-29 18:52:42 +02:00
Julien Valverdé 782629d5b3 Refactoring
Lint / lint (push) Successful in 14s
2025-06-29 18:20:46 +02:00
Julien Valverdé 8b2abbbd19 Fix
Lint / lint (push) Successful in 14s
2025-06-29 17:07:05 +02:00
Julien Valverdé 152657d97b Effect LSP
Lint / lint (push) Failing after 13s
2025-06-29 15:27:49 +02:00
Julien Valverdé faf1d4963c Work
Lint / lint (push) Failing after 28s
2025-06-27 05:43:58 +02:00
Julien Valverdé 9ba36ebc04 Test
Lint / lint (push) Successful in 14s
2025-06-26 04:29:11 +02:00
Julien Valverdé f327728b3a Fix
Lint / lint (push) Successful in 13s
2025-06-26 04:26:58 +02:00
Julien Valverdé 8920674b26 Component scope
Lint / lint (push) Successful in 15s
2025-06-26 04:22:27 +02:00
Julien Valverdé b440503e50 Hook work
Lint / lint (push) Successful in 15s
2025-06-26 02:55:20 +02:00
Julien Valverdé 4088d86652 Work
Lint / lint (push) Successful in 15s
2025-06-26 01:26:25 +02:00
Julien Valverdé 79cf1e5eb7 API change
Lint / lint (push) Successful in 31s
2025-06-25 21:51:46 +02:00
Julien Valverdé 8007c2693a Tests
Lint / lint (push) Successful in 14s
2025-06-25 13:38:44 +02:00
Julien Valverdé 1769c4074d ReactComponent
Lint / lint (push) Failing after 7s
2025-06-25 13:17:27 +02:00
Julien Valverdé 8c5613aa62 Tests
Lint / lint (push) Successful in 14s
2025-06-25 04:07:22 +02:00
Julien Valverdé 7d220cb61a Tests
Lint / lint (push) Successful in 14s
2025-06-25 02:40:54 +02:00
11 changed files with 359 additions and 3 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
+18 -2
View File
@@ -10,6 +10,18 @@
"typescript": "^5.8.3",
},
},
"packages/effect-components": {
"name": "effect-components",
"version": "0.1.0",
"devDependencies": {
"@effect/language-service": "^0.23.3",
},
"peerDependencies": {
"@types/react": "^19.0.0",
"effect": "^3.15.0",
"react": "^19.0.0",
},
},
"packages/example": {
"name": "@reffuse/example",
"version": "0.0.0",
@@ -62,7 +74,7 @@
},
"packages/extension-query": {
"name": "@reffuse/extension-query",
"version": "0.1.3",
"version": "0.1.5",
"devDependencies": {
"reffuse": "workspace:*",
},
@@ -78,7 +90,7 @@
},
"packages/reffuse": {
"name": "reffuse",
"version": "0.1.11",
"version": "0.1.13",
"peerDependencies": {
"@types/react": "^19.0.0",
"effect": "^3.15.0",
@@ -129,6 +141,8 @@
"@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="],
"@effect/language-service": ["@effect/language-service@0.23.3", "", {}, "sha512-yurF+FHd1HwM/3Mh7kQCea+z4wvbs2/NLPyk6FiEWA2sppRKv4Kp4luwfUqWqyd9/uyScWJcHX7WK1caLpx4Pw=="],
"@effect/platform": ["@effect/platform@0.82.1", "", { "dependencies": { "find-my-way-ts": "^0.1.5", "msgpackr": "^1.11.2", "multipasta": "^0.2.5" }, "peerDependencies": { "effect": "^3.15.1" } }, "sha512-fX5Lu//VkLXPegouxT1AdSyuRkxF55k70YaLV0vIzjgK97/u3Mow0ux8fYglm2dWDXWTLBkNprlhheGm/5/bvQ=="],
"@effect/platform-browser": ["@effect/platform-browser@0.62.1", "", { "dependencies": { "multipasta": "^0.2.5" }, "peerDependencies": { "@effect/platform": "^0.82.1", "effect": "^3.15.1" } }, "sha512-+aioMY5OsD9SQc7S88yv6tlWpkKhbA5Dv3lDs4CXQbRL5TWuHjzzDGpFNRhCBdv5ouAjoBAzu2Zi4+HIaWYqHQ=="],
@@ -573,6 +587,8 @@
"effect": ["effect@3.15.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-n3bDF6K3R+FSVuH+dSVU3ya2pI4Wt/tnKzum3DC/3b5e0E9HfhrhbkonOkYU3AVJJOzCA6zZE2/y6EUgQNAY4g=="],
"effect-components": ["effect-components@workspace:packages/effect-components"],
"electron-to-chromium": ["electron-to-chromium@1.5.152", "", {}, "sha512-xBOfg/EBaIlVsHipHl2VdTPJRSvErNUaqW8ejTq5OlOlIYx1wOllCHsAvAIrr55jD1IYEfdR86miUEt8H5IeJg=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
+11
View File
@@ -0,0 +1,11 @@
# Reffuse
[Effect-TS](https://effect.website/) integration for React 19+ with the aim of integrating the Effect context system within React's component hierarchy, while avoiding touching React's internals.
This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome.
Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
## Peer dependencies
- `effect` 3.13+
- `react` & `@types/react` 19+
+40
View File
@@ -0,0 +1,40 @@
{
"name": "effect-components",
"version": "0.1.0",
"type": "module",
"files": [
"./README.md",
"./dist"
],
"license": "MIT",
"repository": {
"url": "git+https://github.com/Thiladev/reffuse.git"
},
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./*": {
"types": "./dist/*.d.ts",
"default": "./dist/*.js"
}
},
"scripts": {
"build": "tsc",
"lint:tsc": "tsc --noEmit",
"pack": "npm pack",
"clean:cache": "rm -f tsconfig.tsbuildinfo",
"clean:dist": "rm -rf dist",
"clean:node": "rm -rf node_modules"
},
"peerDependencies": {
"@types/react": "^19.0.0",
"effect": "^3.15.0",
"react": "^19.0.0"
},
"devDependencies": {
"@effect/language-service": "^0.23.3"
}
}
@@ -0,0 +1,80 @@
import { Context, Effect, ExecutionStrategy, Exit, Ref, Runtime, Scope, Tracer } from "effect"
import * as React from "react"
import * as ReactHook from "./ReactHook.js"
export interface ReactComponent<P, E, R> {
(props: P): Effect.Effect<React.ReactNode, E, R>
}
export const nonReactiveTags = [Tracer.ParentSpan] as const
export const useFC: {
<P, E, R>(
self: ReactComponent<P, E, R>,
options?: ReactHook.ScopeOptions,
): Effect.Effect<React.FC<P>, never, Exclude<R, Scope.Scope>>
} = Effect.fnUntraced(function* useFC<P, E, R>(
self: ReactComponent<P, E, R>,
options?: ReactHook.ScopeOptions,
) {
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
return React.useCallback((props: P) => {
const [isInitialRun, initialScope] = React.useMemo(() => Runtime.runSync(runtime)(
Effect.all([Ref.make(true), makeScope(options)])
), [])
const [scope, setScope] = React.useState(initialScope)
React.useEffect(() => Runtime.runSync(runtime)(
Effect.if(isInitialRun, {
onTrue: () => Effect.as(
Ref.set(isInitialRun, false),
() => closeScope(scope, runtime, options),
),
onFalse: () => makeScope(options).pipe(
Effect.tap(scope => Effect.sync(() => setScope(scope))),
Effect.map(scope => () => closeScope(scope, runtime, options)),
),
})
), [])
return Runtime.runSync(runtime)(
Effect.provideService(self(props), Scope.Scope, scope)
)
}, Array.from(
Context.omit(...nonReactiveTags)(runtime.context).unsafeMap.values()
))
})
const makeScope = (options?: ReactHook.ScopeOptions) => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
const closeScope = (
scope: Scope.CloseableScope,
runtime: Runtime.Runtime<never>,
options?: ReactHook.ScopeOptions,
) => {
switch (options?.finalizerExecutionMode ?? "sync") {
case "sync":
Runtime.runSync(runtime)(Scope.close(scope, Exit.void))
break
case "fork":
Runtime.runFork(runtime)(Scope.close(scope, Exit.void))
break
}
}
export const use: {
<P, E, R>(
self: ReactComponent<P, E, R>,
fn: (Component: React.FC<P>) => React.ReactNode,
options?: ReactHook.ScopeOptions,
): Effect.Effect<React.ReactNode, never, Exclude<R, Scope.Scope>>
} = Effect.fnUntraced(function* use<P, E, R>(
self: ReactComponent<P, E, R>,
fn: (Component: React.FC<P>) => React.ReactNode,
options?: ReactHook.ScopeOptions,
) {
return fn(yield* useFC(self, options))
})
@@ -0,0 +1,96 @@
import { Effect, ExecutionStrategy, Runtime, Scope } from "effect"
import * as React from "react"
export interface ScopeOptions {
readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy
readonly finalizerExecutionMode?: "sync" | "fork"
}
export const useMemo: {
<A, E, R>(
factory: () => Effect.Effect<A, E, R>,
deps: React.DependencyList,
): Effect.Effect<A, never, R>
} = Effect.fnUntraced(function* useMemo<A, E, R>(
factory: () => Effect.Effect<A, E, R>,
deps: React.DependencyList,
) {
const runtime = yield* Effect.runtime<R>()
return React.useMemo(() => Runtime.runSync(runtime)(factory()), deps)
})
export const useOnce: {
<A, E, R>(factory: () => Effect.Effect<A, E, R>): Effect.Effect<A, never, R>
} = Effect.fnUntraced(function* useOnce<A, E, R>(
factory: () => Effect.Effect<A, E, R>
) {
return yield* useMemo(factory, [])
})
export const useEffect: {
<E, R>(
effect: () => Effect.Effect<void, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: ScopeOptions,
): Effect.Effect<void, never, R>
} = Effect.fnUntraced(function* useEffect<E, R>(
effect: () => Effect.Effect<void, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: ScopeOptions,
) {
const runtime = yield* Effect.runtime<R>()
React.useEffect(() => {
const { scope, exit } = Effect.Do.pipe(
Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)),
Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(effect(), Scope.Scope, scope))),
Runtime.runSync(runtime),
)
return () => {
switch (options?.finalizerExecutionMode ?? "sync") {
case "sync":
Runtime.runSync(runtime)(Scope.close(scope, exit))
break
case "fork":
Runtime.runFork(runtime)(Scope.close(scope, exit))
break
}
}
}, deps)
})
export const useLayoutEffect: {
<E, R>(
effect: () => Effect.Effect<void, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: ScopeOptions,
): Effect.Effect<void, never, R>
} = Effect.fnUntraced(function* useLayoutEffect<E, R>(
effect: () => Effect.Effect<void, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: ScopeOptions,
) {
const runtime = yield* Effect.runtime<R>()
React.useLayoutEffect(() => {
const { scope, exit } = Effect.Do.pipe(
Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)),
Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(effect(), Scope.Scope, scope))),
Runtime.runSync(runtime),
)
return () => {
switch (options?.finalizerExecutionMode ?? "sync") {
case "sync":
Runtime.runSync(runtime)(Scope.close(scope, exit))
break
case "fork":
Runtime.runFork(runtime)(Scope.close(scope, exit))
break
}
}
}, deps)
})
+2
View File
@@ -0,0 +1,2 @@
export * as ReactComponent from "./ReactComponent.js"
export * as ReactHook from "./ReactHook.js"
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "NodeNext",
"moduleDetection": "force",
"jsx": "react-jsx",
// "allowJs": true,
// Bundler mode
"moduleResolution": "NodeNext",
// "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
// "noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
// Build
"outDir": "./dist",
"declaration": true
},
"include": ["./src"]
}
+26
View File
@@ -16,6 +16,7 @@ import { Route as TimeImport } from './routes/time'
import { Route as TestsImport } from './routes/tests'
import { Route as PromiseImport } from './routes/promise'
import { Route as LazyrefImport } from './routes/lazyref'
import { Route as EffectComponentTestsImport } from './routes/effect-component-tests'
import { Route as CountImport } from './routes/count'
import { Route as BlankImport } from './routes/blank'
import { Route as IndexImport } from './routes/index'
@@ -56,6 +57,12 @@ const LazyrefRoute = LazyrefImport.update({
getParentRoute: () => rootRoute,
} as any)
const EffectComponentTestsRoute = EffectComponentTestsImport.update({
id: '/effect-component-tests',
path: '/effect-component-tests',
getParentRoute: () => rootRoute,
} as any)
const CountRoute = CountImport.update({
id: '/count',
path: '/count',
@@ -123,6 +130,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CountImport
parentRoute: typeof rootRoute
}
'/effect-component-tests': {
id: '/effect-component-tests'
path: '/effect-component-tests'
fullPath: '/effect-component-tests'
preLoaderRoute: typeof EffectComponentTestsImport
parentRoute: typeof rootRoute
}
'/lazyref': {
id: '/lazyref'
path: '/lazyref'
@@ -195,6 +209,7 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/count': typeof CountRoute
'/effect-component-tests': typeof EffectComponentTestsRoute
'/lazyref': typeof LazyrefRoute
'/promise': typeof PromiseRoute
'/tests': typeof TestsRoute
@@ -210,6 +225,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/count': typeof CountRoute
'/effect-component-tests': typeof EffectComponentTestsRoute
'/lazyref': typeof LazyrefRoute
'/promise': typeof PromiseRoute
'/tests': typeof TestsRoute
@@ -226,6 +242,7 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/count': typeof CountRoute
'/effect-component-tests': typeof EffectComponentTestsRoute
'/lazyref': typeof LazyrefRoute
'/promise': typeof PromiseRoute
'/tests': typeof TestsRoute
@@ -243,6 +260,7 @@ export interface FileRouteTypes {
| '/'
| '/blank'
| '/count'
| '/effect-component-tests'
| '/lazyref'
| '/promise'
| '/tests'
@@ -257,6 +275,7 @@ export interface FileRouteTypes {
| '/'
| '/blank'
| '/count'
| '/effect-component-tests'
| '/lazyref'
| '/promise'
| '/tests'
@@ -271,6 +290,7 @@ export interface FileRouteTypes {
| '/'
| '/blank'
| '/count'
| '/effect-component-tests'
| '/lazyref'
| '/promise'
| '/tests'
@@ -287,6 +307,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
BlankRoute: typeof BlankRoute
CountRoute: typeof CountRoute
EffectComponentTestsRoute: typeof EffectComponentTestsRoute
LazyrefRoute: typeof LazyrefRoute
PromiseRoute: typeof PromiseRoute
TestsRoute: typeof TestsRoute
@@ -302,6 +323,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
BlankRoute: BlankRoute,
CountRoute: CountRoute,
EffectComponentTestsRoute: EffectComponentTestsRoute,
LazyrefRoute: LazyrefRoute,
PromiseRoute: PromiseRoute,
TestsRoute: TestsRoute,
@@ -326,6 +348,7 @@ export const routeTree = rootRoute
"/",
"/blank",
"/count",
"/effect-component-tests",
"/lazyref",
"/promise",
"/tests",
@@ -346,6 +369,9 @@ export const routeTree = rootRoute
"/count": {
"filePath": "count.tsx"
},
"/effect-component-tests": {
"filePath": "effect-component-tests.tsx"
},
"/lazyref": {
"filePath": "lazyref.tsx"
},
@@ -0,0 +1,43 @@
import { Box, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Layer, ManagedRuntime, SubscriptionRef } from "effect"
import { ReactComponent, ReactHook } from "effect-components"
import * as React from "react"
export const Route = createFileRoute("/effect-component-tests")({
component: RouteComponent,
})
function RouteComponent() {
const runtime = React.useMemo(() => ManagedRuntime.make(Layer.empty), [])
return <>
{runtime.runSync(ReactComponent.use(MyTestComponent, Component => (
<Component />
)))}
</>
}
class TestService extends Effect.Service<TestService>()("TestService", {
effect: Effect.bind(Effect.Do, "ref", () => SubscriptionRef.make("value")),
}) {}
const MyTestComponent = Effect.fn(function* MyTestComponent(props?: { readonly value?: string }) {
const [state, setState] = React.useState("value")
// yield* ReactHook.useMemo(() => Effect.andThen(
// Effect.addFinalizer(() => Console.log("MyTestComponent umounted")),
// Console.log("MyTestComponent mounted"),
// ), [])
return <>
<Box>
<TextField.Root
value={state}
onChange={e => setState(e.target.value)}
/>
</Box>
</>
})
+7 -1
View File
@@ -26,7 +26,13 @@
// Build
"outDir": "./dist",
"declaration": true
"declaration": true,
"plugins": [
{
"name": "@effect/language-service"
}
]
},
"include": ["./src"]