16 Commits

Author SHA1 Message Date
Julien Valverdé
6fa73ee33f Tests
All checks were successful
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
All checks were successful
Lint / lint (push) Successful in 14s
2025-06-29 18:20:46 +02:00
Julien Valverdé
8b2abbbd19 Fix
All checks were successful
Lint / lint (push) Successful in 14s
2025-06-29 17:07:05 +02:00
Julien Valverdé
152657d97b Effect LSP
Some checks failed
Lint / lint (push) Failing after 13s
2025-06-29 15:27:49 +02:00
Julien Valverdé
faf1d4963c Work
Some checks failed
Lint / lint (push) Failing after 28s
2025-06-27 05:43:58 +02:00
Julien Valverdé
9ba36ebc04 Test
All checks were successful
Lint / lint (push) Successful in 14s
2025-06-26 04:29:11 +02:00
Julien Valverdé
f327728b3a Fix
All checks were successful
Lint / lint (push) Successful in 13s
2025-06-26 04:26:58 +02:00
Julien Valverdé
8920674b26 Component scope
All checks were successful
Lint / lint (push) Successful in 15s
2025-06-26 04:22:27 +02:00
Julien Valverdé
b440503e50 Hook work
All checks were successful
Lint / lint (push) Successful in 15s
2025-06-26 02:55:20 +02:00
Julien Valverdé
4088d86652 Work
All checks were successful
Lint / lint (push) Successful in 15s
2025-06-26 01:26:25 +02:00
Julien Valverdé
79cf1e5eb7 API change
All checks were successful
Lint / lint (push) Successful in 31s
2025-06-25 21:51:46 +02:00
Julien Valverdé
8007c2693a Tests
All checks were successful
Lint / lint (push) Successful in 14s
2025-06-25 13:38:44 +02:00
Julien Valverdé
1769c4074d ReactComponent
Some checks failed
Lint / lint (push) Failing after 7s
2025-06-25 13:17:27 +02:00
Julien Valverdé
8c5613aa62 Tests
All checks were successful
Lint / lint (push) Successful in 14s
2025-06-25 04:07:22 +02:00
Julien Valverdé
7d220cb61a Tests
All checks were successful
Lint / lint (push) Successful in 14s
2025-06-25 02:40:54 +02:00
11 changed files with 359 additions and 3 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

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=="],

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+

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"
}
}

View File

@@ -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))
})

View File

@@ -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)
})

View File

@@ -0,0 +1,2 @@
export * as ReactComponent from "./ReactComponent.js"
export * as ReactHook from "./ReactHook.js"

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"]
}

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"
},

View File

@@ -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>
</>
})

View File

@@ -26,7 +26,13 @@
// Build
"outDir": "./dist",
"declaration": true
"declaration": true,
"plugins": [
{
"name": "@effect/language-service"
}
]
},
"include": ["./src"]