From 3cb0964a48f357efd6ae05c9ff9cd0810b880037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Thu, 17 Jul 2025 21:17:57 +0200 Subject: [PATCH] 0.1.0 (#1) 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/effect-fc/pulls/1 --- .vscode/settings.json | 3 + README.md | 8 +- bun.lock | 4 + packages/effect-fc/README.md | 46 +- packages/effect-fc/package.json | 1 + packages/effect-fc/src/Component.ts | 464 ++++++++++++++++++ .../effect-fc/src/{ReactHook.ts => Hook.ts} | 191 ++++--- packages/effect-fc/src/ReactComponent.ts | 72 --- packages/effect-fc/src/ReactManagedRuntime.ts | 14 +- packages/effect-fc/src/index.ts | 4 +- .../effect-fc/src/types/SubscriptionSubRef.ts | 21 +- packages/effect-fc/src/utils.ts | 3 + .../dfef8bd1830fa25b780317b9e88c0651 | 9 + packages/example/package.json | 4 +- packages/example/src/VQueryErrorHandler.tsx | 57 --- packages/example/src/main.tsx | 26 +- packages/example/src/query/reffuse.ts | 10 - .../example/src/query/services/Uuid4Query.ts | 11 - packages/example/src/query/services/index.ts | 1 - .../src/query/views/Uuid4QueryService.tsx | 32 -- packages/example/src/reffuse.ts | 24 - packages/example/src/routeTree.gen.ts | 421 +++------------- packages/example/src/routes/__root.tsx | 9 +- packages/example/src/routes/blank.tsx | 9 +- packages/example/src/routes/count.tsx | 26 - .../src/routes/dev/async-rendering.tsx | 54 ++ packages/example/src/routes/dev/memo.tsx | 40 ++ .../src/routes/effect-component-tests.tsx | 105 ---- packages/example/src/routes/index.tsx | 27 +- packages/example/src/routes/lazyref.tsx | 31 -- packages/example/src/routes/promise.tsx | 35 -- packages/example/src/routes/query/service.tsx | 38 -- .../example/src/routes/query/usemutation.tsx | 84 ---- .../example/src/routes/query/usequery.tsx | 77 --- packages/example/src/routes/streams/pull.tsx | 34 -- packages/example/src/routes/tests.tsx | 62 --- packages/example/src/routes/time.tsx | 39 -- packages/example/src/routes/todos.tsx | 35 -- packages/example/src/runtime.ts | 14 + .../example/src/services/AppQueryClient.ts | 7 - .../src/services/AppQueryErrorHandler.ts | 13 - packages/example/src/services/index.ts | 2 - packages/example/src/todo/Todo.tsx | 115 +++++ packages/example/src/todo/Todos.tsx | 32 ++ .../example/src/todo/TodosState.service.ts | 50 ++ packages/example/src/todos/reffuse.ts | 10 - .../example/src/todos/services/TodosState.ts | 44 -- packages/example/src/todos/services/index.ts | 1 - packages/example/src/todos/views/VNewTodo.tsx | 44 -- packages/example/src/todos/views/VTodo.tsx | 53 -- packages/example/src/todos/views/VTodos.tsx | 38 -- packages/example/tsconfig.app.json | 8 +- 52 files changed, 1055 insertions(+), 1507 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 packages/effect-fc/src/Component.ts rename packages/effect-fc/src/{ReactHook.ts => Hook.ts} (67%) delete mode 100644 packages/effect-fc/src/ReactComponent.ts create mode 100644 packages/effect-fc/src/utils.ts create mode 100644 packages/example/.tanstack/tmp/router-generator-K3vMsf/dfef8bd1830fa25b780317b9e88c0651 delete mode 100644 packages/example/src/VQueryErrorHandler.tsx delete mode 100644 packages/example/src/query/reffuse.ts delete mode 100644 packages/example/src/query/services/Uuid4Query.ts delete mode 100644 packages/example/src/query/services/index.ts delete mode 100644 packages/example/src/query/views/Uuid4QueryService.tsx delete mode 100644 packages/example/src/reffuse.ts delete mode 100644 packages/example/src/routes/count.tsx create mode 100644 packages/example/src/routes/dev/async-rendering.tsx create mode 100644 packages/example/src/routes/dev/memo.tsx delete mode 100644 packages/example/src/routes/effect-component-tests.tsx delete mode 100644 packages/example/src/routes/lazyref.tsx delete mode 100644 packages/example/src/routes/promise.tsx delete mode 100644 packages/example/src/routes/query/service.tsx delete mode 100644 packages/example/src/routes/query/usemutation.tsx delete mode 100644 packages/example/src/routes/query/usequery.tsx delete mode 100644 packages/example/src/routes/streams/pull.tsx delete mode 100644 packages/example/src/routes/tests.tsx delete mode 100644 packages/example/src/routes/time.tsx delete mode 100644 packages/example/src/routes/todos.tsx create mode 100644 packages/example/src/runtime.ts delete mode 100644 packages/example/src/services/AppQueryClient.ts delete mode 100644 packages/example/src/services/AppQueryErrorHandler.ts delete mode 100644 packages/example/src/services/index.ts create mode 100644 packages/example/src/todo/Todo.tsx create mode 100644 packages/example/src/todo/Todos.tsx create mode 100644 packages/example/src/todo/TodosState.service.ts delete mode 100644 packages/example/src/todos/reffuse.ts delete mode 100644 packages/example/src/todos/services/TodosState.ts delete mode 100644 packages/example/src/todos/services/index.ts delete mode 100644 packages/example/src/todos/views/VNewTodo.tsx delete mode 100644 packages/example/src/todos/views/VTodo.tsx delete mode 100644 packages/example/src/todos/views/VTodos.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..55712c1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/README.md b/README.md index bff34c9..90ce5b3 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,7 @@ -# Reffuse Monorepo +# Effect FC Monorepo -Reffuse is a [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. +[Effect-TS](https://effect.website/) integration for React 19+ that allows you to write function components using Effect generators. This monorepo contains: -- [The `reffuse` library](packages/reffuse) -- [`@reffuse/extension-lazyref`, a LazyRef integration for Reffuse](packages/extension-lazyref) -- [`@reffuse/extension-query`, TanStack Query style hooks for Reffuse](packages/extension-query) +- [The `effect-fc` library](packages/effect-fc) - [An example project](packges/example) diff --git a/bun.lock b/bun.lock index 9afafdf..25eebc8 100644 --- a/bun.lock +++ b/bun.lock @@ -36,8 +36,10 @@ "effect-fc": "workspace:*", "lucide-react": "^0.510.0", "mobx": "^6.13.7", + "react-icons": "^5.5.0", }, "devDependencies": { + "@effect/language-service": "^0.23.4", "@eslint/js": "^9.26.0", "@tanstack/react-router": "^1.120.3", "@tanstack/react-router-devtools": "^1.120.3", @@ -724,6 +726,8 @@ "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], diff --git a/packages/effect-fc/README.md b/packages/effect-fc/README.md index 151d869..377b7d7 100644 --- a/packages/effect-fc/README.md +++ b/packages/effect-fc/README.md @@ -1,11 +1,51 @@ -# Reffuse +# Effect FC -[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. +[Effect-TS](https://effect.website/) integration for React 19+ that allows you to write function components using Effect generators. 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+ +- `effect` 3.15+ - `react` & `@types/react` 19+ + +## Known issues +- Hot module replacement doesn't work for Effect FC's yet. Page reload is required to view changes. Regular React components are unaffected. + +## What writing components looks like +```typescript +import { Container, Flex, Heading } from "@radix-ui/themes" +import { Chunk, Console, Effect } from "effect" +import { Component, Hook } from "effect-fc" +import { Todo } from "./Todo" +import { TodosState } from "./TodosState.service" + +// Component.Component +// VVV +export const Todos = Component.make(function* Todos() { + const state = yield* TodosState + const [todos] = yield* Hook.useSubscribeRefs(state.ref) + + yield* Hook.useOnce(() => Effect.andThen( + Console.log("Todos mounted"), + Effect.addFinalizer(() => Console.log("Todos unmounted")), + )) + + const VTodo = yield* Component.useFC(Todo) + + return ( + + Todos + + + + + {Chunk.map(todos, (v, k) => + + )} + + + ) +}) +``` diff --git a/packages/effect-fc/package.json b/packages/effect-fc/package.json index 157fe25..9ad6f2a 100644 --- a/packages/effect-fc/package.json +++ b/packages/effect-fc/package.json @@ -1,5 +1,6 @@ { "name": "effect-fc", + "description": "Write React function components using Effect generators", "version": "0.1.0", "type": "module", "files": [ diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts new file mode 100644 index 0000000..f01360b --- /dev/null +++ b/packages/effect-fc/src/Component.ts @@ -0,0 +1,464 @@ +import { Context, Effect, type Equivalence, ExecutionStrategy, Function, pipe, Pipeable, Predicate, Runtime, Scope, String, Tracer, type Utils } from "effect" +import * as React from "react" +import * as Hook from "./Hook.js" +import type { ExcludeKeys } from "./utils.js" + + +export interface Component extends Pipeable.Pipeable { + readonly body: (props: P) => Effect.Effect + readonly displayName?: string + readonly options: Component.Options +} + +export namespace Component { + export type Error = T extends Component ? E : never + export type Context = T extends Component ? R : never + export type Props = T extends Component ? P : never + + export interface Options { + readonly finalizerExecutionMode: "sync" | "fork" + readonly finalizerExecutionStrategy: ExecutionStrategy.ExecutionStrategy + } +} + + +const ComponentProto = Object.seal({ + pipe() { return Pipeable.pipeArguments(this, arguments) } +} as const) + +const defaultOptions: Component.Options = { + finalizerExecutionMode: "sync", + finalizerExecutionStrategy: ExecutionStrategy.sequential, +} + +const nonReactiveTags = [Tracer.ParentSpan] as const + + +export namespace make { + export type Gen = { + >, P extends {} = {}>( + body: (props: P) => Generator, + ): Component< + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never, + P + > + >, A, B extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B + ): Component, Effect.Effect.Context, P> + >, A, B, C extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + ): Component, Effect.Effect.Context, P> + >, A, B, C, D extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + ): Component, Effect.Effect.Context, P> + >, A, B, C, D, E extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + ): Component, Effect.Effect.Context, P> + >, A, B, C, D, E, F extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + ): Component, Effect.Effect.Context, P> + >, A, B, C, D, E, F, G extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + ): Component, Effect.Effect.Context, P> + >, A, B, C, D, E, F, G, H extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + ): Component, Effect.Effect.Context, P> + >, A, B, C, D, E, F, G, H, I extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + h: (_: H, props: NoInfer

) => I, + ): Component, Effect.Effect.Context, P> + >, A, B, C, D, E, F, G, H, I, J extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + h: (_: H, props: NoInfer

) => I, + i: (_: I, props: NoInfer

) => J, + ): Component, Effect.Effect.Context, P> + } + + export type NonGen = { + , P extends {} = {}>( + body: (props: P) => Eff + ): Component, Effect.Effect.Context, P> + , A, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => Eff, + ): Component, Effect.Effect.Context, P> + , A, B, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => Eff, + ): Component, Effect.Effect.Context, P> + , A, B, C, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => Eff, + ): Component, Effect.Effect.Context, P> + , A, B, C, D, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => Eff, + ): Component, Effect.Effect.Context, P> + , A, B, C, D, E, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => Eff, + ): Component, Effect.Effect.Context, P> + , A, B, C, D, E, F, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => Eff, + ): Component, Effect.Effect.Context, P> + , A, B, C, D, E, F, G, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => Eff, + ): Component, Effect.Effect.Context, P> + , A, B, C, D, E, F, G, H, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + h: (_: H, props: NoInfer

) => Eff, + ): Component, Effect.Effect.Context, P> + , A, B, C, D, E, F, G, H, I, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + h: (_: H, props: NoInfer

) => I, + i: (_: I, props: NoInfer

) => Eff, + ): Component, Effect.Effect.Context, P> + } +} + +export const make: ( + & make.Gen + & make.NonGen + & (( + spanName: string, + spanOptions?: Tracer.SpanOptions, + ) => make.Gen & make.NonGen) +) = (spanNameOrBody: Function | string, ...pipeables: any[]) => { + if (typeof spanNameOrBody !== "string") { + const displayName = displayNameFromBody(spanNameOrBody) + return Object.setPrototypeOf({ + body: displayName + ? Effect.fn(displayName)(spanNameOrBody as any, ...pipeables as []) + : Effect.fn(spanNameOrBody as any, ...pipeables), + displayName, + options: { ...defaultOptions }, + }, ComponentProto) + } + else { + const spanOptions = pipeables[0] + return (body: any, ...pipeables: any[]) => Object.setPrototypeOf({ + body: Effect.fn(spanNameOrBody, spanOptions)(body, ...pipeables as []), + displayName: displayNameFromBody(body) ?? spanNameOrBody, + options: { ...defaultOptions }, + }, ComponentProto) + } +} + +export const makeUntraced: make.Gen & make.NonGen = (body: Function, ...pipeables: any[]) => Object.setPrototypeOf({ + body: Effect.fnUntraced(body as any, ...pipeables as []), + displayName: displayNameFromBody(body), + options: { ...defaultOptions }, +}, ComponentProto) + +const displayNameFromBody = (body: Function) => !String.isEmpty(body.name) ? body.name : undefined + + +export const withDisplayName: { + >( + displayName: string + ): (self: T) => T + >( + self: T, + displayName: string, + ): T +} = Function.dual(2, >( + self: T, + displayName: string, +): T => Object.setPrototypeOf( + { ...self, displayName }, + Object.getPrototypeOf(self), +)) + +export const withOptions: { + >( + options: Partial + ): (self: T) => T + >( + self: T, + options: Partial, + ): T +} = Function.dual(2, >( + self: T, + options: Partial, +): T => Object.setPrototypeOf( + { ...self, options: { ...self.options, ...options } }, + Object.getPrototypeOf(self), +)) + +export const withRuntime: { + ( + context: React.Context>, + ): (self: Component) => React.FC

+ ( + self: Component, + context: React.Context>, + ): React.FC

+} = Function.dual(2, ( + self: Component, + context: React.Context>, +): React.FC

=> function WithRuntime(props) { + const runtime = React.useContext(context) + return React.createElement(Runtime.runSync(runtime)(useFC(self)), props) +}) + + +export interface Memoized

{ + readonly memo: true + readonly memoOptions: Memoized.Options

+} + +export namespace Memoized { + export interface Options

{ + readonly propsAreEqual?: Equivalence.Equivalence

+ } +} + +export const memo = >( + self: ExcludeKeys>> +): T & Memoized> => Object.setPrototypeOf( + { ...self, memo: true, memoOptions: {} }, + Object.getPrototypeOf(self), +) + +export const memoWithEquivalence: { + >( + propsAreEqual: Equivalence.Equivalence> + ): ( + self: ExcludeKeys>> + ) => T & Memoized> + >( + self: ExcludeKeys>>, + propsAreEqual: Equivalence.Equivalence>, + ): T & Memoized> +} = Function.dual(2, >( + self: ExcludeKeys>>, + propsAreEqual: Equivalence.Equivalence>, +): T & Memoized> => Object.setPrototypeOf( + { ...self, memo: true, memoOptions: { propsAreEqual } }, + Object.getPrototypeOf(self), +)) + + +export interface Suspense { + readonly suspense: true +} + +export type SuspenseProps = Omit + +export const suspense = , P extends {}>( + self: ExcludeKeys & Component> +): T & Suspense => Object.setPrototypeOf( + { ...self, suspense: true }, + Object.getPrototypeOf(self), +) + + +export const useFC: { + ( + self: Component & Suspense + ): Effect.Effect, never, Exclude> + ( + self: Component + ): Effect.Effect, never, Exclude> +} = Effect.fn("useFC")(function* ( + self: Component & (Memoized

| Suspense | {}) +) { + const runtimeRef = React.useRef>>(null!) + runtimeRef.current = yield* Effect.runtime>() + + return React.useCallback(function ScopeProvider(props: P) { + const scope = Runtime.runSync(runtimeRef.current)(Hook.useScope( + Array.from( + Context.omit(...nonReactiveTags)(runtimeRef.current.context).unsafeMap.values() + ), + self.options, + )) + + const FC = React.useMemo(() => { + const f: React.FC

= Predicate.hasProperty(self, "suspense") + ? pipe( + function SuspenseInner(props: { readonly promise: Promise }) { + return React.use(props.promise) + }, + + SuspenseInner => ({ fallback, name, ...props }: P & SuspenseProps) => { + const promise = Runtime.runPromise(runtimeRef.current)( + Effect.provideService(self.body(props as P), Scope.Scope, scope) + ) + + return React.createElement( + React.Suspense, + { fallback, name }, + React.createElement(SuspenseInner, { promise }), + ) + }, + ) + : (props: P) => Runtime.runSync(runtimeRef.current)( + Effect.provideService(self.body(props), Scope.Scope, scope) + ) + + f.displayName = self.displayName ?? "Anonymous" + return Predicate.hasProperty(self, "memo") + ? React.memo(f, self.memoOptions.propsAreEqual) + : f + }, [scope]) + + return React.createElement(FC, props) + }, []) +}) + +export const use: { + ( + self: Component & Suspense, + fn: (Component: React.FC

) => React.ReactNode, + ): Effect.Effect> + ( + self: Component, + fn: (Component: React.FC

) => React.ReactNode, + ): Effect.Effect> +} = Effect.fn("use")(function*(self, fn) { + return fn(yield* useFC(self)) +}) diff --git a/packages/effect-fc/src/ReactHook.ts b/packages/effect-fc/src/Hook.ts similarity index 67% rename from packages/effect-fc/src/ReactHook.ts rename to packages/effect-fc/src/Hook.ts index 1eb7041..ee766b3 100644 --- a/packages/effect-fc/src/ReactHook.ts +++ b/packages/effect-fc/src/Hook.ts @@ -1,22 +1,26 @@ -import { type Context, Effect, ExecutionStrategy, Exit, type Layer, Option, pipe, PubSub, Ref, Runtime, Scope, Stream, SubscriptionRef } from "effect" +import { type Context, Effect, Equivalence, ExecutionStrategy, Exit, type Layer, Option, pipe, PubSub, Ref, Runtime, Scope, Stream, SubscriptionRef } from "effect" import * as React from "react" import { SetStateAction } from "./types/index.js" export interface ScopeOptions { - readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy readonly finalizerExecutionMode?: "sync" | "fork" + readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy } export const useScope: { - (options?: ScopeOptions): Effect.Effect -} = Effect.fnUntraced(function* (options?: ScopeOptions) { + ( + deps: React.DependencyList, + options?: ScopeOptions, + ): Effect.Effect +} = Effect.fn("useScope")(function*(deps, options) { const runtime = yield* Effect.runtime() - const [isInitialRun, initialScope] = React.useMemo(() => Runtime.runSync(runtime)( - Effect.all([Ref.make(true), makeScope(options)]) - ), []) + const [isInitialRun, initialScope] = React.useMemo(() => Runtime.runSync(runtime)(Effect.all([ + Ref.make(true), + Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential), + ])), []) const [scope, setScope] = React.useState(initialScope) React.useEffect(() => Runtime.runSync(runtime)( @@ -26,17 +30,16 @@ export const useScope: { () => closeScope(scope, runtime, options), ), - onFalse: () => makeScope(options).pipe( + onFalse: () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential).pipe( Effect.tap(scope => Effect.sync(() => setScope(scope))), Effect.map(scope => () => closeScope(scope, runtime, options)), ), }) - ), []) + ), deps) return scope }) -const makeScope = (options?: ScopeOptions) => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential) const closeScope = ( scope: Scope.CloseableScope, runtime: Runtime.Runtime, @@ -53,44 +56,12 @@ const closeScope = ( } -export const useMemo: { - ( - factory: () => Effect.Effect, - deps: React.DependencyList, - ): Effect.Effect -} = Effect.fnUntraced(function* ( - factory: () => Effect.Effect, - deps: React.DependencyList, -) { - const runtime = yield* Effect.runtime() - return yield* React.useMemo(() => Runtime.runSync(runtime)(Effect.cached(factory())), deps) -}) - -export const useOnce: { - (factory: () => Effect.Effect): Effect.Effect -} = Effect.fnUntraced(function* ( - factory: () => Effect.Effect -) { - return yield* useMemo(factory, []) -}) - -export const useMemoLayer: { - ( - layer: Layer.Layer - ): Effect.Effect, E, RIn> -} = Effect.fnUntraced(function* ( - layer: Layer.Layer -) { - return yield* useMemo(() => Effect.provide(Effect.context(), layer), [layer]) -}) - - export const useCallbackSync: { ( callback: (...args: Args) => Effect.Effect, deps: React.DependencyList, ): Effect.Effect<(...args: Args) => A, never, R> -} = Effect.fnUntraced(function* ( +} = Effect.fn("useCallbackSync")(function* ( callback: (...args: Args) => Effect.Effect, deps: React.DependencyList, ) { @@ -103,7 +74,7 @@ export const useCallbackPromise: { callback: (...args: Args) => Effect.Effect, deps: React.DependencyList, ): Effect.Effect<(...args: Args) => Promise, never, R> -} = Effect.fnUntraced(function* ( +} = Effect.fn("useCallbackPromise")(function* ( callback: (...args: Args) => Effect.Effect, deps: React.DependencyList, ) { @@ -112,37 +83,49 @@ export const useCallbackPromise: { }) +export const useMemo: { + ( + factory: () => Effect.Effect, + deps: React.DependencyList, + ): Effect.Effect +} = Effect.fn("useMemo")(function* ( + factory: () => Effect.Effect, + deps: React.DependencyList, +) { + const runtime = yield* Effect.runtime() + return yield* React.useMemo(() => Runtime.runSync(runtime)(Effect.cached(factory())), deps) +}) + +export const useOnce: { + (factory: () => Effect.Effect): Effect.Effect +} = Effect.fn("useOnce")(function* ( + factory: () => Effect.Effect +) { + return yield* useMemo(factory, []) +}) + + export const useEffect: { ( effect: () => Effect.Effect, deps?: React.DependencyList, options?: ScopeOptions, ): Effect.Effect> -} = Effect.fnUntraced(function* ( +} = Effect.fn("useEffect")(function* ( effect: () => Effect.Effect, deps?: React.DependencyList, options?: ScopeOptions, ) { const runtime = yield* Effect.runtime>() - 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) + React.useEffect(() => Effect.Do.pipe( + Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)), + Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(effect(), Scope.Scope, scope))), + Effect.map(({ scope }) => + () => closeScope(scope, runtime, options) + ), + Runtime.runSync(runtime), + ), deps) }) export const useLayoutEffect: { @@ -151,31 +134,21 @@ export const useLayoutEffect: { deps?: React.DependencyList, options?: ScopeOptions, ): Effect.Effect> -} = Effect.fnUntraced(function* ( +} = Effect.fn("useLayoutEffect")(function* ( effect: () => Effect.Effect, deps?: React.DependencyList, options?: ScopeOptions, ) { const runtime = yield* Effect.runtime>() - 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) + React.useLayoutEffect(() => Effect.Do.pipe( + Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)), + Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(effect(), Scope.Scope, scope))), + Effect.map(({ scope }) => + () => closeScope(scope, runtime, options) + ), + Runtime.runSync(runtime), + ), deps) }) export const useFork: { @@ -184,7 +157,7 @@ export const useFork: { deps?: React.DependencyList, options?: Runtime.RunForkOptions & ScopeOptions, ): Effect.Effect> -} = Effect.fnUntraced(function* ( +} = Effect.fn("useFork")(function* ( effect: () => Effect.Effect, deps?: React.DependencyList, options?: Runtime.RunForkOptions & ScopeOptions, @@ -194,27 +167,39 @@ export const useFork: { React.useEffect(() => { const scope = Runtime.runSync(runtime)(options?.scope ? Scope.fork(options.scope, options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential) - : Scope.make(options?.finalizerExecutionStrategy) + : Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential) ) Runtime.runFork(runtime)(Effect.provideService(effect(), Scope.Scope, scope), { ...options, scope }) - - return () => { - switch (options?.finalizerExecutionMode ?? "fork") { - case "sync": - Runtime.runSync(runtime)(Scope.close(scope, Exit.void)) - break - case "fork": - Runtime.runFork(runtime)(Scope.close(scope, Exit.void)) - break - } - } + return () => closeScope(scope, runtime, { + ...options, + finalizerExecutionMode: options?.finalizerExecutionMode ?? "fork", + }) }, deps) }) +export const useContext: { + ( + layer: Layer.Layer, + options?: ScopeOptions, + ): Effect.Effect, E, Exclude> +} = Effect.fn("useContext")(function* ( + layer: Layer.Layer, + options?: ScopeOptions, +) { + const scope = yield* useScope([layer], options) + + return yield* useMemo(() => Effect.provideService( + Effect.provide(Effect.context(), layer), + Scope.Scope, + scope, + ), [scope]) +}) + + export const useRefFromReactiveValue: { (value: A): Effect.Effect> -} = Effect.fnUntraced(function*(value) { +} = Effect.fn("useRefFromReactiveValue")(function*(value) { const ref = yield* useOnce(() => SubscriptionRef.make(value)) yield* useEffect(() => Ref.set(ref, value), [value]) return ref @@ -224,7 +209,7 @@ export const useSubscribeRefs: { []>( ...refs: Refs ): Effect.Effect<{ [K in keyof Refs]: Effect.Effect.Success }> -} = Effect.fnUntraced(function* []>( +} = Effect.fn("useSubscribeRefs")(function* []>( ...refs: Refs ) { const [reactStateValue, setReactStateValue] = React.useState(yield* useOnce(() => @@ -232,7 +217,7 @@ export const useSubscribeRefs: { )) yield* useFork(() => pipe( - refs.map(ref => Stream.changesWith(ref.changes, (x, y) => x === y)), + refs.map(ref => Stream.changesWith(ref.changes, Equivalence.strict())), streams => Stream.zipLatestAll(...streams), Stream.runForEach(v => Effect.sync(() => setReactStateValue(v)) @@ -246,11 +231,11 @@ export const useRefState: { ( ref: SubscriptionRef.SubscriptionRef ): Effect.Effect>]> -} = Effect.fnUntraced(function* (ref: SubscriptionRef.SubscriptionRef) { +} = Effect.fn("useRefState")(function* (ref: SubscriptionRef.SubscriptionRef) { const [reactStateValue, setReactStateValue] = React.useState(yield* useOnce(() => ref)) yield* useFork(() => Stream.runForEach( - Stream.changesWith(ref.changes, (x, y) => x === y), + Stream.changesWith(ref.changes, Equivalence.strict()), v => Effect.sync(() => setReactStateValue(v)), ), [ref]) @@ -268,7 +253,7 @@ export const useStreamFromReactiveValues: { ( values: A ): Effect.Effect, never, Scope.Scope> -} = Effect.fnUntraced(function* (values: A) { +} = Effect.fn("useStreamFromReactiveValues")(function* (values: A) { const { latest, pubsub, stream } = yield* useOnce(() => Effect.Do.pipe( Effect.bind("latest", () => Ref.make(values)), Effect.bind("pubsub", () => Effect.acquireRelease(PubSub.unbounded(), PubSub.shutdown)), @@ -297,7 +282,7 @@ export const useSubscribeStream: { stream: Stream.Stream, initialValue: A, ): Effect.Effect, never, R> -} = Effect.fnUntraced(function* , E, R>( +} = Effect.fn("useSubscribeStream")(function* , E, R>( stream: Stream.Stream, initialValue?: A, ) { @@ -309,7 +294,7 @@ export const useSubscribeStream: { ) yield* useFork(() => Stream.runForEach( - Stream.changesWith(stream, (x, y) => x === y), + Stream.changesWith(stream, Equivalence.strict()), v => Effect.sync(() => setReactStateValue(Option.some(v))), ), [stream]) diff --git a/packages/effect-fc/src/ReactComponent.ts b/packages/effect-fc/src/ReactComponent.ts deleted file mode 100644 index 940e44c..0000000 --- a/packages/effect-fc/src/ReactComponent.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Context, Effect, Function, Runtime, Scope, Tracer } from "effect" -import type { Mutable } from "effect/Types" -import * as React from "react" -import * as ReactHook from "./ReactHook.js" - - -export interface ReactComponent { - (props: P): Effect.Effect - readonly displayName?: string -} - -export const nonReactiveTags = [Tracer.ParentSpan] as const - -export const withDisplayName: { - >(displayName: string): (self: C) => C - >(self: C, displayName: string): C -} = Function.dual(2, >( - self: C, - displayName: string, -): C => { - (self as Mutable).displayName = displayName - return self -}) - -export const useFC: { - ( - self: ReactComponent, - options?: ReactHook.ScopeOptions, - ): Effect.Effect, never, Exclude> -} = Effect.fnUntraced(function* ( - self: ReactComponent, - options?: ReactHook.ScopeOptions, -) { - const runtime = yield* Effect.runtime>() - - return React.useMemo(() => function ScopeProvider(props: P) { - const scope = Runtime.runSync(runtime)(ReactHook.useScope(options)) - - const FC = React.useMemo(() => { - const f = (props: P) => Runtime.runSync(runtime)( - Effect.provideService(self(props), Scope.Scope, scope) - ) - if (self.displayName) f.displayName = self.displayName - return f - }, [scope]) - - return React.createElement(FC, props) - }, Array.from( - Context.omit(...nonReactiveTags)(runtime.context).unsafeMap.values() - )) -}) - -export const use: { - ( - self: ReactComponent, - fn: (Component: React.FC

) => React.ReactNode, - options?: ReactHook.ScopeOptions, - ): Effect.Effect> -} = Effect.fnUntraced(function*(self, fn, options) { - return fn(yield* useFC(self, options)) -}) - -export const withRuntime: { - (context: React.Context>): (self: ReactComponent) => React.FC

- (self: ReactComponent, context: React.Context>): React.FC

-} = Function.dual(2, ( - self: ReactComponent, - context: React.Context>, -): React.FC

=> function WithRuntime(props) { - const runtime = React.useContext(context) - return React.createElement(Runtime.runSync(runtime)(useFC(self)), props) -}) diff --git a/packages/effect-fc/src/ReactManagedRuntime.ts b/packages/effect-fc/src/ReactManagedRuntime.ts index b861aaa..f41286f 100644 --- a/packages/effect-fc/src/ReactManagedRuntime.ts +++ b/packages/effect-fc/src/ReactManagedRuntime.ts @@ -16,31 +16,31 @@ export const make = ( }) -export interface AsyncProviderProps extends React.SuspenseProps { +export interface ProviderProps extends React.SuspenseProps { readonly runtime: ReactManagedRuntime readonly children?: React.ReactNode } -export function AsyncProvider( - { runtime, children, ...suspenseProps }: AsyncProviderProps +export function Provider( + { runtime, children, ...suspenseProps }: ProviderProps ): React.ReactNode { const promise = React.useMemo(() => Effect.runPromise(runtime.runtime.runtimeEffect), [runtime]) return React.createElement( React.Suspense, suspenseProps, - React.createElement(AsyncProviderInner, { runtime, promise, children }), + React.createElement(ProviderInner, { runtime, promise, children }), ) } -interface AsyncProviderInnerProps { +interface ProviderInnerProps { readonly runtime: ReactManagedRuntime readonly promise: Promise> readonly children?: React.ReactNode } -function AsyncProviderInner( - { runtime, promise, children }: AsyncProviderInnerProps +function ProviderInner( + { runtime, promise, children }: ProviderInnerProps ): React.ReactNode { const value = React.use(promise) return React.createElement(runtime.context, { value }, children) diff --git a/packages/effect-fc/src/index.ts b/packages/effect-fc/src/index.ts index 8b8a4b1..4318eff 100644 --- a/packages/effect-fc/src/index.ts +++ b/packages/effect-fc/src/index.ts @@ -1,3 +1,3 @@ -export * as ReactComponent from "./ReactComponent.js" -export * as ReactHook from "./ReactHook.js" +export * as Component from "./Component.js" +export * as Hook from "./Hook.js" export * as ReactManagedRuntime from "./ReactManagedRuntime.js" diff --git a/packages/effect-fc/src/types/SubscriptionSubRef.ts b/packages/effect-fc/src/types/SubscriptionSubRef.ts index b32f45d..579362a 100644 --- a/packages/effect-fc/src/types/SubscriptionSubRef.ts +++ b/packages/effect-fc/src/types/SubscriptionSubRef.ts @@ -1,4 +1,4 @@ -import { Effect, Effectable, Option, Readable, Ref, Stream, Subscribable, SubscriptionRef, SynchronizedRef, type Types, type Unify } from "effect" +import { Chunk, Effect, Effectable, Option, Readable, Ref, Stream, Subscribable, SubscriptionRef, SynchronizedRef, type Types, type Unify } from "effect" import * as PropertyPath from "./PropertyPath.js" @@ -52,7 +52,7 @@ class SubscriptionSubRefImpl extends Effectable.Class imp readonly setter: (parentValue: B, value: A) => B, ) { super() - this.get = Effect.map(Ref.get(this.parent), this.getter) + this.get = Effect.map(this.parent, this.getter) } commit() { @@ -86,9 +86,11 @@ class SubscriptionSubRefImpl extends Effectable.Class imp export const makeFromGetSet = ( parent: SubscriptionRef.SubscriptionRef, - getter: (parentValue: B) => A, - setter: (parentValue: B, value: A) => B, -): SubscriptionSubRef => new SubscriptionSubRefImpl(parent, getter, setter) + options: { + readonly get: (parentValue: B) => A + readonly set: (parentValue: B, value: A) => B + }, +): SubscriptionSubRef => new SubscriptionSubRefImpl(parent, options.get, options.set) export const makeFromPath = >( parent: SubscriptionRef.SubscriptionRef, @@ -98,3 +100,12 @@ export const makeFromPath = >( parentValue => Option.getOrThrow(PropertyPath.get(parentValue, path)), (parentValue, value) => Option.getOrThrow(PropertyPath.immutableSet(parentValue, path, value)), ) + +export const makeFromChunkRef = ( + parent: SubscriptionRef.SubscriptionRef>, + index: number, +): SubscriptionSubRef> => new SubscriptionSubRefImpl( + parent, + parentValue => Chunk.unsafeGet(parentValue, index), + (parentValue, value) => Chunk.replace(parentValue, index, value), +) diff --git a/packages/effect-fc/src/utils.ts b/packages/effect-fc/src/utils.ts new file mode 100644 index 0000000..44ed408 --- /dev/null +++ b/packages/effect-fc/src/utils.ts @@ -0,0 +1,3 @@ +export type ExcludeKeys = K extends keyof T ? ( + { [P in K]?: never } & Omit +) : T diff --git a/packages/example/.tanstack/tmp/router-generator-K3vMsf/dfef8bd1830fa25b780317b9e88c0651 b/packages/example/.tanstack/tmp/router-generator-K3vMsf/dfef8bd1830fa25b780317b9e88c0651 new file mode 100644 index 0000000..36ccbf1 --- /dev/null +++ b/packages/example/.tanstack/tmp/router-generator-K3vMsf/dfef8bd1830fa25b780317b9e88c0651 @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/dev/memo')({ + component: RouteComponent, +}) + +function RouteComponent() { + return

+} diff --git a/packages/example/package.json b/packages/example/package.json index 36aa910..44515c9 100644 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -11,6 +11,7 @@ "preview": "vite preview" }, "devDependencies": { + "@effect/language-service": "^0.23.4", "@eslint/js": "^9.26.0", "@tanstack/react-router": "^1.120.3", "@tanstack/react-router-devtools": "^1.120.3", @@ -36,9 +37,10 @@ "@typed/id": "^0.17.2", "@typed/lazy-ref": "^0.3.3", "effect": "^3.15.1", + "effect-fc": "workspace:*", "lucide-react": "^0.510.0", "mobx": "^6.13.7", - "effect-fc": "workspace:*" + "react-icons": "^5.5.0" }, "overrides": { "effect": "^3.15.1", diff --git a/packages/example/src/VQueryErrorHandler.tsx b/packages/example/src/VQueryErrorHandler.tsx deleted file mode 100644 index 05b396d..0000000 --- a/packages/example/src/VQueryErrorHandler.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { AlertDialog, Button, Flex, Text } from "@radix-ui/themes" -import { Cause, Console, Effect, Either, flow, Match, Option, Stream } from "effect" -import { useState } from "react" -import { R } from "./reffuse" -import { AppQueryErrorHandler } from "./services" - - -export function VQueryErrorHandler() { - const [open, setOpen] = useState(false) - - const error = R.useSubscribeStream( - R.useMemo(() => AppQueryErrorHandler.AppQueryErrorHandler.pipe( - Effect.map(handler => handler.errors.pipe( - Stream.changes, - Stream.tap(Console.error), - Stream.tap(() => Effect.sync(() => setOpen(true))), - )) - ), []) - ) - - if (Option.isNone(error)) - return <> - - return ( - - - Error - - {Either.match(Cause.failureOrCause(error.value), { - onLeft: flow( - Match.value, - Match.tag("RequestError", () => HTTP request error), - Match.tag("ResponseError", () => HTTP response error), - Match.exhaustive, - ), - - onRight: flow( - Cause.dieOption, - Option.match({ - onSome: () => Unrecoverable defect, - onNone: () => Unknown error, - }), - ), - })} - - - - - - - - - - ) -} diff --git a/packages/example/src/main.tsx b/packages/example/src/main.tsx index 32a603d..d983376 100644 --- a/packages/example/src/main.tsx +++ b/packages/example/src/main.tsx @@ -1,24 +1,11 @@ -import { FetchHttpClient } from "@effect/platform" -import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" import { createRouter, RouterProvider } from "@tanstack/react-router" -import { Layer } from "effect" +import { ReactManagedRuntime } from "effect-fc" import { StrictMode } from "react" import { createRoot } from "react-dom/client" -import { ReffuseRuntime } from "reffuse" -import { RootContext } from "./reffuse" import { routeTree } from "./routeTree.gen" -import { AppQueryClient, AppQueryErrorHandler } from "./services" +import { runtime } from "./runtime" -const layer = Layer.empty.pipe( - Layer.provideMerge(AppQueryClient.AppQueryClient.Default), - Layer.provideMerge(AppQueryErrorHandler.AppQueryErrorHandler.Default), - Layer.provideMerge(Clipboard.layer), - Layer.provideMerge(Geolocation.layer), - Layer.provideMerge(Permissions.layer), - Layer.provideMerge(FetchHttpClient.layer), -) - const router = createRouter({ routeTree }) declare module "@tanstack/react-router" { @@ -27,13 +14,10 @@ declare module "@tanstack/react-router" { } } - createRoot(document.getElementById("root")!).render( - - - - - + + + ) diff --git a/packages/example/src/query/reffuse.ts b/packages/example/src/query/reffuse.ts deleted file mode 100644 index 08cf368..0000000 --- a/packages/example/src/query/reffuse.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { RootReffuse } from "@/reffuse" -import { Reffuse, ReffuseContext } from "reffuse" -import { Uuid4Query } from "./services" - - -export const QueryContext = ReffuseContext.make() - -export const R = new class QueryReffuse extends RootReffuse.pipe( - Reffuse.withContexts(QueryContext) -) {} diff --git a/packages/example/src/query/services/Uuid4Query.ts b/packages/example/src/query/services/Uuid4Query.ts deleted file mode 100644 index 46708bf..0000000 --- a/packages/example/src/query/services/Uuid4Query.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { QueryRunner } from "@reffuse/extension-query" -import { ParseResult, Schema } from "effect" - - -export const Result = Schema.Array(Schema.String) - -export class Uuid4Query extends QueryRunner.Tag("Uuid4Query")() {} diff --git a/packages/example/src/query/services/index.ts b/packages/example/src/query/services/index.ts deleted file mode 100644 index 4f67d41..0000000 --- a/packages/example/src/query/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * as Uuid4Query from "./Uuid4Query" diff --git a/packages/example/src/query/views/Uuid4QueryService.tsx b/packages/example/src/query/views/Uuid4QueryService.tsx deleted file mode 100644 index e5e3499..0000000 --- a/packages/example/src/query/views/Uuid4QueryService.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Button, Container, Flex, Text } from "@radix-ui/themes" -import * as AsyncData from "@typed/async-data" -import { R } from "../reffuse" -import { Uuid4Query } from "../services" - - -export function Uuid4QueryService() { - const runFork = R.useRunFork() - - const query = R.useMemo(() => Uuid4Query.Uuid4Query, []) - const [state] = R.useSubscribeRefs(query.stateRef) - - - return ( - - - - {AsyncData.match(state, { - NoData: () => "No data yet", - Loading: () => "Loading...", - Success: (value, { isRefreshing, isOptimistic }) => - `Value: ${value} ${isRefreshing ? "(refreshing)" : ""} ${isOptimistic ? "(optimistic)" : ""}`, - Failure: (cause, { isRefreshing }) => - `Error: ${cause} ${isRefreshing ? "(refreshing)" : ""}`, - })} - - - - - - ) -} diff --git a/packages/example/src/reffuse.ts b/packages/example/src/reffuse.ts deleted file mode 100644 index 00abc14..0000000 --- a/packages/example/src/reffuse.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { HttpClient } from "@effect/platform" -import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" -import { LazyRefExtension } from "@reffuse/extension-lazyref" -import { QueryExtension } from "@reffuse/extension-query" -import { Reffuse, ReffuseContext } from "reffuse" -import { AppQueryClient, AppQueryErrorHandler } from "./services" - - -export const RootContext = ReffuseContext.make< - | AppQueryClient.AppQueryClient - | AppQueryErrorHandler.AppQueryErrorHandler - | Clipboard.Clipboard - | Geolocation.Geolocation - | Permissions.Permissions - | HttpClient.HttpClient ->() - -export class RootReffuse extends Reffuse.Reffuse.pipe( - Reffuse.withExtension(LazyRefExtension), - Reffuse.withExtension(QueryExtension), - Reffuse.withContexts(RootContext), -) {} - -export const R = new RootReffuse() diff --git a/packages/example/src/routeTree.gen.ts b/packages/example/src/routeTree.gen.ts index 201dccb..a908e92 100644 --- a/packages/example/src/routeTree.gen.ts +++ b/packages/example/src/routeTree.gen.ts @@ -8,397 +8,106 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -// Import Routes +import { Route as rootRouteImport } from './routes/__root' +import { Route as BlankRouteImport } from './routes/blank' +import { Route as IndexRouteImport } from './routes/index' +import { Route as DevMemoRouteImport } from './routes/dev/memo' +import { Route as DevAsyncRenderingRouteImport } from './routes/dev/async-rendering' -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' -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' -import { Route as StreamsPullImport } from './routes/streams/pull' -import { Route as QueryUsequeryImport } from './routes/query/usequery' -import { Route as QueryUsemutationImport } from './routes/query/usemutation' -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', - getParentRoute: () => rootRoute, -} as any) - -const TestsRoute = TestsImport.update({ - id: '/tests', - path: '/tests', - getParentRoute: () => rootRoute, -} as any) - -const PromiseRoute = PromiseImport.update({ - id: '/promise', - path: '/promise', - getParentRoute: () => rootRoute, -} as any) - -const LazyrefRoute = LazyrefImport.update({ - id: '/lazyref', - path: '/lazyref', - 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', - getParentRoute: () => rootRoute, -} as any) - -const BlankRoute = BlankImport.update({ +const BlankRoute = BlankRouteImport.update({ id: '/blank', path: '/blank', - getParentRoute: () => rootRoute, + getParentRoute: () => rootRouteImport, } as any) - -const IndexRoute = IndexImport.update({ +const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', - getParentRoute: () => rootRoute, + getParentRoute: () => rootRouteImport, } as any) - -const StreamsPullRoute = StreamsPullImport.update({ - id: '/streams/pull', - path: '/streams/pull', - getParentRoute: () => rootRoute, +const DevMemoRoute = DevMemoRouteImport.update({ + id: '/dev/memo', + path: '/dev/memo', + getParentRoute: () => rootRouteImport, } as any) - -const QueryUsequeryRoute = QueryUsequeryImport.update({ - id: '/query/usequery', - path: '/query/usequery', - getParentRoute: () => rootRoute, +const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({ + id: '/dev/async-rendering', + path: '/dev/async-rendering', + getParentRoute: () => rootRouteImport, } as any) -const QueryUsemutationRoute = QueryUsemutationImport.update({ - id: '/query/usemutation', - path: '/query/usemutation', - getParentRoute: () => rootRoute, -} as any) - -const QueryServiceRoute = QueryServiceImport.update({ - id: '/query/service', - path: '/query/service', - getParentRoute: () => rootRoute, -} as any) - -// Populate the FileRoutesByPath interface - -declare module '@tanstack/react-router' { - interface FileRoutesByPath { - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexImport - parentRoute: typeof rootRoute - } - '/blank': { - id: '/blank' - path: '/blank' - fullPath: '/blank' - preLoaderRoute: typeof BlankImport - parentRoute: typeof rootRoute - } - '/count': { - id: '/count' - path: '/count' - fullPath: '/count' - 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' - fullPath: '/lazyref' - preLoaderRoute: typeof LazyrefImport - parentRoute: typeof rootRoute - } - '/promise': { - id: '/promise' - path: '/promise' - fullPath: '/promise' - preLoaderRoute: typeof PromiseImport - parentRoute: typeof rootRoute - } - '/tests': { - id: '/tests' - path: '/tests' - fullPath: '/tests' - preLoaderRoute: typeof TestsImport - parentRoute: typeof rootRoute - } - '/time': { - id: '/time' - path: '/time' - fullPath: '/time' - 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' - fullPath: '/query/service' - preLoaderRoute: typeof QueryServiceImport - parentRoute: typeof rootRoute - } - '/query/usemutation': { - id: '/query/usemutation' - path: '/query/usemutation' - fullPath: '/query/usemutation' - preLoaderRoute: typeof QueryUsemutationImport - parentRoute: typeof rootRoute - } - '/query/usequery': { - id: '/query/usequery' - path: '/query/usequery' - fullPath: '/query/usequery' - preLoaderRoute: typeof QueryUsequeryImport - parentRoute: typeof rootRoute - } - '/streams/pull': { - id: '/streams/pull' - path: '/streams/pull' - fullPath: '/streams/pull' - preLoaderRoute: typeof StreamsPullImport - parentRoute: typeof rootRoute - } - } -} - -// Create and export the route tree - 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 - '/time': typeof TimeRoute - '/todos': typeof TodosRoute - '/query/service': typeof QueryServiceRoute - '/query/usemutation': typeof QueryUsemutationRoute - '/query/usequery': typeof QueryUsequeryRoute - '/streams/pull': typeof StreamsPullRoute + '/dev/async-rendering': typeof DevAsyncRenderingRoute + '/dev/memo': typeof DevMemoRoute } - 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 - '/time': typeof TimeRoute - '/todos': typeof TodosRoute - '/query/service': typeof QueryServiceRoute - '/query/usemutation': typeof QueryUsemutationRoute - '/query/usequery': typeof QueryUsequeryRoute - '/streams/pull': typeof StreamsPullRoute + '/dev/async-rendering': typeof DevAsyncRenderingRoute + '/dev/memo': typeof DevMemoRoute } - export interface FileRoutesById { - __root__: typeof rootRoute + __root__: typeof rootRouteImport '/': typeof IndexRoute '/blank': typeof BlankRoute - '/count': typeof CountRoute - '/effect-component-tests': typeof EffectComponentTestsRoute - '/lazyref': typeof LazyrefRoute - '/promise': typeof PromiseRoute - '/tests': typeof TestsRoute - '/time': typeof TimeRoute - '/todos': typeof TodosRoute - '/query/service': typeof QueryServiceRoute - '/query/usemutation': typeof QueryUsemutationRoute - '/query/usequery': typeof QueryUsequeryRoute - '/streams/pull': typeof StreamsPullRoute + '/dev/async-rendering': typeof DevAsyncRenderingRoute + '/dev/memo': typeof DevMemoRoute } - export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: - | '/' - | '/blank' - | '/count' - | '/effect-component-tests' - | '/lazyref' - | '/promise' - | '/tests' - | '/time' - | '/todos' - | '/query/service' - | '/query/usemutation' - | '/query/usequery' - | '/streams/pull' + fullPaths: '/' | '/blank' | '/dev/async-rendering' | '/dev/memo' fileRoutesByTo: FileRoutesByTo - to: - | '/' - | '/blank' - | '/count' - | '/effect-component-tests' - | '/lazyref' - | '/promise' - | '/tests' - | '/time' - | '/todos' - | '/query/service' - | '/query/usemutation' - | '/query/usequery' - | '/streams/pull' - id: - | '__root__' - | '/' - | '/blank' - | '/count' - | '/effect-component-tests' - | '/lazyref' - | '/promise' - | '/tests' - | '/time' - | '/todos' - | '/query/service' - | '/query/usemutation' - | '/query/usequery' - | '/streams/pull' + to: '/' | '/blank' | '/dev/async-rendering' | '/dev/memo' + id: '__root__' | '/' | '/blank' | '/dev/async-rendering' | '/dev/memo' fileRoutesById: FileRoutesById } - export interface RootRouteChildren { IndexRoute: typeof IndexRoute BlankRoute: typeof BlankRoute - CountRoute: typeof CountRoute - EffectComponentTestsRoute: typeof EffectComponentTestsRoute - LazyrefRoute: typeof LazyrefRoute - PromiseRoute: typeof PromiseRoute - TestsRoute: typeof TestsRoute - TimeRoute: typeof TimeRoute - TodosRoute: typeof TodosRoute - QueryServiceRoute: typeof QueryServiceRoute - QueryUsemutationRoute: typeof QueryUsemutationRoute - QueryUsequeryRoute: typeof QueryUsequeryRoute - StreamsPullRoute: typeof StreamsPullRoute + DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute + DevMemoRoute: typeof DevMemoRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/blank': { + id: '/blank' + path: '/blank' + fullPath: '/blank' + preLoaderRoute: typeof BlankRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/dev/memo': { + id: '/dev/memo' + path: '/dev/memo' + fullPath: '/dev/memo' + preLoaderRoute: typeof DevMemoRouteImport + parentRoute: typeof rootRouteImport + } + '/dev/async-rendering': { + id: '/dev/async-rendering' + path: '/dev/async-rendering' + fullPath: '/dev/async-rendering' + preLoaderRoute: typeof DevAsyncRenderingRouteImport + parentRoute: typeof rootRouteImport + } + } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, BlankRoute: BlankRoute, - CountRoute: CountRoute, - EffectComponentTestsRoute: EffectComponentTestsRoute, - LazyrefRoute: LazyrefRoute, - PromiseRoute: PromiseRoute, - TestsRoute: TestsRoute, - TimeRoute: TimeRoute, - TodosRoute: TodosRoute, - QueryServiceRoute: QueryServiceRoute, - QueryUsemutationRoute: QueryUsemutationRoute, - QueryUsequeryRoute: QueryUsequeryRoute, - StreamsPullRoute: StreamsPullRoute, + DevAsyncRenderingRoute: DevAsyncRenderingRoute, + DevMemoRoute: DevMemoRoute, } - -export const routeTree = rootRoute +export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -/* ROUTE_MANIFEST_START -{ - "routes": { - "__root__": { - "filePath": "__root.tsx", - "children": [ - "/", - "/blank", - "/count", - "/effect-component-tests", - "/lazyref", - "/promise", - "/tests", - "/time", - "/todos", - "/query/service", - "/query/usemutation", - "/query/usequery", - "/streams/pull" - ] - }, - "/": { - "filePath": "index.tsx" - }, - "/blank": { - "filePath": "blank.tsx" - }, - "/count": { - "filePath": "count.tsx" - }, - "/effect-component-tests": { - "filePath": "effect-component-tests.tsx" - }, - "/lazyref": { - "filePath": "lazyref.tsx" - }, - "/promise": { - "filePath": "promise.tsx" - }, - "/tests": { - "filePath": "tests.tsx" - }, - "/time": { - "filePath": "time.tsx" - }, - "/todos": { - "filePath": "todos.tsx" - }, - "/query/service": { - "filePath": "query/service.tsx" - }, - "/query/usemutation": { - "filePath": "query/usemutation.tsx" - }, - "/query/usequery": { - "filePath": "query/usequery.tsx" - }, - "/streams/pull": { - "filePath": "streams/pull.tsx" - } - } -} -ROUTE_MANIFEST_END */ diff --git a/packages/example/src/routes/__root.tsx b/packages/example/src/routes/__root.tsx index 12abc9b..67b0910 100644 --- a/packages/example/src/routes/__root.tsx +++ b/packages/example/src/routes/__root.tsx @@ -1,4 +1,3 @@ -import { VQueryErrorHandler } from "@/VQueryErrorHandler" import { Container, Flex, Theme } from "@radix-ui/themes" import { createRootRoute, Link, Outlet } from "@tanstack/react-router" import { TanStackRouterDevtools } from "@tanstack/react-router-devtools" @@ -14,21 +13,15 @@ export const Route = createRootRoute({ function Root() { return ( - + Index - Time - Count - Tests - Promise - Query Blank - ) diff --git a/packages/example/src/routes/blank.tsx b/packages/example/src/routes/blank.tsx index 3d1cd68..4f3c7df 100644 --- a/packages/example/src/routes/blank.tsx +++ b/packages/example/src/routes/blank.tsx @@ -1,9 +1,10 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute } from "@tanstack/react-router" -export const Route = createFileRoute('/blank')({ - component: RouteComponent, + +export const Route = createFileRoute("/blank")({ + component: RouteComponent }) function RouteComponent() { - return
Hello "/blank"!
+ return
Hello "/blank"!
} diff --git a/packages/example/src/routes/count.tsx b/packages/example/src/routes/count.tsx deleted file mode 100644 index ab6d802..0000000 --- a/packages/example/src/routes/count.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { R } from "@/reffuse" -import { createFileRoute } from "@tanstack/react-router" -import { Effect, Ref } from "effect" - - -export const Route = createFileRoute("/count")({ - component: Count -}) - -function Count() { - - const runSync = R.useRunSync() - - const countRef = R.useRef(() => Effect.succeed(0)) - const [count] = R.useSubscribeRefs(countRef) - - - return ( -
- -
- ) - -} diff --git a/packages/example/src/routes/dev/async-rendering.tsx b/packages/example/src/routes/dev/async-rendering.tsx new file mode 100644 index 0000000..7e92581 --- /dev/null +++ b/packages/example/src/routes/dev/async-rendering.tsx @@ -0,0 +1,54 @@ +import { runtime } from "@/runtime" +import { Flex, Text, TextField } from "@radix-ui/themes" +import { createFileRoute } from "@tanstack/react-router" +import { GetRandomValues, makeUuid4 } from "@typed/id" +import { Console, Effect } from "effect" +import { Component, Hook } from "effect-fc" +import * as React from "react" + + +const RouteComponent = Component.make(function* AsyncRendering() { + const VMemoizedAsyncComponent = yield* Component.useFC(MemoizedAsyncComponent) + const VAsyncComponent = yield* Component.useFC(AsyncComponent) + const [input, setInput] = React.useState("") + + return ( + + setInput(e.target.value)} + /> + + + + + ) +}).pipe( + Component.withRuntime(runtime.context) +) + +const AsyncComponent = Component.make(function* AsyncComponent() { + yield* Console.log("rendering") + + const VSubComponent = yield* Component.useFC(SubComponent) + yield* Effect.sleep("500 millis") + + return ( + + Rendered! + + + ) +}).pipe( + Component.suspense +) +const MemoizedAsyncComponent = Component.memo(AsyncComponent) + +const SubComponent = Component.make(function* SubComponent() { + const [state] = React.useState(yield* Hook.useOnce(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom))) + return {state} +}) + +export const Route = createFileRoute("/dev/async-rendering")({ + component: RouteComponent +}) diff --git a/packages/example/src/routes/dev/memo.tsx b/packages/example/src/routes/dev/memo.tsx new file mode 100644 index 0000000..f58e6ad --- /dev/null +++ b/packages/example/src/routes/dev/memo.tsx @@ -0,0 +1,40 @@ +import { runtime } from "@/runtime" +import { Flex, Text, TextField } from "@radix-ui/themes" +import { createFileRoute } from "@tanstack/react-router" +import { GetRandomValues, makeUuid4 } from "@typed/id" +import { Effect } from "effect" +import { Component } from "effect-fc" +import * as React from "react" + + +const RouteComponent = Component.make(function* RouteComponent() { + const VSubComponent = yield* Component.useFC(SubComponent) + const VMemoizedSubComponent = yield* Component.useFC(MemoizedSubComponent) + + const [value, setValue] = React.useState("") + + return ( + + setValue(e.target.value)} + /> + + + + + ) +}).pipe( + Component.withRuntime(runtime.context) +) + +const SubComponent = Component.make(function* SubComponent() { + const id = yield* makeUuid4.pipe(Effect.provide(GetRandomValues.CryptoRandom)) + return {id} +}) + +const MemoizedSubComponent = Component.memo(SubComponent) + +export const Route = createFileRoute("/dev/memo")({ + component: RouteComponent, +}) diff --git a/packages/example/src/routes/effect-component-tests.tsx b/packages/example/src/routes/effect-component-tests.tsx deleted file mode 100644 index b4f1fa4..0000000 --- a/packages/example/src/routes/effect-component-tests.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { Box, TextField } from "@radix-ui/themes" -import { createFileRoute } from "@tanstack/react-router" -import { Array, Console, Effect, Layer, pipe, Ref, Runtime, SubscriptionRef } from "effect" -import { ReactComponent, ReactHook, ReactManagedRuntime } from "effect-components" - - -const LogLive = Layer.scopedDiscard(Effect.acquireRelease( - Console.log("Runtime built."), - () => Console.log("Runtime destroyed."), -)) - -class TestService extends Effect.Service()("TestService", { - effect: Effect.bind(Effect.Do, "ref", () => SubscriptionRef.make("value")), -}) {} - -class SubService extends Effect.Service()("SubService", { - effect: Effect.bind(Effect.Do, "ref", () => SubscriptionRef.make("subvalue")), -}) {} - -const runtime = ReactManagedRuntime.make(Layer.empty.pipe( - Layer.provideMerge(LogLive), - Layer.provideMerge(TestService.Default), -)) - - -export const Route = createFileRoute("/effect-component-tests")({ - component: RouteComponent, -}) - -function RouteComponent() { - return ( - - - - ) -} - -const MyRoute = pipe( - Effect.fn(function*() { - const runtime = yield* Effect.runtime() - - const service = yield* TestService - const [value] = yield* ReactHook.useSubscribeRefs(service.ref) - - // const MyTestComponentFC = yield* Effect.provide( - // ReactComponent.useFC(MyTestComponent), - // yield* ReactHook.useMemoLayer(SubService.Default), - // ) - - return <> - - Runtime.runSync(runtime)(Ref.set(service.ref, e.target.value))} - /> - - - {/* {yield* ReactComponent.use(MyTestComponent, C => ).pipe( - Effect.provide(yield* ReactHook.useMemoLayer(SubService.Default)) - )} */} - - {/* {Array.range(0, 3).map(k => - - )} */} - - {yield* pipe( - Array.range(0, 3), - Array.map(k => ReactComponent.use(MyTestComponent, FC => - - )), - Effect.all, - Effect.provide(yield* ReactHook.useMemoLayer(SubService.Default)), - )} - - }), - - ReactComponent.withDisplayName("MyRoute"), - ReactComponent.withRuntime(runtime.context), -) - - -const MyTestComponent = pipe( - Effect.fn(function*() { - const runtime = yield* Effect.runtime() - - const service = yield* SubService - const [value] = yield* ReactHook.useSubscribeRefs(service.ref) - - // yield* ReactHook.useMemo(() => Effect.andThen( - // Effect.addFinalizer(() => Console.log("MyTestComponent umounted")), - // Console.log("MyTestComponent mounted"), - // ), []) - - return <> - - Runtime.runSync(runtime)(Ref.set(service.ref, e.target.value))} - /> - - - }), - - ReactComponent.withDisplayName("MyTestComponent"), -) diff --git a/packages/example/src/routes/index.tsx b/packages/example/src/routes/index.tsx index 58b96d2..93e02d1 100644 --- a/packages/example/src/routes/index.tsx +++ b/packages/example/src/routes/index.tsx @@ -1,10 +1,25 @@ +import { runtime } from "@/runtime" +import { Todos } from "@/todo/Todos" +import { TodosState } from "@/todo/TodosState.service" import { createFileRoute } from "@tanstack/react-router" +import { Effect } from "effect" +import { Component, Hook } from "effect-fc" -export const Route = createFileRoute('/')({ - component: RouteComponent +const TodosStateLive = TodosState.Default("todos") + +export const Route = createFileRoute("/")({ + component: Component.make(function* Index() { + const context = yield* Hook.useContext(TodosStateLive, { finalizerExecutionMode: "fork" }) + return yield* Effect.provide(Component.use(Todos, Todos => ), context) + }).pipe( + Component.withRuntime(runtime.context) + ) + + // component: Component.make("Index")( + // () => Hook.useContext(TodosStateLive, { finalizerExecutionMode: "fork" }), + // Effect.andThen(context => Effect.provide(Component.use(Todos, Todos => ), context)), + // ).pipe( + // Component.withRuntime(runtime.context) + // ) }) - -function RouteComponent() { - return
Hello "/"!
-} diff --git a/packages/example/src/routes/lazyref.tsx b/packages/example/src/routes/lazyref.tsx deleted file mode 100644 index 67657a7..0000000 --- a/packages/example/src/routes/lazyref.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { R } from "@/reffuse" -import { Button, Text } from "@radix-ui/themes" -import { createFileRoute } from "@tanstack/react-router" -import * as LazyRef from "@typed/lazy-ref" -import { Suspense, use } from "react" - - -export const Route = createFileRoute("/lazyref")({ - component: RouteComponent -}) - -function RouteComponent() { - const promise = R.usePromise(() => LazyRef.of(0), []) - - return ( - Loading...}> - - - ) -} - -function LazyRefComponent({ promise }: { readonly promise: Promise> }) { - const ref = use(promise) - const [value, setValue] = R.useLazyRefState(ref) - - return ( - - ) -} diff --git a/packages/example/src/routes/promise.tsx b/packages/example/src/routes/promise.tsx deleted file mode 100644 index 4b197cf..0000000 --- a/packages/example/src/routes/promise.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { R } from "@/reffuse" -import { HttpClient } from "@effect/platform" -import { Text } from "@radix-ui/themes" -import { createFileRoute } from "@tanstack/react-router" -import { Console, Effect, Schema } from "effect" -import { Suspense, use } from "react" - - -export const Route = createFileRoute("/promise")({ - component: RouteComponent -}) - - -const Result = Schema.Tuple(Schema.String) -type Result = typeof Result.Type - -function RouteComponent() { - const promise = R.usePromise(() => Effect.addFinalizer(() => Console.log("Cleanup")).pipe( - Effect.andThen(HttpClient.get("https://www.uuidtools.com/api/generate/v4")), - HttpClient.withTracerPropagation(false), - Effect.flatMap(res => res.json), - Effect.flatMap(Schema.decodeUnknown(Result)), - ), []) - - return ( - Loading...}> - - - ) -} - -function AsyncComponent({ promise }: { readonly promise: Promise }) { - const [uuid] = use(promise) - return {uuid} -} diff --git a/packages/example/src/routes/query/service.tsx b/packages/example/src/routes/query/service.tsx deleted file mode 100644 index 8e15e40..0000000 --- a/packages/example/src/routes/query/service.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { QueryContext } from "@/query/reffuse" -import { Uuid4Query } from "@/query/services" -import { Uuid4QueryService } from "@/query/views/Uuid4QueryService" -import { R } from "@/reffuse" -import { HttpClient } from "@effect/platform" -import { createFileRoute } from "@tanstack/react-router" -import { Console, Effect, Layer, Schema } from "effect" -import { useMemo } from "react" - - -export const Route = createFileRoute("/query/service")({ - component: RouteComponent -}) - -function RouteComponent() { - const query = R.useQuery({ - key: R.useStreamFromReactiveValues(["uuid4", 10 as number]), - query: ([, count]) => Console.log(`Querying ${ count } IDs...`).pipe( - Effect.andThen(Effect.sleep("500 millis")), - Effect.andThen(Effect.map( - HttpClient.HttpClient, - HttpClient.withTracerPropagation(false), - )), - Effect.flatMap(client => client.get(`https://www.uuidtools.com/api/generate/v4/count/${ count }`)), - Effect.flatMap(res => res.json), - Effect.flatMap(Schema.decodeUnknown(Uuid4Query.Result)), - Effect.scoped, - ), - }) - - const layer = useMemo(() => Layer.succeed(Uuid4Query.Uuid4Query, query), [query]) - - return ( - - - - ) -} diff --git a/packages/example/src/routes/query/usemutation.tsx b/packages/example/src/routes/query/usemutation.tsx deleted file mode 100644 index 6a3ff66..0000000 --- a/packages/example/src/routes/query/usemutation.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { R } from "@/reffuse" -import { HttpClient } from "@effect/platform" -import { Button, Container, Flex, Slider, Text } from "@radix-ui/themes" -import { QueryProgress } from "@reffuse/extension-query" -import { createFileRoute } from "@tanstack/react-router" -import * as AsyncData from "@typed/async-data" -import { Array, Console, Effect, flow, Option, Schema, Stream } from "effect" -import { useState } from "react" - - -export const Route = createFileRoute("/query/usemutation")({ - component: RouteComponent -}) - - -const Result = Schema.Array(Schema.String) - -function RouteComponent() { - const runFork = R.useRunFork() - - const [count, setCount] = useState(1) - - const mutation = R.useMutation({ - mutation: ([count]: readonly [count: number]) => Console.log(`Querying ${ count } IDs...`).pipe( - Effect.andThen(QueryProgress.QueryProgress.update(() => - AsyncData.Progress.make({ loaded: 0, total: Option.some(100) }) - )), - Effect.andThen(Effect.sleep("500 millis")), - Effect.tap(() => QueryProgress.QueryProgress.update(() => - AsyncData.Progress.make({ loaded: 50, total: Option.some(100) }) - )), - Effect.andThen(Effect.map( - HttpClient.HttpClient, - HttpClient.withTracerPropagation(false), - )), - Effect.flatMap(client => client.get(`https://www.uuidtools.com/api/generate/v4/count/${ count }`)), - Effect.flatMap(res => res.json), - Effect.flatMap(Schema.decodeUnknown(Result)), - Effect.scoped, - ) - }) - - const [state] = R.useSubscribeRefs(mutation.stateRef) - - - return ( - - - - - - {AsyncData.match(state, { - NoData: () => "No data yet", - Loading: progress => - `Loading... - ${ Option.match(progress, { - onSome: ({ loaded, total }) => ` (${ loaded }/${ Option.getOrElse(total, () => "unknown") })`, - onNone: () => "", - }) }`, - Success: value => `Value: ${ value }`, - Failure: cause => `Error: ${ cause }`, - })} - - - - - - ) -} diff --git a/packages/example/src/routes/query/usequery.tsx b/packages/example/src/routes/query/usequery.tsx deleted file mode 100644 index 4afc21f..0000000 --- a/packages/example/src/routes/query/usequery.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { R } from "@/reffuse" -import { HttpClient } from "@effect/platform" -import { Button, Container, Flex, Slider, Text } from "@radix-ui/themes" -import { createFileRoute } from "@tanstack/react-router" -import * as AsyncData from "@typed/async-data" -import { Array, Console, Effect, flow, Option, Schema, Stream } from "effect" -import { useState } from "react" - - -export const Route = createFileRoute("/query/usequery")({ - component: RouteComponent -}) - - -const Result = Schema.Array(Schema.String) - -function RouteComponent() { - const runFork = R.useRunFork() - - const [count, setCount] = useState(1) - - const query = R.useQuery({ - key: R.useStreamFromReactiveValues(["uuid4", count]), - query: ([, count]) => Console.log(`Querying ${ count } IDs...`).pipe( - Effect.andThen(Effect.sleep("500 millis")), - Effect.andThen(Effect.map( - HttpClient.HttpClient, - HttpClient.withTracerPropagation(false), - )), - Effect.flatMap(client => client.get(`https://www.uuidtools.com/api/generate/v4/count/${ count }`)), - Effect.flatMap(res => res.json), - Effect.flatMap(Schema.decodeUnknown(Result)), - Effect.scoped, - ), - }) - - const [state] = R.useSubscribeRefs(query.stateRef) - - - return ( - - - - - - {AsyncData.match(state, { - NoData: () => "No data yet", - Loading: () => "Loading...", - Success: (value, { isRefreshing, isOptimistic }) => - `Value: ${value} ${isRefreshing ? "(refreshing)" : ""} ${isOptimistic ? "(optimistic)" : ""}`, - Failure: (cause, { isRefreshing }) => - `Error: ${cause} ${isRefreshing ? "(refreshing)" : ""}`, - })} - - - - - - ) -} diff --git a/packages/example/src/routes/streams/pull.tsx b/packages/example/src/routes/streams/pull.tsx deleted file mode 100644 index e9683f4..0000000 --- a/packages/example/src/routes/streams/pull.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { R } from "@/reffuse" -import { Button, Flex, Text } from "@radix-ui/themes" -import { createFileRoute } from "@tanstack/react-router" -import { Chunk, Effect, Exit, Option, Queue, Random, Scope, Stream } from "effect" -import { useMemo, useState } from "react" - - -export const Route = createFileRoute("/streams/pull")({ - component: RouteComponent -}) - -function RouteComponent() { - const stream = useMemo(() => Stream.repeatEffect(Random.nextInt), []) - const streamScope = R.useScope([stream], { finalizerExecutionMode: "fork" }) - - const queue = R.useMemo(() => Effect.provideService(Stream.toQueueOfElements(stream), Scope.Scope, streamScope), [streamScope]) - - const [value, setValue] = useState(Option.none()) - const pullLatest = R.useCallbackSync(() => Queue.takeAll(queue).pipe( - Effect.flatMap(Chunk.last), - Effect.flatMap(Exit.matchEffect({ - onSuccess: Effect.succeed, - onFailure: Effect.fail, - })), - Effect.tap(v => Effect.sync(() => setValue(Option.some(v)))), - ), [queue]) - - return ( - - {Option.isSome(value) && {value.value}} - - - ) -} diff --git a/packages/example/src/routes/tests.tsx b/packages/example/src/routes/tests.tsx deleted file mode 100644 index 1bd85eb..0000000 --- a/packages/example/src/routes/tests.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { R } from "@/reffuse" -import { Button, Flex, Text } from "@radix-ui/themes" -import { createFileRoute } from "@tanstack/react-router" -import { GetRandomValues, makeUuid4 } from "@typed/id" -import { Console, Effect, Option } from "effect" -import { useEffect, useState } from "react" - - -interface Node { - value: string - left?: Leaf - right?: Leaf -} -interface Leaf { - node: Node -} - - -const makeUuid = Effect.provide(makeUuid4, GetRandomValues.CryptoRandom) - - -export const Route = createFileRoute("/tests")({ - component: RouteComponent -}) - -function RouteComponent() { - const runSync = R.useRunSync() - - const [uuid, setUuid] = useState(R.useMemo(() => makeUuid, [])) - const generateUuid = R.useCallbackSync(() => makeUuid.pipe( - Effect.tap(v => Effect.sync(() => setUuid(v))) - ), []) - - const uuidStream = R.useStreamFromReactiveValues([uuid]) - const uuidStreamLatestValue = R.useSubscribeStream(uuidStream) - - const [, scopeLayer] = R.useScope([uuid]) - - useEffect(() => Effect.addFinalizer(() => Console.log("Scope cleanup!")).pipe( - Effect.andThen(Console.log("Scope changed")), - Effect.provide(scopeLayer), - runSync, - ), [scopeLayer, runSync]) - - - const nodeRef = R.useRef(() => Effect.succeed({ value: "prout" })) - const nodeValueRef = R.useSubRefFromPath(nodeRef, ["value"]) - - - return ( - - {uuid} - - - {Option.match(uuidStreamLatestValue, { - onSome: ([v]) => v, - onNone: () => <>, - })} - - - ) -} diff --git a/packages/example/src/routes/time.tsx b/packages/example/src/routes/time.tsx deleted file mode 100644 index 99e7e39..0000000 --- a/packages/example/src/routes/time.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { R } from "@/reffuse" -import { createFileRoute } from "@tanstack/react-router" -import { Console, DateTime, Effect, Ref, Schedule, Stream, SubscriptionRef } from "effect" - - -const timeEverySecond = Stream.repeatEffectWithSchedule( - DateTime.now, - Schedule.intersect(Schedule.forever, Schedule.spaced("1 second")), -) - - -export const Route = createFileRoute("/time")({ - component: Time -}) - -function Time() { - - const timeRef = R.useMemo(() => DateTime.now.pipe(Effect.flatMap(SubscriptionRef.make)), []) - - R.useFork(() => Effect.addFinalizer(() => Console.log("Cleanup")).pipe( - Effect.andThen(Stream.runForEach(timeEverySecond, v => Ref.set(timeRef, v))) - ), [timeRef]) - - const [time] = R.useRefState(timeRef) - - - return ( -
-

- {DateTime.format(time, { - hour: "numeric", - minute: "numeric", - second: "numeric", - })} -

-
- ) - -} diff --git a/packages/example/src/routes/todos.tsx b/packages/example/src/routes/todos.tsx deleted file mode 100644 index a681617..0000000 --- a/packages/example/src/routes/todos.tsx +++ /dev/null @@ -1,35 +0,0 @@ -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/example/src/runtime.ts b/packages/example/src/runtime.ts new file mode 100644 index 0000000..67f73ea --- /dev/null +++ b/packages/example/src/runtime.ts @@ -0,0 +1,14 @@ +import { FetchHttpClient } from "@effect/platform" +import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" +import { Layer } from "effect" +import { ReactManagedRuntime } from "effect-fc" + + +export const AppLive = Layer.empty.pipe( + Layer.provideMerge(Clipboard.layer), + Layer.provideMerge(Geolocation.layer), + Layer.provideMerge(Permissions.layer), + Layer.provideMerge(FetchHttpClient.layer), +) + +export const runtime = ReactManagedRuntime.make(AppLive) diff --git a/packages/example/src/services/AppQueryClient.ts b/packages/example/src/services/AppQueryClient.ts deleted file mode 100644 index bee5014..0000000 --- a/packages/example/src/services/AppQueryClient.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { QueryClient } from "@reffuse/extension-query" -import * as AppQueryErrorHandler from "./AppQueryErrorHandler" - - -export class AppQueryClient extends QueryClient.Service()({ - errorHandler: AppQueryErrorHandler.AppQueryErrorHandler -}) {} diff --git a/packages/example/src/services/AppQueryErrorHandler.ts b/packages/example/src/services/AppQueryErrorHandler.ts deleted file mode 100644 index efff7ec..0000000 --- a/packages/example/src/services/AppQueryErrorHandler.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { HttpClientError } from "@effect/platform" -import { QueryErrorHandler } from "@reffuse/extension-query" -import { Effect } from "effect" - - -export class AppQueryErrorHandler extends Effect.Service()("AppQueryErrorHandler", { - effect: QueryErrorHandler.make()( - (self, failure, defect) => self.pipe( - Effect.catchTag("RequestError", "ResponseError", failure), - Effect.catchAllDefect(defect), - ) - ) -}) {} diff --git a/packages/example/src/services/index.ts b/packages/example/src/services/index.ts deleted file mode 100644 index 691ab08..0000000 --- a/packages/example/src/services/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * as AppQueryClient from "./AppQueryClient" -export * as AppQueryErrorHandler from "./AppQueryErrorHandler" diff --git a/packages/example/src/todo/Todo.tsx b/packages/example/src/todo/Todo.tsx new file mode 100644 index 0000000..3c8365a --- /dev/null +++ b/packages/example/src/todo/Todo.tsx @@ -0,0 +1,115 @@ +import * as Domain from "@/domain" +import { Box, Button, Flex, IconButton, TextArea } from "@radix-ui/themes" +import { GetRandomValues, makeUuid4 } from "@typed/id" +import { Chunk, Effect, Match, Option, Ref, Runtime, SubscriptionRef } from "effect" +import { Component, Hook } from "effect-fc" +import { SubscriptionSubRef } from "effect-fc/types" +import { FaArrowDown, FaArrowUp } from "react-icons/fa" +import { FaDeleteLeft } from "react-icons/fa6" +import { TodosState } from "./TodosState.service" + + +const makeTodo = makeUuid4.pipe( + Effect.map(id => Domain.Todo.Todo.make({ + id, + content: "", + completedAt: Option.none(), + })), + Effect.provide(GetRandomValues.CryptoRandom), +) + + +export type TodoProps = ( + | { readonly _tag: "new", readonly index?: never } + | { readonly _tag: "edit", readonly index: number } +) + +export const Todo = Component.make(function* Todo(props: TodoProps) { + const runtime = yield* Effect.runtime() + const state = yield* TodosState + + const [ref, contentRef] = yield* Hook.useMemo(() => Match.value(props).pipe( + Match.tag("new", () => Effect.andThen(makeTodo, SubscriptionRef.make)), + Match.tag("edit", ({ index }) => Effect.succeed(SubscriptionSubRef.makeFromChunkRef(state.ref, index))), + Match.exhaustive, + + Effect.map(ref => [ + ref, + SubscriptionSubRef.makeFromPath(ref, ["content"]), + ] as const), + ), [props._tag, props.index]) + + const [content, size] = yield* Hook.useSubscribeRefs(contentRef, state.sizeRef) + + return ( + + + +