16 Commits

Author SHA1 Message Date
Julien Valverdé
a71640d493 Cleanup
All checks were successful
Lint / lint (push) Successful in 10s
2025-02-20 00:41:37 +01:00
Julien Valverdé
b636a709f3 Tests
All checks were successful
Lint / lint (push) Successful in 11s
2025-02-20 00:39:15 +01:00
Julien Valverdé
fffbd01b5e Pipeable API tests
All checks were successful
Lint / lint (push) Successful in 11s
2025-02-20 00:21:43 +01:00
Julien Valverdé
36d5414d10 Fix
All checks were successful
Lint / lint (push) Successful in 44s
2025-02-19 23:59:34 +01:00
Julien Valverdé
65810a6d79 usePromise
All checks were successful
Lint / lint (push) Successful in 10s
2025-02-19 23:44:02 +01:00
Julien Valverdé
9e7b30fbb4 useFork refactoring
All checks were successful
Lint / lint (push) Successful in 11s
2025-02-19 23:24:15 +01:00
Julien Valverdé
6c843562ab usePromiseScoped fork implementation
All checks were successful
Lint / lint (push) Successful in 10s
2025-02-18 23:47:32 +01:00
Julien Valverdé
809f512d11 Fix
All checks were successful
Lint / lint (push) Successful in 10s
2025-02-18 22:33:49 +01:00
Julien Valverdé
e71239b903 usePromiseScoped
All checks were successful
Lint / lint (push) Successful in 10s
2025-02-18 22:28:49 +01:00
Julien Valverdé
bfcc097882 usePromise
All checks were successful
Lint / lint (push) Successful in 11s
2025-02-18 15:25:46 +01:00
Julien Valverdé
933b061b5d Promise tests
All checks were successful
Lint / lint (push) Successful in 11s
2025-02-18 05:18:34 +01:00
Julien Valverdé
734c84824c Implement Pipeable
All checks were successful
Lint / lint (push) Successful in 10s
2025-02-18 04:30:10 +01:00
Julien Valverdé
e83e86f8f1 Promise tests
All checks were successful
Lint / lint (push) Successful in 11s
2025-02-18 02:56:05 +01:00
Julien Valverdé
bebbc1d7de Promise tests
All checks were successful
Lint / lint (push) Successful in 11s
2025-02-18 02:23:40 +01:00
Julien Valverdé
a7a0951b61 Dependencies fix
All checks were successful
Lint / lint (push) Successful in 11s
2025-02-18 01:08:26 +01:00
Julien Valverdé
1b1a1961bc Dependencies upgrade
Some checks failed
Lint / lint (push) Failing after 13s
2025-02-17 00:16:41 +01:00
69 changed files with 984 additions and 3180 deletions

View File

@@ -12,5 +12,5 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Build - name: Lint TypeScript
run: bun run build run: bun run lint:tsc

View File

@@ -11,30 +11,22 @@ jobs:
steps: steps:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Clone repo - name: Clone repo
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Build - name: Build
run: bun run build run: |
- name: Publish reffuse cd packages/reffuse
uses: JS-DevTools/npm-publish@v3 bun run build
with: - name: Publish
package: packages/reffuse run: |
access: public cd packages/reffuse
token: ${{ secrets.NPM_TOKEN }} npm publish --access public
registry: https://registry.npmjs.org env:
- name: Publish @reffuse/extension-lazyref NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
uses: JS-DevTools/npm-publish@v3
with:
package: packages/extension-lazyref
access: public
token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org
- name: Publish @reffuse/extension-query
uses: JS-DevTools/npm-publish@v3
with:
package: packages/extension-query
access: public
token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org

View File

@@ -18,6 +18,10 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Build - name: Build
run: bun run build run: |
cd packages/reffuse
bun run build
- name: Pack - name: Pack
run: bun run pack run: |
cd packages/reffuse
npm pack --dry-run

1
.gitignore vendored
View File

@@ -130,4 +130,3 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
.turbo

View File

@@ -1,9 +1,12 @@
# Reffuse Monorepo # Reffuse
Reffuse is a [Effect-TS](https://effect.website/) integration for React 19+ with the aim of integrating the Effect context system within React's component hierarchy, while avoiding touching React's internals. [Effect-TS](https://effect.website/) integration for React 19+ with the aim of integrating the Effect context system within React's component hierarchy, while avoiding touching React's internals.
This monorepo contains: 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.
- [The `reffuse` library](packages/reffuse)
- [`@reffuse/extension-lazyref`, a LazyRef integration for Reffuse](packages/extension-lazyref) Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
- [`@reffuse/extension-query`, TanStack Query style hooks for Reffuse](packages/extension-query)
- [An example project](packges/example) ## Dependencies
(needs to be manually installed)
- `effect`
- `react` 19+

749
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,17 @@
{ {
"name": "@reffuse/monorepo",
"packageManager": "bun@1.2.13",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"./packages/*" "./packages/*"
], ],
"scripts": { "scripts": {
"build": "turbo build --filter=!@reffuse/example", "lint:tsc": "bun run --filter '*' lint:tsc",
"lint:tsc": "turbo lint:tsc",
"pack": "turbo pack --filter=!@reffuse/example",
"publish": "turbo publish --filter=!@reffuse/example",
"clean:cache": "rm -f tsconfig.tsbuildinfo", "clean:cache": "rm -f tsconfig.tsbuildinfo",
"clean:dist": "rm -rf dist", "clean:dist": "rm -rf dist",
"clean:node": "rm -rf node_modules" "clean:node": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"npm-check-updates": "^18.0.1", "npm-check-updates": "^17.1.14",
"npm-sort": "^0.0.4", "npm-sort": "^0.0.4",
"turbo": "^2.5.3", "typescript": "^5.7.3"
"typescript": "^5.8.3"
} }
} }

View File

@@ -11,42 +11,35 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.26.0", "@eslint/js": "^9.20.0",
"@tanstack/react-router": "^1.120.3", "@tanstack/react-router": "^1.105.0",
"@tanstack/react-router-devtools": "^1.120.3", "@tanstack/router-devtools": "^1.105.0",
"@tanstack/router-plugin": "^1.120.3", "@tanstack/router-plugin": "^1.105.0",
"@thilawyn/thilaschema": "^0.1.4", "@thilawyn/thilaschema": "^0.1.4",
"@types/react": "^19.1.4", "@types/react": "^19.0.10",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.26.0", "eslint": "^9.20.1",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.1.0", "globals": "^15.15.0",
"react": "^19.1.0", "react": "^19.0.0",
"react-dom": "^19.1.0", "react-dom": "^19.0.0",
"typescript-eslint": "^8.32.1", "typescript-eslint": "^8.24.1",
"vite": "^6.3.5" "vite": "^6.1.0"
}, },
"dependencies": { "dependencies": {
"@effect/platform": "^0.82.1", "@effect/platform": "~0.77.1",
"@effect/platform-browser": "^0.62.1", "@effect/platform-browser": "~0.56.1",
"@radix-ui/themes": "^3.2.1", "@radix-ui/themes": "^3.2.0",
"@reffuse/extension-lazyref": "workspace:*", "@typed/id": "^0.17.1",
"@reffuse/extension-query": "workspace:*",
"@typed/async-data": "^0.13.1",
"@typed/id": "^0.17.2",
"@typed/lazy-ref": "^0.3.3", "@typed/lazy-ref": "^0.3.3",
"effect": "^3.15.1", "effect": "~3.13.1",
"lucide-react": "^0.510.0", "lucide-react": "^0.475.0",
"mobx": "^6.13.7", "mobx": "^6.13.6",
"reffuse": "workspace:*" "reffuse": "workspace:*"
}, },
"overrides": { "overrides": {
"effect": "^3.15.1", "effect": "~3.13.1"
"@effect/platform": "^0.82.1",
"@effect/platform-browser": "^0.62.1",
"@typed/lazy-ref": "^0.3.3",
"@typed/async-data": "^0.13.1"
} }
} }

View File

@@ -1,57 +0,0 @@
import { AlertDialog, Button, Flex, Text } from "@radix-ui/themes"
import { Cause, Console, Effect, Either, flow, Match, Option, Stream } from "effect"
import { useState } from "react"
import { R } from "./reffuse"
import { AppQueryErrorHandler } from "./services"
export function VQueryErrorHandler() {
const [open, setOpen] = useState(false)
const error = R.useSubscribeStream(
R.useMemo(() => AppQueryErrorHandler.AppQueryErrorHandler.pipe(
Effect.map(handler => handler.errors.pipe(
Stream.changes,
Stream.tap(Console.error),
Stream.tap(() => Effect.sync(() => setOpen(true))),
))
), [])
)
if (Option.isNone(error))
return <></>
return (
<AlertDialog.Root open={open}>
<AlertDialog.Content maxWidth="450px">
<AlertDialog.Title>Error</AlertDialog.Title>
<AlertDialog.Description size="2">
{Either.match(Cause.failureOrCause(error.value), {
onLeft: flow(
Match.value,
Match.tag("RequestError", () => <Text>HTTP request error</Text>),
Match.tag("ResponseError", () => <Text>HTTP response error</Text>),
Match.exhaustive,
),
onRight: flow(
Cause.dieOption,
Option.match({
onSome: () => <Text>Unrecoverable defect</Text>,
onNone: () => <Text>Unknown error</Text>,
}),
),
})}
</AlertDialog.Description>
<Flex gap="3" mt="4" justify="end">
<AlertDialog.Action>
<Button variant="solid" color="red" onClick={() => setOpen(false)}>
Ok
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
)
}

View File

@@ -1,5 +1,6 @@
import { ThSchema } from "@thilawyn/thilaschema" import { ThSchema } from "@thilawyn/thilaschema"
import { Schema } from "effect" import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Effect, Schema } from "effect"
export class Todo extends Schema.Class<Todo>("Todo")({ export class Todo extends Schema.Class<Todo>("Todo")({
@@ -17,4 +18,9 @@ export const TodoFromJsonStruct = Schema.Struct({
ThSchema.assertEncodedJsonifiable ThSchema.assertEncodedJsonifiable
) )
export const TodoFromJson = Schema.compose(TodoFromJsonStruct, Todo) export const TodoFromJson = TodoFromJsonStruct.pipe(Schema.compose(Todo))
export const generateUniqueID = makeUuid4.pipe(
Effect.provide(GetRandomValues.CryptoRandom)
)

View File

@@ -5,14 +5,11 @@ import { Layer } from "effect"
import { StrictMode } from "react" import { StrictMode } from "react"
import { createRoot } from "react-dom/client" import { createRoot } from "react-dom/client"
import { ReffuseRuntime } from "reffuse" import { ReffuseRuntime } from "reffuse"
import { RootContext } from "./reffuse" import { GlobalContext } from "./reffuse"
import { routeTree } from "./routeTree.gen" import { routeTree } from "./routeTree.gen"
import { AppQueryClient, AppQueryErrorHandler } from "./services"
const layer = Layer.empty.pipe( const layer = Layer.empty.pipe(
Layer.provideMerge(AppQueryClient.AppQueryClient.Default),
Layer.provideMerge(AppQueryErrorHandler.AppQueryErrorHandler.Default),
Layer.provideMerge(Clipboard.layer), Layer.provideMerge(Clipboard.layer),
Layer.provideMerge(Geolocation.layer), Layer.provideMerge(Geolocation.layer),
Layer.provideMerge(Permissions.layer), Layer.provideMerge(Permissions.layer),
@@ -31,9 +28,9 @@ declare module "@tanstack/react-router" {
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ReffuseRuntime.Provider> <ReffuseRuntime.Provider>
<RootContext.Provider layer={layer}> <GlobalContext.Provider layer={layer}>
<RouterProvider router={router} /> <RouterProvider router={router} />
</RootContext.Provider> </GlobalContext.Provider>
</ReffuseRuntime.Provider> </ReffuseRuntime.Provider>
</StrictMode> </StrictMode>
) )

View File

@@ -1,10 +0,0 @@
import { RootReffuse } from "@/reffuse"
import { Reffuse, ReffuseContext } from "reffuse"
import { Uuid4Query } from "./services"
export const QueryContext = ReffuseContext.make<Uuid4Query.Uuid4Query>()
export const R = new class QueryReffuse extends RootReffuse.pipe(
Reffuse.withContexts(QueryContext)
) {}

View File

@@ -1,11 +0,0 @@
import { QueryRunner } from "@reffuse/extension-query"
import { ParseResult, Schema } from "effect"
export const Result = Schema.Array(Schema.String)
export class Uuid4Query extends QueryRunner.Tag("Uuid4Query")<Uuid4Query,
readonly ["uuid4", number],
typeof Result.Type,
ParseResult.ParseError
>() {}

View File

@@ -1 +0,0 @@
export * as Uuid4Query from "./Uuid4Query"

View File

@@ -1,32 +0,0 @@
import { Button, Container, Flex, Text } from "@radix-ui/themes"
import * as AsyncData from "@typed/async-data"
import { R } from "../reffuse"
import { Uuid4Query } from "../services"
export function Uuid4QueryService() {
const runFork = R.useRunFork()
const query = R.useMemo(() => Uuid4Query.Uuid4Query, [])
const [state] = R.useSubscribeRefs(query.stateRef)
return (
<Container>
<Flex direction="column" align="center" gap="2">
<Text>
{AsyncData.match(state, {
NoData: () => "No data yet",
Loading: () => "Loading...",
Success: (value, { isRefreshing, isOptimistic }) =>
`Value: ${value} ${isRefreshing ? "(refreshing)" : ""} ${isOptimistic ? "(optimistic)" : ""}`,
Failure: (cause, { isRefreshing }) =>
`Error: ${cause} ${isRefreshing ? "(refreshing)" : ""}`,
})}
</Text>
<Button onClick={() => runFork(query.forkRefresh)}>Refresh</Button>
</Flex>
</Container>
)
}

View File

@@ -1,24 +1,13 @@
import { HttpClient } from "@effect/platform" import { HttpClient } from "@effect/platform"
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
import { LazyRefExtension } from "@reffuse/extension-lazyref"
import { QueryExtension } from "@reffuse/extension-query"
import { Reffuse, ReffuseContext } from "reffuse" import { Reffuse, ReffuseContext } from "reffuse"
import { AppQueryClient, AppQueryErrorHandler } from "./services"
export const RootContext = ReffuseContext.make< export const GlobalContext = ReffuseContext.make<
| AppQueryClient.AppQueryClient
| AppQueryErrorHandler.AppQueryErrorHandler
| Clipboard.Clipboard | Clipboard.Clipboard
| Geolocation.Geolocation | Geolocation.Geolocation
| Permissions.Permissions | Permissions.Permissions
| HttpClient.HttpClient | HttpClient.HttpClient
>() >()
export class RootReffuse extends Reffuse.Reffuse.pipe( export const R = Reffuse.make(GlobalContext)
Reffuse.withExtension(LazyRefExtension),
Reffuse.withExtension(QueryExtension),
Reffuse.withContexts(RootContext),
) {}
export const R = new RootReffuse()

View File

@@ -11,27 +11,15 @@
// Import Routes // Import Routes
import { Route as rootRoute } from './routes/__root' import { Route as rootRoute } from './routes/__root'
import { Route as TodosImport } from './routes/todos'
import { Route as TimeImport } from './routes/time' import { Route as TimeImport } from './routes/time'
import { Route as TestsImport } from './routes/tests' import { Route as TestsImport } from './routes/tests'
import { Route as PromiseImport } from './routes/promise' import { Route as PromiseImport } from './routes/promise'
import { Route as LazyrefImport } from './routes/lazyref'
import { Route as CountImport } from './routes/count' import { Route as CountImport } from './routes/count'
import { Route as BlankImport } from './routes/blank' import { Route as BlankImport } from './routes/blank'
import { Route as IndexImport } from './routes/index' import { Route as IndexImport } from './routes/index'
import { Route as StreamsPullImport } from './routes/streams/pull'
import { Route as QueryUsequeryImport } from './routes/query/usequery'
import { Route as QueryUsemutationImport } from './routes/query/usemutation'
import { Route as QueryServiceImport } from './routes/query/service'
// Create/Update Routes // Create/Update Routes
const TodosRoute = TodosImport.update({
id: '/todos',
path: '/todos',
getParentRoute: () => rootRoute,
} as any)
const TimeRoute = TimeImport.update({ const TimeRoute = TimeImport.update({
id: '/time', id: '/time',
path: '/time', path: '/time',
@@ -50,12 +38,6 @@ const PromiseRoute = PromiseImport.update({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const LazyrefRoute = LazyrefImport.update({
id: '/lazyref',
path: '/lazyref',
getParentRoute: () => rootRoute,
} as any)
const CountRoute = CountImport.update({ const CountRoute = CountImport.update({
id: '/count', id: '/count',
path: '/count', path: '/count',
@@ -74,30 +56,6 @@ const IndexRoute = IndexImport.update({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const StreamsPullRoute = StreamsPullImport.update({
id: '/streams/pull',
path: '/streams/pull',
getParentRoute: () => rootRoute,
} as any)
const QueryUsequeryRoute = QueryUsequeryImport.update({
id: '/query/usequery',
path: '/query/usequery',
getParentRoute: () => rootRoute,
} as any)
const QueryUsemutationRoute = QueryUsemutationImport.update({
id: '/query/usemutation',
path: '/query/usemutation',
getParentRoute: () => rootRoute,
} as any)
const QueryServiceRoute = QueryServiceImport.update({
id: '/query/service',
path: '/query/service',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface // Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -123,13 +81,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CountImport preLoaderRoute: typeof CountImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/lazyref': {
id: '/lazyref'
path: '/lazyref'
fullPath: '/lazyref'
preLoaderRoute: typeof LazyrefImport
parentRoute: typeof rootRoute
}
'/promise': { '/promise': {
id: '/promise' id: '/promise'
path: '/promise' path: '/promise'
@@ -151,41 +102,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TimeImport preLoaderRoute: typeof TimeImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/todos': {
id: '/todos'
path: '/todos'
fullPath: '/todos'
preLoaderRoute: typeof TodosImport
parentRoute: typeof rootRoute
}
'/query/service': {
id: '/query/service'
path: '/query/service'
fullPath: '/query/service'
preLoaderRoute: typeof QueryServiceImport
parentRoute: typeof rootRoute
}
'/query/usemutation': {
id: '/query/usemutation'
path: '/query/usemutation'
fullPath: '/query/usemutation'
preLoaderRoute: typeof QueryUsemutationImport
parentRoute: typeof rootRoute
}
'/query/usequery': {
id: '/query/usequery'
path: '/query/usequery'
fullPath: '/query/usequery'
preLoaderRoute: typeof QueryUsequeryImport
parentRoute: typeof rootRoute
}
'/streams/pull': {
id: '/streams/pull'
path: '/streams/pull'
fullPath: '/streams/pull'
preLoaderRoute: typeof StreamsPullImport
parentRoute: typeof rootRoute
}
} }
} }
@@ -195,30 +111,18 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/count': typeof CountRoute '/count': typeof CountRoute
'/lazyref': typeof LazyrefRoute
'/promise': typeof PromiseRoute '/promise': typeof PromiseRoute
'/tests': typeof TestsRoute '/tests': typeof TestsRoute
'/time': typeof TimeRoute '/time': typeof TimeRoute
'/todos': typeof TodosRoute
'/query/service': typeof QueryServiceRoute
'/query/usemutation': typeof QueryUsemutationRoute
'/query/usequery': typeof QueryUsequeryRoute
'/streams/pull': typeof StreamsPullRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/count': typeof CountRoute '/count': typeof CountRoute
'/lazyref': typeof LazyrefRoute
'/promise': typeof PromiseRoute '/promise': typeof PromiseRoute
'/tests': typeof TestsRoute '/tests': typeof TestsRoute
'/time': typeof TimeRoute '/time': typeof TimeRoute
'/todos': typeof TodosRoute
'/query/service': typeof QueryServiceRoute
'/query/usemutation': typeof QueryUsemutationRoute
'/query/usequery': typeof QueryUsequeryRoute
'/streams/pull': typeof StreamsPullRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@@ -226,60 +130,17 @@ export interface FileRoutesById {
'/': typeof IndexRoute '/': typeof IndexRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/count': typeof CountRoute '/count': typeof CountRoute
'/lazyref': typeof LazyrefRoute
'/promise': typeof PromiseRoute '/promise': typeof PromiseRoute
'/tests': typeof TestsRoute '/tests': typeof TestsRoute
'/time': typeof TimeRoute '/time': typeof TimeRoute
'/todos': typeof TodosRoute
'/query/service': typeof QueryServiceRoute
'/query/usemutation': typeof QueryUsemutationRoute
'/query/usequery': typeof QueryUsequeryRoute
'/streams/pull': typeof StreamsPullRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths: '/' | '/blank' | '/count' | '/promise' | '/tests' | '/time'
| '/'
| '/blank'
| '/count'
| '/lazyref'
| '/promise'
| '/tests'
| '/time'
| '/todos'
| '/query/service'
| '/query/usemutation'
| '/query/usequery'
| '/streams/pull'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to: '/' | '/blank' | '/count' | '/promise' | '/tests' | '/time'
| '/' id: '__root__' | '/' | '/blank' | '/count' | '/promise' | '/tests' | '/time'
| '/blank'
| '/count'
| '/lazyref'
| '/promise'
| '/tests'
| '/time'
| '/todos'
| '/query/service'
| '/query/usemutation'
| '/query/usequery'
| '/streams/pull'
id:
| '__root__'
| '/'
| '/blank'
| '/count'
| '/lazyref'
| '/promise'
| '/tests'
| '/time'
| '/todos'
| '/query/service'
| '/query/usemutation'
| '/query/usequery'
| '/streams/pull'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@@ -287,30 +148,18 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
BlankRoute: typeof BlankRoute BlankRoute: typeof BlankRoute
CountRoute: typeof CountRoute CountRoute: typeof CountRoute
LazyrefRoute: typeof LazyrefRoute
PromiseRoute: typeof PromiseRoute PromiseRoute: typeof PromiseRoute
TestsRoute: typeof TestsRoute TestsRoute: typeof TestsRoute
TimeRoute: typeof TimeRoute TimeRoute: typeof TimeRoute
TodosRoute: typeof TodosRoute
QueryServiceRoute: typeof QueryServiceRoute
QueryUsemutationRoute: typeof QueryUsemutationRoute
QueryUsequeryRoute: typeof QueryUsequeryRoute
StreamsPullRoute: typeof StreamsPullRoute
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
BlankRoute: BlankRoute, BlankRoute: BlankRoute,
CountRoute: CountRoute, CountRoute: CountRoute,
LazyrefRoute: LazyrefRoute,
PromiseRoute: PromiseRoute, PromiseRoute: PromiseRoute,
TestsRoute: TestsRoute, TestsRoute: TestsRoute,
TimeRoute: TimeRoute, TimeRoute: TimeRoute,
TodosRoute: TodosRoute,
QueryServiceRoute: QueryServiceRoute,
QueryUsemutationRoute: QueryUsemutationRoute,
QueryUsequeryRoute: QueryUsequeryRoute,
StreamsPullRoute: StreamsPullRoute,
} }
export const routeTree = rootRoute export const routeTree = rootRoute
@@ -326,15 +175,9 @@ export const routeTree = rootRoute
"/", "/",
"/blank", "/blank",
"/count", "/count",
"/lazyref",
"/promise", "/promise",
"/tests", "/tests",
"/time", "/time"
"/todos",
"/query/service",
"/query/usemutation",
"/query/usequery",
"/streams/pull"
] ]
}, },
"/": { "/": {
@@ -346,9 +189,6 @@ export const routeTree = rootRoute
"/count": { "/count": {
"filePath": "count.tsx" "filePath": "count.tsx"
}, },
"/lazyref": {
"filePath": "lazyref.tsx"
},
"/promise": { "/promise": {
"filePath": "promise.tsx" "filePath": "promise.tsx"
}, },
@@ -357,21 +197,6 @@ export const routeTree = rootRoute
}, },
"/time": { "/time": {
"filePath": "time.tsx" "filePath": "time.tsx"
},
"/todos": {
"filePath": "todos.tsx"
},
"/query/service": {
"filePath": "query/service.tsx"
},
"/query/usemutation": {
"filePath": "query/usemutation.tsx"
},
"/query/usequery": {
"filePath": "query/usequery.tsx"
},
"/streams/pull": {
"filePath": "streams/pull.tsx"
} }
} }
} }

View File

@@ -1,7 +1,6 @@
import { VQueryErrorHandler } from "@/VQueryErrorHandler"
import { Container, Flex, Theme } from "@radix-ui/themes" import { Container, Flex, Theme } from "@radix-ui/themes"
import { createRootRoute, Link, Outlet } from "@tanstack/react-router" import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools" import { TanStackRouterDevtools } from "@tanstack/router-devtools"
import "@radix-ui/themes/styles.css" import "@radix-ui/themes/styles.css"
import "../index.css" import "../index.css"
@@ -21,14 +20,11 @@ function Root() {
<Link to="/count">Count</Link> <Link to="/count">Count</Link>
<Link to="/tests">Tests</Link> <Link to="/tests">Tests</Link>
<Link to="/promise">Promise</Link> <Link to="/promise">Promise</Link>
<Link to="/query/usequery">Query</Link>
<Link to="/blank">Blank</Link> <Link to="/blank">Blank</Link>
</Flex> </Flex>
</Container> </Container>
<Outlet /> <Outlet />
<VQueryErrorHandler />
<TanStackRouterDevtools /> <TanStackRouterDevtools />
</Theme> </Theme>
) )

View File

@@ -1,6 +1,6 @@
import { R } from "@/reffuse" import { R } from "@/reffuse"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Effect, Ref } from "effect" import { Ref } from "effect"
export const Route = createFileRoute("/count")({ export const Route = createFileRoute("/count")({
@@ -11,13 +11,14 @@ function Count() {
const runSync = R.useRunSync() const runSync = R.useRunSync()
const countRef = R.useRef(() => Effect.succeed(0)) const countRef = R.useRef(0)
const [count] = R.useSubscribeRefs(countRef) const [count] = R.useRefState(countRef)
return ( return (
<div className="container mx-auto"> <div className="container mx-auto">
<button onClick={() => runSync(Ref.update(countRef, count => count + 1))}> {/* <button onClick={() => setCount((count) => count + 1)}> */}
<button onClick={() => Ref.update(countRef, count => count + 1).pipe(runSync)}>
count is {count} count is {count}
</button> </button>
</div> </div>

View File

@@ -1,10 +1,29 @@
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 { createFileRoute } from "@tanstack/react-router"
import { Layer } from "effect"
import { useMemo } from "react"
export const Route = createFileRoute('/')({ export const Route = createFileRoute("/")({
component: RouteComponent component: Index
}) })
function RouteComponent() { function Index() {
return <div>Hello "/"!</div>
const todosLayer = useMemo(() => Layer.empty.pipe(
Layer.provideMerge(TodosState.make("todos"))
), [])
return (
<Container>
<TodosContext.Provider layer={todosLayer}>
<VTodos />
</TodosContext.Provider>
</Container>
)
} }

View File

@@ -1,31 +0,0 @@
import { R } from "@/reffuse"
import { Button, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import * as LazyRef from "@typed/lazy-ref"
import { Suspense, use } from "react"
export const Route = createFileRoute("/lazyref")({
component: RouteComponent
})
function RouteComponent() {
const promise = R.usePromise(() => LazyRef.of(0), [])
return (
<Suspense fallback={<Text>Loading...</Text>}>
<LazyRefComponent promise={promise} />
</Suspense>
)
}
function LazyRefComponent({ promise }: { readonly promise: Promise<LazyRef.LazyRef<number>> }) {
const ref = use(promise)
const [value, setValue] = R.useLazyRefState(ref)
return (
<Button onClick={() => setValue(prev => prev + 1)}>
{value}
</Button>
)
}

View File

@@ -15,12 +15,12 @@ const Result = Schema.Tuple(Schema.String)
type Result = typeof Result.Type type Result = typeof Result.Type
function RouteComponent() { function RouteComponent() {
const promise = R.usePromise(() => Effect.addFinalizer(() => Console.log("Cleanup")).pipe( const promise = R.usePromise(Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
Effect.andThen(HttpClient.get("https://www.uuidtools.com/api/generate/v4")), Effect.andThen(HttpClient.get("https://www.uuidtools.com/api/generate/v4")),
HttpClient.withTracerPropagation(false), HttpClient.withTracerPropagation(false),
Effect.flatMap(res => res.json), Effect.flatMap(res => res.json),
Effect.flatMap(Schema.decodeUnknown(Result)), Effect.flatMap(Schema.decodeUnknown(Result)),
), []) ))
return ( return (
<Suspense fallback={<Text>Loading...</Text>}> <Suspense fallback={<Text>Loading...</Text>}>

View File

@@ -1,38 +0,0 @@
import { QueryContext } from "@/query/reffuse"
import { Uuid4Query } from "@/query/services"
import { Uuid4QueryService } from "@/query/views/Uuid4QueryService"
import { R } from "@/reffuse"
import { HttpClient } from "@effect/platform"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Layer, Schema } from "effect"
import { useMemo } from "react"
export const Route = createFileRoute("/query/service")({
component: RouteComponent
})
function RouteComponent() {
const query = R.useQuery({
key: R.useStreamFromReactiveValues(["uuid4", 10 as number]),
query: ([, count]) => Console.log(`Querying ${ count } IDs...`).pipe(
Effect.andThen(Effect.sleep("500 millis")),
Effect.andThen(Effect.map(
HttpClient.HttpClient,
HttpClient.withTracerPropagation(false),
)),
Effect.flatMap(client => client.get(`https://www.uuidtools.com/api/generate/v4/count/${ count }`)),
Effect.flatMap(res => res.json),
Effect.flatMap(Schema.decodeUnknown(Uuid4Query.Result)),
Effect.scoped,
),
})
const layer = useMemo(() => Layer.succeed(Uuid4Query.Uuid4Query, query), [query])
return (
<QueryContext.Provider layer={layer}>
<Uuid4QueryService />
</QueryContext.Provider>
)
}

View File

@@ -1,84 +0,0 @@
import { R } from "@/reffuse"
import { HttpClient } from "@effect/platform"
import { Button, Container, Flex, Slider, Text } from "@radix-ui/themes"
import { QueryProgress } from "@reffuse/extension-query"
import { createFileRoute } from "@tanstack/react-router"
import * as AsyncData from "@typed/async-data"
import { Array, Console, Effect, flow, Option, Schema, Stream } from "effect"
import { useState } from "react"
export const Route = createFileRoute("/query/usemutation")({
component: RouteComponent
})
const Result = Schema.Array(Schema.String)
function RouteComponent() {
const runFork = R.useRunFork()
const [count, setCount] = useState(1)
const mutation = R.useMutation({
mutation: ([count]: readonly [count: number]) => Console.log(`Querying ${ count } IDs...`).pipe(
Effect.andThen(QueryProgress.QueryProgress.update(() =>
AsyncData.Progress.make({ loaded: 0, total: Option.some(100) })
)),
Effect.andThen(Effect.sleep("500 millis")),
Effect.tap(() => QueryProgress.QueryProgress.update(() =>
AsyncData.Progress.make({ loaded: 50, total: Option.some(100) })
)),
Effect.andThen(Effect.map(
HttpClient.HttpClient,
HttpClient.withTracerPropagation(false),
)),
Effect.flatMap(client => client.get(`https://www.uuidtools.com/api/generate/v4/count/${ count }`)),
Effect.flatMap(res => res.json),
Effect.flatMap(Schema.decodeUnknown(Result)),
Effect.scoped,
)
})
const [state] = R.useSubscribeRefs(mutation.stateRef)
return (
<Container>
<Flex direction="column" align="center" gap="2">
<Slider
min={1}
max={100}
value={[count]}
onValueChange={flow(
Array.head,
Option.getOrThrow,
setCount,
)}
/>
<Text>
{AsyncData.match(state, {
NoData: () => "No data yet",
Loading: progress =>
`Loading...
${ Option.match(progress, {
onSome: ({ loaded, total }) => ` (${ loaded }/${ Option.getOrElse(total, () => "unknown") })`,
onNone: () => "",
}) }`,
Success: value => `Value: ${ value }`,
Failure: cause => `Error: ${ cause }`,
})}
</Text>
<Button onClick={() => mutation.forkMutate(count).pipe(
Effect.flatMap(([, state]) => Stream.runForEach(state, Console.log)),
Effect.andThen(Console.log("Mutation done.")),
runFork,
)}>
Get
</Button>
</Flex>
</Container>
)
}

View File

@@ -1,77 +0,0 @@
import { R } from "@/reffuse"
import { HttpClient } from "@effect/platform"
import { Button, Container, Flex, Slider, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import * as AsyncData from "@typed/async-data"
import { Array, Console, Effect, flow, Option, Schema, Stream } from "effect"
import { useState } from "react"
export const Route = createFileRoute("/query/usequery")({
component: RouteComponent
})
const Result = Schema.Array(Schema.String)
function RouteComponent() {
const runFork = R.useRunFork()
const [count, setCount] = useState(1)
const query = R.useQuery({
key: R.useStreamFromReactiveValues(["uuid4", count]),
query: ([, count]) => Console.log(`Querying ${ count } IDs...`).pipe(
Effect.andThen(Effect.sleep("500 millis")),
Effect.andThen(Effect.map(
HttpClient.HttpClient,
HttpClient.withTracerPropagation(false),
)),
Effect.flatMap(client => client.get(`https://www.uuidtools.com/api/generate/v4/count/${ count }`)),
Effect.flatMap(res => res.json),
Effect.flatMap(Schema.decodeUnknown(Result)),
Effect.scoped,
),
})
const [state] = R.useSubscribeRefs(query.stateRef)
return (
<Container>
<Flex direction="column" align="center" gap="2">
<Slider
min={1}
max={100}
value={[count]}
onValueChange={flow(
Array.head,
Option.getOrThrow,
setCount,
)}
/>
<Text>
{AsyncData.match(state, {
NoData: () => "No data yet",
Loading: () => "Loading...",
Success: (value, { isRefreshing, isOptimistic }) =>
`Value: ${value} ${isRefreshing ? "(refreshing)" : ""} ${isOptimistic ? "(optimistic)" : ""}`,
Failure: (cause, { isRefreshing }) =>
`Error: ${cause} ${isRefreshing ? "(refreshing)" : ""}`,
})}
</Text>
<Button
onClick={() => query.forkRefresh.pipe(
Effect.flatMap(([, state]) => Stream.runForEach(state, Console.log)),
Effect.andThen(Console.log("Refresh finished or stopped")),
runFork,
)}
>
Refresh
</Button>
</Flex>
</Container>
)
}

View File

@@ -1,34 +0,0 @@
import { R } from "@/reffuse"
import { Button, Flex, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Chunk, Effect, Exit, Option, Queue, Random, Scope, Stream } from "effect"
import { useMemo, useState } from "react"
export const Route = createFileRoute("/streams/pull")({
component: RouteComponent
})
function RouteComponent() {
const stream = useMemo(() => Stream.repeatEffect(Random.nextInt), [])
const streamScope = R.useScope([stream], { finalizerExecutionMode: "fork" })
const queue = R.useMemo(() => Effect.provideService(Stream.toQueueOfElements(stream), Scope.Scope, streamScope), [streamScope])
const [value, setValue] = useState(Option.none<number>())
const pullLatest = R.useCallbackSync(() => Queue.takeAll(queue).pipe(
Effect.flatMap(Chunk.last),
Effect.flatMap(Exit.matchEffect({
onSuccess: Effect.succeed,
onFailure: Effect.fail,
})),
Effect.tap(v => Effect.sync(() => setValue(Option.some(v)))),
), [queue])
return (
<Flex direction="column" align="center" gap="2">
{Option.isSome(value) && <Text>{value.value}</Text>}
<Button onClick={pullLatest}>Pull latest</Button>
</Flex>
)
}

View File

@@ -1,22 +1,6 @@
import { R } from "@/reffuse" import { R } from "@/reffuse"
import { Button, Flex, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { GetRandomValues, makeUuid4 } from "@typed/id" import { Console, Effect } from "effect"
import { Console, Effect, Option } from "effect"
import { useEffect, useState } from "react"
interface Node {
value: string
left?: Leaf
right?: Leaf
}
interface Leaf {
node: Node
}
const makeUuid = Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)
export const Route = createFileRoute("/tests")({ export const Route = createFileRoute("/tests")({
@@ -24,39 +8,17 @@ export const Route = createFileRoute("/tests")({
}) })
function RouteComponent() { function RouteComponent() {
const runSync = R.useRunSync() // const value = R.useMemoScoped(Effect.addFinalizer(() => Console.log("cleanup")).pipe(
// Effect.andThen(makeUuid4),
// Effect.provide(GetRandomValues.CryptoRandom),
// ), [])
// console.log(value)
const [uuid, setUuid] = useState(R.useMemo(() => makeUuid, [])) R.useFork(Effect.addFinalizer(() => Console.log("cleanup")).pipe(
const generateUuid = R.useCallbackSync(() => makeUuid.pipe( Effect.andThen(Console.log("ouient")),
Effect.tap(v => Effect.sync(() => setUuid(v))) Effect.delay("1 second"),
), []) ))
const uuidStream = R.useStreamFromReactiveValues([uuid])
const uuidStreamLatestValue = R.useSubscribeStream(uuidStream)
const [, scopeLayer] = R.useScope([uuid])
useEffect(() => Effect.addFinalizer(() => Console.log("Scope cleanup!")).pipe(
Effect.andThen(Console.log("Scope changed")),
Effect.provide(scopeLayer),
runSync,
), [scopeLayer, runSync])
const nodeRef = R.useRef(() => Effect.succeed<Node>({ value: "prout" })) return <div>Hello "/tests"!</div>
const nodeValueRef = R.useSubRefFromPath(nodeRef, ["value"])
return (
<Flex direction="column" justify="center" align="center" gap="2">
<Text>{uuid}</Text>
<Button onClick={generateUuid}>Generate UUID</Button>
<Text>
{Option.match(uuidStreamLatestValue, {
onSome: ([v]) => v,
onNone: () => <></>,
})}
</Text>
</Flex>
)
} }

View File

@@ -15,9 +15,9 @@ export const Route = createFileRoute("/time")({
function Time() { function Time() {
const timeRef = R.useMemo(() => DateTime.now.pipe(Effect.flatMap(SubscriptionRef.make)), []) const timeRef = R.useMemo(DateTime.now.pipe(Effect.flatMap(SubscriptionRef.make)))
R.useFork(() => Effect.addFinalizer(() => Console.log("Cleanup")).pipe( R.useFork(Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
Effect.andThen(Stream.runForEach(timeEverySecond, v => Ref.set(timeRef, v))) Effect.andThen(Stream.runForEach(timeEverySecond, v => Ref.set(timeRef, v)))
), [timeRef]) ), [timeRef])

View File

@@ -1,35 +0,0 @@
import { TodosContext } from "@/todos/reffuse"
import { TodosState } from "@/todos/services"
import { VTodos } from "@/todos/views/VTodos"
import { Container } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Layer } from "effect"
import { useMemo } from "react"
export const Route = createFileRoute("/todos")({
component: Todos
})
function Todos() {
const todosLayer = useMemo(() => Layer.empty.pipe(
Layer.provideMerge(TodosState.make("todos")),
Layer.merge(Layer.effectDiscard(
Effect.addFinalizer(() => Console.log("TodosContext cleaned up")).pipe(
Effect.andThen(Console.log("TodosContext constructed"))
)
)),
), [])
return (
<Container>
<TodosContext.Provider layer={todosLayer} finalizerExecutionMode="fork">
<VTodos />
</TodosContext.Provider>
</Container>
)
}

View File

@@ -1,7 +0,0 @@
import { QueryClient } from "@reffuse/extension-query"
import * as AppQueryErrorHandler from "./AppQueryErrorHandler"
export class AppQueryClient extends QueryClient.Service<AppQueryClient>()({
errorHandler: AppQueryErrorHandler.AppQueryErrorHandler
}) {}

View File

@@ -1,13 +0,0 @@
import { HttpClientError } from "@effect/platform"
import { QueryErrorHandler } from "@reffuse/extension-query"
import { Effect } from "effect"
export class AppQueryErrorHandler extends Effect.Service<AppQueryErrorHandler>()("AppQueryErrorHandler", {
effect: QueryErrorHandler.make<HttpClientError.HttpClientError>()(
(self, failure, defect) => self.pipe(
Effect.catchTag("RequestError", "ResponseError", failure),
Effect.catchAllDefect(defect),
)
)
}) {}

View File

@@ -1,2 +1 @@
export * as AppQueryClient from "./AppQueryClient" export {}
export * as AppQueryErrorHandler from "./AppQueryErrorHandler"

View File

@@ -1,10 +1,7 @@
import { RootReffuse } from "@/reffuse" import { GlobalContext } from "@/reffuse"
import { Reffuse, ReffuseContext } from "reffuse" import { Reffuse, ReffuseContext } from "reffuse"
import { TodosState } from "./services" import { TodosState } from "./services"
export const TodosContext = ReffuseContext.make<TodosState.TodosState>() export const TodosContext = ReffuseContext.make<TodosState.TodosState>()
export const R = Reffuse.make(GlobalContext, TodosContext)
export const R = new class TodosReffuse extends RootReffuse.pipe(
Reffuse.withContexts(TodosContext)
) {}

View File

@@ -2,43 +2,68 @@ import { Todo } from "@/domain"
import { KeyValueStore } from "@effect/platform" import { KeyValueStore } from "@effect/platform"
import { BrowserKeyValueStore } from "@effect/platform-browser" import { BrowserKeyValueStore } from "@effect/platform-browser"
import { PlatformError } from "@effect/platform/Error" import { PlatformError } from "@effect/platform/Error"
import { Chunk, Context, Effect, identity, Layer, ParseResult, Ref, Schema, Stream, SubscriptionRef } from "effect" import { Chunk, Context, Effect, identity, Layer, ParseResult, Ref, Schema, SubscriptionRef } from "effect"
export class TodosState extends Context.Tag("TodosState")<TodosState, { export class TodosState extends Context.Tag("TodosState")<TodosState, {
readonly todos: SubscriptionRef.SubscriptionRef<Chunk.Chunk<Todo.Todo>> readonly todos: SubscriptionRef.SubscriptionRef<Chunk.Chunk<Todo.Todo>>
readonly load: Effect.Effect<void, PlatformError | ParseResult.ParseError>
readonly save: Effect.Effect<void, PlatformError | ParseResult.ParseError> readonly readFromLocalStorage: Effect.Effect<void, PlatformError | ParseResult.ParseError>
readonly saveToLocalStorage: Effect.Effect<void, PlatformError | ParseResult.ParseError>
readonly prepend: (todo: Todo.Todo) => Effect.Effect<void>
readonly replace: (index: number, todo: Todo.Todo) => Effect.Effect<void>
readonly remove: (index: number) => Effect.Effect<void>
// readonly moveUp: (index: number) => Effect.Effect<void, Cause.NoSuchElementException>
// readonly moveDown: (index: number) => Effect.Effect<void, Cause.NoSuchElementException>
}>() {} }>() {}
export const make = (key: string) => Layer.effect(TodosState, Effect.gen(function*() { export const make = (key: string) => Layer.effect(TodosState, Effect.gen(function*() {
const todos = yield* SubscriptionRef.make(Chunk.empty<Todo.Todo>())
const readFromLocalStorage = KeyValueStore.KeyValueStore.pipe( const readFromLocalStorage = KeyValueStore.KeyValueStore.pipe(
Effect.flatMap(kv => kv.get(key)), Effect.flatMap(kv => kv.get(key)),
Effect.flatMap(identity), Effect.flatMap(identity),
Effect.flatMap(Schema.decode( Effect.flatMap(Schema.parseJson().pipe(
Schema.compose(Schema.parseJson(), Schema.Chunk(Todo.TodoFromJson)) Schema.compose(Schema.Chunk(Todo.TodoFromJson)),
Schema.decode,
)), )),
Effect.catchTag("NoSuchElementException", () => Effect.succeed(Chunk.empty<Todo.Todo>())), Effect.flatMap(v => Ref.set(todos, v)),
Effect.catchTag("NoSuchElementException", () => Ref.set(todos, Chunk.empty())),
Effect.provide(BrowserKeyValueStore.layerLocalStorage), Effect.provide(BrowserKeyValueStore.layerLocalStorage),
) )
const writeToLocalStorage = (values: Chunk.Chunk<Todo.Todo>) => KeyValueStore.KeyValueStore.pipe( const saveToLocalStorage = Effect.all([KeyValueStore.KeyValueStore, todos]).pipe(
Effect.flatMap(kv => values.pipe( Effect.flatMap(([kv, values]) => values.pipe(
Schema.encode( Schema.parseJson().pipe(
Schema.compose(Schema.parseJson(), Schema.Chunk(Todo.TodoFromJson)) Schema.compose(Schema.Chunk(Todo.TodoFromJson)),
Schema.encode,
), ),
Effect.flatMap(v => kv.set(key, v)), Effect.flatMap(v => kv.set(key, v)),
)), )),
Effect.provide(BrowserKeyValueStore.layerLocalStorage), Effect.provide(BrowserKeyValueStore.layerLocalStorage),
) )
const todos = yield* SubscriptionRef.make(yield* readFromLocalStorage) const prepend = (todo: Todo.Todo) => Ref.update(todos, Chunk.prepend(todo))
const load = Effect.flatMap(readFromLocalStorage, v => Ref.set(todos, v)) const replace = (index: number, todo: Todo.Todo) => Ref.update(todos, Chunk.replace(index, todo))
const save = Effect.flatMap(todos, writeToLocalStorage) const remove = (index: number) => Ref.update(todos, Chunk.remove(index))
// Sync changes with local storage // const moveUp = (index: number) => Effect.gen(function*() {
yield* Effect.forkScoped(Stream.runForEach(todos.changes, writeToLocalStorage))
return { todos, load, save } // })
yield* readFromLocalStorage
return {
todos,
readFromLocalStorage,
saveToLocalStorage,
prepend,
replace,
remove,
}
})) }))

View File

@@ -1,27 +1,25 @@
import { Todo } from "@/domain" import { Todo } from "@/domain"
import { Box, Button, Card, Flex, TextArea } from "@radix-ui/themes" import { Box, Button, Card, Flex, TextArea } from "@radix-ui/themes"
import { GetRandomValues, makeUuid4 } from "@typed/id" import { Effect, Option, SubscriptionRef } from "effect"
import { Chunk, Effect, Option, Ref } from "effect"
import { R } from "../reffuse" import { R } from "../reffuse"
import { TodosState } from "../services" import { TodosState } from "../services"
const createEmptyTodo = makeUuid4.pipe( const createEmptyTodo = Todo.generateUniqueID.pipe(
Effect.map(id => Todo.Todo.make({ id, content: "", completedAt: Option.none()}, true)), Effect.map(id => Todo.Todo.make({
Effect.provide(GetRandomValues.CryptoRandom), id,
content: "",
completedAt: Option.none(),
}, true))
) )
export function VNewTodo() { export function VNewTodo() {
const todoRef = R.useRef(() => createEmptyTodo) const runSync = R.useRunSync()
const [content, setContent] = R.useRefState(R.useSubRefFromPath(todoRef, ["content"]))
const add = R.useCallbackSync(() => Effect.all([TodosState.TodosState, todoRef]).pipe( const todoRef = R.useMemo(createEmptyTodo.pipe(Effect.flatMap(SubscriptionRef.make)))
Effect.flatMap(([state, todo]) => Ref.update(state.todos, Chunk.prepend(todo))), const [todo, setTodo] = R.useRefState(todoRef)
Effect.andThen(createEmptyTodo),
Effect.flatMap(v => Ref.set(todoRef, v)),
), [todoRef])
return ( return (
@@ -29,12 +27,23 @@ export function VNewTodo() {
<Card> <Card>
<Flex direction="column" align="stretch" gap="2"> <Flex direction="column" align="stretch" gap="2">
<TextArea <TextArea
value={content} value={todo.content}
onChange={e => setContent(e.target.value)} onChange={e => setTodo(prev =>
Todo.Todo.make({ ...prev, content: e.target.value }, true)
)}
/> />
<Flex direction="row" justify="center" align="center"> <Flex direction="row" justify="center" align="center">
<Button onClick={add}>Add</Button> <Button
onClick={() => TodosState.TodosState.pipe(
Effect.flatMap(state => state.prepend(todo)),
Effect.flatMap(() => createEmptyTodo),
Effect.map(setTodo),
runSync,
)}
>
Add
</Button>
</Flex> </Flex>
</Flex> </Flex>
</Card> </Card>

View File

@@ -1,28 +1,20 @@
import { Todo } from "@/domain" import { Todo } from "@/domain"
import { Box, Card, Flex, IconButton, TextArea } from "@radix-ui/themes" import { Box, Card, Flex, IconButton, TextArea } from "@radix-ui/themes"
import { Effect, Ref, Stream, SubscriptionRef } from "effect" import { Effect } from "effect"
import { Delete } from "lucide-react" import { Delete } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { R } from "../reffuse" import { R } from "../reffuse"
import { TodosState } from "../services"
export interface VTodoProps { export interface VTodoProps {
readonly todoRef: SubscriptionRef.SubscriptionRef<Todo.Todo> readonly index: number
readonly remove: Effect.Effect<void> readonly todo: Todo.Todo
} }
export function VTodo({ todoRef, remove }: VTodoProps) { export function VTodo({ index, todo }: VTodoProps) {
const runSync = R.useRunSync() const runSync = R.useRunSync()
const localTodoRef = R.useRef(() => todoRef)
const [content, setContent] = R.useRefState(R.useSubRefFromPath(localTodoRef, ["content"]))
R.useFork(() => localTodoRef.changes.pipe(
Stream.debounce("250 millis"),
Stream.runForEach(v => Ref.set(todoRef, v)),
), [localTodoRef])
const editorMode = useState(false) const editorMode = useState(false)
@@ -31,8 +23,14 @@ export function VTodo({ todoRef, remove }: VTodoProps) {
<Card> <Card>
<Flex direction="column" align="stretch" gap="1"> <Flex direction="column" align="stretch" gap="1">
<TextArea <TextArea
value={content} value={todo.content}
onChange={e => setContent(e.target.value)} onChange={e => TodosState.TodosState.pipe(
Effect.flatMap(state => state.replace(
index,
Todo.Todo.make({ ...todo, content: e.target.value }, true),
)),
runSync,
)}
disabled={!editorMode} disabled={!editorMode}
/> />
@@ -40,7 +38,12 @@ export function VTodo({ todoRef, remove }: VTodoProps) {
<Box></Box> <Box></Box>
<Flex direction="row" align="center" gap="1"> <Flex direction="row" align="center" gap="1">
<IconButton onClick={() => runSync(remove)}> <IconButton
onClick={() => TodosState.TodosState.pipe(
Effect.flatMap(state => state.remove(index)),
runSync,
)}
>
<Delete /> <Delete />
</IconButton> </IconButton>
</Flex> </Flex>

View File

@@ -1,5 +1,5 @@
import { Box, Flex } from "@radix-ui/themes" import { Box, Flex } from "@radix-ui/themes"
import { Chunk, Effect, Ref } from "effect" import { Chunk, Effect, Stream } from "effect"
import { R } from "../reffuse" import { R } from "../reffuse"
import { TodosState } from "../services" import { TodosState } from "../services"
import { VNewTodo } from "./VNewTodo" import { VNewTodo } from "./VNewTodo"
@@ -8,8 +8,15 @@ import { VTodo } from "./VTodo"
export function VTodos() { export function VTodos() {
const todosRef = R.useMemo(() => Effect.map(TodosState.TodosState, state => state.todos), []) // Sync changes to the todos with the local storage
const [todos] = R.useSubscribeRefs(todosRef) R.useFork(TodosState.TodosState.pipe(
Effect.flatMap(state =>
Stream.runForEach(state.todos.changes, () => state.saveToLocalStorage)
)
))
const todosRef = R.useMemo(TodosState.TodosState.pipe(Effect.map(state => state.todos)))
const [todos] = R.useRefState(todosRef)
return ( return (
@@ -20,16 +27,7 @@ export function VTodos() {
{Chunk.map(todos, (todo, index) => ( {Chunk.map(todos, (todo, index) => (
<Box key={todo.id} width="500px"> <Box key={todo.id} width="500px">
<R.SubRefFromGetSet <VTodo index={index} todo={todo} />
parent={todosRef}
getter={parentValue => Chunk.unsafeGet(parentValue, index)}
setter={(parentValue, value) => Chunk.replace(parentValue, index, value)}
>
{ref => <VTodo
todoRef={ref}
remove={Ref.update(todosRef, Chunk.remove(index))}
/>}
</R.SubRefFromGetSet>
</Box> </Box>
))} ))}
</Flex> </Flex>

View File

@@ -1,9 +0,0 @@
# LazyRef extension for Reffuse
Extension to integrate `@typed/lazy-ref` with Reffuse.
## Peer dependencies
- `@typed/lazy-ref`
- `reffuse` 0.1.3+
- `effect` 3.13+
- `react` & `@types/react` 19+

View File

@@ -1,42 +0,0 @@
{
"name": "@reffuse/extension-lazyref",
"version": "0.1.4",
"type": "module",
"files": [
"./README.md",
"./dist"
],
"license": "MIT",
"repository": {
"url": "git+https://github.com/Thiladev/reffuse.git"
},
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./*": {
"types": "./dist/*.d.ts",
"default": "./dist/*.js"
}
},
"scripts": {
"build": "tsc",
"lint:tsc": "tsc --noEmit",
"pack": "npm pack",
"clean:cache": "rm -f tsconfig.tsbuildinfo",
"clean:dist": "rm -rf dist",
"clean:node": "rm -rf node_modules"
},
"devDependencies": {
"reffuse": "workspace:*"
},
"peerDependencies": {
"@typed/lazy-ref": "^0.3.0",
"@types/react": "^19.0.0",
"effect": "^3.15.0",
"react": "^19.0.0",
"reffuse": "^0.1.8"
}
}

View File

@@ -1,56 +0,0 @@
import * as LazyRef from "@typed/lazy-ref"
import { Effect, pipe, Stream } from "effect"
import * as React from "react"
import { ReffuseExtension, type ReffuseNamespace } from "reffuse"
import { SetStateAction } from "reffuse/types"
export const LazyRefExtension = ReffuseExtension.make(() => ({
useSubscribeLazyRefs<
const Refs extends readonly LazyRef.LazyRef<any>[],
R,
>(
this: ReffuseNamespace.ReffuseNamespace<R>,
...refs: Refs
): [...{ [K in keyof Refs]: Effect.Effect.Success<Refs[K]> }] {
const [reactStateValue, setReactStateValue] = React.useState(this.useMemo(
() => Effect.all(refs as readonly LazyRef.LazyRef<any>[]),
[],
{ doNotReExecuteOnRuntimeOrContextChange: true },
) as [...{ [K in keyof Refs]: Effect.Effect.Success<Refs[K]> }])
this.useFork(() => pipe(
refs.map(ref => Stream.changesWith(ref.changes, (x, y) => x === y)),
streams => Stream.zipLatestAll(...streams),
Stream.runForEach(v =>
Effect.sync(() => setReactStateValue(v as [...{ [K in keyof Refs]: Effect.Effect.Success<Refs[K]> }]))
),
), refs)
return reactStateValue
},
useLazyRefState<A, E, R>(
this: ReffuseNamespace.ReffuseNamespace<R>,
ref: LazyRef.LazyRef<A, E, R>,
): [A, React.Dispatch<React.SetStateAction<A>>] {
const [reactStateValue, setReactStateValue] = React.useState(this.useMemo(
() => ref,
[],
{ doNotReExecuteOnRuntimeOrContextChange: true },
))
this.useFork(() => Stream.runForEach(
Stream.changesWith(ref.changes, (x, y) => x === y),
v => Effect.sync(() => setReactStateValue(v)),
), [ref])
const setValue = this.useCallbackSync((setStateAction: React.SetStateAction<A>) =>
LazyRef.update(ref, prevState =>
SetStateAction.value(setStateAction, prevState)
),
[ref])
return [reactStateValue, setValue]
},
}))

View File

@@ -1,33 +0,0 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "NodeNext",
"moduleDetection": "force",
"jsx": "react-jsx",
// "allowJs": true,
// Bundler mode
"moduleResolution": "NodeNext",
// "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
// "noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
// Build
"outDir": "./dist",
"declaration": true
},
"include": ["./src"]
}

View File

@@ -1,10 +0,0 @@
# Reffuse Query
TanStack Query style hooks for Reffuse.
## Peer dependencies
- `reffuse` 0.1.3+
- `effect` 3.13+
- `@effect/platform` & `@effect/platform-browser`
- `react` & `@types/react` 19+
- `@typed/async-data`

View File

@@ -1,44 +0,0 @@
{
"name": "@reffuse/extension-query",
"version": "0.1.5",
"type": "module",
"files": [
"./README.md",
"./dist"
],
"license": "MIT",
"repository": {
"url": "git+https://github.com/Thiladev/reffuse.git"
},
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./*": {
"types": "./dist/*.d.ts",
"default": "./dist/*.js"
}
},
"scripts": {
"build": "tsc",
"lint:tsc": "tsc --noEmit",
"pack": "npm pack",
"clean:cache": "rm -f tsconfig.tsbuildinfo",
"clean:dist": "rm -rf dist",
"clean:node": "rm -rf node_modules"
},
"devDependencies": {
"reffuse": "workspace:*"
},
"peerDependencies": {
"@effect/platform": "^0.77.0",
"@effect/platform-browser": "^0.56.0",
"@typed/async-data": "^0.13.0",
"@types/react": "^19.0.0",
"effect": "^3.15.0",
"react": "^19.0.0",
"reffuse": "^0.1.11"
}
}

View File

@@ -1,95 +0,0 @@
import * as AsyncData from "@typed/async-data"
import { Effect, type Fiber, Queue, Ref, Stream, SubscriptionRef } from "effect"
import type * as QueryClient from "./QueryClient.js"
import * as QueryProgress from "./QueryProgress.js"
import { QueryState } from "./internal/index.js"
export interface MutationRunner<K extends readonly unknown[], A, E> {
readonly stateRef: SubscriptionRef.SubscriptionRef<AsyncData.AsyncData<A, E>>
readonly mutate: (...key: K) => Effect.Effect<AsyncData.Success<A> | AsyncData.Failure<E>>
readonly forkMutate: (...key: K) => Effect.Effect<readonly [
fiber: Fiber.RuntimeFiber<AsyncData.Success<A> | AsyncData.Failure<E>>,
state: Stream.Stream<AsyncData.AsyncData<A, E>>,
]>
}
export const Tag = <const Id extends string>(id: Id) => <
Self, K extends readonly unknown[], A, E = never,
>() => Effect.Tag(id)<Self, MutationRunner<K, A, E>>()
export interface MakeProps<K extends readonly unknown[], A, FallbackA, E, HandledE, R> {
readonly QueryClient: QueryClient.GenericTagClass<FallbackA, HandledE>
readonly mutation: (key: K) => Effect.Effect<A, E, R | QueryProgress.QueryProgress>
}
export const make = <K extends readonly unknown[], A, FallbackA, E, HandledE, R>(
{
QueryClient,
mutation,
}: MakeProps<K, A, FallbackA, E, HandledE, R>
): Effect.Effect<
MutationRunner<K, A | FallbackA, Exclude<E, HandledE>>,
never,
R | QueryClient.TagClassShape<FallbackA, HandledE>
> => Effect.gen(function*() {
const context = yield* Effect.context<R | QueryClient.TagClassShape<FallbackA, HandledE>>()
const globalStateRef = yield* SubscriptionRef.make(AsyncData.noData<A | FallbackA, Exclude<E, HandledE>>())
const queryStateTag = QueryState.makeTag<A | FallbackA, Exclude<E, HandledE>>()
const run = (key: K) => Effect.all([QueryClient, queryStateTag]).pipe(
Effect.flatMap(([client, state]) => state.set(AsyncData.loading()).pipe(
Effect.andThen(mutation(key)),
client.errorHandler.handle,
Effect.matchCauseEffect({
onSuccess: v => Effect.tap(Effect.succeed(AsyncData.success(v)), state.set),
onFailure: c => Effect.tap(Effect.succeed(AsyncData.failure(c)), state.set),
}),
)),
Effect.provide(context),
Effect.provide(QueryProgress.QueryProgress.Default),
)
const mutate = (...key: K) => Effect.provide(run(key), QueryState.layer(
queryStateTag,
globalStateRef,
value => Ref.set(globalStateRef, value),
))
const forkMutate = (...key: K) => Effect.all([
Ref.make(AsyncData.noData<A | FallbackA, Exclude<E, HandledE>>()),
Queue.unbounded<AsyncData.AsyncData<A | FallbackA, Exclude<E, HandledE>>>(),
]).pipe(
Effect.flatMap(([stateRef, stateQueue]) =>
Effect.addFinalizer(() => Queue.shutdown(stateQueue)).pipe(
Effect.andThen(run(key)),
Effect.scoped,
Effect.forkDaemon,
Effect.map(fiber => [fiber, Stream.fromQueue(stateQueue)] as const),
Effect.provide(QueryState.layer(
queryStateTag,
stateRef,
value => Queue.offer(stateQueue, value).pipe(
Effect.andThen(Ref.set(stateRef, value)),
Effect.andThen(Ref.set(globalStateRef, value)),
),
)),
)
)
)
return {
context,
stateRef: globalStateRef,
mutate,
forkMutate,
}
})

View File

@@ -1,58 +0,0 @@
import { Context, Effect, identity, Layer } from "effect"
import type { Mutable } from "effect/Types"
import * as QueryErrorHandler from "./QueryErrorHandler.js"
export interface QueryClient<FallbackA, HandledE> {
readonly errorHandler: QueryErrorHandler.QueryErrorHandler<FallbackA, HandledE>
}
export interface MakeProps<FallbackA, HandledE> {
readonly errorHandler: QueryErrorHandler.QueryErrorHandler<FallbackA, HandledE>
}
export const make = <FallbackA, HandledE>(
{ errorHandler }: MakeProps<FallbackA, HandledE>
): Effect.Effect<QueryClient<FallbackA, HandledE>> => Effect.Do.pipe(
Effect.let("errorHandler", () => errorHandler)
)
const id = "@reffuse/extension-query/QueryClient"
export type TagClassShape<FallbackA, HandledE> = Context.TagClassShape<typeof id, QueryClient<FallbackA, HandledE>>
export type GenericTagClass<FallbackA, HandledE> = Context.TagClass<
TagClassShape<FallbackA, HandledE>,
typeof id,
QueryClient<FallbackA, HandledE>
>
export const makeGenericTagClass = <FallbackA = never, HandledE = never>(): GenericTagClass<FallbackA, HandledE> => Context.Tag(id)()
export interface ServiceProps<FallbackA = never, HandledE = never, E = never, R = never> {
readonly errorHandler?: Effect.Effect<QueryErrorHandler.QueryErrorHandler<FallbackA, HandledE>, E, R>
}
export interface ServiceResult<Self, FallbackA, HandledE, E, R> extends Context.TagClass<
Self,
typeof id,
QueryClient<FallbackA, HandledE>
> {
readonly Default: Layer.Layer<Self, E, R>
}
export const Service = <Self>() => (
<FallbackA = never, HandledE = never, E = never, R = never>(
props?: ServiceProps<FallbackA, HandledE, E, R>
): ServiceResult<Self, FallbackA, HandledE, E, R> => {
const TagClass = Context.Tag(id)() as ServiceResult<Self, FallbackA, HandledE, E, R>
(TagClass as Mutable<typeof TagClass>).Default = Layer.effect(TagClass, Effect.flatMap(
props?.errorHandler ?? QueryErrorHandler.make<never>()(identity),
errorHandler => make({ errorHandler }),
))
return TagClass
}
)

View File

@@ -1,40 +0,0 @@
import { Cause, Effect, PubSub, Stream } from "effect"
export interface QueryErrorHandler<FallbackA, HandledE> {
readonly errors: Stream.Stream<Cause.Cause<HandledE>>
readonly handle: <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A | FallbackA, Exclude<E, HandledE>, R>
}
export type Fallback<T> = T extends QueryErrorHandler<infer A, any> ? A : never
export type Error<T> = T extends QueryErrorHandler<any, infer E> ? E : never
export const make = <HandledE = never>() => (
<FallbackA>(
f: (
self: Effect.Effect<never, HandledE>,
failure: (failure: HandledE) => Effect.Effect<never>,
defect: (defect: unknown) => Effect.Effect<never>,
) => Effect.Effect<FallbackA>
): Effect.Effect<QueryErrorHandler<FallbackA, HandledE>> => Effect.gen(function*() {
const pubsub = yield* PubSub.unbounded<Cause.Cause<HandledE>>()
const errors = Stream.fromPubSub(pubsub)
const handle = <A, E, R>(
self: Effect.Effect<A, E, R>
): Effect.Effect<A | FallbackA, Exclude<E, HandledE>, R> => f(
self as unknown as Effect.Effect<never, HandledE, never>,
(failure: HandledE) => Effect.andThen(
PubSub.publish(pubsub, Cause.fail(failure)),
Effect.failCause(Cause.empty),
),
(defect: unknown) => Effect.andThen(
PubSub.publish(pubsub, Cause.die(defect)),
Effect.failCause(Cause.empty),
),
)
return { errors, handle }
})
)

View File

@@ -1,61 +0,0 @@
import type { Effect, Stream } from "effect"
import { ReffuseExtension, type ReffuseNamespace } from "reffuse"
import * as MutationRunner from "./MutationRunner.js"
import * as QueryClient from "./QueryClient.js"
import type * as QueryProgress from "./QueryProgress.js"
import * as QueryRunner from "./QueryRunner.js"
export interface UseQueryProps<K extends readonly unknown[], A, E, R> {
readonly key: Stream.Stream<K>
readonly query: (key: K) => Effect.Effect<A, E, R | QueryProgress.QueryProgress>
readonly options?: QueryRunner.RunOptions
}
export interface UseMutationProps<K extends readonly unknown[], A, E, R> {
readonly mutation: (key: K) => Effect.Effect<A, E, R | QueryProgress.QueryProgress>
}
export const QueryExtension = ReffuseExtension.make(() => ({
useQuery<
QK extends readonly unknown[],
QA,
FallbackA,
QE,
HandledE,
QR extends R,
R,
>(
this: ReffuseNamespace.ReffuseNamespace<R | QueryClient.TagClassShape<FallbackA, HandledE>>,
props: UseQueryProps<QK, QA, QE, QR>,
): QueryRunner.QueryRunner<QK, QA | FallbackA, Exclude<QE, HandledE>> {
const runner = this.useMemo(() => QueryRunner.make({
QueryClient: QueryClient.makeGenericTagClass<FallbackA, HandledE>(),
key: props.key,
query: props.query,
}), [props.key])
this.useFork(() => QueryRunner.run(runner, props.options), [runner])
return runner
},
useMutation<
QK extends readonly unknown[],
QA,
FallbackA,
QE,
HandledE,
QR extends R,
R,
>(
this: ReffuseNamespace.ReffuseNamespace<R | QueryClient.TagClassShape<FallbackA, HandledE>>,
props: UseMutationProps<QK, QA, QE, QR>,
): MutationRunner.MutationRunner<QK, QA | FallbackA, Exclude<QE, HandledE>> {
return this.useMemo(() => MutationRunner.make({
QueryClient: QueryClient.makeGenericTagClass<FallbackA, HandledE>(),
mutation: props.mutation,
}), [])
},
}))

View File

@@ -1,37 +0,0 @@
import * as AsyncData from "@typed/async-data"
import { Effect, flow, Layer, Match, Option } from "effect"
import { QueryState } from "./internal/index.js"
export class QueryProgress extends Effect.Tag("@reffuse/extension-query/QueryProgress")<QueryProgress, {
readonly get: Effect.Effect<Option.Option<AsyncData.Progress>>
readonly update: (
f: (previous: Option.Option<AsyncData.Progress>) => AsyncData.Progress
) => Effect.Effect<void>
}>() {
static readonly Default: Layer.Layer<
QueryProgress,
never,
QueryState.QueryState<any, any>
> = Layer.effect(this, Effect.gen(function*() {
const state = yield* QueryState.makeTag()
const get = state.get.pipe(
Effect.map(flow(Match.value,
Match.tag("Loading", v => v.progress),
Match.tag("Refreshing", v => v.progress),
Match.orElse(() => Option.none()),
))
)
const update = (f: (previous: Option.Option<AsyncData.Progress>) => AsyncData.Progress) => get.pipe(
Effect.map(f),
Effect.flatMap(progress => state.update(previous =>
AsyncData.updateProgress(previous, progress)
)),
)
return { get, update }
}))
}

View File

@@ -1,193 +0,0 @@
import { BrowserStream } from "@effect/platform-browser"
import * as AsyncData from "@typed/async-data"
import { type Cause, Effect, Fiber, identity, Option, Queue, Ref, type Scope, Stream, SubscriptionRef } from "effect"
import type * as QueryClient from "./QueryClient.js"
import * as QueryProgress from "./QueryProgress.js"
import { QueryState } from "./internal/index.js"
export interface QueryRunner<K extends readonly unknown[], A, E> {
readonly queryKey: Stream.Stream<K>
readonly latestKeyValueRef: SubscriptionRef.SubscriptionRef<Option.Option<K>>
readonly stateRef: SubscriptionRef.SubscriptionRef<AsyncData.AsyncData<A, E>>
readonly fiberRef: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.RuntimeFiber<
AsyncData.Success<A> | AsyncData.Failure<E>,
Cause.NoSuchElementException
>>>
readonly interrupt: Effect.Effect<void>
readonly forkInterrupt: Effect.Effect<Fiber.RuntimeFiber<void>>
readonly forkFetch: (keyValue: K) => Effect.Effect<readonly [
fiber: Fiber.RuntimeFiber<AsyncData.Success<A> | AsyncData.Failure<E>>,
state: Stream.Stream<AsyncData.AsyncData<A, E>>,
]>
readonly forkRefresh: Effect.Effect<readonly [
fiber: Fiber.RuntimeFiber<AsyncData.Success<A> | AsyncData.Failure<E>, Cause.NoSuchElementException>,
state: Stream.Stream<AsyncData.AsyncData<A, E>>,
]>
}
export const Tag = <const Id extends string>(id: Id) => <
Self, K extends readonly unknown[], A, E = never
>() => Effect.Tag(id)<Self, QueryRunner<K, A, E>>()
export interface MakeProps<K extends readonly unknown[], A, FallbackA, E, HandledE, R> {
readonly QueryClient: QueryClient.GenericTagClass<FallbackA, HandledE>
readonly key: Stream.Stream<K>
readonly query: (key: K) => Effect.Effect<A, E, R | QueryProgress.QueryProgress>
}
export const make = <K extends readonly unknown[], A, FallbackA, E, HandledE, R>(
{
QueryClient,
key,
query,
}: MakeProps<K, A, FallbackA, E, HandledE, R>
): Effect.Effect<
QueryRunner<K, A | FallbackA, Exclude<E, HandledE>>,
never,
R | QueryClient.TagClassShape<FallbackA, HandledE>
> => Effect.gen(function*() {
const context = yield* Effect.context<R | QueryClient.TagClassShape<FallbackA, HandledE>>()
const latestKeyValueRef = yield* SubscriptionRef.make(Option.none<K>())
const stateRef = yield* SubscriptionRef.make(AsyncData.noData<A | FallbackA, Exclude<E, HandledE>>())
const fiberRef = yield* SubscriptionRef.make(Option.none<Fiber.RuntimeFiber<
AsyncData.Success<A | FallbackA> | AsyncData.Failure<Exclude<E, HandledE>>,
Cause.NoSuchElementException
>>())
const queryStateTag = QueryState.makeTag<A | FallbackA, Exclude<E, HandledE>>()
const interrupt = Effect.flatMap(fiberRef, Option.match({
onSome: fiber => Ref.set(fiberRef, Option.none()).pipe(
Effect.andThen(Fiber.interrupt(fiber))
),
onNone: () => Effect.void,
}))
const forkInterrupt = Effect.flatMap(fiberRef, Option.match({
onSome: fiber => Ref.set(fiberRef, Option.none()).pipe(
Effect.andThen(Fiber.interrupt(fiber).pipe(
Effect.asVoid,
Effect.forkDaemon,
))
),
onNone: () => Effect.forkDaemon(Effect.void),
}))
const run = (keyValue: K) => Effect.all([QueryClient, queryStateTag]).pipe(
Effect.flatMap(([client, state]) => Ref.set(latestKeyValueRef, Option.some(keyValue)).pipe(
Effect.andThen(query(keyValue)),
client.errorHandler.handle,
Effect.matchCauseEffect({
onSuccess: v => Effect.tap(Effect.succeed(AsyncData.success(v)), state.set),
onFailure: c => Effect.tap(Effect.succeed(AsyncData.failure(c)), state.set),
}),
)),
Effect.provide(context),
Effect.provide(QueryProgress.QueryProgress.Default),
)
const forkFetch = (keyValue: K) => Queue.unbounded<AsyncData.AsyncData<A | FallbackA, Exclude<E, HandledE>>>().pipe(
Effect.flatMap(stateQueue => queryStateTag.pipe(
Effect.flatMap(state => interrupt.pipe(
Effect.andThen(
Effect.addFinalizer(() => Effect.andThen(
Ref.set(fiberRef, Option.none()),
Queue.shutdown(stateQueue),
)).pipe(
Effect.andThen(state.set(AsyncData.loading())),
Effect.andThen(run(keyValue)),
Effect.scoped,
Effect.forkDaemon,
)
),
Effect.tap(fiber => Ref.set(fiberRef, Option.some(fiber))),
Effect.map(fiber => [fiber, Stream.fromQueue(stateQueue)] as const),
)),
Effect.provide(QueryState.layer(
queryStateTag,
stateRef,
value => Effect.andThen(
Queue.offer(stateQueue, value),
Ref.set(stateRef, value),
),
)),
))
)
const setInitialRefreshState = Effect.flatMap(queryStateTag, state => state.update(previous => {
if (AsyncData.isSuccess(previous) || AsyncData.isFailure(previous))
return AsyncData.refreshing(previous)
if (AsyncData.isRefreshing(previous))
return AsyncData.refreshing(previous.previous)
return AsyncData.loading()
}))
const forkRefresh = Queue.unbounded<AsyncData.AsyncData<A | FallbackA, Exclude<E, HandledE>>>().pipe(
Effect.flatMap(stateQueue => interrupt.pipe(
Effect.andThen(
Effect.addFinalizer(() => Effect.andThen(
Ref.set(fiberRef, Option.none()),
Queue.shutdown(stateQueue),
)).pipe(
Effect.andThen(setInitialRefreshState),
Effect.andThen(latestKeyValueRef.pipe(
Effect.flatMap(identity),
Effect.flatMap(run),
)),
Effect.scoped,
Effect.forkDaemon,
)
),
Effect.tap(fiber => Ref.set(fiberRef, Option.some(fiber))),
Effect.map(fiber => [fiber, Stream.fromQueue(stateQueue)] as const),
Effect.provide(QueryState.layer(
queryStateTag,
stateRef,
value => Effect.andThen(
Queue.offer(stateQueue, value),
Ref.set(stateRef, value),
),
)),
))
)
return {
queryKey: key,
latestKeyValueRef,
stateRef,
fiberRef,
interrupt,
forkInterrupt,
forkFetch,
forkRefresh,
}
})
export interface RunOptions {
readonly refreshOnWindowFocus?: boolean
}
export const run = <K extends readonly unknown[], A, E>(
self: QueryRunner<K, A, E>,
options?: RunOptions,
): Effect.Effect<void, never, Scope.Scope> => Effect.gen(function*() {
if (typeof window !== "undefined" && (options?.refreshOnWindowFocus ?? true))
yield* Effect.forkScoped(
Stream.runForEach(BrowserStream.fromEventListenerWindow("focus"), () => self.forkRefresh)
)
yield* Effect.addFinalizer(() => self.interrupt)
yield* Stream.runForEach(Stream.changes(self.queryKey), latestKey => self.forkFetch(latestKey))
})

View File

@@ -1,6 +0,0 @@
export * as MutationRunner from "./MutationRunner.js"
export * as QueryClient from "./QueryClient.js"
export * as QueryErrorHandler from "./QueryErrorHandler.js"
export * from "./QueryExtension.js"
export * as QueryProgress from "./QueryProgress.js"
export * as QueryRunner from "./QueryRunner.js"

View File

@@ -1,24 +0,0 @@
import type * as AsyncData from "@typed/async-data"
import { Context, Effect, Layer } from "effect"
export interface QueryState<A, E> {
readonly get: Effect.Effect<AsyncData.AsyncData<A, E>>
readonly set: (value: AsyncData.AsyncData<A, E>) => Effect.Effect<void>
readonly update: (f: (previous: AsyncData.AsyncData<A, E>) => AsyncData.AsyncData<A, E>) => Effect.Effect<void>
}
export const makeTag = <A, E>(): Context.Tag<QueryState<A, E>, QueryState<A, E>> => Context.GenericTag("@reffuse/query-extension/QueryState")
export const layer = <A, E>(
tag: Context.Tag<QueryState<A, E>, QueryState<A, E>>,
get: Effect.Effect<AsyncData.AsyncData<A, E>>,
set: (value: AsyncData.AsyncData<A, E>) => Effect.Effect<void>,
): Layer.Layer<QueryState<A, E>> => Layer.succeed(tag, {
get,
set,
update: f => get.pipe(
Effect.map(f),
Effect.flatMap(set),
),
})

View File

@@ -1 +0,0 @@
export * as QueryState from "./QueryState.js"

View File

@@ -1,33 +0,0 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "NodeNext",
"moduleDetection": "force",
"jsx": "react-jsx",
// "allowJs": true,
// Bundler mode
"moduleResolution": "NodeNext",
// "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
// "noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
// Build
"outDir": "./dist",
"declaration": true
},
"include": ["./src"]
}

View File

@@ -6,6 +6,7 @@ This library is in early development. While it is (almost) feature complete and
Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory. Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
## Peer dependencies ## Dependencies
- `effect` 3.13+ (needs to be manually installed)
- `react` & `@types/react` 19+ - `effect`
- `react` 19+

View File

@@ -1,6 +1,6 @@
{ {
"name": "reffuse", "name": "reffuse",
"version": "0.1.13", "version": "0.1.1",
"type": "module", "type": "module",
"files": [ "files": [
"./README.md", "./README.md",
@@ -16,10 +16,6 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
}, },
"./types": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
},
"./*": { "./*": {
"types": "./dist/*.d.ts", "types": "./dist/*.d.ts",
"default": "./dist/*.js" "default": "./dist/*.js"
@@ -28,14 +24,13 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"lint:tsc": "tsc --noEmit", "lint:tsc": "tsc --noEmit",
"pack": "npm pack",
"clean:cache": "rm -f tsconfig.tsbuildinfo", "clean:cache": "rm -f tsconfig.tsbuildinfo",
"clean:dist": "rm -rf dist", "clean:dist": "rm -rf dist",
"clean:node": "rm -rf node_modules" "clean:node": "rm -rf node_modules"
}, },
"peerDependencies": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.10",
"effect": "^3.15.0", "effect": "~3.13.1",
"react": "^19.0.0" "react": "^19.0.0"
} }
} }

View File

@@ -1,48 +1,399 @@
import type * as ReffuseContext from "./ReffuseContext.js" import { Context, Effect, ExecutionStrategy, Exit, Fiber, Pipeable, Ref, Runtime, Scope, Stream, SubscriptionRef } from "effect"
import type * as ReffuseExtension from "./ReffuseExtension.js" import * as React from "react"
import * as ReffuseNamespace from "./ReffuseNamespace.js" import * as ReffuseContext from "./ReffuseContext.js"
import type { Merge, StaticType } from "./utils.js" import * as ReffuseRuntime from "./ReffuseRuntime.js"
import * as SetStateAction from "./SetStateAction.js"
export class Reffuse extends ReffuseNamespace.makeClass() {} export class Reffuse<R> {
constructor(
readonly contexts: readonly ReffuseContext.ReffuseContext<R>[]
) {}
export const withContexts = <R2 extends Array<unknown>>( useContext(): Context.Context<R> {
...contexts: [...{ [K in keyof R2]: ReffuseContext.ReffuseContext<R2[K]> }] return ReffuseContext.useMergeAll(...this.contexts)
) => (
<
BaseClass extends ReffuseNamespace.ReffuseNamespaceClass<R1>,
R1
>(
self: BaseClass & ReffuseNamespace.ReffuseNamespaceClass<R1>
): (
{
new(): Merge<
InstanceType<BaseClass>,
{ constructor: ReffuseNamespace.ReffuseNamespaceClass<R1 | R2[number]> }
>
} &
Merge<
StaticType<BaseClass>,
StaticType<ReffuseNamespace.ReffuseNamespaceClass<R1 | R2[number]>>
>
) => class extends self {
static readonly contexts = [...self.contexts, ...contexts]
} as any
)
export const withExtension = <A extends object>(extension: ReffuseExtension.ReffuseExtension<A>) => (
<
BaseClass extends ReffuseNamespace.ReffuseNamespaceClass<R>,
R
>(
self: BaseClass & ReffuseNamespace.ReffuseNamespaceClass<R>
): (
{ new(): Merge<InstanceType<BaseClass>, A> } &
StaticType<BaseClass>
) => {
const class_ = class extends self {}
Object.assign(class_.prototype, extension())
return class_ as any
} }
)
useRunSync() {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
return React.useCallback(<A, E>(
effect: Effect.Effect<A, E, R>
): A => effect.pipe(
Effect.provide(context),
Runtime.runSync(runtime),
), [runtime, context])
}
useRunPromise() {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
return React.useCallback(<A, E>(
effect: Effect.Effect<A, E, R>,
options?: { readonly signal?: AbortSignal },
): Promise<A> => effect.pipe(
Effect.provide(context),
effect => Runtime.runPromise(runtime)(effect, options),
), [runtime, context])
}
useRunFork() {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
return React.useCallback(<A, E>(
effect: Effect.Effect<A, E, R>,
options?: Runtime.RunForkOptions,
): Fiber.RuntimeFiber<A, E> => effect.pipe(
Effect.provide(context),
effect => Runtime.runFork(runtime)(effect, options),
), [runtime, context])
}
useRunCallback() {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
return React.useCallback(<A, E>(
effect: Effect.Effect<A, E, R>,
options?: Runtime.RunCallbackOptions<A, E>,
): Runtime.Cancel<A, E> => effect.pipe(
Effect.provide(context),
effect => Runtime.runCallback(runtime)(effect, options),
), [runtime, context])
}
/**
* Reffuse equivalent to `React.useMemo`.
*
* `useMemo` will only recompute the memoized value by running the given synchronous effect when one of the deps has changed. \
* Trying to run an asynchronous effect will throw.
*
* Changes to the Reffuse runtime or context will recompute the value in addition to the deps.
* You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`.
*/
useMemo<A, E>(
effect: Effect.Effect<A, E, R>,
deps?: React.DependencyList,
options?: RenderOptions,
): A {
const runSync = this.useRunSync()
return React.useMemo(() => runSync(effect), [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
...(deps ?? []),
])
}
useMemoScoped<A, E>(
effect: Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: RenderOptions & ScopeOptions,
): A {
const runSync = this.useRunSync()
// Calculate an initial version of the value so that it can be accessed during the first render
const [initialScope, initialValue] = React.useMemo(() => Scope.make(options?.finalizerExecutionStrategy).pipe(
Effect.flatMap(scope => effect.pipe(
Effect.provideService(Scope.Scope, scope),
Effect.map(value => [scope, value] as const),
)),
runSync,
), [])
// Keep track of the state of the initial scope
const initialScopeClosed = React.useRef(false)
const [value, setValue] = React.useState(initialValue)
React.useEffect(() => {
const closeInitialScopeIfNeeded = Scope.close(initialScope, Exit.void).pipe(
Effect.andThen(Effect.sync(() => { initialScopeClosed.current = true })),
Effect.when(() => !initialScopeClosed.current),
)
const [scope, value] = closeInitialScopeIfNeeded.pipe(
Effect.andThen(Scope.make(options?.finalizerExecutionStrategy).pipe(
Effect.flatMap(scope => effect.pipe(
Effect.provideService(Scope.Scope, scope),
Effect.map(value => [scope, value] as const),
))
)),
runSync,
)
setValue(value)
return () => { runSync(Scope.close(scope, Exit.void)) }
}, [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
...(deps ?? []),
])
return value
}
/**
* Reffuse equivalent to `React.useEffect`.
*
* Executes a synchronous effect wrapped into a Scope when one of the deps has changed. Trying to run an asynchronous effect will throw.
*
* The Scope is closed on every cleanup, i.e. when one of the deps has changed and the effect needs to be re-executed. \
* Add finalizers to the Scope to handle cleanup logic.
*
* Changes to the Reffuse runtime or context will re-execute the effect in addition to the deps.
* You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`.
*
* ### Example
* ```
* useEffect(Effect.addFinalizer(() => Console.log("Component unmounted")).pipe(
* Effect.flatMap(() => Console.log("Component mounted"))
* ))
* ```
*
* Plain React equivalent:
* ```
* React.useEffect(() => {
* console.log("Component mounted")
* return () => { console.log("Component unmounted") }
* })
* ```
*/
useEffect<A, E>(
effect: Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: RenderOptions & ScopeOptions,
): void {
const runSync = this.useRunSync()
return React.useEffect(() => {
const scope = Scope.make(options?.finalizerExecutionStrategy).pipe(
Effect.tap(scope => Effect.provideService(effect, Scope.Scope, scope)),
runSync,
)
return () => { runSync(Scope.close(scope, Exit.void)) }
}, [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
...(deps ?? []),
])
}
/**
* Reffuse equivalent to `React.useLayoutEffect`.
*
* Executes a synchronous effect wrapped into a Scope when one of the deps has changed. Fires synchronously after all DOM mutations. \
* Trying to run an asynchronous effect will throw.
*
* The Scope is closed on every cleanup, i.e. when one of the deps has changed and the effect needs to be re-executed. \
* Add finalizers to the Scope to handle cleanup logic.
*
* Changes to the Reffuse runtime or context will re-execute the effect in addition to the deps.
* You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`.
*
* ### Example
* ```
* useLayoutEffect(Effect.addFinalizer(() => Console.log("Component unmounted")).pipe(
* Effect.flatMap(() => Console.log("Component mounted"))
* ))
* ```
*
* Plain React equivalent:
* ```
* React.useLayoutEffect(() => {
* console.log("Component mounted")
* return () => { console.log("Component unmounted") }
* })
* ```
*/
useLayoutEffect<A, E>(
effect: Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: RenderOptions & ScopeOptions,
): void {
const runSync = this.useRunSync()
return React.useLayoutEffect(() => {
const scope = Scope.make(options?.finalizerExecutionStrategy).pipe(
Effect.tap(scope => Effect.provideService(effect, Scope.Scope, scope)),
runSync,
)
return () => { runSync(Scope.close(scope, Exit.void)) }
}, [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
...(deps ?? []),
])
}
/**
* An asynchronous and non-blocking alternative to `React.useEffect`.
*
* Forks an effect wrapped into a Scope in the background when one of the deps has changed.
*
* The Scope is closed on every cleanup, i.e. when one of the deps has changed and the effect needs to be re-executed. \
* Add finalizers to the Scope to handle cleanup logic.
*
* Changes to the Reffuse runtime or context will re-execute the effect in addition to the deps.
* You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`.
*
* ### Example
* ```
* const timeRef = useRefFromEffect(DateTime.now)
*
* useFork(Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
* Effect.map(() => Stream.repeatEffectWithSchedule(
* DateTime.now,
* Schedule.intersect(Schedule.forever, Schedule.spaced("1 second")),
* )),
*
* Effect.flatMap(Stream.runForEach(time => Ref.set(timeRef, time)),
* )), [timeRef])
*
* const [time] = useRefState(timeRef)
* ```
*/
useFork<A, E>(
effect: Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: Runtime.RunForkOptions & RenderOptions & ScopeOptions,
): void {
const runSync = this.useRunSync()
const runFork = this.useRunFork()
return React.useEffect(() => {
const scope = runSync(options?.scope
? Scope.fork(options.scope, options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
: Scope.make(options?.finalizerExecutionStrategy)
)
runFork(Effect.provideService(effect, Scope.Scope, scope), { ...options, scope })
return () => { runFork(Scope.close(scope, Exit.void)) }
}, [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync, runFork],
...(deps ?? []),
])
}
usePromise<A, E>(
effect: Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: { readonly signal?: AbortSignal } & Runtime.RunForkOptions & RenderOptions & ScopeOptions,
): Promise<A> {
const runSync = this.useRunSync()
const runFork = this.useRunFork()
const [value, setValue] = React.useState(Promise.withResolvers<A>().promise)
React.useEffect(() => {
const { promise, resolve, reject } = Promise.withResolvers<A>()
setValue(promise)
const scope = runSync(options?.scope
? Scope.fork(options.scope, options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
: Scope.make(options?.finalizerExecutionStrategy)
)
const cleanup = () => { runFork(Scope.close(scope, Exit.void)) }
if (options?.signal)
options.signal.addEventListener("abort", cleanup)
effect.pipe(
Effect.provideService(Scope.Scope, scope),
Effect.match({
onSuccess: resolve,
onFailure: reject,
}),
effect => runFork(effect, { ...options, scope }),
)
return () => {
if (options?.signal)
options.signal.removeEventListener("abort", cleanup)
cleanup()
}
}, [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync, runFork],
...(deps ?? []),
])
return value
}
useRef<A>(value: A): SubscriptionRef.SubscriptionRef<A> {
return this.useMemo(
SubscriptionRef.make(value),
[],
{ doNotReExecuteOnRuntimeOrContextChange: true }, // Do not recreate the ref when the context changes
)
}
/**
* Binds the state of a `SubscriptionRef` to the state of the React component.
*
* Returns a [value, setter] tuple just like `React.useState` and triggers a re-render everytime the value held by the ref changes.
*
* Note that the rules of React's immutable state still apply: updating a ref with the same value will not trigger a re-render.
*/
useRefState<A>(ref: SubscriptionRef.SubscriptionRef<A>): [A, React.Dispatch<React.SetStateAction<A>>] {
const runSync = this.useRunSync()
const initialState = React.useMemo(() => runSync(ref), [])
const [reactStateValue, setReactStateValue] = React.useState(initialState)
this.useFork(Stream.runForEach(ref.changes, v => Effect.sync(() =>
setReactStateValue(v)
)), [ref])
const setValue = React.useCallback((setStateAction: React.SetStateAction<A>) =>
runSync(Ref.update(ref, prevState =>
SetStateAction.value(setStateAction, prevState)
)),
[ref])
return [reactStateValue, setValue]
}
}
export interface Reffuse<R> extends Pipeable.Pipeable {}
Reffuse.prototype.pipe = function pipe() {
return Pipeable.pipeArguments(this, arguments)
}
export interface RenderOptions {
/** Prevents re-executing the effect when the Effect runtime or context changes. Defaults to `false`. */
readonly doNotReExecuteOnRuntimeOrContextChange?: boolean
}
export interface ScopeOptions {
readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy
}
export const make = <T extends Array<unknown>>(
...contexts: [...{ [K in keyof T]: ReffuseContext.ReffuseContext<T[K]> }]
): Reffuse<T[number]> =>
new Reffuse(contexts)
// export const make = (): Reffuse<never> => new Reffuse([])
// export const withContexts = <R2 extends Array<unknown>>(
// ...contexts: [...{ [K in keyof R2]: ReffuseContext.ReffuseContext<R2[K]> }]
// ) =>
// <T extends Reffuse<R1>, R1>(self: T & Reffuse<R1>): (
// Reffuse<R1 | R2[number]> & Exclude<T, Reffuse<R1>>
// ) =>
// new Reffuse([...self.contexts, ...contexts as any]) as any

View File

@@ -1,178 +0,0 @@
import { Array, Context, Effect, ExecutionStrategy, Exit, Layer, Match, Ref, Runtime, Scope } from "effect"
import * as React from "react"
import * as ReffuseRuntime from "./ReffuseRuntime.js"
export class ReffuseContext<R> {
readonly Context = React.createContext<Context.Context<R>>(null!)
readonly Provider = makeProvider(this.Context)
readonly AsyncProvider = makeAsyncProvider(this.Context)
useContext(): Context.Context<R> {
return React.useContext(this.Context)
}
useLayer(): Layer.Layer<R> {
const context = this.useContext()
return React.useMemo(() => Layer.effectContext(Effect.succeed(context)), [context])
}
}
export type R<T> = T extends ReffuseContext<infer R> ? R : never
export type ReactProvider<R> = React.FC<{
readonly layer: Layer.Layer<R, unknown, Scope.Scope>
readonly scope?: Scope.Scope
readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy
readonly finalizerExecutionMode?: "sync" | "fork"
readonly children?: React.ReactNode
}>
const makeProvider = <R>(Context: React.Context<Context.Context<R>>): ReactProvider<R> => {
return function ReffuseContextReactProvider(props) {
const runtime = ReffuseRuntime.useRuntime()
const runSync = React.useMemo(() => Runtime.runSync(runtime), [runtime])
const runFork = React.useMemo(() => Runtime.runFork(runtime), [runtime])
const makeScope = React.useMemo(() => props.scope
? Scope.fork(props.scope, props.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
: Scope.make(props.finalizerExecutionStrategy ?? ExecutionStrategy.sequential),
[props.scope])
const makeContext = (scope: Scope.CloseableScope) => Effect.context<R>().pipe(
Effect.provide(props.layer),
Effect.provideService(Scope.Scope, scope),
)
const closeScope = (scope: Scope.CloseableScope) => Scope.close(scope, Exit.void).pipe(
effect => Match.value(props.finalizerExecutionMode ?? "sync").pipe(
Match.when("sync", () => { runSync(effect) }),
Match.when("fork", () => { runFork(effect) }),
Match.exhaustive,
)
)
const [isInitialRun, initialScope, initialValue] = React.useMemo(() => Effect.Do.pipe(
Effect.bind("isInitialRun", () => Ref.make(true)),
Effect.bind("scope", () => makeScope),
Effect.bind("context", ({ scope }) => makeContext(scope)),
Effect.map(({ isInitialRun, scope, context }) => [isInitialRun, scope, context] as const),
runSync,
), [])
const [value, setValue] = React.useState(initialValue)
React.useEffect(() => isInitialRun.pipe(
Effect.if({
onTrue: () => Ref.set(isInitialRun, false).pipe(
Effect.map(() =>
() => closeScope(initialScope)
)
),
onFalse: () => Effect.Do.pipe(
Effect.bind("scope", () => makeScope),
Effect.bind("context", ({ scope }) => makeContext(scope)),
Effect.tap(({ context }) =>
Effect.sync(() => setValue(context))
),
Effect.map(({ scope }) =>
() => closeScope(scope)
),
),
}),
runSync,
), [makeScope, runSync, runFork])
return React.createElement(Context, { ...props, value })
}
}
export type AsyncReactProvider<R> = React.FC<{
readonly layer: Layer.Layer<R, unknown, Scope.Scope>
readonly scope?: Scope.Scope
readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy
readonly finalizerExecutionMode?: "sync" | "fork"
readonly fallback?: React.ReactNode
readonly children?: React.ReactNode
}>
const makeAsyncProvider = <R>(Context: React.Context<Context.Context<R>>): AsyncReactProvider<R> => {
function ReffuseContextAsyncReactProviderInner({ promise, children }: {
readonly promise: Promise<Context.Context<R>>
readonly children?: React.ReactNode
}) {
return React.createElement(Context, {
value: React.use(promise),
children,
})
}
return function ReffuseContextAsyncReactProvider(props) {
const runtime = ReffuseRuntime.useRuntime()
const runSync = React.useMemo(() => Runtime.runSync(runtime), [runtime])
const runFork = React.useMemo(() => Runtime.runFork(runtime), [runtime])
const [promise, setPromise] = React.useState(Promise.withResolvers<Context.Context<R>>().promise)
React.useEffect(() => {
const { promise, resolve, reject } = Promise.withResolvers<Context.Context<R>>()
setPromise(promise)
const scope = runSync(props.scope
? Scope.fork(props.scope, props.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
: Scope.make(props.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
)
Effect.context<R>().pipe(
Effect.match({
onSuccess: resolve,
onFailure: reject,
}),
Effect.provide(props.layer),
Effect.provideService(Scope.Scope, scope),
effect => runFork(effect, { ...props, scope }),
)
return () => Scope.close(scope, Exit.void).pipe(
effect => Match.value(props.finalizerExecutionMode ?? "sync").pipe(
Match.when("sync", () => { runSync(effect) }),
Match.when("fork", () => { runFork(effect) }),
Match.exhaustive,
)
)
}, [props.layer, runSync, runFork])
return React.createElement(React.Suspense, {
children: React.createElement(ReffuseContextAsyncReactProviderInner, { ...props, promise }),
fallback: props.fallback,
})
}
}
export const make = <R = never>() => new ReffuseContext<R>()
export const useMergeAll = <T extends Array<unknown>>(
...contexts: [...{ [K in keyof T]: ReffuseContext<T[K]> }]
): Context.Context<T[number]> => {
const values = contexts.map(v => React.use(v.Context))
return React.useMemo(() => Context.mergeAll(...values), values)
}
export const useMergeAllLayers = <T extends Array<unknown>>(
...contexts: [...{ [K in keyof T]: ReffuseContext<T[K]> }]
): Layer.Layer<T[number]> => {
const values = contexts.map(v => React.use(v.Context))
return React.useMemo(() => Array.isNonEmptyArray(values)
? Layer.mergeAll(
...Array.map(values, context => Layer.effectContext(Effect.succeed(context)))
)
: Layer.empty as Layer.Layer<T[number]>,
values)
}

View File

@@ -0,0 +1,74 @@
import { Array, Context, Effect, Layer, Runtime } from "effect"
import * as React from "react"
import * as ReffuseRuntime from "./ReffuseRuntime.js"
// TODO: merge this with the Provider, just like React 19 contexts
export class ReffuseContext<R> {
readonly Context = React.createContext<Context.Context<R>>(null!)
readonly Provider: ReffuseContextReactProvider<R>
constructor() {
// TODO: scope the layer creation
this.Provider = (props) => {
const runtime = ReffuseRuntime.useRuntime()
const value = React.useMemo(() => Effect.context<R>().pipe(
Effect.provide(props.layer),
Runtime.runSync(runtime),
), [props.layer, runtime])
return (
<this.Context
{...props}
value={value}
/>
)
}
this.Provider.displayName = "ReffuseContextReactProvider"
}
useContext(): Context.Context<R> {
return React.useContext(this.Context)
}
useLayer(): Layer.Layer<R> {
const context = this.useContext()
return React.useMemo(() => Layer.effectContext(Effect.succeed(context)), [context])
}
}
export type ReffuseContextReactProvider<R> = React.FC<{
readonly layer: Layer.Layer<R, unknown>
readonly children?: React.ReactNode
}>
export type R<T> = T extends ReffuseContext<infer R> ? R : never
export function make<R = never>() {
return new ReffuseContext<R>()
}
export function useMergeAll<T extends Array<unknown>>(
...contexts: [...{ [K in keyof T]: ReffuseContext<T[K]> }]
): Context.Context<T[number]> {
const values = contexts.map(v => React.use(v.Context))
return React.useMemo(() => Context.mergeAll(...values), values)
}
export function useMergeAllLayers<T extends Array.NonEmptyArray<unknown>>(
...contexts: [...{ [K in keyof T]: ReffuseContext<T[K]> }]
): Layer.Layer<T[number]> {
const values = Array.map(
contexts as Array.NonEmptyArray<ReffuseContext<T[number]>>,
v => React.use(v.Context),
)
return React.useMemo(() => Layer.mergeAll(
...Array.map(values, context => Layer.effectContext(Effect.succeed(context)))
), values)
}

View File

@@ -1,7 +0,0 @@
export interface ReffuseExtension<A extends object> {
(): A
readonly Type: A
}
export const make = <A extends object>(extension: () => A): ReffuseExtension<A> =>
extension as ReffuseExtension<A>

View File

@@ -1,646 +0,0 @@
import { type Context, Effect, ExecutionStrategy, Exit, type Fiber, Layer, Match, Option, pipe, Pipeable, PubSub, Ref, Runtime, Scope, Stream, SubscriptionRef } from "effect"
import * as React from "react"
import * as ReffuseContext from "./ReffuseContext.js"
import * as ReffuseRuntime from "./ReffuseRuntime.js"
import { type PropertyPath, SetStateAction, SubscriptionSubRef } from "./types/index.js"
export interface RenderOptions {
/** Prevents re-executing the effect when the Effect runtime or context changes. Defaults to `false`. */
readonly doNotReExecuteOnRuntimeOrContextChange?: boolean
}
export interface ScopeOptions {
readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy
}
export interface UseScopeOptions extends RenderOptions, ScopeOptions {
readonly scope?: Scope.Scope
readonly finalizerExecutionMode?: "sync" | "fork"
}
export type RefsA<T extends readonly SubscriptionRef.SubscriptionRef<any>[]> = {
[K in keyof T]: Effect.Effect.Success<T[K]>
}
export abstract class ReffuseNamespace<R> {
declare ["constructor"]: ReffuseNamespaceClass<R>
constructor() {
this.SubRefFromGetSet = this.SubRefFromGetSet.bind(this as any) as any
this.SubRefFromPath = this.SubRefFromPath.bind(this as any) as any
this.SubscribeRefs = this.SubscribeRefs.bind(this as any) as any
this.RefState = this.RefState.bind(this as any) as any
this.SubscribeStream = this.SubscribeStream.bind(this as any) as any
}
useContext<R>(this: ReffuseNamespace<R>): Context.Context<R> {
return ReffuseContext.useMergeAll(...this.constructor.contexts)
}
useLayer<R>(this: ReffuseNamespace<R>): Layer.Layer<R> {
return ReffuseContext.useMergeAllLayers(...this.constructor.contexts)
}
useRunSync<R>(this: ReffuseNamespace<R>): <A, E>(effect: Effect.Effect<A, E, R>) => A {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
return React.useCallback(effect => effect.pipe(
Effect.provide(context),
Runtime.runSync(runtime),
), [runtime, context])
}
useRunPromise<R>(this: ReffuseNamespace<R>): <A, E>(
effect: Effect.Effect<A, E, R>,
options?: { readonly signal?: AbortSignal },
) => Promise<A> {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
return React.useCallback((effect, options) => effect.pipe(
Effect.provide(context),
effect => Runtime.runPromise(runtime)(effect, options),
), [runtime, context])
}
useRunFork<R>(this: ReffuseNamespace<R>): <A, E>(
effect: Effect.Effect<A, E, R>,
options?: Runtime.RunForkOptions,
) => Fiber.RuntimeFiber<A, E> {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
return React.useCallback((effect, options) => effect.pipe(
Effect.provide(context),
effect => Runtime.runFork(runtime)(effect, options),
), [runtime, context])
}
useRunCallback<R>(this: ReffuseNamespace<R>): <A, E>(
effect: Effect.Effect<A, E, R>,
options?: Runtime.RunCallbackOptions<A, E>,
) => Runtime.Cancel<A, E> {
const runtime = ReffuseRuntime.useRuntime()
const context = this.useContext()
return React.useCallback((effect, options) => effect.pipe(
Effect.provide(context),
effect => Runtime.runCallback(runtime)(effect, options),
), [runtime, context])
}
useScope<R>(
this: ReffuseNamespace<R>,
deps: React.DependencyList = [],
options?: UseScopeOptions,
): readonly [scope: Scope.Scope, layer: Layer.Layer<Scope.Scope>] {
const runSync = this.useRunSync()
const runFork = this.useRunFork()
const makeScope = React.useMemo(() => options?.scope
? Scope.fork(options.scope, options.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
: Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential),
[options?.scope])
const closeScope = (scope: Scope.CloseableScope) => Scope.close(scope, Exit.void).pipe(
effect => Match.value(options?.finalizerExecutionMode ?? "sync").pipe(
Match.when("sync", () => { runSync(effect) }),
Match.when("fork", () => { runFork(effect) }),
Match.exhaustive,
)
)
const [isInitialRun, initialScope] = React.useMemo(() => runSync(
Effect.all([Ref.make(true), makeScope])
), [makeScope])
const [scope, setScope] = React.useState(initialScope)
React.useEffect(() => isInitialRun.pipe(
Effect.if({
onTrue: () => Effect.as(
Ref.set(isInitialRun, false),
() => closeScope(initialScope),
),
onFalse: () => makeScope.pipe(
Effect.tap(v => Effect.sync(() => setScope(v))),
Effect.map(v => () => closeScope(v)),
),
}),
runSync,
), [
makeScope,
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync, runFork],
...deps,
])
return React.useMemo(() => [scope, Layer.succeed(Scope.Scope, scope)] as const, [scope])
}
/**
* Reffuse equivalent to `React.useMemo`.
*
* `useMemo` will only recompute the memoized value by running the given synchronous effect when one of the deps has changed. \
* Trying to run an asynchronous effect will throw.
*
* Changes to the Reffuse runtime or context will recompute the value in addition to the deps.
* You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`.
*/
useMemo<A, E, R>(
this: ReffuseNamespace<R>,
effect: () => Effect.Effect<A, E, R>,
deps: React.DependencyList,
options?: RenderOptions,
): A {
const runSync = this.useRunSync()
return React.useMemo(() => runSync(effect()), [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
...deps,
])
}
/**
* Reffuse equivalent to `React.useEffect`.
*
* Executes a synchronous effect wrapped into a Scope when one of the deps has changed. Trying to run an asynchronous effect will throw.
*
* The Scope is closed on every cleanup, i.e. when one of the deps has changed and the effect needs to be re-executed. \
* Add finalizers to the Scope to handle cleanup logic.
*
* Changes to the Reffuse runtime or context will re-execute the effect in addition to the deps.
* You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`.
*
* ### Example
* ```
* useEffect(() => Effect.addFinalizer(() => Console.log("Component unmounted")).pipe(
* Effect.flatMap(() => Console.log("Component mounted"))
* ))
* ```
*
* Plain React equivalent:
* ```
* React.useEffect(() => {
* console.log("Component mounted")
* return () => { console.log("Component unmounted") }
* }, [])
* ```
*/
useEffect<A, E, R>(
this: ReffuseNamespace<R>,
effect: () => Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: RenderOptions & ScopeOptions,
): void {
const runSync = this.useRunSync()
React.useEffect(() => {
const scope = Scope.make(options?.finalizerExecutionStrategy).pipe(
Effect.tap(scope => Effect.provideService(effect(), Scope.Scope, scope)),
runSync,
)
return () => { runSync(Scope.close(scope, Exit.void)) }
}, deps && [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
...deps,
])
}
/**
* Reffuse equivalent to `React.useLayoutEffect`.
*
* Executes a synchronous effect wrapped into a Scope when one of the deps has changed. Fires synchronously after all DOM mutations. \
* Trying to run an asynchronous effect will throw.
*
* The Scope is closed on every cleanup, i.e. when one of the deps has changed and the effect needs to be re-executed. \
* Add finalizers to the Scope to handle cleanup logic.
*
* Changes to the Reffuse runtime or context will re-execute the effect in addition to the deps.
* You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`.
*
* ### Example
* ```
* useLayoutEffect(() => Effect.addFinalizer(() => Console.log("Component unmounted")).pipe(
* Effect.flatMap(() => Console.log("Component mounted"))
* ))
* ```
*
* Plain React equivalent:
* ```
* React.useLayoutEffect(() => {
* console.log("Component mounted")
* return () => { console.log("Component unmounted") }
* }, [])
* ```
*/
useLayoutEffect<A, E, R>(
this: ReffuseNamespace<R>,
effect: () => Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: RenderOptions & ScopeOptions,
): void {
const runSync = this.useRunSync()
React.useLayoutEffect(() => {
const scope = Scope.make(options?.finalizerExecutionStrategy).pipe(
Effect.tap(scope => Effect.provideService(effect(), Scope.Scope, scope)),
runSync,
)
return () => { runSync(Scope.close(scope, Exit.void)) }
}, deps && [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
...deps,
])
}
/**
* An asynchronous and non-blocking alternative to `React.useEffect`.
*
* Forks an effect wrapped into a Scope in the background when one of the deps has changed.
*
* The Scope is closed on every cleanup, i.e. when one of the deps has changed and the effect needs to be re-executed. \
* Add finalizers to the Scope to handle cleanup logic.
*
* Changes to the Reffuse runtime or context will re-execute the effect in addition to the deps.
* You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`.
*
* ### Example
* ```
* const timeRef = useRefFromEffect(DateTime.now)
*
* useFork(() => Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
* Effect.map(() => Stream.repeatEffectWithSchedule(
* DateTime.now,
* Schedule.intersect(Schedule.forever, Schedule.spaced("1 second")),
* )),
*
* Effect.flatMap(Stream.runForEach(time => Ref.set(timeRef, time)),
* )), [timeRef])
*
* const [time] = useRefState(timeRef)
* ```
*/
useFork<A, E, R>(
this: ReffuseNamespace<R>,
effect: () => Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: Runtime.RunForkOptions & RenderOptions & ScopeOptions,
): void {
const runSync = this.useRunSync()
const runFork = this.useRunFork()
React.useEffect(() => {
const scope = runSync(options?.scope
? Scope.fork(options.scope, options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
: Scope.make(options?.finalizerExecutionStrategy)
)
runFork(Effect.provideService(effect(), Scope.Scope, scope), { ...options, scope })
return () => { runFork(Scope.close(scope, Exit.void)) }
}, deps && [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
...deps,
])
}
usePromise<A, E, R>(
this: ReffuseNamespace<R>,
effect: () => Effect.Effect<A, E, R | Scope.Scope>,
deps?: React.DependencyList,
options?: { readonly signal?: AbortSignal } & Runtime.RunForkOptions & RenderOptions & ScopeOptions,
): Promise<A> {
const runSync = this.useRunSync()
const runFork = this.useRunFork()
const [value, setValue] = React.useState(Promise.withResolvers<A>().promise)
React.useEffect(() => {
const { promise, resolve, reject } = Promise.withResolvers<A>()
setValue(promise)
const scope = runSync(options?.scope
? Scope.fork(options.scope, options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
: Scope.make(options?.finalizerExecutionStrategy)
)
const cleanup = () => { runFork(Scope.close(scope, Exit.void)) }
if (options?.signal)
options.signal.addEventListener("abort", cleanup)
effect().pipe(
Effect.provideService(Scope.Scope, scope),
Effect.match({
onSuccess: resolve,
onFailure: reject,
}),
effect => runFork(effect, { ...options, scope }),
)
return () => {
if (options?.signal)
options.signal.removeEventListener("abort", cleanup)
cleanup()
}
}, deps && [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
...deps,
])
return value
}
useCallbackSync<Args extends unknown[], A, E, R>(
this: ReffuseNamespace<R>,
callback: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
options?: RenderOptions,
): (...args: Args) => A {
const runSync = this.useRunSync()
return React.useCallback((...args) => runSync(callback(...args)), [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
...deps,
])
}
useCallbackPromise<Args extends unknown[], A, E, R>(
this: ReffuseNamespace<R>,
callback: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
options?: { readonly signal?: AbortSignal } & RenderOptions,
): (...args: Args) => Promise<A> {
const runPromise = this.useRunPromise()
return React.useCallback((...args) => runPromise(callback(...args), options), [
...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runPromise],
...deps,
])
}
useRef<A, E, R>(
this: ReffuseNamespace<R>,
initialValue: () => Effect.Effect<A, E, R>,
): SubscriptionRef.SubscriptionRef<A> {
return this.useMemo(
() => Effect.flatMap(initialValue(), SubscriptionRef.make),
[],
{ doNotReExecuteOnRuntimeOrContextChange: true }, // Do not recreate the ref when the context changes
)
}
useRefFromReactiveValue<A, R>(
this: ReffuseNamespace<R>,
value: A,
): SubscriptionRef.SubscriptionRef<A> {
const ref = this.useRef(() => Effect.succeed(value))
this.useEffect(() => Ref.set(ref, value), [value], { doNotReExecuteOnRuntimeOrContextChange: true })
return ref
}
useSubRefFromGetSet<A, B, R>(
this: ReffuseNamespace<R>,
parent: SubscriptionRef.SubscriptionRef<B>,
getter: (parentValue: B) => A,
setter: (parentValue: B, value: A) => B,
): SubscriptionSubRef.SubscriptionSubRef<A, B> {
return React.useMemo(
() => SubscriptionSubRef.makeFromGetSet(parent, getter, setter),
[parent],
)
}
useSubRefFromPath<B, const P extends PropertyPath.Paths<B>, R>(
this: ReffuseNamespace<R>,
parent: SubscriptionRef.SubscriptionRef<B>,
path: P,
): SubscriptionSubRef.SubscriptionSubRef<PropertyPath.ValueFromPath<B, P>, B> {
return React.useMemo(
() => SubscriptionSubRef.makeFromPath(parent, path),
[parent],
)
}
useSubscribeRefs<
const Refs extends readonly SubscriptionRef.SubscriptionRef<any>[],
R,
>(
this: ReffuseNamespace<R>,
...refs: Refs
): RefsA<Refs> {
const [reactStateValue, setReactStateValue] = React.useState(this.useMemo(
() => Effect.all(refs as readonly SubscriptionRef.SubscriptionRef<any>[]),
[],
{ doNotReExecuteOnRuntimeOrContextChange: true },
) as RefsA<Refs>)
this.useFork(() => pipe(
refs.map(ref => Stream.changesWith(ref.changes, (x, y) => x === y)),
streams => Stream.zipLatestAll(...streams),
Stream.runForEach(v =>
Effect.sync(() => setReactStateValue(v as RefsA<Refs>))
),
), refs)
return reactStateValue
}
/**
* Binds the state of a `SubscriptionRef` to the state of the React component.
*
* Returns a [value, setter] tuple just like `React.useState` and triggers a re-render everytime the value held by the ref changes.
*
* Note that the rules of React's immutable state still apply: updating a ref with the same value will not trigger a re-render.
*/
useRefState<A, R>(
this: ReffuseNamespace<R>,
ref: SubscriptionRef.SubscriptionRef<A>,
): [A, React.Dispatch<React.SetStateAction<A>>] {
const [reactStateValue, setReactStateValue] = React.useState(this.useMemo(
() => ref,
[],
{ doNotReExecuteOnRuntimeOrContextChange: true },
))
this.useFork(() => Stream.runForEach(
Stream.changesWith(ref.changes, (x, y) => x === y),
v => Effect.sync(() => setReactStateValue(v)),
), [ref])
const setValue = this.useCallbackSync((setStateAction: React.SetStateAction<A>) =>
Ref.update(ref, prevState =>
SetStateAction.value(setStateAction, prevState)
),
[ref])
return [reactStateValue, setValue]
}
useStreamFromReactiveValues<const A extends React.DependencyList, R>(
this: ReffuseNamespace<R>,
values: A,
): Stream.Stream<A> {
const [, scopeLayer] = this.useScope([], { finalizerExecutionMode: "fork" })
const { latest, pubsub, stream } = this.useMemo(() => Effect.Do.pipe(
Effect.bind("latest", () => Ref.make(values)),
Effect.bind("pubsub", () => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown)),
Effect.let("stream", ({ latest, pubsub }) => Ref.get(latest).pipe(
Effect.flatMap(a => Effect.map(
Stream.fromPubSub(pubsub, { scoped: true }),
s => Stream.concat(Stream.make(a), s),
)),
Stream.unwrapScoped,
)),
Effect.provide(scopeLayer),
), [scopeLayer], { doNotReExecuteOnRuntimeOrContextChange: true })
this.useEffect(() => Ref.set(latest, values).pipe(
Effect.andThen(PubSub.publish(pubsub, values)),
Effect.unlessEffect(PubSub.isShutdown(pubsub)),
), values, { doNotReExecuteOnRuntimeOrContextChange: true })
return stream
}
useSubscribeStream<A, E, R>(
this: ReffuseNamespace<R>,
stream: Stream.Stream<A, E, R>,
): Option.Option<A>
useSubscribeStream<A, E, IE, R>(
this: ReffuseNamespace<R>,
stream: Stream.Stream<A, E, R>,
initialValue: () => Effect.Effect<A, IE, R>,
): Option.Some<A>
useSubscribeStream<A, E, IE, R>(
this: ReffuseNamespace<R>,
stream: Stream.Stream<A, E, R>,
initialValue?: () => Effect.Effect<A, IE, R>,
): Option.Option<A> {
const [reactStateValue, setReactStateValue] = React.useState(this.useMemo(
() => initialValue
? Effect.map(initialValue(), Option.some)
: Effect.succeed(Option.none()),
[],
{ doNotReExecuteOnRuntimeOrContextChange: true },
))
this.useFork(() => Stream.runForEach(
Stream.changesWith(stream, (x, y) => x === y),
v => Effect.sync(() => setReactStateValue(Option.some(v))),
), [stream])
return reactStateValue
}
SubRefFromGetSet<A, B, R>(
this: ReffuseNamespace<R>,
props: {
readonly parent: SubscriptionRef.SubscriptionRef<B>,
readonly getter: (parentValue: B) => A,
readonly setter: (parentValue: B, value: A) => B,
readonly children: (subRef: SubscriptionSubRef.SubscriptionSubRef<A, B>) => React.ReactNode
},
): React.ReactNode {
return props.children(this.useSubRefFromGetSet(props.parent, props.getter, props.setter))
}
SubRefFromPath<B, const P extends PropertyPath.Paths<B>, R>(
this: ReffuseNamespace<R>,
props: {
readonly parent: SubscriptionRef.SubscriptionRef<B>,
readonly path: P,
readonly children: (subRef: SubscriptionSubRef.SubscriptionSubRef<PropertyPath.ValueFromPath<B, P>, B>) => React.ReactNode
},
): React.ReactNode {
return props.children(this.useSubRefFromPath(props.parent, props.path))
}
SubscribeRefs<
const Refs extends readonly SubscriptionRef.SubscriptionRef<any>[],
R,
>(
this: ReffuseNamespace<R>,
props: {
readonly refs: Refs
readonly children: (...args: RefsA<Refs>) => React.ReactNode
},
): React.ReactNode {
return props.children(...this.useSubscribeRefs(...props.refs))
}
RefState<A, R>(
this: ReffuseNamespace<R>,
props: {
readonly ref: SubscriptionRef.SubscriptionRef<A>
readonly children: (state: [A, React.Dispatch<React.SetStateAction<A>>]) => React.ReactNode
},
): React.ReactNode {
return props.children(this.useRefState(props.ref))
}
SubscribeStream<A, E, R>(
this: ReffuseNamespace<R>,
props: {
readonly stream: Stream.Stream<A, E, R>
readonly children: (latestValue: Option.Option<A>) => React.ReactNode
},
): React.ReactNode
SubscribeStream<A, E, IE, R>(
this: ReffuseNamespace<R>,
props: {
readonly stream: Stream.Stream<A, E, R>
readonly initialValue: () => Effect.Effect<A, IE, R>
readonly children: (latestValue: Option.Some<A>) => React.ReactNode
},
): React.ReactNode
SubscribeStream<A, E, IE, R>(
this: ReffuseNamespace<R>,
props: {
readonly stream: Stream.Stream<A, E, R>
readonly initialValue?: () => Effect.Effect<A, IE, R>
readonly children: (latestValue: Option.Some<A>) => React.ReactNode
},
): React.ReactNode {
return props.children(this.useSubscribeStream(props.stream, props.initialValue as () => Effect.Effect<A, IE, R>))
}
}
export interface ReffuseNamespace<R> extends Pipeable.Pipeable {}
ReffuseNamespace.prototype.pipe = function pipe() {
return Pipeable.pipeArguments(this, arguments)
};
export interface ReffuseNamespaceClass<R> extends Pipeable.Pipeable {
new(): ReffuseNamespace<R>
make<Self>(this: new () => Self): Self
readonly contexts: readonly ReffuseContext.ReffuseContext<R>[]
}
(ReffuseNamespace as ReffuseNamespaceClass<any>).make = function make() {
return new this()
};
(ReffuseNamespace as ReffuseNamespaceClass<any>).pipe = function pipe() {
return Pipeable.pipeArguments(this, arguments)
};
export const makeClass = (): ReffuseNamespaceClass<never> => (
class extends (ReffuseNamespace<never> as ReffuseNamespaceClass<never>) {
static readonly contexts = []
}
)

View File

@@ -1,16 +0,0 @@
import { Runtime } from "effect"
import * as React from "react"
export const Context = React.createContext<Runtime.Runtime<never>>(null!)
export const Provider = function ReffuseRuntimeReactProvider(props: {
readonly children?: React.ReactNode
}) {
return React.createElement(Context, {
...props,
value: Runtime.defaultRuntime,
})
}
export const useRuntime = () => React.useContext(Context)

View File

@@ -0,0 +1,15 @@
import { Runtime } from "effect"
import * as React from "react"
export const Context = React.createContext<Runtime.Runtime<never>>(null!)
export const Provider = (props: { readonly children?: React.ReactNode }) => (
<Context
{...props}
value={Runtime.defaultRuntime}
/>
)
Provider.displayName = "ReffuseRuntimeReactProvider"
export const useRuntime = () => React.useContext(Context)

View File

@@ -1,5 +1,4 @@
export * as Reffuse from "./Reffuse.js" export * as Reffuse from "./Reffuse.js"
export * as ReffuseContext from "./ReffuseContext.js" export * as ReffuseContext from "./ReffuseContext.js"
export * as ReffuseExtension from "./ReffuseExtension.js"
export * as ReffuseNamespace from "./ReffuseNamespace.js"
export * as ReffuseRuntime from "./ReffuseRuntime.js" export * as ReffuseRuntime from "./ReffuseRuntime.js"
export * as SetStateAction from "./SetStateAction.js"

View File

@@ -1,99 +0,0 @@
import { Array, Function, Option, Predicate } from "effect"
type Prev = readonly [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
export type Paths<T, D extends number = 5, Seen = never> = [] | (
D extends never ? [] :
T extends Seen ? [] :
T extends readonly any[] ? ArrayPaths<T, D, Seen | T> :
T extends object ? ObjectPaths<T, D, Seen | T> :
never
)
export type ArrayPaths<T extends readonly any[], D extends number, Seen> = {
[K in keyof T as K extends number ? K : never]:
| [K]
| [K, ...Paths<T[K], Prev[D], Seen>]
} extends infer O
? O[keyof O]
: never
export type ObjectPaths<T extends object, D extends number, Seen> = {
[K in keyof T as K extends string | number | symbol ? K : never]-?:
NonNullable<T[K]> extends infer V
? [K] | [K, ...Paths<V, Prev[D], Seen>]
: never
} extends infer O
? O[keyof O]
: never
export type ValueFromPath<T, P extends any[]> = P extends [infer Head, ...infer Tail]
? Head extends keyof T
? ValueFromPath<T[Head], Tail>
: T extends readonly any[]
? Head extends number
? ValueFromPath<T[number], Tail>
: never
: never
: T
export type AnyKey = string | number | symbol
export type AnyPath = readonly AnyKey[]
export const unsafeGet: {
<T, const P extends Paths<T>>(path: P): (self: T) => ValueFromPath<T, P>
<T, const P extends Paths<T>>(self: T, path: P): ValueFromPath<T, P>
} = Function.dual(2, <T, const P extends Paths<T>>(self: T, path: P): ValueFromPath<T, P> =>
path.reduce((acc: any, key: any) => acc?.[key], self)
)
export const get: {
<T, const P extends Paths<T>>(path: P): (self: T) => Option.Option<ValueFromPath<T, P>>
<T, const P extends Paths<T>>(self: T, path: P): Option.Option<ValueFromPath<T, P>>
} = Function.dual(2, <T, const P extends Paths<T>>(self: T, path: P): Option.Option<ValueFromPath<T, P>> =>
path.reduce(
(acc: Option.Option<any>, key: any): Option.Option<any> => Option.isSome(acc)
? Predicate.hasProperty(acc.value, key)
? Option.some(acc.value[key])
: Option.none()
: acc,
Option.some(self),
)
)
export const immutableSet: {
<T, const P extends Paths<T>>(path: P, value: ValueFromPath<T, P>): (self: T) => ValueFromPath<T, P>
<T, const P extends Paths<T>>(self: T, path: P, value: ValueFromPath<T, P>): Option.Option<T>
} = Function.dual(3, <T, const P extends Paths<T>>(self: T, path: P, value: ValueFromPath<T, P>): Option.Option<T> => {
const key = Array.head(path as AnyPath)
if (Option.isNone(key))
return Option.some(value as T)
if (!Predicate.hasProperty(self, key.value))
return Option.none()
const child = immutableSet<any, any>(self[key.value], Option.getOrThrow(Array.tail(path as AnyPath)), value)
if (Option.isNone(child))
return child
if (Array.isArray(self))
return typeof key.value === "number"
? Option.some([
...self.slice(0, key.value),
child.value,
...self.slice(key.value + 1),
] as T)
: Option.none()
if (typeof self === "object")
return Option.some(
Object.assign(
Object.create(Object.getPrototypeOf(self)),
{ ...self, [key.value]: child.value },
)
)
return Option.none()
})

View File

@@ -1,100 +0,0 @@
import { Effect, Effectable, Option, Readable, Ref, Stream, Subscribable, SubscriptionRef, SynchronizedRef, type Types, type Unify } from "effect"
import * as PropertyPath from "./PropertyPath.js"
export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("reffuse/types/SubscriptionSubRef")
export type SubscriptionSubRefTypeId = typeof SubscriptionSubRefTypeId
export interface SubscriptionSubRef<in out A, in out B> extends SubscriptionSubRef.Variance<A, B>, SubscriptionRef.SubscriptionRef<A> {
readonly parent: SubscriptionRef.SubscriptionRef<B>
readonly [Unify.typeSymbol]?: unknown
readonly [Unify.unifySymbol]?: SubscriptionSubRefUnify<this>
readonly [Unify.ignoreSymbol]?: SubscriptionSubRefUnifyIgnore
}
export declare namespace SubscriptionSubRef {
export interface Variance<in out A, in out B> {
readonly [SubscriptionSubRefTypeId]: {
readonly _A: Types.Invariant<A>
readonly _B: Types.Invariant<B>
}
}
}
export interface SubscriptionSubRefUnify<A extends { [Unify.typeSymbol]?: any }> extends SubscriptionRef.SubscriptionRefUnify<A> {
SubscriptionSubRef?: () => Extract<A[Unify.typeSymbol], SubscriptionSubRef<any, any>>
}
export interface SubscriptionSubRefUnifyIgnore extends SubscriptionRef.SubscriptionRefUnifyIgnore {
SubscriptionRef?: true
}
const refVariance = { _A: (_: any) => _ }
const synchronizedRefVariance = { _A: (_: any) => _ }
const subscriptionRefVariance = { _A: (_: any) => _ }
const subscriptionSubRefVariance = { _A: (_: any) => _, _B: (_: any) => _ }
class SubscriptionSubRefImpl<in out A, in out B> extends Effectable.Class<A> implements SubscriptionSubRef<A, B> {
readonly [Readable.TypeId]: Readable.TypeId = Readable.TypeId
readonly [Subscribable.TypeId]: Subscribable.TypeId = Subscribable.TypeId
readonly [Ref.RefTypeId] = refVariance
readonly [SynchronizedRef.SynchronizedRefTypeId] = synchronizedRefVariance
readonly [SubscriptionRef.SubscriptionRefTypeId] = subscriptionRefVariance
readonly [SubscriptionSubRefTypeId] = subscriptionSubRefVariance
readonly get: Effect.Effect<A>
constructor(
readonly parent: SubscriptionRef.SubscriptionRef<B>,
readonly getter: (parentValue: B) => A,
readonly setter: (parentValue: B, value: A) => B,
) {
super()
this.get = Effect.map(Ref.get(this.parent), this.getter)
}
commit() {
return this.get
}
get changes(): Stream.Stream<A> {
return this.get.pipe(
Effect.map(a => this.parent.changes.pipe(
Stream.map(this.getter),
s => Stream.concat(Stream.make(a), s),
)),
Stream.unwrap,
)
}
modify<C>(f: (a: A) => readonly [C, A]): Effect.Effect<C> {
return this.modifyEffect(a => Effect.succeed(f(a)))
}
modifyEffect<C, E, R>(f: (a: A) => Effect.Effect<readonly [C, A], E, R>): Effect.Effect<C, E, R> {
return Effect.Do.pipe(
Effect.bind("b", () => Ref.get(this.parent)),
Effect.bind("ca", ({ b }) => f(this.getter(b))),
Effect.tap(({ b, ca: [, a] }) => Ref.set(this.parent, this.setter(b, a))),
Effect.map(({ ca: [c] }) => c),
)
}
}
export const makeFromGetSet = <A, B>(
parent: SubscriptionRef.SubscriptionRef<B>,
getter: (parentValue: B) => A,
setter: (parentValue: B, value: A) => B,
): SubscriptionSubRef<A, B> => new SubscriptionSubRefImpl(parent, getter, setter)
export const makeFromPath = <B, const P extends PropertyPath.Paths<B>>(
parent: SubscriptionRef.SubscriptionRef<B>,
path: P,
): SubscriptionSubRef<PropertyPath.ValueFromPath<B, P>, B> => new SubscriptionSubRefImpl(
parent,
parentValue => Option.getOrThrow(PropertyPath.get(parentValue, path)),
(parentValue, value) => Option.getOrThrow(PropertyPath.immutableSet(parentValue, path, value)),
)

View File

@@ -1,3 +0,0 @@
export * as PropertyPath from "./PropertyPath.js"
export * as SetStateAction from "./SetStateAction.js"
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"

View File

@@ -1,11 +0,0 @@
/**
* Extracts the common keys between two types
*/
export type CommonKeys<A, B> = Extract<keyof A, keyof B>
/**
* Obtain the static members type of a constructor function type
*/
export type StaticType<T extends abstract new (...args: any) => any> = Omit<T, "prototype">
export type Merge<Super, Self> = Omit<Super, CommonKeys<Self, Super>> & Self

View File

@@ -1,11 +0,0 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["./src/**"],
"outputs": ["./dist/**"]
},
"pack": {}
}
}