Compare commits
182 Commits
next
..
948de593f0
| Author | SHA1 | Date | |
|---|---|---|---|
| 948de593f0 | |||
| 7b8a83dcd8 | |||
| e744e614ad | |||
| 5451c84d34 | |||
| 696fa21fab | |||
| 7b2e28451a | |||
| 93f65c5016 | |||
| 95d0bf70bd | |||
| dad4cd60d1 | |||
| 731eed4209 | |||
| 8c22206ad7 | |||
| a9ed86c4a8 | |||
| 4f9441c89c | |||
| 4f9bfaafaa | |||
| b8a3b089b7 | |||
| b29dec7d30 | |||
| d1ef42e9cb | |||
| c029f85401 | |||
| 8203063253 | |||
| 931511b890 | |||
| 7705880afe | |||
| 0f79f12632 | |||
| cb788952a4 | |||
| cd18a9d108 | |||
| a6d91a93a5 | |||
| 66694c7d7e | |||
| cfd3028218 | |||
| f09c6e3a1c | |||
| edf06b5f99 | |||
| cb5e3a84ae | |||
| 94ba0b0c5e | |||
| c352b38417 | |||
| cb84cf4cfe | |||
| 563f1c3ba7 | |||
| f2bfca6685 | |||
| 4772d02349 | |||
| ccc2ff1b57 | |||
| cef44cab0a | |||
| ac98fb6e8b | |||
| 88059c5ea7 | |||
| 8ccee184f5 | |||
| 6adb7061f4 | |||
| 637aeaa04e | |||
| 5070c0706d | |||
| 92a13efabc | |||
| 1accd657e0 | |||
| 943c2aa35d | |||
| 9dd7592c45 | |||
| f51b1b04ae | |||
| 485278558f | |||
| ceb61ef992 | |||
| 4cafcfac6f | |||
| a623e8217c | |||
| 4b67552a14 | |||
| 6f50cf2989 | |||
| aa243c6493 | |||
| 10cec68ee2 | |||
| 3addaf391e | |||
| 3b5a9abefa | |||
| 70d5ef950b | |||
| 140b2deda8 | |||
| 83128bb467 | |||
| 3ff646db0f | |||
| c4bfcb07c1 | |||
| cd937a86c7 | |||
| c79e6e8bad | |||
| 70dcdf8160 | |||
| 9feb94ea9e | |||
| a890d1c855 | |||
| e3bb50e2c9 | |||
| cd17be8c6d | |||
| 25d5defdca | |||
| f78b9f318a | |||
| 3b90384f8f | |||
| c1705c1587 | |||
| 87c9c637be | |||
| 981e989461 | |||
| d1ff6b31e2 | |||
| 9f5f0edfd9 | |||
| 4d5c188599 | |||
| ec91f8e5ee | |||
| d38e220df8 | |||
| da0847b3f9 | |||
| ec5e5bdc87 | |||
| fe5cfbe99c | |||
| c789de3ad8 | |||
| a510dbc77b | |||
| 5f85938449 | |||
| 9fde8dc57c | |||
| d682643407 | |||
| 0718ba48bd | |||
| 9680a7007d | |||
| a8b2c1e098 | |||
| 6d52c4ee31 | |||
| c80b25b0e0 | |||
| 7e14f27df4 | |||
| 5bc1e65a61 | |||
| 53bc4407b3 | |||
| 66289da64c | |||
| 679a624fab | |||
| ba76f38bc4 | |||
| 13a7c44aae | |||
| c7a68d8653 | |||
| 87e7b74ed6 | |||
| 4b82b8e627 | |||
| 4f69f667b0 | |||
| 0b8418e114 | |||
| 65447a6fec | |||
| 15a9ef3f79 | |||
| 7132f7a463 | |||
| 56f05e93e7 | |||
| 9beddc0877 | |||
| 8a354b5519 | |||
| 5de4773974 | |||
| 1090a685d2 | |||
| f537490f40 | |||
| 2348ea9bc1 | |||
| 0619af6524 | |||
| 993e97676f | |||
| 95f53b8a00 | |||
| 8b948b2251 | |||
| 626a9292d5 | |||
| cb40ecff06 | |||
| b9b9f37859 | |||
| 363c7d24f4 | |||
| d57654d872 | |||
| 0b7d9383ec | |||
| c380fe9d08 | |||
| 92722444cf | |||
| 882054b53d | |||
| 1c0519cfaf | |||
| f69125012e | |||
| 8c8560b63c | |||
| 86e8a7bd92 | |||
| 12878cd76b | |||
| 308025d662 | |||
| 2094f254b3 | |||
| 8ce4a959a6 | |||
| 3708059da4 | |||
| cd8b5e6364 | |||
| a48b623822 | |||
| 499e1e174b | |||
| 6b9c177ae7 | |||
| b73b053cc8 | |||
| bbad86bf97 | |||
| 6ae311cdfd | |||
| 03eca8a1af | |||
| c03d697361 | |||
| 3847686d54 | |||
| 9801444c0a | |||
| 68d8c9fa84 | |||
| cba42bfa52 | |||
| 874da0b963 | |||
| bb0579408d | |||
| b39c5946f9 | |||
| aaf494e27a | |||
| dbc5694b6d | |||
| 86582de0c5 | |||
| 72495bb9b5 | |||
| 312c103e71 | |||
| a252cfec27 | |||
| 4a5f4c329d | |||
| 6f96608f64 | |||
| 0bc29b2cb9 | |||
| 8642619a6a | |||
| e8b8df9449 | |||
| 3695128923 | |||
| 1f14e8be6b | |||
| adc8835304 | |||
| 8b06c56ec0 | |||
| 003d2f19a2 | |||
| 15f6d695f8 | |||
| 64583601dc | |||
| cf4ba5805f | |||
| 90db94e905 | |||
| 336ea67ea2 | |||
| 1af839f036 | |||
| 6bdf2a4d87 | |||
| 8d55a67e75 | |||
| a1ec5c4781 | |||
| 756b652861 | |||
| 59f9358b9a |
@@ -1,6 +1,6 @@
|
|||||||
# 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)
|
||||||
|
|||||||
+7
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@effect-fc/monorepo",
|
"name": "@effect-fc/monorepo",
|
||||||
"packageManager": "bun@1.3.14",
|
"packageManager": "bun@1.3.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./packages/*"
|
"./packages/*"
|
||||||
@@ -15,12 +15,12 @@
|
|||||||
"clean:modules": "turbo clean:modules && rm -rf node_modules"
|
"clean:modules": "turbo clean:modules && rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.16",
|
"@biomejs/biome": "^2.3.8",
|
||||||
"@effect/language-service": "^0.86.2",
|
"@effect/language-service": "^0.65.0",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.3",
|
||||||
"npm-check-updates": "^22.2.1",
|
"npm-check-updates": "^19.1.2",
|
||||||
"npm-sort": "^0.0.4",
|
"npm-sort": "^0.0.4",
|
||||||
"turbo": "^2.9.16",
|
"turbo": "^2.6.1",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
/node_modules
|
|
||||||
|
|
||||||
# Production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# Generated files
|
|
||||||
.docusaurus
|
|
||||||
.cache-loader
|
|
||||||
|
|
||||||
# Misc
|
|
||||||
.DS_Store
|
|
||||||
.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# effect-fc Docs
|
|
||||||
|
|
||||||
The documentation site is built with Docusaurus.
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run --cwd packages/docs start
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run --cwd packages/docs build
|
|
||||||
```
|
|
||||||
|
|
||||||
The static site is written to `packages/docs/build`.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
|
||||||
"root": false,
|
|
||||||
"extends": "//",
|
|
||||||
"files": {
|
|
||||||
"includes": ["./src/**"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
---
|
|
||||||
sidebar_position: 3
|
|
||||||
title: Forms
|
|
||||||
---
|
|
||||||
|
|
||||||
# Forms
|
|
||||||
|
|
||||||
The `Form` module is the low-level field model used by Effect-FC forms. A form
|
|
||||||
field is built on the same state model as the rest of Effect-FC:
|
|
||||||
|
|
||||||
- `encodedValue`: a `Lens` containing the raw value used by the input.
|
|
||||||
- `value`: a `Subscribable` containing the decoded value as an `Option`.
|
|
||||||
- `issues`: a `Subscribable` containing schema validation issues for that field.
|
|
||||||
- `canCommit`, `isValidating`, and `isCommitting`: `Subscribable` flags for UI
|
|
||||||
state.
|
|
||||||
|
|
||||||
Most apps create a root form with `SubmittableForm` or `SynchronizedForm`, then
|
|
||||||
focus it into fields with `Form.focusObjectOn`, `Form.focusArrayAt`, or the
|
|
||||||
other focusing helpers.
|
|
||||||
|
|
||||||
## Field Inputs
|
|
||||||
|
|
||||||
Use `Form.useInput` to connect a `Form` field to a controlled input. It returns
|
|
||||||
a React-style `{ value, setValue }` pair and writes changes back to the field's
|
|
||||||
`encodedValue` Lens.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Component, Form, Subscribable } from "effect-fc"
|
|
||||||
|
|
||||||
const TextInputView = Component.make("TextInput")(
|
|
||||||
function* (props: {
|
|
||||||
readonly form: Form.Form<readonly PropertyKey[], string, string>
|
|
||||||
readonly label: string
|
|
||||||
}) {
|
|
||||||
const input = yield* Form.useInput(props.form, {
|
|
||||||
debounce: "250 millis",
|
|
||||||
})
|
|
||||||
const [issues] = yield* Subscribable.useAll([props.form.issues])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label>
|
|
||||||
{props.label}
|
|
||||||
<input
|
|
||||||
value={input.value}
|
|
||||||
onChange={(event) => input.setValue(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
{issues.map((issue) => (
|
|
||||||
<small key={issue.path.join(".")}>
|
|
||||||
{issue.message}
|
|
||||||
</small>
|
|
||||||
))}
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `Form.useOptionalInput` for `Option` fields. It returns the same value/setter
|
|
||||||
pair plus `enabled` and `setEnabled`, which is useful for optional inputs that
|
|
||||||
can be toggled on and off.
|
|
||||||
|
|
||||||
## Submittable Forms
|
|
||||||
|
|
||||||
Use `SubmittableForm` when the user edits local encoded form state, validates it
|
|
||||||
with a schema, and then submits a decoded value.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Effect, Schema } from "effect"
|
|
||||||
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
|
|
||||||
import { TextInputView } from "./TextInputView"
|
|
||||||
|
|
||||||
const RegisterSchema = Schema.Struct({
|
|
||||||
email: Schema.String,
|
|
||||||
password: Schema.String,
|
|
||||||
})
|
|
||||||
|
|
||||||
class RegisterFormState extends Effect.Service<RegisterFormState>()(
|
|
||||||
"RegisterFormState",
|
|
||||||
{
|
|
||||||
scoped: Effect.gen(function* () {
|
|
||||||
const form = yield* SubmittableForm.service({
|
|
||||||
schema: RegisterSchema,
|
|
||||||
initialEncodedValue: {
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
},
|
|
||||||
f: ([value]) =>
|
|
||||||
Effect.log(`Registering ${value.email}`),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
form,
|
|
||||||
email: Form.focusObjectOn(form, "email"),
|
|
||||||
password: Form.focusObjectOn(form, "password"),
|
|
||||||
} as const
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
const RegisterFormView = Component.make("RegisterForm")(
|
|
||||||
function* () {
|
|
||||||
const state = yield* RegisterFormState
|
|
||||||
const [canCommit, isCommitting] = yield* Subscribable.useAll([
|
|
||||||
state.form.canCommit,
|
|
||||||
state.form.isCommitting,
|
|
||||||
])
|
|
||||||
const runPromise = yield* Component.useRunPromise()
|
|
||||||
const TextInput = yield* TextInputView.use
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
void runPromise(state.form.submit)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextInput label="Email" form={state.email} />
|
|
||||||
<TextInput label="Password" form={state.password} />
|
|
||||||
<button disabled={!canCommit}>
|
|
||||||
{isCommitting ? "Submitting..." : "Submit"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`SubmittableForm.service` starts validation in the form's scope. The form keeps
|
|
||||||
its encoded input state, decoded value, validation issues, submit mutation, and
|
|
||||||
commit flags available as Lenses/Subscribables.
|
|
||||||
|
|
||||||
## Synchronized Forms
|
|
||||||
|
|
||||||
Use `SynchronizedForm` when a form should edit an existing target `Lens`.
|
|
||||||
Instead of submitting a final value, valid encoded changes are synchronized back
|
|
||||||
to the target Lens.
|
|
||||||
|
|
||||||
This is useful for edit screens where the form is a validated view over existing
|
|
||||||
state. Use `SubmittableForm` for "submit this draft" flows, and
|
|
||||||
`SynchronizedForm` for "keep this target state synchronized when valid" flows.
|
|
||||||
|
|
||||||
## Focusing Fields
|
|
||||||
|
|
||||||
Forms can be focused just like Lenses:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const emailField = Form.focusObjectOn(form, "email")
|
|
||||||
const firstItemField = Form.focusArrayAt(form, 0)
|
|
||||||
```
|
|
||||||
|
|
||||||
Focused fields keep the parent form's validation and commit state, but narrow
|
|
||||||
`encodedValue`, `value`, and `issues` to the field path. This lets each input
|
|
||||||
component receive only the field it needs.
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
---
|
|
||||||
sidebar_position: 1
|
|
||||||
title: Getting Started
|
|
||||||
---
|
|
||||||
|
|
||||||
# Getting Started
|
|
||||||
|
|
||||||
`effect-fc` lets React components be written as Effect programs. Inside a
|
|
||||||
component body you can yield services, run Effects, subscribe to Effect-powered
|
|
||||||
state, and still export a normal React function component at the edge of your
|
|
||||||
app.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Install `effect-fc` alongside `effect` and React 19.2 or newer:
|
|
||||||
|
|
||||||
```bash npm2yarn
|
|
||||||
npm install effect-fc effect react
|
|
||||||
```
|
|
||||||
```bash npm2yarn
|
|
||||||
npm install --save-dev @types/react
|
|
||||||
```
|
|
||||||
|
|
||||||
`effect-fc` is not opinionated about the React platform. Use it with web,
|
|
||||||
native, custom renderers, or any environment where React components can run.
|
|
||||||
Then install the platform-specific React packages for your target.
|
|
||||||
|
|
||||||
For web apps, install React DOM:
|
|
||||||
|
|
||||||
```bash npm2yarn
|
|
||||||
npm install react-dom
|
|
||||||
```
|
|
||||||
```bash npm2yarn
|
|
||||||
npm install --save-dev @types/react-dom
|
|
||||||
```
|
|
||||||
|
|
||||||
## Create A Runtime
|
|
||||||
|
|
||||||
An Effect-FC app needs an Effect runtime. Build one from the services your UI
|
|
||||||
needs, then share it with React through `ReactRuntime.Provider`.
|
|
||||||
|
|
||||||
For an empty app, `Layer.empty` is enough:
|
|
||||||
|
|
||||||
```tsx title="src/runtime.ts"
|
|
||||||
import { Layer } from "effect"
|
|
||||||
import { ReactRuntime } from "effect-fc"
|
|
||||||
|
|
||||||
export const runtime = ReactRuntime.make(Layer.empty)
|
|
||||||
```
|
|
||||||
|
|
||||||
As your app grows, add services to the layer:
|
|
||||||
|
|
||||||
```tsx title="src/runtime.ts"
|
|
||||||
import { FetchHttpClient } from "@effect/platform"
|
|
||||||
import { Layer } from "effect"
|
|
||||||
import { ReactRuntime } from "effect-fc"
|
|
||||||
|
|
||||||
const AppLive = Layer.empty.pipe(
|
|
||||||
Layer.provideMerge(FetchHttpClient.layer),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const runtime = ReactRuntime.make(AppLive)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Provide The Runtime
|
|
||||||
|
|
||||||
At the React root, wrap your app with `ReactRuntime.Provider`:
|
|
||||||
|
|
||||||
```tsx title="src/main.tsx"
|
|
||||||
import { StrictMode } from "react"
|
|
||||||
import { createRoot } from "react-dom/client"
|
|
||||||
import { ReactRuntime } from "effect-fc"
|
|
||||||
import { App } from "./App"
|
|
||||||
import { runtime } from "./runtime"
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<ReactRuntime.Provider runtime={runtime}>
|
|
||||||
<App />
|
|
||||||
</ReactRuntime.Provider>
|
|
||||||
</StrictMode>,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`ReactRuntime.Provider` also works with routers. Keep it above your router
|
|
||||||
provider so route components can be converted with the same runtime context.
|
|
||||||
|
|
||||||
## Write Your First Component
|
|
||||||
|
|
||||||
Use `Component.make` when you want automatic tracing spans, or
|
|
||||||
`Component.makeUntraced` when you only want the component behavior. This creates
|
|
||||||
an Effect-FC component, not a plain React component yet.
|
|
||||||
|
|
||||||
The Effect run during render must be synchronous. Effect-FC follows React's
|
|
||||||
render model, so component bodies should produce JSX without waiting on async
|
|
||||||
work. Use lifecycle hooks, callbacks, queries, or other Effect-FC helpers for
|
|
||||||
async work that happens outside render.
|
|
||||||
|
|
||||||
```tsx title="src/HelloView.tsx"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { Component } from "effect-fc"
|
|
||||||
|
|
||||||
export const HelloView = Component.make("HelloView")(function* (props: {
|
|
||||||
readonly name: string
|
|
||||||
}) {
|
|
||||||
const message = yield* Effect.succeed(`Hello, ${props.name}`)
|
|
||||||
|
|
||||||
return <h1>{message}</h1>
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
At this point `HelloView` can yield Effects, services, and scoped lifecycle
|
|
||||||
work, but React cannot render it directly.
|
|
||||||
|
|
||||||
## Apply A Runtime
|
|
||||||
|
|
||||||
Use `Component.withRuntime` at the boundary where an Effect-FC component needs
|
|
||||||
to become a regular React function component.
|
|
||||||
|
|
||||||
```tsx title="src/Hello.tsx"
|
|
||||||
import { Component } from "effect-fc"
|
|
||||||
import { HelloView } from "./HelloView"
|
|
||||||
import { runtime } from "./runtime"
|
|
||||||
|
|
||||||
export const Hello = HelloView.pipe(
|
|
||||||
Component.withRuntime(runtime.context),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`Hello` is now a normal React component:
|
|
||||||
|
|
||||||
```tsx title="src/App.tsx"
|
|
||||||
import { Hello } from "./Hello"
|
|
||||||
|
|
||||||
export function App() {
|
|
||||||
return <Hello name="Effect" />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Effect-FC Components Together
|
|
||||||
|
|
||||||
Inside an Effect-FC component, other Effect-FC components are available through
|
|
||||||
their `.use` effect. Yield `.use` to get a React component for the current
|
|
||||||
runtime context, then render it like JSX.
|
|
||||||
|
|
||||||
```tsx title="src/GreetingCardView.tsx"
|
|
||||||
import { Component } from "effect-fc"
|
|
||||||
import { HelloView } from "./HelloView"
|
|
||||||
|
|
||||||
export const GreetingCardView = Component.make("GreetingCard")(
|
|
||||||
function* () {
|
|
||||||
const Hello = yield* HelloView.use
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<Hello name="Effect" />
|
|
||||||
<p>This component is still running inside Effect-FC.</p>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `Component.withRuntime` only when you leave the Effect-FC tree and need a
|
|
||||||
normal React component again:
|
|
||||||
|
|
||||||
```tsx title="src/GreetingCard.tsx"
|
|
||||||
import { Component } from "effect-fc"
|
|
||||||
import { GreetingCardView } from "./GreetingCardView"
|
|
||||||
import { runtime } from "./runtime"
|
|
||||||
|
|
||||||
export const GreetingCard = GreetingCardView.pipe(
|
|
||||||
Component.withRuntime(runtime.context),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Lifecycle
|
|
||||||
|
|
||||||
Every Effect-FC component instance exposes an Effect `Scope`. Effects that need
|
|
||||||
`Scope.Scope` can use that component scope to register finalizers, fork scoped
|
|
||||||
work, or acquire scoped resources.
|
|
||||||
|
|
||||||
The component scope is created when React mounts the component and closes when
|
|
||||||
React unmounts it. When the scope closes, Effect runs the finalizers registered
|
|
||||||
inside that scope.
|
|
||||||
|
|
||||||
Finalizers are forked when the scope closes, so cleanup logic can run
|
|
||||||
asynchronous Effects even though the component body itself must stay
|
|
||||||
synchronous.
|
|
||||||
|
|
||||||
```tsx title="src/MountedMessageView.tsx"
|
|
||||||
import { Console, Effect } from "effect"
|
|
||||||
import { Component } from "effect-fc"
|
|
||||||
|
|
||||||
export const MountedMessageView = Component.make("MountedMessage")(
|
|
||||||
function* () {
|
|
||||||
const message = yield* Component.useOnMount(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* Console.log("MountedMessage mounted")
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Console.log("MountedMessage unmounted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
return "This value was loaded on mount."
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return <p>{message}</p>
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `Component.useOnMount` when the component needs a value produced by an
|
|
||||||
Effect during its first render. The value is then cached for the component
|
|
||||||
instance. You can also use it to set up scoped component logic, subscriptions,
|
|
||||||
or resources that should live for the lifetime of that component instance.
|
|
||||||
|
|
||||||
Use `Component.useOnChange` when render needs the value computed by
|
|
||||||
scoped work that depends on changing inputs. When a dependency changes, React
|
|
||||||
re-renders the component and the hook computes the next value during that
|
|
||||||
render.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Console, Effect } from "effect"
|
|
||||||
import { Component } from "effect-fc"
|
|
||||||
|
|
||||||
const UserPanelView = Component.make("UserPanel")(
|
|
||||||
function* (props: { readonly userId: string }) {
|
|
||||||
const label = yield* Component.useOnChange(
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* Console.log(`Preparing view for ${props.userId}`)
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Console.log(`Cleaning up ${props.userId}`),
|
|
||||||
)
|
|
||||||
|
|
||||||
return `Viewing user ${props.userId}`
|
|
||||||
}),
|
|
||||||
[props.userId],
|
|
||||||
)
|
|
||||||
|
|
||||||
return <p>{label}</p>
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
In this example, each `userId` gets its own scope. When `userId` changes,
|
|
||||||
Effect-FC closes the previous scope, runs its finalizers, and creates a new
|
|
||||||
scope for the next load. Unlike `useOnMount`, `useOnChange` does not expose the
|
|
||||||
component's root scope directly. It creates and provides its own scope for that
|
|
||||||
dependency window. Some other Effect-FC hooks follow the same pattern when they
|
|
||||||
need a lifecycle that is narrower than the whole component instance.
|
|
||||||
|
|
||||||
## Useful Effect-FC Hooks
|
|
||||||
|
|
||||||
Unlike plain React hooks, Effect-FC hooks return Effects. Use `yield*` to run
|
|
||||||
them inside the component body.
|
|
||||||
|
|
||||||
The most common Effect-FC hooks are:
|
|
||||||
|
|
||||||
- `Component.useOnMount`: run an Effect once during the component's first render
|
|
||||||
after mount, and return its value. The Effect must be synchronous.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const initialData = yield* Component.useOnMount(() => loadInitialData)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Component.useOnChange`: when a dependency changes, React re-renders the
|
|
||||||
component and the Effect is run again inside the component body. It returns
|
|
||||||
the latest value, so the Effect must be synchronous too.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const label = yield* Component.useOnChange(() => formatUserLabel(id), [id])
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Component.useReactEffect`: Effect-powered `React.useEffect` for side effects
|
|
||||||
that do not need to compute render output or trigger a re-render. The Effect
|
|
||||||
must be synchronous and can register scoped finalizers.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
yield* Component.useReactEffect(
|
|
||||||
() => Effect.forkScoped(subscribeToUser(id)),
|
|
||||||
[id],
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Component.useReactLayoutEffect`: Effect-powered `React.useLayoutEffect` with scoped
|
|
||||||
finalizers.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
yield* Component.useReactLayoutEffect(() => measure(ref), [])
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Component.useRunSync` and `Component.useRunPromise`: run Effects from React
|
|
||||||
event handlers.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const runPromise = yield* Component.useRunPromise()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button onClick={() => void runPromise(saveUser)}>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Component.useCallbackSync` and `Component.useCallbackPromise`: create stable
|
|
||||||
React callbacks.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const save = yield* Component.useCallbackPromise(
|
|
||||||
(user: User) => saveUser(user),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
return <button onClick={() => void save(user)}>Save</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use React Normally
|
|
||||||
|
|
||||||
An Effect-FC component is still a React function component. Anything you can do
|
|
||||||
in a regular React component can also be done in an Effect-FC component,
|
|
||||||
including React hooks, refs, event handlers, context, and JSX composition.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Component } from "effect-fc"
|
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
const CounterView = Component.make("Counter")(function* () {
|
|
||||||
const [count, setCount] = React.useState(0)
|
|
||||||
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button ref={buttonRef} onClick={() => setCount(count + 1)}>
|
|
||||||
Count: {count}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Use regular React hooks for local UI concerns, and reach for Effect-FC helpers
|
|
||||||
when the component needs Effect services, scopes, resources, or Effect-powered
|
|
||||||
callbacks.
|
|
||||||
|
|
||||||
## Use Tags
|
|
||||||
|
|
||||||
Components can yield Effect tags directly in the component body. There is no
|
|
||||||
need to memoize the service yourself; just yield the tag wherever the component
|
|
||||||
needs it.
|
|
||||||
|
|
||||||
```tsx title="src/Greeting.tsx"
|
|
||||||
const GreetingView = Component.make("Greeting")(function* (props: {
|
|
||||||
readonly name: string
|
|
||||||
}) {
|
|
||||||
const greeting = yield* GreetingService
|
|
||||||
|
|
||||||
return <p>{greeting.greet(props.name)}</p>
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Service values in the runtime context are reactive by default. If a provided
|
|
||||||
service instance changes, Effect-FC unmounts and mounts again the components
|
|
||||||
that depend on that context, so they read the new service and restart their
|
|
||||||
scoped lifecycle with the new environment. For services that should not trigger
|
|
||||||
that behavior, pass their tags with `Component.withOptions({ nonReactiveTags:
|
|
||||||
[...] })`.
|
|
||||||
|
|
||||||
## Provide Services To A Subtree
|
|
||||||
|
|
||||||
Use `Component.useContextFromLayer` when an Effect-FC component should provide
|
|
||||||
extra services to another Effect-FC component. It turns a `Layer` into a runtime
|
|
||||||
context that can be provided to `.use`.
|
|
||||||
|
|
||||||
```tsx title="src/GreetingPageView.tsx"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { Component } from "effect-fc"
|
|
||||||
import { GreetingView } from "./Greeting"
|
|
||||||
import { GreetingService } from "./services"
|
|
||||||
|
|
||||||
const GreetingLive = GreetingService.Default({
|
|
||||||
greet: (name: string) => `Welcome, ${name}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const GreetingPageView = Component.make("GreetingPage")(function* () {
|
|
||||||
const context = yield* Component.useContextFromLayer(GreetingLive)
|
|
||||||
const Greeting = yield* Effect.provide(GreetingView.use, context)
|
|
||||||
|
|
||||||
return <Greeting name="Effect" />
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
The layer passed to `useContextFromLayer` should be stable. If a new layer value
|
|
||||||
is created on every render, Effect-FC has to build a new context, which can
|
|
||||||
cause unnecessary re-renders and scoped lifecycle restarts. Define static layers
|
|
||||||
outside the component, or memoize layers that depend on React props or state.
|
|
||||||
|
|
||||||
Layers built with `useContextFromLayer` are scoped to the component that builds
|
|
||||||
them. That means service construction can register scoped logic, acquire
|
|
||||||
resources, fork scoped fibers, and add finalizers that are tied to that
|
|
||||||
component's lifecycle.
|
|
||||||
|
|
||||||
## Where To Go Next
|
|
||||||
|
|
||||||
Once the runtime and component boundary are in place, the rest of the library
|
|
||||||
builds on the same idea:
|
|
||||||
|
|
||||||
- `Subscribable.useAll` reads Effect subscribables and rerenders when they
|
|
||||||
change.
|
|
||||||
- `Lens` connects React state and Effect `SubscriptionRef` values.
|
|
||||||
- `Query` and `Mutation` model async data and user-triggered operations.
|
|
||||||
- `Form`, `SubmittableForm`, and `SynchronizedForm` help build Effect-backed
|
|
||||||
forms.
|
|
||||||
|
|
||||||
The important pattern is small and repeatable: write Effect-FC components inside
|
|
||||||
the runtime, then use `Component.withRuntime` at React boundaries.
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
---
|
|
||||||
sidebar_position: 2
|
|
||||||
title: State Management
|
|
||||||
---
|
|
||||||
|
|
||||||
# State Management
|
|
||||||
|
|
||||||
`Lens` is the main type used for state management in `effect-fc`.
|
|
||||||
|
|
||||||
A Lens is an effectful handle to a piece of state. It can read the current value,
|
|
||||||
subscribe to changes, and write updates back to the underlying source. A Lens
|
|
||||||
can point at a whole state object or focus on one nested field inside it.
|
|
||||||
|
|
||||||
The usual pattern is to create state with Effect primitives such as
|
|
||||||
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor
|
|
||||||
such as `Lens.fromSubscriptionRef`, and bind Lens values into components with
|
|
||||||
`Subscribable.useAll`.
|
|
||||||
|
|
||||||
`Subscribable` is the read-only side of this model. Every Lens is also a
|
|
||||||
Subscribable.
|
|
||||||
|
|
||||||
`effect-fc` re-exports the `Lens` and `Subscribable` modules from
|
|
||||||
[`effect-lens`](https://github.com/Thiladev/effect-lens) for convenience. The
|
|
||||||
core data model and transformation APIs belong to `effect-lens`, so check the
|
|
||||||
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens) for the full Lens/Subscribable API.
|
|
||||||
|
|
||||||
## Where To Store State
|
|
||||||
|
|
||||||
State can live pretty much anywhere as a `Lens` or `Subscribable`: in a
|
|
||||||
service, in a layer, in a component scope, or alongside plain React state. Pick
|
|
||||||
the owner based on who needs the state. Once you have a Lens/Subscribable
|
|
||||||
handle, pass it around however you like, including through React props.
|
|
||||||
|
|
||||||
If state is shared by multiple components or belongs to application logic, store
|
|
||||||
it in an Effect service:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
|
||||||
import { Component, Lens, Subscribable } from "effect-fc"
|
|
||||||
|
|
||||||
class CounterState extends Effect.Service<CounterState>()("CounterState", {
|
|
||||||
effect: Effect.gen(function* () {
|
|
||||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
|
||||||
|
|
||||||
return { count } as const
|
|
||||||
}),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const CounterValueView = Component.make("CounterValue")(function* () {
|
|
||||||
const state = yield* CounterState
|
|
||||||
const [count] = yield* Subscribable.useAll([state.count])
|
|
||||||
|
|
||||||
return <p>Count: {count}</p>
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
If state belongs to a single Effect-FC component instance, or to a shallow
|
|
||||||
hierarchy of subcomponents that receive it through props, create it with
|
|
||||||
`Component.useOnMount`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
|
||||||
import { Component, Lens, Subscribable } from "effect-fc"
|
|
||||||
|
|
||||||
const LocalCounterView = Component.make("LocalCounter")(function* () {
|
|
||||||
const state = yield* Component.useOnMount(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
|
||||||
|
|
||||||
return { count } as const
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const [count] = yield* Subscribable.useAll([state.count])
|
|
||||||
|
|
||||||
return <p>Count: {count}</p>
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
For simple UI state that is not shared and does not need Effect integration,
|
|
||||||
prefer regular React state. A local "show details" toggle is usually better as
|
|
||||||
`React.useState(false)` than as a Lens.
|
|
||||||
|
|
||||||
## Subscribable.useAll
|
|
||||||
|
|
||||||
A `Subscribable<A>` is reactive state with a current value and a stream of
|
|
||||||
changes. Use `Subscribable.useAll` whenever a component needs to bind
|
|
||||||
subscribable values into render output.
|
|
||||||
|
|
||||||
`Lens` is a `Subscribable`, so this is also the default way to read Lens values
|
|
||||||
from a component.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
|
||||||
import { Component, Lens, Subscribable } from "effect-fc"
|
|
||||||
|
|
||||||
class CounterState extends Effect.Service<CounterState>()("CounterState", {
|
|
||||||
effect: Effect.gen(function* () {
|
|
||||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
|
||||||
const doubled = Subscribable.map(count, (n) => n * 2)
|
|
||||||
|
|
||||||
return { count, doubled } as const
|
|
||||||
}),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const CounterReadOnlyView = Component.make("CounterReadOnly")(
|
|
||||||
function* () {
|
|
||||||
const state = yield* CounterState
|
|
||||||
const [count, doubled] = yield* Subscribable.useAll([
|
|
||||||
state.count,
|
|
||||||
state.doubled,
|
|
||||||
])
|
|
||||||
|
|
||||||
return <p>Count: {count}, doubled: {doubled}</p>
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`Subscribable.useAll` reads the current values during render and uses scoped subscriptions to update React state when changes arrive.
|
|
||||||
|
|
||||||
When you need to modify state, write to the Lens with `Lens.set` or `Lens.update`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const CounterControlsView = Component.make("CounterControls")(
|
|
||||||
function* () {
|
|
||||||
const state = yield* CounterState
|
|
||||||
const [count] = yield* Subscribable.useAll([state.count])
|
|
||||||
|
|
||||||
const increment = yield* Component.useCallbackSync(
|
|
||||||
() => Lens.update(state.count, (n) => n + 1),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
const reset = yield* Component.useCallbackSync(
|
|
||||||
() => Lens.set(state.count, 0),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<p>Count: {count}</p>
|
|
||||||
<button onClick={increment}>Increment</button>
|
|
||||||
<button onClick={reset}>Reset</button>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lens.useState
|
|
||||||
|
|
||||||
`Lens.useState` is useful when React needs the familiar `[value, setValue]`
|
|
||||||
tuple, backed by a Lens. Reach for it when the JSX API expects a synchronous
|
|
||||||
setter, especially controlled inputs such as text fields, checkboxes, selects,
|
|
||||||
or third-party components with `value` / `onChange` props.
|
|
||||||
|
|
||||||
If a component only needs to display the value, prefer `Subscribable.useAll`.
|
|
||||||
`Lens.useState` is for places where reading and writing need to be wired
|
|
||||||
together in React's local-state shape.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
|
||||||
import { Component, Lens } from "effect-fc"
|
|
||||||
|
|
||||||
class FormState extends Effect.Service<FormState>()("FormState", {
|
|
||||||
effect: Effect.gen(function* () {
|
|
||||||
const name = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(""))
|
|
||||||
|
|
||||||
return { name } as const
|
|
||||||
}),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const NameInputView = Component.make("NameInput")(function* () {
|
|
||||||
const state = yield* FormState
|
|
||||||
const [name, setName] = yield* Lens.useState(state.name)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
value={name}
|
|
||||||
onChange={(event) => setName(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
`Lens.useState` returns the current value and a React-compatible setter. Calling
|
|
||||||
the setter writes through the Lens, so every other component subscribed to the
|
|
||||||
same Lens sees the update.
|
|
||||||
|
|
||||||
## Focused Lenses
|
|
||||||
|
|
||||||
Use focused Lenses when a component should work with one part of a larger state
|
|
||||||
object. A focused Lens is still a Lens, so it can be read with
|
|
||||||
`Subscribable.useAll` or used with `Lens.useState` when React needs a
|
|
||||||
read/write tuple.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
|
||||||
import { Component, Lens, Subscribable } from "effect-fc"
|
|
||||||
|
|
||||||
interface UserProfile {
|
|
||||||
readonly name: string
|
|
||||||
readonly email: string
|
|
||||||
readonly role: string
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProfileState extends Effect.Service<ProfileState>()("ProfileState", {
|
|
||||||
effect: Effect.gen(function* () {
|
|
||||||
const profile = Lens.fromSubscriptionRef(
|
|
||||||
yield* SubscriptionRef.make<UserProfile>({
|
|
||||||
name: "",
|
|
||||||
email: "",
|
|
||||||
role: "reader",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const name = Lens.focusObjectOn(profile, "name")
|
|
||||||
const role = Lens.focusObjectOn(profile, "role")
|
|
||||||
|
|
||||||
return { profile, name, role } as const
|
|
||||||
}),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const ProfileNameView = Component.make("ProfileName")(function* () {
|
|
||||||
const state = yield* ProfileState
|
|
||||||
const [name, setName] = yield* Lens.useState(state.name)
|
|
||||||
const [role] = yield* Subscribable.useAll([state.role])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input
|
|
||||||
value={name}
|
|
||||||
onChange={(event) => setName(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<span>Role: {role}</span>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Updating the focused `name` Lens through `Lens.useState` updates the parent
|
|
||||||
`profile` Lens. The focused `role` Lens is only read, so it stays on the simpler
|
|
||||||
`Subscribable.useAll` path.
|
|
||||||
|
|
||||||
For focusing into nested state, deriving lenses, custom write behavior, and the
|
|
||||||
complete API, refer to the
|
|
||||||
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens).
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import type * as Preset from "@docusaurus/preset-classic"
|
|
||||||
import type { Config } from "@docusaurus/types"
|
|
||||||
import { themes as prismThemes } from "prism-react-renderer"
|
|
||||||
|
|
||||||
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
|
||||||
|
|
||||||
const config: Config = {
|
|
||||||
title: "effect-fc",
|
|
||||||
tagline: "Write React function components with Effect",
|
|
||||||
favicon: "img/favicon.ico",
|
|
||||||
|
|
||||||
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
|
|
||||||
future: {
|
|
||||||
v4: true, // Improve compatibility with the upcoming Docusaurus v4
|
|
||||||
},
|
|
||||||
|
|
||||||
// Set the production url of your site here
|
|
||||||
url: "https://thiladev.github.io",
|
|
||||||
// Set the /<baseUrl>/ pathname under which your site is served
|
|
||||||
// For GitHub pages deployment, it is often '/<projectName>/'
|
|
||||||
baseUrl: "/effect-fc/",
|
|
||||||
|
|
||||||
// GitHub pages deployment config.
|
|
||||||
// If you aren't using GitHub pages, you don't need these.
|
|
||||||
organizationName: "Thiladev", // Usually your GitHub org/user name.
|
|
||||||
projectName: "effect-fc", // Usually your repo name.
|
|
||||||
|
|
||||||
onBrokenLinks: "throw",
|
|
||||||
|
|
||||||
// Even if you don't use internationalization, you can use this field to set
|
|
||||||
// useful metadata like html lang. For example, if your site is Chinese, you
|
|
||||||
// may want to replace "en" with "zh-Hans".
|
|
||||||
i18n: {
|
|
||||||
defaultLocale: "en",
|
|
||||||
locales: ["en"],
|
|
||||||
},
|
|
||||||
|
|
||||||
presets: [
|
|
||||||
[
|
|
||||||
"classic",
|
|
||||||
{
|
|
||||||
docs: {
|
|
||||||
sidebarPath: "./sidebars.ts",
|
|
||||||
editUrl:
|
|
||||||
"https://github.com/Thiladev/effect-fc/tree/main/packages/docs/",
|
|
||||||
},
|
|
||||||
blog: false,
|
|
||||||
theme: {
|
|
||||||
customCss: "./src/css/custom.css",
|
|
||||||
},
|
|
||||||
} satisfies Preset.Options,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
themeConfig: {
|
|
||||||
// Replace with your project's social card
|
|
||||||
colorMode: {
|
|
||||||
respectPrefersColorScheme: true,
|
|
||||||
},
|
|
||||||
navbar: {
|
|
||||||
title: "effect-fc",
|
|
||||||
logo: {
|
|
||||||
alt: "effect-fc logo",
|
|
||||||
src: "img/logo.svg",
|
|
||||||
},
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
type: "docSidebar",
|
|
||||||
sidebarId: "docsSidebar",
|
|
||||||
position: "left",
|
|
||||||
label: "Docs",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "https://github.com/Thiladev/effect-fc",
|
|
||||||
label: "GitHub",
|
|
||||||
position: "right",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
style: "dark",
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
title: "Docs",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: "Getting Started",
|
|
||||||
to: "/docs/getting-started",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Project",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: "GitHub",
|
|
||||||
href: "https://github.com/Thiladev/effect-fc",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Example App",
|
|
||||||
href: "https://github.com/Thiladev/effect-fc/tree/main/packages/example",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Ecosystem",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: "Effect",
|
|
||||||
href: "https://effect.website/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "React",
|
|
||||||
href: "https://react.dev/",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
copyright: `Copyright © ${new Date().getFullYear()} effect-fc contributors. Built with Docusaurus.`,
|
|
||||||
},
|
|
||||||
prism: {
|
|
||||||
theme: prismThemes.github,
|
|
||||||
darkTheme: prismThemes.dracula,
|
|
||||||
},
|
|
||||||
} satisfies Preset.ThemeConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default config
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "docs",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"docusaurus": "docusaurus",
|
|
||||||
"start": "docusaurus start",
|
|
||||||
"build": "docusaurus build",
|
|
||||||
"swizzle": "docusaurus swizzle",
|
|
||||||
"deploy": "docusaurus deploy",
|
|
||||||
"clear": "docusaurus clear",
|
|
||||||
"serve": "docusaurus serve",
|
|
||||||
"write-translations": "docusaurus write-translations",
|
|
||||||
"write-heading-ids": "docusaurus write-heading-ids",
|
|
||||||
"typecheck": "tsc"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@docusaurus/core": "3.10.1",
|
|
||||||
"@docusaurus/faster": "^3.10.1",
|
|
||||||
"@docusaurus/preset-classic": "3.10.1",
|
|
||||||
"@mdx-js/react": "^3.1.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"prism-react-renderer": "^2.4.1",
|
|
||||||
"react": "^19.2.5",
|
|
||||||
"react-dom": "^19.2.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@docusaurus/module-type-aliases": "3.10.1",
|
|
||||||
"@docusaurus/tsconfig": "3.10.1",
|
|
||||||
"@docusaurus/types": "3.10.1",
|
|
||||||
"@types/react": "^19.2.15",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"typescript": "~6.0.3"
|
|
||||||
},
|
|
||||||
"browserslist": {
|
|
||||||
"production": [
|
|
||||||
">0.5%",
|
|
||||||
"not dead",
|
|
||||||
"not op_mini all"
|
|
||||||
],
|
|
||||||
"development": [
|
|
||||||
"last 3 chrome version",
|
|
||||||
"last 3 firefox version",
|
|
||||||
"last 5 safari version"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { SidebarsConfig } from "@docusaurus/plugin-content-docs"
|
|
||||||
|
|
||||||
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
|
||||||
|
|
||||||
const sidebars: SidebarsConfig = {
|
|
||||||
docsSidebar: ["getting-started", "state-management", "forms"],
|
|
||||||
}
|
|
||||||
|
|
||||||
export default sidebars
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
:root {
|
|
||||||
--ifm-color-primary: #0b6f74;
|
|
||||||
--ifm-color-primary-dark: #096469;
|
|
||||||
--ifm-color-primary-darker: #085e63;
|
|
||||||
--ifm-color-primary-darkest: #074d51;
|
|
||||||
--ifm-color-primary-light: #0d7a7f;
|
|
||||||
--ifm-color-primary-lighter: #0e8085;
|
|
||||||
--ifm-color-primary-lightest: #109096;
|
|
||||||
--ifm-background-color: #fffaf1;
|
|
||||||
--ifm-code-font-size: 95%;
|
|
||||||
--ifm-font-family-base:
|
|
||||||
"Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
|
|
||||||
--docusaurus-highlighted-code-line-bg: rgba(11, 111, 116, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button--primary {
|
|
||||||
--ifm-button-background-color: #0b6f74;
|
|
||||||
--ifm-button-border-color: #0b6f74;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button--secondary {
|
|
||||||
--ifm-button-background-color: #f4a636;
|
|
||||||
--ifm-button-border-color: #f4a636;
|
|
||||||
--ifm-button-color: #1f2526;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--ifm-color-primary: #7ad6d4;
|
|
||||||
--ifm-color-primary-dark: #5cccca;
|
|
||||||
--ifm-color-primary-darker: #4cc6c4;
|
|
||||||
--ifm-color-primary-darkest: #34aaa8;
|
|
||||||
--ifm-color-primary-light: #98e0de;
|
|
||||||
--ifm-color-primary-lighter: #a8e6e4;
|
|
||||||
--ifm-color-primary-lightest: #d5f4f3;
|
|
||||||
--ifm-background-color: #111c1f;
|
|
||||||
--docusaurus-highlighted-code-line-bg: rgba(122, 214, 212, 0.18);
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
.page {
|
|
||||||
min-height: calc(100vh - var(--ifm-navbar-height));
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(11, 111, 116, 0.22), transparent 32rem),
|
|
||||||
radial-gradient(circle at 85% 20%, rgba(244, 166, 54, 0.18), transparent 28rem),
|
|
||||||
linear-gradient(135deg, #f8f4ea 0%, #eef8f7 48%, #fffaf1 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
padding: 8rem 1rem 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
color: #0b6f74;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero h1 {
|
|
||||||
color: #132c2f;
|
|
||||||
font-size: clamp(3rem, 9vw, 6.75rem);
|
|
||||||
letter-spacing: -0.08em;
|
|
||||||
line-height: 0.9;
|
|
||||||
margin: 0;
|
|
||||||
max-width: 880px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lede {
|
|
||||||
color: #385256;
|
|
||||||
font-size: clamp(1.15rem, 2vw, 1.45rem);
|
|
||||||
line-height: 1.65;
|
|
||||||
margin: 2rem 0 0;
|
|
||||||
max-width: 720px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cards {
|
|
||||||
display: grid;
|
|
||||||
gap: 1.25rem;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
padding: 0 1rem 6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cards article {
|
|
||||||
background: rgba(255, 255, 255, 0.74);
|
|
||||||
border: 1px solid rgba(19, 44, 47, 0.12);
|
|
||||||
border-radius: 1.5rem;
|
|
||||||
box-shadow: 0 24px 80px rgba(19, 44, 47, 0.08);
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cards h2 {
|
|
||||||
color: #132c2f;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cards p {
|
|
||||||
color: #486267;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 996px) {
|
|
||||||
.hero {
|
|
||||||
padding-top: 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cards {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import Link from "@docusaurus/Link"
|
|
||||||
import Layout from "@theme/Layout"
|
|
||||||
import clsx from "clsx"
|
|
||||||
import type { ReactNode } from "react"
|
|
||||||
|
|
||||||
import styles from "./index.module.css"
|
|
||||||
|
|
||||||
export default function Home(): ReactNode {
|
|
||||||
return (
|
|
||||||
<Layout
|
|
||||||
title="effect-fc"
|
|
||||||
description="Write React function components with Effect"
|
|
||||||
>
|
|
||||||
<main className={styles.page}>
|
|
||||||
<section className={clsx("container", styles.hero)}>
|
|
||||||
<p className={styles.eyebrow}>Effect for React function components</p>
|
|
||||||
<h1>Write components as Effect programs.</h1>
|
|
||||||
<p className={styles.lede}>
|
|
||||||
effect-fc gives React 19 components access to Effect services,
|
|
||||||
scopes, subscriptions, and async workflows without giving up normal
|
|
||||||
React boundaries.
|
|
||||||
</p>
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<Link className="button button--primary button--lg" to="/docs/getting-started">
|
|
||||||
Get Started
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
className="button button--secondary button--lg"
|
|
||||||
to="https://github.com/Thiladev/effect-fc"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className={clsx("container", styles.cards)}>
|
|
||||||
<article>
|
|
||||||
<h2>Generator components</h2>
|
|
||||||
<p>
|
|
||||||
Use <code>Component.make</code> to yield Effects and return JSX
|
|
||||||
from the same component body.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article>
|
|
||||||
<h2>Runtime at the edge</h2>
|
|
||||||
<p>
|
|
||||||
Provide your app layer once with <code>ReactRuntime.Provider</code>
|
|
||||||
and convert Effect-FC components at React boundaries.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article>
|
|
||||||
<h2>Scoped lifecycles</h2>
|
|
||||||
<p>
|
|
||||||
Tie subscriptions and resources to component scopes so finalizers
|
|
||||||
run when React unmounts.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Vendored
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 KiB |
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="128" height="128" rx="32" fill="#0B6F74"/>
|
|
||||||
<path d="M34 39H78C88.4934 39 97 47.5066 97 58C97 68.4934 88.4934 77 78 77H51V95H34V39Z" fill="#FFF9ED"/>
|
|
||||||
<path d="M51 55V62H78C80.2091 62 82 60.2091 82 58C82 55.7909 80.2091 55 78 55H51Z" fill="#0B6F74"/>
|
|
||||||
<path d="M51 77H86V95H51V77Z" fill="#F4A636"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 424 B |
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
// This file is not used in compilation. It is here just for a nice editor experience.
|
|
||||||
"extends": "@docusaurus/tsconfig",
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": ".",
|
|
||||||
"ignoreDeprecations": "6.0"
|
|
||||||
},
|
|
||||||
"exclude": [".docusaurus", "build"]
|
|
||||||
}
|
|
||||||
@@ -1,51 +1,59 @@
|
|||||||
# 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 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 TodosView extends Component.make("TodosView")(function*() {
|
import { Component } from "effect-fc"
|
||||||
const state = yield* TodosState
|
import { useOnce, useSubscribables } from "effect-fc/Hooks"
|
||||||
const [todos] = yield* Component.useSubscribables([state.subscriptionRef])
|
import { Todo } from "./Todo"
|
||||||
|
import { TodosState } from "./TodosState.service"
|
||||||
|
|
||||||
yield* Component.useOnMount(() => Effect.andThen(
|
|
||||||
|
export class Todos extends Component.makeUntraced("Todos")(function*() {
|
||||||
|
const state = yield* TodosState
|
||||||
|
const [todos] = yield* useSubscribables(state.ref)
|
||||||
|
|
||||||
|
yield* useOnce(() => Effect.andThen(
|
||||||
Console.log("Todos mounted"),
|
Console.log("Todos mounted"),
|
||||||
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
||||||
))
|
))
|
||||||
|
|
||||||
const Todo = yield* TodoView.use
|
const TodoFC = yield* Todo
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Heading align="center">Todos</Heading>
|
<Heading align="center">Todos</Heading>
|
||||||
|
|
||||||
<Flex direction="column" align="stretch" gap="2" mt="2">
|
<Flex direction="column" align="stretch" gap="2" mt="2">
|
||||||
<Todo _tag="new" />
|
<TodoFC _tag="new" />
|
||||||
|
|
||||||
{Chunk.map(todos, todo =>
|
{Chunk.map(todos, todo =>
|
||||||
<Todo key={todo.id} _tag="edit" id={todo.id} />
|
<TodoFC key={todo.id} _tag="edit" id={todo.id} />
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
const Index = Component.make("IndexView")(function*() {
|
const TodosStateLive = TodosState.Default("todos")
|
||||||
const context = yield* Component.useContextFromLayer(TodosState.Default)
|
|
||||||
const Todos = yield* Effect.provide(TodosView.use, context)
|
|
||||||
|
|
||||||
return <Todos />
|
const Index = Component.makeUntraced("Index")(function*() {
|
||||||
|
const context = yield* useContext(TodosStateLive, { finalizerExecutionMode: "fork" })
|
||||||
|
const TodosFC = yield* Effect.provide(Todos, context)
|
||||||
|
|
||||||
|
return <TodosFC />
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Component.withRuntime(runtime.context)
|
Component.withRuntime(runtime.context)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.3.0",
|
"version": "0.2.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"./README.md",
|
"./README.md",
|
||||||
@@ -32,24 +32,17 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint:tsc": "tsc --noEmit",
|
"lint:tsc": "tsc --noEmit",
|
||||||
"lint:biome": "biome lint",
|
"lint:biome": "biome lint",
|
||||||
"test": "vitest run",
|
|
||||||
"pack": "npm pack",
|
"pack": "npm pack",
|
||||||
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
|
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
|
||||||
"clean:dist": "rm -rf dist",
|
"clean:dist": "rm -rf dist",
|
||||||
"clean:modules": "rm -rf node_modules"
|
"clean:modules": "rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@effect/platform-browser": "^0.76.0",
|
"@effect/platform-browser": "^0.74.0"
|
||||||
"@testing-library/react": "^16.3.0",
|
|
||||||
"jsdom": "^26.1.0",
|
|
||||||
"vitest": "^3.2.4"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"effect": "^3.21.0",
|
"effect": "^3.19.0",
|
||||||
"react": "^19.2.0"
|
"react": "^19.2.0"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"effect-lens": "^0.2.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-116
@@ -1,49 +1,35 @@
|
|||||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||||
import { Effect, type Equivalence, Function, Predicate, Runtime, Scope } from "effect"
|
import { Effect, Function, Predicate, Runtime, Scope } from "effect"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
|
export const TypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
|
||||||
export type AsyncTypeId = typeof AsyncTypeId
|
export type TypeId = typeof TypeId
|
||||||
|
|
||||||
|
export interface Async extends Async.Options {
|
||||||
/**
|
readonly [TypeId]: TypeId
|
||||||
* A trait for `Component`'s that allows them running asynchronous effects.
|
|
||||||
*/
|
|
||||||
export interface Async extends AsyncPrototype, AsyncOptions {}
|
|
||||||
|
|
||||||
export interface AsyncPrototype {
|
|
||||||
readonly [AsyncTypeId]: AsyncTypeId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export namespace Async {
|
||||||
* Configuration options for `Async` components.
|
export interface Options {
|
||||||
*/
|
|
||||||
export interface AsyncOptions {
|
|
||||||
/**
|
|
||||||
* The default fallback React node to display while the async operation is pending.
|
|
||||||
* Used if no fallback is provided to the component when rendering.
|
|
||||||
*/
|
|
||||||
readonly defaultFallback?: React.ReactNode
|
readonly defaultFallback?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Props = Omit<React.SuspenseProps, "children">
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for `Async` components.
|
|
||||||
*/
|
|
||||||
export type AsyncProps = Omit<React.SuspenseProps, "children">
|
|
||||||
|
|
||||||
|
const AsyncProto = Object.freeze({
|
||||||
|
[TypeId]: TypeId,
|
||||||
|
|
||||||
export const AsyncPrototype: AsyncPrototype = Object.freeze({
|
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
|
||||||
[AsyncTypeId]: AsyncTypeId,
|
this: Component.Component<P, A, E, R> & Async,
|
||||||
|
|
||||||
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Component.Signature>(
|
|
||||||
this: Component.Component<P, A, E, R, F> & Async,
|
|
||||||
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
|
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
|
||||||
) {
|
) {
|
||||||
const Inner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
|
const SuspenseInner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
|
||||||
|
|
||||||
return ({ fallback, name, ...props }: AsyncProps) => {
|
return ({ fallback, name, ...props }: Async.Props) => {
|
||||||
const promise = Runtime.runPromise(runtimeRef.current)(
|
const promise = Runtime.runPromise(runtimeRef.current)(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
Component.useScope([], this),
|
Component.useScope([], this),
|
||||||
@@ -54,117 +40,45 @@ export const AsyncPrototype: AsyncPrototype = Object.freeze({
|
|||||||
return React.createElement(
|
return React.createElement(
|
||||||
React.Suspense,
|
React.Suspense,
|
||||||
{ fallback: fallback ?? this.defaultFallback, name },
|
{ fallback: fallback ?? this.defaultFallback, name },
|
||||||
React.createElement(Inner, { promise }),
|
React.createElement(SuspenseInner, { promise }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
} as const)
|
} as const)
|
||||||
|
|
||||||
/**
|
|
||||||
* An equivalence function for comparing `AsyncProps` that ignores the `fallback` property.
|
|
||||||
* Used by default by async components with `Memoized.memoized` applied.
|
|
||||||
*/
|
|
||||||
export const defaultPropsEquivalence: Equivalence.Equivalence<AsyncProps> = (
|
|
||||||
self: Record<string, unknown>,
|
|
||||||
that: Record<string, unknown>,
|
|
||||||
) => {
|
|
||||||
if (self === that)
|
|
||||||
return true
|
|
||||||
|
|
||||||
for (const key in self) {
|
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, TypeId)
|
||||||
if (key === "fallback")
|
|
||||||
continue
|
|
||||||
if (!(key in that) || !Object.is(self[key], that[key]))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key in that) {
|
export const async = <T extends Component.Component<any, any, any, any>>(
|
||||||
if (key === "fallback")
|
self: T
|
||||||
continue
|
|
||||||
if (!(key in self))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, AsyncTypeId)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a Component into an `Async` component that supports running asynchronous effects.
|
|
||||||
*
|
|
||||||
* Note: The component cannot have a prop named "promise" as it's reserved for internal use.
|
|
||||||
*
|
|
||||||
* @param self - The component to convert to an Async component
|
|
||||||
* @returns A new `Async` component with the same body, error, and context types as the input
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const MyAsyncComponent = MyComponent.pipe(
|
|
||||||
* Async.async,
|
|
||||||
* )
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const async = <T extends Component.Component.Any>(
|
|
||||||
self: T & (
|
|
||||||
"promise" extends keyof Component.Component.Props<T>
|
|
||||||
? "The 'promise' prop name is restricted for Async components. Please rename the 'promise' prop to something else."
|
|
||||||
: T
|
|
||||||
)
|
|
||||||
): (
|
): (
|
||||||
& Omit<T, keyof Component.Component.AsComponent<T>>
|
& Omit<T, keyof Component.Component.AsComponent<T>>
|
||||||
& Component.Component<
|
& Component.Component<
|
||||||
Component.Component.Props<T> & AsyncProps,
|
Component.Component.Props<T> & Async.Props,
|
||||||
Component.Component.Success<T>,
|
Component.Component.Success<T>,
|
||||||
Component.Component.Error<T>,
|
Component.Component.Error<T>,
|
||||||
Component.Component.Context<T>,
|
Component.Component.Context<T>
|
||||||
Component.Component.DefaultSignature<Component.Component.Props<T> & AsyncProps, Component.Component.Success<T>>
|
|
||||||
>
|
>
|
||||||
& Async
|
& Async
|
||||||
) => Object.setPrototypeOf(
|
) => Object.setPrototypeOf(
|
||||||
Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }),
|
Object.assign(function() {}, self),
|
||||||
Object.freeze(Object.setPrototypeOf(
|
Object.freeze(Object.setPrototypeOf(
|
||||||
Object.assign({}, AsyncPrototype),
|
Object.assign({}, AsyncProto),
|
||||||
Object.getPrototypeOf(self),
|
Object.getPrototypeOf(self),
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies options to an Async component, returning a new Async component with the updated configuration.
|
|
||||||
*
|
|
||||||
* Supports both curried and uncurried application styles.
|
|
||||||
*
|
|
||||||
* @param self - The Async component to apply options to (in uncurried form)
|
|
||||||
* @param options - The options to apply to the component
|
|
||||||
* @returns An Async component with the applied options
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // Curried
|
|
||||||
* const MyAsyncComponent = MyComponent.pipe(
|
|
||||||
* Async.async,
|
|
||||||
* Async.withOptions({ defaultFallback: <p>Loading...</p> }),
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* // Uncurried
|
|
||||||
* const MyAsyncComponent = Async.withOptions(
|
|
||||||
* Async.async(MyComponent),
|
|
||||||
* { defaultFallback: <p>Loading...</p> },
|
|
||||||
* )
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const withOptions: {
|
export const withOptions: {
|
||||||
<T extends Component.Component.Any & Async>(
|
<T extends Component.Component<any, any, any, any> & Async>(
|
||||||
options: Partial<AsyncOptions>
|
options: Partial<Async.Options>
|
||||||
): (self: T) => T
|
): (self: T) => T
|
||||||
<T extends Component.Component.Any & Async>(
|
<T extends Component.Component<any, any, any, any> & Async>(
|
||||||
self: T,
|
self: T,
|
||||||
options: Partial<AsyncOptions>,
|
options: Partial<Async.Options>,
|
||||||
): T
|
): T
|
||||||
} = Function.dual(2, <T extends Component.Component.Any & Async>(
|
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Async>(
|
||||||
self: T,
|
self: T,
|
||||||
options: Partial<AsyncOptions>,
|
options: Partial<Async.Options>,
|
||||||
): T => Object.setPrototypeOf(
|
): T => Object.setPrototypeOf(
|
||||||
Object.assign(function() {}, self, options),
|
Object.assign(function() {}, self, options),
|
||||||
Object.getPrototypeOf(self),
|
Object.getPrototypeOf(self),
|
||||||
|
|||||||
+119
-637
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,20 @@
|
|||||||
import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect"
|
import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect"
|
||||||
|
|
||||||
|
|
||||||
export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
|
export const TypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
|
||||||
export type ErrorObserverTypeId = typeof ErrorObserverTypeId
|
export type TypeId = typeof TypeId
|
||||||
|
|
||||||
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
|
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
|
||||||
readonly [ErrorObserverTypeId]: ErrorObserverTypeId
|
readonly [TypeId]: TypeId
|
||||||
handle<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
|
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>
|
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")
|
export const ErrorObserver = <E = never>(): Context.Tag<ErrorObserver, ErrorObserver<E>> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver")
|
||||||
|
|
||||||
export class ErrorObserverImpl<in out E = never>
|
class ErrorObserverImpl<in out E = never>
|
||||||
extends Pipeable.Class() implements ErrorObserver<E> {
|
extends Pipeable.Class() implements ErrorObserver<E> {
|
||||||
readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId
|
readonly [TypeId]: TypeId = TypeId
|
||||||
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
|
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -29,7 +29,7 @@ extends Pipeable.Class() implements ErrorObserver<E> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
|
class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
|
||||||
readonly value = Effect.void
|
readonly value = Effect.void
|
||||||
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) {
|
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) {
|
||||||
super()
|
super()
|
||||||
@@ -43,7 +43,7 @@ export class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, ErrorObserverTypeId)
|
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, TypeId)
|
||||||
|
|
||||||
export const layer: Layer.Layer<ErrorObserver> = Layer.unwrapEffect(Effect.map(
|
export const layer: Layer.Layer<ErrorObserver> = Layer.unwrapEffect(Effect.map(
|
||||||
PubSub.unbounded<Cause.Cause<never>>(),
|
PubSub.unbounded<Cause.Cause<never>>(),
|
||||||
|
|||||||
+301
-179
@@ -1,157 +1,293 @@
|
|||||||
import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, type ParseResult, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
|
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 type * as React from "react"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "./Component.js"
|
||||||
import * as Lens from "./Lens.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 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 const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
|
||||||
export type FormTypeId = typeof FormTypeId
|
export type FormTypeId = typeof FormTypeId
|
||||||
|
|
||||||
export interface Form<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
|
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 {
|
extends Pipeable.Pipeable {
|
||||||
readonly [FormTypeId]: FormTypeId
|
readonly [FormTypeId]: FormTypeId
|
||||||
|
|
||||||
readonly path: P
|
readonly schema: Schema.Schema<A, I, R>
|
||||||
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>
|
readonly context: Context.Context<Scope.Scope | R>
|
||||||
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>
|
readonly mutation: Mutation.Mutation<
|
||||||
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>
|
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>],
|
||||||
readonly isValidating: Subscribable.Subscribable<boolean, ER, never>
|
MA, ME, MR, MP
|
||||||
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
|
>
|
||||||
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
|
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<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
|
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<P, A, I, ER, EW> {
|
extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
|
||||||
readonly [FormTypeId]: FormTypeId = FormTypeId
|
readonly [FormTypeId]: FormTypeId = FormTypeId
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly path: P,
|
readonly schema: Schema.Schema<A, I, R>,
|
||||||
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>,
|
readonly context: Context.Context<Scope.Scope | R>,
|
||||||
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>,
|
readonly mutation: Mutation.Mutation<
|
||||||
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>,
|
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>],
|
||||||
readonly isValidating: Subscribable.Subscribable<boolean, never, never>,
|
MA, ME, MR, MP
|
||||||
readonly canCommit: Subscribable.Subscribable<boolean, never, never>,
|
>,
|
||||||
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>,
|
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()
|
super()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FormFieldKeyTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormFieldKey")
|
||||||
|
type FormFieldKeyTypeId = typeof FormFieldKeyTypeId
|
||||||
|
|
||||||
export const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const filterIssuesByPath = (
|
export const isFormField = (u: unknown): u is FormField<unknown, unknown> => Predicate.hasProperty(u, FormFieldTypeId)
|
||||||
issues: readonly ParseResult.ArrayFormatterIssue[],
|
const isFormFieldKey = (u: unknown): u is FormFieldKey => Predicate.hasProperty(u, FormFieldKeyTypeId)
|
||||||
path: readonly PropertyKey[],
|
|
||||||
): readonly ParseResult.ArrayFormatterIssue[] => Array.filter(issues, issue =>
|
|
||||||
issue.path.length >= path.length && Array.every(path, (p, i) => p === issue.path[i])
|
|
||||||
)
|
|
||||||
|
|
||||||
export const focusObjectOn: {
|
export const makeFormField = <A, I, R, MA, ME, MR, MP, const P extends PropertyPath.Paths<NoInfer<I>>>(
|
||||||
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
self: Form<A, I, R, MA, ME, MR, MP>,
|
||||||
self: Form<P, A, I, ER, EW>,
|
path: P,
|
||||||
key: K,
|
): FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>> => {
|
||||||
): Form<readonly [...P, K], A[K], I[K], ER, EW>
|
return new FormFieldImpl(
|
||||||
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
Subscribable.mapEffect(self.value, Option.match({
|
||||||
key: K,
|
onSome: v => Option.map(PropertyPath.get(v, path), Option.some),
|
||||||
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
|
onNone: () => Option.some(Option.none()),
|
||||||
} = Function.dual(2, <P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
})),
|
||||||
self: Form<P, A, I, ER, EW>,
|
SubscriptionSubRef.makeFromPath(self.encodedValue, path),
|
||||||
key: K,
|
Subscribable.mapEffect(self.error, Option.match({
|
||||||
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
|
onSome: flow(
|
||||||
const form = self as FormImpl<P, A, I, ER, EW>
|
ParseResult.ArrayFormatter.formatError,
|
||||||
const path = [...form.path, key] as const
|
Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))),
|
||||||
|
),
|
||||||
return new FormImpl(
|
onNone: () => Effect.succeed([]),
|
||||||
path,
|
})),
|
||||||
Subscribable.mapOption(form.value, a => a[key]),
|
Subscribable.map(self.validationFiber, Option.isSome),
|
||||||
Lens.focusObjectOn(form.encodedValue, key),
|
Subscribable.map(self.mutation.result, result => Result.isRunning(result) || Result.hasRefreshingFlag(result)),
|
||||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
|
||||||
form.isValidating,
|
|
||||||
form.canCommit,
|
|
||||||
form.isCommitting,
|
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
export const focusArrayAt: {
|
|
||||||
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
|
|
||||||
self: Form<P, A, I, ER, EW>,
|
|
||||||
index: number,
|
|
||||||
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException>
|
|
||||||
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
|
|
||||||
index: number,
|
|
||||||
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException>
|
|
||||||
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
|
|
||||||
self: Form<P, A, I, ER, EW>,
|
|
||||||
index: number,
|
|
||||||
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException> => {
|
|
||||||
const form = self as FormImpl<P, A, I, ER, EW>
|
|
||||||
const path = [...form.path, index] as const
|
|
||||||
|
|
||||||
return new FormImpl(
|
|
||||||
path,
|
|
||||||
Subscribable.mapOptionEffect(form.value, Array.get(index)),
|
|
||||||
Lens.focusArrayAt(form.encodedValue, index),
|
|
||||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
|
||||||
form.isValidating,
|
|
||||||
form.canCommit,
|
|
||||||
form.isCommitting,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const focusTupleAt: {
|
|
||||||
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
|
|
||||||
self: Form<P, A, I, ER, EW>,
|
|
||||||
index: K,
|
|
||||||
): Form<readonly [...P, K], A[K], I[K], ER, EW>
|
|
||||||
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
|
|
||||||
index: K,
|
|
||||||
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
|
|
||||||
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
|
|
||||||
self: Form<P, A, I, ER, EW>,
|
|
||||||
index: K,
|
|
||||||
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
|
|
||||||
const form = self as FormImpl<P, A, I, ER, EW>
|
|
||||||
const path = [...form.path, index] as const
|
|
||||||
|
|
||||||
return new FormImpl(
|
|
||||||
path,
|
|
||||||
Subscribable.mapOption(form.value, Array.unsafeGet(index)),
|
|
||||||
Lens.focusTupleAt(form.encodedValue, index),
|
|
||||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
|
||||||
form.isValidating,
|
|
||||||
form.canCommit,
|
|
||||||
form.isCommitting,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const focusChunkAt: {
|
|
||||||
<P extends readonly PropertyKey[], A, I, ER, EW>(
|
|
||||||
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
|
|
||||||
index: number,
|
|
||||||
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW>
|
|
||||||
<P extends readonly PropertyKey[], A, I, ER, EW>(
|
|
||||||
index: number,
|
|
||||||
): (self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>) => Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW>
|
|
||||||
} = Function.dual(2, <P extends readonly PropertyKey[], A, I, ER, EW>(
|
|
||||||
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
|
|
||||||
index: number,
|
|
||||||
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW> => {
|
|
||||||
const form = self as FormImpl<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>
|
|
||||||
const path = [...form.path, index] as const
|
|
||||||
|
|
||||||
return new FormImpl(
|
|
||||||
path,
|
|
||||||
Subscribable.mapOptionEffect(form.value, Chunk.get(index)),
|
|
||||||
Lens.focusChunkAt(form.encodedValue, index),
|
|
||||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
|
||||||
form.isValidating,
|
|
||||||
form.canCommit,
|
|
||||||
form.isCommitting,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
export namespace useInput {
|
export namespace useInput {
|
||||||
@@ -165,39 +301,33 @@ export namespace useInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
|
export const useInput = Effect.fnUntraced(function* <A, I>(
|
||||||
form: Form<P, A, I, ER, EW>,
|
field: FormField<A, I>,
|
||||||
options?: useInput.Options,
|
options?: useInput.Options,
|
||||||
): Effect.fn.Return<useInput.Success<I>, ER, Scope.Scope> {
|
): Effect.fn.Return<useInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
|
||||||
const internalValueLens = yield* Component.useOnChange(() => Effect.gen(function*() {
|
const internalValueRef = yield* Component.useOnChange(() => Effect.tap(
|
||||||
const internalValueLens = yield* Lens.get(form.encodedValue).pipe(
|
Effect.andThen(field.encodedValue, SubscriptionRef.make),
|
||||||
Effect.flatMap(SubscriptionRef.make),
|
internalValueRef => Effect.forkScoped(Effect.all([
|
||||||
Effect.map(Lens.fromSubscriptionRef),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* Effect.forkScoped(Effect.all([
|
|
||||||
Stream.runForEach(
|
Stream.runForEach(
|
||||||
Stream.drop(form.encodedValue.changes, 1),
|
Stream.drop(field.encodedValue, 1),
|
||||||
upstreamEncodedValue => Effect.whenEffect(
|
upstreamEncodedValue => Effect.whenEffect(
|
||||||
Lens.set(internalValueLens, upstreamEncodedValue),
|
Ref.set(internalValueRef, upstreamEncodedValue),
|
||||||
Effect.andThen(Lens.get(internalValueLens), internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
|
Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
Stream.runForEach(
|
Stream.runForEach(
|
||||||
internalValueLens.changes.pipe(
|
internalValueRef.changes.pipe(
|
||||||
Stream.drop(1),
|
Stream.drop(1),
|
||||||
Stream.changesWith(Equal.equivalence()),
|
Stream.changesWith(Equal.equivalence()),
|
||||||
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
||||||
),
|
),
|
||||||
internalValue => Lens.set(form.encodedValue, internalValue),
|
internalValue => Ref.set(field.encodedValue, internalValue),
|
||||||
),
|
),
|
||||||
], { concurrency: "unbounded", discard: true }))
|
], { concurrency: "unbounded" })),
|
||||||
|
), [field, options?.debounce])
|
||||||
|
|
||||||
return internalValueLens
|
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
|
||||||
}), [form, options?.debounce])
|
|
||||||
|
|
||||||
const [value, setValue] = yield* Lens.useState(internalValueLens)
|
|
||||||
return { value, setValue }
|
return { value, setValue }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -212,63 +342,55 @@ export namespace useOptionalInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useOptionalInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
|
export const useOptionalInput = Effect.fnUntraced(function* <A, I>(
|
||||||
field: Form<P, A, Option.Option<I>, ER, EW>,
|
field: FormField<A, Option.Option<I>>,
|
||||||
options: useOptionalInput.Options<I>,
|
options: useOptionalInput.Options<I>,
|
||||||
): Effect.fn.Return<useOptionalInput.Success<I>, ER, Scope.Scope> {
|
): Effect.fn.Return<useOptionalInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
|
||||||
const [enabledLens, internalValueLens] = yield* Component.useOnChange(() => Effect.gen(function*() {
|
const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap(
|
||||||
const [enabledLens, internalValueLens] = yield* Effect.flatMap(
|
Effect.andThen(
|
||||||
Lens.get(field.encodedValue),
|
field.encodedValue,
|
||||||
Option.match({
|
Option.match({
|
||||||
onSome: v => Effect.all([
|
onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]),
|
||||||
Effect.map(SubscriptionRef.make(true), Lens.fromSubscriptionRef),
|
onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]),
|
||||||
Effect.map(SubscriptionRef.make(v), Lens.fromSubscriptionRef),
|
|
||||||
]),
|
|
||||||
onNone: () => Effect.all([
|
|
||||||
Effect.map(SubscriptionRef.make(false), Lens.fromSubscriptionRef),
|
|
||||||
Effect.map(SubscriptionRef.make(options.defaultValue), Lens.fromSubscriptionRef),
|
|
||||||
]),
|
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
|
|
||||||
yield* Effect.forkScoped(Effect.all([
|
([enabledRef, internalValueRef]) => Effect.forkScoped(Effect.all([
|
||||||
Stream.runForEach(
|
Stream.runForEach(
|
||||||
Stream.drop(field.encodedValue.changes, 1),
|
Stream.drop(field.encodedValue, 1),
|
||||||
|
|
||||||
upstreamEncodedValue => Effect.whenEffect(
|
upstreamEncodedValue => Effect.whenEffect(
|
||||||
Option.match(upstreamEncodedValue, {
|
Option.match(upstreamEncodedValue, {
|
||||||
onSome: v => Effect.andThen(
|
onSome: v => Effect.andThen(
|
||||||
Lens.set(enabledLens, true),
|
Ref.set(enabledRef, true),
|
||||||
Lens.set(internalValueLens, v),
|
Ref.set(internalValueRef, v),
|
||||||
),
|
),
|
||||||
onNone: () => Effect.andThen(
|
onNone: () => Effect.andThen(
|
||||||
Lens.set(enabledLens, false),
|
Ref.set(enabledRef, false),
|
||||||
Lens.set(internalValueLens, options.defaultValue),
|
Ref.set(internalValueRef, options.defaultValue),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
Effect.all([Lens.get(enabledLens), Lens.get(internalValueLens)]),
|
Effect.all([enabledRef, internalValueRef]),
|
||||||
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
Stream.runForEach(
|
Stream.runForEach(
|
||||||
enabledLens.changes.pipe(
|
enabledRef.changes.pipe(
|
||||||
Stream.zipLatest(internalValueLens.changes),
|
Stream.zipLatest(internalValueRef.changes),
|
||||||
Stream.drop(1),
|
Stream.drop(1),
|
||||||
Stream.changesWith(Equal.equivalence()),
|
Stream.changesWith(Equal.equivalence()),
|
||||||
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
||||||
),
|
),
|
||||||
([enabled, internalValue]) => Lens.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
([enabled, internalValue]) => Ref.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
||||||
),
|
),
|
||||||
], { concurrency: "unbounded" }))
|
], { concurrency: "unbounded" })),
|
||||||
|
), [field, options.debounce])
|
||||||
|
|
||||||
return [enabledLens, internalValueLens] as const
|
const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef)
|
||||||
}), [field, options.debounce])
|
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
|
||||||
|
|
||||||
const [enabled, setEnabled] = yield* Lens.useState(enabledLens)
|
|
||||||
const [value, setValue] = yield* Lens.useState(internalValueLens)
|
|
||||||
return { enabled, setEnabled, value, setValue }
|
return { enabled, setEnabled, value, setValue }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { Effect, Equivalence, Stream, SubscriptionRef } from "effect"
|
|
||||||
import { Lens } from "effect-lens"
|
|
||||||
import * as React from "react"
|
|
||||||
import * as Component from "./Component.js"
|
|
||||||
import * as SetStateAction from "./SetStateAction.js"
|
|
||||||
|
|
||||||
|
|
||||||
export * from "effect-lens/Lens"
|
|
||||||
|
|
||||||
export declare namespace useState {
|
|
||||||
export interface Options<A> {
|
|
||||||
readonly equivalence?: Equivalence.Equivalence<A>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useState = Effect.fnUntraced(function* <A, ER, EW, RR, RW>(
|
|
||||||
lens: Lens.Lens<A, ER, EW, RR, RW>,
|
|
||||||
options?: useState.Options<NoInfer<A>>,
|
|
||||||
): Effect.fn.Return<readonly [A, React.Dispatch<React.SetStateAction<A>>], ER, RR | RW> {
|
|
||||||
const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => Lens.get(lens)))
|
|
||||||
|
|
||||||
yield* Component.useReactEffect(() => Effect.forkScoped(
|
|
||||||
Stream.runForEach(
|
|
||||||
Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strict()),
|
|
||||||
v => Effect.sync(() => setReactStateValue(v)),
|
|
||||||
)
|
|
||||||
), [lens])
|
|
||||||
|
|
||||||
const setValue = yield* Component.useCallbackSync(
|
|
||||||
(setStateAction: React.SetStateAction<A>) => Effect.andThen(
|
|
||||||
Lens.updateAndGet(lens, prevState => SetStateAction.value(setStateAction, prevState)),
|
|
||||||
v => setReactStateValue(v),
|
|
||||||
),
|
|
||||||
[lens],
|
|
||||||
)
|
|
||||||
|
|
||||||
return [reactStateValue, setValue]
|
|
||||||
})
|
|
||||||
|
|
||||||
export declare namespace useFromReactState {
|
|
||||||
export interface Options<A> {
|
|
||||||
readonly equivalence?: Equivalence.Equivalence<A>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useFromReactState = Effect.fnUntraced(function* <A>(
|
|
||||||
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
|
|
||||||
options?: useFromReactState.Options<NoInfer<A>>,
|
|
||||||
): Effect.fn.Return<Lens.Lens<A, never, never, never, never>> {
|
|
||||||
const lens = yield* Component.useOnMount(() => Effect.map(
|
|
||||||
SubscriptionRef.make(value),
|
|
||||||
Lens.fromSubscriptionRef,
|
|
||||||
))
|
|
||||||
|
|
||||||
yield* Component.useReactEffect(() => Effect.forkScoped(Stream.runForEach(
|
|
||||||
Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strict()),
|
|
||||||
v => Effect.sync(() => setValue(v)),
|
|
||||||
)), [setValue])
|
|
||||||
yield* Component.useReactEffect(() => Lens.set(lens, value), [value])
|
|
||||||
|
|
||||||
return lens
|
|
||||||
})
|
|
||||||
@@ -1,111 +1,50 @@
|
|||||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
/** 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 MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
|
export const TypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
|
||||||
export type MemoizedTypeId = typeof MemoizedTypeId
|
export type TypeId = typeof TypeId
|
||||||
|
|
||||||
|
export interface Memoized<P> extends Memoized.Options<P> {
|
||||||
/**
|
readonly [TypeId]: TypeId
|
||||||
* A trait for `Component`'s that uses `React.memo` to optimize re-renders based on prop equality.
|
|
||||||
*
|
|
||||||
* @template P The props type of the component
|
|
||||||
*/
|
|
||||||
export interface Memoized<P> extends MemoizedPrototype, MemoizedOptions<P> {}
|
|
||||||
|
|
||||||
export interface MemoizedPrototype {
|
|
||||||
readonly [MemoizedTypeId]: MemoizedTypeId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export namespace Memoized {
|
||||||
* Configuration options for Memoized components.
|
export interface Options<P> {
|
||||||
*
|
readonly propsAreEqual?: Equivalence.Equivalence<P>
|
||||||
* @template P The props type of the component
|
}
|
||||||
*/
|
|
||||||
export interface MemoizedOptions<P> {
|
|
||||||
/**
|
|
||||||
* An optional equivalence function for comparing component props.
|
|
||||||
* If provided, this function is used by React.memo to determine if props have changed.
|
|
||||||
* Returns `true` if props are equivalent (no re-render), `false` if they differ (re-render).
|
|
||||||
*/
|
|
||||||
readonly propsEquivalence?: Equivalence.Equivalence<P>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const MemoizedPrototype: MemoizedPrototype = Object.freeze({
|
const MemoizedProto = Object.freeze({
|
||||||
[MemoizedTypeId]: MemoizedTypeId,
|
[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, MemoizedTypeId)
|
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, TypeId)
|
||||||
|
|
||||||
/**
|
export const memoized = <T extends Component.Component<any, any, any, any>>(
|
||||||
* Converts a Component into a `Memoized` component that optimizes re-renders using `React.memo`.
|
|
||||||
*
|
|
||||||
* @param self - The component to convert to a Memoized component
|
|
||||||
* @returns A new `Memoized` component with the same body, error, and context types as the input
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const MyMemoizedComponent = MyComponent.pipe(
|
|
||||||
* Memoized.memoized,
|
|
||||||
* )
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const memoized = <T extends Component.Component.Any>(
|
|
||||||
self: T
|
self: T
|
||||||
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
|
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
|
||||||
Object.assign(function() {}, self),
|
Object.assign(function() {}, self),
|
||||||
Object.freeze(Object.setPrototypeOf(
|
Object.freeze(Object.setPrototypeOf(
|
||||||
Object.assign({}, MemoizedPrototype),
|
Object.assign({}, MemoizedProto),
|
||||||
Object.getPrototypeOf(self),
|
Object.getPrototypeOf(self),
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies options to a Memoized component, returning a new Memoized component with the updated configuration.
|
|
||||||
*
|
|
||||||
* Supports both curried and uncurried application styles.
|
|
||||||
*
|
|
||||||
* @param self - The Memoized component to apply options to (in uncurried form)
|
|
||||||
* @param options - The options to apply to the component
|
|
||||||
* @returns A Memoized component with the applied options
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // Curried
|
|
||||||
* const MyMemoizedComponent = MyComponent.pipe(
|
|
||||||
* Memoized.memoized,
|
|
||||||
* Memoized.withOptions({ propsEquivalence: (a, b) => a.id === b.id }),
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* // Uncurried
|
|
||||||
* const MyMemoizedComponent = Memoized.withOptions(
|
|
||||||
* Memoized.memoized(MyComponent),
|
|
||||||
* { propsEquivalence: (a, b) => a.id === b.id },
|
|
||||||
* )
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const withOptions: {
|
export const withOptions: {
|
||||||
<T extends Component.Component.Any & Memoized<any>>(
|
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>
|
options: Partial<Memoized.Options<Component.Component.Props<T>>>
|
||||||
): (self: T) => T
|
): (self: T) => T
|
||||||
<T extends Component.Component.Any & Memoized<any>>(
|
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||||
self: T,
|
self: T,
|
||||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
|
options: Partial<Memoized.Options<Component.Component.Props<T>>>,
|
||||||
): T
|
): T
|
||||||
} = Function.dual(2, <T extends Component.Component.Any & Memoized<any>>(
|
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||||
self: T,
|
self: T,
|
||||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
|
options: Partial<Memoized.Options<Component.Component.Props<T>>>,
|
||||||
): T => Object.setPrototypeOf(
|
): T => Object.setPrototypeOf(
|
||||||
Object.assign(function() {}, self, options),
|
Object.assign(function() {}, self, options),
|
||||||
Object.getPrototypeOf(self),
|
Object.getPrototypeOf(self),
|
||||||
|
|||||||
@@ -99,10 +99,8 @@ extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const isMutation = (u: unknown): u is Mutation<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
|
export const isMutation = (u: unknown): u is Mutation<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
|
||||||
|
|
||||||
|
|
||||||
export declare namespace make {
|
export declare namespace make {
|
||||||
export interface Options<K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never> {
|
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 f: (key: K) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
|
||||||
@@ -113,12 +111,12 @@ export declare namespace make {
|
|||||||
export const make = Effect.fnUntraced(function* <const K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never>(
|
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>
|
options: make.Options<K, A, E, R, P>
|
||||||
): Effect.fn.Return<
|
): Effect.fn.Return<
|
||||||
Mutation<K, A, E, Result.forkEffect.OutputContext<R, P>, P>,
|
Mutation<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
|
||||||
never,
|
never,
|
||||||
Scope.Scope | Result.forkEffect.OutputContext<R, P>
|
Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>
|
||||||
> {
|
> {
|
||||||
return new MutationImpl(
|
return new MutationImpl(
|
||||||
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<R, P>>(),
|
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>>(),
|
||||||
options.f as any,
|
options.f as any,
|
||||||
options.initialProgress as P,
|
options.initialProgress as P,
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Array, Equivalence, 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]
|
||||||
|
|
||||||
|
export type Paths<T, D extends number = 5, Seen = never> = readonly [] | (
|
||||||
|
D extends never ? readonly [] :
|
||||||
|
T extends Seen ? readonly [] :
|
||||||
|
T extends readonly any[] ? {
|
||||||
|
[K in keyof T as K extends number ? K : never]:
|
||||||
|
| 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
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ValueFromPath<T, P extends readonly any[]> = P extends readonly [infer Head, ...infer Tail]
|
||||||
|
? Head extends keyof T
|
||||||
|
? ValueFromPath<T[Head], Tail>
|
||||||
|
: T extends readonly any[]
|
||||||
|
? Head extends number
|
||||||
|
? ValueFromPath<T[number], Tail>
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
: T
|
||||||
|
|
||||||
|
|
||||||
|
export const equivalence: Equivalence.Equivalence<PropertyPath> = Equivalence.array(Equivalence.strict())
|
||||||
|
|
||||||
|
export const unsafeGet: {
|
||||||
|
<T, const P extends Paths<T>>(path: P): (self: T) => ValueFromPath<T, P>
|
||||||
|
<T, const P extends Paths<T>>(self: T, path: P): ValueFromPath<T, P>
|
||||||
|
} = Function.dual(2, <T, const P extends Paths<T>>(self: T, path: P): ValueFromPath<T, P> =>
|
||||||
|
path.reduce((acc: any, key: any) => acc?.[key], self)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const get: {
|
||||||
|
<T, const P extends Paths<T>>(path: P): (self: T) => Option.Option<ValueFromPath<T, P>>
|
||||||
|
<T, const P extends Paths<T>>(self: T, path: P): Option.Option<ValueFromPath<T, P>>
|
||||||
|
} = Function.dual(2, <T, const P extends Paths<T>>(self: T, path: P): Option.Option<ValueFromPath<T, P>> =>
|
||||||
|
path.reduce(
|
||||||
|
(acc: Option.Option<any>, key: any): Option.Option<any> => Option.isSome(acc)
|
||||||
|
? Predicate.hasProperty(acc.value, key)
|
||||||
|
? Option.some(acc.value[key])
|
||||||
|
: Option.none()
|
||||||
|
: acc,
|
||||||
|
|
||||||
|
Option.some(self),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const immutableSet: {
|
||||||
|
<T, const P extends Paths<T>>(path: P, value: ValueFromPath<T, P>): (self: T) => 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> => {
|
||||||
|
const key = Array.head(path as PropertyPath)
|
||||||
|
if (Option.isNone(key))
|
||||||
|
return Option.some(value as T)
|
||||||
|
if (!Predicate.hasProperty(self, key.value))
|
||||||
|
return Option.none()
|
||||||
|
|
||||||
|
const child = immutableSet<any, any>(self[key.value], Option.getOrThrow(Array.tail(path as PropertyPath)), value)
|
||||||
|
if (Option.isNone(child))
|
||||||
|
return child
|
||||||
|
|
||||||
|
if (Array.isArray(self))
|
||||||
|
return typeof key.value === "number"
|
||||||
|
? Option.some([
|
||||||
|
...self.slice(0, key.value),
|
||||||
|
child.value,
|
||||||
|
...self.slice(key.value + 1),
|
||||||
|
] as T)
|
||||||
|
: Option.none()
|
||||||
|
|
||||||
|
if (typeof self === "object")
|
||||||
|
return Option.some(
|
||||||
|
Object.assign(
|
||||||
|
Object.create(Object.getPrototypeOf(self)),
|
||||||
|
{ ...self, [key.value]: child.value },
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return Option.none()
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@ import type * as React from "react"
|
|||||||
import * as Component from "./Component.js"
|
import * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
export const useFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
|
export const usePubSubFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
|
||||||
values: A
|
values: A
|
||||||
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
|
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
|
||||||
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
|
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type Cause, type Context, type Duration, Effect, Equal, Fiber, identity, Option, Pipeable, Predicate, type Scope, Stream, Subscribable, SubscriptionRef } from "effect"
|
import { type Cause, type Context, DateTime, type Duration, Effect, Equal, Equivalence, Fiber, HashMap, identity, Option, Pipeable, Predicate, type Scope, Stream, Subscribable, SubscriptionRef } from "effect"
|
||||||
import * as QueryClient from "./QueryClient.js"
|
import * as QueryClient from "./QueryClient.js"
|
||||||
import * as Result from "./Result.js"
|
import * as Result from "./Result.js"
|
||||||
|
|
||||||
@@ -6,12 +6,12 @@ import * as Result from "./Result.js"
|
|||||||
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
|
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
|
||||||
export type QueryTypeId = typeof QueryTypeId
|
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, in out P = never>
|
export interface Query<in out K extends Query.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
|
||||||
extends Pipeable.Pipeable {
|
extends Pipeable.Pipeable {
|
||||||
readonly [QueryTypeId]: QueryTypeId
|
readonly [QueryTypeId]: QueryTypeId
|
||||||
|
|
||||||
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>
|
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>
|
||||||
readonly key: Stream.Stream<K, KE, KR>
|
readonly key: Stream.Stream<K>
|
||||||
readonly f: (key: K) => Effect.Effect<A, E, R>
|
readonly f: (key: K) => Effect.Effect<A, E, R>
|
||||||
readonly initialProgress: P
|
readonly initialProgress: P
|
||||||
|
|
||||||
@@ -37,13 +37,13 @@ export declare namespace Query {
|
|||||||
export type AnyKey = readonly any[]
|
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, in out P = never>
|
export class QueryImpl<in out K extends Query.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
|
||||||
extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
|
extends Pipeable.Class() implements Query<K, A, E, R, P> {
|
||||||
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
|
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>,
|
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>,
|
||||||
readonly key: Stream.Stream<K, KE, KR>,
|
readonly key: Stream.Stream<K>,
|
||||||
readonly f: (key: K) => Effect.Effect<A, E, R>,
|
readonly f: (key: K) => Effect.Effect<A, E, R>,
|
||||||
readonly initialProgress: P,
|
readonly initialProgress: P,
|
||||||
|
|
||||||
@@ -77,11 +77,10 @@ extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
|
|||||||
], { concurrency: "unbounded" }).pipe(
|
], { concurrency: "unbounded" }).pipe(
|
||||||
Effect.ignore,
|
Effect.ignore,
|
||||||
this.runSemaphore.withPermits(1),
|
this.runSemaphore.withPermits(1),
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get interrupt(): Effect.Effect<void> {
|
get interrupt(): Effect.Effect<void, never, never> {
|
||||||
return Effect.andThen(this.fiber, Option.match({
|
return Effect.andThen(this.fiber, Option.match({
|
||||||
onSome: Fiber.interrupt,
|
onSome: Fiber.interrupt,
|
||||||
onNone: () => Effect.void,
|
onNone: () => Effect.void,
|
||||||
@@ -160,7 +159,7 @@ extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
|
|||||||
> {
|
> {
|
||||||
return Effect.andThen(this.getCacheEntry(key), Option.match({
|
return Effect.andThen(this.getCacheEntry(key), Option.match({
|
||||||
onSome: entry => Effect.andThen(
|
onSome: entry => Effect.andThen(
|
||||||
QueryClient.isQueryClientCacheEntryStale(entry),
|
QueryClient.isQueryClientCacheEntryStale(entry, this.staleTime),
|
||||||
isStale => isStale
|
isStale => isStale
|
||||||
? this.start(key, Result.willRefresh(entry.result) as Result.Final<A, E, P>)
|
? this.start(key, Result.willRefresh(entry.result) as Result.Final<A, E, P>)
|
||||||
: Effect.succeed(Subscribable.make({
|
: Effect.succeed(Subscribable.make({
|
||||||
@@ -213,7 +212,7 @@ extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
|
|||||||
) as Effect.Effect<Result.Final<A, E, P>>),
|
) as Effect.Effect<Result.Final<A, E, P>>),
|
||||||
Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
|
Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
|
||||||
Effect.tap(result => Result.isSuccess(result)
|
Effect.tap(result => Result.isSuccess(result)
|
||||||
? this.setCacheEntry(key, result)
|
? this.updateCacheEntry(key, result)
|
||||||
: Effect.void
|
: Effect.void
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -226,53 +225,54 @@ extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
|
|||||||
getCacheEntry(
|
getCacheEntry(
|
||||||
key: K
|
key: K
|
||||||
): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> {
|
): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> {
|
||||||
return Effect.andThen(
|
return QueryClient.QueryClient.pipe(
|
||||||
Effect.all([
|
Effect.andThen(client => client.cache),
|
||||||
Effect.succeed(this.makeCacheKey(key)),
|
Effect.map(HashMap.get(this.makeCacheKey(key))),
|
||||||
QueryClient.QueryClient,
|
|
||||||
]),
|
|
||||||
([key, client]) => client.getCacheEntry(key),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
setCacheEntry(
|
updateCacheEntry(
|
||||||
key: K,
|
key: K,
|
||||||
result: Result.Success<A>,
|
result: Result.Success<A>,
|
||||||
): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> {
|
): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> {
|
||||||
return Effect.andThen(
|
return Effect.Do.pipe(
|
||||||
Effect.all([
|
Effect.bind("client", () => QueryClient.QueryClient),
|
||||||
Effect.succeed(this.makeCacheKey(key)),
|
Effect.bind("now", () => DateTime.now),
|
||||||
QueryClient.QueryClient,
|
Effect.let("entry", ({ now }) => new QueryClient.QueryClientCacheEntry(result, now)),
|
||||||
]),
|
Effect.tap(({ client, entry }) => SubscriptionRef.update(
|
||||||
([key, client]) => client.setCacheEntry(key, result, this.staleTime),
|
client.cache,
|
||||||
|
HashMap.set(this.makeCacheKey(key), entry),
|
||||||
|
)),
|
||||||
|
Effect.map(({ entry }) => entry),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get invalidateCache(): Effect.Effect<void> {
|
get invalidateCache(): Effect.Effect<void> {
|
||||||
return QueryClient.QueryClient.pipe(
|
return QueryClient.QueryClient.pipe(
|
||||||
Effect.andThen(client => client.invalidateCacheEntries(this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>)),
|
Effect.andThen(client => SubscriptionRef.update(
|
||||||
|
client.cache,
|
||||||
|
HashMap.filter((_, key) => !Equivalence.strict()(key.f, this.f)),
|
||||||
|
)),
|
||||||
Effect.provide(this.context),
|
Effect.provide(this.context),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateCacheEntry(key: K): Effect.Effect<void> {
|
invalidateCacheEntry(key: K): Effect.Effect<void> {
|
||||||
return Effect.all([
|
return QueryClient.QueryClient.pipe(
|
||||||
Effect.succeed(this.makeCacheKey(key)),
|
Effect.andThen(client => SubscriptionRef.update(
|
||||||
QueryClient.QueryClient,
|
client.cache,
|
||||||
]).pipe(
|
HashMap.remove(this.makeCacheKey(key)),
|
||||||
Effect.andThen(([key, client]) => client.invalidateCacheEntry(key)),
|
)),
|
||||||
Effect.provide(this.context),
|
Effect.provide(this.context),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId)
|
||||||
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown> => Predicate.hasProperty(u, QueryTypeId)
|
|
||||||
|
|
||||||
|
|
||||||
export declare namespace make {
|
export declare namespace make {
|
||||||
export interface Options<K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never> {
|
export interface Options<K extends Query.AnyKey, A, E = never, R = never, P = never> {
|
||||||
readonly key: Stream.Stream<K, KE, KR>
|
readonly key: Stream.Stream<K>
|
||||||
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
|
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
|
||||||
readonly initialProgress?: P
|
readonly initialProgress?: P
|
||||||
readonly staleTime?: Duration.DurationInput
|
readonly staleTime?: Duration.DurationInput
|
||||||
@@ -280,17 +280,17 @@ export declare namespace make {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
|
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, E = never, R = never, P = never>(
|
||||||
options: make.Options<K, A, KE, KR, E, R, P>
|
options: make.Options<K, A, E, R, P>
|
||||||
): Effect.fn.Return<
|
): Effect.fn.Return<
|
||||||
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
|
Query<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
|
||||||
never,
|
never,
|
||||||
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
|
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>
|
||||||
> {
|
> {
|
||||||
const client = yield* QueryClient.QueryClient
|
const client = yield* QueryClient.QueryClient
|
||||||
|
|
||||||
return new QueryImpl<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>(
|
return new QueryImpl(
|
||||||
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>>(),
|
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>>(),
|
||||||
options.key,
|
options.key,
|
||||||
options.f as any,
|
options.f as any,
|
||||||
options.initialProgress as P,
|
options.initialProgress as P,
|
||||||
@@ -307,12 +307,12 @@ export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE =
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const service = <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
|
export const service = <K extends Query.AnyKey, A, E = never, R = never, P = never>(
|
||||||
options: make.Options<K, A, KE, KR, E, R, P>
|
options: make.Options<K, A, E, R, P>
|
||||||
): Effect.Effect<
|
): Effect.Effect<
|
||||||
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
|
Query<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
|
||||||
never,
|
never,
|
||||||
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
|
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>
|
||||||
> => Effect.tap(
|
> => Effect.tap(
|
||||||
make(options),
|
make(options),
|
||||||
query => Effect.forkScoped(query.run),
|
query => Effect.forkScoped(query.run),
|
||||||
|
|||||||
@@ -1,28 +1,17 @@
|
|||||||
import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, type Option, Pipeable, Predicate, Schedule, type Scope, type Subscribable, SubscriptionRef } from "effect"
|
import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, Pipeable, Predicate, type Scope, SubscriptionRef } from "effect"
|
||||||
import type * as Query from "./Query.js"
|
import type * as Query from "./Query.js"
|
||||||
import type * as Result from "./Result.js"
|
import type * as Result from "./Result.js"
|
||||||
|
|
||||||
|
|
||||||
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService")
|
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientServiceTypeId")
|
||||||
export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
|
export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
|
||||||
|
|
||||||
export interface QueryClientService extends Pipeable.Pipeable {
|
export interface QueryClientService extends Pipeable.Pipeable {
|
||||||
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
|
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
|
||||||
|
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
|
||||||
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
|
readonly gcTime: Duration.DurationInput
|
||||||
readonly cacheGcTime: Duration.DurationInput
|
|
||||||
readonly defaultStaleTime: Duration.DurationInput
|
readonly defaultStaleTime: Duration.DurationInput
|
||||||
readonly defaultRefreshOnWindowFocus: boolean
|
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", {
|
export class QueryClient extends Effect.Service<QueryClient>()("@effect-fc/QueryClient/QueryClient", {
|
||||||
@@ -36,64 +25,20 @@ implements QueryClientService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
|
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
|
||||||
readonly cacheGcTime: Duration.DurationInput,
|
readonly gcTime: Duration.DurationInput,
|
||||||
readonly defaultStaleTime: Duration.DurationInput,
|
readonly defaultStaleTime: Duration.DurationInput,
|
||||||
readonly defaultRefreshOnWindowFocus: boolean,
|
readonly defaultRefreshOnWindowFocus: boolean,
|
||||||
readonly runSemaphore: Effect.Semaphore,
|
readonly runSemaphore: Effect.Semaphore,
|
||||||
) {
|
) {
|
||||||
super()
|
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 const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId)
|
||||||
|
|
||||||
export declare namespace make {
|
export declare namespace make {
|
||||||
export interface Options {
|
export interface Options {
|
||||||
readonly cacheGcTime?: Duration.DurationInput
|
readonly gcTime?: Duration.DurationInput
|
||||||
readonly defaultStaleTime?: Duration.DurationInput
|
readonly defaultStaleTime?: Duration.DurationInput
|
||||||
readonly defaultRefreshOnWindowFocus?: boolean
|
readonly defaultRefreshOnWindowFocus?: boolean
|
||||||
}
|
}
|
||||||
@@ -102,20 +47,22 @@ export declare namespace make {
|
|||||||
export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return<QueryClientService> {
|
export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return<QueryClientService> {
|
||||||
return new QueryClientServiceImpl(
|
return new QueryClientServiceImpl(
|
||||||
yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()),
|
yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()),
|
||||||
options.cacheGcTime ?? "5 minutes",
|
options.gcTime ?? "5 minutes",
|
||||||
options.defaultStaleTime ?? "0 minutes",
|
options.defaultStaleTime ?? "0 minutes",
|
||||||
options.defaultRefreshOnWindowFocus ?? true,
|
options.defaultRefreshOnWindowFocus ?? true,
|
||||||
yield* Effect.makeSemaphore(1),
|
yield* Effect.makeSemaphore(1),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const run = (_self: QueryClientService): Effect.Effect<void> => Effect.void
|
||||||
|
|
||||||
export declare namespace service {
|
export declare namespace service {
|
||||||
export interface Options extends make.Options {}
|
export interface Options extends make.Options {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const service = (options?: service.Options): Effect.Effect<QueryClientService, never, Scope.Scope> => Effect.tap(
|
export const service = (options?: service.Options): Effect.Effect<QueryClientService, never, Scope.Scope> => Effect.tap(
|
||||||
make(options),
|
make(options),
|
||||||
client => Effect.forkScoped(client.run),
|
client => Effect.forkScoped(run(client)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -155,9 +102,7 @@ implements Pipeable.Pipeable {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly result: Result.Success<unknown>,
|
readonly result: Result.Success<unknown>,
|
||||||
readonly staleTime: Duration.DurationInput,
|
|
||||||
readonly createdAt: DateTime.DateTime,
|
readonly createdAt: DateTime.DateTime,
|
||||||
readonly lastAccessedAt: DateTime.DateTime,
|
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
@@ -166,8 +111,9 @@ implements Pipeable.Pipeable {
|
|||||||
export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId)
|
export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId)
|
||||||
|
|
||||||
export const isQueryClientCacheEntryStale = (
|
export const isQueryClientCacheEntryStale = (
|
||||||
self: QueryClientCacheEntry
|
self: QueryClientCacheEntry,
|
||||||
|
staleTime: Duration.DurationInput,
|
||||||
): Effect.Effect<boolean> => Effect.andThen(
|
): Effect.Effect<boolean> => Effect.andThen(
|
||||||
DateTime.now,
|
DateTime.now,
|
||||||
now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), self.staleTime),
|
now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), staleTime),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, type Subscribable, SynchronizedRef } from "effect"
|
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, Subscribable } from "effect"
|
||||||
import { Lens } from "effect-lens"
|
|
||||||
|
|
||||||
|
|
||||||
export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result")
|
export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result")
|
||||||
@@ -16,6 +15,10 @@ export type Final<A, E = never, P = never> = (Success<A> | Failure<E>) & ({} | F
|
|||||||
export type Flags<P = never> = WillFetch | WillRefresh | Refreshing<P>
|
export type Flags<P = never> = WillFetch | WillRefresh | Refreshing<P>
|
||||||
|
|
||||||
export declare namespace Result {
|
export declare namespace Result {
|
||||||
|
export interface Prototype extends Pipeable.Pipeable, Equal.Equal {
|
||||||
|
readonly [ResultTypeId]: ResultTypeId
|
||||||
|
}
|
||||||
|
|
||||||
export type Success<R extends Result<any, any, any>> = [R] extends [Result<infer A, infer _E, infer _P>] ? A : never
|
export type Success<R extends Result<any, any, any>> = [R] extends [Result<infer A, infer _E, infer _P>] ? A : never
|
||||||
export type Failure<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer E, infer _P>] ? E : never
|
export type Failure<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer E, infer _P>] ? E : never
|
||||||
export type Progress<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer _E, infer P>] ? P : never
|
export type Progress<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer _E, infer P>] ? P : never
|
||||||
@@ -25,21 +28,21 @@ export declare namespace Flags {
|
|||||||
export type Keys = keyof WillFetch & WillRefresh & Refreshing<any>
|
export type Keys = keyof WillFetch & WillRefresh & Refreshing<any>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Initial extends ResultPrototype {
|
export interface Initial extends Result.Prototype {
|
||||||
readonly _tag: "Initial"
|
readonly _tag: "Initial"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Running<P = never> extends ResultPrototype {
|
export interface Running<P = never> extends Result.Prototype {
|
||||||
readonly _tag: "Running"
|
readonly _tag: "Running"
|
||||||
readonly progress: P
|
readonly progress: P
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Success<A> extends ResultPrototype {
|
export interface Success<A> extends Result.Prototype {
|
||||||
readonly _tag: "Success"
|
readonly _tag: "Success"
|
||||||
readonly value: A
|
readonly value: A
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Failure<E = never> extends ResultPrototype {
|
export interface Failure<E = never> extends Result.Prototype {
|
||||||
readonly _tag: "Failure"
|
readonly _tag: "Failure"
|
||||||
readonly cause: Cause.Cause<E>
|
readonly cause: Cause.Cause<E>
|
||||||
}
|
}
|
||||||
@@ -58,11 +61,7 @@ export interface Refreshing<P = never> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface ResultPrototype extends Pipeable.Pipeable, Equal.Equal {
|
const ResultPrototype = Object.freeze({
|
||||||
readonly [ResultTypeId]: ResultTypeId
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ResultPrototype: ResultPrototype = Object.freeze({
|
|
||||||
...Pipeable.Prototype,
|
...Pipeable.Prototype,
|
||||||
[ResultTypeId]: ResultTypeId,
|
[ResultTypeId]: ResultTypeId,
|
||||||
|
|
||||||
@@ -96,7 +95,7 @@ export const ResultPrototype: ResultPrototype = Object.freeze({
|
|||||||
Hash.cached(this),
|
Hash.cached(this),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
} as const)
|
} as const satisfies Result.Prototype)
|
||||||
|
|
||||||
|
|
||||||
export const isResult = (u: unknown): u is Result<unknown, unknown, unknown> => Predicate.hasProperty(u, ResultTypeId)
|
export const isResult = (u: unknown): u is Result<unknown, unknown, unknown> => Predicate.hasProperty(u, ResultTypeId)
|
||||||
@@ -163,40 +162,52 @@ export const toExit: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface Progress<P = never> {
|
export interface State<A, E = never, P = never> {
|
||||||
readonly progress: Lens.Lens<P, PreviousResultNotRunningNorRefreshing, never, never, never>
|
readonly get: Effect.Effect<Result<A, E, P>>
|
||||||
|
readonly set: (v: Result<A, E, P>) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const State = <A, E = never, P = never>(): Context.Tag<State<A, E, P>, State<A, E, P>> => Context.GenericTag("@effect-fc/Result/State")
|
||||||
|
|
||||||
|
export interface Progress<P = never> {
|
||||||
|
readonly update: <E, R>(
|
||||||
|
f: (previous: P) => Effect.Effect<P, E, R>
|
||||||
|
) => Effect.Effect<void, PreviousResultNotRunningNorRefreshing | E, R>
|
||||||
}
|
}
|
||||||
export const Progress = <P = never>(): Context.Tag<Progress<P>, Progress<P>> => Context.GenericTag("@effect-fc/Result/Progress")
|
|
||||||
|
|
||||||
export class PreviousResultNotRunningNorRefreshing extends Data.TaggedError("@effect-fc/Result/PreviousResultNotRunningNorRefreshing")<{
|
export class PreviousResultNotRunningNorRefreshing extends Data.TaggedError("@effect-fc/Result/PreviousResultNotRunningNorRefreshing")<{
|
||||||
readonly previous: Result<unknown, unknown, unknown>
|
readonly previous: Result<unknown, unknown, unknown>
|
||||||
}> {}
|
}> {}
|
||||||
|
|
||||||
export const makeProgressLayer = <A, E, P = never>(
|
export const Progress = <P = never>(): Context.Tag<Progress<P>, Progress<P>> => Context.GenericTag("@effect-fc/Result/Progress")
|
||||||
state: Lens.Lens<Result<A, E, P>, never, never, never, never>
|
|
||||||
): Layer.Layer<Progress<P> | Progress<never>, never, never> => Layer.succeed(
|
export const makeProgressLayer = <A, E, P = never>(): Layer.Layer<
|
||||||
Progress<P>() as Context.Tag<Progress<P> | Progress<never>, Progress<P> | Progress<never>>,
|
Progress<P>,
|
||||||
{
|
never,
|
||||||
progress: state.pipe(
|
State<A, E, P>
|
||||||
Lens.mapEffect(
|
> => Layer.effect(Progress<P>(), Effect.gen(function*() {
|
||||||
a => (isRunning(a) || hasRefreshingFlag(a))
|
const state = yield* State<A, E, P>()
|
||||||
? Effect.succeed(a)
|
|
||||||
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous: a })),
|
return {
|
||||||
(_, b) => Effect.succeed(b),
|
update: <FE, FR>(f: (previous: P) => Effect.Effect<P, FE, FR>) => Effect.Do.pipe(
|
||||||
|
Effect.bind("previous", () => Effect.andThen(state.get, previous =>
|
||||||
|
(isRunning(previous) || hasRefreshingFlag(previous))
|
||||||
|
? Effect.succeed(previous)
|
||||||
|
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous })),
|
||||||
|
)),
|
||||||
|
Effect.bind("progress", ({ previous }) => f(previous.progress)),
|
||||||
|
Effect.let("next", ({ previous, progress }) => isRunning(previous)
|
||||||
|
? running(progress)
|
||||||
|
: refreshing(previous, progress) as Final<A, E, P> & Refreshing<P>
|
||||||
),
|
),
|
||||||
Lens.map(
|
Effect.andThen(({ next }) => state.set(next)),
|
||||||
a => a.progress,
|
|
||||||
(a, b) => isRunning(a)
|
|
||||||
? running(b)
|
|
||||||
: refreshing(a, b) as Final<A, E, P> & Refreshing<P>,
|
|
||||||
),
|
),
|
||||||
)
|
}
|
||||||
},
|
}))
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
export namespace unsafeForkEffect {
|
export namespace unsafeForkEffect {
|
||||||
export type OutputContext<R, P> = Exclude<R, Progress<P> | Progress<never>>
|
export type OutputContext<A, E, R, P> = Exclude<R, State<A, E, P> | Progress<P> | Progress<never>>
|
||||||
|
|
||||||
export interface Options<A, E, P> {
|
export interface Options<A, E, P> {
|
||||||
readonly initial?: Initial | Final<A, E, P>
|
readonly initial?: Initial | Final<A, E, P>
|
||||||
@@ -204,56 +215,55 @@ export namespace unsafeForkEffect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const unsafeForkEffect = Effect.fnUntraced(function* <A, E, R, P = never>(
|
export const unsafeForkEffect = <A, E, R, P = never>(
|
||||||
effect: Effect.Effect<A, E, R>,
|
effect: Effect.Effect<A, E, R>,
|
||||||
options?: unsafeForkEffect.Options<NoInfer<A>, NoInfer<E>, P>,
|
options?: unsafeForkEffect.Options<NoInfer<A>, NoInfer<E>, P>,
|
||||||
): Effect.fn.Return<
|
): Effect.Effect<
|
||||||
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
|
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
|
||||||
never,
|
never,
|
||||||
Scope.Scope | unsafeForkEffect.OutputContext<R, P>
|
Scope.Scope | unsafeForkEffect.OutputContext<A, E, R, P>
|
||||||
> {
|
> => Effect.Do.pipe(
|
||||||
const ref = (yield* SynchronizedRef.make(
|
Effect.bind("ref", () => Ref.make(options?.initial ?? initial<A, E, P>())),
|
||||||
options?.initial ?? initial<A, E, P>()
|
Effect.bind("pubsub", () => PubSub.unbounded<Result<A, E, P>>()),
|
||||||
)) as Lens.SynchronizedRefLensImpl.SynchronizedRefWithInternals<Result<A, E, P>>
|
Effect.bind("fiber", ({ ref, pubsub }) => Effect.forkScoped(State<A, E, P>().pipe(
|
||||||
const pubsub = yield* PubSub.unbounded<Result<A, E, P>>()
|
Effect.andThen(state => state.set(
|
||||||
|
|
||||||
const state = Lens.make({
|
|
||||||
get: Ref.get(ref.ref),
|
|
||||||
get changes() {
|
|
||||||
return Stream.unwrapScoped(Effect.map(
|
|
||||||
Effect.all([Ref.get(ref.ref), Stream.fromPubSub(pubsub, { scoped: true })]),
|
|
||||||
([latest, stream]) => Stream.concat(Stream.make(latest), stream),
|
|
||||||
))
|
|
||||||
},
|
|
||||||
commit: value => Effect.zipLeft(
|
|
||||||
Ref.set(ref.ref, value),
|
|
||||||
PubSub.publish(pubsub, value),
|
|
||||||
),
|
|
||||||
lock: Effect.succeed(ref.withLock),
|
|
||||||
})
|
|
||||||
|
|
||||||
const fiber = yield* Effect.gen(function*() {
|
|
||||||
yield* Lens.set(
|
|
||||||
state,
|
|
||||||
(isFinal(options?.initial) && hasWillRefreshFlag(options?.initial))
|
(isFinal(options?.initial) && hasWillRefreshFlag(options?.initial))
|
||||||
? refreshing(options.initial, options?.initialProgress) as Result<A, E, P>
|
? refreshing(options.initial, options?.initialProgress) as Result<A, E, P>
|
||||||
: running(options?.initialProgress),
|
: running(options?.initialProgress)
|
||||||
)
|
).pipe(
|
||||||
return yield* Effect.onExit(effect, exit => Effect.andThen(
|
Effect.andThen(effect),
|
||||||
Lens.set(state, fromExit(exit)),
|
Effect.onExit(exit => Effect.andThen(
|
||||||
|
state.set(fromExit(exit)),
|
||||||
Effect.forkScoped(PubSub.shutdown(pubsub)),
|
Effect.forkScoped(PubSub.shutdown(pubsub)),
|
||||||
))
|
)),
|
||||||
}).pipe(
|
)),
|
||||||
Effect.forkScoped,
|
Effect.provide(Layer.empty.pipe(
|
||||||
Effect.provide(makeProgressLayer(state)),
|
Layer.provideMerge(makeProgressLayer<A, E, P>()),
|
||||||
)
|
Layer.provideMerge(Layer.succeed(State<A, E, P>(), {
|
||||||
|
get: ref,
|
||||||
return [state, fiber] as const
|
set: v => Effect.andThen(Ref.set(ref, v), PubSub.publish(pubsub, v))
|
||||||
})
|
})),
|
||||||
|
)),
|
||||||
|
))),
|
||||||
|
Effect.map(({ ref, pubsub, fiber }) => [
|
||||||
|
Subscribable.make({
|
||||||
|
get: ref,
|
||||||
|
changes: Stream.unwrapScoped(Effect.map(
|
||||||
|
Effect.all([ref, Stream.fromPubSub(pubsub, { scoped: true })]),
|
||||||
|
([latest, stream]) => Stream.concat(Stream.make(latest), stream),
|
||||||
|
)),
|
||||||
|
}),
|
||||||
|
fiber,
|
||||||
|
]),
|
||||||
|
) as Effect.Effect<
|
||||||
|
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
|
||||||
|
never,
|
||||||
|
Scope.Scope | unsafeForkEffect.OutputContext<A, E, R, P>
|
||||||
|
>
|
||||||
|
|
||||||
export namespace forkEffect {
|
export namespace forkEffect {
|
||||||
export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R
|
export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R
|
||||||
export type OutputContext<R, P> = unsafeForkEffect.OutputContext<R, P>
|
export type OutputContext<A, E, R, P> = unsafeForkEffect.OutputContext<A, E, R, P>
|
||||||
export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {}
|
export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +274,6 @@ export const forkEffect: {
|
|||||||
): Effect.Effect<
|
): Effect.Effect<
|
||||||
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
|
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
|
||||||
never,
|
never,
|
||||||
Scope.Scope | forkEffect.OutputContext<R, P>
|
Scope.Scope | forkEffect.OutputContext<A, E, R, P>
|
||||||
>
|
>
|
||||||
} = unsafeForkEffect
|
} = unsafeForkEffect
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import type * as React from "react"
|
|||||||
|
|
||||||
|
|
||||||
export const value: {
|
export const value: {
|
||||||
<S>(self: React.SetStateAction<S>, prevState: S): S
|
|
||||||
<S>(prevState: S): (self: React.SetStateAction<S>) => S
|
<S>(prevState: S): (self: React.SetStateAction<S>) => S
|
||||||
|
<S>(self: React.SetStateAction<S>, prevState: S): S
|
||||||
} = Function.dual(2, <S>(self: React.SetStateAction<S>, prevState: S): S =>
|
} = Function.dual(2, <S>(self: React.SetStateAction<S>, prevState: S): S =>
|
||||||
typeof self === "function"
|
typeof self === "function"
|
||||||
? (self as (prevState: S) => S)(prevState)
|
? (self as (prevState: S) => S)(prevState)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as React from "react"
|
|||||||
import * as Component from "./Component.js"
|
import * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
export const use: {
|
export const useStream: {
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
import { Array, Cause, Chunk, type Context, Effect, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Schema, type Scope, SubscriptionRef } from "effect"
|
|
||||||
import * as Form from "./Form.js"
|
|
||||||
import * as Lens from "./Lens.js"
|
|
||||||
import * as Mutation from "./Mutation.js"
|
|
||||||
import * as Result from "./Result.js"
|
|
||||||
import * as Subscribable from "./Subscribable.js"
|
|
||||||
|
|
||||||
|
|
||||||
export const SubmittableFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SubmittableForm")
|
|
||||||
export type SubmittableFormTypeId = typeof SubmittableFormTypeId
|
|
||||||
|
|
||||||
export interface SubmittableForm<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 Form.Form<readonly [], A, I, never, never> {
|
|
||||||
readonly [SubmittableFormTypeId]: SubmittableFormTypeId
|
|
||||||
|
|
||||||
readonly schema: Schema.Schema<A, I, R>
|
|
||||||
readonly context: Context.Context<Scope.Scope | R>
|
|
||||||
readonly mutation: Mutation.Mutation<
|
|
||||||
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
|
|
||||||
MA, ME, MR, MP
|
|
||||||
>
|
|
||||||
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never>
|
|
||||||
|
|
||||||
readonly run: Effect.Effect<void>
|
|
||||||
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SubmittableFormImpl<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 SubmittableForm<A, I, R, MA, ME, MR, MP> {
|
|
||||||
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
|
|
||||||
readonly [SubmittableFormTypeId]: SubmittableFormTypeId = SubmittableFormTypeId
|
|
||||||
|
|
||||||
readonly path = [] as const
|
|
||||||
|
|
||||||
readonly encodedValue: Lens.Lens<I, never, never, never, never>
|
|
||||||
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
|
|
||||||
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
|
|
||||||
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly schema: Schema.Schema<A, I, R>,
|
|
||||||
readonly context: Context.Context<Scope.Scope | R>,
|
|
||||||
readonly mutation: Mutation.Mutation<
|
|
||||||
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
|
|
||||||
MA, ME, MR, MP
|
|
||||||
>,
|
|
||||||
readonly value: Lens.Lens<Option.Option<A>, never, never, never, never>,
|
|
||||||
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
|
|
||||||
readonly issues: Lens.Lens<readonly ParseResult.ArrayFormatterIssue[], never, never, never, never>,
|
|
||||||
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never, never, never>,
|
|
||||||
|
|
||||||
readonly runSemaphore: Effect.Semaphore,
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
|
|
||||||
this.encodedValue = Effect.all([
|
|
||||||
Effect.succeed(this),
|
|
||||||
Effect.succeed(Lens.asLensImpl(this.internalEncodedValue)),
|
|
||||||
]).pipe(
|
|
||||||
Effect.map(([self, parent]) => Lens.make({
|
|
||||||
get: parent.get,
|
|
||||||
get changes() { return parent.changes },
|
|
||||||
commit: a => Effect.andThen(
|
|
||||||
Effect.flatMap(
|
|
||||||
parent.resolve,
|
|
||||||
resolved => resolved.commit(Effect.succeed(a)),
|
|
||||||
),
|
|
||||||
self.synchronizeEncodedValue(a),
|
|
||||||
),
|
|
||||||
lock: parent.lock,
|
|
||||||
})),
|
|
||||||
Lens.unwrap,
|
|
||||||
)
|
|
||||||
this.isValidating = Effect.succeed(this).pipe(
|
|
||||||
Effect.map(self => Subscribable.map(self.validationFiber, Option.isSome)),
|
|
||||||
Subscribable.unwrap,
|
|
||||||
)
|
|
||||||
this.canCommit = Effect.succeed(this).pipe(
|
|
||||||
Effect.map(self => Subscribable.map(
|
|
||||||
Subscribable.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.result),
|
|
||||||
([value, issues, validationFiber, result]) => (
|
|
||||||
Option.isSome(value) &&
|
|
||||||
Array.isEmptyReadonlyArray(issues) &&
|
|
||||||
Option.isNone(validationFiber) &&
|
|
||||||
!(Result.isRunning(result) || Result.hasRefreshingFlag(result))
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
Subscribable.unwrap,
|
|
||||||
)
|
|
||||||
this.isCommitting = Effect.succeed(this).pipe(
|
|
||||||
Effect.map(self => Subscribable.map(
|
|
||||||
self.mutation.result,
|
|
||||||
result => Result.isRunning(result) || Result.hasRefreshingFlag(result),
|
|
||||||
)),
|
|
||||||
Subscribable.unwrap,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, never, never> {
|
|
||||||
return Lens.get(this.validationFiber).pipe(
|
|
||||||
Effect.andThen(Option.match({
|
|
||||||
onSome: Fiber.interrupt,
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
})),
|
|
||||||
Effect.andThen(Effect.forkScoped(
|
|
||||||
Effect.ensuring(
|
|
||||||
Schema.decode(this.schema, { errors: "all" })(encodedValue),
|
|
||||||
Lens.set(this.validationFiber, Option.none()),
|
|
||||||
)
|
|
||||||
)),
|
|
||||||
Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
|
|
||||||
Effect.flatMap(Fiber.join),
|
|
||||||
|
|
||||||
Effect.tap(() => Lens.set(this.issues, Array.empty())),
|
|
||||||
Effect.flatMap(value => Lens.set(this.value, Option.some(value))),
|
|
||||||
Effect.catchIf(
|
|
||||||
ParseResult.isParseError,
|
|
||||||
flow(
|
|
||||||
ParseResult.ArrayFormatter.formatError,
|
|
||||||
Effect.flatMap(v => Lens.set(this.issues, v)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get run(): Effect.Effect<void, never, never> {
|
|
||||||
return Lens.get(this.encodedValue).pipe(
|
|
||||||
Effect.flatMap(v => Schema.decode(this.schema)(v)),
|
|
||||||
Effect.option,
|
|
||||||
Effect.flatMap(v => Lens.set(this.value, v)),
|
|
||||||
Effect.provide(this.context),
|
|
||||||
this.runSemaphore.withPermits(1),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException, never> {
|
|
||||||
return Lens.get(this.value).pipe(
|
|
||||||
Effect.flatMap(identity),
|
|
||||||
Effect.flatMap(value => this.submitValue(value)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, never, never> {
|
|
||||||
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 => Effect.flatMap(
|
|
||||||
ParseResult.ArrayFormatter.formatError(e),
|
|
||||||
v => Lens.set(this.issues, v),
|
|
||||||
),
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: Effect.void
|
|
||||||
),
|
|
||||||
this.canCommit.get,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const isSubmittableForm = (u: unknown): u is SubmittableForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SubmittableFormTypeId)
|
|
||||||
|
|
||||||
|
|
||||||
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: SubmittableForm<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>],
|
|
||||||
MA, ME, MR, MP
|
|
||||||
> {
|
|
||||||
readonly schema: Schema.Schema<A, I, R>
|
|
||||||
readonly initialEncodedValue: NoInfer<I>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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<
|
|
||||||
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
|
|
||||||
never,
|
|
||||||
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
|
|
||||||
> {
|
|
||||||
return new SubmittableFormImpl(
|
|
||||||
options.schema,
|
|
||||||
yield* Effect.context<Scope.Scope | R>(),
|
|
||||||
yield* Mutation.make(options),
|
|
||||||
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<A>())),
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)),
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly ParseResult.ArrayFormatterIssue[]>(Array.empty())),
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>())),
|
|
||||||
|
|
||||||
yield* Effect.makeSemaphore(1),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
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<
|
|
||||||
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
|
|
||||||
never,
|
|
||||||
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
|
|
||||||
> => Effect.tap(
|
|
||||||
make(options),
|
|
||||||
form => Effect.forkScoped(form.run),
|
|
||||||
)
|
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Effect, Equivalence, Stream } from "effect"
|
import { Effect, Equivalence, Stream, Subscribable } from "effect"
|
||||||
import { Subscribable } from "effect-lens"
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
export * from "effect-lens/Subscribable"
|
|
||||||
|
|
||||||
export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
|
export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
|
||||||
...elements: T
|
...elements: T
|
||||||
): Subscribable.Subscribable<
|
): Subscribable.Subscribable<
|
||||||
@@ -19,7 +16,7 @@ export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<
|
|||||||
changes: Stream.zipLatestAll(...elements.map(v => v.changes)),
|
changes: Stream.zipLatestAll(...elements.map(v => v.changes)),
|
||||||
}) as any
|
}) as any
|
||||||
|
|
||||||
export declare namespace useAll {
|
export declare namespace useSubscribables {
|
||||||
export type Success<T extends readonly Subscribable.Subscribable<any, any, any>[]> = [T[number]] extends [never]
|
export type Success<T extends readonly Subscribable.Subscribable<any, any, any>[]> = [T[number]] extends [never]
|
||||||
? never
|
? never
|
||||||
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never }
|
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never }
|
||||||
@@ -29,11 +26,11 @@ export declare namespace useAll {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAll = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
|
export const useSubscribables = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
|
||||||
elements: T,
|
elements: T,
|
||||||
options?: useAll.Options<useAll.Success<NoInfer<T>>>,
|
options?: useSubscribables.Options<useSubscribables.Success<NoInfer<T>>>,
|
||||||
): Effect.fn.Return<
|
): Effect.fn.Return<
|
||||||
useAll.Success<T>,
|
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> ? E : never,
|
||||||
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
|
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
|
||||||
> {
|
> {
|
||||||
@@ -51,3 +48,5 @@ export const useAll = Effect.fnUntraced(function* <const T extends readonly Subs
|
|||||||
|
|
||||||
return reactStateValue as any
|
return reactStateValue as any
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export * from "effect/Subscribable"
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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"
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { Chunk, Effect, Effectable, Option, Predicate, Readable, Ref, Stream, Subscribable, SubscriptionRef, SynchronizedRef, type Types, type Unify } from "effect"
|
||||||
|
import * as PropertyPath from "./PropertyPath.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("@effect-fc/SubscriptionSubRef/SubscriptionSubRef")
|
||||||
|
export type SubscriptionSubRefTypeId = typeof SubscriptionSubRefTypeId
|
||||||
|
|
||||||
|
export interface SubscriptionSubRef<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
|
||||||
|
extends SubscriptionSubRef.Variance<A, B>, SubscriptionRef.SubscriptionRef<A> {
|
||||||
|
readonly parent: B
|
||||||
|
|
||||||
|
readonly [Unify.typeSymbol]?: unknown
|
||||||
|
readonly [Unify.unifySymbol]?: SubscriptionSubRefUnify<this>
|
||||||
|
readonly [Unify.ignoreSymbol]?: SubscriptionSubRefUnifyIgnore
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare namespace SubscriptionSubRef {
|
||||||
|
export interface Variance<in out A, in out B> {
|
||||||
|
readonly [SubscriptionSubRefTypeId]: {
|
||||||
|
readonly _A: Types.Invariant<A>
|
||||||
|
readonly _B: Types.Invariant<B>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionSubRefUnify<A extends { [Unify.typeSymbol]?: any }> extends SubscriptionRef.SubscriptionRefUnify<A> {
|
||||||
|
SubscriptionSubRef?: () => Extract<A[Unify.typeSymbol], SubscriptionSubRef<any, any>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionSubRefUnifyIgnore extends SubscriptionRef.SubscriptionRefUnifyIgnore {
|
||||||
|
SubscriptionRef?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const refVariance = { _A: (_: any) => _ }
|
||||||
|
const synchronizedRefVariance = { _A: (_: any) => _ }
|
||||||
|
const subscriptionRefVariance = { _A: (_: any) => _ }
|
||||||
|
const subscriptionSubRefVariance = { _A: (_: any) => _, _B: (_: any) => _ }
|
||||||
|
|
||||||
|
class SubscriptionSubRefImpl<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
|
||||||
|
extends Effectable.Class<A> implements SubscriptionSubRef<A, B> {
|
||||||
|
readonly [Readable.TypeId]: Readable.TypeId = Readable.TypeId
|
||||||
|
readonly [Subscribable.TypeId]: Subscribable.TypeId = Subscribable.TypeId
|
||||||
|
readonly [Ref.RefTypeId] = refVariance
|
||||||
|
readonly [SynchronizedRef.SynchronizedRefTypeId] = synchronizedRefVariance
|
||||||
|
readonly [SubscriptionRef.SubscriptionRefTypeId] = subscriptionRefVariance
|
||||||
|
readonly [SubscriptionSubRefTypeId] = subscriptionSubRefVariance
|
||||||
|
|
||||||
|
readonly get: Effect.Effect<A>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly parent: B,
|
||||||
|
readonly getter: (parentValue: Effect.Effect.Success<B>) => A,
|
||||||
|
readonly setter: (parentValue: Effect.Effect.Success<B>, value: A) => Effect.Effect.Success<B>,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
this.get = Effect.map(this.parent, this.getter)
|
||||||
|
}
|
||||||
|
|
||||||
|
commit() {
|
||||||
|
return this.get
|
||||||
|
}
|
||||||
|
|
||||||
|
get changes(): Stream.Stream<A> {
|
||||||
|
return Stream.unwrap(
|
||||||
|
Effect.map(this.get, a => Stream.concat(
|
||||||
|
Stream.make(a),
|
||||||
|
Stream.map(this.parent.changes, this.getter),
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
modify<C>(f: (a: A) => readonly [C, A]): Effect.Effect<C> {
|
||||||
|
return this.modifyEffect(a => Effect.succeed(f(a)))
|
||||||
|
}
|
||||||
|
|
||||||
|
modifyEffect<C, E, R>(f: (a: A) => Effect.Effect<readonly [C, A], E, R>): Effect.Effect<C, E, R> {
|
||||||
|
return Effect.Do.pipe(
|
||||||
|
Effect.bind("b", (): Effect.Effect<Effect.Effect.Success<B>> => this.parent),
|
||||||
|
Effect.bind("ca", ({ b }) => f(this.getter(b))),
|
||||||
|
Effect.tap(({ b, ca: [, a] }) => SubscriptionRef.set(this.parent, this.setter(b, a))),
|
||||||
|
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>>(
|
||||||
|
parent: B,
|
||||||
|
options: {
|
||||||
|
readonly get: (parentValue: Effect.Effect.Success<B>) => A
|
||||||
|
readonly set: (parentValue: Effect.Effect.Success<B>, value: A) => Effect.Effect.Success<B>
|
||||||
|
},
|
||||||
|
): SubscriptionSubRef<A, B> => new SubscriptionSubRefImpl(parent, options.get, options.set)
|
||||||
|
|
||||||
|
export const makeFromPath = <
|
||||||
|
B extends SubscriptionRef.SubscriptionRef<any>,
|
||||||
|
const P extends PropertyPath.Paths<Effect.Effect.Success<B>>,
|
||||||
|
>(
|
||||||
|
parent: B,
|
||||||
|
path: P,
|
||||||
|
): SubscriptionSubRef<PropertyPath.ValueFromPath<Effect.Effect.Success<B>, P>, B> => new SubscriptionSubRefImpl(
|
||||||
|
parent,
|
||||||
|
parentValue => Option.getOrThrow(PropertyPath.get(parentValue, path)),
|
||||||
|
(parentValue, value) => Option.getOrThrow(PropertyPath.immutableSet(parentValue, path, value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const makeFromChunkIndex: {
|
||||||
|
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
|
||||||
|
parent: B,
|
||||||
|
index: number,
|
||||||
|
): SubscriptionSubRef<
|
||||||
|
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
|
||||||
|
B
|
||||||
|
>
|
||||||
|
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
|
||||||
|
parent: B,
|
||||||
|
index: number,
|
||||||
|
): SubscriptionSubRef<
|
||||||
|
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
|
||||||
|
B
|
||||||
|
>
|
||||||
|
} = (
|
||||||
|
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>,
|
||||||
|
index: number,
|
||||||
|
) => new SubscriptionSubRefImpl(
|
||||||
|
parent,
|
||||||
|
parentValue => Chunk.unsafeGet(parentValue, index),
|
||||||
|
(parentValue, value) => Chunk.replace(parentValue, index, value),
|
||||||
|
) 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
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
import { Array, type Context, Effect, Equal, Fiber, flow, Option, ParseResult, Pipeable, Predicate, Schema, type Scope, Stream, SubscriptionRef } from "effect"
|
|
||||||
import * as Form from "./Form.js"
|
|
||||||
import * as Lens from "./Lens.js"
|
|
||||||
import * as Subscribable from "./Subscribable.js"
|
|
||||||
|
|
||||||
|
|
||||||
export const SynchronizedFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SynchronizedForm")
|
|
||||||
export type SynchronizedFormTypeId = typeof SynchronizedFormTypeId
|
|
||||||
|
|
||||||
export interface SynchronizedForm<
|
|
||||||
in out A,
|
|
||||||
in out I = A,
|
|
||||||
in out R = never,
|
|
||||||
in out TER = never,
|
|
||||||
in out TEW = never,
|
|
||||||
in out TRR = never,
|
|
||||||
in out TRW = never,
|
|
||||||
> extends Form.Form<readonly [], A, I, TER, TER | TEW> {
|
|
||||||
readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId
|
|
||||||
|
|
||||||
readonly schema: Schema.Schema<A, I, R>
|
|
||||||
readonly context: Context.Context<Scope.Scope | R | TRR | TRW>
|
|
||||||
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
|
|
||||||
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never>
|
|
||||||
|
|
||||||
readonly run: Effect.Effect<void, TER>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SynchronizedFormImpl<
|
|
||||||
in out A,
|
|
||||||
in out I = A,
|
|
||||||
in out R = never,
|
|
||||||
in out TER = never,
|
|
||||||
in out TEW = never,
|
|
||||||
in out TRR = never,
|
|
||||||
in out TRW = never,
|
|
||||||
> extends Pipeable.Class() implements SynchronizedForm<A, I, R, TER, TEW, TRR, TRW> {
|
|
||||||
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
|
|
||||||
readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId = SynchronizedFormTypeId
|
|
||||||
|
|
||||||
readonly path = [] as const
|
|
||||||
|
|
||||||
readonly value: Subscribable.Subscribable<Option.Option<A>, never, never>
|
|
||||||
readonly encodedValue: Lens.Lens<I, TER, TER | TEW, never, never>
|
|
||||||
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
|
|
||||||
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly schema: Schema.Schema<A, I, R>,
|
|
||||||
readonly context: Context.Context<Scope.Scope | R | TRR | TRW>,
|
|
||||||
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>,
|
|
||||||
|
|
||||||
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
|
|
||||||
readonly issues: Lens.Lens<readonly ParseResult.ArrayFormatterIssue[], never, never, never, never>,
|
|
||||||
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never, never, never>,
|
|
||||||
readonly isCommitting: Lens.Lens<boolean, never, never>,
|
|
||||||
|
|
||||||
readonly runSemaphore: Effect.Semaphore,
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
|
|
||||||
this.value = Effect.succeed(this).pipe(
|
|
||||||
Effect.map(self => Subscribable.make({
|
|
||||||
get: Effect.provide(Effect.option(self.target.get), self.context),
|
|
||||||
get changes() {
|
|
||||||
return Stream.provideContext(
|
|
||||||
self.target.changes.pipe(
|
|
||||||
Stream.map(Option.some),
|
|
||||||
Stream.catchAll(() => Stream.make(Option.none())),
|
|
||||||
),
|
|
||||||
self.context,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
Subscribable.unwrap,
|
|
||||||
)
|
|
||||||
this.encodedValue = Effect.all([
|
|
||||||
Effect.succeed(this),
|
|
||||||
Effect.succeed(Lens.asLensImpl(this.internalEncodedValue)),
|
|
||||||
]).pipe(
|
|
||||||
Effect.map(([self, parent]) => Lens.make<I, TER, TER | TEW, never, never>({
|
|
||||||
get: parent.get,
|
|
||||||
get changes() { return parent.changes },
|
|
||||||
commit: a => Effect.andThen(
|
|
||||||
Effect.flatMap(
|
|
||||||
parent.resolve,
|
|
||||||
resolved => resolved.commit(Effect.succeed(a)),
|
|
||||||
),
|
|
||||||
self.synchronizeEncodedValue(a),
|
|
||||||
),
|
|
||||||
lock: parent.lock,
|
|
||||||
})),
|
|
||||||
Lens.unwrap,
|
|
||||||
)
|
|
||||||
this.isValidating = Effect.succeed(this).pipe(
|
|
||||||
Effect.map(self => Subscribable.map(self.validationFiber, Option.isSome)),
|
|
||||||
Subscribable.unwrap,
|
|
||||||
)
|
|
||||||
this.canCommit = Effect.succeed(this).pipe(
|
|
||||||
Effect.map(self => Subscribable.map(
|
|
||||||
Subscribable.zipLatestAll(self.issues, self.validationFiber, self.isCommitting),
|
|
||||||
([issues, validationFiber, isCommitting]) => (
|
|
||||||
Array.isEmptyReadonlyArray(issues) &&
|
|
||||||
Option.isNone(validationFiber) &&
|
|
||||||
!isCommitting
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
Subscribable.unwrap,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, TER | TEW, never> {
|
|
||||||
return Lens.get(this.validationFiber).pipe(
|
|
||||||
Effect.andThen(Option.match({
|
|
||||||
onSome: Fiber.interrupt,
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
})),
|
|
||||||
Effect.andThen(Effect.forkScoped(
|
|
||||||
Effect.ensuring(
|
|
||||||
Schema.decode(this.schema, { errors: "all" })(encodedValue),
|
|
||||||
Lens.set(this.validationFiber, Option.none()),
|
|
||||||
)
|
|
||||||
)),
|
|
||||||
Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
|
|
||||||
Effect.flatMap(Fiber.join),
|
|
||||||
|
|
||||||
Effect.flatMap(value => Effect.ensuring(
|
|
||||||
Lens.set(this.isCommitting, true).pipe(
|
|
||||||
Effect.andThen(Lens.set(this.issues, Array.empty())),
|
|
||||||
Effect.andThen(Lens.set(this.target, value)),
|
|
||||||
),
|
|
||||||
Lens.set(this.isCommitting, false),
|
|
||||||
)),
|
|
||||||
Effect.catchIf(
|
|
||||||
ParseResult.isParseError,
|
|
||||||
flow(
|
|
||||||
ParseResult.ArrayFormatter.formatError,
|
|
||||||
Effect.flatMap(v => Lens.set(this.issues, v)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get run(): Effect.Effect<void, TER, never> {
|
|
||||||
return this.runSemaphore.withPermits(1)(Effect.provide(
|
|
||||||
Stream.runForEach(
|
|
||||||
Stream.drop(this.target.changes, 1),
|
|
||||||
targetValue => Schema.encode(this.schema, { errors: "all" })(targetValue).pipe(
|
|
||||||
Effect.flatMap(encodedValue => Effect.whenEffect(
|
|
||||||
Effect.andThen(
|
|
||||||
Lens.set(this.issues, Array.empty()),
|
|
||||||
Lens.set(this.internalEncodedValue, encodedValue),
|
|
||||||
),
|
|
||||||
Effect.map(
|
|
||||||
Lens.get(this.internalEncodedValue),
|
|
||||||
currentEncodedValue => !Equal.equals(encodedValue, currentEncodedValue),
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
Effect.ignore,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
this.context,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const isSynchronizedForm = (u: unknown): u is SynchronizedForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SynchronizedFormTypeId)
|
|
||||||
|
|
||||||
|
|
||||||
export declare namespace make {
|
|
||||||
export interface Options<in out A, in out I = A, in out R = never, in out TER = never, in out TEW = never, in out TRR = never, in out TRW = never> {
|
|
||||||
readonly schema: Schema.Schema<A, I, R>
|
|
||||||
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
|
|
||||||
readonly initialEncodedValue?: NoInfer<I>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const make = Effect.fnUntraced(function* <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
|
|
||||||
options: make.Options<A, I, R, TER, TEW, TRR, TRW>
|
|
||||||
): Effect.fn.Return<
|
|
||||||
SynchronizedForm<A, I, R, TER, TEW, TRR, TRW>,
|
|
||||||
ParseResult.ParseError | TER,
|
|
||||||
Scope.Scope | R | TRR | TRW
|
|
||||||
> {
|
|
||||||
const initialEncodedValue = options.initialEncodedValue !== undefined
|
|
||||||
? options.initialEncodedValue
|
|
||||||
: yield* Effect.flatMap(
|
|
||||||
Lens.get(options.target),
|
|
||||||
Schema.encode(options.schema, { errors: "all" }),
|
|
||||||
)
|
|
||||||
|
|
||||||
return new SynchronizedFormImpl(
|
|
||||||
options.schema,
|
|
||||||
yield* Effect.context<Scope.Scope | R | TRR | TRW>(),
|
|
||||||
options.target,
|
|
||||||
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(initialEncodedValue)),
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly ParseResult.ArrayFormatterIssue[]>(Array.empty())),
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>())),
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(false)),
|
|
||||||
|
|
||||||
yield* Effect.makeSemaphore(1),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export declare namespace service {
|
|
||||||
export interface Options<in out A, in out I = A, in out R = never, in out TER = never, in out TEW = never, in out TRR = never, in out TRW = never>
|
|
||||||
extends make.Options<A, I, R, TER, TEW, TRR, TRW> {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const service = <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
|
|
||||||
options: service.Options<A, I, R, TER, TEW, TRR, TRW>
|
|
||||||
): Effect.Effect<
|
|
||||||
SynchronizedForm<A, I, R, TER, TEW, TRR, TRW>,
|
|
||||||
ParseResult.ParseError | TER,
|
|
||||||
Scope.Scope | R | TRR | TRW
|
|
||||||
> => Effect.tap(
|
|
||||||
make(options),
|
|
||||||
form => Effect.forkScoped(form.run),
|
|
||||||
)
|
|
||||||
@@ -2,9 +2,9 @@ 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 ErrorObserver from "./ErrorObserver.js"
|
||||||
export * as Form from "./Form.js"
|
export * as Form from "./Form.js"
|
||||||
export * as Lens from "./Lens.js"
|
|
||||||
export * as Memoized from "./Memoized.js"
|
export * as Memoized from "./Memoized.js"
|
||||||
export * as Mutation from "./Mutation.js"
|
export * as Mutation from "./Mutation.js"
|
||||||
|
export * as PropertyPath from "./PropertyPath.js"
|
||||||
export * as PubSub from "./PubSub.js"
|
export * as PubSub from "./PubSub.js"
|
||||||
export * as Query from "./Query.js"
|
export * as Query from "./Query.js"
|
||||||
export * as QueryClient from "./QueryClient.js"
|
export * as QueryClient from "./QueryClient.js"
|
||||||
@@ -12,6 +12,6 @@ export * as ReactRuntime from "./ReactRuntime.js"
|
|||||||
export * as Result from "./Result.js"
|
export * as Result from "./Result.js"
|
||||||
export * as SetStateAction from "./SetStateAction.js"
|
export * as SetStateAction from "./SetStateAction.js"
|
||||||
export * as Stream from "./Stream.js"
|
export * as Stream from "./Stream.js"
|
||||||
export * as SubmittableForm from "./SubmittableForm.js"
|
|
||||||
export * as Subscribable from "./Subscribable.js"
|
export * as Subscribable from "./Subscribable.js"
|
||||||
export * as SynchronizedForm from "./SynchronizedForm.js"
|
export * as SubscriptionRef from "./SubscriptionRef.js"
|
||||||
|
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react"
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
|
||||||
import * as React from "react"
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
||||||
import * as Component from "../src/Component.js"
|
|
||||||
import * as ReactRuntime from "../src/ReactRuntime.js"
|
|
||||||
|
|
||||||
|
|
||||||
class ValueService extends Context.Tag("ValueService")<ValueService, { readonly value: string }>() {}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Component", () => {
|
|
||||||
it("runs useOnMount only once across rerenders", async () => {
|
|
||||||
const onMount = vi.fn(() => Effect.succeed("mounted"))
|
|
||||||
const runtime = ReactRuntime.make(Layer.empty)
|
|
||||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
|
||||||
|
|
||||||
const Probe = Component.makeUntraced("UseOnMountProbe")(function*() {
|
|
||||||
const value = yield* Component.useOnMount(onMount)
|
|
||||||
return <div>{value}</div>
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
const view = render(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("mounted")
|
|
||||||
expect(onMount).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
expect(await screen.findByText("mounted")).toBeTruthy()
|
|
||||||
expect(onMount).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
view.unmount()
|
|
||||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recomputes useOnChange only when dependencies change", async () => {
|
|
||||||
const onChange = vi.fn((value: number) => Effect.succeed(`value:${value}`))
|
|
||||||
const runtime = ReactRuntime.make(Layer.empty)
|
|
||||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
|
||||||
|
|
||||||
const Probe = Component.makeUntraced("UseOnChangeProbe")(function*(props: { readonly value: number }) {
|
|
||||||
const result = yield* Component.useOnChange(() => onChange(props.value), [props.value], {
|
|
||||||
finalizerExecutionDebounce: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
return <div>{result}</div>
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
const view = render(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe value={1} />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("value:1")
|
|
||||||
expect(onChange).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe value={1} />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(await screen.findByText("value:1")).toBeTruthy()
|
|
||||||
expect(onChange).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe value={2} />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("value:2")
|
|
||||||
expect(onChange).toHaveBeenCalledTimes(2)
|
|
||||||
|
|
||||||
view.unmount()
|
|
||||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("closes the previous scope on dependency changes and unmount", async () => {
|
|
||||||
const cleanup = vi.fn()
|
|
||||||
const runtime = ReactRuntime.make(Layer.empty)
|
|
||||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
|
||||||
|
|
||||||
const Probe = Component.makeUntraced("ScopeCleanupProbe")(function*(props: { readonly value: string }) {
|
|
||||||
const result = yield* Component.useOnChange(
|
|
||||||
() => Effect.gen(function*() {
|
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
|
|
||||||
return props.value
|
|
||||||
}),
|
|
||||||
[props.value],
|
|
||||||
{ finalizerExecutionDebounce: 0 },
|
|
||||||
)
|
|
||||||
|
|
||||||
return <div>{result}</div>
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
const view = render(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe value="first" />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("first")
|
|
||||||
expect(cleanup).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe value="second" />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("second")
|
|
||||||
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"))
|
|
||||||
expect(cleanup).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
view.unmount()
|
|
||||||
|
|
||||||
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"))
|
|
||||||
expect(cleanup).toHaveBeenCalledTimes(2)
|
|
||||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("runs useReactEffect setup and cleanup when dependencies change", async () => {
|
|
||||||
const lifecycle = vi.fn<(message: string) => void>()
|
|
||||||
const runtime = ReactRuntime.make(Layer.empty)
|
|
||||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
|
||||||
|
|
||||||
const Probe = Component.makeUntraced("UseReactEffectProbe")(function*(props: { readonly value: string }) {
|
|
||||||
yield* Component.useReactEffect(() =>
|
|
||||||
Effect.gen(function*() {
|
|
||||||
yield* Effect.sync(() => lifecycle(`mount:${props.value}`))
|
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(() => lifecycle(`cleanup:${props.value}`)))
|
|
||||||
}),
|
|
||||||
[props.value])
|
|
||||||
|
|
||||||
return <div>{props.value}</div>
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
const view = render(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe value="first" />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("first")
|
|
||||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:first"))
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe value="second" />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("second")
|
|
||||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:first"))
|
|
||||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:second"))
|
|
||||||
|
|
||||||
view.unmount()
|
|
||||||
|
|
||||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:second"))
|
|
||||||
expect(lifecycle.mock.calls.map(([message]) => message)).toEqual([
|
|
||||||
"mount:first",
|
|
||||||
"cleanup:first",
|
|
||||||
"mount:second",
|
|
||||||
"cleanup:second",
|
|
||||||
])
|
|
||||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps useCallbackSync stable until dependencies change", async () => {
|
|
||||||
const runtime = ReactRuntime.make(Layer.empty)
|
|
||||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
|
||||||
const seenCallbacks: Array<(value: number) => string> = []
|
|
||||||
|
|
||||||
const Probe = Component.makeUntraced("UseCallbackSyncProbe")(function*(props: { readonly prefix: string }) {
|
|
||||||
const callback = yield* Component.useCallbackSync(
|
|
||||||
(value: number) => Effect.succeed(`${props.prefix}:${value}`),
|
|
||||||
[props.prefix],
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
|
||||||
seenCallbacks.push(callback)
|
|
||||||
}))
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
seenCallbacks.push(callback)
|
|
||||||
}, [callback])
|
|
||||||
|
|
||||||
return <div>{callback(1)}</div>
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
const view = render(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe prefix="a" />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("a:1")
|
|
||||||
expect(seenCallbacks).toHaveLength(2)
|
|
||||||
expect(seenCallbacks[0]).toBe(seenCallbacks[1])
|
|
||||||
expect(seenCallbacks[0]?.(2)).toBe("a:2")
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe prefix="a" />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("a:1")
|
|
||||||
expect(seenCallbacks).toHaveLength(2)
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe prefix="b" />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("b:1")
|
|
||||||
await waitFor(() => expect(seenCallbacks).toHaveLength(3))
|
|
||||||
expect(seenCallbacks[2]).not.toBe(seenCallbacks[1])
|
|
||||||
expect(seenCallbacks[2]?.(2)).toBe("b:2")
|
|
||||||
|
|
||||||
view.unmount()
|
|
||||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("delays cleanup according to finalizerExecutionDebounce", async () => {
|
|
||||||
const cleanup = vi.fn()
|
|
||||||
const runtime = ReactRuntime.make(Layer.empty)
|
|
||||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
|
||||||
|
|
||||||
const Probe = Component.makeUntraced("DebouncedCleanupProbe")(function*(props: { readonly value: string }) {
|
|
||||||
const result = yield* Component.useOnChange(
|
|
||||||
() => Effect.gen(function*() {
|
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
|
|
||||||
return props.value
|
|
||||||
}),
|
|
||||||
[props.value],
|
|
||||||
{ finalizerExecutionDebounce: "20 millis" },
|
|
||||||
)
|
|
||||||
|
|
||||||
return <div>{result}</div>
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
const view = render(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe value="first" />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("first")
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe value="second" />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("second")
|
|
||||||
expect(cleanup).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 5))
|
|
||||||
expect(cleanup).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"), { timeout: 100 })
|
|
||||||
|
|
||||||
view.unmount()
|
|
||||||
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"), { timeout: 100 })
|
|
||||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not remount a component when only nonReactiveTags change", async () => {
|
|
||||||
const mounts = vi.fn()
|
|
||||||
const unmounts = vi.fn()
|
|
||||||
const runtime = ReactRuntime.make(Layer.empty)
|
|
||||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
|
||||||
|
|
||||||
const SubComponent = Component.makeUntraced("NonReactiveSubComponent")(function*() {
|
|
||||||
const service = yield* ValueService
|
|
||||||
|
|
||||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
|
||||||
yield* Effect.sync(() => mounts())
|
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
|
|
||||||
}))
|
|
||||||
|
|
||||||
return <div>{service.value}</div>
|
|
||||||
}).pipe(
|
|
||||||
Component.withOptions({ nonReactiveTags: [ValueService] })
|
|
||||||
)
|
|
||||||
|
|
||||||
const Parent = Component.makeUntraced("NonReactiveParent")(function*(props: { readonly value: string }) {
|
|
||||||
const serviceLayer = React.useMemo(
|
|
||||||
() => Layer.succeed(ValueService, { value: props.value }),
|
|
||||||
[props.value],
|
|
||||||
)
|
|
||||||
const context = yield* Component.useContextFromLayer(serviceLayer, {
|
|
||||||
finalizerExecutionDebounce: 0,
|
|
||||||
})
|
|
||||||
const Child = yield* Effect.provide(SubComponent.use, context)
|
|
||||||
|
|
||||||
return <Child />
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
const view = render(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Parent value="first" />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("first")
|
|
||||||
expect(mounts).toHaveBeenCalledTimes(1)
|
|
||||||
expect(unmounts).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Parent value="second" />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("second")
|
|
||||||
expect(mounts).toHaveBeenCalledTimes(1)
|
|
||||||
expect(unmounts).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
view.unmount()
|
|
||||||
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
|
|
||||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
|
||||||
import { Effect, Layer, SubscriptionRef } from "effect"
|
|
||||||
import * as React from "react"
|
|
||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import * as Component from "../src/Component.js"
|
|
||||||
import * as Lens from "../src/Lens.js"
|
|
||||||
import * as ReactRuntime from "../src/ReactRuntime.js"
|
|
||||||
|
|
||||||
|
|
||||||
const makeRuntime = async () => {
|
|
||||||
const runtime = ReactRuntime.make(Layer.empty)
|
|
||||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
|
||||||
|
|
||||||
return {
|
|
||||||
runtime,
|
|
||||||
effectRuntime,
|
|
||||||
dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Lens", () => {
|
|
||||||
it("useState stays in sync with lens updates in both directions", async () => {
|
|
||||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
|
||||||
const ref = await Effect.runPromise(SubscriptionRef.make(0))
|
|
||||||
const lens = Lens.fromSubscriptionRef(ref)
|
|
||||||
|
|
||||||
const Probe = Component.makeUntraced("LensUseStateProbe")(function*() {
|
|
||||||
const [value, setValue] = yield* Lens.useState(lens)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>{value}</div>
|
|
||||||
<button onClick={() => setValue(previous => previous + 1)}>increment</button>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
const view = render(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("0")
|
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(lens, 5))
|
|
||||||
await screen.findByText("5")
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "increment" }))
|
|
||||||
await screen.findByText("6")
|
|
||||||
expect(await Effect.runPromise(Lens.get(lens))).toBe(6)
|
|
||||||
|
|
||||||
view.unmount()
|
|
||||||
await dispose()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("useState respects the provided equivalence when subscribing to lens changes", async () => {
|
|
||||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
|
||||||
const ref = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
|
|
||||||
const lens = Lens.fromSubscriptionRef(ref)
|
|
||||||
|
|
||||||
const Probe = Component.makeUntraced("LensUseStateEquivalenceProbe")(function*() {
|
|
||||||
const [value] = yield* Lens.useState(lens, {
|
|
||||||
equivalence: (self, that) => self.id === that.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return <div>{value.label}</div>
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
const view = render(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("first")
|
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(lens, { id: 1, label: "ignored" }))
|
|
||||||
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
|
||||||
expect(screen.queryByText("ignored")).toBeNull()
|
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(lens, { id: 2, label: "updated" }))
|
|
||||||
await screen.findByText("updated")
|
|
||||||
|
|
||||||
view.unmount()
|
|
||||||
await dispose()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("useFromReactState writes React state changes into the returned lens", async () => {
|
|
||||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
|
||||||
let lens: Lens.Lens<string, never, never, never, never> | undefined
|
|
||||||
|
|
||||||
const Probe = Component.makeUntraced("LensUseFromReactStateProbe")(function*() {
|
|
||||||
const [value, setValue] = React.useState("hello")
|
|
||||||
const reactLens = yield* Lens.useFromReactState([value, setValue])
|
|
||||||
|
|
||||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
|
||||||
lens = reactLens
|
|
||||||
}))
|
|
||||||
|
|
||||||
return <button onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
const view = render(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("hello")
|
|
||||||
await waitFor(() => expect(lens).toBeDefined())
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "hello" }))
|
|
||||||
await screen.findByText("hello!")
|
|
||||||
await waitFor(async () => expect(await Effect.runPromise(Lens.get(lens!))).toBe("hello!"))
|
|
||||||
|
|
||||||
view.unmount()
|
|
||||||
await dispose()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("useFromReactState respects equivalence when lens updates flow back into React state", async () => {
|
|
||||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
|
||||||
let lens: Lens.Lens<{ readonly id: number; readonly label: string }, never, never, never, never> | undefined
|
|
||||||
|
|
||||||
const Probe = Component.makeUntraced("LensUseFromReactStateEquivalenceProbe")(function*() {
|
|
||||||
const [value, setValue] = React.useState({ id: 1, label: "first" })
|
|
||||||
const reactLens = yield* Lens.useFromReactState([value, setValue], {
|
|
||||||
equivalence: (self, that) => self.id === that.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
|
||||||
lens = reactLens
|
|
||||||
}))
|
|
||||||
|
|
||||||
return <div>{value.label}</div>
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
const view = render(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("first")
|
|
||||||
await waitFor(() => expect(lens).toBeDefined())
|
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(lens!, { id: 1, label: "ignored" }))
|
|
||||||
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
|
||||||
expect(screen.queryByText("ignored")).toBeNull()
|
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(lens!, { id: 2, label: "updated" }))
|
|
||||||
await screen.findByText("updated")
|
|
||||||
|
|
||||||
view.unmount()
|
|
||||||
await dispose()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import { Effect, Option, type Scope, Stream } from "effect"
|
|
||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import * as Query from "../src/Query.js"
|
|
||||||
import * as QueryClient from "../src/QueryClient.js"
|
|
||||||
import * as Result from "../src/Result.js"
|
|
||||||
|
|
||||||
|
|
||||||
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
|
|
||||||
Effect.runPromise(Effect.scoped(effect.pipe(
|
|
||||||
Effect.provide(QueryClient.QueryClient.Default),
|
|
||||||
)))
|
|
||||||
|
|
||||||
const expectSuccessValue = <A, E, P>(
|
|
||||||
result: Result.Result<A, E, P>,
|
|
||||||
): A => {
|
|
||||||
expect(Result.isSuccess(result)).toBe(true)
|
|
||||||
|
|
||||||
if (!Result.isSuccess(result))
|
|
||||||
throw new Error(`Expected Success result, received ${result._tag}`)
|
|
||||||
|
|
||||||
return result.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectSomeValue = <A>(option: Option.Option<A>): A => {
|
|
||||||
expect(Option.isSome(option)).toBe(true)
|
|
||||||
|
|
||||||
if (!Option.isSome(option))
|
|
||||||
throw new Error("Expected Some option, received None")
|
|
||||||
|
|
||||||
return option.value
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Query", () => {
|
|
||||||
it("fetch caches successful results until they are invalidated or stale", async () => {
|
|
||||||
let calls = 0
|
|
||||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
|
||||||
|
|
||||||
const result = await runQueryTest(Effect.gen(function*() {
|
|
||||||
const query = yield* Query.make({
|
|
||||||
key,
|
|
||||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
|
||||||
calls += 1
|
|
||||||
return `value:${id}:${calls}`
|
|
||||||
}),
|
|
||||||
staleTime: "1 minute",
|
|
||||||
})
|
|
||||||
|
|
||||||
const first = yield* query.fetch([1])
|
|
||||||
const second = yield* query.fetch([1])
|
|
||||||
|
|
||||||
return [first, second] as const
|
|
||||||
}))
|
|
||||||
|
|
||||||
expect(calls).toBe(1)
|
|
||||||
expect(result[0]._tag).toBe("Success")
|
|
||||||
expect(result[1]._tag).toBe("Success")
|
|
||||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
|
||||||
expect(expectSuccessValue(result[1])).toBe("value:1:1")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("refresh reruns the latest query key", async () => {
|
|
||||||
let calls = 0
|
|
||||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
|
||||||
|
|
||||||
const result = await runQueryTest(Effect.gen(function*() {
|
|
||||||
const query = yield* Query.make({
|
|
||||||
key,
|
|
||||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
|
||||||
calls += 1
|
|
||||||
return `value:${id}:${calls}`
|
|
||||||
}),
|
|
||||||
staleTime: "0 millis",
|
|
||||||
})
|
|
||||||
|
|
||||||
const first = yield* query.fetch([1])
|
|
||||||
yield* Effect.sleep("1 millis")
|
|
||||||
const refreshed = yield* query.refresh
|
|
||||||
|
|
||||||
return [first, refreshed] as const
|
|
||||||
}))
|
|
||||||
|
|
||||||
expect(calls).toBe(2)
|
|
||||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
|
||||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
|
|
||||||
let calls = 0
|
|
||||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
|
||||||
|
|
||||||
const result = await runQueryTest(Effect.gen(function*() {
|
|
||||||
const query = yield* Query.make({
|
|
||||||
key,
|
|
||||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
|
||||||
calls += 1
|
|
||||||
return `value:${id}:${calls}`
|
|
||||||
}),
|
|
||||||
staleTime: "1 minute",
|
|
||||||
})
|
|
||||||
|
|
||||||
const first = yield* query.fetch([1])
|
|
||||||
yield* query.invalidateCacheEntry([1])
|
|
||||||
const second = yield* query.fetch([1])
|
|
||||||
|
|
||||||
return [first, second] as const
|
|
||||||
}))
|
|
||||||
|
|
||||||
expect(calls).toBe(2)
|
|
||||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
|
||||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("invalidateCache clears cached entries for the query function", async () => {
|
|
||||||
let calls = 0
|
|
||||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
|
||||||
|
|
||||||
const result = await runQueryTest(Effect.gen(function*() {
|
|
||||||
const query = yield* Query.make({
|
|
||||||
key,
|
|
||||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
|
||||||
calls += 1
|
|
||||||
return `value:${id}:${calls}`
|
|
||||||
}),
|
|
||||||
staleTime: "1 minute",
|
|
||||||
})
|
|
||||||
|
|
||||||
const first = yield* query.fetch([1])
|
|
||||||
yield* query.invalidateCache
|
|
||||||
const second = yield* query.fetch([1])
|
|
||||||
|
|
||||||
return [first, second] as const
|
|
||||||
}))
|
|
||||||
|
|
||||||
expect(calls).toBe(2)
|
|
||||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
|
||||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("service starts the key stream automatically and updates latest state", async () => {
|
|
||||||
let calls = 0
|
|
||||||
const key = Stream.make([1] as const) as Stream.Stream<readonly [number]>
|
|
||||||
|
|
||||||
const effect = Effect.gen(function*() {
|
|
||||||
const query = yield* Query.service({
|
|
||||||
key,
|
|
||||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
|
||||||
calls += 1
|
|
||||||
return `value:${id}:${calls}`
|
|
||||||
}),
|
|
||||||
staleTime: "1 minute",
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* Effect.sleep("10 millis")
|
|
||||||
|
|
||||||
return {
|
|
||||||
final: yield* query.result.get,
|
|
||||||
latestKey: yield* query.latestKey.get,
|
|
||||||
latestFinalResult: yield* query.latestFinalResult.get,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await runQueryTest(effect)
|
|
||||||
|
|
||||||
expect(calls).toBe(1)
|
|
||||||
expect(expectSuccessValue(result.final)).toBe("value:1:1")
|
|
||||||
expect(expectSomeValue(result.latestKey)).toEqual([1])
|
|
||||||
expect(expectSuccessValue(expectSomeValue(result.latestFinalResult))).toBe("value:1:1")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react"
|
|
||||||
import { Effect, Fiber, Layer, Stream, SubscriptionRef } from "effect"
|
|
||||||
import { Lens } from "effect-lens"
|
|
||||||
import { describe, expect, it } from "vitest"
|
|
||||||
import * as Component from "../src/Component.js"
|
|
||||||
import * as ReactRuntime from "../src/ReactRuntime.js"
|
|
||||||
import * as Subscribable from "../src/Subscribable.js"
|
|
||||||
|
|
||||||
|
|
||||||
const makeRuntime = async () => {
|
|
||||||
const runtime = ReactRuntime.make(Layer.empty)
|
|
||||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
|
||||||
|
|
||||||
return {
|
|
||||||
runtime,
|
|
||||||
effectRuntime,
|
|
||||||
dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Subscribable", () => {
|
|
||||||
it("zipLatestAll reads current values from all inputs", async () => {
|
|
||||||
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
|
|
||||||
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
|
|
||||||
const left = Lens.fromSubscriptionRef(leftRef)
|
|
||||||
const right = Lens.fromSubscriptionRef(rightRef)
|
|
||||||
|
|
||||||
const zipped = Subscribable.zipLatestAll(left, right)
|
|
||||||
|
|
||||||
expect(await Effect.runPromise(zipped.get)).toEqual([1, "a"])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("zipLatestAll emits updates when any input changes", async () => {
|
|
||||||
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
|
|
||||||
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
|
|
||||||
const left = Lens.fromSubscriptionRef(leftRef)
|
|
||||||
const right = Lens.fromSubscriptionRef(rightRef)
|
|
||||||
|
|
||||||
const zipped = Subscribable.zipLatestAll(left, right)
|
|
||||||
const values: Array<readonly [number, string]> = []
|
|
||||||
|
|
||||||
const collector = Effect.runFork(Effect.scoped(zipped.changes.pipe(
|
|
||||||
Stream.runForEach(value => Effect.sync(() => {
|
|
||||||
values.push(value as readonly [number, string])
|
|
||||||
})),
|
|
||||||
)))
|
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(left, 2))
|
|
||||||
await waitFor(() => expect(values).toContainEqual([2, "a"]))
|
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(right, "b"))
|
|
||||||
await waitFor(() => expect(values).toContainEqual([2, "b"]))
|
|
||||||
|
|
||||||
Fiber.interruptFork(collector)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("useAll returns the latest values and rerenders when any input changes", async () => {
|
|
||||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
|
||||||
const countRef = await Effect.runPromise(SubscriptionRef.make(1))
|
|
||||||
const labelRef = await Effect.runPromise(SubscriptionRef.make("a"))
|
|
||||||
const count = Lens.fromSubscriptionRef(countRef)
|
|
||||||
const label = Lens.fromSubscriptionRef(labelRef)
|
|
||||||
|
|
||||||
const Probe = Component.makeUntraced("SubscribableUseAllProbe")(function*() {
|
|
||||||
const [currentCount, currentLabel] = yield* Subscribable.useAll([count, label])
|
|
||||||
|
|
||||||
return <div>{`${currentCount}:${currentLabel}`}</div>
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
const view = render(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("1:a")
|
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(count, 2))
|
|
||||||
await screen.findByText("2:a")
|
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(label, "b"))
|
|
||||||
await screen.findByText("2:b")
|
|
||||||
|
|
||||||
view.unmount()
|
|
||||||
await dispose()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("useAll respects the provided equivalence when processing updates", async () => {
|
|
||||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
|
||||||
const itemRef = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
|
|
||||||
const flagRef = await Effect.runPromise(SubscriptionRef.make(true))
|
|
||||||
const item = Lens.fromSubscriptionRef(itemRef)
|
|
||||||
const flag = Lens.fromSubscriptionRef(flagRef)
|
|
||||||
|
|
||||||
const Probe = Component.makeUntraced("SubscribableUseAllEquivalenceProbe")(function*() {
|
|
||||||
const [currentItem, currentFlag] = yield* Subscribable.useAll([item, flag], {
|
|
||||||
equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) =>
|
|
||||||
selfItem.id === thatItem.id && selfFlag === thatFlag,
|
|
||||||
})
|
|
||||||
|
|
||||||
return <div>{`${currentItem.label}:${currentFlag ? "on" : "off"}`}</div>
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
const view = render(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
await screen.findByText("first:on")
|
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(item, { id: 1, label: "ignored" }))
|
|
||||||
await waitFor(() => expect(screen.getByText("first:on")).toBeTruthy())
|
|
||||||
expect(screen.queryByText("ignored:on")).toBeNull()
|
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(flag, false))
|
|
||||||
await screen.findByText("ignored:off")
|
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(item, { id: 2, label: "updated" }))
|
|
||||||
await screen.findByText("updated:off")
|
|
||||||
|
|
||||||
view.unmount()
|
|
||||||
await dispose()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -25,7 +25,6 @@
|
|||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
|
||||||
// Build
|
// Build
|
||||||
"rootDir": "./src",
|
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
@@ -35,6 +34,5 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
"include": ["./src"],
|
"include": ["./src"]
|
||||||
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { defineConfig } from "vitest/config"
|
|
||||||
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: "jsdom",
|
|
||||||
include: ["test/**/*.test.ts?(x)"],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -13,30 +13,30 @@
|
|||||||
"clean:modules": "rm -rf node_modules"
|
"clean:modules": "rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/react-router": "^1.170.10",
|
"@tanstack/react-router": "^1.139.12",
|
||||||
"@tanstack/react-router-devtools": "^1.167.0",
|
"@tanstack/react-router-devtools": "^1.139.12",
|
||||||
"@tanstack/router-plugin": "^1.168.13",
|
"@tanstack/router-plugin": "^1.139.12",
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.0.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.0",
|
||||||
"type-fest": "^5.7.0",
|
"type-fest": "^5.2.0",
|
||||||
"vite": "^8.0.16"
|
"vite": "^7.2.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform": "^0.96.1",
|
"@effect/platform": "^0.94.0",
|
||||||
"@effect/platform-browser": "^0.76.0",
|
"@effect/platform-browser": "^0.74.0",
|
||||||
"@radix-ui/themes": "^3.3.0",
|
"@radix-ui/themes": "^3.2.1",
|
||||||
"@typed/id": "^0.17.2",
|
"@typed/id": "^0.17.2",
|
||||||
"effect": "^3.21.2",
|
"effect": "^3.19.8",
|
||||||
"effect-fc": "workspace:*",
|
"effect-fc": "workspace:*",
|
||||||
"react-icons": "^5.6.0"
|
"react-icons": "^5.5.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.7",
|
||||||
"effect": "^3.21.2",
|
"effect": "^3.19.8",
|
||||||
"react": "^19.2.6"
|
"react": "^19.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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 TextFieldFormInput extends Component.makeUntraced("TextFieldFormInput")(function*(props: TextFieldFormInputProps) {
|
||||||
|
const input: (
|
||||||
|
| { readonly optional: true } & Form.useOptionalInput.Result<string>
|
||||||
|
| { readonly optional: false } & Form.useInput.Result<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,56 +0,0 @@
|
|||||||
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
|
|
||||||
import { Array, Option, Struct } from "effect"
|
|
||||||
import { Component, Form, Subscribable } from "effect-fc"
|
|
||||||
import type * as React from "react"
|
|
||||||
|
|
||||||
|
|
||||||
export declare namespace TextFieldFormInputView {
|
|
||||||
export interface Props<out P extends readonly PropertyKey[], A, ER, EW>
|
|
||||||
extends Omit<TextField.RootProps, "form">, Form.useInput.Options {
|
|
||||||
readonly form: Form.Form<P, A, string, ER, EW>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Signature = <P extends readonly PropertyKey[], A, ER, EW>(props: Props<P, A, ER, EW>) => React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(function*(
|
|
||||||
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
|
|
||||||
) {
|
|
||||||
const input = yield* Form.useInput(props.form, props)
|
|
||||||
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
|
|
||||||
props.form.issues,
|
|
||||||
props.form.isValidating,
|
|
||||||
props.form.isCommitting,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex direction="column" gap="1">
|
|
||||||
<TextField.Root
|
|
||||||
value={input.value}
|
|
||||||
onChange={e => input.setValue(e.target.value)}
|
|
||||||
disabled={isCommitting}
|
|
||||||
{...Struct.omit(props, "form")}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
)
|
|
||||||
}).pipe(
|
|
||||||
Component.withSignature<TextFieldFormInputView.Signature>()
|
|
||||||
)
|
|
||||||
@@ -1,64 +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"
|
|
||||||
import type * as React from "react"
|
|
||||||
|
|
||||||
|
|
||||||
export declare namespace TextFieldOptionalFormInputView {
|
|
||||||
export interface Props<out P extends readonly PropertyKey[], A, ER, EW>
|
|
||||||
extends Omit<TextField.RootProps, "form" | "defaultValue">, Form.useOptionalInput.Options<string> {
|
|
||||||
readonly form: Form.Form<P, A, Option.Option<string>, ER, EW>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Signature = <P extends readonly PropertyKey[], A, ER, EW>(props: Props<P, A, ER, EW>) => React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalFormInputView")(function*(
|
|
||||||
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
|
|
||||||
) {
|
|
||||||
const input = yield* Form.useOptionalInput(props.form, props)
|
|
||||||
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
|
|
||||||
props.form.issues,
|
|
||||||
props.form.isValidating,
|
|
||||||
props.form.isCommitting,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex direction="column" gap="1">
|
|
||||||
<TextField.Root
|
|
||||||
value={input.value}
|
|
||||||
onChange={e => input.setValue(e.target.value)}
|
|
||||||
disabled={!input.enabled || isCommitting}
|
|
||||||
{...Struct.omit(props, "form", "defaultValue")}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}).pipe(
|
|
||||||
Component.withSignature<TextFieldOptionalFormInputView.Signature>()
|
|
||||||
)
|
|
||||||
@@ -13,10 +13,10 @@ import { Route as ResultRouteImport } from './routes/result'
|
|||||||
import { Route as QueryRouteImport } from './routes/query'
|
import { Route as QueryRouteImport } from './routes/query'
|
||||||
import { Route as FormRouteImport } from './routes/form'
|
import { Route as FormRouteImport } from './routes/form'
|
||||||
import { Route as BlankRouteImport } from './routes/blank'
|
import { Route as BlankRouteImport } from './routes/blank'
|
||||||
import { Route as AsyncRouteImport } from './routes/async'
|
|
||||||
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 DevContextRouteImport } from './routes/dev/context'
|
||||||
|
import { Route as DevAsyncRenderingRouteImport } from './routes/dev/async-rendering'
|
||||||
|
|
||||||
const ResultRoute = ResultRouteImport.update({
|
const ResultRoute = ResultRouteImport.update({
|
||||||
id: '/result',
|
id: '/result',
|
||||||
@@ -38,11 +38,6 @@ const BlankRoute = BlankRouteImport.update({
|
|||||||
path: '/blank',
|
path: '/blank',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const AsyncRoute = AsyncRouteImport.update({
|
|
||||||
id: '/async',
|
|
||||||
path: '/async',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -58,35 +53,40 @@ const DevContextRoute = DevContextRouteImport.update({
|
|||||||
path: '/dev/context',
|
path: '/dev/context',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({
|
||||||
|
id: '/dev/async-rendering',
|
||||||
|
path: '/dev/async-rendering',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/async': typeof AsyncRoute
|
|
||||||
'/blank': typeof BlankRoute
|
'/blank': typeof BlankRoute
|
||||||
'/form': typeof FormRoute
|
'/form': typeof FormRoute
|
||||||
'/query': typeof QueryRoute
|
'/query': typeof QueryRoute
|
||||||
'/result': typeof ResultRoute
|
'/result': typeof ResultRoute
|
||||||
|
'/dev/async-rendering': typeof DevAsyncRenderingRoute
|
||||||
'/dev/context': typeof DevContextRoute
|
'/dev/context': typeof DevContextRoute
|
||||||
'/dev/memo': typeof DevMemoRoute
|
'/dev/memo': typeof DevMemoRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/async': typeof AsyncRoute
|
|
||||||
'/blank': typeof BlankRoute
|
'/blank': typeof BlankRoute
|
||||||
'/form': typeof FormRoute
|
'/form': typeof FormRoute
|
||||||
'/query': typeof QueryRoute
|
'/query': typeof QueryRoute
|
||||||
'/result': typeof ResultRoute
|
'/result': typeof ResultRoute
|
||||||
|
'/dev/async-rendering': typeof DevAsyncRenderingRoute
|
||||||
'/dev/context': typeof DevContextRoute
|
'/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
|
||||||
'/async': typeof AsyncRoute
|
|
||||||
'/blank': typeof BlankRoute
|
'/blank': typeof BlankRoute
|
||||||
'/form': typeof FormRoute
|
'/form': typeof FormRoute
|
||||||
'/query': typeof QueryRoute
|
'/query': typeof QueryRoute
|
||||||
'/result': typeof ResultRoute
|
'/result': typeof ResultRoute
|
||||||
|
'/dev/async-rendering': typeof DevAsyncRenderingRoute
|
||||||
'/dev/context': typeof DevContextRoute
|
'/dev/context': typeof DevContextRoute
|
||||||
'/dev/memo': typeof DevMemoRoute
|
'/dev/memo': typeof DevMemoRoute
|
||||||
}
|
}
|
||||||
@@ -94,42 +94,42 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/async'
|
|
||||||
| '/blank'
|
| '/blank'
|
||||||
| '/form'
|
| '/form'
|
||||||
| '/query'
|
| '/query'
|
||||||
| '/result'
|
| '/result'
|
||||||
|
| '/dev/async-rendering'
|
||||||
| '/dev/context'
|
| '/dev/context'
|
||||||
| '/dev/memo'
|
| '/dev/memo'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/async'
|
|
||||||
| '/blank'
|
| '/blank'
|
||||||
| '/form'
|
| '/form'
|
||||||
| '/query'
|
| '/query'
|
||||||
| '/result'
|
| '/result'
|
||||||
|
| '/dev/async-rendering'
|
||||||
| '/dev/context'
|
| '/dev/context'
|
||||||
| '/dev/memo'
|
| '/dev/memo'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/async'
|
|
||||||
| '/blank'
|
| '/blank'
|
||||||
| '/form'
|
| '/form'
|
||||||
| '/query'
|
| '/query'
|
||||||
| '/result'
|
| '/result'
|
||||||
|
| '/dev/async-rendering'
|
||||||
| '/dev/context'
|
| '/dev/context'
|
||||||
| '/dev/memo'
|
| '/dev/memo'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AsyncRoute: typeof AsyncRoute
|
|
||||||
BlankRoute: typeof BlankRoute
|
BlankRoute: typeof BlankRoute
|
||||||
FormRoute: typeof FormRoute
|
FormRoute: typeof FormRoute
|
||||||
QueryRoute: typeof QueryRoute
|
QueryRoute: typeof QueryRoute
|
||||||
ResultRoute: typeof ResultRoute
|
ResultRoute: typeof ResultRoute
|
||||||
|
DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute
|
||||||
DevContextRoute: typeof DevContextRoute
|
DevContextRoute: typeof DevContextRoute
|
||||||
DevMemoRoute: typeof DevMemoRoute
|
DevMemoRoute: typeof DevMemoRoute
|
||||||
}
|
}
|
||||||
@@ -164,13 +164,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof BlankRouteImport
|
preLoaderRoute: typeof BlankRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/async': {
|
|
||||||
id: '/async'
|
|
||||||
path: '/async'
|
|
||||||
fullPath: '/async'
|
|
||||||
preLoaderRoute: typeof AsyncRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -192,16 +185,23 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DevContextRouteImport
|
preLoaderRoute: typeof DevContextRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/dev/async-rendering': {
|
||||||
|
id: '/dev/async-rendering'
|
||||||
|
path: '/dev/async-rendering'
|
||||||
|
fullPath: '/dev/async-rendering'
|
||||||
|
preLoaderRoute: typeof DevAsyncRenderingRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AsyncRoute: AsyncRoute,
|
|
||||||
BlankRoute: BlankRoute,
|
BlankRoute: BlankRoute,
|
||||||
FormRoute: FormRoute,
|
FormRoute: FormRoute,
|
||||||
QueryRoute: QueryRoute,
|
QueryRoute: QueryRoute,
|
||||||
ResultRoute: ResultRoute,
|
ResultRoute: ResultRoute,
|
||||||
|
DevAsyncRenderingRoute: DevAsyncRenderingRoute,
|
||||||
DevContextRoute: DevContextRoute,
|
DevContextRoute: DevContextRoute,
|
||||||
DevMemoRoute: DevMemoRoute,
|
DevMemoRoute: DevMemoRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { HttpClient } from "@effect/platform"
|
|
||||||
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
|
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
|
||||||
import { Array, Effect, flow, Option, Schema } from "effect"
|
|
||||||
import { Async, Component, Memoized } from "effect-fc"
|
|
||||||
import * as React from "react"
|
|
||||||
import { runtime } from "@/runtime"
|
|
||||||
|
|
||||||
|
|
||||||
const Post = Schema.Struct({
|
|
||||||
userId: Schema.Int,
|
|
||||||
id: Schema.Int,
|
|
||||||
title: Schema.String,
|
|
||||||
body: Schema.String,
|
|
||||||
})
|
|
||||||
|
|
||||||
interface AsyncFetchPostViewProps {
|
|
||||||
readonly id: number
|
|
||||||
}
|
|
||||||
|
|
||||||
class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) {
|
|
||||||
const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe(
|
|
||||||
Effect.tap(Effect.sleep("500 millis")),
|
|
||||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)),
|
|
||||||
Effect.andThen(response => response.json),
|
|
||||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
|
||||||
), [props.id])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Heading>{post.title}</Heading>
|
|
||||||
<Text>{post.body}</Text>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}).pipe(
|
|
||||||
Async.async,
|
|
||||||
Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }),
|
|
||||||
Memoized.memoized,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
|
|
||||||
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
|
|
||||||
const [text, setText] = React.useState("Typing here should not trigger a refetch of the post")
|
|
||||||
const [id, setId] = React.useState(1)
|
|
||||||
|
|
||||||
const AsyncFetchPost = yield* AsyncFetchPostView.use
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Flex direction="column" align="stretch" gap="2">
|
|
||||||
<TextField.Root
|
|
||||||
value={text}
|
|
||||||
onChange={e => setText(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Slider
|
|
||||||
value={[id]}
|
|
||||||
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
|
|
||||||
</Flex>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/async")({
|
|
||||||
component: AsyncRouteComponent,
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Flex, Text, TextField } from "@radix-ui/themes"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Async, Component, Memoized } from "effect-fc"
|
||||||
|
import * as React from "react"
|
||||||
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
|
|
||||||
|
// Generator version
|
||||||
|
const RouteComponent = Component.makeUntraced(function* AsyncRendering() {
|
||||||
|
const MemoizedAsyncComponentFC = yield* MemoizedAsyncComponent
|
||||||
|
const AsyncComponentFC = yield* AsyncComponent
|
||||||
|
const [input, setInput] = React.useState("")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" align="stretch" gap="2">
|
||||||
|
<TextField.Root
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MemoizedAsyncComponentFC fallback={React.useMemo(() => <p>Loading memoized...</p>, [])} />
|
||||||
|
<AsyncComponentFC />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pipeline version
|
||||||
|
// const RouteComponent = Component.make("RouteComponent")(() => Effect.Do,
|
||||||
|
// Effect.bind("VMemoizedAsyncComponent", () => Component.useFC(MemoizedAsyncComponent)),
|
||||||
|
// Effect.bind("VAsyncComponent", () => Component.useFC(AsyncComponent)),
|
||||||
|
// Effect.let("input", () => React.useState("")),
|
||||||
|
|
||||||
|
// Effect.map(({ input: [input, setInput], VAsyncComponent, VMemoizedAsyncComponent }) =>
|
||||||
|
// <Flex direction="column" align="stretch" gap="2">
|
||||||
|
// <TextField.Root
|
||||||
|
// value={input}
|
||||||
|
// onChange={e => setInput(e.target.value)}
|
||||||
|
// />
|
||||||
|
|
||||||
|
// <VMemoizedAsyncComponent />
|
||||||
|
// <VAsyncComponent />
|
||||||
|
// </Flex>
|
||||||
|
// ),
|
||||||
|
// ).pipe(
|
||||||
|
// Component.withRuntime(runtime.context)
|
||||||
|
// )
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncComponent extends Component.makeUntraced("AsyncComponent")(function*() {
|
||||||
|
const SubComponentFC = yield* SubComponent
|
||||||
|
|
||||||
|
yield* Effect.sleep("500 millis") // Async operation
|
||||||
|
// Cannot use React hooks after the async operation
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" align="stretch">
|
||||||
|
<Text>Rendered!</Text>
|
||||||
|
<SubComponentFC />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Async.async,
|
||||||
|
Async.withOptions({ defaultFallback: <p>Loading...</p> }),
|
||||||
|
) {}
|
||||||
|
class MemoizedAsyncComponent extends Memoized.memoized(AsyncComponent) {}
|
||||||
|
|
||||||
|
class SubComponent extends Component.makeUntraced("SubComponent")(function*() {
|
||||||
|
const [state] = React.useState(yield* Component.useOnMount(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
|
||||||
|
return <Text>{state}</Text>
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dev/async-rendering")({
|
||||||
|
component: RouteComponent
|
||||||
|
})
|
||||||
@@ -23,7 +23,7 @@ const SubComponent = Component.makeUntraced("SubComponent")(function*() {
|
|||||||
const ContextView = Component.makeUntraced("ContextView")(function*() {
|
const ContextView = Component.makeUntraced("ContextView")(function*() {
|
||||||
const [serviceValue, setServiceValue] = React.useState("test")
|
const [serviceValue, setServiceValue] = React.useState("test")
|
||||||
const SubServiceLayer = React.useMemo(() => SubService.Default(serviceValue), [serviceValue])
|
const SubServiceLayer = React.useMemo(() => SubService.Default(serviceValue), [serviceValue])
|
||||||
const SubComponentFC = yield* Effect.provide(SubComponent.use, yield* Component.useContextFromLayer(SubServiceLayer))
|
const SubComponentFC = yield* Effect.provide(SubComponent, yield* Component.useContext(SubServiceLayer))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ 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(
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Button, Container, Flex, Text } from "@radix-ui/themes"
|
import { Button, Container, Flex, Text } from "@radix-ui/themes"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
|
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
|
||||||
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
|
import { Component, Form, Subscribable } from "effect-fc"
|
||||||
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
import { TextFieldFormInput } from "@/lib/form/TextFieldFormInput"
|
||||||
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
|
|
||||||
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
||||||
import { runtime } from "@/runtime"
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
@@ -39,9 +38,8 @@ const RegisterFormSubmitSchema = Schema.Struct({
|
|||||||
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
|
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
|
||||||
})
|
})
|
||||||
|
|
||||||
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
|
class RegisterForm extends Effect.Service<RegisterForm>()("RegisterForm", {
|
||||||
scoped: Effect.gen(function*() {
|
scoped: Form.service({
|
||||||
const form = yield* SubmittableForm.service({
|
|
||||||
schema: RegisterFormSchema.pipe(
|
schema: RegisterFormSchema.pipe(
|
||||||
Schema.compose(
|
Schema.compose(
|
||||||
Schema.transformOrFail(
|
Schema.transformOrFail(
|
||||||
@@ -60,27 +58,19 @@ class RegisterFormService extends Effect.Service<RegisterFormService>()("Registe
|
|||||||
yield* Effect.sleep("500 millis")
|
yield* Effect.sleep("500 millis")
|
||||||
return yield* Schema.decode(RegisterFormSubmitSchema)(value)
|
return yield* Schema.decode(RegisterFormSubmitSchema)(value)
|
||||||
}),
|
}),
|
||||||
})
|
debounce: "500 millis",
|
||||||
|
|
||||||
return {
|
|
||||||
form,
|
|
||||||
emailField: Form.focusObjectOn(form, "email"),
|
|
||||||
passwordField: Form.focusObjectOn(form, "password"),
|
|
||||||
birthField: Form.focusObjectOn(form, "birth"),
|
|
||||||
} as const
|
|
||||||
})
|
})
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
class RegisterFormView extends Component.make("RegisterFormView")(function*() {
|
class RegisterFormView extends Component.makeUntraced("RegisterFormView")(function*() {
|
||||||
const form = yield* RegisterFormService
|
const form = yield* RegisterForm
|
||||||
const [canCommit, submitResult] = yield* Subscribable.useAll([
|
const [canSubmit, submitResult] = yield* Subscribable.useSubscribables([
|
||||||
form.form.canCommit,
|
form.canSubmit,
|
||||||
form.form.mutation.result,
|
form.mutation.result,
|
||||||
])
|
])
|
||||||
|
|
||||||
const runPromise = yield* Component.useRunPromise()
|
const runPromise = yield* Component.useRunPromise()
|
||||||
const TextFieldFormInput = yield* TextFieldFormInputView.use
|
const TextFieldFormInputFC = yield* TextFieldFormInput
|
||||||
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
|
|
||||||
|
|
||||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||||
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
|
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
|
||||||
@@ -92,26 +82,25 @@ class RegisterFormView extends Component.make("RegisterFormView")(function*() {
|
|||||||
<Container width="300">
|
<Container width="300">
|
||||||
<form onSubmit={e => {
|
<form onSubmit={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void runPromise(form.form.submit)
|
void runPromise(form.submit)
|
||||||
}}>
|
}}>
|
||||||
<Flex direction="column" gap="2">
|
<Flex direction="column" gap="2">
|
||||||
<TextFieldFormInput
|
<TextFieldFormInputFC
|
||||||
form={form.emailField}
|
field={yield* form.field(["email"])}
|
||||||
debounce="250 millis"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextFieldFormInput
|
<TextFieldFormInputFC
|
||||||
form={form.passwordField}
|
field={yield* form.field(["password"])}
|
||||||
debounce="250 millis"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextFieldOptionalFormInput
|
<TextFieldFormInputFC
|
||||||
|
optional
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
form={form.birthField}
|
field={yield* form.field(["birth"])}
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button disabled={!canCommit}>Submit</Button>
|
<Button disabled={!canSubmit}>Submit</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -126,13 +115,13 @@ class RegisterFormView extends Component.make("RegisterFormView")(function*() {
|
|||||||
)
|
)
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
const RegisterPage = Component.make("RegisterPageView")(function*() {
|
const RegisterPage = Component.makeUntraced("RegisterPage")(function*() {
|
||||||
const RegisterForm = yield* Effect.provide(
|
const RegisterFormViewFC = yield* Effect.provide(
|
||||||
RegisterFormView.use,
|
RegisterFormView,
|
||||||
yield* Component.useContextFromLayer(RegisterFormService.Default),
|
yield* Component.useContext(RegisterForm.Default),
|
||||||
)
|
)
|
||||||
|
|
||||||
return <RegisterForm />
|
return <RegisterFormViewFC />
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Component.withRuntime(runtime.context)
|
Component.withRuntime(runtime.context)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,19 +2,19 @@ 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 { runtime } from "@/runtime"
|
||||||
import { TodosState } from "@/todo/TodosState"
|
import { Todos } from "@/todo/Todos"
|
||||||
import { TodosView } from "@/todo/TodosView"
|
import { TodosState } from "@/todo/TodosState.service"
|
||||||
|
|
||||||
|
|
||||||
const TodosStateLive = TodosState.Default("todos")
|
const TodosStateLive = TodosState.Default("todos")
|
||||||
|
|
||||||
const Index = Component.make("IndexView")(function*() {
|
const Index = Component.makeUntraced("Index")(function*() {
|
||||||
const Todos = yield* Effect.provide(
|
const TodosFC = yield* Effect.provide(
|
||||||
TodosView.use,
|
Todos,
|
||||||
yield* Component.useContextFromLayer(TodosStateLive),
|
yield* Component.useContext(TodosStateLive),
|
||||||
)
|
)
|
||||||
|
|
||||||
return <Todos />
|
return <TodosFC />
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Component.withRuntime(runtime.context)
|
Component.withRuntime(runtime.context)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { HttpClient, type HttpClientError } from "@effect/platform"
|
import { HttpClient, type HttpClientError } from "@effect/platform"
|
||||||
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
|
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect"
|
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream } from "effect"
|
||||||
import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc"
|
import { Component, ErrorObserver, Mutation, Query, Result, Subscribable, SubscriptionRef } from "effect-fc"
|
||||||
import { runtime } from "@/runtime"
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
|
|
||||||
@@ -13,16 +13,15 @@ const Post = Schema.Struct({
|
|||||||
body: Schema.String,
|
body: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
const ResultView = Component.make("ResultView")(function*() {
|
const ResultView = Component.makeUntraced("Result")(function*() {
|
||||||
const runPromise = yield* Component.useRunPromise()
|
const runPromise = yield* Component.useRunPromise()
|
||||||
|
|
||||||
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
const [idRef, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||||
const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
|
const idRef = yield* SubscriptionRef.make(1)
|
||||||
const key = Stream.map(idLens.changes, id => [id] as const)
|
|
||||||
|
|
||||||
const query = yield* Query.service({
|
const query = yield* Query.service({
|
||||||
key,
|
key: Stream.zipLatest(Stream.make("posts" as const), idRef.changes),
|
||||||
f: ([id]) => HttpClient.HttpClient.pipe(
|
f: ([, id]) => HttpClient.HttpClient.pipe(
|
||||||
Effect.tap(Effect.sleep("500 millis")),
|
Effect.tap(Effect.sleep("500 millis")),
|
||||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
||||||
Effect.andThen(response => response.json),
|
Effect.andThen(response => response.json),
|
||||||
@@ -40,11 +39,11 @@ const ResultView = Component.make("ResultView")(function*() {
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
return [idLens, query, mutation] as const
|
return [idRef, query, mutation] as const
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const [id, setId] = yield* Lens.useState(idLens)
|
const [id, setId] = yield* SubscriptionRef.useSubscriptionRefState(idRef)
|
||||||
const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result])
|
const [queryResult, mutationResult] = yield* Subscribable.useSubscribables([query.result, mutation.result])
|
||||||
|
|
||||||
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
||||||
Effect.andThen(observer => observer.subscribe),
|
Effect.andThen(observer => observer.subscribe),
|
||||||
@@ -105,7 +104,7 @@ const ResultView = Component.make("ResultView")(function*() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Flex direction="row" justify="center" align="center" gap="1">
|
<Flex direction="row" justify="center" align="center" gap="1">
|
||||||
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button>
|
<Button onClick={() => runPromise(Effect.andThen(idRef, id => mutation.mutate([id])))}>Mutate</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const ResultView = Component.makeUntraced("Result")(function*() {
|
|||||||
Effect.tap(Effect.sleep("250 millis")),
|
Effect.tap(Effect.sleep("250 millis")),
|
||||||
Result.forkEffect,
|
Result.forkEffect,
|
||||||
))
|
))
|
||||||
const [result] = yield* Subscribable.useAll([resultSubscribable])
|
const [result] = yield* Subscribable.useSubscribables([resultSubscribable])
|
||||||
|
|
||||||
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
||||||
Effect.andThen(observer => observer.subscribe),
|
Effect.andThen(observer => observer.subscribe),
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import { Box, Flex, IconButton } from "@radix-ui/themes"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { Component, Form, Subscribable, SynchronizedForm } from "effect-fc"
|
|
||||||
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
|
|
||||||
import { FaDeleteLeft } from "react-icons/fa6"
|
|
||||||
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
|
||||||
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
|
|
||||||
import { TodoFormSchema } from "./TodoFormSchema"
|
|
||||||
import { TodosState } from "./TodosState"
|
|
||||||
|
|
||||||
|
|
||||||
export interface EditTodoViewProps {
|
|
||||||
readonly id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EditTodoView extends Component.make("TodoView")(function*(props: EditTodoViewProps) {
|
|
||||||
const state = yield* TodosState
|
|
||||||
|
|
||||||
const [
|
|
||||||
indexSubscribable,
|
|
||||||
contentField,
|
|
||||||
completedAtField,
|
|
||||||
] = yield* Component.useOnChange(() => Effect.gen(function*() {
|
|
||||||
const indexSubscribable = state.getIndexSubscribable(props.id)
|
|
||||||
|
|
||||||
const form = yield* SynchronizedForm.service({
|
|
||||||
schema: TodoFormSchema,
|
|
||||||
target: state.getElementLens(props.id),
|
|
||||||
})
|
|
||||||
|
|
||||||
return [
|
|
||||||
indexSubscribable,
|
|
||||||
Form.focusObjectOn(form, "content"),
|
|
||||||
Form.focusObjectOn(form, "completedAt"),
|
|
||||||
] as const
|
|
||||||
}), [props.id])
|
|
||||||
|
|
||||||
const [index, size] = yield* Subscribable.useAll([
|
|
||||||
indexSubscribable,
|
|
||||||
state.sizeSubscribable,
|
|
||||||
])
|
|
||||||
|
|
||||||
const runSync = yield* Component.useRunSync()
|
|
||||||
const TextFieldFormInput = yield* TextFieldFormInputView.use
|
|
||||||
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex direction="row" align="center" gap="2">
|
|
||||||
<Box flexGrow="1">
|
|
||||||
<Flex direction="column" align="stretch" gap="2">
|
|
||||||
<TextFieldFormInput
|
|
||||||
form={contentField}
|
|
||||||
debounce="250 millis"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Flex direction="row" justify="center" align="center" gap="2">
|
|
||||||
<TextFieldOptionalFormInput
|
|
||||||
form={completedAtField}
|
|
||||||
type="datetime-local"
|
|
||||||
defaultValue=""
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}) {}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { Box, Button, Flex } from "@radix-ui/themes"
|
|
||||||
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
|
||||||
import { Chunk, type DateTime, Effect, Option, Schema } from "effect"
|
|
||||||
import { Component, Form, Lens, SubmittableForm, Subscribable } from "effect-fc"
|
|
||||||
import * as Domain from "@/domain"
|
|
||||||
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
|
||||||
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
|
|
||||||
import { TodoFormSchema } from "./TodoFormSchema"
|
|
||||||
import { TodosState } from "./TodosState"
|
|
||||||
|
|
||||||
|
|
||||||
const makeTodo = makeUuid4.pipe(
|
|
||||||
Effect.map(id => Domain.Todo.Todo.make({
|
|
||||||
id,
|
|
||||||
content: "",
|
|
||||||
completedAt: Option.none(),
|
|
||||||
})),
|
|
||||||
Effect.provide(GetRandomValues.CryptoRandom),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
export class NewTodoView extends Component.make("NewTodoView")(function*() {
|
|
||||||
const state = yield* TodosState
|
|
||||||
|
|
||||||
const [
|
|
||||||
form,
|
|
||||||
contentField,
|
|
||||||
completedAtField,
|
|
||||||
] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
|
||||||
const form = yield* SubmittableForm.service({
|
|
||||||
schema: TodoFormSchema,
|
|
||||||
initialEncodedValue: yield* Schema.encode(TodoFormSchema)(yield* makeTodo),
|
|
||||||
f: ([todo, form]) => Lens.update(state.lens, Chunk.prepend(todo)).pipe(
|
|
||||||
Effect.andThen(makeTodo),
|
|
||||||
Effect.andThen(Schema.encode(TodoFormSchema)),
|
|
||||||
Effect.andThen(v => Lens.set(form.encodedValue, v)),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
return [
|
|
||||||
form,
|
|
||||||
Form.focusObjectOn(form, "content"),
|
|
||||||
Form.focusObjectOn(form, "completedAt"),
|
|
||||||
] as const
|
|
||||||
}))
|
|
||||||
|
|
||||||
const [canCommit] = yield* Subscribable.useAll([form.canCommit])
|
|
||||||
|
|
||||||
const runPromise = yield* Component.useRunPromise<DateTime.CurrentTimeZone>()
|
|
||||||
const TextFieldFormInput = yield* TextFieldFormInputView.use
|
|
||||||
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex direction="row" align="center" gap="2">
|
|
||||||
<Box flexGrow="1">
|
|
||||||
<Flex direction="column" align="stretch" gap="2">
|
|
||||||
<TextFieldFormInput
|
|
||||||
form={contentField}
|
|
||||||
debounce="250 millis"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Flex direction="row" justify="center" align="center" gap="2">
|
|
||||||
<TextFieldOptionalFormInput
|
|
||||||
form={completedAtField}
|
|
||||||
type="datetime-local"
|
|
||||||
defaultValue=""
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button disabled={!canCommit} onClick={() => void runPromise(form.submit)}>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}) {}
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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 { TextFieldFormInput } from "@/lib/form/TextFieldFormInput"
|
||||||
|
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
||||||
|
import { TodosState } from "./TodosState.service"
|
||||||
|
|
||||||
|
|
||||||
|
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 Todo extends Component.makeUntraced("Todo")(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 TextFieldFormInputFC = yield* TextFieldFormInput
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="row" align="center" gap="2">
|
||||||
|
<Box flexGrow="1">
|
||||||
|
<Flex direction="column" align="stretch" gap="2">
|
||||||
|
<TextFieldFormInputFC field={contentField} />
|
||||||
|
|
||||||
|
<Flex direction="row" justify="center" align="center" gap="2">
|
||||||
|
<TextFieldFormInputFC
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}) {}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Schema } from "effect"
|
|
||||||
import * as Domain from "@/domain"
|
|
||||||
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
|
||||||
|
|
||||||
|
|
||||||
export const TodoFormSchema = Schema.compose(Schema.Struct({
|
|
||||||
...Domain.Todo.Todo.fields,
|
|
||||||
completedAt: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
|
|
||||||
}), Domain.Todo.Todo)
|
|
||||||
@@ -1,32 +1,30 @@
|
|||||||
import { Container, Flex, Heading } from "@radix-ui/themes"
|
import { Container, Flex, Heading } from "@radix-ui/themes"
|
||||||
import { Chunk, Console, Effect } from "effect"
|
import { Chunk, Console, Effect } from "effect"
|
||||||
import { Component, Subscribable } from "effect-fc"
|
import { Component, Subscribable } from "effect-fc"
|
||||||
import { EditTodoView } from "./EditTodoView"
|
import { Todo } from "./Todo"
|
||||||
import { NewTodoView } from "./NewTodoView"
|
import { TodosState } from "./TodosState.service"
|
||||||
import { TodosState } from "./TodosState"
|
|
||||||
|
|
||||||
|
|
||||||
export class TodosView extends Component.make("TodosView")(function*() {
|
export class Todos extends Component.makeUntraced("Todos")(function*() {
|
||||||
const state = yield* TodosState
|
const state = yield* TodosState
|
||||||
const [todos] = yield* Subscribable.useAll([state.lens])
|
const [todos] = yield* Subscribable.useSubscribables([state.ref])
|
||||||
|
|
||||||
yield* Component.useOnMount(() => Effect.andThen(
|
yield* Component.useOnMount(() => Effect.andThen(
|
||||||
Console.log("Todos mounted"),
|
Console.log("Todos mounted"),
|
||||||
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
||||||
))
|
))
|
||||||
|
|
||||||
const NewTodo = yield* NewTodoView.use
|
const TodoFC = yield* Todo
|
||||||
const EditTodo = yield* EditTodoView.use
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Heading align="center">Todos</Heading>
|
<Heading align="center">Todos</Heading>
|
||||||
|
|
||||||
<Flex direction="column" align="stretch" gap="2" mt="2">
|
<Flex direction="column" align="stretch" gap="2" mt="2">
|
||||||
<NewTodo />
|
<TodoFC _tag="new" />
|
||||||
|
|
||||||
{Chunk.map(todos, todo =>
|
{Chunk.map(todos, todo =>
|
||||||
<EditTodo key={todo.id} id={todo.id} />
|
<TodoFC key={todo.id} _tag="edit" id={todo.id} />
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Container>
|
</Container>
|
||||||
+18
-20
@@ -1,7 +1,7 @@
|
|||||||
import { KeyValueStore } from "@effect/platform"
|
import { KeyValueStore } from "@effect/platform"
|
||||||
import { BrowserKeyValueStore } from "@effect/platform-browser"
|
import { BrowserKeyValueStore } from "@effect/platform-browser"
|
||||||
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
|
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
|
||||||
import { Lens, Subscribable } from "effect-fc"
|
import { Subscribable, SubscriptionSubRef } from "effect-fc"
|
||||||
import { Todo } from "@/domain"
|
import { Todo } from "@/domain"
|
||||||
|
|
||||||
|
|
||||||
@@ -30,29 +30,27 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
|||||||
: kv.remove(key)
|
: kv.remove(key)
|
||||||
)
|
)
|
||||||
|
|
||||||
const lens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(yield* readFromLocalStorage))
|
const ref = yield* SubscriptionRef.make(yield* readFromLocalStorage)
|
||||||
yield* Effect.forkScoped(lens.changes.pipe(
|
yield* Effect.forkScoped(ref.changes.pipe(
|
||||||
Stream.debounce("500 millis"),
|
Stream.debounce("500 millis"),
|
||||||
Stream.runForEach(saveToLocalStorage),
|
Stream.runForEach(saveToLocalStorage),
|
||||||
))
|
))
|
||||||
yield* Effect.addFinalizer(() => Lens.get(lens).pipe(
|
yield* Effect.addFinalizer(() => ref.pipe(
|
||||||
Effect.andThen(saveToLocalStorage),
|
Effect.andThen(saveToLocalStorage),
|
||||||
Effect.ignore,
|
Effect.ignore,
|
||||||
))
|
))
|
||||||
|
|
||||||
const sizeSubscribable = Subscribable.map(lens, Chunk.size)
|
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 getElementLens = (id: string) => Lens.mapEffect(
|
const moveLeft = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
|
||||||
lens,
|
|
||||||
Chunk.findFirst(v => v.id === id),
|
|
||||||
(a, b) => Effect.flatMap(
|
|
||||||
Chunk.findFirstIndex(a, v => v.id === id),
|
|
||||||
i => Chunk.replaceOption(a, i, b),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const getIndexSubscribable = (id: string) => Subscribable.mapEffect(lens, Chunk.findFirstIndex(v => v.id === id))
|
|
||||||
|
|
||||||
const moveLeft = (id: string) => Lens.updateEffect(lens, todos => Effect.Do.pipe(
|
|
||||||
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
|
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
|
||||||
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
|
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
|
||||||
Effect.bind("previous", ({ index }) => Chunk.get(todos, index - 1)),
|
Effect.bind("previous", ({ index }) => Chunk.get(todos, index - 1)),
|
||||||
@@ -64,7 +62,7 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
|||||||
: todos
|
: todos
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
const moveRight = (id: string) => Lens.updateEffect(lens, todos => Effect.Do.pipe(
|
const moveRight = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
|
||||||
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
|
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
|
||||||
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
|
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
|
||||||
Effect.bind("next", ({ index }) => Chunk.get(todos, index + 1)),
|
Effect.bind("next", ({ index }) => Chunk.get(todos, index + 1)),
|
||||||
@@ -76,15 +74,15 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
|||||||
: todos
|
: todos
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
const remove = (id: string) => Lens.updateEffect(lens, todos => Effect.andThen(
|
const remove = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.andThen(
|
||||||
Chunk.findFirstIndex(todos, v => v.id === id),
|
Chunk.findFirstIndex(todos, v => v.id === id),
|
||||||
index => Chunk.remove(todos, index),
|
index => Chunk.remove(todos, index),
|
||||||
))
|
))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lens,
|
ref,
|
||||||
sizeSubscribable,
|
sizeSubscribable,
|
||||||
getElementLens,
|
getElementRef,
|
||||||
getIndexSubscribable,
|
getIndexSubscribable,
|
||||||
moveLeft,
|
moveLeft,
|
||||||
moveRight,
|
moveRight,
|
||||||
Reference in New Issue
Block a user