Compare commits
21 Commits
result
...
5fb22ad183
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fb22ad183 | ||
|
|
8e4bcfe59d | ||
|
|
c158cdef19 | ||
|
|
fa90d9438b | ||
|
|
f8b356ef39 | ||
|
|
d38a5a4afd | ||
|
|
53bceb3a8a | ||
|
|
d3afca85da | ||
|
|
9cfc7072df | ||
|
|
bb9c41deae | ||
|
|
41b1396a58 | ||
|
|
b2b002852c | ||
|
|
ec8f9f2ddb | ||
|
|
55ca8a0dd4 | ||
|
|
6b39671d60 | ||
|
|
bada57a591 | ||
|
|
09ed773b96 | ||
|
|
956a532195 | ||
|
|
1c1659e82c | ||
|
|
051226ebd4 | ||
|
|
35463d5607 |
@@ -7,12 +7,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v1
|
||||||
- name: Clone repo
|
- name: Clone repo
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Lint TypeScript
|
- name: Build
|
||||||
run: bun lint:tsc
|
run: bun run build
|
||||||
- name: Lint Biome
|
|
||||||
run: bun lint:biome
|
|
||||||
|
|||||||
@@ -10,19 +10,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v1
|
||||||
- name: Clone repo
|
- name: Clone repo
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Lint TypeScript
|
|
||||||
run: bun lint:tsc
|
|
||||||
- name: Lint Biome
|
|
||||||
run: bun lint:biome
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build
|
||||||
- name: Publish effect-fc
|
- name: Publish effect-fc
|
||||||
uses: JS-DevTools/npm-publish@v4
|
uses: JS-DevTools/npm-publish@v3
|
||||||
with:
|
with:
|
||||||
package: packages/effect-fc
|
package: packages/effect-fc
|
||||||
access: public
|
access: public
|
||||||
|
|||||||
@@ -8,20 +8,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v1
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "20"
|
||||||
- name: Clone repo
|
- name: Clone repo
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Lint TypeScript
|
|
||||||
run: bun lint:tsc
|
|
||||||
- name: Lint Biome
|
|
||||||
run: bun lint:biome
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build
|
||||||
- name: Pack
|
- name: Pack
|
||||||
run: bun pack
|
run: bun run pack
|
||||||
|
|||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@thilawyn:registry=https://git.valverde.cloud/api/packages/thilawyn/npm/
|
||||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,6 +1,3 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll.biome": "explicit"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Effect FC Monorepo
|
# Effect FC Monorepo
|
||||||
|
|
||||||
[Effect-TS](https://effect.website/) integration for React 19.2+ that allows you to write function components using Effect generators.
|
[Effect-TS](https://effect.website/) integration for React 19+ that allows you to write function components using Effect generators.
|
||||||
|
|
||||||
This monorepo contains:
|
This monorepo contains:
|
||||||
- [The `effect-fc` library](packages/effect-fc)
|
- [The `effect-fc` library](packages/effect-fc)
|
||||||
- [An example project](packages/example)
|
- [An example project](packges/example)
|
||||||
|
|||||||
40
biome.json
40
biome.json
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
|
||||||
"vcs": {
|
|
||||||
"enabled": false,
|
|
||||||
"clientKind": "git",
|
|
||||||
"useIgnoreFile": false
|
|
||||||
},
|
|
||||||
"files": {
|
|
||||||
"ignoreUnknown": false
|
|
||||||
},
|
|
||||||
"formatter": {
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
"linter": {
|
|
||||||
"enabled": true,
|
|
||||||
"rules": {
|
|
||||||
"recommended": true,
|
|
||||||
"style": {
|
|
||||||
"useShorthandFunctionType": "off"
|
|
||||||
},
|
|
||||||
"suspicious": {
|
|
||||||
"noExplicitAny": "off",
|
|
||||||
"noShadowRestrictedNames": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"javascript": {
|
|
||||||
"formatter": {
|
|
||||||
"quoteStyle": "double"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"assist": {
|
|
||||||
"enabled": true,
|
|
||||||
"actions": {
|
|
||||||
"source": {
|
|
||||||
"organizeImports": "on"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[install.scopes]
|
||||||
|
"@thilawyn" = "https://git.valverde.cloud/api/packages/thilawyn/npm/"
|
||||||
23
package.json
23
package.json
@@ -1,26 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "@effect-fc/monorepo",
|
"name": "@effect-fc/monorepo",
|
||||||
"packageManager": "bun@1.3.6",
|
"packageManager": "bun@1.2.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./packages/*"
|
"./packages/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build --filter=!@effect-fc/example",
|
||||||
"lint:tsc": "turbo lint:tsc",
|
"lint:tsc": "turbo lint:tsc",
|
||||||
"lint:biome": "turbo lint:biome",
|
"pack": "turbo pack --filter=!@effect-fc/example",
|
||||||
"pack": "turbo pack",
|
"publish": "turbo publish --filter=!@effect-fc/example",
|
||||||
"clean:cache": "turbo clean:cache",
|
"clean:cache": "rm -f tsconfig.tsbuildinfo",
|
||||||
"clean:dist": "turbo clean:dist",
|
"clean:dist": "rm -rf dist",
|
||||||
"clean:modules": "turbo clean:modules && rm -rf node_modules"
|
"clean:node": "rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.11",
|
"npm-check-updates": "^18.0.1",
|
||||||
"@effect/language-service": "^0.75.0",
|
|
||||||
"@types/bun": "^1.3.6",
|
|
||||||
"npm-check-updates": "^19.3.1",
|
|
||||||
"npm-sort": "^0.0.4",
|
"npm-sort": "^0.0.4",
|
||||||
"turbo": "^2.7.5",
|
"turbo": "^2.5.3",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,35 @@
|
|||||||
# Effect FC
|
# Effect FC
|
||||||
|
|
||||||
[Effect-TS](https://effect.website/) integration for React 19.2+ that allows you to write function components using Effect generators.
|
[Effect-TS](https://effect.website/) integration for React 19+ that allows you to write function components using Effect generators.
|
||||||
|
|
||||||
This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome.
|
This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome.
|
||||||
|
|
||||||
Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
|
Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
|
||||||
|
|
||||||
## Peer dependencies
|
## Peer dependencies
|
||||||
- `effect` 3.19+
|
- `effect` 3.15+
|
||||||
- `react` & `@types/react` 19.2+
|
- `react` & `@types/react` 19+
|
||||||
|
|
||||||
## Known issues
|
## Known issues
|
||||||
- React Refresh doesn't work for Effect FC's yet. Page reload is required to view changes. Regular React components are unaffected.
|
- React Refresh replacement doesn't work for Effect FC's yet. Page reload is required to view changes. Regular React components are unaffected.
|
||||||
|
|
||||||
## What writing components looks like
|
## What writing components looks like
|
||||||
```typescript
|
```typescript
|
||||||
export class Todos extends Component.make("Todos")(function*() {
|
import { Component, Hook, Memoized } from "effect-fc"
|
||||||
const state = yield* TodosState
|
import { Todo } from "./Todo"
|
||||||
const [todos] = yield* useSubscribables(state.ref)
|
import { TodosState } from "./TodosState.service"
|
||||||
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
yield* useOnMount(() => Effect.andThen(
|
class Todos extends Component.make(function* Todos() {
|
||||||
|
const state = yield* TodosState
|
||||||
|
const [todos] = yield* Hook.useSubscribeRefs(state.ref)
|
||||||
|
|
||||||
|
yield* Hook.useOnce(() => Effect.andThen(
|
||||||
Console.log("Todos mounted"),
|
Console.log("Todos mounted"),
|
||||||
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
||||||
))
|
))
|
||||||
|
|
||||||
const TodoFC = yield* Todo
|
const TodoFC = yield* Component.useFC(Todo)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
@@ -33,26 +38,22 @@ export class Todos extends Component.make("Todos")(function*() {
|
|||||||
<Flex direction="column" align="stretch" gap="2" mt="2">
|
<Flex direction="column" align="stretch" gap="2" mt="2">
|
||||||
<TodoFC _tag="new" />
|
<TodoFC _tag="new" />
|
||||||
|
|
||||||
{Chunk.map(todos, todo =>
|
{Chunk.map(todos, (v, k) =>
|
||||||
<TodoFC key={todo.id} _tag="edit" id={todo.id} />
|
<TodoFC key={v.id} _tag="edit" index={k} />
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}) {}
|
}).pipe(
|
||||||
|
Memoized.memo
|
||||||
|
) {}
|
||||||
|
|
||||||
const TodosStateLive = TodosState.Default("todos")
|
const TodosEntrypoint = Component.make(function* TodosEntrypoint() {
|
||||||
|
const context = yield* Hook.useContext(TodosState.Default, { finalizerExecutionMode: "fork" })
|
||||||
const Index = Component.make("Index")(function*() {
|
const TodosFC = yield* Effect.provide(Component.useFC(Todos), context)
|
||||||
const context = yield* useContext(TodosStateLive)
|
|
||||||
const TodosFC = yield* Effect.provide(Todos, context)
|
|
||||||
|
|
||||||
return <TodosFC />
|
return <TodosFC />
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Component.withRuntime(runtime.context)
|
Component.withRuntime(runtime.context)
|
||||||
)
|
)
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
|
||||||
component: Index
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
|
||||||
"root": false,
|
|
||||||
"extends": "//",
|
|
||||||
"files": {
|
|
||||||
"includes": ["./src/**"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "effect-fc",
|
"name": "effect-fc",
|
||||||
"description": "Write React function components with Effect",
|
"description": "Write React function components with Effect",
|
||||||
"version": "0.2.3",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"./README.md",
|
"./README.md",
|
||||||
@@ -17,33 +17,33 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
},
|
},
|
||||||
"./*": [
|
"./hooks": {
|
||||||
{
|
"types": "./dist/hooks/index.d.ts",
|
||||||
"types": "./dist/*/index.d.ts",
|
"default": "./dist/hooks/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"
|
||||||
}
|
}
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint:tsc": "tsc --noEmit",
|
"lint:tsc": "tsc --noEmit",
|
||||||
"lint:biome": "biome lint",
|
|
||||||
"pack": "npm pack",
|
"pack": "npm pack",
|
||||||
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
|
"clean:cache": "rm -f tsconfig.tsbuildinfo",
|
||||||
"clean:dist": "rm -rf dist",
|
"clean:dist": "rm -rf dist",
|
||||||
"clean:modules": "rm -rf node_modules"
|
"clean:node": "rm -rf node_modules"
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@effect/platform-browser": "^0.74.0"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@effect-atom/atom": "^0.5.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react": "^19.2.0",
|
"effect": "^3.15.0",
|
||||||
"effect": "^3.19.0",
|
"react": "^19.0.0"
|
||||||
"react": "^19.2.0"
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@effect/language-service": "^0.23.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
|
||||||
import { Effect, Function, Predicate, Runtime, Scope } from "effect"
|
|
||||||
import * as React from "react"
|
|
||||||
import * as Component from "./Component.js"
|
|
||||||
|
|
||||||
|
|
||||||
export const TypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
|
|
||||||
export type TypeId = typeof TypeId
|
|
||||||
|
|
||||||
export interface Async extends AsyncOptions {
|
|
||||||
readonly [TypeId]: TypeId
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AsyncOptions {
|
|
||||||
readonly defaultFallback?: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AsyncProps = Omit<React.SuspenseProps, "children">
|
|
||||||
|
|
||||||
|
|
||||||
export const AsyncPrototype = Object.freeze({
|
|
||||||
[TypeId]: TypeId,
|
|
||||||
|
|
||||||
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
|
|
||||||
this: Component.Component<P, A, E, R> & Async,
|
|
||||||
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
|
|
||||||
) {
|
|
||||||
const SuspenseInner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
|
|
||||||
|
|
||||||
return ({ fallback, name, ...props }: AsyncProps) => {
|
|
||||||
const promise = Runtime.runPromise(runtimeRef.current)(
|
|
||||||
Effect.andThen(
|
|
||||||
Component.useScope([], this),
|
|
||||||
scope => Effect.provideService(this.body(props as P), Scope.Scope, scope),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return React.createElement(
|
|
||||||
React.Suspense,
|
|
||||||
{ fallback: fallback ?? this.defaultFallback, name },
|
|
||||||
React.createElement(SuspenseInner, { promise }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
} as const)
|
|
||||||
|
|
||||||
|
|
||||||
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, TypeId)
|
|
||||||
|
|
||||||
export const async = <T extends Component.Component<any, any, any, any>>(
|
|
||||||
self: T
|
|
||||||
): (
|
|
||||||
& Omit<T, keyof Component.Component.AsComponent<T>>
|
|
||||||
& Component.Component<
|
|
||||||
Component.Component.Props<T> & AsyncProps,
|
|
||||||
Component.Component.Success<T>,
|
|
||||||
Component.Component.Error<T>,
|
|
||||||
Component.Component.Context<T>
|
|
||||||
>
|
|
||||||
& Async
|
|
||||||
) => Object.setPrototypeOf(
|
|
||||||
Object.assign(function() {}, self),
|
|
||||||
Object.freeze(Object.setPrototypeOf(
|
|
||||||
Object.assign({}, AsyncPrototype),
|
|
||||||
Object.getPrototypeOf(self),
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const withOptions: {
|
|
||||||
<T extends Component.Component<any, any, any, any> & Async>(
|
|
||||||
options: Partial<AsyncOptions>
|
|
||||||
): (self: T) => T
|
|
||||||
<T extends Component.Component<any, any, any, any> & Async>(
|
|
||||||
self: T,
|
|
||||||
options: Partial<AsyncOptions>,
|
|
||||||
): T
|
|
||||||
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Async>(
|
|
||||||
self: T,
|
|
||||||
options: Partial<AsyncOptions>,
|
|
||||||
): T => Object.setPrototypeOf(
|
|
||||||
Object.assign(function() {}, self, options),
|
|
||||||
Object.getPrototypeOf(self),
|
|
||||||
))
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,62 +0,0 @@
|
|||||||
import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect"
|
|
||||||
|
|
||||||
|
|
||||||
export const TypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
|
|
||||||
export type TypeId = typeof TypeId
|
|
||||||
|
|
||||||
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
|
|
||||||
readonly [TypeId]: TypeId
|
|
||||||
handle<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
|
|
||||||
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ErrorObserver = <E = never>(): Context.Tag<ErrorObserver, ErrorObserver<E>> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver")
|
|
||||||
|
|
||||||
class ErrorObserverImpl<in out E = never>
|
|
||||||
extends Pipeable.Class() implements ErrorObserver<E> {
|
|
||||||
readonly [TypeId]: TypeId = TypeId
|
|
||||||
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly pubsub: PubSub.PubSub<Cause.Cause<E>>
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
this.subscribe = pubsub.subscribe
|
|
||||||
}
|
|
||||||
|
|
||||||
handle<A, EffE, R>(effect: Effect.Effect<A, EffE, R>): Effect.Effect<A, EffE, R> {
|
|
||||||
return Effect.tapErrorCause(effect, cause => PubSub.publish(this.pubsub, cause as Cause.Cause<E>))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
|
|
||||||
readonly value = Effect.void
|
|
||||||
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
onEnd<A, E>(_value: Exit.Exit<A, E>): void {
|
|
||||||
if (Exit.isFailure(_value)) {
|
|
||||||
Effect.runSync(PubSub.publish(this.pubsub, _value.cause as Cause.Cause<never>))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, TypeId)
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<ErrorObserver> = Layer.unwrapEffect(Effect.map(
|
|
||||||
PubSub.unbounded<Cause.Cause<never>>(),
|
|
||||||
pubsub => Layer.merge(
|
|
||||||
Supervisor.addSupervisor(new ErrorObserverSupervisorImpl(pubsub)),
|
|
||||||
Layer.succeed(ErrorObserver(), new ErrorObserverImpl(pubsub)),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
|
|
||||||
export const handle = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => Effect.andThen(
|
|
||||||
Effect.serviceOption(ErrorObserver()),
|
|
||||||
Option.match({
|
|
||||||
onSome: observer => observer.handle(effect),
|
|
||||||
onNone: () => effect,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
import { Array, Cause, Chunk, type Context, type Duration, Effect, Equal, Exit, Fiber, flow, Hash, HashMap, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect"
|
|
||||||
import type * as React from "react"
|
|
||||||
import * as Component from "./Component.js"
|
|
||||||
import * as Mutation from "./Mutation.js"
|
|
||||||
import * as PropertyPath from "./PropertyPath.js"
|
|
||||||
import * as Result from "./Result.js"
|
|
||||||
import * as Subscribable from "./Subscribable.js"
|
|
||||||
import * as SubscriptionRef from "./SubscriptionRef.js"
|
|
||||||
import * as SubscriptionSubRef from "./SubscriptionSubRef.js"
|
|
||||||
|
|
||||||
|
|
||||||
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
|
|
||||||
export type FormTypeId = typeof FormTypeId
|
|
||||||
|
|
||||||
export interface Form<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
|
||||||
extends Pipeable.Pipeable {
|
|
||||||
readonly [FormTypeId]: FormTypeId
|
|
||||||
|
|
||||||
readonly schema: Schema.Schema<A, I, R>
|
|
||||||
readonly context: Context.Context<Scope.Scope | R>
|
|
||||||
readonly mutation: Mutation.Mutation<
|
|
||||||
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>],
|
|
||||||
MA, ME, MR, MP
|
|
||||||
>
|
|
||||||
readonly autosubmit: boolean
|
|
||||||
readonly debounce: Option.Option<Duration.DurationInput>
|
|
||||||
|
|
||||||
readonly value: Subscribable.Subscribable<Option.Option<A>>
|
|
||||||
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
|
|
||||||
readonly error: Subscribable.Subscribable<Option.Option<ParseResult.ParseError>>
|
|
||||||
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>
|
|
||||||
|
|
||||||
readonly canSubmit: Subscribable.Subscribable<boolean>
|
|
||||||
|
|
||||||
field<const P extends PropertyPath.Paths<I>>(
|
|
||||||
path: P
|
|
||||||
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>>
|
|
||||||
|
|
||||||
readonly run: Effect.Effect<void>
|
|
||||||
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FormImpl<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
|
||||||
extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
|
|
||||||
readonly [FormTypeId]: FormTypeId = FormTypeId
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly schema: Schema.Schema<A, I, R>,
|
|
||||||
readonly context: Context.Context<Scope.Scope | R>,
|
|
||||||
readonly mutation: Mutation.Mutation<
|
|
||||||
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>],
|
|
||||||
MA, ME, MR, MP
|
|
||||||
>,
|
|
||||||
readonly autosubmit: boolean,
|
|
||||||
readonly debounce: Option.Option<Duration.DurationInput>,
|
|
||||||
|
|
||||||
readonly value: SubscriptionRef.SubscriptionRef<Option.Option<A>>,
|
|
||||||
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
|
|
||||||
readonly error: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>,
|
|
||||||
readonly validationFiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>,
|
|
||||||
|
|
||||||
readonly runSemaphore: Effect.Semaphore,
|
|
||||||
readonly fieldCache: Ref.Ref<HashMap.HashMap<FormFieldKey, FormField<unknown, unknown>>>,
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
|
|
||||||
this.canSubmit = Subscribable.map(
|
|
||||||
Subscribable.zipLatestAll(this.value, this.error, this.validationFiber, this.mutation.result),
|
|
||||||
([value, error, validationFiber, result]) => (
|
|
||||||
Option.isSome(value) &&
|
|
||||||
Option.isNone(error) &&
|
|
||||||
Option.isNone(validationFiber) &&
|
|
||||||
!(Result.isRunning(result) || Result.hasRefreshingFlag(result))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
field<const P extends PropertyPath.Paths<I>>(
|
|
||||||
path: P
|
|
||||||
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>> {
|
|
||||||
const key = new FormFieldKey(path)
|
|
||||||
return this.fieldCache.pipe(
|
|
||||||
Effect.map(HashMap.get(key)),
|
|
||||||
Effect.flatMap(Option.match({
|
|
||||||
onSome: v => Effect.succeed(v as FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>),
|
|
||||||
onNone: () => Effect.tap(
|
|
||||||
Effect.succeed(makeFormField(this as Form<A, I, R, MA, ME, MR, MP>, path)),
|
|
||||||
v => Ref.update(this.fieldCache, HashMap.set(key, v as FormField<unknown, unknown>)),
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly canSubmit: Subscribable.Subscribable<boolean>
|
|
||||||
|
|
||||||
get run(): Effect.Effect<void> {
|
|
||||||
return this.runSemaphore.withPermits(1)(Stream.runForEach(
|
|
||||||
this.encodedValue.changes.pipe(
|
|
||||||
Option.isSome(this.debounce) ? Stream.debounce(this.debounce.value) : identity
|
|
||||||
),
|
|
||||||
|
|
||||||
encodedValue => this.validationFiber.pipe(
|
|
||||||
Effect.andThen(Option.match({
|
|
||||||
onSome: Fiber.interrupt,
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
})),
|
|
||||||
Effect.andThen(
|
|
||||||
Effect.forkScoped(Effect.onExit(
|
|
||||||
Schema.decode(this.schema, { errors: "all" })(encodedValue),
|
|
||||||
exit => Effect.andThen(
|
|
||||||
Exit.matchEffect(exit, {
|
|
||||||
onSuccess: v => Effect.andThen(
|
|
||||||
Ref.set(this.value, Option.some(v)),
|
|
||||||
Ref.set(this.error, Option.none()),
|
|
||||||
),
|
|
||||||
onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), {
|
|
||||||
onSome: e => Ref.set(this.error, Option.some(e)),
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
Ref.set(this.validationFiber, Option.none()),
|
|
||||||
),
|
|
||||||
)).pipe(
|
|
||||||
Effect.tap(fiber => Ref.set(this.validationFiber, Option.some(fiber))),
|
|
||||||
Effect.andThen(Fiber.join),
|
|
||||||
Effect.andThen(value => this.autosubmit
|
|
||||||
? Effect.asVoid(Effect.forkScoped(this.submitValue(value)))
|
|
||||||
: Effect.void
|
|
||||||
),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
Effect.provide(this.context),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException> {
|
|
||||||
return this.value.pipe(
|
|
||||||
Effect.andThen(identity),
|
|
||||||
Effect.andThen(value => this.submitValue(value)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>> {
|
|
||||||
return Effect.whenEffect(
|
|
||||||
Effect.tap(
|
|
||||||
this.mutation.mutate([value, this as any]),
|
|
||||||
result => Result.isFailure(result)
|
|
||||||
? Option.match(
|
|
||||||
Chunk.findFirst(
|
|
||||||
Cause.failures(result.cause as Cause.Cause<ParseResult.ParseError>),
|
|
||||||
e => e._tag === "ParseError",
|
|
||||||
),
|
|
||||||
{
|
|
||||||
onSome: e => Ref.set(this.error, Option.some(e)),
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: Effect.void
|
|
||||||
),
|
|
||||||
this.canSubmit.get,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isForm = (u: unknown): u is Form<unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
|
|
||||||
|
|
||||||
export declare namespace make {
|
|
||||||
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
|
||||||
extends Mutation.make.Options<
|
|
||||||
readonly [value: NoInfer<A>, form: Form<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>],
|
|
||||||
MA, ME, MR, MP
|
|
||||||
> {
|
|
||||||
readonly schema: Schema.Schema<A, I, R>
|
|
||||||
readonly initialEncodedValue: NoInfer<I>
|
|
||||||
readonly autosubmit?: boolean
|
|
||||||
readonly debounce?: Duration.DurationInput
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
|
|
||||||
options: make.Options<A, I, R, MA, ME, MR, MP>
|
|
||||||
): Effect.fn.Return<
|
|
||||||
Form<A, I, R, MA, ME, Result.forkEffect.OutputContext<MA, ME, MR, MP>, MP>,
|
|
||||||
never,
|
|
||||||
Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP>
|
|
||||||
> {
|
|
||||||
return new FormImpl(
|
|
||||||
options.schema,
|
|
||||||
yield* Effect.context<Scope.Scope | R>(),
|
|
||||||
yield* Mutation.make(options),
|
|
||||||
options.autosubmit ?? false,
|
|
||||||
Option.fromNullable(options.debounce),
|
|
||||||
|
|
||||||
yield* SubscriptionRef.make(Option.none<A>()),
|
|
||||||
yield* SubscriptionRef.make(options.initialEncodedValue),
|
|
||||||
yield* SubscriptionRef.make(Option.none<ParseResult.ParseError>()),
|
|
||||||
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>()),
|
|
||||||
|
|
||||||
yield* Effect.makeSemaphore(1),
|
|
||||||
yield* Ref.make(HashMap.empty<FormFieldKey, FormField<unknown, unknown>>()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export declare namespace service {
|
|
||||||
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
|
||||||
extends make.Options<A, I, R, MA, ME, MR, MP> {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const service = <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
|
|
||||||
options: service.Options<A, I, R, MA, ME, MR, MP>
|
|
||||||
): Effect.Effect<
|
|
||||||
Form<A, I, R, MA, ME, Result.forkEffect.OutputContext<MA, ME, MR, MP>, MP>,
|
|
||||||
never,
|
|
||||||
Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP>
|
|
||||||
> => Effect.tap(
|
|
||||||
make(options),
|
|
||||||
form => Effect.forkScoped(form.run),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
export const FormFieldTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormField")
|
|
||||||
export type FormFieldTypeId = typeof FormFieldTypeId
|
|
||||||
|
|
||||||
export interface FormField<in out A, in out I = A>
|
|
||||||
extends Pipeable.Pipeable {
|
|
||||||
readonly [FormFieldTypeId]: FormFieldTypeId
|
|
||||||
|
|
||||||
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>
|
|
||||||
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
|
|
||||||
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
|
|
||||||
readonly isValidating: Subscribable.Subscribable<boolean>
|
|
||||||
readonly isSubmitting: Subscribable.Subscribable<boolean>
|
|
||||||
}
|
|
||||||
|
|
||||||
class FormFieldImpl<in out A, in out I = A>
|
|
||||||
extends Pipeable.Class() implements FormField<A, I> {
|
|
||||||
readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>,
|
|
||||||
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
|
|
||||||
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>,
|
|
||||||
readonly isValidating: Subscribable.Subscribable<boolean>,
|
|
||||||
readonly isSubmitting: Subscribable.Subscribable<boolean>,
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormFieldKeyTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormFieldKey")
|
|
||||||
type FormFieldKeyTypeId = typeof FormFieldKeyTypeId
|
|
||||||
|
|
||||||
class FormFieldKey implements Equal.Equal {
|
|
||||||
readonly [FormFieldKeyTypeId]: FormFieldKeyTypeId = FormFieldKeyTypeId
|
|
||||||
constructor(readonly path: PropertyPath.PropertyPath) {}
|
|
||||||
|
|
||||||
[Equal.symbol](that: Equal.Equal) {
|
|
||||||
return isFormFieldKey(that) && PropertyPath.equivalence(this.path, that.path)
|
|
||||||
}
|
|
||||||
[Hash.symbol]() {
|
|
||||||
return Hash.array(this.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isFormField = (u: unknown): u is FormField<unknown, unknown> => Predicate.hasProperty(u, FormFieldTypeId)
|
|
||||||
const isFormFieldKey = (u: unknown): u is FormFieldKey => Predicate.hasProperty(u, FormFieldKeyTypeId)
|
|
||||||
|
|
||||||
export const makeFormField = <A, I, R, MA, ME, MR, MP, const P extends PropertyPath.Paths<NoInfer<I>>>(
|
|
||||||
self: Form<A, I, R, MA, ME, MR, MP>,
|
|
||||||
path: P,
|
|
||||||
): FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>> => {
|
|
||||||
return new FormFieldImpl(
|
|
||||||
Subscribable.mapEffect(self.value, Option.match({
|
|
||||||
onSome: v => Option.map(PropertyPath.get(v, path), Option.some),
|
|
||||||
onNone: () => Option.some(Option.none()),
|
|
||||||
})),
|
|
||||||
SubscriptionSubRef.makeFromPath(self.encodedValue, path),
|
|
||||||
Subscribable.mapEffect(self.error, Option.match({
|
|
||||||
onSome: flow(
|
|
||||||
ParseResult.ArrayFormatter.formatError,
|
|
||||||
Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))),
|
|
||||||
),
|
|
||||||
onNone: () => Effect.succeed([]),
|
|
||||||
})),
|
|
||||||
Subscribable.map(self.validationFiber, Option.isSome),
|
|
||||||
Subscribable.map(self.mutation.result, result => Result.isRunning(result) || Result.hasRefreshingFlag(result)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export namespace useInput {
|
|
||||||
export interface Options {
|
|
||||||
readonly debounce?: Duration.DurationInput
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Success<T> {
|
|
||||||
readonly value: T
|
|
||||||
readonly setValue: React.Dispatch<React.SetStateAction<T>>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useInput = Effect.fnUntraced(function* <A, I>(
|
|
||||||
field: FormField<A, I>,
|
|
||||||
options?: useInput.Options,
|
|
||||||
): Effect.fn.Return<useInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
|
|
||||||
const internalValueRef = yield* Component.useOnChange(() => Effect.tap(
|
|
||||||
Effect.andThen(field.encodedValue, SubscriptionRef.make),
|
|
||||||
internalValueRef => Effect.forkScoped(Effect.all([
|
|
||||||
Stream.runForEach(
|
|
||||||
Stream.drop(field.encodedValue, 1),
|
|
||||||
upstreamEncodedValue => Effect.whenEffect(
|
|
||||||
Ref.set(internalValueRef, upstreamEncodedValue),
|
|
||||||
Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
Stream.runForEach(
|
|
||||||
internalValueRef.changes.pipe(
|
|
||||||
Stream.drop(1),
|
|
||||||
Stream.changesWith(Equal.equivalence()),
|
|
||||||
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
|
||||||
),
|
|
||||||
internalValue => Ref.set(field.encodedValue, internalValue),
|
|
||||||
),
|
|
||||||
], { concurrency: "unbounded" })),
|
|
||||||
), [field, options?.debounce])
|
|
||||||
|
|
||||||
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
|
|
||||||
return { value, setValue }
|
|
||||||
})
|
|
||||||
|
|
||||||
export namespace useOptionalInput {
|
|
||||||
export interface Options<T> extends useInput.Options {
|
|
||||||
readonly defaultValue: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Success<T> extends useInput.Success<T> {
|
|
||||||
readonly enabled: boolean
|
|
||||||
readonly setEnabled: React.Dispatch<React.SetStateAction<boolean>>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useOptionalInput = Effect.fnUntraced(function* <A, I>(
|
|
||||||
field: FormField<A, Option.Option<I>>,
|
|
||||||
options: useOptionalInput.Options<I>,
|
|
||||||
): Effect.fn.Return<useOptionalInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
|
|
||||||
const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap(
|
|
||||||
Effect.andThen(
|
|
||||||
field.encodedValue,
|
|
||||||
Option.match({
|
|
||||||
onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]),
|
|
||||||
onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
([enabledRef, internalValueRef]) => Effect.forkScoped(Effect.all([
|
|
||||||
Stream.runForEach(
|
|
||||||
Stream.drop(field.encodedValue, 1),
|
|
||||||
|
|
||||||
upstreamEncodedValue => Effect.whenEffect(
|
|
||||||
Option.match(upstreamEncodedValue, {
|
|
||||||
onSome: v => Effect.andThen(
|
|
||||||
Ref.set(enabledRef, true),
|
|
||||||
Ref.set(internalValueRef, v),
|
|
||||||
),
|
|
||||||
onNone: () => Effect.andThen(
|
|
||||||
Ref.set(enabledRef, false),
|
|
||||||
Ref.set(internalValueRef, options.defaultValue),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
|
|
||||||
Effect.andThen(
|
|
||||||
Effect.all([enabledRef, internalValueRef]),
|
|
||||||
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
Stream.runForEach(
|
|
||||||
enabledRef.changes.pipe(
|
|
||||||
Stream.zipLatest(internalValueRef.changes),
|
|
||||||
Stream.drop(1),
|
|
||||||
Stream.changesWith(Equal.equivalence()),
|
|
||||||
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
|
||||||
),
|
|
||||||
([enabled, internalValue]) => Ref.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
|
||||||
),
|
|
||||||
], { concurrency: "unbounded" })),
|
|
||||||
), [field, options.debounce])
|
|
||||||
|
|
||||||
const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef)
|
|
||||||
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
|
|
||||||
return { enabled, setEnabled, value, setValue }
|
|
||||||
})
|
|
||||||
@@ -1,56 +1,46 @@
|
|||||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
|
||||||
import { type Equivalence, Function, Predicate } from "effect"
|
import { type Equivalence, Function, Predicate } from "effect"
|
||||||
import * as React from "react"
|
|
||||||
import type * as Component from "./Component.js"
|
import type * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
export const TypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
|
export const TypeId: unique symbol = Symbol.for("effect-fc/Memoized")
|
||||||
export type TypeId = typeof TypeId
|
export type TypeId = typeof TypeId
|
||||||
|
|
||||||
export interface Memoized<P> extends MemoizedOptions<P> {
|
export interface Memoized<P> extends Memoized.Options<P> {
|
||||||
readonly [TypeId]: TypeId
|
readonly [TypeId]: TypeId
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoizedOptions<P> {
|
export namespace Memoized {
|
||||||
readonly propsEquivalence?: Equivalence.Equivalence<P>
|
export interface Options<P> {
|
||||||
|
readonly propsAreEqual?: Equivalence.Equivalence<P>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const MemoizedPrototype = Object.freeze({
|
const MemoizedProto = Object.freeze({
|
||||||
[TypeId]: TypeId,
|
[TypeId]: TypeId
|
||||||
|
|
||||||
transformFunctionComponent<P extends {}>(
|
|
||||||
this: Memoized<P>,
|
|
||||||
f: React.FC<P>,
|
|
||||||
) {
|
|
||||||
return React.memo(f, this.propsEquivalence)
|
|
||||||
},
|
|
||||||
} as const)
|
} as const)
|
||||||
|
|
||||||
|
|
||||||
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, TypeId)
|
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, TypeId)
|
||||||
|
|
||||||
export const memoized = <T extends Component.Component<any, any, any, any>>(
|
export const memo = <T extends Component.Component<any, any, any>>(
|
||||||
self: T
|
self: T
|
||||||
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
|
): T & Memoized<Component.FunctionComponent.Props<Component.Component.FunctionComponent<T>>> => Object.setPrototypeOf(
|
||||||
Object.assign(function() {}, self),
|
Object.assign(function() {}, self),
|
||||||
Object.freeze(Object.setPrototypeOf(
|
Object.freeze({ ...Object.getPrototypeOf(self), ...MemoizedProto }),
|
||||||
Object.assign({}, MemoizedPrototype),
|
|
||||||
Object.getPrototypeOf(self),
|
|
||||||
)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const withOptions: {
|
export const withOptions: {
|
||||||
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
<T extends Component.Component<any, any, any> & Memoized<any>>(
|
||||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>
|
options: Partial<Memoized.Options<Component.FunctionComponent.Props<Component.Component.FunctionComponent<T>>>>
|
||||||
): (self: T) => T
|
): (self: T) => T
|
||||||
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
<T extends Component.Component<any, any, any> & Memoized<any>>(
|
||||||
self: T,
|
self: T,
|
||||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
|
options: Partial<Memoized.Options<Component.FunctionComponent.Props<Component.Component.FunctionComponent<T>>>>,
|
||||||
): T
|
): T
|
||||||
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
} = Function.dual(2, <T extends Component.Component<any, any, any> & Memoized<any>>(
|
||||||
self: T,
|
self: T,
|
||||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
|
options: Partial<Memoized.Options<Component.FunctionComponent.Props<Component.Component.FunctionComponent<T>>>>,
|
||||||
): T => Object.setPrototypeOf(
|
): T => Object.setPrototypeOf(
|
||||||
Object.assign(function() {}, self, options),
|
Object.assign(function() {}, self, options),
|
||||||
Object.getPrototypeOf(self),
|
Object.getPrototypeOf(self),
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import { type Context, Effect, Equal, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, type Subscribable, SubscriptionRef } from "effect"
|
|
||||||
import * as Result from "./Result.js"
|
|
||||||
|
|
||||||
|
|
||||||
export const MutationTypeId: unique symbol = Symbol.for("@effect-fc/Mutation/Mutation")
|
|
||||||
export type MutationTypeId = typeof MutationTypeId
|
|
||||||
|
|
||||||
export interface Mutation<in out K extends Mutation.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
|
|
||||||
extends Pipeable.Pipeable {
|
|
||||||
readonly [MutationTypeId]: MutationTypeId
|
|
||||||
|
|
||||||
readonly context: Context.Context<Scope.Scope | R>
|
|
||||||
readonly f: (key: K) => Effect.Effect<A, E, R>
|
|
||||||
readonly initialProgress: P
|
|
||||||
|
|
||||||
readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
|
|
||||||
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
|
|
||||||
readonly result: Subscribable.Subscribable<Result.Result<A, E, P>>
|
|
||||||
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Final<A, E, P>>>
|
|
||||||
|
|
||||||
mutate(key: K): Effect.Effect<Result.Final<A, E, P>>
|
|
||||||
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export declare namespace Mutation {
|
|
||||||
export type AnyKey = readonly any[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MutationImpl<in out K extends Mutation.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
|
|
||||||
extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
|
|
||||||
readonly [MutationTypeId]: MutationTypeId = MutationTypeId
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly context: Context.Context<Scope.Scope | R>,
|
|
||||||
readonly f: (key: K) => Effect.Effect<A, E, R>,
|
|
||||||
readonly initialProgress: P,
|
|
||||||
|
|
||||||
readonly latestKey: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
|
|
||||||
readonly fiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
|
|
||||||
readonly result: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>,
|
|
||||||
readonly latestFinalResult: SubscriptionRef.SubscriptionRef<Option.Option<Result.Final<A, E, P>>>,
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
mutate(key: K): Effect.Effect<Result.Final<A, E, P>> {
|
|
||||||
return SubscriptionRef.set(this.latestKey, Option.some(key)).pipe(
|
|
||||||
Effect.andThen(this.start(key)),
|
|
||||||
Effect.andThen(sub => this.watch(sub)),
|
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> {
|
|
||||||
return SubscriptionRef.set(this.latestKey, Option.some(key)).pipe(
|
|
||||||
Effect.andThen(this.start(key)),
|
|
||||||
Effect.tap(sub => Effect.forkScoped(this.watch(sub))),
|
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
start(key: K): Effect.Effect<
|
|
||||||
Subscribable.Subscribable<Result.Result<A, E, P>>,
|
|
||||||
never,
|
|
||||||
Scope.Scope | R
|
|
||||||
> {
|
|
||||||
return this.latestFinalResult.pipe(
|
|
||||||
Effect.andThen(initial => Result.unsafeForkEffect(
|
|
||||||
Effect.onExit(this.f(key), () => Effect.andThen(
|
|
||||||
Effect.all([Effect.fiberId, this.fiber]),
|
|
||||||
([currentFiberId, fiber]) => Option.match(fiber, {
|
|
||||||
onSome: v => Equal.equals(currentFiberId, v.id())
|
|
||||||
? SubscriptionRef.set(this.fiber, Option.none())
|
|
||||||
: Effect.void,
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
}),
|
|
||||||
)),
|
|
||||||
|
|
||||||
{
|
|
||||||
initial: Option.isSome(initial) ? Result.willFetch(initial.value) : Result.initial(),
|
|
||||||
initialProgress: this.initialProgress,
|
|
||||||
} as Result.unsafeForkEffect.Options<A, E, P>,
|
|
||||||
)),
|
|
||||||
Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))),
|
|
||||||
Effect.map(([sub]) => sub),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
sub: Subscribable.Subscribable<Result.Result<A, E, P>>
|
|
||||||
): Effect.Effect<Result.Final<A, E, P>> {
|
|
||||||
return sub.get.pipe(
|
|
||||||
Effect.andThen(initial => Stream.runFoldEffect(
|
|
||||||
sub.changes,
|
|
||||||
initial,
|
|
||||||
(_, result) => Effect.as(SubscriptionRef.set(this.result, result), result),
|
|
||||||
) as Effect.Effect<Result.Final<A, E, P>>),
|
|
||||||
Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isMutation = (u: unknown): u is Mutation<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
|
|
||||||
|
|
||||||
export declare namespace make {
|
|
||||||
export interface Options<K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never> {
|
|
||||||
readonly f: (key: K) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
|
|
||||||
readonly initialProgress?: P
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const make = Effect.fnUntraced(function* <const K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never>(
|
|
||||||
options: make.Options<K, A, E, R, P>
|
|
||||||
): Effect.fn.Return<
|
|
||||||
Mutation<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
|
|
||||||
never,
|
|
||||||
Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>
|
|
||||||
> {
|
|
||||||
return new MutationImpl(
|
|
||||||
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>>(),
|
|
||||||
options.f as any,
|
|
||||||
options.initialProgress as P,
|
|
||||||
|
|
||||||
yield* SubscriptionRef.make(Option.none<K>()),
|
|
||||||
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>()),
|
|
||||||
yield* SubscriptionRef.make(Result.initial<A, E, P>()),
|
|
||||||
yield* SubscriptionRef.make(Option.none<Result.Final<A, E, P>>()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { Effect, PubSub, type Scope } from "effect"
|
|
||||||
import type * as React from "react"
|
|
||||||
import * as Component from "./Component.js"
|
|
||||||
|
|
||||||
|
|
||||||
export const usePubSubFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
|
|
||||||
values: A
|
|
||||||
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
|
|
||||||
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
|
|
||||||
yield* Component.useReactEffect(() => Effect.unlessEffect(PubSub.publish(pubsub, values), PubSub.isShutdown(pubsub)), values)
|
|
||||||
return pubsub
|
|
||||||
})
|
|
||||||
|
|
||||||
export * from "effect/PubSub"
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
import { type Cause, type Context, type Duration, Effect, Equal, Fiber, identity, Option, Pipeable, Predicate, type Scope, Stream, Subscribable, SubscriptionRef } from "effect"
|
|
||||||
import * as QueryClient from "./QueryClient.js"
|
|
||||||
import * as Result from "./Result.js"
|
|
||||||
|
|
||||||
|
|
||||||
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
|
|
||||||
export type QueryTypeId = typeof QueryTypeId
|
|
||||||
|
|
||||||
export interface Query<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never>
|
|
||||||
extends Pipeable.Pipeable {
|
|
||||||
readonly [QueryTypeId]: QueryTypeId
|
|
||||||
|
|
||||||
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>
|
|
||||||
readonly key: Stream.Stream<K, KE, KR>
|
|
||||||
readonly f: (key: K) => Effect.Effect<A, E, R>
|
|
||||||
|
|
||||||
readonly staleTime: Duration.DurationInput
|
|
||||||
readonly refreshOnWindowFocus: boolean
|
|
||||||
|
|
||||||
readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
|
|
||||||
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
|
|
||||||
readonly result: Subscribable.Subscribable<Result.Result<A, E>>
|
|
||||||
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Success<A, E> | Result.Failure<A, E>>>
|
|
||||||
|
|
||||||
readonly run: Effect.Effect<void>
|
|
||||||
fetch(key: K): Effect.Effect<Result.Success<A, E> | Result.Failure<A, E>>
|
|
||||||
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E>>>
|
|
||||||
readonly refresh: Effect.Effect<Result.Success<A, E> | Result.Failure<A, E>, Cause.NoSuchElementException>
|
|
||||||
readonly refreshSubscribable: Effect.Effect<Subscribable.Subscribable<Result.Result<A, E>>, Cause.NoSuchElementException>
|
|
||||||
|
|
||||||
readonly invalidateCache: Effect.Effect<void>
|
|
||||||
invalidateCacheEntry(key: K): Effect.Effect<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export declare namespace Query {
|
|
||||||
export type AnyKey = readonly any[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export class QueryImpl<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never>
|
|
||||||
extends Pipeable.Class() implements Query<K, A, KE, KR, E, R> {
|
|
||||||
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>,
|
|
||||||
readonly key: Stream.Stream<K, KE, KR>,
|
|
||||||
readonly f: (key: K) => Effect.Effect<A, E, R>,
|
|
||||||
|
|
||||||
readonly staleTime: Duration.DurationInput,
|
|
||||||
readonly refreshOnWindowFocus: boolean,
|
|
||||||
|
|
||||||
readonly latestKey: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
|
|
||||||
readonly fiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
|
|
||||||
readonly result: SubscriptionRef.SubscriptionRef<Result.Success<A, E> | Result.Failure<A, E>>,
|
|
||||||
readonly latestFinalResult: SubscriptionRef.SubscriptionRef<Option.Option<Result.Success<A, E> | Result.Failure<A, E>>>,
|
|
||||||
|
|
||||||
readonly runSemaphore: Effect.Semaphore,
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
get run(): Effect.Effect<void> {
|
|
||||||
return Effect.all([
|
|
||||||
Stream.runForEach(this.key, key => this.fetchSubscribable(key)),
|
|
||||||
|
|
||||||
Effect.promise(() => import("@effect/platform-browser")).pipe(
|
|
||||||
Effect.andThen(({ BrowserStream }) => this.refreshOnWindowFocus
|
|
||||||
? Stream.runForEach(
|
|
||||||
BrowserStream.fromEventListenerWindow("focus"),
|
|
||||||
() => this.refreshSubscribable,
|
|
||||||
)
|
|
||||||
: Effect.void
|
|
||||||
),
|
|
||||||
Effect.catchAllDefect(() => Effect.void),
|
|
||||||
),
|
|
||||||
], { concurrency: "unbounded" }).pipe(
|
|
||||||
Effect.ignore,
|
|
||||||
this.runSemaphore.withPermits(1),
|
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get interrupt(): Effect.Effect<void> {
|
|
||||||
return Effect.andThen(this.fiber, Option.match({
|
|
||||||
onSome: Fiber.interrupt,
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(key: K): Effect.Effect<Result.Success<A, E> | Result.Failure<A, E>> {
|
|
||||||
return this.interrupt.pipe(
|
|
||||||
Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))),
|
|
||||||
Effect.andThen(this.latestFinalResult),
|
|
||||||
Effect.andThen(previous => this.startCached(key, Option.isSome(previous)
|
|
||||||
? Result.willFetch(previous.value) as Result.Final<A, E, P>
|
|
||||||
: Result.initial()
|
|
||||||
)),
|
|
||||||
Effect.andThen(sub => this.watch(key, sub)),
|
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> {
|
|
||||||
return this.interrupt.pipe(
|
|
||||||
Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))),
|
|
||||||
Effect.andThen(this.latestFinalResult),
|
|
||||||
Effect.andThen(previous => this.startCached(key, Option.isSome(previous)
|
|
||||||
? Result.willFetch(previous.value) as Result.Final<A, E, P>
|
|
||||||
: Result.initial()
|
|
||||||
)),
|
|
||||||
Effect.tap(sub => Effect.forkScoped(this.watch(key, sub))),
|
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get refresh(): Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException> {
|
|
||||||
return this.interrupt.pipe(
|
|
||||||
Effect.andThen(Effect.Do),
|
|
||||||
Effect.bind("latestKey", () => Effect.andThen(this.latestKey, identity)),
|
|
||||||
Effect.bind("latestFinalResult", () => this.latestFinalResult),
|
|
||||||
Effect.bind("subscribable", ({ latestKey, latestFinalResult }) =>
|
|
||||||
this.startCached(latestKey, Option.isSome(latestFinalResult)
|
|
||||||
? Result.willRefresh(latestFinalResult.value) as Result.Final<A, E, P>
|
|
||||||
: Result.initial()
|
|
||||||
)
|
|
||||||
),
|
|
||||||
Effect.andThen(({ latestKey, subscribable }) => this.watch(latestKey, subscribable)),
|
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get refreshSubscribable(): Effect.Effect<
|
|
||||||
Subscribable.Subscribable<Result.Result<A, E, P>>,
|
|
||||||
Cause.NoSuchElementException
|
|
||||||
> {
|
|
||||||
return this.interrupt.pipe(
|
|
||||||
Effect.andThen(Effect.Do),
|
|
||||||
Effect.bind("latestKey", () => Effect.andThen(this.latestKey, identity)),
|
|
||||||
Effect.bind("latestFinalResult", () => this.latestFinalResult),
|
|
||||||
Effect.bind("subscribable", ({ latestKey, latestFinalResult }) =>
|
|
||||||
this.startCached(latestKey, Option.isSome(latestFinalResult)
|
|
||||||
? Result.willRefresh(latestFinalResult.value) as Result.Final<A, E, P>
|
|
||||||
: Result.initial()
|
|
||||||
)
|
|
||||||
),
|
|
||||||
Effect.tap(({ latestKey, subscribable }) => Effect.forkScoped(this.watch(latestKey, subscribable))),
|
|
||||||
Effect.map(({ subscribable }) => subscribable),
|
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
startCached(
|
|
||||||
key: K,
|
|
||||||
previous: Result.Success<A, E> | Result.Failure<A, E>,
|
|
||||||
): Effect.Effect<
|
|
||||||
Subscribable.Subscribable<Result.Result<A, E, P>>,
|
|
||||||
never,
|
|
||||||
Scope.Scope | QueryClient.QueryClient | R
|
|
||||||
> {
|
|
||||||
return Effect.andThen(this.getCacheEntry(key), Option.match({
|
|
||||||
onSome: entry => Effect.andThen(
|
|
||||||
QueryClient.isQueryClientCacheEntryStale(entry),
|
|
||||||
isStale => isStale
|
|
||||||
? this.start(key, Result.willRefresh(entry.result) as Result.Final<A, E, P>)
|
|
||||||
: Effect.succeed(Subscribable.make({
|
|
||||||
get: Effect.succeed(entry.result as Result.Result<A, E, P>),
|
|
||||||
get changes() { return Stream.make(entry.result as Result.Result<A, E, P>) },
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
onNone: () => this.start(key, initial),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
start(
|
|
||||||
key: K,
|
|
||||||
previous: Result.Success<A, E> | Result.Failure<A, E>,
|
|
||||||
): Effect.Effect<
|
|
||||||
Subscribable.Subscribable<Result.Result<A, E>>,
|
|
||||||
never,
|
|
||||||
Scope.Scope | R
|
|
||||||
> {
|
|
||||||
return Effect.Do.pipe(
|
|
||||||
Effect.bind("ref", () => SubscriptionRef.make<Result.Result<A, E>>(Result.initial())),
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
Effect.onExit(this.f(key), () => Effect.andThen(
|
|
||||||
Effect.all([Effect.fiberId, this.fiber]),
|
|
||||||
([currentFiberId, fiber]) => Option.match(fiber, {
|
|
||||||
onSome: v => Equal.equals(currentFiberId, v.id())
|
|
||||||
? SubscriptionRef.set(this.fiber, Option.none())
|
|
||||||
: Effect.void,
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
|
|
||||||
// return Result.unsafeForkEffect(
|
|
||||||
// Effect.onExit(this.f(key), () => Effect.andThen(
|
|
||||||
// Effect.all([Effect.fiberId, this.fiber]),
|
|
||||||
// ([currentFiberId, fiber]) => Option.match(fiber, {
|
|
||||||
// onSome: v => Equal.equals(currentFiberId, v.id())
|
|
||||||
// ? SubscriptionRef.set(this.fiber, Option.none())
|
|
||||||
// : Effect.void,
|
|
||||||
// onNone: () => Effect.void,
|
|
||||||
// }),
|
|
||||||
// )),
|
|
||||||
|
|
||||||
// {
|
|
||||||
// initial,
|
|
||||||
// initialProgress: this.initialProgress,
|
|
||||||
// } as Result.unsafeForkEffect.Options<A, E, P>,
|
|
||||||
// ).pipe(
|
|
||||||
// Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))),
|
|
||||||
// Effect.map(([sub]) => sub),
|
|
||||||
// )
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
key: K,
|
|
||||||
sub: Subscribable.Subscribable<Result.Result<A, E, P>>
|
|
||||||
): Effect.Effect<Result.Final<A, E, P>, never, QueryClient.QueryClient> {
|
|
||||||
return sub.get.pipe(
|
|
||||||
Effect.andThen(initial => Stream.runFoldEffect(
|
|
||||||
sub.changes,
|
|
||||||
initial,
|
|
||||||
(_, result) => Effect.as(SubscriptionRef.set(this.result, result), result),
|
|
||||||
) as Effect.Effect<Result.Final<A, E, P>>),
|
|
||||||
Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
|
|
||||||
Effect.tap(result => Result.isSuccess(result)
|
|
||||||
? this.setCacheEntry(key, result)
|
|
||||||
: Effect.void
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
makeCacheKey(key: K): QueryClient.QueryClientCacheKey {
|
|
||||||
return new QueryClient.QueryClientCacheKey(key, this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>)
|
|
||||||
}
|
|
||||||
|
|
||||||
getCacheEntry(
|
|
||||||
key: K
|
|
||||||
): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> {
|
|
||||||
return Effect.andThen(
|
|
||||||
Effect.all([
|
|
||||||
Effect.succeed(this.makeCacheKey(key)),
|
|
||||||
QueryClient.QueryClient,
|
|
||||||
]),
|
|
||||||
([key, client]) => client.getCacheEntry(key),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
setCacheEntry(
|
|
||||||
key: K,
|
|
||||||
result: Result.Success<A>,
|
|
||||||
): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> {
|
|
||||||
return Effect.andThen(
|
|
||||||
Effect.all([
|
|
||||||
Effect.succeed(this.makeCacheKey(key)),
|
|
||||||
QueryClient.QueryClient,
|
|
||||||
]),
|
|
||||||
([key, client]) => client.setCacheEntry(key, result, this.staleTime),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get invalidateCache(): Effect.Effect<void> {
|
|
||||||
return QueryClient.QueryClient.pipe(
|
|
||||||
Effect.andThen(client => client.invalidateCacheEntries(this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>)),
|
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidateCacheEntry(key: K): Effect.Effect<void> {
|
|
||||||
return Effect.all([
|
|
||||||
Effect.succeed(this.makeCacheKey(key)),
|
|
||||||
QueryClient.QueryClient,
|
|
||||||
]).pipe(
|
|
||||||
Effect.andThen(([key, client]) => client.invalidateCacheEntry(key)),
|
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown> => Predicate.hasProperty(u, QueryTypeId)
|
|
||||||
|
|
||||||
export declare namespace make {
|
|
||||||
export interface Options<K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never> {
|
|
||||||
readonly key: Stream.Stream<K, KE, KR>
|
|
||||||
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
|
|
||||||
readonly initialProgress?: P
|
|
||||||
readonly staleTime?: Duration.DurationInput
|
|
||||||
readonly refreshOnWindowFocus?: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
|
|
||||||
options: make.Options<K, A, KE, KR, E, R, P>
|
|
||||||
): Effect.fn.Return<
|
|
||||||
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
|
|
||||||
never,
|
|
||||||
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<A, E, R, P>
|
|
||||||
> {
|
|
||||||
const client = yield* QueryClient.QueryClient
|
|
||||||
|
|
||||||
return new QueryImpl(
|
|
||||||
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<A, E, R, P>>(),
|
|
||||||
options.key,
|
|
||||||
options.f as any,
|
|
||||||
options.initialProgress as P,
|
|
||||||
|
|
||||||
options.staleTime ?? client.defaultStaleTime,
|
|
||||||
options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus,
|
|
||||||
|
|
||||||
yield* SubscriptionRef.make(Option.none<K>()),
|
|
||||||
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>()),
|
|
||||||
yield* SubscriptionRef.make(Result.initial<A, E, P>()),
|
|
||||||
yield* SubscriptionRef.make(Option.none<Result.Final<A, E, P>>()),
|
|
||||||
|
|
||||||
yield* Effect.makeSemaphore(1),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const service = <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
|
|
||||||
options: make.Options<K, A, KE, KR, E, R, P>
|
|
||||||
): Effect.Effect<
|
|
||||||
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
|
|
||||||
never,
|
|
||||||
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<A, E, R, P>
|
|
||||||
> => Effect.tap(
|
|
||||||
make(options),
|
|
||||||
query => Effect.forkScoped(query.run),
|
|
||||||
)
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, type Option, Pipeable, Predicate, Schedule, type Scope, type Subscribable, SubscriptionRef } from "effect"
|
|
||||||
import type * as Query from "./Query.js"
|
|
||||||
import type * as Result from "./Result.js"
|
|
||||||
|
|
||||||
|
|
||||||
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService")
|
|
||||||
export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
|
|
||||||
|
|
||||||
export interface QueryClientService extends Pipeable.Pipeable {
|
|
||||||
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
|
|
||||||
|
|
||||||
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
|
|
||||||
readonly cacheGcTime: Duration.DurationInput
|
|
||||||
readonly defaultStaleTime: Duration.DurationInput
|
|
||||||
readonly defaultRefreshOnWindowFocus: boolean
|
|
||||||
|
|
||||||
readonly run: Effect.Effect<void>
|
|
||||||
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>>
|
|
||||||
setCacheEntry(
|
|
||||||
key: QueryClientCacheKey,
|
|
||||||
result: Result.Success<unknown>,
|
|
||||||
staleTime: Duration.DurationInput,
|
|
||||||
): Effect.Effect<QueryClientCacheEntry>
|
|
||||||
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void>
|
|
||||||
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class QueryClient extends Effect.Service<QueryClient>()("@effect-fc/QueryClient/QueryClient", {
|
|
||||||
scoped: Effect.suspend(() => service())
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class QueryClientServiceImpl
|
|
||||||
extends Pipeable.Class()
|
|
||||||
implements QueryClientService {
|
|
||||||
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
|
|
||||||
readonly cacheGcTime: Duration.DurationInput,
|
|
||||||
readonly defaultStaleTime: Duration.DurationInput,
|
|
||||||
readonly defaultRefreshOnWindowFocus: boolean,
|
|
||||||
readonly runSemaphore: Effect.Semaphore,
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
get run(): Effect.Effect<void> {
|
|
||||||
return this.runSemaphore.withPermits(1)(Effect.repeat(
|
|
||||||
Effect.andThen(
|
|
||||||
DateTime.now,
|
|
||||||
now => SubscriptionRef.update(this.cache, HashMap.filter(entry =>
|
|
||||||
Duration.lessThan(
|
|
||||||
DateTime.distanceDuration(entry.lastAccessedAt, now),
|
|
||||||
Duration.sum(entry.staleTime, this.cacheGcTime),
|
|
||||||
)
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
Schedule.spaced("30 second"),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>> {
|
|
||||||
return Effect.all([
|
|
||||||
Effect.andThen(this.cache, HashMap.get(key)),
|
|
||||||
DateTime.now,
|
|
||||||
]).pipe(
|
|
||||||
Effect.map(([entry, now]) => new QueryClientCacheEntry(entry.result, entry.staleTime, entry.createdAt, now)),
|
|
||||||
Effect.tap(entry => SubscriptionRef.update(this.cache, HashMap.set(key, entry))),
|
|
||||||
Effect.option,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
setCacheEntry(
|
|
||||||
key: QueryClientCacheKey,
|
|
||||||
result: Result.Success<unknown>,
|
|
||||||
staleTime: Duration.DurationInput,
|
|
||||||
): Effect.Effect<QueryClientCacheEntry> {
|
|
||||||
return DateTime.now.pipe(
|
|
||||||
Effect.map(now => new QueryClientCacheEntry(result, staleTime, now, now)),
|
|
||||||
Effect.tap(entry => SubscriptionRef.update(this.cache, HashMap.set(key, entry))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void> {
|
|
||||||
return SubscriptionRef.update(this.cache, HashMap.filter((_, key) => !Equivalence.strict()(key.f, f)))
|
|
||||||
}
|
|
||||||
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void> {
|
|
||||||
return SubscriptionRef.update(this.cache, HashMap.remove(key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId)
|
|
||||||
|
|
||||||
export declare namespace make {
|
|
||||||
export interface Options {
|
|
||||||
readonly cacheGcTime?: Duration.DurationInput
|
|
||||||
readonly defaultStaleTime?: Duration.DurationInput
|
|
||||||
readonly defaultRefreshOnWindowFocus?: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return<QueryClientService> {
|
|
||||||
return new QueryClientServiceImpl(
|
|
||||||
yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()),
|
|
||||||
options.cacheGcTime ?? "5 minutes",
|
|
||||||
options.defaultStaleTime ?? "0 minutes",
|
|
||||||
options.defaultRefreshOnWindowFocus ?? true,
|
|
||||||
yield* Effect.makeSemaphore(1),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export declare namespace service {
|
|
||||||
export interface Options extends make.Options {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const service = (options?: service.Options): Effect.Effect<QueryClientService, never, Scope.Scope> => Effect.tap(
|
|
||||||
make(options),
|
|
||||||
client => Effect.forkScoped(client.run),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheKey")
|
|
||||||
export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId
|
|
||||||
|
|
||||||
export class QueryClientCacheKey
|
|
||||||
extends Pipeable.Class()
|
|
||||||
implements Pipeable.Pipeable, Equal.Equal {
|
|
||||||
readonly [QueryClientCacheKeyTypeId]: QueryClientCacheKeyTypeId = QueryClientCacheKeyTypeId
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly key: Query.Query.AnyKey,
|
|
||||||
readonly f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>,
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
[Equal.symbol](that: Equal.Equal) {
|
|
||||||
return isQueryClientCacheKey(that) && Equivalence.array(Equal.equivalence())(this.key, that.key) && Equivalence.strict()(this.f, that.f)
|
|
||||||
}
|
|
||||||
[Hash.symbol]() {
|
|
||||||
return Hash.combine(Hash.hash(this.f))(Hash.array(this.key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isQueryClientCacheKey = (u: unknown): u is QueryClientCacheKey => Predicate.hasProperty(u, QueryClientCacheKeyTypeId)
|
|
||||||
|
|
||||||
|
|
||||||
export const QueryClientCacheEntryTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheEntry")
|
|
||||||
export type QueryClientCacheEntryTypeId = typeof QueryClientCacheEntryTypeId
|
|
||||||
|
|
||||||
export class QueryClientCacheEntry
|
|
||||||
extends Pipeable.Class()
|
|
||||||
implements Pipeable.Pipeable {
|
|
||||||
readonly [QueryClientCacheEntryTypeId]: QueryClientCacheEntryTypeId = QueryClientCacheEntryTypeId
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly result: Result.Success<unknown>,
|
|
||||||
readonly staleTime: Duration.DurationInput,
|
|
||||||
readonly createdAt: DateTime.DateTime,
|
|
||||||
readonly lastAccessedAt: DateTime.DateTime,
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId)
|
|
||||||
|
|
||||||
export const isQueryClientCacheEntryStale = (
|
|
||||||
self: QueryClientCacheEntry
|
|
||||||
): Effect.Effect<boolean> => Effect.andThen(
|
|
||||||
DateTime.now,
|
|
||||||
now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), self.staleTime),
|
|
||||||
)
|
|
||||||
47
packages/effect-fc/src/ReactManagedRuntime.ts
Normal file
47
packages/effect-fc/src/ReactManagedRuntime.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Effect, type Layer, ManagedRuntime, type Runtime } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
|
||||||
|
export interface ReactManagedRuntime<R, ER> {
|
||||||
|
readonly runtime: ManagedRuntime.ManagedRuntime<R, ER>
|
||||||
|
readonly context: React.Context<Runtime.Runtime<R>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const make = <R, ER>(
|
||||||
|
layer: Layer.Layer<R, ER>,
|
||||||
|
memoMap?: Layer.MemoMap,
|
||||||
|
): ReactManagedRuntime<R, ER> => ({
|
||||||
|
runtime: ManagedRuntime.make(layer, memoMap),
|
||||||
|
context: React.createContext<Runtime.Runtime<R>>(null!),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
export interface ProviderProps<R, ER> extends React.SuspenseProps {
|
||||||
|
readonly runtime: ReactManagedRuntime<R, ER>
|
||||||
|
readonly children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Provider<R, ER>(
|
||||||
|
{ runtime, children, ...suspenseProps }: ProviderProps<R, ER>
|
||||||
|
): React.ReactNode {
|
||||||
|
const promise = React.useMemo(() => Effect.runPromise(runtime.runtime.runtimeEffect), [runtime])
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
React.Suspense,
|
||||||
|
suspenseProps,
|
||||||
|
React.createElement(ProviderInner<R, ER>, { runtime, promise, children }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderInnerProps<R, ER> {
|
||||||
|
readonly runtime: ReactManagedRuntime<R, ER>
|
||||||
|
readonly promise: Promise<Runtime.Runtime<R>>
|
||||||
|
readonly children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderInner<R, ER>(
|
||||||
|
{ runtime, promise, children }: ProviderInnerProps<R, ER>
|
||||||
|
): React.ReactNode {
|
||||||
|
const value = React.use(promise)
|
||||||
|
return React.createElement(runtime.context, { value }, children)
|
||||||
|
}
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
|
||||||
import { Effect, Layer, ManagedRuntime, Predicate, Runtime, Scope } from "effect"
|
|
||||||
import * as React from "react"
|
|
||||||
import * as Component from "./Component.js"
|
|
||||||
import * as ErrorObserver from "./ErrorObserver.js"
|
|
||||||
import * as QueryClient from "./QueryClient.js"
|
|
||||||
|
|
||||||
|
|
||||||
export const TypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime")
|
|
||||||
export type TypeId = typeof TypeId
|
|
||||||
|
|
||||||
export interface ReactRuntime<R, ER> {
|
|
||||||
new(_: never): Record<string, never>
|
|
||||||
readonly [TypeId]: TypeId
|
|
||||||
readonly runtime: ManagedRuntime.ManagedRuntime<R, ER>
|
|
||||||
readonly context: React.Context<Runtime.Runtime<R>>
|
|
||||||
}
|
|
||||||
|
|
||||||
const ReactRuntimeProto = Object.freeze({ [TypeId]: TypeId } as const)
|
|
||||||
|
|
||||||
export const Prelude: Layer.Layer<
|
|
||||||
| Component.ScopeMap
|
|
||||||
| ErrorObserver.ErrorObserver
|
|
||||||
| QueryClient.QueryClient
|
|
||||||
> = Layer.mergeAll(
|
|
||||||
Component.ScopeMap.Default,
|
|
||||||
ErrorObserver.layer,
|
|
||||||
QueryClient.QueryClient.Default,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
export const isReactRuntime = (u: unknown): u is ReactRuntime<unknown, unknown> => Predicate.hasProperty(u, TypeId)
|
|
||||||
|
|
||||||
export const make = <R, ER>(
|
|
||||||
layer: Layer.Layer<R, ER>,
|
|
||||||
memoMap?: Layer.MemoMap,
|
|
||||||
): ReactRuntime<Layer.Layer.Success<typeof Prelude> | R, ER> => Object.setPrototypeOf(
|
|
||||||
Object.assign(function() {}, {
|
|
||||||
runtime: ManagedRuntime.make(
|
|
||||||
Layer.merge(layer, Prelude),
|
|
||||||
memoMap,
|
|
||||||
),
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: context initialization
|
|
||||||
context: React.createContext<Runtime.Runtime<R>>(null!),
|
|
||||||
}),
|
|
||||||
ReactRuntimeProto,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
export namespace Provider {
|
|
||||||
export interface Props<R, ER> extends React.SuspenseProps {
|
|
||||||
readonly runtime: ReactRuntime<R, ER>
|
|
||||||
readonly children?: React.ReactNode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Provider = <R, ER>(
|
|
||||||
{ runtime, children, ...suspenseProps }: Provider.Props<R, ER>
|
|
||||||
): React.ReactNode => {
|
|
||||||
const promise = React.useMemo(() => Effect.runPromise(runtime.runtime.runtimeEffect), [runtime])
|
|
||||||
|
|
||||||
return React.createElement(
|
|
||||||
React.Suspense,
|
|
||||||
suspenseProps,
|
|
||||||
React.createElement(ProviderInner<R, ER>, { runtime, promise, children }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProviderInner = <R, ER>(
|
|
||||||
{ runtime, promise, children }: {
|
|
||||||
readonly runtime: ReactRuntime<R, ER>
|
|
||||||
readonly promise: Promise<Runtime.Runtime<R>>
|
|
||||||
readonly children?: React.ReactNode
|
|
||||||
}
|
|
||||||
): React.ReactNode => {
|
|
||||||
const effectRuntime = React.use(promise)
|
|
||||||
const scope = Runtime.runSync(effectRuntime)(Component.useScope([effectRuntime]))
|
|
||||||
Runtime.runSync(effectRuntime)(Effect.provideService(
|
|
||||||
Component.useOnChange(() => Effect.addFinalizer(() => runtime.runtime.disposeEffect), [scope]),
|
|
||||||
Scope.Scope,
|
|
||||||
scope,
|
|
||||||
))
|
|
||||||
|
|
||||||
return React.createElement(runtime.context, { value: effectRuntime }, children)
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "@effect-atom/atom/Result"
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Effect, Equivalence, Stream, Subscribable } from "effect"
|
|
||||||
import * as React from "react"
|
|
||||||
import * as Component from "./Component.js"
|
|
||||||
|
|
||||||
|
|
||||||
export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
|
|
||||||
...elements: T
|
|
||||||
): Subscribable.Subscribable<
|
|
||||||
[T[number]] extends [never]
|
|
||||||
? never
|
|
||||||
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never },
|
|
||||||
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer E, infer _R> ? E : never,
|
|
||||||
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
|
|
||||||
> => Subscribable.make({
|
|
||||||
get: Effect.all(elements.map(v => v.get)),
|
|
||||||
changes: Stream.zipLatestAll(...elements.map(v => v.changes)),
|
|
||||||
}) as any
|
|
||||||
|
|
||||||
export declare namespace useSubscribables {
|
|
||||||
export type Success<T extends readonly Subscribable.Subscribable<any, any, any>[]> = [T[number]] extends [never]
|
|
||||||
? never
|
|
||||||
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never }
|
|
||||||
|
|
||||||
export interface Options<A> {
|
|
||||||
readonly equivalence?: Equivalence.Equivalence<A>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSubscribables = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
|
|
||||||
elements: T,
|
|
||||||
options?: useSubscribables.Options<useSubscribables.Success<NoInfer<T>>>,
|
|
||||||
): Effect.fn.Return<
|
|
||||||
useSubscribables.Success<T>,
|
|
||||||
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer E, infer _R> ? E : never,
|
|
||||||
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
|
|
||||||
> {
|
|
||||||
const [reactStateValue, setReactStateValue] = React.useState(
|
|
||||||
yield* Component.useOnMount(() => Effect.all(elements.map(v => v.get)))
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* Component.useReactEffect(() => Stream.zipLatestAll(...elements.map(ref => ref.changes)).pipe(
|
|
||||||
Stream.changesWith((options?.equivalence as Equivalence.Equivalence<any[]> | undefined) ?? Equivalence.array(Equivalence.strict())),
|
|
||||||
Stream.runForEach(v =>
|
|
||||||
Effect.sync(() => setReactStateValue(v))
|
|
||||||
),
|
|
||||||
Effect.forkScoped,
|
|
||||||
), elements)
|
|
||||||
|
|
||||||
return reactStateValue as any
|
|
||||||
})
|
|
||||||
|
|
||||||
export * from "effect/Subscribable"
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { Effect, Equivalence, Ref, Stream, SubscriptionRef } from "effect"
|
|
||||||
import * as React from "react"
|
|
||||||
import * as Component from "./Component.js"
|
|
||||||
import * as SetStateAction from "./SetStateAction.js"
|
|
||||||
|
|
||||||
|
|
||||||
export declare namespace useSubscriptionRefState {
|
|
||||||
export interface Options<A> {
|
|
||||||
readonly equivalence?: Equivalence.Equivalence<A>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSubscriptionRefState = Effect.fnUntraced(function* <A>(
|
|
||||||
ref: SubscriptionRef.SubscriptionRef<A>,
|
|
||||||
options?: useSubscriptionRefState.Options<NoInfer<A>>,
|
|
||||||
): Effect.fn.Return<readonly [A, React.Dispatch<React.SetStateAction<A>>]> {
|
|
||||||
const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => ref))
|
|
||||||
|
|
||||||
yield* Component.useReactEffect(() => Effect.forkScoped(
|
|
||||||
Stream.runForEach(
|
|
||||||
Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()),
|
|
||||||
v => Effect.sync(() => setReactStateValue(v)),
|
|
||||||
)
|
|
||||||
), [ref])
|
|
||||||
|
|
||||||
const setValue = yield* Component.useCallbackSync(
|
|
||||||
(setStateAction: React.SetStateAction<A>) => Effect.andThen(
|
|
||||||
Ref.updateAndGet(ref, prevState => SetStateAction.value(setStateAction, prevState)),
|
|
||||||
v => setReactStateValue(v),
|
|
||||||
),
|
|
||||||
[ref],
|
|
||||||
)
|
|
||||||
|
|
||||||
return [reactStateValue, setValue]
|
|
||||||
})
|
|
||||||
|
|
||||||
export declare namespace useSubscriptionRefFromState {
|
|
||||||
export interface Options<A> {
|
|
||||||
readonly equivalence?: Equivalence.Equivalence<A>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSubscriptionRefFromState = Effect.fnUntraced(function* <A>(
|
|
||||||
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
|
|
||||||
options?: useSubscriptionRefFromState.Options<NoInfer<A>>,
|
|
||||||
): Effect.fn.Return<SubscriptionRef.SubscriptionRef<A>> {
|
|
||||||
const ref = yield* Component.useOnChange(() => Effect.tap(
|
|
||||||
SubscriptionRef.make(value),
|
|
||||||
ref => Effect.forkScoped(
|
|
||||||
Stream.runForEach(
|
|
||||||
Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()),
|
|
||||||
v => Effect.sync(() => setValue(v)),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
), [setValue])
|
|
||||||
|
|
||||||
yield* Component.useReactEffect(() => Ref.set(ref, value), [value])
|
|
||||||
return ref
|
|
||||||
})
|
|
||||||
|
|
||||||
export * from "effect/SubscriptionRef"
|
|
||||||
81
packages/effect-fc/src/Suspense.ts
Normal file
81
packages/effect-fc/src/Suspense.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Effect, Function, Predicate, Runtime, Scope } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import type * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const TypeId: unique symbol = Symbol.for("effect-fc/Suspense")
|
||||||
|
export type TypeId = typeof TypeId
|
||||||
|
|
||||||
|
export interface Suspense extends Suspense.Options {
|
||||||
|
readonly [TypeId]: TypeId
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Suspense {
|
||||||
|
export interface Options {
|
||||||
|
readonly defaultFallback?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Props = Omit<React.SuspenseProps, "children">
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const SuspenseProto = Object.freeze({
|
||||||
|
[TypeId]: TypeId,
|
||||||
|
|
||||||
|
makeFunctionComponent<F extends Component.FunctionComponent, E, R>(
|
||||||
|
this: Component.Component<F, E, R> & Suspense,
|
||||||
|
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
|
||||||
|
scope: Scope.Scope,
|
||||||
|
) {
|
||||||
|
const SuspenseInner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
|
||||||
|
|
||||||
|
return ({ fallback, name, ...props }: Suspense.Props) => {
|
||||||
|
const promise = Runtime.runPromise(runtimeRef.current)(
|
||||||
|
Effect.provideService(this.body(props as Component.FunctionComponent.Props<F>), Scope.Scope, scope)
|
||||||
|
)
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
React.Suspense,
|
||||||
|
{ fallback: fallback ?? this.defaultFallback, name },
|
||||||
|
React.createElement(SuspenseInner, { promise }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as const)
|
||||||
|
|
||||||
|
|
||||||
|
export const isSuspense = (u: unknown): u is Suspense => Predicate.hasProperty(u, TypeId)
|
||||||
|
|
||||||
|
export const suspense = <T extends Component.Component<any, any, any>>(
|
||||||
|
self: T
|
||||||
|
): (
|
||||||
|
& Omit<T, keyof Component.Component.AsComponent<T>>
|
||||||
|
& Component.Component<
|
||||||
|
Component.Component.FunctionComponent<T> extends (...args: readonly [infer P, ...infer Args]) => infer A
|
||||||
|
? A extends React.ReactNode
|
||||||
|
? (...args: readonly [props: P & Suspense.Props, ...Args]) => A
|
||||||
|
: never
|
||||||
|
: never,
|
||||||
|
Component.Component.Error<T>,
|
||||||
|
Component.Component.Context<T>>
|
||||||
|
& Suspense
|
||||||
|
) => Object.setPrototypeOf(
|
||||||
|
Object.assign(function() {}, self),
|
||||||
|
Object.freeze({ ...Object.getPrototypeOf(self), ...SuspenseProto }),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const withOptions: {
|
||||||
|
<T extends Component.Component<any, any, any> & Suspense>(
|
||||||
|
options: Partial<Suspense.Options>
|
||||||
|
): (self: T) => T
|
||||||
|
<T extends Component.Component<any, any, any> & Suspense>(
|
||||||
|
self: T,
|
||||||
|
options: Partial<Suspense.Options>,
|
||||||
|
): T
|
||||||
|
} = Function.dual(2, <T extends Component.Component<any, any, any> & Suspense>(
|
||||||
|
self: T,
|
||||||
|
options: Partial<Suspense.Options>,
|
||||||
|
): T => Object.setPrototypeOf(
|
||||||
|
Object.assign(function() {}, self, options),
|
||||||
|
Object.getPrototypeOf(self),
|
||||||
|
))
|
||||||
7
packages/effect-fc/src/hooks/Hooks/ScopeOptions.ts
Normal file
7
packages/effect-fc/src/hooks/Hooks/ScopeOptions.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ExecutionStrategy } from "effect"
|
||||||
|
|
||||||
|
|
||||||
|
export interface ScopeOptions {
|
||||||
|
readonly finalizerExecutionMode?: "sync" | "fork"
|
||||||
|
readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy
|
||||||
|
}
|
||||||
15
packages/effect-fc/src/hooks/Hooks/index.ts
Normal file
15
packages/effect-fc/src/hooks/Hooks/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export * from "./ScopeOptions.js"
|
||||||
|
export * from "./useCallbackPromise.js"
|
||||||
|
export * from "./useCallbackSync.js"
|
||||||
|
export * from "./useContext.js"
|
||||||
|
export * from "./useEffect.js"
|
||||||
|
export * from "./useInput.js"
|
||||||
|
export * from "./useLayoutEffect.js"
|
||||||
|
export * from "./useMemo.js"
|
||||||
|
export * from "./useOnce.js"
|
||||||
|
export * from "./useRefFromReactiveValue.js"
|
||||||
|
export * from "./useRefState.js"
|
||||||
|
export * from "./useScope.js"
|
||||||
|
export * from "./useStreamFromReactiveValues.js"
|
||||||
|
export * from "./useSubscribeRefs.js"
|
||||||
|
export * from "./useSubscribeStream.js"
|
||||||
18
packages/effect-fc/src/hooks/Hooks/internal.ts
Normal file
18
packages/effect-fc/src/hooks/Hooks/internal.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Exit, Runtime, Scope } from "effect"
|
||||||
|
import type { ScopeOptions } from "./ScopeOptions.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const closeScope = (
|
||||||
|
scope: Scope.CloseableScope,
|
||||||
|
runtime: Runtime.Runtime<never>,
|
||||||
|
options?: ScopeOptions,
|
||||||
|
) => {
|
||||||
|
switch (options?.finalizerExecutionMode ?? "sync") {
|
||||||
|
case "sync":
|
||||||
|
Runtime.runSync(runtime)(Scope.close(scope, Exit.void))
|
||||||
|
break
|
||||||
|
case "fork":
|
||||||
|
Runtime.runFork(runtime)(Scope.close(scope, Exit.void))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
18
packages/effect-fc/src/hooks/Hooks/useCallbackPromise.ts
Normal file
18
packages/effect-fc/src/hooks/Hooks/useCallbackPromise.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Effect, Runtime } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
|
||||||
|
export const useCallbackPromise: {
|
||||||
|
<Args extends unknown[], A, E, R>(
|
||||||
|
callback: (...args: Args) => Effect.Effect<A, E, R>,
|
||||||
|
deps: React.DependencyList,
|
||||||
|
): Effect.Effect<(...args: Args) => Promise<A>, never, R>
|
||||||
|
} = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
|
||||||
|
callback: (...args: Args) => Effect.Effect<A, E, R>,
|
||||||
|
deps: React.DependencyList,
|
||||||
|
) {
|
||||||
|
const runtimeRef = React.useRef<Runtime.Runtime<R>>(null!)
|
||||||
|
runtimeRef.current = yield* Effect.runtime<R>()
|
||||||
|
|
||||||
|
return React.useCallback((...args: Args) => Runtime.runPromise(runtimeRef.current)(callback(...args)), deps)
|
||||||
|
})
|
||||||
18
packages/effect-fc/src/hooks/Hooks/useCallbackSync.ts
Normal file
18
packages/effect-fc/src/hooks/Hooks/useCallbackSync.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Effect, Runtime } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
|
||||||
|
export const useCallbackSync: {
|
||||||
|
<Args extends unknown[], A, E, R>(
|
||||||
|
callback: (...args: Args) => Effect.Effect<A, E, R>,
|
||||||
|
deps: React.DependencyList,
|
||||||
|
): Effect.Effect<(...args: Args) => A, never, R>
|
||||||
|
} = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
|
||||||
|
callback: (...args: Args) => Effect.Effect<A, E, R>,
|
||||||
|
deps: React.DependencyList,
|
||||||
|
) {
|
||||||
|
const runtimeRef = React.useRef<Runtime.Runtime<R>>(null!)
|
||||||
|
runtimeRef.current = yield* Effect.runtime<R>()
|
||||||
|
|
||||||
|
return React.useCallback((...args: Args) => Runtime.runSync(runtimeRef.current)(callback(...args)), deps)
|
||||||
|
})
|
||||||
23
packages/effect-fc/src/hooks/Hooks/useContext.ts
Normal file
23
packages/effect-fc/src/hooks/Hooks/useContext.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { type Context, Effect, type Layer, Scope } from "effect"
|
||||||
|
import type { ScopeOptions } from "./ScopeOptions.js"
|
||||||
|
import { useMemo } from "./useMemo.js"
|
||||||
|
import { useScope } from "./useScope.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const useContext: {
|
||||||
|
<ROut, E, RIn>(
|
||||||
|
layer: Layer.Layer<ROut, E, RIn>,
|
||||||
|
options?: ScopeOptions,
|
||||||
|
): Effect.Effect<Context.Context<ROut>, E, Exclude<RIn, Scope.Scope>>
|
||||||
|
} = Effect.fnUntraced(function* <ROut, E, RIn>(
|
||||||
|
layer: Layer.Layer<ROut, E, RIn>,
|
||||||
|
options?: ScopeOptions,
|
||||||
|
) {
|
||||||
|
const scope = yield* useScope([layer], options)
|
||||||
|
|
||||||
|
return yield* useMemo(() => Effect.provideService(
|
||||||
|
Effect.provide(Effect.context<ROut>(), layer),
|
||||||
|
Scope.Scope,
|
||||||
|
scope,
|
||||||
|
), [scope])
|
||||||
|
})
|
||||||
28
packages/effect-fc/src/hooks/Hooks/useEffect.ts
Normal file
28
packages/effect-fc/src/hooks/Hooks/useEffect.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Effect, ExecutionStrategy, Runtime, Scope } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import type { ScopeOptions } from "./ScopeOptions.js"
|
||||||
|
import { closeScope } from "./internal.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const useEffect: {
|
||||||
|
<E, R>(
|
||||||
|
effect: () => Effect.Effect<void, E, R>,
|
||||||
|
deps?: React.DependencyList,
|
||||||
|
options?: ScopeOptions,
|
||||||
|
): Effect.Effect<void, never, Exclude<R, Scope.Scope>>
|
||||||
|
} = Effect.fnUntraced(function* <E, R>(
|
||||||
|
effect: () => Effect.Effect<void, E, R>,
|
||||||
|
deps?: React.DependencyList,
|
||||||
|
options?: ScopeOptions,
|
||||||
|
) {
|
||||||
|
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
|
||||||
|
|
||||||
|
React.useEffect(() => Effect.Do.pipe(
|
||||||
|
Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)),
|
||||||
|
Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(effect(), Scope.Scope, scope))),
|
||||||
|
Effect.map(({ scope }) =>
|
||||||
|
() => closeScope(scope, runtime, options)
|
||||||
|
),
|
||||||
|
Runtime.runSync(runtime),
|
||||||
|
), deps)
|
||||||
|
})
|
||||||
31
packages/effect-fc/src/hooks/Hooks/useFork.ts
Normal file
31
packages/effect-fc/src/hooks/Hooks/useFork.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Effect, ExecutionStrategy, Runtime, Scope } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import { closeScope } from "./internal.js"
|
||||||
|
import type { ScopeOptions } from "./ScopeOptions.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const useFork: {
|
||||||
|
<E, R>(
|
||||||
|
effect: () => Effect.Effect<void, E, R>,
|
||||||
|
deps?: React.DependencyList,
|
||||||
|
options?: Runtime.RunForkOptions & ScopeOptions,
|
||||||
|
): Effect.Effect<void, never, Exclude<R, Scope.Scope>>
|
||||||
|
} = Effect.fnUntraced(function* <E, R>(
|
||||||
|
effect: () => Effect.Effect<void, E, R>,
|
||||||
|
deps?: React.DependencyList,
|
||||||
|
options?: Runtime.RunForkOptions & ScopeOptions,
|
||||||
|
) {
|
||||||
|
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const scope = Runtime.runSync(runtime)(options?.scope
|
||||||
|
? Scope.fork(options.scope, options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
|
||||||
|
: Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)
|
||||||
|
)
|
||||||
|
Runtime.runFork(runtime)(Effect.provideService(effect(), Scope.Scope, scope), { ...options, scope })
|
||||||
|
return () => closeScope(scope, runtime, {
|
||||||
|
...options,
|
||||||
|
finalizerExecutionMode: options?.finalizerExecutionMode ?? "fork",
|
||||||
|
})
|
||||||
|
}, deps)
|
||||||
|
})
|
||||||
59
packages/effect-fc/src/hooks/Hooks/useInput.ts
Normal file
59
packages/effect-fc/src/hooks/Hooks/useInput.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { type Duration, Effect, flow, identity, Option, type ParseResult, Ref, Schema, Stream, SubscriptionRef, type Types } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import { useCallbackSync } from "./useCallbackSync.js"
|
||||||
|
import { useFork } from "./useFork.js"
|
||||||
|
import { useOnce } from "./useOnce.js"
|
||||||
|
import { useSubscribeRefs } from "./useSubscribeRefs.js"
|
||||||
|
|
||||||
|
|
||||||
|
export namespace useInput {
|
||||||
|
export interface Options<A, R> {
|
||||||
|
readonly ref: SubscriptionRef.SubscriptionRef<A>
|
||||||
|
readonly schema: Schema.Schema<Types.NoInfer<A>, string, R>
|
||||||
|
readonly debounce?: Duration.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Result {
|
||||||
|
readonly value: string
|
||||||
|
readonly onChange: React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>
|
||||||
|
readonly error: Option.Option<ParseResult.ParseError>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useInput: {
|
||||||
|
<A, R>(options: useInput.Options<A, R>): Effect.Effect<useInput.Result, ParseResult.ParseError, R>
|
||||||
|
} = Effect.fnUntraced(function* <A, R>(options: useInput.Options<A, R>) {
|
||||||
|
const internalRef = yield* useOnce(() => options.ref.pipe(
|
||||||
|
Effect.andThen(Schema.encode(options.schema)),
|
||||||
|
Effect.andThen(SubscriptionRef.make),
|
||||||
|
))
|
||||||
|
const [error, setError] = React.useState(Option.none<ParseResult.ParseError>())
|
||||||
|
|
||||||
|
yield* useFork(() => Effect.all([
|
||||||
|
Stream.runForEach(options.ref.changes, upstreamValue =>
|
||||||
|
Effect.andThen(internalRef, internalValue =>
|
||||||
|
upstreamValue !== internalValue
|
||||||
|
? Effect.andThen(Schema.encode(options.schema)(upstreamValue), v => Ref.set(internalRef, v))
|
||||||
|
: Effect.void
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
Stream.runForEach(
|
||||||
|
internalRef.changes.pipe(options.debounce ? Stream.debounce(options.debounce) : identity),
|
||||||
|
flow(
|
||||||
|
Schema.decode(options.schema),
|
||||||
|
Effect.andThen(v => Ref.set(options.ref, v)),
|
||||||
|
Effect.andThen(() => setError(Option.none())),
|
||||||
|
Effect.catchTag("ParseError", e => Effect.sync(() => setError(Option.some(e)))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
], { concurrency: "unbounded" }), [options.ref, options.schema, options.debounce, internalRef])
|
||||||
|
|
||||||
|
const [value] = yield* useSubscribeRefs(internalRef)
|
||||||
|
const onChange = yield* useCallbackSync((e: React.ChangeEvent<HTMLInputElement>) => Ref.set(
|
||||||
|
internalRef,
|
||||||
|
e.target.value,
|
||||||
|
), [internalRef])
|
||||||
|
|
||||||
|
return { value, onChange, error }
|
||||||
|
})
|
||||||
28
packages/effect-fc/src/hooks/Hooks/useLayoutEffect.ts
Normal file
28
packages/effect-fc/src/hooks/Hooks/useLayoutEffect.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Effect, ExecutionStrategy, Runtime, Scope } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import type { ScopeOptions } from "./ScopeOptions.js"
|
||||||
|
import { closeScope } from "./internal.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const useLayoutEffect: {
|
||||||
|
<E, R>(
|
||||||
|
effect: () => Effect.Effect<void, E, R>,
|
||||||
|
deps?: React.DependencyList,
|
||||||
|
options?: ScopeOptions,
|
||||||
|
): Effect.Effect<void, never, Exclude<R, Scope.Scope>>
|
||||||
|
} = Effect.fnUntraced(function* <E, R>(
|
||||||
|
effect: () => Effect.Effect<void, E, R>,
|
||||||
|
deps?: React.DependencyList,
|
||||||
|
options?: ScopeOptions,
|
||||||
|
) {
|
||||||
|
const runtime = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => Effect.Do.pipe(
|
||||||
|
Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)),
|
||||||
|
Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(effect(), Scope.Scope, scope))),
|
||||||
|
Effect.map(({ scope }) =>
|
||||||
|
() => closeScope(scope, runtime, options)
|
||||||
|
),
|
||||||
|
Runtime.runSync(runtime),
|
||||||
|
), deps)
|
||||||
|
})
|
||||||
16
packages/effect-fc/src/hooks/Hooks/useMemo.ts
Normal file
16
packages/effect-fc/src/hooks/Hooks/useMemo.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Effect, Runtime } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
|
||||||
|
export const useMemo: {
|
||||||
|
<A, E, R>(
|
||||||
|
factory: () => Effect.Effect<A, E, R>,
|
||||||
|
deps: React.DependencyList,
|
||||||
|
): Effect.Effect<A, E, R>
|
||||||
|
} = Effect.fnUntraced(function* <A, E, R>(
|
||||||
|
factory: () => Effect.Effect<A, E, R>,
|
||||||
|
deps: React.DependencyList,
|
||||||
|
) {
|
||||||
|
const runtime = yield* Effect.runtime()
|
||||||
|
return yield* React.useMemo(() => Runtime.runSync(runtime)(Effect.cached(factory())), deps)
|
||||||
|
})
|
||||||
11
packages/effect-fc/src/hooks/Hooks/useOnce.ts
Normal file
11
packages/effect-fc/src/hooks/Hooks/useOnce.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
import { useMemo } from "./useMemo.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const useOnce: {
|
||||||
|
<A, E, R>(factory: () => Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
|
||||||
|
} = Effect.fnUntraced(function* <A, E, R>(
|
||||||
|
factory: () => Effect.Effect<A, E, R>
|
||||||
|
) {
|
||||||
|
return yield* useMemo(factory, [])
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Effect, Ref, SubscriptionRef } from "effect"
|
||||||
|
import { useEffect } from "./useEffect.js"
|
||||||
|
import { useOnce } from "./useOnce.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const useRefFromReactiveValue: {
|
||||||
|
<A>(value: A): Effect.Effect<SubscriptionRef.SubscriptionRef<A>>
|
||||||
|
} = Effect.fnUntraced(function*(value) {
|
||||||
|
const ref = yield* useOnce(() => SubscriptionRef.make(value))
|
||||||
|
yield* useEffect(() => Ref.set(ref, value), [value])
|
||||||
|
return ref
|
||||||
|
})
|
||||||
28
packages/effect-fc/src/hooks/Hooks/useRefState.ts
Normal file
28
packages/effect-fc/src/hooks/Hooks/useRefState.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Effect, Equivalence, Ref, Stream, SubscriptionRef } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import { SetStateAction } from "../../types/index.js"
|
||||||
|
import { useCallbackSync } from "./useCallbackSync.js"
|
||||||
|
import { useFork } from "./useFork.js"
|
||||||
|
import { useOnce } from "./useOnce.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const useRefState: {
|
||||||
|
<A>(
|
||||||
|
ref: SubscriptionRef.SubscriptionRef<A>
|
||||||
|
): Effect.Effect<readonly [A, React.Dispatch<React.SetStateAction<A>>]>
|
||||||
|
} = Effect.fnUntraced(function* <A>(ref: SubscriptionRef.SubscriptionRef<A>) {
|
||||||
|
const [reactStateValue, setReactStateValue] = React.useState(yield* useOnce(() => ref))
|
||||||
|
|
||||||
|
yield* useFork(() => Stream.runForEach(
|
||||||
|
Stream.changesWith(ref.changes, Equivalence.strict()),
|
||||||
|
v => Effect.sync(() => setReactStateValue(v)),
|
||||||
|
), [ref])
|
||||||
|
|
||||||
|
const setValue = yield* useCallbackSync((setStateAction: React.SetStateAction<A>) =>
|
||||||
|
Ref.update(ref, prevState =>
|
||||||
|
SetStateAction.value(setStateAction, prevState)
|
||||||
|
),
|
||||||
|
[ref])
|
||||||
|
|
||||||
|
return [reactStateValue, setValue]
|
||||||
|
})
|
||||||
36
packages/effect-fc/src/hooks/Hooks/useScope.ts
Normal file
36
packages/effect-fc/src/hooks/Hooks/useScope.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Effect, ExecutionStrategy, Ref, Runtime, Scope } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import type { ScopeOptions } from "./ScopeOptions.js"
|
||||||
|
import { closeScope } from "./internal.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const useScope: {
|
||||||
|
(
|
||||||
|
deps: React.DependencyList,
|
||||||
|
options?: ScopeOptions,
|
||||||
|
): Effect.Effect<Scope.Scope>
|
||||||
|
} = Effect.fnUntraced(function*(deps, options) {
|
||||||
|
const runtime = yield* Effect.runtime()
|
||||||
|
|
||||||
|
const [isInitialRun, initialScope] = React.useMemo(() => Runtime.runSync(runtime)(Effect.all([
|
||||||
|
Ref.make(true),
|
||||||
|
Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential),
|
||||||
|
])), [])
|
||||||
|
const [scope, setScope] = React.useState(initialScope)
|
||||||
|
|
||||||
|
React.useEffect(() => Runtime.runSync(runtime)(
|
||||||
|
Effect.if(isInitialRun, {
|
||||||
|
onTrue: () => Effect.as(
|
||||||
|
Ref.set(isInitialRun, false),
|
||||||
|
() => closeScope(scope, runtime, options),
|
||||||
|
),
|
||||||
|
|
||||||
|
onFalse: () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential).pipe(
|
||||||
|
Effect.tap(scope => Effect.sync(() => setScope(scope))),
|
||||||
|
Effect.map(scope => () => closeScope(scope, runtime, options)),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
), deps)
|
||||||
|
|
||||||
|
return scope
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Effect, PubSub, Ref, Scope, Stream } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import { useEffect } from "./useEffect.js"
|
||||||
|
import { useOnce } from "./useOnce.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const useStreamFromReactiveValues: {
|
||||||
|
<const A extends React.DependencyList>(
|
||||||
|
values: A
|
||||||
|
): Effect.Effect<Stream.Stream<A>, never, Scope.Scope>
|
||||||
|
} = Effect.fnUntraced(function* <const A extends React.DependencyList>(values: A) {
|
||||||
|
const { latest, pubsub, stream } = yield* useOnce(() => Effect.Do.pipe(
|
||||||
|
Effect.bind("latest", () => Ref.make(values)),
|
||||||
|
Effect.bind("pubsub", () => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown)),
|
||||||
|
Effect.let("stream", ({ latest, pubsub }) => latest.pipe(
|
||||||
|
Effect.flatMap(a => Effect.map(
|
||||||
|
Stream.fromPubSub(pubsub, { scoped: true }),
|
||||||
|
s => Stream.concat(Stream.make(a), s),
|
||||||
|
)),
|
||||||
|
Stream.unwrapScoped,
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
|
||||||
|
yield* useEffect(() => Ref.set(latest, values).pipe(
|
||||||
|
Effect.andThen(PubSub.publish(pubsub, values)),
|
||||||
|
Effect.unlessEffect(PubSub.isShutdown(pubsub)),
|
||||||
|
), values)
|
||||||
|
|
||||||
|
return stream
|
||||||
|
})
|
||||||
27
packages/effect-fc/src/hooks/Hooks/useSubscribeRefs.ts
Normal file
27
packages/effect-fc/src/hooks/Hooks/useSubscribeRefs.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Effect, Equivalence, pipe, Stream, SubscriptionRef } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import { useFork } from "./useFork.js"
|
||||||
|
import { useOnce } from "./useOnce.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const useSubscribeRefs: {
|
||||||
|
<const Refs extends readonly SubscriptionRef.SubscriptionRef<any>[]>(
|
||||||
|
...refs: Refs
|
||||||
|
): Effect.Effect<{ [K in keyof Refs]: Effect.Effect.Success<Refs[K]> }>
|
||||||
|
} = Effect.fnUntraced(function* <const Refs extends readonly SubscriptionRef.SubscriptionRef<any>[]>(
|
||||||
|
...refs: Refs
|
||||||
|
) {
|
||||||
|
const [reactStateValue, setReactStateValue] = React.useState(yield* useOnce(() =>
|
||||||
|
Effect.all(refs as readonly SubscriptionRef.SubscriptionRef<any>[])
|
||||||
|
))
|
||||||
|
|
||||||
|
yield* useFork(() => pipe(
|
||||||
|
refs.map(ref => Stream.changesWith(ref.changes, Equivalence.strict())),
|
||||||
|
streams => Stream.zipLatestAll(...streams),
|
||||||
|
Stream.runForEach(v =>
|
||||||
|
Effect.sync(() => setReactStateValue(v))
|
||||||
|
),
|
||||||
|
), refs)
|
||||||
|
|
||||||
|
return reactStateValue as any
|
||||||
|
})
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Effect, Equivalence, Option, Stream } from "effect"
|
import { Effect, Equivalence, Option, Stream } from "effect"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as Component from "./Component.js"
|
import { useFork } from "./useFork.js"
|
||||||
|
|
||||||
|
|
||||||
export const useStream: {
|
export const useSubscribeStream: {
|
||||||
<A, E, R>(
|
<A, E, R>(
|
||||||
stream: Stream.Stream<A, E, R>
|
stream: Stream.Stream<A, E, R>
|
||||||
): Effect.Effect<Option.Option<A>, never, R>
|
): Effect.Effect<Option.Option<A>, never, R>
|
||||||
@@ -15,19 +15,17 @@ export const useStream: {
|
|||||||
stream: Stream.Stream<A, E, R>,
|
stream: Stream.Stream<A, E, R>,
|
||||||
initialValue?: A,
|
initialValue?: A,
|
||||||
) {
|
) {
|
||||||
const [reactStateValue, setReactStateValue] = React.useState(() => initialValue
|
const [reactStateValue, setReactStateValue] = React.useState(
|
||||||
|
React.useMemo(() => initialValue
|
||||||
? Option.some(initialValue)
|
? Option.some(initialValue)
|
||||||
: Option.none()
|
: Option.none(),
|
||||||
|
[])
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* Component.useReactEffect(() => Effect.forkScoped(
|
yield* useFork(() => Stream.runForEach(
|
||||||
Stream.runForEach(
|
|
||||||
Stream.changesWith(stream, Equivalence.strict()),
|
Stream.changesWith(stream, Equivalence.strict()),
|
||||||
v => Effect.sync(() => setReactStateValue(Option.some(v))),
|
v => Effect.sync(() => setReactStateValue(Option.some(v))),
|
||||||
)
|
|
||||||
), [stream])
|
), [stream])
|
||||||
|
|
||||||
return reactStateValue as Option.Some<A>
|
return reactStateValue as Option.Some<A>
|
||||||
})
|
})
|
||||||
|
|
||||||
export * from "effect/Stream"
|
|
||||||
2
packages/effect-fc/src/hooks/index.ts
Normal file
2
packages/effect-fc/src/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./Hooks/index.js"
|
||||||
|
export * as Hooks from "./Hooks/index.js"
|
||||||
@@ -1,17 +1,4 @@
|
|||||||
export * as Async from "./Async.js"
|
|
||||||
export * as Component from "./Component.js"
|
export * as Component from "./Component.js"
|
||||||
export * as ErrorObserver from "./ErrorObserver.js"
|
|
||||||
export * as Form from "./Form.js"
|
|
||||||
export * as Memoized from "./Memoized.js"
|
export * as Memoized from "./Memoized.js"
|
||||||
export * as Mutation from "./Mutation.js"
|
export * as ReactManagedRuntime from "./ReactManagedRuntime.js"
|
||||||
export * as PropertyPath from "./PropertyPath.js"
|
export * as Suspense from "./Suspense.js"
|
||||||
export * as PubSub from "./PubSub.js"
|
|
||||||
export * as Query from "./Query.js"
|
|
||||||
export * as QueryClient from "./QueryClient.js"
|
|
||||||
export * as ReactRuntime from "./ReactRuntime.js"
|
|
||||||
export * as Result from "./Result.js"
|
|
||||||
export * as SetStateAction from "./SetStateAction.js"
|
|
||||||
export * as Stream from "./Stream.js"
|
|
||||||
export * as Subscribable from "./Subscribable.js"
|
|
||||||
export * as SubscriptionRef from "./SubscriptionRef.js"
|
|
||||||
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"
|
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
import { Array, Equivalence, Function, Option, Predicate } from "effect"
|
import { Array, Function, Option, Predicate } from "effect"
|
||||||
|
|
||||||
|
|
||||||
export type PropertyPath = readonly PropertyKey[]
|
|
||||||
|
|
||||||
type Prev = readonly [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
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> = readonly [] | (
|
export type Paths<T, D extends number = 5, Seen = never> = [] | (
|
||||||
D extends never ? readonly [] :
|
D extends never ? [] :
|
||||||
T extends Seen ? readonly [] :
|
T extends Seen ? [] :
|
||||||
T extends readonly any[] ? {
|
T extends readonly any[] ? ArrayPaths<T, D, Seen | T> :
|
||||||
[K in keyof T as K extends number ? K : never]:
|
T extends object ? ObjectPaths<T, D, Seen | T> :
|
||||||
| readonly [K]
|
|
||||||
| readonly [K, ...Paths<T[K], Prev[D], Seen | T>]
|
|
||||||
} extends infer O
|
|
||||||
? O[keyof O]
|
|
||||||
: never
|
|
||||||
:
|
|
||||||
T extends object ? {
|
|
||||||
[K in keyof T as K extends string | number | symbol ? K : never]-?:
|
|
||||||
NonNullable<T[K]> extends infer V
|
|
||||||
? readonly [K] | readonly [K, ...Paths<V, Prev[D], Seen>]
|
|
||||||
: never
|
|
||||||
} extends infer O
|
|
||||||
? O[keyof O]
|
|
||||||
: never
|
|
||||||
:
|
|
||||||
never
|
never
|
||||||
)
|
)
|
||||||
|
|
||||||
export type ValueFromPath<T, P extends readonly any[]> = P extends readonly [infer Head, ...infer Tail]
|
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
|
? Head extends keyof T
|
||||||
? ValueFromPath<T[Head], Tail>
|
? ValueFromPath<T[Head], Tail>
|
||||||
: T extends readonly any[]
|
: T extends readonly any[]
|
||||||
@@ -38,8 +38,9 @@ export type ValueFromPath<T, P extends readonly any[]> = P extends readonly [inf
|
|||||||
: never
|
: never
|
||||||
: T
|
: T
|
||||||
|
|
||||||
|
export type AnyKey = string | number | symbol
|
||||||
|
export type AnyPath = readonly AnyKey[]
|
||||||
|
|
||||||
export const equivalence: Equivalence.Equivalence<PropertyPath> = Equivalence.array(Equivalence.strict())
|
|
||||||
|
|
||||||
export const unsafeGet: {
|
export const unsafeGet: {
|
||||||
<T, const P extends Paths<T>>(path: P): (self: T) => ValueFromPath<T, P>
|
<T, const P extends Paths<T>>(path: P): (self: T) => ValueFromPath<T, P>
|
||||||
@@ -64,16 +65,16 @@ export const get: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const immutableSet: {
|
export const immutableSet: {
|
||||||
<T, const P extends Paths<T>>(path: P, value: ValueFromPath<T, P>): (self: T) => Option.Option<T>
|
<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>
|
<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> => {
|
} = 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 PropertyPath)
|
const key = Array.head(path as AnyPath)
|
||||||
if (Option.isNone(key))
|
if (Option.isNone(key))
|
||||||
return Option.some(value as T)
|
return Option.some(value as T)
|
||||||
if (!Predicate.hasProperty(self, key.value))
|
if (!Predicate.hasProperty(self, key.value))
|
||||||
return Option.none()
|
return Option.none()
|
||||||
|
|
||||||
const child = immutableSet<any, any>(self[key.value], Option.getOrThrow(Array.tail(path as PropertyPath)), value)
|
const child = immutableSet<any, any>(self[key.value], Option.getOrThrow(Array.tail(path as AnyPath)), value)
|
||||||
if (Option.isNone(child))
|
if (Option.isNone(child))
|
||||||
return child
|
return child
|
||||||
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Chunk, Effect, Effectable, Option, Predicate, Readable, Ref, Stream, Subscribable, SubscriptionRef, SynchronizedRef, type Types, type Unify } from "effect"
|
import { Chunk, Effect, Effectable, Option, Readable, Ref, Stream, Subscribable, SubscriptionRef, SynchronizedRef, type Types, type Unify } from "effect"
|
||||||
import * as PropertyPath from "./PropertyPath.js"
|
import * as PropertyPath from "./PropertyPath.js"
|
||||||
|
|
||||||
|
|
||||||
export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("@effect-fc/SubscriptionSubRef/SubscriptionSubRef")
|
export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("effect-fc/types/SubscriptionSubRef")
|
||||||
export type SubscriptionSubRefTypeId = typeof SubscriptionSubRefTypeId
|
export type SubscriptionSubRefTypeId = typeof SubscriptionSubRefTypeId
|
||||||
|
|
||||||
export interface SubscriptionSubRef<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
|
export interface SubscriptionSubRef<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
|
||||||
extends SubscriptionSubRef.Variance<A, B>, SubscriptionRef.SubscriptionRef<A> {
|
extends SubscriptionSubRef.Variance<A, B>, SubscriptionRef.SubscriptionRef<A> {
|
||||||
readonly parent: B
|
readonly parent: SubscriptionRef.SubscriptionRef<B>
|
||||||
|
|
||||||
readonly [Unify.typeSymbol]?: unknown
|
readonly [Unify.typeSymbol]?: unknown
|
||||||
readonly [Unify.unifySymbol]?: SubscriptionSubRefUnify<this>
|
readonly [Unify.unifySymbol]?: SubscriptionSubRefUnify<this>
|
||||||
@@ -78,15 +78,13 @@ extends Effectable.Class<A> implements SubscriptionSubRef<A, B> {
|
|||||||
return Effect.Do.pipe(
|
return Effect.Do.pipe(
|
||||||
Effect.bind("b", (): Effect.Effect<Effect.Effect.Success<B>> => this.parent),
|
Effect.bind("b", (): Effect.Effect<Effect.Effect.Success<B>> => this.parent),
|
||||||
Effect.bind("ca", ({ b }) => f(this.getter(b))),
|
Effect.bind("ca", ({ b }) => f(this.getter(b))),
|
||||||
Effect.tap(({ b, ca: [, a] }) => SubscriptionRef.set(this.parent, this.setter(b, a))),
|
Effect.tap(({ b, ca: [, a] }) => Ref.set(this.parent, this.setter(b, a))),
|
||||||
Effect.map(({ ca: [c] }) => c),
|
Effect.map(({ ca: [c] }) => c),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const isSubscriptionSubRef = (u: unknown): u is SubscriptionSubRef<unknown, SubscriptionRef.SubscriptionRef<unknown>> => Predicate.hasProperty(u, SubscriptionSubRefTypeId)
|
|
||||||
|
|
||||||
export const makeFromGetSet = <A, B extends SubscriptionRef.SubscriptionRef<any>>(
|
export const makeFromGetSet = <A, B extends SubscriptionRef.SubscriptionRef<any>>(
|
||||||
parent: B,
|
parent: B,
|
||||||
options: {
|
options: {
|
||||||
@@ -107,7 +105,7 @@ export const makeFromPath = <
|
|||||||
(parentValue, value) => Option.getOrThrow(PropertyPath.immutableSet(parentValue, path, value)),
|
(parentValue, value) => Option.getOrThrow(PropertyPath.immutableSet(parentValue, path, value)),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const makeFromChunkIndex: {
|
export const makeFromChunkRef: {
|
||||||
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
|
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
|
||||||
parent: B,
|
parent: B,
|
||||||
index: number,
|
index: number,
|
||||||
@@ -130,57 +128,3 @@ export const makeFromChunkIndex: {
|
|||||||
parentValue => Chunk.unsafeGet(parentValue, index),
|
parentValue => Chunk.unsafeGet(parentValue, index),
|
||||||
(parentValue, value) => Chunk.replace(parentValue, index, value),
|
(parentValue, value) => Chunk.replace(parentValue, index, value),
|
||||||
) as any
|
) as any
|
||||||
|
|
||||||
export const makeFromChunkFindFirst: {
|
|
||||||
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
|
|
||||||
parent: B,
|
|
||||||
findFirstPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never>,
|
|
||||||
): SubscriptionSubRef<
|
|
||||||
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
|
|
||||||
B
|
|
||||||
>
|
|
||||||
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
|
|
||||||
parent: B,
|
|
||||||
findFirstPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never>,
|
|
||||||
): SubscriptionSubRef<
|
|
||||||
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
|
|
||||||
B
|
|
||||||
>
|
|
||||||
} = (
|
|
||||||
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<never>>,
|
|
||||||
findFirstPredicate: Predicate.Predicate.Any,
|
|
||||||
) => new SubscriptionSubRefImpl(
|
|
||||||
parent,
|
|
||||||
parentValue => Option.getOrThrow(Chunk.findFirst(parentValue, findFirstPredicate)),
|
|
||||||
(parentValue, value) => Option.getOrThrow(Option.andThen(
|
|
||||||
Chunk.findFirstIndex(parentValue, findFirstPredicate),
|
|
||||||
index => Chunk.replace(parentValue, index, value),
|
|
||||||
)),
|
|
||||||
) as any
|
|
||||||
|
|
||||||
export const makeFromChunkFindLast: {
|
|
||||||
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
|
|
||||||
parent: B,
|
|
||||||
findLastPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never>,
|
|
||||||
): SubscriptionSubRef<
|
|
||||||
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
|
|
||||||
B
|
|
||||||
>
|
|
||||||
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
|
|
||||||
parent: B,
|
|
||||||
findLastPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never>,
|
|
||||||
): SubscriptionSubRef<
|
|
||||||
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
|
|
||||||
B
|
|
||||||
>
|
|
||||||
} = (
|
|
||||||
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<never>>,
|
|
||||||
findLastPredicate: Predicate.Predicate.Any,
|
|
||||||
) => new SubscriptionSubRefImpl(
|
|
||||||
parent,
|
|
||||||
parentValue => Option.getOrThrow(Chunk.findLast(parentValue, findLastPredicate)),
|
|
||||||
(parentValue, value) => Option.getOrThrow(Option.andThen(
|
|
||||||
Chunk.findLastIndex(parentValue, findLastPredicate),
|
|
||||||
index => Chunk.replace(parentValue, index, value),
|
|
||||||
)),
|
|
||||||
) as any
|
|
||||||
3
packages/effect-fc/src/types/index.ts
Normal file
3
packages/effect-fc/src/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * as PropertyPath from "./PropertyPath.js"
|
||||||
|
export * as SetStateAction from "./SetStateAction.js"
|
||||||
|
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"
|
||||||
@@ -26,12 +26,7 @@
|
|||||||
|
|
||||||
// Build
|
// Build
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"declaration": true,
|
"declaration": true
|
||||||
"sourceMap": true,
|
|
||||||
|
|
||||||
"plugins": [
|
|
||||||
{ "name": "@effect/language-service" }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"include": ["./src"]
|
"include": ["./src"]
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
|
||||||
"root": false,
|
|
||||||
"extends": "//",
|
|
||||||
"files": {
|
|
||||||
"includes": ["./src/**", "!src/routeTree.gen.ts"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
28
packages/example/eslint.config.js
Normal file
28
packages/example/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -5,38 +5,48 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
"lint:tsc": "tsc --noEmit",
|
"lint:tsc": "tsc --noEmit",
|
||||||
"lint:biome": "biome lint",
|
"lint:eslint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"clean:cache": "rm -rf .turbo node_modules/.tmp node_modules/.vite* .tanstack",
|
|
||||||
"clean:dist": "rm -rf dist",
|
|
||||||
"clean:modules": "rm -rf node_modules"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/react-router": "^1.154.12",
|
"@effect/language-service": "^0.23.4",
|
||||||
"@tanstack/react-router-devtools": "^1.154.12",
|
"@eslint/js": "^9.26.0",
|
||||||
"@tanstack/router-plugin": "^1.154.12",
|
"@tanstack/react-router": "^1.120.3",
|
||||||
"@types/react": "^19.2.9",
|
"@tanstack/react-router-devtools": "^1.120.3",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@tanstack/router-plugin": "^1.120.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@thilawyn/thilaschema": "^0.1.4",
|
||||||
"globals": "^17.0.0",
|
"@types/react": "^19.1.4",
|
||||||
"react": "^19.2.3",
|
"@types/react-dom": "^19.1.5",
|
||||||
"react-dom": "^19.2.3",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"type-fest": "^5.4.1",
|
"eslint": "^9.26.0",
|
||||||
"vite": "^7.3.1"
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.1.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"typescript-eslint": "^8.32.1",
|
||||||
|
"vite": "^6.3.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform": "^0.94.2",
|
"@effect/platform": "^0.82.1",
|
||||||
"@effect/platform-browser": "^0.74.0",
|
"@effect/platform-browser": "^0.62.1",
|
||||||
"@radix-ui/themes": "^3.2.1",
|
"@radix-ui/themes": "^3.2.1",
|
||||||
|
"@typed/async-data": "^0.13.1",
|
||||||
"@typed/id": "^0.17.2",
|
"@typed/id": "^0.17.2",
|
||||||
"effect": "^3.19.15",
|
"@typed/lazy-ref": "^0.3.3",
|
||||||
|
"effect": "^3.15.1",
|
||||||
"effect-fc": "workspace:*",
|
"effect-fc": "workspace:*",
|
||||||
|
"lucide-react": "^0.510.0",
|
||||||
|
"mobx": "^6.13.7",
|
||||||
"react-icons": "^5.5.0"
|
"react-icons": "^5.5.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "^19.2.9",
|
"effect": "^3.15.1",
|
||||||
"effect": "^3.19.15",
|
"@effect/platform": "^0.82.1",
|
||||||
"react": "^19.2.3"
|
"@effect/platform-browser": "^0.62.1",
|
||||||
|
"@typed/lazy-ref": "^0.3.3",
|
||||||
|
"@typed/async-data": "^0.13.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { ThSchema } from "@thilawyn/thilaschema"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { assertEncodedJsonifiable } from "@/lib/schema"
|
|
||||||
|
|
||||||
|
|
||||||
export class Todo extends Schema.Class<Todo>("Todo")({
|
export class Todo extends Schema.Class<Todo>("Todo")({
|
||||||
@@ -14,7 +14,7 @@ export const TodoFromJsonStruct = Schema.Struct({
|
|||||||
...Todo.fields,
|
...Todo.fields,
|
||||||
completedAt: Schema.Option(Schema.DateTimeUtc),
|
completedAt: Schema.Option(Schema.DateTimeUtc),
|
||||||
}).pipe(
|
}).pipe(
|
||||||
assertEncodedJsonifiable
|
ThSchema.assertEncodedJsonifiable
|
||||||
)
|
)
|
||||||
|
|
||||||
export const TodoFromJson = Schema.compose(TodoFromJsonStruct, Todo)
|
export const TodoFromJson = Schema.compose(TodoFromJsonStruct, Todo)
|
||||||
|
|||||||
22
packages/example/src/lib/TextInput.tsx
Normal file
22
packages/example/src/lib/TextInput.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { TextField } from "@radix-ui/themes"
|
||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Component } from "effect-fc"
|
||||||
|
import { useInput } from "effect-fc/hooks"
|
||||||
|
|
||||||
|
|
||||||
|
export namespace TextInput {
|
||||||
|
export interface Props<A, R> extends Omit<useInput.Options<A, R>, "schema">, TextField.RootProps {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextInput = <A, R>(schema: Schema.Schema<A, string, R>) => Component.make(
|
||||||
|
Effect.fnUntraced(function*(props: TextInput.Props<A, R>) {
|
||||||
|
const input = yield* useInput({ ...props, schema })
|
||||||
|
return (
|
||||||
|
<TextField.Root
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
|
||||||
|
</TextField.Root>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
|
|
||||||
import { Array, Option, Struct } from "effect"
|
|
||||||
import { Component, Form, Subscribable } from "effect-fc"
|
|
||||||
|
|
||||||
|
|
||||||
interface Props
|
|
||||||
extends TextField.RootProps, Form.useInput.Options {
|
|
||||||
readonly optional?: false
|
|
||||||
readonly field: Form.FormField<any, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OptionalProps
|
|
||||||
extends Omit<TextField.RootProps, "optional" | "defaultValue">, Form.useOptionalInput.Options<string> {
|
|
||||||
readonly optional: true
|
|
||||||
readonly field: Form.FormField<any, Option.Option<string>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TextFieldFormInputProps = Props | OptionalProps
|
|
||||||
|
|
||||||
|
|
||||||
export class TextFieldFormInputView extends Component.make("TextFieldFormInputView")(function*(props: TextFieldFormInputProps) {
|
|
||||||
const input: (
|
|
||||||
| { readonly optional: true } & Form.useOptionalInput.Success<string>
|
|
||||||
| { readonly optional: false } & Form.useInput.Success<string>
|
|
||||||
) = props.optional
|
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: "optional" reactivity not supported
|
|
||||||
? { optional: true, ...yield* Form.useOptionalInput(props.field, props) }
|
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: "optional" reactivity not supported
|
|
||||||
: { optional: false, ...yield* Form.useInput(props.field, props) }
|
|
||||||
|
|
||||||
const [issues, isValidating, isSubmitting] = yield* Subscribable.useSubscribables([
|
|
||||||
props.field.issues,
|
|
||||||
props.field.isValidating,
|
|
||||||
props.field.isSubmitting,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex direction="column" gap="1">
|
|
||||||
<TextField.Root
|
|
||||||
value={input.value}
|
|
||||||
onChange={e => input.setValue(e.target.value)}
|
|
||||||
disabled={(input.optional && !input.enabled) || isSubmitting}
|
|
||||||
{...Struct.omit(props, "optional", "defaultValue")}
|
|
||||||
>
|
|
||||||
{input.optional &&
|
|
||||||
<TextField.Slot side="left">
|
|
||||||
<Switch
|
|
||||||
size="1"
|
|
||||||
checked={input.enabled}
|
|
||||||
onCheckedChange={input.setEnabled}
|
|
||||||
/>
|
|
||||||
</TextField.Slot>
|
|
||||||
}
|
|
||||||
|
|
||||||
{isValidating &&
|
|
||||||
<TextField.Slot side="right">
|
|
||||||
<Spinner />
|
|
||||||
</TextField.Slot>
|
|
||||||
}
|
|
||||||
|
|
||||||
{props.children}
|
|
||||||
</TextField.Root>
|
|
||||||
|
|
||||||
{Option.match(Array.head(issues), {
|
|
||||||
onSome: issue => (
|
|
||||||
<Callout.Root>
|
|
||||||
<Callout.Text>{issue.message}</Callout.Text>
|
|
||||||
</Callout.Root>
|
|
||||||
),
|
|
||||||
|
|
||||||
onNone: () => <></>,
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}) {}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { DateTime, Effect, Option, ParseResult, Schema } from "effect"
|
|
||||||
|
|
||||||
|
|
||||||
export class DateTimeUtcFromZoned extends Schema.transformOrFail(
|
|
||||||
Schema.DateTimeZonedFromSelf,
|
|
||||||
Schema.DateTimeUtcFromSelf,
|
|
||||||
{
|
|
||||||
strict: true,
|
|
||||||
encode: DateTime.setZoneCurrent,
|
|
||||||
decode: i => ParseResult.succeed(DateTime.toUtc(i)),
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class DateTimeZonedFromUtc extends Schema.transformOrFail(
|
|
||||||
Schema.DateTimeUtcFromSelf,
|
|
||||||
Schema.DateTimeZonedFromSelf,
|
|
||||||
{
|
|
||||||
strict: true,
|
|
||||||
encode: a => ParseResult.succeed(DateTime.toUtc(a)),
|
|
||||||
decode: DateTime.setZoneCurrent,
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class DateTimeUtcFromZonedInput extends Schema.transformOrFail(
|
|
||||||
Schema.String,
|
|
||||||
DateTimeUtcFromZoned,
|
|
||||||
{
|
|
||||||
strict: true,
|
|
||||||
encode: a => ParseResult.succeed(DateTime.formatIsoZoned(a).slice(0, 16)),
|
|
||||||
decode: (i, _, ast) => Effect.flatMap(
|
|
||||||
DateTime.CurrentTimeZone,
|
|
||||||
timeZone => Option.match(DateTime.makeZoned(i, { timeZone, adjustForTimeZone: true }), {
|
|
||||||
onSome: ParseResult.succeed,
|
|
||||||
onNone: () => ParseResult.fail(new ParseResult.Type(ast, i, `Unable to decode ${JSON.stringify(i)} into a DateTime.Zoned`)),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from "./datetime"
|
|
||||||
export * from "./json"
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import type { Schema } from "effect"
|
|
||||||
import type { JsonValue } from "type-fest"
|
|
||||||
|
|
||||||
|
|
||||||
export const assertEncodedJsonifiable = <S extends Schema.Schema<A, I, R>, A, I extends JsonValue, R>(schema: S & Schema.Schema<A, I, R>): S => schema
|
|
||||||
export const assertTypeJsonifiable = <S extends Schema.Schema<A, I, R>, A extends JsonValue, I, R>(schema: S & Schema.Schema<A, I, R>): S => schema
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||||
import { ReactRuntime } from "effect-fc"
|
import { ReactManagedRuntime } from "effect-fc"
|
||||||
import { StrictMode } from "react"
|
import { StrictMode } from "react"
|
||||||
import { createRoot } from "react-dom/client"
|
import { createRoot } from "react-dom/client"
|
||||||
import { routeTree } from "./routeTree.gen"
|
import { routeTree } from "./routeTree.gen"
|
||||||
@@ -14,11 +14,10 @@ declare module "@tanstack/react-router" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: React entrypoint
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ReactRuntime.Provider runtime={runtime}>
|
<ReactManagedRuntime.Provider runtime={runtime}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</ReactRuntime.Provider>
|
</ReactManagedRuntime.Provider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,30 +9,11 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as ResultRouteImport } from './routes/result'
|
|
||||||
import { Route as QueryRouteImport } from './routes/query'
|
|
||||||
import { Route as FormRouteImport } from './routes/form'
|
|
||||||
import { Route as BlankRouteImport } from './routes/blank'
|
import { Route as BlankRouteImport } from './routes/blank'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as DevMemoRouteImport } from './routes/dev/memo'
|
import { Route as DevMemoRouteImport } from './routes/dev/memo'
|
||||||
import { Route as DevContextRouteImport } from './routes/dev/context'
|
|
||||||
import { Route as DevAsyncRenderingRouteImport } from './routes/dev/async-rendering'
|
import { Route as DevAsyncRenderingRouteImport } from './routes/dev/async-rendering'
|
||||||
|
|
||||||
const ResultRoute = ResultRouteImport.update({
|
|
||||||
id: '/result',
|
|
||||||
path: '/result',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const QueryRoute = QueryRouteImport.update({
|
|
||||||
id: '/query',
|
|
||||||
path: '/query',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const FormRoute = FormRouteImport.update({
|
|
||||||
id: '/form',
|
|
||||||
path: '/form',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const BlankRoute = BlankRouteImport.update({
|
const BlankRoute = BlankRouteImport.update({
|
||||||
id: '/blank',
|
id: '/blank',
|
||||||
path: '/blank',
|
path: '/blank',
|
||||||
@@ -48,11 +29,6 @@ const DevMemoRoute = DevMemoRouteImport.update({
|
|||||||
path: '/dev/memo',
|
path: '/dev/memo',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const DevContextRoute = DevContextRouteImport.update({
|
|
||||||
id: '/dev/context',
|
|
||||||
path: '/dev/context',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({
|
const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({
|
||||||
id: '/dev/async-rendering',
|
id: '/dev/async-rendering',
|
||||||
path: '/dev/async-rendering',
|
path: '/dev/async-rendering',
|
||||||
@@ -62,101 +38,39 @@ const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/blank': typeof BlankRoute
|
'/blank': typeof BlankRoute
|
||||||
'/form': typeof FormRoute
|
|
||||||
'/query': typeof QueryRoute
|
|
||||||
'/result': typeof ResultRoute
|
|
||||||
'/dev/async-rendering': typeof DevAsyncRenderingRoute
|
'/dev/async-rendering': typeof DevAsyncRenderingRoute
|
||||||
'/dev/context': typeof DevContextRoute
|
|
||||||
'/dev/memo': typeof DevMemoRoute
|
'/dev/memo': typeof DevMemoRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/blank': typeof BlankRoute
|
'/blank': typeof BlankRoute
|
||||||
'/form': typeof FormRoute
|
|
||||||
'/query': typeof QueryRoute
|
|
||||||
'/result': typeof ResultRoute
|
|
||||||
'/dev/async-rendering': typeof DevAsyncRenderingRoute
|
'/dev/async-rendering': typeof DevAsyncRenderingRoute
|
||||||
'/dev/context': typeof DevContextRoute
|
|
||||||
'/dev/memo': typeof DevMemoRoute
|
'/dev/memo': typeof DevMemoRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/blank': typeof BlankRoute
|
'/blank': typeof BlankRoute
|
||||||
'/form': typeof FormRoute
|
|
||||||
'/query': typeof QueryRoute
|
|
||||||
'/result': typeof ResultRoute
|
|
||||||
'/dev/async-rendering': typeof DevAsyncRenderingRoute
|
'/dev/async-rendering': typeof DevAsyncRenderingRoute
|
||||||
'/dev/context': typeof DevContextRoute
|
|
||||||
'/dev/memo': typeof DevMemoRoute
|
'/dev/memo': typeof DevMemoRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths: '/' | '/blank' | '/dev/async-rendering' | '/dev/memo'
|
||||||
| '/'
|
|
||||||
| '/blank'
|
|
||||||
| '/form'
|
|
||||||
| '/query'
|
|
||||||
| '/result'
|
|
||||||
| '/dev/async-rendering'
|
|
||||||
| '/dev/context'
|
|
||||||
| '/dev/memo'
|
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to: '/' | '/blank' | '/dev/async-rendering' | '/dev/memo'
|
||||||
| '/'
|
id: '__root__' | '/' | '/blank' | '/dev/async-rendering' | '/dev/memo'
|
||||||
| '/blank'
|
|
||||||
| '/form'
|
|
||||||
| '/query'
|
|
||||||
| '/result'
|
|
||||||
| '/dev/async-rendering'
|
|
||||||
| '/dev/context'
|
|
||||||
| '/dev/memo'
|
|
||||||
id:
|
|
||||||
| '__root__'
|
|
||||||
| '/'
|
|
||||||
| '/blank'
|
|
||||||
| '/form'
|
|
||||||
| '/query'
|
|
||||||
| '/result'
|
|
||||||
| '/dev/async-rendering'
|
|
||||||
| '/dev/context'
|
|
||||||
| '/dev/memo'
|
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
BlankRoute: typeof BlankRoute
|
BlankRoute: typeof BlankRoute
|
||||||
FormRoute: typeof FormRoute
|
|
||||||
QueryRoute: typeof QueryRoute
|
|
||||||
ResultRoute: typeof ResultRoute
|
|
||||||
DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute
|
DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute
|
||||||
DevContextRoute: typeof DevContextRoute
|
|
||||||
DevMemoRoute: typeof DevMemoRoute
|
DevMemoRoute: typeof DevMemoRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/result': {
|
|
||||||
id: '/result'
|
|
||||||
path: '/result'
|
|
||||||
fullPath: '/result'
|
|
||||||
preLoaderRoute: typeof ResultRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/query': {
|
|
||||||
id: '/query'
|
|
||||||
path: '/query'
|
|
||||||
fullPath: '/query'
|
|
||||||
preLoaderRoute: typeof QueryRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/form': {
|
|
||||||
id: '/form'
|
|
||||||
path: '/form'
|
|
||||||
fullPath: '/form'
|
|
||||||
preLoaderRoute: typeof FormRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/blank': {
|
'/blank': {
|
||||||
id: '/blank'
|
id: '/blank'
|
||||||
path: '/blank'
|
path: '/blank'
|
||||||
@@ -178,13 +92,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DevMemoRouteImport
|
preLoaderRoute: typeof DevMemoRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/dev/context': {
|
|
||||||
id: '/dev/context'
|
|
||||||
path: '/dev/context'
|
|
||||||
fullPath: '/dev/context'
|
|
||||||
preLoaderRoute: typeof DevContextRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/dev/async-rendering': {
|
'/dev/async-rendering': {
|
||||||
id: '/dev/async-rendering'
|
id: '/dev/async-rendering'
|
||||||
path: '/dev/async-rendering'
|
path: '/dev/async-rendering'
|
||||||
@@ -198,11 +105,7 @@ declare module '@tanstack/react-router' {
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
BlankRoute: BlankRoute,
|
BlankRoute: BlankRoute,
|
||||||
FormRoute: FormRoute,
|
|
||||||
QueryRoute: QueryRoute,
|
|
||||||
ResultRoute: ResultRoute,
|
|
||||||
DevAsyncRenderingRoute: DevAsyncRenderingRoute,
|
DevAsyncRenderingRoute: DevAsyncRenderingRoute,
|
||||||
DevContextRoute: DevContextRoute,
|
|
||||||
DevMemoRoute: DevMemoRoute,
|
DevMemoRoute: DevMemoRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
|
import { runtime } from "@/runtime"
|
||||||
import { Flex, Text, TextField } from "@radix-ui/themes"
|
import { Flex, Text, TextField } from "@radix-ui/themes"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
||||||
import { Effect } from "effect"
|
import { Effect, Types } from "effect"
|
||||||
import { Async, Component, Memoized } from "effect-fc"
|
import { Component, Memoized, Suspense } from "effect-fc"
|
||||||
|
import { Hooks } from "effect-fc/hooks"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { runtime } from "@/runtime"
|
|
||||||
|
|
||||||
|
|
||||||
// Generator version
|
// Generator version
|
||||||
const RouteComponent = Component.makeUntraced(function* AsyncRendering() {
|
const RouteComponent = Component.make(Effect.fnUntraced(function* AsyncRendering() {
|
||||||
const MemoizedAsyncComponentFC = yield* MemoizedAsyncComponent.use
|
const MemoizedAsyncComponentFC = yield* MemoizedAsyncComponent
|
||||||
const AsyncComponentFC = yield* AsyncComponent.use
|
const AsyncComponentFC = yield* AsyncComponent
|
||||||
const [input, setInput] = React.useState("")
|
const [input, setInput] = React.useState("")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -24,7 +25,7 @@ const RouteComponent = Component.makeUntraced(function* AsyncRendering() {
|
|||||||
<AsyncComponentFC />
|
<AsyncComponentFC />
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
}).pipe(
|
})).pipe(
|
||||||
Component.withRuntime(runtime.context)
|
Component.withRuntime(runtime.context)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,8 +51,8 @@ const RouteComponent = Component.makeUntraced(function* AsyncRendering() {
|
|||||||
// )
|
// )
|
||||||
|
|
||||||
|
|
||||||
class AsyncComponent extends Component.makeUntraced("AsyncComponent")(function*() {
|
class AsyncComponent extends Component.make(Effect.fnUntraced(function* AsyncComponent() {
|
||||||
const SubComponentFC = yield* SubComponent.use
|
const SubComponentFC = yield* SubComponent
|
||||||
|
|
||||||
yield* Effect.sleep("500 millis") // Async operation
|
yield* Effect.sleep("500 millis") // Async operation
|
||||||
// Cannot use React hooks after the async operation
|
// Cannot use React hooks after the async operation
|
||||||
@@ -62,16 +63,18 @@ class AsyncComponent extends Component.makeUntraced("AsyncComponent")(function*(
|
|||||||
<SubComponentFC />
|
<SubComponentFC />
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
}).pipe(
|
})).pipe(
|
||||||
Async.async,
|
// Suspense.suspense,
|
||||||
Async.withOptions({ defaultFallback: <p>Loading...</p> }),
|
// Suspense.withOptions({ defaultFallback: <p>Loading...</p> }),
|
||||||
) {}
|
) {}
|
||||||
class MemoizedAsyncComponent extends Memoized.memoized(AsyncComponent) {}
|
const AsyncComponent2 = Suspense.withOptions(Suspense.suspense(AsyncComponent), {})
|
||||||
|
type T = Types.Simplify<typeof AsyncComponent2>
|
||||||
|
class MemoizedAsyncComponent extends Memoized.memo(AsyncComponent) {}
|
||||||
|
|
||||||
class SubComponent extends Component.makeUntraced("SubComponent")(function*() {
|
class SubComponent extends Component.make(Effect.fnUntraced(function* SubComponent() {
|
||||||
const [state] = React.useState(yield* Component.useOnMount(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
|
const [state] = React.useState(yield* Hooks.useOnce(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
|
||||||
return <Text>{state}</Text>
|
return <Text>{state}</Text>
|
||||||
}) {}
|
})) {}
|
||||||
|
|
||||||
export const Route = createFileRoute("/dev/async-rendering")({
|
export const Route = createFileRoute("/dev/async-rendering")({
|
||||||
component: RouteComponent
|
component: RouteComponent
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Container, Flex, Text, TextField } from "@radix-ui/themes"
|
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
|
||||||
import { Console, Effect } from "effect"
|
|
||||||
import { Component } from "effect-fc"
|
|
||||||
import * as React from "react"
|
|
||||||
import { runtime } from "@/runtime"
|
|
||||||
|
|
||||||
|
|
||||||
class SubService extends Effect.Service<SubService>()("SubService", {
|
|
||||||
effect: (value: string) => Effect.succeed({ value })
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const SubComponent = Component.makeUntraced("SubComponent")(function*() {
|
|
||||||
const service = yield* SubService
|
|
||||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
|
||||||
yield* Effect.addFinalizer(() => Console.log("SubComponent unmounted"))
|
|
||||||
yield* Console.log("SubComponent mounted")
|
|
||||||
}))
|
|
||||||
|
|
||||||
return <Text>{service.value}</Text>
|
|
||||||
})
|
|
||||||
|
|
||||||
const ContextView = Component.makeUntraced("ContextView")(function*() {
|
|
||||||
const [serviceValue, setServiceValue] = React.useState("test")
|
|
||||||
const SubServiceLayer = React.useMemo(() => SubService.Default(serviceValue), [serviceValue])
|
|
||||||
const SubComponentFC = yield* Effect.provide(SubComponent.use, yield* Component.useContextFromLayer(SubServiceLayer))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Flex direction="column" align="center">
|
|
||||||
<TextField.Root value={serviceValue} onChange={e => setServiceValue(e.target.value)} />
|
|
||||||
<SubComponentFC />
|
|
||||||
</Flex>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/dev/context")({
|
|
||||||
component: ContextView
|
|
||||||
})
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
|
import { runtime } from "@/runtime"
|
||||||
import { Flex, Text, TextField } from "@radix-ui/themes"
|
import { Flex, Text, TextField } from "@radix-ui/themes"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Component, Memoized } from "effect-fc"
|
import { Component, Memoized } from "effect-fc"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { runtime } from "@/runtime"
|
|
||||||
|
|
||||||
|
|
||||||
const RouteComponent = Component.makeUntraced("RouteComponent")(function*() {
|
const RouteComponent = Component.make(Effect.fnUntraced(function* RouteComponent() {
|
||||||
const [value, setValue] = React.useState("")
|
const [value, setValue] = React.useState("")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -17,20 +17,20 @@ const RouteComponent = Component.makeUntraced("RouteComponent")(function*() {
|
|||||||
onChange={e => setValue(e.target.value)}
|
onChange={e => setValue(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{yield* Effect.map(SubComponent.use, FC => <FC />)}
|
{yield* Effect.map(SubComponent, FC => <FC />)}
|
||||||
{yield* Effect.map(MemoizedSubComponent.use, FC => <FC />)}
|
{yield* Effect.map(MemoizedSubComponent, FC => <FC />)}
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
}).pipe(
|
})).pipe(
|
||||||
Component.withRuntime(runtime.context)
|
Component.withRuntime(runtime.context)
|
||||||
)
|
)
|
||||||
|
|
||||||
class SubComponent extends Component.makeUntraced("SubComponent")(function*() {
|
class SubComponent extends Component.make(Effect.fnUntraced(function* SubComponent() {
|
||||||
const id = yield* makeUuid4.pipe(Effect.provide(GetRandomValues.CryptoRandom))
|
const id = yield* makeUuid4.pipe(Effect.provide(GetRandomValues.CryptoRandom))
|
||||||
return <Text>{id}</Text>
|
return <Text>{id}</Text>
|
||||||
}) {}
|
})) {}
|
||||||
|
|
||||||
class MemoizedSubComponent extends Memoized.memoized(SubComponent) {}
|
class MemoizedSubComponent extends Memoized.memo(SubComponent) {}
|
||||||
|
|
||||||
export const Route = createFileRoute("/dev/memo")({
|
export const Route = createFileRoute("/dev/memo")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
import { Button, Container, Flex, Text } from "@radix-ui/themes"
|
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
|
||||||
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
|
|
||||||
import { Component, Form, Subscribable } from "effect-fc"
|
|
||||||
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
|
||||||
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
|
||||||
import { runtime } from "@/runtime"
|
|
||||||
|
|
||||||
|
|
||||||
const email = Schema.pattern<typeof Schema.String>(
|
|
||||||
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i,
|
|
||||||
|
|
||||||
{
|
|
||||||
identifier: "email",
|
|
||||||
title: "email",
|
|
||||||
message: () => "Not an email address",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const RegisterFormSchema = Schema.Struct({
|
|
||||||
email: Schema.String.pipe(email),
|
|
||||||
password: Schema.String.pipe(Schema.minLength(3)),
|
|
||||||
birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
|
|
||||||
})
|
|
||||||
|
|
||||||
const RegisterFormSubmitSchema = Schema.Struct({
|
|
||||||
email: Schema.transformOrFail(
|
|
||||||
Schema.String,
|
|
||||||
Schema.String,
|
|
||||||
{
|
|
||||||
decode: (input, _options, ast) => input !== "admin@admin.com"
|
|
||||||
? ParseResult.succeed(input)
|
|
||||||
: ParseResult.fail(new ParseResult.Type(ast, input, "This email is already in use.")),
|
|
||||||
encode: ParseResult.succeed,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
password: Schema.String,
|
|
||||||
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
|
|
||||||
})
|
|
||||||
|
|
||||||
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
|
|
||||||
scoped: Form.service({
|
|
||||||
schema: RegisterFormSchema.pipe(
|
|
||||||
Schema.compose(
|
|
||||||
Schema.transformOrFail(
|
|
||||||
Schema.typeSchema(RegisterFormSchema),
|
|
||||||
Schema.typeSchema(RegisterFormSchema),
|
|
||||||
{
|
|
||||||
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
|
|
||||||
encode: ParseResult.succeed,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
initialEncodedValue: { email: "", password: "", birth: Option.none() },
|
|
||||||
f: Effect.fnUntraced(function*([value]) {
|
|
||||||
yield* Effect.sleep("500 millis")
|
|
||||||
return yield* Schema.decode(RegisterFormSubmitSchema)(value)
|
|
||||||
}),
|
|
||||||
debounce: "500 millis",
|
|
||||||
})
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class RegisterFormView extends Component.make("RegisterFormView")(function*() {
|
|
||||||
const form = yield* RegisterFormService
|
|
||||||
const [canSubmit, submitResult] = yield* Subscribable.useSubscribables([
|
|
||||||
form.canSubmit,
|
|
||||||
form.mutation.result,
|
|
||||||
])
|
|
||||||
|
|
||||||
const runPromise = yield* Component.useRunPromise()
|
|
||||||
const TextFieldFormInput = yield* TextFieldFormInputView.use
|
|
||||||
|
|
||||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
|
||||||
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
|
|
||||||
yield* Console.log("RegisterFormView mounted")
|
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container width="300">
|
|
||||||
<form onSubmit={e => {
|
|
||||||
e.preventDefault()
|
|
||||||
void runPromise(form.submit)
|
|
||||||
}}>
|
|
||||||
<Flex direction="column" gap="2">
|
|
||||||
<TextFieldFormInput
|
|
||||||
field={yield* form.field(["email"])}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextFieldFormInput
|
|
||||||
field={yield* form.field(["password"])}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextFieldFormInput
|
|
||||||
optional
|
|
||||||
type="datetime-local"
|
|
||||||
field={yield* form.field(["birth"])}
|
|
||||||
defaultValue=""
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button disabled={!canSubmit}>Submit</Button>
|
|
||||||
</Flex>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{Match.value(submitResult).pipe(
|
|
||||||
Match.tag("Initial", () => <></>),
|
|
||||||
Match.tag("Running", () => <Text>Submitting...</Text>),
|
|
||||||
Match.tag("Success", () => <Text>Submitted successfully!</Text>),
|
|
||||||
Match.tag("Failure", e => <Text>Error: {e.cause.toString()}</Text>),
|
|
||||||
Match.exhaustive,
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const RegisterPage = Component.make("RegisterPageView")(function*() {
|
|
||||||
const RegisterForm = yield* Effect.provide(
|
|
||||||
RegisterFormView.use,
|
|
||||||
yield* Component.useContextFromLayer(RegisterFormService.Default),
|
|
||||||
)
|
|
||||||
|
|
||||||
return <RegisterForm />
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/form")({
|
|
||||||
component: RegisterPage
|
|
||||||
})
|
|
||||||
@@ -1,24 +1,21 @@
|
|||||||
|
import { runtime } from "@/runtime"
|
||||||
|
import { Todos } from "@/todo/Todos"
|
||||||
|
import { TodosState } from "@/todo/TodosState.service"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Component } from "effect-fc"
|
import { Component } from "effect-fc"
|
||||||
import { runtime } from "@/runtime"
|
import { Hooks } from "effect-fc/hooks"
|
||||||
import { TodosState } from "@/todo/TodosState"
|
|
||||||
import { TodosView } from "@/todo/TodosView"
|
|
||||||
|
|
||||||
|
|
||||||
const TodosStateLive = TodosState.Default("todos")
|
const TodosStateLive = TodosState.Default("todos")
|
||||||
|
|
||||||
const Index = Component.make("IndexView")(function*() {
|
export const Route = createFileRoute("/")({
|
||||||
const Todos = yield* Effect.provide(
|
component: Component.make(Effect.fnUntraced(function* Index() {
|
||||||
TodosView.use,
|
return yield* Todos.pipe(
|
||||||
yield* Component.useContextFromLayer(TodosStateLive),
|
Effect.map(FC => <FC />),
|
||||||
|
Effect.provide(yield* Hooks.useContext(TodosStateLive, { finalizerExecutionMode: "fork" })),
|
||||||
)
|
)
|
||||||
|
})).pipe(
|
||||||
return <Todos />
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
Component.withRuntime(runtime.context)
|
||||||
)
|
)
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
|
||||||
component: Index
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import { HttpClient, type HttpClientError } from "@effect/platform"
|
|
||||||
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
|
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
|
||||||
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream } from "effect"
|
|
||||||
import { Component, ErrorObserver, Mutation, Query, Result, Subscribable, SubscriptionRef } from "effect-fc"
|
|
||||||
import { runtime } from "@/runtime"
|
|
||||||
|
|
||||||
|
|
||||||
const Post = Schema.Struct({
|
|
||||||
userId: Schema.Int,
|
|
||||||
id: Schema.Int,
|
|
||||||
title: Schema.String,
|
|
||||||
body: Schema.String,
|
|
||||||
})
|
|
||||||
|
|
||||||
const ResultView = Component.make("ResultView")(function*() {
|
|
||||||
const runPromise = yield* Component.useRunPromise()
|
|
||||||
|
|
||||||
const [idRef, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
|
||||||
const idRef = yield* SubscriptionRef.make(1)
|
|
||||||
const key = Stream.map(idRef.changes, id => [id] as const)
|
|
||||||
|
|
||||||
const query = yield* Query.service({
|
|
||||||
key,
|
|
||||||
f: ([id]) => HttpClient.HttpClient.pipe(
|
|
||||||
Effect.tap(Effect.sleep("500 millis")),
|
|
||||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
|
||||||
Effect.andThen(response => response.json),
|
|
||||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
|
||||||
),
|
|
||||||
staleTime: "10 seconds",
|
|
||||||
})
|
|
||||||
|
|
||||||
const mutation = yield* Mutation.make({
|
|
||||||
f: ([id]: readonly [id: number]) => HttpClient.HttpClient.pipe(
|
|
||||||
Effect.tap(Effect.sleep("500 millis")),
|
|
||||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
|
||||||
Effect.andThen(response => response.json),
|
|
||||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
return [idRef, query, mutation] as const
|
|
||||||
}))
|
|
||||||
|
|
||||||
const [id, setId] = yield* SubscriptionRef.useSubscriptionRefState(idRef)
|
|
||||||
const [queryResult, mutationResult] = yield* Subscribable.useSubscribables([query.result, mutation.result])
|
|
||||||
|
|
||||||
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
|
||||||
Effect.andThen(observer => observer.subscribe),
|
|
||||||
Effect.andThen(Stream.fromQueue),
|
|
||||||
Stream.unwrapScoped,
|
|
||||||
Stream.runForEach(flow(
|
|
||||||
Cause.failures,
|
|
||||||
Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"),
|
|
||||||
Option.match({
|
|
||||||
onSome: e => Console.log("ResultView HttpClient error", e),
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
}),
|
|
||||||
)),
|
|
||||||
Effect.forkScoped,
|
|
||||||
))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Flex direction="column" align="center" gap="2">
|
|
||||||
<Slider
|
|
||||||
value={[id]}
|
|
||||||
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{Match.value(queryResult).pipe(
|
|
||||||
Match.tag("Running", () => <Text>Loading...</Text>),
|
|
||||||
Match.tag("Success", result => <>
|
|
||||||
<Heading>{result.value.title}</Heading>
|
|
||||||
<Text>{result.value.body}</Text>
|
|
||||||
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
|
|
||||||
</>),
|
|
||||||
Match.tag("Failure", result =>
|
|
||||||
<Text>An error has occured: {result.cause.toString()}</Text>
|
|
||||||
),
|
|
||||||
Match.orElse(() => <></>),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Flex direction="row" justify="center" align="center" gap="1">
|
|
||||||
<Button onClick={() => runPromise(query.refresh)}>Refresh</Button>
|
|
||||||
<Button onClick={() => runPromise(query.invalidateCache)}>Invalidate cache</Button>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{Match.value(mutationResult).pipe(
|
|
||||||
Match.tag("Running", () => <Text>Loading...</Text>),
|
|
||||||
Match.tag("Success", result => <>
|
|
||||||
<Heading>{result.value.title}</Heading>
|
|
||||||
<Text>{result.value.body}</Text>
|
|
||||||
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
|
|
||||||
</>),
|
|
||||||
Match.tag("Failure", result =>
|
|
||||||
<Text>An error has occured: {result.cause.toString()}</Text>
|
|
||||||
),
|
|
||||||
Match.orElse(() => <></>),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Flex direction="row" justify="center" align="center" gap="1">
|
|
||||||
<Button onClick={() => runPromise(Effect.andThen(idRef, id => mutation.mutate([id])))}>Mutate</Button>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/query")({
|
|
||||||
component: Component.withRuntime(ResultView, runtime.context)
|
|
||||||
})
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { HttpClient, type HttpClientError } from "@effect/platform"
|
|
||||||
import { Container, Heading, Text } from "@radix-ui/themes"
|
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
|
||||||
import { Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream } from "effect"
|
|
||||||
import { Component, ErrorObserver, Result, Subscribable } from "effect-fc"
|
|
||||||
import { runtime } from "@/runtime"
|
|
||||||
|
|
||||||
|
|
||||||
const Post = Schema.Struct({
|
|
||||||
userId: Schema.Int,
|
|
||||||
id: Schema.Int,
|
|
||||||
title: Schema.String,
|
|
||||||
body: Schema.String,
|
|
||||||
})
|
|
||||||
|
|
||||||
const ResultView = Component.makeUntraced("Result")(function*() {
|
|
||||||
const [resultSubscribable] = yield* Component.useOnMount(() => HttpClient.HttpClient.pipe(
|
|
||||||
Effect.andThen(client => client.get("https://jsonplaceholder.typicode.com/posts/1")),
|
|
||||||
Effect.andThen(response => response.json),
|
|
||||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
|
||||||
Effect.tap(Effect.sleep("250 millis")),
|
|
||||||
Result.forkEffect,
|
|
||||||
))
|
|
||||||
const [result] = yield* Subscribable.useSubscribables([resultSubscribable])
|
|
||||||
|
|
||||||
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
|
||||||
Effect.andThen(observer => observer.subscribe),
|
|
||||||
Effect.andThen(Stream.fromQueue),
|
|
||||||
Stream.unwrapScoped,
|
|
||||||
Stream.runForEach(flow(
|
|
||||||
Cause.failures,
|
|
||||||
Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"),
|
|
||||||
Option.match({
|
|
||||||
onSome: e => Console.log("ResultView HttpClient error", e),
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
}),
|
|
||||||
)),
|
|
||||||
Effect.forkScoped,
|
|
||||||
))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
{Match.value(result).pipe(
|
|
||||||
Match.tag("Running", () => <Text>Loading...</Text>),
|
|
||||||
Match.tag("Success", result => <>
|
|
||||||
<Heading>{result.value.title}</Heading>
|
|
||||||
<Text>{result.value.body}</Text>
|
|
||||||
</>),
|
|
||||||
Match.tag("Failure", result =>
|
|
||||||
<Text>An error has occured: {result.cause.toString()}</Text>
|
|
||||||
),
|
|
||||||
Match.orElse(() => <></>),
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/result")({
|
|
||||||
component: Component.withRuntime(ResultView, runtime.context)
|
|
||||||
})
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import { FetchHttpClient } from "@effect/platform"
|
import { FetchHttpClient } from "@effect/platform"
|
||||||
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
|
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
|
||||||
import { DateTime, Layer } from "effect"
|
import { Layer } from "effect"
|
||||||
import { ReactRuntime } from "effect-fc"
|
import { ReactManagedRuntime } from "effect-fc"
|
||||||
|
|
||||||
|
|
||||||
export const AppLive = Layer.empty.pipe(
|
export const AppLive = Layer.empty.pipe(
|
||||||
Layer.provideMerge(DateTime.layerCurrentZoneLocal),
|
|
||||||
Layer.provideMerge(Clipboard.layer),
|
Layer.provideMerge(Clipboard.layer),
|
||||||
Layer.provideMerge(Geolocation.layer),
|
Layer.provideMerge(Geolocation.layer),
|
||||||
Layer.provideMerge(Permissions.layer),
|
Layer.provideMerge(Permissions.layer),
|
||||||
Layer.provideMerge(FetchHttpClient.layer),
|
Layer.provideMerge(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const runtime = ReactRuntime.make(AppLive)
|
export const runtime = ReactManagedRuntime.make(AppLive)
|
||||||
|
|||||||
127
packages/example/src/todo/Todo.tsx
Normal file
127
packages/example/src/todo/Todo.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import * as Domain from "@/domain"
|
||||||
|
import { Box, Button, Callout, Flex, IconButton, Text, TextArea } from "@radix-ui/themes"
|
||||||
|
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
||||||
|
import { Chunk, Effect, Match, Option, ParseResult, Ref, Runtime, Schema, SubscriptionRef } from "effect"
|
||||||
|
import { Component, Memoized } from "effect-fc"
|
||||||
|
import { Hooks } from "effect-fc/hooks"
|
||||||
|
import { SubscriptionSubRef } from "effect-fc/types"
|
||||||
|
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
|
||||||
|
import { FaDeleteLeft } from "react-icons/fa6"
|
||||||
|
import { TodosState } from "./TodosState.service"
|
||||||
|
|
||||||
|
|
||||||
|
const makeTodo = makeUuid4.pipe(
|
||||||
|
Effect.map(id => Domain.Todo.Todo.make({
|
||||||
|
id,
|
||||||
|
content: "",
|
||||||
|
completedAt: Option.none(),
|
||||||
|
})),
|
||||||
|
Effect.provide(GetRandomValues.CryptoRandom),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
export type TodoProps = (
|
||||||
|
| { readonly _tag: "new", readonly index?: never }
|
||||||
|
| { readonly _tag: "edit", readonly index: number }
|
||||||
|
)
|
||||||
|
|
||||||
|
export class Todo extends Component.make(Effect.fnUntraced(function* Todo(props: TodoProps) {
|
||||||
|
const runtime = yield* Effect.runtime()
|
||||||
|
const state = yield* TodosState
|
||||||
|
|
||||||
|
const [ref, contentRef] = yield* Hooks.useMemo(() => Match.value(props).pipe(
|
||||||
|
Match.tag("new", () => Effect.andThen(makeTodo, SubscriptionRef.make)),
|
||||||
|
Match.tag("edit", ({ index }) => Effect.succeed(SubscriptionSubRef.makeFromChunkRef(state.ref, index))),
|
||||||
|
Match.exhaustive,
|
||||||
|
|
||||||
|
Effect.map(ref => [
|
||||||
|
ref,
|
||||||
|
SubscriptionSubRef.makeFromPath(ref, ["content"]),
|
||||||
|
] as const),
|
||||||
|
), [props._tag, props.index])
|
||||||
|
|
||||||
|
const [size] = yield* Hooks.useSubscribeRefs(state.sizeRef)
|
||||||
|
const contentInput = yield* Hooks.useInput({ ref: contentRef, schema: Schema.Any })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" align="stretch" gap="2">
|
||||||
|
{Option.isSome(contentInput.error) &&
|
||||||
|
<Callout.Root color="red">
|
||||||
|
<Callout.Text>
|
||||||
|
{ParseResult.ArrayFormatter.formatErrorSync(contentInput.error.value).map(e => <>
|
||||||
|
<Text>• {e.message}</Text><br />
|
||||||
|
</>)}
|
||||||
|
</Callout.Text>
|
||||||
|
</Callout.Root>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Flex direction="row" align="center" gap="2">
|
||||||
|
<Box flexGrow="1">
|
||||||
|
<TextArea
|
||||||
|
value={contentInput.value}
|
||||||
|
onChange={contentInput.onChange}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{props._tag === "edit" &&
|
||||||
|
<Flex direction="column" justify="center" align="center" gap="1">
|
||||||
|
<IconButton
|
||||||
|
disabled={props.index <= 0}
|
||||||
|
onClick={() => Runtime.runSync(runtime)(
|
||||||
|
SubscriptionRef.updateEffect(state.ref, todos => Effect.gen(function*() {
|
||||||
|
if (props.index <= 0) return yield* Option.none()
|
||||||
|
return todos.pipe(
|
||||||
|
Chunk.replace(props.index, yield* Chunk.get(todos, props.index - 1)),
|
||||||
|
Chunk.replace(props.index - 1, yield* ref),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FaArrowUp />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
disabled={props.index >= size - 1}
|
||||||
|
onClick={() => Runtime.runSync(runtime)(
|
||||||
|
SubscriptionRef.updateEffect(state.ref, todos => Effect.gen(function*() {
|
||||||
|
if (props.index >= size - 1) return yield* Option.none()
|
||||||
|
return todos.pipe(
|
||||||
|
Chunk.replace(props.index, yield* Chunk.get(todos, props.index + 1)),
|
||||||
|
Chunk.replace(props.index + 1, yield* ref),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FaArrowDown />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={() => Runtime.runSync(runtime)(
|
||||||
|
Ref.update(state.ref, Chunk.remove(props.index))
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FaDeleteLeft />
|
||||||
|
</IconButton>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{props._tag === "new" &&
|
||||||
|
<Flex direction="row" justify="center">
|
||||||
|
<Button
|
||||||
|
onClick={() => ref.pipe(
|
||||||
|
Effect.andThen(todo => Ref.update(state.ref, Chunk.prepend(todo))),
|
||||||
|
Effect.andThen(makeTodo),
|
||||||
|
Effect.andThen(todo => Ref.set(ref, todo)),
|
||||||
|
Runtime.runSync(runtime),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
})).pipe(
|
||||||
|
Memoized.memo
|
||||||
|
) {}
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { Box, Button, Flex, IconButton } from "@radix-ui/themes"
|
|
||||||
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
|
||||||
import { Chunk, type DateTime, Effect, Match, Option, Ref, Schema, Stream } from "effect"
|
|
||||||
import { Component, Form, Subscribable } from "effect-fc"
|
|
||||||
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
|
|
||||||
import { FaDeleteLeft } from "react-icons/fa6"
|
|
||||||
import * as Domain from "@/domain"
|
|
||||||
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
|
||||||
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
|
||||||
import { TodosState } from "./TodosState"
|
|
||||||
|
|
||||||
|
|
||||||
const TodoFormSchema = Schema.compose(Schema.Struct({
|
|
||||||
...Domain.Todo.Todo.fields,
|
|
||||||
completedAt: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
|
|
||||||
}), Domain.Todo.Todo)
|
|
||||||
|
|
||||||
const makeTodo = makeUuid4.pipe(
|
|
||||||
Effect.map(id => Domain.Todo.Todo.make({
|
|
||||||
id,
|
|
||||||
content: "",
|
|
||||||
completedAt: Option.none(),
|
|
||||||
})),
|
|
||||||
Effect.provide(GetRandomValues.CryptoRandom),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
export type TodoProps = (
|
|
||||||
| { readonly _tag: "new" }
|
|
||||||
| { readonly _tag: "edit", readonly id: string }
|
|
||||||
)
|
|
||||||
|
|
||||||
export class TodoView extends Component.make("TodoView")(function*(props: TodoProps) {
|
|
||||||
const state = yield* TodosState
|
|
||||||
|
|
||||||
const [
|
|
||||||
indexRef,
|
|
||||||
form,
|
|
||||||
contentField,
|
|
||||||
completedAtField,
|
|
||||||
] = yield* Component.useOnChange(() => Effect.gen(function*() {
|
|
||||||
const indexRef = Match.value(props).pipe(
|
|
||||||
Match.tag("new", () => Subscribable.make({ get: Effect.succeed(-1), changes: Stream.make(-1) })),
|
|
||||||
Match.tag("edit", ({ id }) => state.getIndexSubscribable(id)),
|
|
||||||
Match.exhaustive,
|
|
||||||
)
|
|
||||||
|
|
||||||
const form = yield* Form.service({
|
|
||||||
schema: TodoFormSchema,
|
|
||||||
initialEncodedValue: yield* Schema.encode(TodoFormSchema)(
|
|
||||||
yield* Match.value(props).pipe(
|
|
||||||
Match.tag("new", () => makeTodo),
|
|
||||||
Match.tag("edit", ({ id }) => state.getElementRef(id)),
|
|
||||||
Match.exhaustive,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
f: ([todo, form]) => Match.value(props).pipe(
|
|
||||||
Match.tag("new", () => Ref.update(state.ref, Chunk.prepend(todo)).pipe(
|
|
||||||
Effect.andThen(makeTodo),
|
|
||||||
Effect.andThen(Schema.encode(TodoFormSchema)),
|
|
||||||
Effect.andThen(v => Ref.set(form.encodedValue, v)),
|
|
||||||
)),
|
|
||||||
Match.tag("edit", ({ id }) => Ref.set(state.getElementRef(id), todo)),
|
|
||||||
Match.exhaustive,
|
|
||||||
),
|
|
||||||
autosubmit: props._tag === "edit",
|
|
||||||
debounce: "250 millis",
|
|
||||||
})
|
|
||||||
|
|
||||||
return [
|
|
||||||
indexRef,
|
|
||||||
form,
|
|
||||||
yield* form.field(["content"]),
|
|
||||||
yield* form.field(["completedAt"]),
|
|
||||||
] as const
|
|
||||||
}), [props._tag, props._tag === "edit" ? props.id : undefined])
|
|
||||||
|
|
||||||
const [index, size, canSubmit] = yield* Subscribable.useSubscribables([
|
|
||||||
indexRef,
|
|
||||||
state.sizeSubscribable,
|
|
||||||
form.canSubmit,
|
|
||||||
])
|
|
||||||
|
|
||||||
const runSync = yield* Component.useRunSync()
|
|
||||||
const runPromise = yield* Component.useRunPromise<DateTime.CurrentTimeZone>()
|
|
||||||
const TextFieldFormInput = yield* TextFieldFormInputView.use
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex direction="row" align="center" gap="2">
|
|
||||||
<Box flexGrow="1">
|
|
||||||
<Flex direction="column" align="stretch" gap="2">
|
|
||||||
<TextFieldFormInput field={contentField} />
|
|
||||||
|
|
||||||
<Flex direction="row" justify="center" align="center" gap="2">
|
|
||||||
<TextFieldFormInput
|
|
||||||
optional
|
|
||||||
field={completedAtField}
|
|
||||||
type="datetime-local"
|
|
||||||
defaultValue=""
|
|
||||||
/>
|
|
||||||
|
|
||||||
{props._tag === "new" &&
|
|
||||||
<Button disabled={!canSubmit} onClick={() => void runPromise(form.submit)}>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{props._tag === "edit" &&
|
|
||||||
<Flex direction="column" justify="center" align="center" gap="1">
|
|
||||||
<IconButton
|
|
||||||
disabled={index <= 0}
|
|
||||||
onClick={() => runSync(state.moveLeft(props.id))}
|
|
||||||
>
|
|
||||||
<FaArrowUp />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
disabled={index >= size - 1}
|
|
||||||
onClick={() => runSync(state.moveRight(props.id))}
|
|
||||||
>
|
|
||||||
<FaArrowDown />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<IconButton onClick={() => runSync(state.remove(props.id))}>
|
|
||||||
<FaDeleteLeft />
|
|
||||||
</IconButton>
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}) {}
|
|
||||||
33
packages/example/src/todo/Todos.tsx
Normal file
33
packages/example/src/todo/Todos.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Container, Flex, Heading } from "@radix-ui/themes"
|
||||||
|
import { Chunk, Console, Effect } from "effect"
|
||||||
|
import { Component } from "effect-fc"
|
||||||
|
import { Hooks } from "effect-fc/hooks"
|
||||||
|
import { Todo } from "./Todo"
|
||||||
|
import { TodosState } from "./TodosState.service"
|
||||||
|
|
||||||
|
|
||||||
|
export class Todos extends Component.make(Effect.fnUntraced(function* Todos() {
|
||||||
|
const state = yield* TodosState
|
||||||
|
const [todos] = yield* Hooks.useSubscribeRefs(state.ref)
|
||||||
|
|
||||||
|
yield* Hooks.useOnce(() => Effect.andThen(
|
||||||
|
Console.log("Todos mounted"),
|
||||||
|
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
||||||
|
))
|
||||||
|
|
||||||
|
const TodoFC = yield* Todo
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Heading align="center">Todos</Heading>
|
||||||
|
|
||||||
|
<Flex direction="column" align="stretch" gap="2" mt="2">
|
||||||
|
<TodoFC _tag="new" />
|
||||||
|
|
||||||
|
{Chunk.map(todos, (v, k) =>
|
||||||
|
<TodoFC key={v.id} _tag="edit" index={k} />
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
})) {}
|
||||||
50
packages/example/src/todo/TodosState.service.ts
Normal file
50
packages/example/src/todo/TodosState.service.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Todo } from "@/domain"
|
||||||
|
import { KeyValueStore } from "@effect/platform"
|
||||||
|
import { BrowserKeyValueStore } from "@effect/platform-browser"
|
||||||
|
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
|
||||||
|
import { SubscriptionSubRef } from "effect-fc/types"
|
||||||
|
|
||||||
|
|
||||||
|
export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
||||||
|
effect: Effect.fn("TodosState")(function*(key: string) {
|
||||||
|
const kv = yield* KeyValueStore.KeyValueStore
|
||||||
|
|
||||||
|
const readFromLocalStorage = Console.log("Reading todos from local storage...").pipe(
|
||||||
|
Effect.andThen(kv.get(key)),
|
||||||
|
Effect.andThen(Option.match({
|
||||||
|
onSome: Schema.decode(
|
||||||
|
Schema.parseJson(Schema.Chunk(Todo.TodoFromJson))
|
||||||
|
),
|
||||||
|
onNone: () => Effect.succeed(Chunk.empty()),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveToLocalStorage = (todos: Chunk.Chunk<Todo.Todo>) => Effect.andThen(
|
||||||
|
Console.log("Saving todos to local storage..."),
|
||||||
|
Chunk.isNonEmpty(todos)
|
||||||
|
? Effect.andThen(
|
||||||
|
Schema.encode(
|
||||||
|
Schema.parseJson(Schema.Chunk(Todo.TodoFromJson))
|
||||||
|
)(todos),
|
||||||
|
v => kv.set(key, v),
|
||||||
|
)
|
||||||
|
: kv.remove(key)
|
||||||
|
)
|
||||||
|
|
||||||
|
const ref = yield* SubscriptionRef.make(yield* readFromLocalStorage)
|
||||||
|
const sizeRef = SubscriptionSubRef.makeFromPath(ref, ["length"])
|
||||||
|
|
||||||
|
yield* Effect.forkScoped(ref.changes.pipe(
|
||||||
|
Stream.debounce("500 millis"),
|
||||||
|
Stream.runForEach(saveToLocalStorage),
|
||||||
|
))
|
||||||
|
yield* Effect.addFinalizer(() => ref.pipe(
|
||||||
|
Effect.andThen(saveToLocalStorage),
|
||||||
|
Effect.ignore,
|
||||||
|
))
|
||||||
|
|
||||||
|
return { ref, sizeRef } as const
|
||||||
|
}),
|
||||||
|
|
||||||
|
dependencies: [BrowserKeyValueStore.layerLocalStorage],
|
||||||
|
}) {}
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { KeyValueStore } from "@effect/platform"
|
|
||||||
import { BrowserKeyValueStore } from "@effect/platform-browser"
|
|
||||||
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
|
|
||||||
import { Subscribable, SubscriptionSubRef } from "effect-fc"
|
|
||||||
import { Todo } from "@/domain"
|
|
||||||
|
|
||||||
|
|
||||||
export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
|
||||||
scoped: Effect.fnUntraced(function*(key: string) {
|
|
||||||
const kv = yield* KeyValueStore.KeyValueStore
|
|
||||||
|
|
||||||
const readFromLocalStorage = Console.log("Reading todos from local storage...").pipe(
|
|
||||||
Effect.andThen(kv.get(key)),
|
|
||||||
Effect.andThen(Option.match({
|
|
||||||
onSome: Schema.decode(
|
|
||||||
Schema.parseJson(Schema.Chunk(Todo.TodoFromJson))
|
|
||||||
),
|
|
||||||
onNone: () => Effect.succeed(Chunk.empty()),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
const saveToLocalStorage = (todos: Chunk.Chunk<Todo.Todo>) => Effect.andThen(
|
|
||||||
Console.log("Saving todos to local storage..."),
|
|
||||||
Chunk.isNonEmpty(todos)
|
|
||||||
? Effect.andThen(
|
|
||||||
Schema.encode(
|
|
||||||
Schema.parseJson(Schema.Chunk(Todo.TodoFromJson))
|
|
||||||
)(todos),
|
|
||||||
v => kv.set(key, v),
|
|
||||||
)
|
|
||||||
: kv.remove(key)
|
|
||||||
)
|
|
||||||
|
|
||||||
const ref = yield* SubscriptionRef.make(yield* readFromLocalStorage)
|
|
||||||
yield* Effect.forkScoped(ref.changes.pipe(
|
|
||||||
Stream.debounce("500 millis"),
|
|
||||||
Stream.runForEach(saveToLocalStorage),
|
|
||||||
))
|
|
||||||
yield* Effect.addFinalizer(() => ref.pipe(
|
|
||||||
Effect.andThen(saveToLocalStorage),
|
|
||||||
Effect.ignore,
|
|
||||||
))
|
|
||||||
|
|
||||||
const sizeSubscribable = Subscribable.make({
|
|
||||||
get: Effect.andThen(ref, Chunk.size),
|
|
||||||
get changes() { return Stream.map(ref.changes, Chunk.size) },
|
|
||||||
})
|
|
||||||
const getElementRef = (id: string) => SubscriptionSubRef.makeFromChunkFindFirst(ref, v => v.id === id)
|
|
||||||
const getIndexSubscribable = (id: string) => Subscribable.make({
|
|
||||||
get: Effect.flatMap(ref, Chunk.findFirstIndex(v => v.id === id)),
|
|
||||||
get changes() { return Stream.flatMap(ref.changes, Chunk.findFirstIndex(v => v.id === id)) },
|
|
||||||
})
|
|
||||||
|
|
||||||
const moveLeft = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
|
|
||||||
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
|
|
||||||
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
|
|
||||||
Effect.bind("previous", ({ index }) => Chunk.get(todos, index - 1)),
|
|
||||||
Effect.andThen(({ todo, index, previous }) => index > 0
|
|
||||||
? todos.pipe(
|
|
||||||
Chunk.replace(index, previous),
|
|
||||||
Chunk.replace(index - 1, todo),
|
|
||||||
)
|
|
||||||
: todos
|
|
||||||
),
|
|
||||||
))
|
|
||||||
const moveRight = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
|
|
||||||
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
|
|
||||||
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
|
|
||||||
Effect.bind("next", ({ index }) => Chunk.get(todos, index + 1)),
|
|
||||||
Effect.andThen(({ todo, index, next }) => index < Chunk.size(todos) - 1
|
|
||||||
? todos.pipe(
|
|
||||||
Chunk.replace(index, next),
|
|
||||||
Chunk.replace(index + 1, todo),
|
|
||||||
)
|
|
||||||
: todos
|
|
||||||
),
|
|
||||||
))
|
|
||||||
const remove = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.andThen(
|
|
||||||
Chunk.findFirstIndex(todos, v => v.id === id),
|
|
||||||
index => Chunk.remove(todos, index),
|
|
||||||
))
|
|
||||||
|
|
||||||
return {
|
|
||||||
ref,
|
|
||||||
sizeSubscribable,
|
|
||||||
getElementRef,
|
|
||||||
getIndexSubscribable,
|
|
||||||
moveLeft,
|
|
||||||
moveRight,
|
|
||||||
remove,
|
|
||||||
} as const
|
|
||||||
}),
|
|
||||||
|
|
||||||
dependencies: [BrowserKeyValueStore.layerLocalStorage],
|
|
||||||
}) {}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { Container, Flex, Heading } from "@radix-ui/themes"
|
|
||||||
import { Chunk, Console, Effect } from "effect"
|
|
||||||
import { Component, Subscribable } from "effect-fc"
|
|
||||||
import { TodosState } from "./TodosState"
|
|
||||||
import { TodoView } from "./TodoView"
|
|
||||||
|
|
||||||
|
|
||||||
export class TodosView extends Component.make("TodosView")(function*() {
|
|
||||||
const state = yield* TodosState
|
|
||||||
const [todos] = yield* Subscribable.useSubscribables([state.ref])
|
|
||||||
|
|
||||||
yield* Component.useOnMount(() => Effect.andThen(
|
|
||||||
Console.log("Todos mounted"),
|
|
||||||
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
|
||||||
))
|
|
||||||
|
|
||||||
const Todo = yield* TodoView.use
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Heading align="center">Todos</Heading>
|
|
||||||
|
|
||||||
<Flex direction="column" align="stretch" gap="2" mt="2">
|
|
||||||
<Todo _tag="new" />
|
|
||||||
|
|
||||||
{Chunk.map(todos, todo =>
|
|
||||||
<Todo key={todo.id} _tag="edit" id={todo.id} />
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}) {}
|
|
||||||
@@ -22,15 +22,15 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
},
|
},
|
||||||
|
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{ "name": "@effect/language-service" }
|
{
|
||||||
|
"name": "@effect/language-service"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,5 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { tanstackRouter } from "@tanstack/router-plugin/vite"
|
import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
|
||||||
import react from "@vitejs/plugin-react"
|
import react from "@vitejs/plugin-react"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vite"
|
||||||
@@ -7,10 +7,7 @@ import { defineConfig } from "vite"
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
tanstackRouter({
|
TanStackRouterVite(),
|
||||||
target: "react",
|
|
||||||
autoCodeSplitting: true,
|
|
||||||
}),
|
|
||||||
react(),
|
react(),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
"extends": ["config:recommended"],
|
|
||||||
"baseBranchPatterns": ["next"],
|
|
||||||
"packageRules": [
|
|
||||||
{
|
|
||||||
"matchManagers": ["bun", "npm"],
|
|
||||||
"matchUpdateTypes": ["minor", "patch"],
|
|
||||||
"groupName": "bun minor+patch updates",
|
|
||||||
"groupSlug": "bun-minor-patch"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matchManagers": ["dockerfile", "docker-compose"],
|
|
||||||
"matchUpdateTypes": ["minor", "patch", "digest"],
|
|
||||||
"groupName": "docker minor+patch+digest updates",
|
|
||||||
"groupSlug": "docker-minor-patch-digest"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
21
turbo.json
21
turbo.json
@@ -1,30 +1,11 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"lint:tsc": {
|
|
||||||
"cache": false
|
|
||||||
},
|
|
||||||
"lint:biome": {
|
|
||||||
"cache": false
|
|
||||||
},
|
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"inputs": ["./src/**"],
|
"inputs": ["./src/**"],
|
||||||
"outputs": ["./dist/**"]
|
"outputs": ["./dist/**"]
|
||||||
},
|
},
|
||||||
"pack": {
|
"pack": {}
|
||||||
"dependsOn": ["^pack"],
|
|
||||||
"cache": false
|
|
||||||
},
|
|
||||||
"clean:cache": {
|
|
||||||
"cache": false
|
|
||||||
},
|
|
||||||
"clean:dist": {
|
|
||||||
"cache": false
|
|
||||||
},
|
|
||||||
"clean:modules": {
|
|
||||||
"cache": false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user