From 38320b502d70d9c2f590d7e50679dc53dafbeb8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sun, 26 Jul 2026 22:38:39 +0200 Subject: [PATCH] Rename packages --- .../.gitignore | 0 packages/effect-fc-example/README.md | 50 +++++ .../biome.json | 0 .../index.html | 0 .../package.json | 15 +- .../public/vite.svg | 0 .../src/domain/Todo.ts | 0 .../src/domain/index.ts | 0 .../src/index.css | 0 .../src/lib/form/TextFieldFormInputView.tsx | 6 +- .../form/TextFieldOptionalFormInputView.tsx | 6 +- .../src/lib/schema/datetime.ts | 0 .../src/lib/schema/index.ts | 0 .../src/lib/schema/json.ts | 0 .../src/main.tsx | 11 +- .../src/routeTree.gen.ts | 175 ++++++++++++------ .../src/routes/__root.tsx | 6 +- .../src/routes/async.tsx | 52 +++--- .../src/routes/blank.tsx | 0 .../src/routes/dev/context.tsx | 0 .../src/routes/dev/memo.tsx | 0 .../effect-fc-example/src/routes/form.tsx | 143 ++++++++++++++ .../effect-fc-example/src/routes/index.tsx | 24 +++ .../effect-fc-example/src/routes/query.tsx | 117 ++++++++++++ .../src/routes/result.tsx | 0 .../src/runtime.ts | 9 +- .../src/todo/EditTodoView.tsx | 0 .../src/todo/NewTodoView.tsx | 0 .../src/todo/TodoFormSchema.ts | 0 .../src/todo/TodosState.ts | 0 .../src/todo/TodosView.tsx | 0 .../src/vite-env.d.ts | 0 .../tsconfig.app.json | 0 .../tsconfig.json | 0 .../tsconfig.node.json | 0 .../vite.config.ts | 2 - .../{effect-fc-next => effect-view}/README.md | 0 .../biome.json | 0 .../package.json | 4 +- .../src/Async.test.tsx | 0 .../src/Async.ts | 0 .../src/Component.test.tsx | 0 .../src/Component.ts | 0 .../src/Form.ts | 0 .../src/Lens.test.tsx | 0 .../src/Lens.ts | 0 .../src/LensForm.ts | 0 .../src/Memoized.ts | 0 .../src/Mutation.ts | 0 .../src/MutationForm.ts | 0 .../src/PubSub.ts | 0 .../src/Query.test.ts | 0 .../src/Query.ts | 0 .../src/QueryClient.ts | 0 .../src/ReactRuntime.ts | 0 .../src/Refreshable.test.ts | 0 .../src/Refreshable.ts | 0 .../src/ScopeRegistry.ts | 0 .../src/SetStateAction.ts | 0 .../src/Stream.ts | 0 .../src/View.test.tsx | 0 .../src/View.ts | 0 .../src/index.ts | 0 .../src/setup-tests.ts | 0 .../tsconfig.build.json | 0 .../tsconfig.json | 0 .../vitest.config.ts | 0 packages/example-next/README.md | 10 - packages/example-next/src/routes/form.tsx | 82 -------- packages/example-next/src/routes/index.tsx | 56 ------ packages/example-next/src/routes/query.tsx | 102 ---------- packages/example/README.md | 52 +----- packages/example/package.json | 15 +- .../src/lib/form/TextFieldFormInputView.tsx | 6 +- .../form/TextFieldOptionalFormInputView.tsx | 6 +- packages/example/src/main.tsx | 11 +- packages/example/src/routeTree.gen.ts | 175 ++++++------------ packages/example/src/routes/__root.tsx | 6 +- packages/example/src/routes/async.tsx | 52 +++--- packages/example/src/routes/form.tsx | 151 +++++---------- packages/example/src/routes/index.tsx | 56 ++++-- .../src/routes/lensform.tsx | 0 packages/example/src/routes/query.tsx | 115 +++++------- packages/example/src/runtime.ts | 9 +- packages/example/vite.config.ts | 2 + .../EFFECT_FC_NEXT_ERROR_HANDLING_REPORT.md | 0 .../EFFECT_REACT_LIFECYCLE_REPORT.md | 0 .../REACT_REFRESH_FEASIBILITY.md | 0 88 files changed, 763 insertions(+), 763 deletions(-) rename packages/{example-next => effect-fc-example}/.gitignore (100%) create mode 100644 packages/effect-fc-example/README.md rename packages/{example-next => effect-fc-example}/biome.json (100%) rename packages/{example-next => effect-fc-example}/index.html (100%) rename packages/{example-next => effect-fc-example}/package.json (76%) rename packages/{example => effect-fc-example}/public/vite.svg (100%) rename packages/{example => effect-fc-example}/src/domain/Todo.ts (100%) rename packages/{example => effect-fc-example}/src/domain/index.ts (100%) rename packages/{example-next => effect-fc-example}/src/index.css (100%) rename packages/{example-next => effect-fc-example}/src/lib/form/TextFieldFormInputView.tsx (90%) rename packages/{example-next => effect-fc-example}/src/lib/form/TextFieldOptionalFormInputView.tsx (91%) rename packages/{example => effect-fc-example}/src/lib/schema/datetime.ts (100%) rename packages/{example => effect-fc-example}/src/lib/schema/index.ts (100%) rename packages/{example => effect-fc-example}/src/lib/schema/json.ts (100%) rename packages/{example-next => effect-fc-example}/src/main.tsx (72%) rename packages/{example-next => effect-fc-example}/src/routeTree.gen.ts (63%) rename packages/{example-next => effect-fc-example}/src/routes/__root.tsx (75%) rename packages/{example-next => effect-fc-example}/src/routes/async.tsx (50%) rename packages/{example-next => effect-fc-example}/src/routes/blank.tsx (100%) rename packages/{example => effect-fc-example}/src/routes/dev/context.tsx (100%) rename packages/{example => effect-fc-example}/src/routes/dev/memo.tsx (100%) create mode 100644 packages/effect-fc-example/src/routes/form.tsx create mode 100644 packages/effect-fc-example/src/routes/index.tsx create mode 100644 packages/effect-fc-example/src/routes/query.tsx rename packages/{example => effect-fc-example}/src/routes/result.tsx (100%) rename packages/{example-next => effect-fc-example}/src/runtime.ts (59%) rename packages/{example => effect-fc-example}/src/todo/EditTodoView.tsx (100%) rename packages/{example => effect-fc-example}/src/todo/NewTodoView.tsx (100%) rename packages/{example => effect-fc-example}/src/todo/TodoFormSchema.ts (100%) rename packages/{example => effect-fc-example}/src/todo/TodosState.ts (100%) rename packages/{example => effect-fc-example}/src/todo/TodosView.tsx (100%) rename packages/{example-next => effect-fc-example}/src/vite-env.d.ts (100%) rename packages/{example-next => effect-fc-example}/tsconfig.app.json (100%) rename packages/{example-next => effect-fc-example}/tsconfig.json (100%) rename packages/{example-next => effect-fc-example}/tsconfig.node.json (100%) rename packages/{example-next => effect-fc-example}/vite.config.ts (84%) rename packages/{effect-fc-next => effect-view}/README.md (100%) rename packages/{effect-fc-next => effect-view}/biome.json (100%) rename packages/{effect-fc-next => effect-view}/package.json (98%) rename packages/{effect-fc-next => effect-view}/src/Async.test.tsx (100%) rename packages/{effect-fc-next => effect-view}/src/Async.ts (100%) rename packages/{effect-fc-next => effect-view}/src/Component.test.tsx (100%) rename packages/{effect-fc-next => effect-view}/src/Component.ts (100%) rename packages/{effect-fc-next => effect-view}/src/Form.ts (100%) rename packages/{effect-fc-next => effect-view}/src/Lens.test.tsx (100%) rename packages/{effect-fc-next => effect-view}/src/Lens.ts (100%) rename packages/{effect-fc-next => effect-view}/src/LensForm.ts (100%) rename packages/{effect-fc-next => effect-view}/src/Memoized.ts (100%) rename packages/{effect-fc-next => effect-view}/src/Mutation.ts (100%) rename packages/{effect-fc-next => effect-view}/src/MutationForm.ts (100%) rename packages/{effect-fc-next => effect-view}/src/PubSub.ts (100%) rename packages/{effect-fc-next => effect-view}/src/Query.test.ts (100%) rename packages/{effect-fc-next => effect-view}/src/Query.ts (100%) rename packages/{effect-fc-next => effect-view}/src/QueryClient.ts (100%) rename packages/{effect-fc-next => effect-view}/src/ReactRuntime.ts (100%) rename packages/{effect-fc-next => effect-view}/src/Refreshable.test.ts (100%) rename packages/{effect-fc-next => effect-view}/src/Refreshable.ts (100%) rename packages/{effect-fc-next => effect-view}/src/ScopeRegistry.ts (100%) rename packages/{effect-fc-next => effect-view}/src/SetStateAction.ts (100%) rename packages/{effect-fc-next => effect-view}/src/Stream.ts (100%) rename packages/{effect-fc-next => effect-view}/src/View.test.tsx (100%) rename packages/{effect-fc-next => effect-view}/src/View.ts (100%) rename packages/{effect-fc-next => effect-view}/src/index.ts (100%) rename packages/{effect-fc-next => effect-view}/src/setup-tests.ts (100%) rename packages/{effect-fc-next => effect-view}/tsconfig.build.json (100%) rename packages/{effect-fc-next => effect-view}/tsconfig.json (100%) rename packages/{effect-fc-next => effect-view}/vitest.config.ts (100%) delete mode 100644 packages/example-next/README.md delete mode 100644 packages/example-next/src/routes/form.tsx delete mode 100644 packages/example-next/src/routes/index.tsx delete mode 100644 packages/example-next/src/routes/query.tsx rename packages/{example-next => example}/src/routes/lensform.tsx (100%) rename EFFECT_FC_NEXT_ERROR_HANDLING_REPORT.md => reports/EFFECT_FC_NEXT_ERROR_HANDLING_REPORT.md (100%) rename EFFECT_REACT_LIFECYCLE_REPORT.md => reports/EFFECT_REACT_LIFECYCLE_REPORT.md (100%) rename REACT_REFRESH_FEASIBILITY.md => reports/REACT_REFRESH_FEASIBILITY.md (100%) diff --git a/packages/example-next/.gitignore b/packages/effect-fc-example/.gitignore similarity index 100% rename from packages/example-next/.gitignore rename to packages/effect-fc-example/.gitignore diff --git a/packages/effect-fc-example/README.md b/packages/effect-fc-example/README.md new file mode 100644 index 0000000..74872fd --- /dev/null +++ b/packages/effect-fc-example/README.md @@ -0,0 +1,50 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from 'eslint-plugin-react' + +export default tseslint.config({ + // Set the react version + settings: { react: { version: '18.3' } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + }, +}) +``` diff --git a/packages/example-next/biome.json b/packages/effect-fc-example/biome.json similarity index 100% rename from packages/example-next/biome.json rename to packages/effect-fc-example/biome.json diff --git a/packages/example-next/index.html b/packages/effect-fc-example/index.html similarity index 100% rename from packages/example-next/index.html rename to packages/effect-fc-example/index.html diff --git a/packages/example-next/package.json b/packages/effect-fc-example/package.json similarity index 76% rename from packages/example-next/package.json rename to packages/effect-fc-example/package.json index 5e96b7a..141145e 100644 --- a/packages/example-next/package.json +++ b/packages/effect-fc-example/package.json @@ -1,10 +1,9 @@ { - "name": "@effect-fc/example-next", + "name": "@effect-fc/example", "version": "0.0.0", "type": "module", "private": true, "scripts": { - "build": "tsc -b && vite build", "dev": "vite", "lint:tsc": "tsc -b --noEmit", "lint:biome": "biome lint", @@ -14,7 +13,6 @@ "clean:modules": "rm -rf node_modules" }, "devDependencies": { - "@effect-view/vite-plugin": "workspace:*", "@tanstack/react-router": "^1.170.10", "@tanstack/react-router-devtools": "^1.167.0", "@tanstack/router-plugin": "^1.168.13", @@ -24,18 +22,21 @@ "globals": "^17.6.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "type-fest": "^5.7.0", "vite": "^8.0.16" }, "dependencies": { - "@effect/platform-browser": "4.0.0-beta.98", + "@effect/platform": "^0.96.1", + "@effect/platform-browser": "^0.76.0", "@radix-ui/themes": "^3.3.0", - "effect": "4.0.0-beta.98", - "effect-fc-next": "workspace:*", + "@typed/id": "^0.17.2", + "effect": "^3.21.2", + "effect-fc": "workspace:*", "react-icons": "^5.6.0" }, "overrides": { "@types/react": "^19.2.15", - "effect": "4.0.0-beta.98", + "effect": "^3.21.2", "react": "^19.2.6" } } diff --git a/packages/example/public/vite.svg b/packages/effect-fc-example/public/vite.svg similarity index 100% rename from packages/example/public/vite.svg rename to packages/effect-fc-example/public/vite.svg diff --git a/packages/example/src/domain/Todo.ts b/packages/effect-fc-example/src/domain/Todo.ts similarity index 100% rename from packages/example/src/domain/Todo.ts rename to packages/effect-fc-example/src/domain/Todo.ts diff --git a/packages/example/src/domain/index.ts b/packages/effect-fc-example/src/domain/index.ts similarity index 100% rename from packages/example/src/domain/index.ts rename to packages/effect-fc-example/src/domain/index.ts diff --git a/packages/example-next/src/index.css b/packages/effect-fc-example/src/index.css similarity index 100% rename from packages/example-next/src/index.css rename to packages/effect-fc-example/src/index.css diff --git a/packages/example-next/src/lib/form/TextFieldFormInputView.tsx b/packages/effect-fc-example/src/lib/form/TextFieldFormInputView.tsx similarity index 90% rename from packages/example-next/src/lib/form/TextFieldFormInputView.tsx rename to packages/effect-fc-example/src/lib/form/TextFieldFormInputView.tsx index 48241a9..ca4f843 100644 --- a/packages/example-next/src/lib/form/TextFieldFormInputView.tsx +++ b/packages/effect-fc-example/src/lib/form/TextFieldFormInputView.tsx @@ -1,6 +1,6 @@ import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes" import { Array, Option, Struct } from "effect" -import { Component, Form, View } from "effect-fc-next" +import { Component, Form, Subscribable } from "effect-fc" import type * as React from "react" @@ -17,7 +17,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f props: TextFieldFormInputView.Props ) { const input = yield* Form.useInput(props.form, props) - const [issues, isValidating, isCommitting] = yield* View.useAll([ + const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([ props.form.issues, props.form.isValidating, props.form.isCommitting, @@ -29,7 +29,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f value={input.value} onChange={e => input.setValue(e.target.value)} disabled={isCommitting} - {...Struct.omit(props, ["form"])} + {...Struct.omit(props, "form")} > {isValidating && diff --git a/packages/example-next/src/lib/form/TextFieldOptionalFormInputView.tsx b/packages/effect-fc-example/src/lib/form/TextFieldOptionalFormInputView.tsx similarity index 91% rename from packages/example-next/src/lib/form/TextFieldOptionalFormInputView.tsx rename to packages/effect-fc-example/src/lib/form/TextFieldOptionalFormInputView.tsx index cd2bea4..68693ea 100644 --- a/packages/example-next/src/lib/form/TextFieldOptionalFormInputView.tsx +++ b/packages/effect-fc-example/src/lib/form/TextFieldOptionalFormInputView.tsx @@ -1,6 +1,6 @@ import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes" import { Array, Option, Struct } from "effect" -import { Component, Form, View } from "effect-fc-next" +import { Component, Form, Subscribable } from "effect-fc" import type * as React from "react" @@ -17,7 +17,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF props: TextFieldOptionalFormInputView.Props ) { const input = yield* Form.useOptionalInput(props.form, props) - const [issues, isValidating, isCommitting] = yield* View.useAll([ + const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([ props.form.issues, props.form.isValidating, props.form.isCommitting, @@ -29,7 +29,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF value={input.value} onChange={e => input.setValue(e.target.value)} disabled={!input.enabled || isCommitting} - {...Struct.omit(props, ["form", "defaultValue"])} + {...Struct.omit(props, "form", "defaultValue")} > - , + ) diff --git a/packages/example-next/src/routeTree.gen.ts b/packages/effect-fc-example/src/routeTree.gen.ts similarity index 63% rename from packages/example-next/src/routeTree.gen.ts rename to packages/effect-fc-example/src/routeTree.gen.ts index b70ab97..0e6de17 100644 --- a/packages/example-next/src/routeTree.gen.ts +++ b/packages/effect-fc-example/src/routeTree.gen.ts @@ -9,26 +9,23 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as IndexRouteImport } from './routes/index' -import { Route as AsyncRouteImport } from './routes/async' -import { Route as BlankRouteImport } from './routes/blank' -import { Route as FormRouteImport } from './routes/form' -import { Route as LensformRouteImport } from './routes/lensform' +import { Route as ResultRouteImport } from './routes/result' import { Route as QueryRouteImport } from './routes/query' +import { Route as FormRouteImport } from './routes/form' +import { Route as BlankRouteImport } from './routes/blank' +import { Route as AsyncRouteImport } from './routes/async' +import { Route as IndexRouteImport } from './routes/index' +import { Route as DevMemoRouteImport } from './routes/dev/memo' +import { Route as DevContextRouteImport } from './routes/dev/context' -const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', +const ResultRoute = ResultRouteImport.update({ + id: '/result', + path: '/result', getParentRoute: () => rootRouteImport, } as any) -const AsyncRoute = AsyncRouteImport.update({ - id: '/async', - path: '/async', - getParentRoute: () => rootRouteImport, -} as any) -const BlankRoute = BlankRouteImport.update({ - id: '/blank', - path: '/blank', +const QueryRoute = QueryRouteImport.update({ + id: '/query', + path: '/query', getParentRoute: () => rootRouteImport, } as any) const FormRoute = FormRouteImport.update({ @@ -36,14 +33,29 @@ const FormRoute = FormRouteImport.update({ path: '/form', getParentRoute: () => rootRouteImport, } as any) -const LensformRoute = LensformRouteImport.update({ - id: '/lensform', - path: '/lensform', +const BlankRoute = BlankRouteImport.update({ + id: '/blank', + path: '/blank', getParentRoute: () => rootRouteImport, } as any) -const QueryRoute = QueryRouteImport.update({ - id: '/query', - path: '/query', +const AsyncRoute = AsyncRouteImport.update({ + id: '/async', + path: '/async', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const DevMemoRoute = DevMemoRouteImport.update({ + id: '/dev/memo', + path: '/dev/memo', + getParentRoute: () => rootRouteImport, +} as any) +const DevContextRoute = DevContextRouteImport.update({ + id: '/dev/context', + path: '/dev/context', getParentRoute: () => rootRouteImport, } as any) @@ -52,16 +64,20 @@ export interface FileRoutesByFullPath { '/async': typeof AsyncRoute '/blank': typeof BlankRoute '/form': typeof FormRoute - '/lensform': typeof LensformRoute '/query': typeof QueryRoute + '/result': typeof ResultRoute + '/dev/context': typeof DevContextRoute + '/dev/memo': typeof DevMemoRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/async': typeof AsyncRoute '/blank': typeof BlankRoute '/form': typeof FormRoute - '/lensform': typeof LensformRoute '/query': typeof QueryRoute + '/result': typeof ResultRoute + '/dev/context': typeof DevContextRoute + '/dev/memo': typeof DevMemoRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -69,15 +85,42 @@ export interface FileRoutesById { '/async': typeof AsyncRoute '/blank': typeof BlankRoute '/form': typeof FormRoute - '/lensform': typeof LensformRoute '/query': typeof QueryRoute + '/result': typeof ResultRoute + '/dev/context': typeof DevContextRoute + '/dev/memo': typeof DevMemoRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query' + fullPaths: + | '/' + | '/async' + | '/blank' + | '/form' + | '/query' + | '/result' + | '/dev/context' + | '/dev/memo' fileRoutesByTo: FileRoutesByTo - to: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query' - id: '__root__' | '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query' + to: + | '/' + | '/async' + | '/blank' + | '/form' + | '/query' + | '/result' + | '/dev/context' + | '/dev/memo' + id: + | '__root__' + | '/' + | '/async' + | '/blank' + | '/form' + | '/query' + | '/result' + | '/dev/context' + | '/dev/memo' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -85,31 +128,26 @@ export interface RootRouteChildren { AsyncRoute: typeof AsyncRoute BlankRoute: typeof BlankRoute FormRoute: typeof FormRoute - LensformRoute: typeof LensformRoute QueryRoute: typeof QueryRoute + ResultRoute: typeof ResultRoute + DevContextRoute: typeof DevContextRoute + DevMemoRoute: typeof DevMemoRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexRouteImport + '/result': { + id: '/result' + path: '/result' + fullPath: '/result' + preLoaderRoute: typeof ResultRouteImport parentRoute: typeof rootRouteImport } - '/async': { - id: '/async' - path: '/async' - fullPath: '/async' - preLoaderRoute: typeof AsyncRouteImport - parentRoute: typeof rootRouteImport - } - '/blank': { - id: '/blank' - path: '/blank' - fullPath: '/blank' - preLoaderRoute: typeof BlankRouteImport + '/query': { + id: '/query' + path: '/query' + fullPath: '/query' + preLoaderRoute: typeof QueryRouteImport parentRoute: typeof rootRouteImport } '/form': { @@ -119,18 +157,39 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof FormRouteImport parentRoute: typeof rootRouteImport } - '/lensform': { - id: '/lensform' - path: '/lensform' - fullPath: '/lensform' - preLoaderRoute: typeof LensformRouteImport + '/blank': { + id: '/blank' + path: '/blank' + fullPath: '/blank' + preLoaderRoute: typeof BlankRouteImport parentRoute: typeof rootRouteImport } - '/query': { - id: '/query' - path: '/query' - fullPath: '/query' - preLoaderRoute: typeof QueryRouteImport + '/async': { + id: '/async' + path: '/async' + fullPath: '/async' + preLoaderRoute: typeof AsyncRouteImport + 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/context': { + id: '/dev/context' + path: '/dev/context' + fullPath: '/dev/context' + preLoaderRoute: typeof DevContextRouteImport parentRoute: typeof rootRouteImport } } @@ -141,8 +200,10 @@ const rootRouteChildren: RootRouteChildren = { AsyncRoute: AsyncRoute, BlankRoute: BlankRoute, FormRoute: FormRoute, - LensformRoute: LensformRoute, QueryRoute: QueryRoute, + ResultRoute: ResultRoute, + DevContextRoute: DevContextRoute, + DevMemoRoute: DevMemoRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/packages/example-next/src/routes/__root.tsx b/packages/effect-fc-example/src/routes/__root.tsx similarity index 75% rename from packages/example-next/src/routes/__root.tsx rename to packages/effect-fc-example/src/routes/__root.tsx index aba3a8d..67b0910 100644 --- a/packages/example-next/src/routes/__root.tsx +++ b/packages/effect-fc-example/src/routes/__root.tsx @@ -7,7 +7,7 @@ import "../index.css" export const Route = createRootRoute({ - component: Root, + component: Root }) function Root() { @@ -17,10 +17,6 @@ function Root() { Index Blank - Async - Query - Form - LensForm diff --git a/packages/example-next/src/routes/async.tsx b/packages/effect-fc-example/src/routes/async.tsx similarity index 50% rename from packages/example-next/src/routes/async.tsx rename to packages/effect-fc-example/src/routes/async.tsx index 45c12c2..62e0234 100644 --- a/packages/example-next/src/routes/async.tsx +++ b/packages/effect-fc-example/src/routes/async.tsx @@ -1,35 +1,30 @@ +import { HttpClient } from "@effect/platform" import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes" import { createFileRoute } from "@tanstack/react-router" -import { Console, Effect } from "effect" -import { Async, Component, Memoized } from "effect-fc-next" +import { Array, Effect, flow, Option, Schema } from "effect" +import { Async, Component, Memoized } from "effect-fc" import * as React from "react" import { runtime } from "@/runtime" -const fetchPost = (id: number) => Effect.sleep("500 millis").pipe( - Effect.as({ - title: `Post ${id}`, - body: `This is the content of post ${id}.`, - }), -) - +const Post = Schema.Struct({ + userId: Schema.Int, + id: Schema.Int, + title: Schema.String, + body: Schema.String, +}) interface AsyncFetchPostViewProps { readonly id: number } -const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*( - props: AsyncFetchPostViewProps, -) { - yield* Component.useOnMount(() => Effect.gen(function*() { - yield* Effect.addFinalizer(() => Console.log("AsyncFetchPostView unmounted")) - yield* Console.log("AsyncFetchPostView mounted") - })) - - const post = yield* Component.useOnChange( - () => fetchPost(props.id), - [props.id], - ) +class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) { + const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe( + Effect.tap(Effect.sleep("500 millis")), + Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)), + Effect.andThen(response => response.json), + Effect.andThen(Schema.decodeUnknown(Post)), + ), [props.id]) return (
@@ -39,9 +34,10 @@ const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*( ) }).pipe( Async.async, - Async.withOptions({ defaultFallback: Loading post... }), + Async.withOptions({ defaultFallback: Default fallback }), Memoized.memoized, -) +) {} + const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() { const [text, setText] = React.useState("Typing here should not trigger a refetch of the post") @@ -54,22 +50,20 @@ const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() { setText(event.currentTarget.value)} + onChange={e => setText(e.currentTarget.value)} /> setId(value ?? 1)} + onValueChange={flow(Array.head, Option.getOrThrow, setId)} /> - + Loading post...} /> ) }).pipe( - Component.withContext(runtime.context), + Component.withRuntime(runtime.context) ) export const Route = createFileRoute("/async")({ diff --git a/packages/example-next/src/routes/blank.tsx b/packages/effect-fc-example/src/routes/blank.tsx similarity index 100% rename from packages/example-next/src/routes/blank.tsx rename to packages/effect-fc-example/src/routes/blank.tsx diff --git a/packages/example/src/routes/dev/context.tsx b/packages/effect-fc-example/src/routes/dev/context.tsx similarity index 100% rename from packages/example/src/routes/dev/context.tsx rename to packages/effect-fc-example/src/routes/dev/context.tsx diff --git a/packages/example/src/routes/dev/memo.tsx b/packages/effect-fc-example/src/routes/dev/memo.tsx similarity index 100% rename from packages/example/src/routes/dev/memo.tsx rename to packages/effect-fc-example/src/routes/dev/memo.tsx diff --git a/packages/effect-fc-example/src/routes/form.tsx b/packages/effect-fc-example/src/routes/form.tsx new file mode 100644 index 0000000..cde4eda --- /dev/null +++ b/packages/effect-fc-example/src/routes/form.tsx @@ -0,0 +1,143 @@ +import { Button, Container, Flex, Text } from "@radix-ui/themes" +import { createFileRoute } from "@tanstack/react-router" +import { Console, Effect, Match, Option, ParseResult, Schema } from "effect" +import { Component, Form, SubmittableForm, Subscribable } from "effect-fc" +import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView" +import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView" +import { DateTimeUtcFromZonedInput } from "@/lib/schema" +import { runtime } from "@/runtime" + + +const email = Schema.pattern( + /^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i, + + { + identifier: "email", + title: "email", + message: () => "Not an email address", + }, +) + +const RegisterFormSchema = Schema.Struct({ + email: Schema.String.pipe(email), + password: Schema.String.pipe(Schema.minLength(3)), + birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput), +}) + +const RegisterFormSubmitSchema = Schema.Struct({ + email: Schema.transformOrFail( + Schema.String, + Schema.String, + { + decode: (input, _options, ast) => input !== "admin@admin.com" + ? ParseResult.succeed(input) + : ParseResult.fail(new ParseResult.Type(ast, input, "This email is already in use.")), + encode: ParseResult.succeed, + }, + ), + password: Schema.String, + birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf), +}) + +class RegisterFormService extends Effect.Service()("RegisterFormService", { + scoped: Effect.gen(function*() { + const form = yield* SubmittableForm.service({ + schema: RegisterFormSchema.pipe( + Schema.compose( + Schema.transformOrFail( + Schema.typeSchema(RegisterFormSchema), + Schema.typeSchema(RegisterFormSchema), + { + decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)), + encode: ParseResult.succeed, + }, + ), + ), + ), + + initialEncodedValue: { email: "", password: "", birth: Option.none() }, + f: Effect.fnUntraced(function*([value]) { + yield* Effect.sleep("500 millis") + return yield* Schema.decode(RegisterFormSubmitSchema)(value) + }), + }) + + return { + form, + emailField: Form.focusObjectOn(form, "email"), + passwordField: Form.focusObjectOn(form, "password"), + birthField: Form.focusObjectOn(form, "birth"), + } as const + }) +}) {} + +class RegisterFormView extends Component.make("RegisterFormView")(function*() { + const form = yield* RegisterFormService + const [canCommit, submitResult] = yield* Subscribable.useAll([ + form.form.canCommit, + form.form.mutation.result, + ]) + + const runPromise = yield* Component.useRunPromise() + const TextFieldFormInput = yield* TextFieldFormInputView.use + const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use + + yield* Component.useOnMount(() => Effect.gen(function*() { + yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted")) + yield* Console.log("RegisterFormView mounted") + })) + + + return ( + +
{ + e.preventDefault() + void runPromise(form.form.submit) + }}> + + + + + + + + + +
+ + {Match.value(submitResult).pipe( + Match.tag("Initial", () => <>), + Match.tag("Running", () => Submitting...), + Match.tag("Success", () => Submitted successfully!), + Match.tag("Failure", e => Error: {e.cause.toString()}), + Match.exhaustive, + )} +
+ ) +}) {} + +const RegisterPage = Component.make("RegisterPageView")(function*() { + const RegisterForm = yield* Effect.provide( + RegisterFormView.use, + yield* Component.useLayer(RegisterFormService.Default), + ) + + return +}).pipe( + Component.withRuntime(runtime.context) +) + + +export const Route = createFileRoute("/form")({ + component: RegisterPage +}) diff --git a/packages/effect-fc-example/src/routes/index.tsx b/packages/effect-fc-example/src/routes/index.tsx new file mode 100644 index 0000000..b4582f3 --- /dev/null +++ b/packages/effect-fc-example/src/routes/index.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router" +import { Effect } from "effect" +import { Component } from "effect-fc" +import { runtime } from "@/runtime" +import { TodosState } from "@/todo/TodosState" +import { TodosView } from "@/todo/TodosView" + + +const TodosStateLive = TodosState.Default("todos") + +const Index = Component.make("IndexView")(function*() { + const Todos = yield* Effect.provide( + TodosView.use, + yield* Component.useLayer(TodosStateLive), + ) + + return +}).pipe( + Component.withRuntime(runtime.context) +) + +export const Route = createFileRoute("/")({ + component: Index +}) diff --git a/packages/effect-fc-example/src/routes/query.tsx b/packages/effect-fc-example/src/routes/query.tsx new file mode 100644 index 0000000..047560e --- /dev/null +++ b/packages/effect-fc-example/src/routes/query.tsx @@ -0,0 +1,117 @@ +import { HttpClient, type HttpClientError } from "@effect/platform" +import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes" +import { createFileRoute } from "@tanstack/react-router" +import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect" +import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc" +import { runtime } from "@/runtime" + + +const Post = Schema.Struct({ + userId: Schema.Int, + id: Schema.Int, + title: Schema.String, + body: Schema.String, +}) + +const ResultView = Component.make("ResultView")(function*() { + const runPromise = yield* Component.useRunPromise() + + const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() { + const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1)) + const key = Stream.map(idLens.changes, id => [id] as const) + + const query = yield* Query.service({ + key, + f: ([id]) => HttpClient.HttpClient.pipe( + Effect.tap(Effect.sleep("500 millis")), + Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), + Effect.andThen(response => response.json), + Effect.andThen(Schema.decodeUnknown(Post)), + ), + staleTime: "10 seconds", + }) + + const mutation = yield* Mutation.make({ + f: ([id]: readonly [id: number]) => HttpClient.HttpClient.pipe( + Effect.tap(Effect.sleep("500 millis")), + Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), + Effect.andThen(response => response.json), + Effect.andThen(Schema.decodeUnknown(Post)), + ), + }) + + return [idLens, query, mutation] as const + })) + + const [id, setId] = yield* Lens.useState(idLens) + const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result]) + + yield* Component.useOnMount(() => ErrorObserver.ErrorObserver().pipe( + Effect.andThen(observer => observer.subscribe), + Effect.andThen(Stream.fromQueue), + Stream.unwrapScoped, + Stream.runForEach(flow( + Cause.failures, + Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"), + Option.match({ + onSome: e => Console.log("ResultView HttpClient error", e), + onNone: () => Effect.void, + }), + )), + Effect.forkScoped, + )) + + return ( + + + + +
+ {Match.value(queryResult).pipe( + Match.tag("Running", () => Loading...), + Match.tag("Success", result => <> + {result.value.title} + {result.value.body} + {Result.hasRefreshingFlag(result) && Refreshing...} + ), + Match.tag("Failure", result => + An error has occured: {result.cause.toString()} + ), + Match.orElse(() => <>), + )} +
+ + + + + + +
+ {Match.value(mutationResult).pipe( + Match.tag("Running", () => Loading...), + Match.tag("Success", result => <> + {result.value.title} + {result.value.body} + {Result.hasRefreshingFlag(result) && Refreshing...} + ), + Match.tag("Failure", result => + An error has occured: {result.cause.toString()} + ), + Match.orElse(() => <>), + )} +
+ + + + +
+
+ ) +}) + +export const Route = createFileRoute("/query")({ + component: Component.withRuntime(ResultView, runtime.context) +}) diff --git a/packages/example/src/routes/result.tsx b/packages/effect-fc-example/src/routes/result.tsx similarity index 100% rename from packages/example/src/routes/result.tsx rename to packages/effect-fc-example/src/routes/result.tsx diff --git a/packages/example-next/src/runtime.ts b/packages/effect-fc-example/src/runtime.ts similarity index 59% rename from packages/example-next/src/runtime.ts rename to packages/effect-fc-example/src/runtime.ts index c50daee..3467529 100644 --- a/packages/example-next/src/runtime.ts +++ b/packages/effect-fc-example/src/runtime.ts @@ -1,11 +1,10 @@ +import { FetchHttpClient } from "@effect/platform" import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" import { DateTime, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" -import { QueryClient, ReactRuntime } from "effect-fc-next" +import { ReactRuntime } from "effect-fc" -export const layer = Layer.empty.pipe( - Layer.provideMerge(QueryClient.layer()), +export const AppLive = Layer.empty.pipe( Layer.provideMerge(DateTime.layerCurrentZoneLocal), Layer.provideMerge(Clipboard.layer), Layer.provideMerge(Geolocation.layer), @@ -13,4 +12,4 @@ export const layer = Layer.empty.pipe( Layer.provideMerge(FetchHttpClient.layer), ) -export const runtime = ReactRuntime.make(layer) +export const runtime = ReactRuntime.make(AppLive) diff --git a/packages/example/src/todo/EditTodoView.tsx b/packages/effect-fc-example/src/todo/EditTodoView.tsx similarity index 100% rename from packages/example/src/todo/EditTodoView.tsx rename to packages/effect-fc-example/src/todo/EditTodoView.tsx diff --git a/packages/example/src/todo/NewTodoView.tsx b/packages/effect-fc-example/src/todo/NewTodoView.tsx similarity index 100% rename from packages/example/src/todo/NewTodoView.tsx rename to packages/effect-fc-example/src/todo/NewTodoView.tsx diff --git a/packages/example/src/todo/TodoFormSchema.ts b/packages/effect-fc-example/src/todo/TodoFormSchema.ts similarity index 100% rename from packages/example/src/todo/TodoFormSchema.ts rename to packages/effect-fc-example/src/todo/TodoFormSchema.ts diff --git a/packages/example/src/todo/TodosState.ts b/packages/effect-fc-example/src/todo/TodosState.ts similarity index 100% rename from packages/example/src/todo/TodosState.ts rename to packages/effect-fc-example/src/todo/TodosState.ts diff --git a/packages/example/src/todo/TodosView.tsx b/packages/effect-fc-example/src/todo/TodosView.tsx similarity index 100% rename from packages/example/src/todo/TodosView.tsx rename to packages/effect-fc-example/src/todo/TodosView.tsx diff --git a/packages/example-next/src/vite-env.d.ts b/packages/effect-fc-example/src/vite-env.d.ts similarity index 100% rename from packages/example-next/src/vite-env.d.ts rename to packages/effect-fc-example/src/vite-env.d.ts diff --git a/packages/example-next/tsconfig.app.json b/packages/effect-fc-example/tsconfig.app.json similarity index 100% rename from packages/example-next/tsconfig.app.json rename to packages/effect-fc-example/tsconfig.app.json diff --git a/packages/example-next/tsconfig.json b/packages/effect-fc-example/tsconfig.json similarity index 100% rename from packages/example-next/tsconfig.json rename to packages/effect-fc-example/tsconfig.json diff --git a/packages/example-next/tsconfig.node.json b/packages/effect-fc-example/tsconfig.node.json similarity index 100% rename from packages/example-next/tsconfig.node.json rename to packages/effect-fc-example/tsconfig.node.json diff --git a/packages/example-next/vite.config.ts b/packages/effect-fc-example/vite.config.ts similarity index 84% rename from packages/example-next/vite.config.ts rename to packages/effect-fc-example/vite.config.ts index 45684ff..79367b3 100644 --- a/packages/example-next/vite.config.ts +++ b/packages/effect-fc-example/vite.config.ts @@ -1,6 +1,5 @@ import { tanstackRouter } from "@tanstack/router-plugin/vite" import react from "@vitejs/plugin-react" -import { effectViewPlugin } from "@effect-view/vite-plugin" import path from "node:path" import { defineConfig } from "vite" @@ -8,7 +7,6 @@ import { defineConfig } from "vite" // https://vite.dev/config/ export default defineConfig({ plugins: [ - effectViewPlugin(), tanstackRouter({ target: "react", autoCodeSplitting: true, diff --git a/packages/effect-fc-next/README.md b/packages/effect-view/README.md similarity index 100% rename from packages/effect-fc-next/README.md rename to packages/effect-view/README.md diff --git a/packages/effect-fc-next/biome.json b/packages/effect-view/biome.json similarity index 100% rename from packages/effect-fc-next/biome.json rename to packages/effect-view/biome.json diff --git a/packages/effect-fc-next/package.json b/packages/effect-view/package.json similarity index 98% rename from packages/effect-fc-next/package.json rename to packages/effect-view/package.json index c6f0226..400c4fb 100644 --- a/packages/effect-fc-next/package.json +++ b/packages/effect-view/package.json @@ -1,7 +1,7 @@ { - "name": "effect-fc-next", + "name": "effect-view", "description": "Write React function components with Effect", - "version": "0.1.0-beta.0", + "version": "0.1.0", "type": "module", "files": [ "./README.md", diff --git a/packages/effect-fc-next/src/Async.test.tsx b/packages/effect-view/src/Async.test.tsx similarity index 100% rename from packages/effect-fc-next/src/Async.test.tsx rename to packages/effect-view/src/Async.test.tsx diff --git a/packages/effect-fc-next/src/Async.ts b/packages/effect-view/src/Async.ts similarity index 100% rename from packages/effect-fc-next/src/Async.ts rename to packages/effect-view/src/Async.ts diff --git a/packages/effect-fc-next/src/Component.test.tsx b/packages/effect-view/src/Component.test.tsx similarity index 100% rename from packages/effect-fc-next/src/Component.test.tsx rename to packages/effect-view/src/Component.test.tsx diff --git a/packages/effect-fc-next/src/Component.ts b/packages/effect-view/src/Component.ts similarity index 100% rename from packages/effect-fc-next/src/Component.ts rename to packages/effect-view/src/Component.ts diff --git a/packages/effect-fc-next/src/Form.ts b/packages/effect-view/src/Form.ts similarity index 100% rename from packages/effect-fc-next/src/Form.ts rename to packages/effect-view/src/Form.ts diff --git a/packages/effect-fc-next/src/Lens.test.tsx b/packages/effect-view/src/Lens.test.tsx similarity index 100% rename from packages/effect-fc-next/src/Lens.test.tsx rename to packages/effect-view/src/Lens.test.tsx diff --git a/packages/effect-fc-next/src/Lens.ts b/packages/effect-view/src/Lens.ts similarity index 100% rename from packages/effect-fc-next/src/Lens.ts rename to packages/effect-view/src/Lens.ts diff --git a/packages/effect-fc-next/src/LensForm.ts b/packages/effect-view/src/LensForm.ts similarity index 100% rename from packages/effect-fc-next/src/LensForm.ts rename to packages/effect-view/src/LensForm.ts diff --git a/packages/effect-fc-next/src/Memoized.ts b/packages/effect-view/src/Memoized.ts similarity index 100% rename from packages/effect-fc-next/src/Memoized.ts rename to packages/effect-view/src/Memoized.ts diff --git a/packages/effect-fc-next/src/Mutation.ts b/packages/effect-view/src/Mutation.ts similarity index 100% rename from packages/effect-fc-next/src/Mutation.ts rename to packages/effect-view/src/Mutation.ts diff --git a/packages/effect-fc-next/src/MutationForm.ts b/packages/effect-view/src/MutationForm.ts similarity index 100% rename from packages/effect-fc-next/src/MutationForm.ts rename to packages/effect-view/src/MutationForm.ts diff --git a/packages/effect-fc-next/src/PubSub.ts b/packages/effect-view/src/PubSub.ts similarity index 100% rename from packages/effect-fc-next/src/PubSub.ts rename to packages/effect-view/src/PubSub.ts diff --git a/packages/effect-fc-next/src/Query.test.ts b/packages/effect-view/src/Query.test.ts similarity index 100% rename from packages/effect-fc-next/src/Query.test.ts rename to packages/effect-view/src/Query.test.ts diff --git a/packages/effect-fc-next/src/Query.ts b/packages/effect-view/src/Query.ts similarity index 100% rename from packages/effect-fc-next/src/Query.ts rename to packages/effect-view/src/Query.ts diff --git a/packages/effect-fc-next/src/QueryClient.ts b/packages/effect-view/src/QueryClient.ts similarity index 100% rename from packages/effect-fc-next/src/QueryClient.ts rename to packages/effect-view/src/QueryClient.ts diff --git a/packages/effect-fc-next/src/ReactRuntime.ts b/packages/effect-view/src/ReactRuntime.ts similarity index 100% rename from packages/effect-fc-next/src/ReactRuntime.ts rename to packages/effect-view/src/ReactRuntime.ts diff --git a/packages/effect-fc-next/src/Refreshable.test.ts b/packages/effect-view/src/Refreshable.test.ts similarity index 100% rename from packages/effect-fc-next/src/Refreshable.test.ts rename to packages/effect-view/src/Refreshable.test.ts diff --git a/packages/effect-fc-next/src/Refreshable.ts b/packages/effect-view/src/Refreshable.ts similarity index 100% rename from packages/effect-fc-next/src/Refreshable.ts rename to packages/effect-view/src/Refreshable.ts diff --git a/packages/effect-fc-next/src/ScopeRegistry.ts b/packages/effect-view/src/ScopeRegistry.ts similarity index 100% rename from packages/effect-fc-next/src/ScopeRegistry.ts rename to packages/effect-view/src/ScopeRegistry.ts diff --git a/packages/effect-fc-next/src/SetStateAction.ts b/packages/effect-view/src/SetStateAction.ts similarity index 100% rename from packages/effect-fc-next/src/SetStateAction.ts rename to packages/effect-view/src/SetStateAction.ts diff --git a/packages/effect-fc-next/src/Stream.ts b/packages/effect-view/src/Stream.ts similarity index 100% rename from packages/effect-fc-next/src/Stream.ts rename to packages/effect-view/src/Stream.ts diff --git a/packages/effect-fc-next/src/View.test.tsx b/packages/effect-view/src/View.test.tsx similarity index 100% rename from packages/effect-fc-next/src/View.test.tsx rename to packages/effect-view/src/View.test.tsx diff --git a/packages/effect-fc-next/src/View.ts b/packages/effect-view/src/View.ts similarity index 100% rename from packages/effect-fc-next/src/View.ts rename to packages/effect-view/src/View.ts diff --git a/packages/effect-fc-next/src/index.ts b/packages/effect-view/src/index.ts similarity index 100% rename from packages/effect-fc-next/src/index.ts rename to packages/effect-view/src/index.ts diff --git a/packages/effect-fc-next/src/setup-tests.ts b/packages/effect-view/src/setup-tests.ts similarity index 100% rename from packages/effect-fc-next/src/setup-tests.ts rename to packages/effect-view/src/setup-tests.ts diff --git a/packages/effect-fc-next/tsconfig.build.json b/packages/effect-view/tsconfig.build.json similarity index 100% rename from packages/effect-fc-next/tsconfig.build.json rename to packages/effect-view/tsconfig.build.json diff --git a/packages/effect-fc-next/tsconfig.json b/packages/effect-view/tsconfig.json similarity index 100% rename from packages/effect-fc-next/tsconfig.json rename to packages/effect-view/tsconfig.json diff --git a/packages/effect-fc-next/vitest.config.ts b/packages/effect-view/vitest.config.ts similarity index 100% rename from packages/effect-fc-next/vitest.config.ts rename to packages/effect-view/vitest.config.ts diff --git a/packages/example-next/README.md b/packages/example-next/README.md deleted file mode 100644 index aef5174..0000000 --- a/packages/example-next/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Effect FC Next Example - -Minimal React example for `effect-fc-next`, Effect V4, and `effect-lens@2`. - -```bash -bun run dev -``` - -The counter demonstrates a V4 `SubscriptionRef` exposed as an Effect Lens and -rendered through an Effect-FC component. diff --git a/packages/example-next/src/routes/form.tsx b/packages/example-next/src/routes/form.tsx deleted file mode 100644 index 38c9109..0000000 --- a/packages/example-next/src/routes/form.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Button, Container, Flex, Text } from "@radix-ui/themes" -import { createFileRoute } from "@tanstack/react-router" -import { Console, Effect, Schema } from "effect" -import { Component, Form, MutationForm, View } from "effect-fc-next" -import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView" -import { runtime } from "@/runtime" - - -const RegisterSchema = Schema.Struct({ - email: Schema.String.check( - Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, { - message: "Enter a valid email address", - }), - ), - password: Schema.String.check( - Schema.isMinLength(5, { - message: "Password must be at least 5 characters long", - }), - ), -}) - -const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() { - yield* Component.useOnMount(() => Effect.gen(function*() { - yield* Effect.addFinalizer(() => Console.log("Form route unmounted")) - yield* Console.log("Form route mounted") - })) - - const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() { - const form = yield* MutationForm.service({ - schema: RegisterSchema, - initialEncodedValue: { email: "", password: "" }, - f: ([value]) => Effect.log(`Registered ${value.email}`), - }) - - const emailField = Form.focusObjectOn(form, "email") - const passwordField = Form.focusObjectOn(form, "password") - - return [form, emailField, passwordField] as const - })) - - const [canCommit, isCommitting] = yield* View.useAll([ - form.canCommit, - form.isCommitting, - ]) - - const TextFieldFormInput = yield* TextFieldFormInputView.use - const runPromise = yield* Component.useRunPromise() - - - return ( - -
{ - event.preventDefault() - void runPromise(form.submit) - }}> - - - - - -
- A MutationForm validates local input, then submits it. -
- ) -}).pipe( - Component.withContext(runtime.context), -) - -export const Route = createFileRoute("/form")({ - component: RegisterRouteComponent, -}) diff --git a/packages/example-next/src/routes/index.tsx b/packages/example-next/src/routes/index.tsx deleted file mode 100644 index 7f98e30..0000000 --- a/packages/example-next/src/routes/index.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Button, Container, Flex, Text, TextField } from "@radix-ui/themes" -import { createFileRoute } from "@tanstack/react-router" -import { Effect, SubscriptionRef } from "effect" -import { Component, Lens, View } from "effect-fc-next" -import { runtime } from "@/runtime" - - -const TodoRouteComponent = Component.make("TodoRouteView")(function*() { - const todosLens = yield* Component.useOnMount(() => Effect.map( - SubscriptionRef.make([]), - Lens.fromSubscriptionRef, - )) - const draftLens = yield* Component.useOnMount(() => Effect.map( - SubscriptionRef.make(""), - Lens.fromSubscriptionRef, - )) - - const [todos] = yield* View.useAll([todosLens]) - const [draft, setDraft] = yield* Lens.useState(draftLens) - const runPromise = yield* Component.useRunPromise() - - const addTodo = Lens.update(todosLens, todos => - draft.trim() === "" - ? todos - : [...todos, draft.trim()], - ).pipe( - Effect.andThen(Lens.set(draftLens, "")), - ) - - return ( - - - A small Effect v4 todo state example backed by a Lens. - - - setDraft(event.currentTarget.value)} - /> - - - - - {todos.map(todo => • {todo})} - - - ) -}).pipe( - Component.withContext(runtime.context), -) - -export const Route = createFileRoute("/")({ - component: TodoRouteComponent, -}) diff --git a/packages/example-next/src/routes/query.tsx b/packages/example-next/src/routes/query.tsx deleted file mode 100644 index fd41ad8..0000000 --- a/packages/example-next/src/routes/query.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes" -import { createFileRoute } from "@tanstack/react-router" -import { Effect, Schema, SubscriptionRef } from "effect" -import { HttpClient } from "effect/unstable/http" -import { AsyncResult } from "effect/unstable/reactivity" -import { Component, Lens, Mutation, Query, View } from "effect-fc-next" -import { runtime } from "@/runtime" - - -const Post = Schema.Struct({ - userId: Schema.Int, - id: Schema.Int, - title: Schema.String, - body: Schema.String, -}) - - -interface PostResultViewProps { - readonly result: AsyncResult.AsyncResult -} - -const PostResultView = (props: PostResultViewProps) => AsyncResult.match(props.result, { - onInitial: result => result.waiting - ? Loading... - : No data., - onFailure: result => Request failed: { result.cause.toString() }, - onSuccess: result => <> - {result.waiting && Refreshing...} - {result.value.title} - {result.value.body} - , -}) - -const QueryRouteComponent = Component.make("QueryRouteView")(function*() { - const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() { - const keyLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const)) - const idLens = Lens.focusTupleAt(keyLens, 1) - - const query = yield* Query.service({ - key: keyLens, - f: ([, id]) => HttpClient.HttpClient.pipe( - Effect.tap(Effect.sleep("1 second")), - Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), - Effect.andThen(response => response.json), - Effect.andThen(Schema.decodeUnknownEffect(Post)), - ), - staleTime: "10 seconds", - }) - - const mutation = yield* Mutation.make({ - f: ([id]: [id: number]) => HttpClient.HttpClient.pipe( - Effect.tap(Effect.sleep("1 second")), - Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), - Effect.andThen(response => response.json), - Effect.andThen(Schema.decodeUnknownEffect(Post)), - ), - }) - - return [idLens, query, mutation] as const - })) - - const [id, setId] = yield* Lens.useState(idLens) - const [queryState, mutationState] = yield* View.useAll([query.state, mutation.state]) - - const runSync = yield* Component.useRunSync() - - return ( - - - setId(value ?? 1)} - /> - - - - - - - - - - - - - - ) -}).pipe( - Component.withContext(runtime.context), -) - -export const Route = createFileRoute("/query")({ - component: QueryRouteComponent, -}) diff --git a/packages/example/README.md b/packages/example/README.md index 74872fd..aef5174 100644 --- a/packages/example/README.md +++ b/packages/example/README.md @@ -1,50 +1,10 @@ -# React + TypeScript + Vite +# Effect FC Next Example -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +Minimal React example for `effect-fc-next`, Effect V4, and `effect-lens@2`. -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default tseslint.config({ - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) +```bash +bun run dev ``` -- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` -- Optionally add `...tseslint.configs.stylisticTypeChecked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: - -```js -// eslint.config.js -import react from 'eslint-plugin-react' - -export default tseslint.config({ - // Set the react version - settings: { react: { version: '18.3' } }, - plugins: { - // Add the react plugin - react, - }, - rules: { - // other rules... - // Enable its recommended rules - ...react.configs.recommended.rules, - ...react.configs['jsx-runtime'].rules, - }, -}) -``` +The counter demonstrates a V4 `SubscriptionRef` exposed as an Effect Lens and +rendered through an Effect-FC component. diff --git a/packages/example/package.json b/packages/example/package.json index 141145e..232f8c2 100644 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -1,9 +1,10 @@ { - "name": "@effect-fc/example", + "name": "@effect-view/example", "version": "0.0.0", "type": "module", "private": true, "scripts": { + "build": "tsc -b && vite build", "dev": "vite", "lint:tsc": "tsc -b --noEmit", "lint:biome": "biome lint", @@ -13,6 +14,7 @@ "clean:modules": "rm -rf node_modules" }, "devDependencies": { + "@effect-view/vite-plugin": "workspace:*", "@tanstack/react-router": "^1.170.10", "@tanstack/react-router-devtools": "^1.167.0", "@tanstack/router-plugin": "^1.168.13", @@ -22,21 +24,18 @@ "globals": "^17.6.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "type-fest": "^5.7.0", "vite": "^8.0.16" }, "dependencies": { - "@effect/platform": "^0.96.1", - "@effect/platform-browser": "^0.76.0", + "@effect/platform-browser": "4.0.0-beta.98", "@radix-ui/themes": "^3.3.0", - "@typed/id": "^0.17.2", - "effect": "^3.21.2", - "effect-fc": "workspace:*", + "effect": "4.0.0-beta.98", + "effect-fc-next": "workspace:*", "react-icons": "^5.6.0" }, "overrides": { "@types/react": "^19.2.15", - "effect": "^3.21.2", + "effect": "4.0.0-beta.98", "react": "^19.2.6" } } diff --git a/packages/example/src/lib/form/TextFieldFormInputView.tsx b/packages/example/src/lib/form/TextFieldFormInputView.tsx index ca4f843..48241a9 100644 --- a/packages/example/src/lib/form/TextFieldFormInputView.tsx +++ b/packages/example/src/lib/form/TextFieldFormInputView.tsx @@ -1,6 +1,6 @@ import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes" import { Array, Option, Struct } from "effect" -import { Component, Form, Subscribable } from "effect-fc" +import { Component, Form, View } from "effect-fc-next" import type * as React from "react" @@ -17,7 +17,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f props: TextFieldFormInputView.Props ) { const input = yield* Form.useInput(props.form, props) - const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([ + const [issues, isValidating, isCommitting] = yield* View.useAll([ props.form.issues, props.form.isValidating, props.form.isCommitting, @@ -29,7 +29,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f value={input.value} onChange={e => input.setValue(e.target.value)} disabled={isCommitting} - {...Struct.omit(props, "form")} + {...Struct.omit(props, ["form"])} > {isValidating && diff --git a/packages/example/src/lib/form/TextFieldOptionalFormInputView.tsx b/packages/example/src/lib/form/TextFieldOptionalFormInputView.tsx index 68693ea..cd2bea4 100644 --- a/packages/example/src/lib/form/TextFieldOptionalFormInputView.tsx +++ b/packages/example/src/lib/form/TextFieldOptionalFormInputView.tsx @@ -1,6 +1,6 @@ import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes" import { Array, Option, Struct } from "effect" -import { Component, Form, Subscribable } from "effect-fc" +import { Component, Form, View } from "effect-fc-next" import type * as React from "react" @@ -17,7 +17,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF props: TextFieldOptionalFormInputView.Props ) { const input = yield* Form.useOptionalInput(props.form, props) - const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([ + const [issues, isValidating, isCommitting] = yield* View.useAll([ props.form.issues, props.form.isValidating, props.form.isCommitting, @@ -29,7 +29,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF value={input.value} onChange={e => input.setValue(e.target.value)} disabled={!input.enabled || isCommitting} - {...Struct.omit(props, "form", "defaultValue")} + {...Struct.omit(props, ["form", "defaultValue"])} > - + , ) diff --git a/packages/example/src/routeTree.gen.ts b/packages/example/src/routeTree.gen.ts index 0e6de17..b70ab97 100644 --- a/packages/example/src/routeTree.gen.ts +++ b/packages/example/src/routeTree.gen.ts @@ -9,33 +9,16 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as ResultRouteImport } from './routes/result' -import { Route as QueryRouteImport } from './routes/query' -import { Route as FormRouteImport } from './routes/form' -import { Route as BlankRouteImport } from './routes/blank' -import { Route as AsyncRouteImport } from './routes/async' import { Route as IndexRouteImport } from './routes/index' -import { Route as DevMemoRouteImport } from './routes/dev/memo' -import { Route as DevContextRouteImport } from './routes/dev/context' +import { Route as AsyncRouteImport } from './routes/async' +import { Route as BlankRouteImport } from './routes/blank' +import { Route as FormRouteImport } from './routes/form' +import { Route as LensformRouteImport } from './routes/lensform' +import { Route as QueryRouteImport } from './routes/query' -const ResultRoute = ResultRouteImport.update({ - id: '/result', - path: '/result', - getParentRoute: () => rootRouteImport, -} as any) -const QueryRoute = QueryRouteImport.update({ - id: '/query', - path: '/query', - getParentRoute: () => rootRouteImport, -} as any) -const FormRoute = FormRouteImport.update({ - id: '/form', - path: '/form', - getParentRoute: () => rootRouteImport, -} as any) -const BlankRoute = BlankRouteImport.update({ - id: '/blank', - path: '/blank', +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', getParentRoute: () => rootRouteImport, } as any) const AsyncRoute = AsyncRouteImport.update({ @@ -43,19 +26,24 @@ const AsyncRoute = AsyncRouteImport.update({ path: '/async', getParentRoute: () => rootRouteImport, } as any) -const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', +const BlankRoute = BlankRouteImport.update({ + id: '/blank', + path: '/blank', getParentRoute: () => rootRouteImport, } as any) -const DevMemoRoute = DevMemoRouteImport.update({ - id: '/dev/memo', - path: '/dev/memo', +const FormRoute = FormRouteImport.update({ + id: '/form', + path: '/form', getParentRoute: () => rootRouteImport, } as any) -const DevContextRoute = DevContextRouteImport.update({ - id: '/dev/context', - path: '/dev/context', +const LensformRoute = LensformRouteImport.update({ + id: '/lensform', + path: '/lensform', + getParentRoute: () => rootRouteImport, +} as any) +const QueryRoute = QueryRouteImport.update({ + id: '/query', + path: '/query', getParentRoute: () => rootRouteImport, } as any) @@ -64,20 +52,16 @@ export interface FileRoutesByFullPath { '/async': typeof AsyncRoute '/blank': typeof BlankRoute '/form': typeof FormRoute + '/lensform': typeof LensformRoute '/query': typeof QueryRoute - '/result': typeof ResultRoute - '/dev/context': typeof DevContextRoute - '/dev/memo': typeof DevMemoRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/async': typeof AsyncRoute '/blank': typeof BlankRoute '/form': typeof FormRoute + '/lensform': typeof LensformRoute '/query': typeof QueryRoute - '/result': typeof ResultRoute - '/dev/context': typeof DevContextRoute - '/dev/memo': typeof DevMemoRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -85,42 +69,15 @@ export interface FileRoutesById { '/async': typeof AsyncRoute '/blank': typeof BlankRoute '/form': typeof FormRoute + '/lensform': typeof LensformRoute '/query': typeof QueryRoute - '/result': typeof ResultRoute - '/dev/context': typeof DevContextRoute - '/dev/memo': typeof DevMemoRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: - | '/' - | '/async' - | '/blank' - | '/form' - | '/query' - | '/result' - | '/dev/context' - | '/dev/memo' + fullPaths: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query' fileRoutesByTo: FileRoutesByTo - to: - | '/' - | '/async' - | '/blank' - | '/form' - | '/query' - | '/result' - | '/dev/context' - | '/dev/memo' - id: - | '__root__' - | '/' - | '/async' - | '/blank' - | '/form' - | '/query' - | '/result' - | '/dev/context' - | '/dev/memo' + to: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query' + id: '__root__' | '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -128,40 +85,17 @@ export interface RootRouteChildren { AsyncRoute: typeof AsyncRoute BlankRoute: typeof BlankRoute FormRoute: typeof FormRoute + LensformRoute: typeof LensformRoute QueryRoute: typeof QueryRoute - ResultRoute: typeof ResultRoute - DevContextRoute: typeof DevContextRoute - DevMemoRoute: typeof DevMemoRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/result': { - id: '/result' - path: '/result' - fullPath: '/result' - preLoaderRoute: typeof ResultRouteImport - parentRoute: typeof rootRouteImport - } - '/query': { - id: '/query' - path: '/query' - fullPath: '/query' - preLoaderRoute: typeof QueryRouteImport - parentRoute: typeof rootRouteImport - } - '/form': { - id: '/form' - path: '/form' - fullPath: '/form' - preLoaderRoute: typeof FormRouteImport - parentRoute: typeof rootRouteImport - } - '/blank': { - id: '/blank' - path: '/blank' - fullPath: '/blank' - preLoaderRoute: typeof BlankRouteImport + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } '/async': { @@ -171,25 +105,32 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AsyncRouteImport parentRoute: typeof rootRouteImport } - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexRouteImport + '/blank': { + id: '/blank' + path: '/blank' + fullPath: '/blank' + preLoaderRoute: typeof BlankRouteImport parentRoute: typeof rootRouteImport } - '/dev/memo': { - id: '/dev/memo' - path: '/dev/memo' - fullPath: '/dev/memo' - preLoaderRoute: typeof DevMemoRouteImport + '/form': { + id: '/form' + path: '/form' + fullPath: '/form' + preLoaderRoute: typeof FormRouteImport parentRoute: typeof rootRouteImport } - '/dev/context': { - id: '/dev/context' - path: '/dev/context' - fullPath: '/dev/context' - preLoaderRoute: typeof DevContextRouteImport + '/lensform': { + id: '/lensform' + path: '/lensform' + fullPath: '/lensform' + preLoaderRoute: typeof LensformRouteImport + parentRoute: typeof rootRouteImport + } + '/query': { + id: '/query' + path: '/query' + fullPath: '/query' + preLoaderRoute: typeof QueryRouteImport parentRoute: typeof rootRouteImport } } @@ -200,10 +141,8 @@ const rootRouteChildren: RootRouteChildren = { AsyncRoute: AsyncRoute, BlankRoute: BlankRoute, FormRoute: FormRoute, + LensformRoute: LensformRoute, QueryRoute: QueryRoute, - ResultRoute: ResultRoute, - DevContextRoute: DevContextRoute, - DevMemoRoute: DevMemoRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/packages/example/src/routes/__root.tsx b/packages/example/src/routes/__root.tsx index 67b0910..aba3a8d 100644 --- a/packages/example/src/routes/__root.tsx +++ b/packages/example/src/routes/__root.tsx @@ -7,7 +7,7 @@ import "../index.css" export const Route = createRootRoute({ - component: Root + component: Root, }) function Root() { @@ -17,6 +17,10 @@ function Root() { Index Blank + Async + Query + Form + LensForm diff --git a/packages/example/src/routes/async.tsx b/packages/example/src/routes/async.tsx index 62e0234..45c12c2 100644 --- a/packages/example/src/routes/async.tsx +++ b/packages/example/src/routes/async.tsx @@ -1,30 +1,35 @@ -import { HttpClient } from "@effect/platform" import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes" import { createFileRoute } from "@tanstack/react-router" -import { Array, Effect, flow, Option, Schema } from "effect" -import { Async, Component, Memoized } from "effect-fc" +import { Console, Effect } from "effect" +import { Async, Component, Memoized } from "effect-fc-next" import * as React from "react" import { runtime } from "@/runtime" -const Post = Schema.Struct({ - userId: Schema.Int, - id: Schema.Int, - title: Schema.String, - body: Schema.String, -}) +const fetchPost = (id: number) => Effect.sleep("500 millis").pipe( + Effect.as({ + title: `Post ${id}`, + body: `This is the content of post ${id}.`, + }), +) + interface AsyncFetchPostViewProps { readonly id: number } -class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) { - const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe( - Effect.tap(Effect.sleep("500 millis")), - Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)), - Effect.andThen(response => response.json), - Effect.andThen(Schema.decodeUnknown(Post)), - ), [props.id]) +const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*( + props: AsyncFetchPostViewProps, +) { + yield* Component.useOnMount(() => Effect.gen(function*() { + yield* Effect.addFinalizer(() => Console.log("AsyncFetchPostView unmounted")) + yield* Console.log("AsyncFetchPostView mounted") + })) + + const post = yield* Component.useOnChange( + () => fetchPost(props.id), + [props.id], + ) return (
@@ -34,10 +39,9 @@ class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*( ) }).pipe( Async.async, - Async.withOptions({ defaultFallback: Default fallback }), + Async.withOptions({ defaultFallback: Loading post... }), Memoized.memoized, -) {} - +) const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() { const [text, setText] = React.useState("Typing here should not trigger a refetch of the post") @@ -50,20 +54,22 @@ const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() { setText(e.currentTarget.value)} + onChange={event => setText(event.currentTarget.value)} /> setId(value ?? 1)} /> - Loading post...} /> + ) }).pipe( - Component.withRuntime(runtime.context) + Component.withContext(runtime.context), ) export const Route = createFileRoute("/async")({ diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index cde4eda..38c9109 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -1,143 +1,82 @@ import { Button, Container, Flex, Text } from "@radix-ui/themes" import { createFileRoute } from "@tanstack/react-router" -import { Console, Effect, Match, Option, ParseResult, Schema } from "effect" -import { Component, Form, SubmittableForm, Subscribable } from "effect-fc" +import { Console, Effect, Schema } from "effect" +import { Component, Form, MutationForm, View } from "effect-fc-next" import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView" -import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView" -import { DateTimeUtcFromZonedInput } from "@/lib/schema" import { runtime } from "@/runtime" -const email = Schema.pattern( - /^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i, - - { - identifier: "email", - title: "email", - message: () => "Not an email address", - }, -) - -const RegisterFormSchema = Schema.Struct({ - email: Schema.String.pipe(email), - password: Schema.String.pipe(Schema.minLength(3)), - birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput), -}) - -const RegisterFormSubmitSchema = Schema.Struct({ - email: Schema.transformOrFail( - Schema.String, - Schema.String, - { - decode: (input, _options, ast) => input !== "admin@admin.com" - ? ParseResult.succeed(input) - : ParseResult.fail(new ParseResult.Type(ast, input, "This email is already in use.")), - encode: ParseResult.succeed, - }, +const RegisterSchema = Schema.Struct({ + email: Schema.String.check( + Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, { + message: "Enter a valid email address", + }), + ), + password: Schema.String.check( + Schema.isMinLength(5, { + message: "Password must be at least 5 characters long", + }), ), - password: Schema.String, - birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf), }) -class RegisterFormService extends Effect.Service()("RegisterFormService", { - scoped: Effect.gen(function*() { - const form = yield* SubmittableForm.service({ - schema: RegisterFormSchema.pipe( - Schema.compose( - Schema.transformOrFail( - Schema.typeSchema(RegisterFormSchema), - Schema.typeSchema(RegisterFormSchema), - { - decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)), - encode: ParseResult.succeed, - }, - ), - ), - ), +const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() { + yield* Component.useOnMount(() => Effect.gen(function*() { + yield* Effect.addFinalizer(() => Console.log("Form route unmounted")) + yield* Console.log("Form route mounted") + })) - initialEncodedValue: { email: "", password: "", birth: Option.none() }, - f: Effect.fnUntraced(function*([value]) { - yield* Effect.sleep("500 millis") - return yield* Schema.decode(RegisterFormSubmitSchema)(value) - }), + const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() { + const form = yield* MutationForm.service({ + schema: RegisterSchema, + initialEncodedValue: { email: "", password: "" }, + f: ([value]) => Effect.log(`Registered ${value.email}`), }) - return { - form, - emailField: Form.focusObjectOn(form, "email"), - passwordField: Form.focusObjectOn(form, "password"), - birthField: Form.focusObjectOn(form, "birth"), - } as const - }) -}) {} + const emailField = Form.focusObjectOn(form, "email") + const passwordField = Form.focusObjectOn(form, "password") -class RegisterFormView extends Component.make("RegisterFormView")(function*() { - const form = yield* RegisterFormService - const [canCommit, submitResult] = yield* Subscribable.useAll([ - form.form.canCommit, - form.form.mutation.result, + return [form, emailField, passwordField] as const + })) + + const [canCommit, isCommitting] = yield* View.useAll([ + form.canCommit, + form.isCommitting, ]) - const runPromise = yield* Component.useRunPromise() const TextFieldFormInput = yield* TextFieldFormInputView.use - const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use - - yield* Component.useOnMount(() => Effect.gen(function*() { - yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted")) - yield* Console.log("RegisterFormView mounted") - })) + const runPromise = yield* Component.useRunPromise() return ( -
{ - e.preventDefault() - void runPromise(form.form.submit) + { + event.preventDefault() + void runPromise(form.submit) }}> - - - - - +
- - {Match.value(submitResult).pipe( - Match.tag("Initial", () => <>), - Match.tag("Running", () => Submitting...), - Match.tag("Success", () => Submitted successfully!), - Match.tag("Failure", e => Error: {e.cause.toString()}), - Match.exhaustive, - )} + A MutationForm validates local input, then submits it.
) -}) {} - -const RegisterPage = Component.make("RegisterPageView")(function*() { - const RegisterForm = yield* Effect.provide( - RegisterFormView.use, - yield* Component.useLayer(RegisterFormService.Default), - ) - - return }).pipe( - Component.withRuntime(runtime.context) + Component.withContext(runtime.context), ) - export const Route = createFileRoute("/form")({ - component: RegisterPage + component: RegisterRouteComponent, }) diff --git a/packages/example/src/routes/index.tsx b/packages/example/src/routes/index.tsx index b4582f3..7f98e30 100644 --- a/packages/example/src/routes/index.tsx +++ b/packages/example/src/routes/index.tsx @@ -1,24 +1,56 @@ +import { Button, Container, Flex, Text, TextField } from "@radix-ui/themes" import { createFileRoute } from "@tanstack/react-router" -import { Effect } from "effect" -import { Component } from "effect-fc" +import { Effect, SubscriptionRef } from "effect" +import { Component, Lens, View } from "effect-fc-next" import { runtime } from "@/runtime" -import { TodosState } from "@/todo/TodosState" -import { TodosView } from "@/todo/TodosView" -const TodosStateLive = TodosState.Default("todos") +const TodoRouteComponent = Component.make("TodoRouteView")(function*() { + const todosLens = yield* Component.useOnMount(() => Effect.map( + SubscriptionRef.make([]), + Lens.fromSubscriptionRef, + )) + const draftLens = yield* Component.useOnMount(() => Effect.map( + SubscriptionRef.make(""), + Lens.fromSubscriptionRef, + )) -const Index = Component.make("IndexView")(function*() { - const Todos = yield* Effect.provide( - TodosView.use, - yield* Component.useLayer(TodosStateLive), + const [todos] = yield* View.useAll([todosLens]) + const [draft, setDraft] = yield* Lens.useState(draftLens) + const runPromise = yield* Component.useRunPromise() + + const addTodo = Lens.update(todosLens, todos => + draft.trim() === "" + ? todos + : [...todos, draft.trim()], + ).pipe( + Effect.andThen(Lens.set(draftLens, "")), ) - return + return ( + + + A small Effect v4 todo state example backed by a Lens. + + + setDraft(event.currentTarget.value)} + /> + + + + + {todos.map(todo => • {todo})} + + + ) }).pipe( - Component.withRuntime(runtime.context) + Component.withContext(runtime.context), ) export const Route = createFileRoute("/")({ - component: Index + component: TodoRouteComponent, }) diff --git a/packages/example-next/src/routes/lensform.tsx b/packages/example/src/routes/lensform.tsx similarity index 100% rename from packages/example-next/src/routes/lensform.tsx rename to packages/example/src/routes/lensform.tsx diff --git a/packages/example/src/routes/query.tsx b/packages/example/src/routes/query.tsx index 047560e..fd41ad8 100644 --- a/packages/example/src/routes/query.tsx +++ b/packages/example/src/routes/query.tsx @@ -1,8 +1,9 @@ -import { HttpClient, type HttpClientError } from "@effect/platform" import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes" import { createFileRoute } from "@tanstack/react-router" -import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect" -import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc" +import { Effect, Schema, SubscriptionRef } from "effect" +import { HttpClient } from "effect/unstable/http" +import { AsyncResult } from "effect/unstable/reactivity" +import { Component, Lens, Mutation, Query, View } from "effect-fc-next" import { runtime } from "@/runtime" @@ -13,30 +14,45 @@ const Post = Schema.Struct({ body: Schema.String, }) -const ResultView = Component.make("ResultView")(function*() { - const runPromise = yield* Component.useRunPromise() +interface PostResultViewProps { + readonly result: AsyncResult.AsyncResult +} + +const PostResultView = (props: PostResultViewProps) => AsyncResult.match(props.result, { + onInitial: result => result.waiting + ? Loading... + : No data., + onFailure: result => Request failed: { result.cause.toString() }, + onSuccess: result => <> + {result.waiting && Refreshing...} + {result.value.title} + {result.value.body} + , +}) + +const QueryRouteComponent = Component.make("QueryRouteView")(function*() { const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() { - const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1)) - const key = Stream.map(idLens.changes, id => [id] as const) + const keyLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const)) + const idLens = Lens.focusTupleAt(keyLens, 1) const query = yield* Query.service({ - key, - f: ([id]) => HttpClient.HttpClient.pipe( - Effect.tap(Effect.sleep("500 millis")), + key: keyLens, + f: ([, id]) => HttpClient.HttpClient.pipe( + Effect.tap(Effect.sleep("1 second")), Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), Effect.andThen(response => response.json), - Effect.andThen(Schema.decodeUnknown(Post)), + Effect.andThen(Schema.decodeUnknownEffect(Post)), ), staleTime: "10 seconds", }) const mutation = yield* Mutation.make({ - f: ([id]: readonly [id: number]) => HttpClient.HttpClient.pipe( - Effect.tap(Effect.sleep("500 millis")), + f: ([id]: [id: number]) => HttpClient.HttpClient.pipe( + Effect.tap(Effect.sleep("1 second")), Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), Effect.andThen(response => response.json), - Effect.andThen(Schema.decodeUnknown(Post)), + Effect.andThen(Schema.decodeUnknownEffect(Post)), ), }) @@ -44,74 +60,43 @@ const ResultView = Component.make("ResultView")(function*() { })) const [id, setId] = yield* Lens.useState(idLens) - const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result]) + const [queryState, mutationState] = yield* View.useAll([query.state, mutation.state]) - yield* Component.useOnMount(() => ErrorObserver.ErrorObserver().pipe( - Effect.andThen(observer => observer.subscribe), - Effect.andThen(Stream.fromQueue), - Stream.unwrapScoped, - Stream.runForEach(flow( - Cause.failures, - Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"), - Option.match({ - onSome: e => Console.log("ResultView HttpClient error", e), - onNone: () => Effect.void, - }), - )), - Effect.forkScoped, - )) + const runSync = yield* Component.useRunSync() return ( setId(value ?? 1)} /> -
- {Match.value(queryResult).pipe( - Match.tag("Running", () => Loading...), - Match.tag("Success", result => <> - {result.value.title} - {result.value.body} - {Result.hasRefreshingFlag(result) && Refreshing...} - ), - Match.tag("Failure", result => - An error has occured: {result.cause.toString()} - ), - Match.orElse(() => <>), - )} -
+ - - + + -
- {Match.value(mutationResult).pipe( - Match.tag("Running", () => Loading...), - Match.tag("Success", result => <> - {result.value.title} - {result.value.body} - {Result.hasRefreshingFlag(result) && Refreshing...} - ), - Match.tag("Failure", result => - An error has occured: {result.cause.toString()} - ), - Match.orElse(() => <>), - )} -
+ - - - +
) -}) +}).pipe( + Component.withContext(runtime.context), +) export const Route = createFileRoute("/query")({ - component: Component.withRuntime(ResultView, runtime.context) + component: QueryRouteComponent, }) diff --git a/packages/example/src/runtime.ts b/packages/example/src/runtime.ts index 3467529..c50daee 100644 --- a/packages/example/src/runtime.ts +++ b/packages/example/src/runtime.ts @@ -1,10 +1,11 @@ -import { FetchHttpClient } from "@effect/platform" import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" import { DateTime, Layer } from "effect" -import { ReactRuntime } from "effect-fc" +import { FetchHttpClient } from "effect/unstable/http" +import { QueryClient, ReactRuntime } from "effect-fc-next" -export const AppLive = Layer.empty.pipe( +export const layer = Layer.empty.pipe( + Layer.provideMerge(QueryClient.layer()), Layer.provideMerge(DateTime.layerCurrentZoneLocal), Layer.provideMerge(Clipboard.layer), Layer.provideMerge(Geolocation.layer), @@ -12,4 +13,4 @@ export const AppLive = Layer.empty.pipe( Layer.provideMerge(FetchHttpClient.layer), ) -export const runtime = ReactRuntime.make(AppLive) +export const runtime = ReactRuntime.make(layer) diff --git a/packages/example/vite.config.ts b/packages/example/vite.config.ts index 79367b3..45684ff 100644 --- a/packages/example/vite.config.ts +++ b/packages/example/vite.config.ts @@ -1,5 +1,6 @@ import { tanstackRouter } from "@tanstack/router-plugin/vite" import react from "@vitejs/plugin-react" +import { effectViewPlugin } from "@effect-view/vite-plugin" import path from "node:path" import { defineConfig } from "vite" @@ -7,6 +8,7 @@ import { defineConfig } from "vite" // https://vite.dev/config/ export default defineConfig({ plugins: [ + effectViewPlugin(), tanstackRouter({ target: "react", autoCodeSplitting: true, diff --git a/EFFECT_FC_NEXT_ERROR_HANDLING_REPORT.md b/reports/EFFECT_FC_NEXT_ERROR_HANDLING_REPORT.md similarity index 100% rename from EFFECT_FC_NEXT_ERROR_HANDLING_REPORT.md rename to reports/EFFECT_FC_NEXT_ERROR_HANDLING_REPORT.md diff --git a/EFFECT_REACT_LIFECYCLE_REPORT.md b/reports/EFFECT_REACT_LIFECYCLE_REPORT.md similarity index 100% rename from EFFECT_REACT_LIFECYCLE_REPORT.md rename to reports/EFFECT_REACT_LIFECYCLE_REPORT.md diff --git a/REACT_REFRESH_FEASIBILITY.md b/reports/REACT_REFRESH_FEASIBILITY.md similarity index 100% rename from REACT_REFRESH_FEASIBILITY.md rename to reports/REACT_REFRESH_FEASIBILITY.md