diff --git a/.gitea/workflows/lint.yaml b/.gitea/workflows/lint.yaml index 87cc2d2..7efb5a0 100644 --- a/.gitea/workflows/lint.yaml +++ b/.gitea/workflows/lint.yaml @@ -8,13 +8,9 @@ jobs: steps: - name: Setup Bun uses: oven-sh/setup-bun@v1 - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "20" - name: Clone repo uses: actions/checkout@v4 - name: Install dependencies run: bun install --frozen-lockfile - name: Lint TypeScript - run: npm run lint:tsc + run: bun run lint:tsc diff --git a/.gitea/workflows/publish.yaml b/.gitea/workflows/publish.yaml index 1b33cdb..7289f97 100644 --- a/.gitea/workflows/publish.yaml +++ b/.gitea/workflows/publish.yaml @@ -15,14 +15,18 @@ jobs: uses: actions/setup-node@v4 with: node-version: "20" + registry-url: "https://registry.npmjs.org" - name: Clone repo uses: actions/checkout@v4 - name: Install dependencies run: bun install --frozen-lockfile - name: Build - run: npm run build + run: | + cd packages/reffuse + bun run build - name: Publish run: | - npm config set @thilawyn:registry https://git.valverde.cloud/api/packages/thilawyn/npm/ - npm config set -- //git.valverde.cloud/api/packages/thilawyn/npm/:_authToken "${{ vars.NODE_AUTH_TOKEN }}" - npm publish + cd packages/reffuse + npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitea/workflows/test-build.yaml b/.gitea/workflows/test-build.yaml index 0f61dc5..ee239a7 100644 --- a/.gitea/workflows/test-build.yaml +++ b/.gitea/workflows/test-build.yaml @@ -18,6 +18,10 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - name: Build - run: npm run build + run: | + cd packages/reffuse + bun run build - name: Pack - run: npm pack --dry-run + run: | + cd packages/reffuse + npm pack --dry-run diff --git a/README.md b/README.md index 7d92783..dab847a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ # Reffuse -Effect integration for React +[Effect-TS](https://effect.website/) integration for React 19+ with the aim of integrating the Effect context system within React's component hierarchy, while avoiding touching React's internals. + +This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome. + +Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory. + +## Dependencies +(needs to be manually installed) +- `effect` +- `react` 19+ diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..ea85420 Binary files /dev/null and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..4fe1e6e --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[install.scopes] +"@thilawyn" = "https://git.valverde.cloud/api/packages/thilawyn/npm/" diff --git a/package.json b/package.json index 8781544..6d4beab 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,14 @@ "private": true, "workspaces": ["./packages/*"], "scripts": { + "lint:tsc": "bun run --filter '*' lint:tsc", "clean:cache": "rm -f tsconfig.tsbuildinfo", "clean:dist": "rm -rf dist", "clean:node": "rm -rf node_modules" + }, + "devDependencies": { + "npm-check-updates": "^17.1.13", + "npm-sort": "^0.0.4", + "typescript": "^5.7.3" } } diff --git a/packages/example/.gitignore b/packages/example/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/packages/example/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/example/README.md b/packages/example/README.md new file mode 100644 index 0000000..74872fd --- /dev/null +++ b/packages/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/eslint.config.js b/packages/example/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/packages/example/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/packages/example/index.html b/packages/example/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/packages/example/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/packages/example/package.json b/packages/example/package.json new file mode 100644 index 0000000..d73afec --- /dev/null +++ b/packages/example/package.json @@ -0,0 +1,41 @@ +{ + "name": "@reffuse/example", + "version": "0.0.0", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint:tsc": "tsc --noEmit", + "lint:eslint": "eslint .", + "preview": "vite preview" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@tanstack/react-router": "^1.95.3", + "@tanstack/router-devtools": "^1.95.3", + "@tanstack/router-plugin": "^1.95.3", + "@thilawyn/thilaschema": "^0.1.4", + "@types/react": "^19.0.4", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^4.3.4", + "effect": "^3.12.1", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "reffuse": "workspace:*", + "typescript-eslint": "^8.18.2", + "vite": "^6.0.5" + }, + "dependencies": { + "@effect/platform": "^0.73.1", + "@effect/platform-browser": "^0.52.1", + "@radix-ui/themes": "^3.1.6", + "@typed/id": "^0.17.1", + "lucide-react": "^0.471.1", + "mobx": "^6.13.5" + } +} diff --git a/packages/example/public/vite.svg b/packages/example/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/packages/example/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/example/src/domain/Todo.ts b/packages/example/src/domain/Todo.ts new file mode 100644 index 0000000..cd120b3 --- /dev/null +++ b/packages/example/src/domain/Todo.ts @@ -0,0 +1,26 @@ +import { ThSchema } from "@thilawyn/thilaschema" +import { GetRandomValues, makeUuid4 } from "@typed/id" +import { Effect, Schema } from "effect" + + +export class Todo extends Schema.Class("Todo")({ + _tag: Schema.tag("Todo"), + id: Schema.String, + content: Schema.String, + completedAt: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf), +}) {} + + +export const TodoFromJsonStruct = Schema.Struct({ + ...Todo.fields, + completedAt: Schema.Option(Schema.DateTimeUtc), +}).pipe( + ThSchema.assertEncodedJsonifiable +) + +export const TodoFromJson = TodoFromJsonStruct.pipe(Schema.compose(Todo)) + + +export const generateUniqueID = makeUuid4.pipe( + Effect.provide(GetRandomValues.CryptoRandom) +) diff --git a/packages/example/src/domain/index.ts b/packages/example/src/domain/index.ts new file mode 100644 index 0000000..8d70c13 --- /dev/null +++ b/packages/example/src/domain/index.ts @@ -0,0 +1 @@ +export * as Todo from "./Todo" diff --git a/packages/example/src/index.css b/packages/example/src/index.css new file mode 100644 index 0000000..e69de29 diff --git a/packages/example/src/main.tsx b/packages/example/src/main.tsx new file mode 100644 index 0000000..f3e3987 --- /dev/null +++ b/packages/example/src/main.tsx @@ -0,0 +1,36 @@ +import { FetchHttpClient } from "@effect/platform" +import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" +import { createRouter, RouterProvider } from "@tanstack/react-router" +import { ReffuseRuntime } from "@thilawyn/reffuse" +import { Layer } from "effect" +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" +import { GlobalContext } from "./reffuse" +import { routeTree } from "./routeTree.gen" + + +const layer = Layer.empty.pipe( + 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" { + interface Register { + router: typeof router + } +} + + +createRoot(document.getElementById("root")!).render( + + + + + + + +) diff --git a/packages/example/src/reffuse.ts b/packages/example/src/reffuse.ts new file mode 100644 index 0000000..beba3d3 --- /dev/null +++ b/packages/example/src/reffuse.ts @@ -0,0 +1,13 @@ +import { HttpClient } from "@effect/platform" +import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" +import { Reffuse, ReffuseContext } from "reffuse" + + +export const GlobalContext = ReffuseContext.make< + | Clipboard.Clipboard + | Geolocation.Geolocation + | Permissions.Permissions + | HttpClient.HttpClient +>() + +export const R = Reffuse.make(GlobalContext) diff --git a/packages/example/src/routeTree.gen.ts b/packages/example/src/routeTree.gen.ts new file mode 100644 index 0000000..2cec328 --- /dev/null +++ b/packages/example/src/routeTree.gen.ts @@ -0,0 +1,134 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// 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 rootRoute } from './routes/__root' +import { Route as TimeImport } from './routes/time' +import { Route as CountImport } from './routes/count' +import { Route as IndexImport } from './routes/index' + +// Create/Update Routes + +const TimeRoute = TimeImport.update({ + id: '/time', + path: '/time', + getParentRoute: () => rootRoute, +} as any) + +const CountRoute = CountImport.update({ + id: '/count', + path: '/count', + getParentRoute: () => rootRoute, +} as any) + +const IndexRoute = IndexImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRoute, +} as any) + +// Populate the FileRoutesByPath interface + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexImport + parentRoute: typeof rootRoute + } + '/count': { + id: '/count' + path: '/count' + fullPath: '/count' + preLoaderRoute: typeof CountImport + parentRoute: typeof rootRoute + } + '/time': { + id: '/time' + path: '/time' + fullPath: '/time' + preLoaderRoute: typeof TimeImport + parentRoute: typeof rootRoute + } + } +} + +// Create and export the route tree + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/count': typeof CountRoute + '/time': typeof TimeRoute +} + +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/count': typeof CountRoute + '/time': typeof TimeRoute +} + +export interface FileRoutesById { + __root__: typeof rootRoute + '/': typeof IndexRoute + '/count': typeof CountRoute + '/time': typeof TimeRoute +} + +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/count' | '/time' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/count' | '/time' + id: '__root__' | '/' | '/count' | '/time' + fileRoutesById: FileRoutesById +} + +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + CountRoute: typeof CountRoute + TimeRoute: typeof TimeRoute +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + CountRoute: CountRoute, + TimeRoute: TimeRoute, +} + +export const routeTree = rootRoute + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +/* ROUTE_MANIFEST_START +{ + "routes": { + "__root__": { + "filePath": "__root.tsx", + "children": [ + "/", + "/count", + "/time" + ] + }, + "/": { + "filePath": "index.tsx" + }, + "/count": { + "filePath": "count.tsx" + }, + "/time": { + "filePath": "time.tsx" + } + } +} +ROUTE_MANIFEST_END */ diff --git a/packages/example/src/routes/__root.tsx b/packages/example/src/routes/__root.tsx new file mode 100644 index 0000000..beef033 --- /dev/null +++ b/packages/example/src/routes/__root.tsx @@ -0,0 +1,27 @@ +import { Container, Flex, Theme } from "@radix-ui/themes" +import "@radix-ui/themes/styles.css" +import { createRootRoute, Link, Outlet } from "@tanstack/react-router" +import { TanStackRouterDevtools } from "@tanstack/router-devtools" +import "../index.css" + + +export const Route = createRootRoute({ + component: Root +}) + +function Root() { + return ( + + + + Index + Time + Count + + + + + + + ) +} diff --git a/packages/example/src/routes/count.tsx b/packages/example/src/routes/count.tsx new file mode 100644 index 0000000..77aedc7 --- /dev/null +++ b/packages/example/src/routes/count.tsx @@ -0,0 +1,27 @@ +import { R } from "@/reffuse" +import { createFileRoute } from "@tanstack/react-router" +import { Ref } from "effect" + + +export const Route = createFileRoute("/count")({ + component: Count +}) + +function Count() { + + const runSync = R.useRunSync() + + const countRef = R.useRef(0) + const [count] = R.useRefState(countRef) + + + return ( +
+ {/* +
+ ) + +} diff --git a/packages/example/src/routes/index.tsx b/packages/example/src/routes/index.tsx new file mode 100644 index 0000000..c4caacd --- /dev/null +++ b/packages/example/src/routes/index.tsx @@ -0,0 +1,34 @@ +import { R } from "@/reffuse" +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("/")({ + component: Index +}) + +function Index() { + + const todosLayer = useMemo(() => Layer.empty.pipe( + Layer.provideMerge(TodosState.make("todos")) + ), []) + + R.useEffect(Effect.addFinalizer(() => Console.log("Effect cleanup")).pipe( + Effect.flatMap(() => Console.log("Effect recalculated")) + )) + + + return ( + + + + + + ) + +} diff --git a/packages/example/src/routes/time.tsx b/packages/example/src/routes/time.tsx new file mode 100644 index 0000000..e2ba3d8 --- /dev/null +++ b/packages/example/src/routes/time.tsx @@ -0,0 +1,49 @@ +import { R } from "@/reffuse" +import { createFileRoute } from "@tanstack/react-router" +import { Console, DateTime, Effect, Ref, Schedule, Stream } 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.useRefFromEffect(DateTime.now) + + R.useFork(Effect.addFinalizer(() => Console.log("Cleanup")).pipe( + Effect.flatMap(() => + Stream.runForEach(timeEverySecond, v => Ref.set(timeRef, v)) + ) + ), [timeRef]) + // Reffuse.useFork(Effect.addFinalizer(() => Console.log("Cleanup")).pipe( + // Effect.flatMap(() => DateTime.now), + // Effect.flatMap(v => Ref.set(timeRef, v)), + // Effect.repeat(Schedule.intersect( + // Schedule.forever, + // Schedule.spaced("1 second"), + // )), + // ), [timeRef]) + + const [time] = R.useRefState(timeRef) + + + return ( +
+

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

+
+ ) + +} diff --git a/packages/example/src/services/index.ts b/packages/example/src/services/index.ts new file mode 100644 index 0000000..336ce12 --- /dev/null +++ b/packages/example/src/services/index.ts @@ -0,0 +1 @@ +export {} diff --git a/packages/example/src/todos/reffuse.ts b/packages/example/src/todos/reffuse.ts new file mode 100644 index 0000000..8502e12 --- /dev/null +++ b/packages/example/src/todos/reffuse.ts @@ -0,0 +1,7 @@ +import { GlobalContext } from "@/reffuse" +import { Reffuse, ReffuseContext } from "reffuse" +import { TodosState } from "./services" + + +export const TodosContext = ReffuseContext.make() +export const R = Reffuse.make(GlobalContext, TodosContext) diff --git a/packages/example/src/todos/services/TodosState.ts b/packages/example/src/todos/services/TodosState.ts new file mode 100644 index 0000000..d9b2bea --- /dev/null +++ b/packages/example/src/todos/services/TodosState.ts @@ -0,0 +1,69 @@ +import { Todo } from "@/domain" +import { KeyValueStore } from "@effect/platform" +import { BrowserKeyValueStore } from "@effect/platform-browser" +import { PlatformError } from "@effect/platform/Error" +import { Chunk, Context, Effect, identity, Layer, ParseResult, Ref, Schema, SubscriptionRef } from "effect" + + +export class TodosState extends Context.Tag("TodosState")> + + readonly readFromLocalStorage: Effect.Effect + readonly saveToLocalStorage: Effect.Effect + + readonly prepend: (todo: Todo.Todo) => Effect.Effect + readonly replace: (index: number, todo: Todo.Todo) => Effect.Effect + readonly remove: (index: number) => Effect.Effect + // readonly moveUp: (index: number) => Effect.Effect + // readonly moveDown: (index: number) => Effect.Effect +}>() {} + + +export const make = (key: string) => Layer.effect(TodosState, Effect.gen(function*() { + const todos = yield* SubscriptionRef.make(Chunk.empty()) + + const readFromLocalStorage = KeyValueStore.KeyValueStore.pipe( + Effect.flatMap(kv => kv.get(key)), + Effect.flatMap(identity), + Effect.flatMap(Schema.parseJson().pipe( + Schema.compose(Schema.Chunk(Todo.TodoFromJson)), + Schema.decode, + )), + Effect.flatMap(v => Ref.set(todos, v)), + + Effect.catchTag("NoSuchElementException", () => Ref.set(todos, Chunk.empty())), + + Effect.provide(BrowserKeyValueStore.layerLocalStorage), + ) + + const saveToLocalStorage = Effect.all([KeyValueStore.KeyValueStore, todos]).pipe( + Effect.flatMap(([kv, values]) => values.pipe( + Schema.parseJson().pipe( + Schema.compose(Schema.Chunk(Todo.TodoFromJson)), + Schema.encode, + ), + Effect.flatMap(v => kv.set(key, v)), + )), + + Effect.provide(BrowserKeyValueStore.layerLocalStorage), + ) + + const prepend = (todo: Todo.Todo) => Ref.update(todos, Chunk.prepend(todo)) + const replace = (index: number, todo: Todo.Todo) => Ref.update(todos, Chunk.replace(index, todo)) + const remove = (index: number) => Ref.update(todos, Chunk.remove(index)) + + // const moveUp = (index: number) => Effect.gen(function*() { + + // }) + + yield* readFromLocalStorage + + return { + todos, + readFromLocalStorage, + saveToLocalStorage, + prepend, + replace, + remove, + } +})) diff --git a/packages/example/src/todos/services/index.ts b/packages/example/src/todos/services/index.ts new file mode 100644 index 0000000..5d1c39e --- /dev/null +++ b/packages/example/src/todos/services/index.ts @@ -0,0 +1 @@ +export * as TodosState from "./TodosState" diff --git a/packages/example/src/todos/views/VNewTodo.tsx b/packages/example/src/todos/views/VNewTodo.tsx new file mode 100644 index 0000000..fee4f16 --- /dev/null +++ b/packages/example/src/todos/views/VNewTodo.tsx @@ -0,0 +1,52 @@ +import { Todo } from "@/domain" +import { Box, Button, Card, Flex, TextArea } from "@radix-ui/themes" +import { Effect, Option } from "effect" +import { R } from "../reffuse" +import { TodosState } from "../services" + + +export function VNewTodo() { + + const runSync = R.useRunSync() + + const createEmptyTodo = Todo.generateUniqueID.pipe( + Effect.map(id => Todo.Todo.make({ + id, + content: "", + completedAt: Option.none(), + }, true)) + ) + + const todoRef = R.useRefFromEffect(createEmptyTodo) + const [todo, setTodo] = R.useRefState(todoRef) + + + return ( + + + +