Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27bb78dc14 | |||
| 4b712de788 | |||
| ec21d49dce | |||
| 4478598f15 | |||
| 721ab7d736 | |||
| e5d0808b02 | |||
| ff13e941e3 | |||
| 67b01d4621 | |||
| 092737076f |
@@ -16,3 +16,5 @@ jobs:
|
||||
run: bun lint:tsc
|
||||
- name: Lint Biome
|
||||
run: bun lint:biome
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
@@ -11,16 +11,35 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.valverde.cloud
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v6
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Lint TypeScript
|
||||
run: bun lint:tsc
|
||||
- name: Lint Biome
|
||||
run: bun lint:biome
|
||||
- name: Test
|
||||
run: bun run test
|
||||
- name: Build
|
||||
run: bun run build
|
||||
- name: Publish effect-view
|
||||
uses: JS-DevTools/npm-publish@v4
|
||||
with:
|
||||
package: packages/effect-view
|
||||
access: public
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
registry: https://registry.npmjs.org
|
||||
- name: Publish effect-fc
|
||||
uses: JS-DevTools/npm-publish@v4
|
||||
with:
|
||||
@@ -28,3 +47,30 @@ jobs:
|
||||
access: public
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
registry: https://registry.npmjs.org
|
||||
|
||||
- name: Clean before Docker build
|
||||
run: |
|
||||
bun clean:cache
|
||||
bun clean:dist
|
||||
bun clean:modules
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.valverde.cloud/thilawyn/effect-view
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
flavor: |
|
||||
latest=true
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -10,18 +10,51 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version: "22"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v6
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Lint TypeScript
|
||||
run: bun lint:tsc
|
||||
- name: Lint Biome
|
||||
run: bun lint:biome
|
||||
- name: Test
|
||||
run: bun run test
|
||||
- name: Build
|
||||
run: bun run build
|
||||
- name: Pack
|
||||
run: bun pack
|
||||
|
||||
- name: Clean before Docker build
|
||||
run: |
|
||||
bun clean:cache
|
||||
bun clean:dist
|
||||
bun clean:modules
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.valverde.cloud/thilawyn/effect-view
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
flavor: |
|
||||
latest=true
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
FROM oven/bun:1.3.12-debian@sha256:1b709c9dd883fc1af38c210f7ea5222c552a8d470ea73efbd4b8fcfee798a64b AS bun
|
||||
|
||||
FROM node:22.21.1-trixie-slim@sha256:98e1429d1a0b99378b4de43fa385f0746fd6276faf4feeb6104d91f6bad290f9
|
||||
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bunx /usr/local/bin/
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
RUN bun install --frozen-lockfile && \
|
||||
bun run build && \
|
||||
bun clean:cache && \
|
||||
bun clean:modules && \
|
||||
bun install --production --frozen-lockfile
|
||||
@@ -1,7 +1,61 @@
|
||||
# Effect FC Monorepo
|
||||
<p align="center">
|
||||
<a href="https://thila.dev/effect-view">
|
||||
<img src="https://github.com/Thiladev/effect-view/raw/master/packages/docs/static/img/logo.svg" width="104" height="104" alt="Effect View logo" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
[Effect-TS](https://effect.website/) integration for React 19+ that allows you to write function components using Effect generators.
|
||||
<h1 align="center">Effect View Monorepo</h1>
|
||||
|
||||
This monorepo contains:
|
||||
- [The `effect-fc` library](packages/effect-fc)
|
||||
- [An example project](packages/example)
|
||||
<p align="center">
|
||||
Write React components as typed Effect programs.
|
||||
</p>
|
||||
|
||||
This repository contains Effect View, its documentation, development examples,
|
||||
and supporting tooling. Effect View brings Effect's typed services, resource
|
||||
safety, concurrency, and data modeling to React while keeping ordinary React
|
||||
components, hooks, and JSX.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Description |
|
||||
| --- | --- |
|
||||
| [`effect-view`](packages/effect-view) | The Effect View library for Effect v4 and React 19. |
|
||||
| [`@effect-view/vite-plugin`](packages/vite-plugin) | Vite Fast Refresh support for Effect View components. |
|
||||
| [`docs`](packages/docs) | The [Effect View documentation](https://thila.dev/effect-view), built with Docusaurus. |
|
||||
| [`example`](packages/example) | Example application using Effect View and Effect v4. |
|
||||
| [`effect-fc`](packages/effect-fc) | Legacy Effect v3 package. |
|
||||
| [`effect-fc-example`](packages/effect-fc-example) | Example application for the legacy package. |
|
||||
|
||||
## Development
|
||||
|
||||
The monorepo uses [Bun](https://bun.sh/) workspaces and
|
||||
[Turborepo](https://turbo.build/).
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
Run checks and builds from the repository root:
|
||||
|
||||
```bash
|
||||
bun run lint:tsc
|
||||
bun run lint:biome
|
||||
bun run test
|
||||
bun run build
|
||||
```
|
||||
|
||||
Start the Effect View example or documentation site:
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/example dev
|
||||
bun run --cwd packages/docs start
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Read the documentation at
|
||||
[thila.dev/effect-view](https://thila.dev/effect-view).
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
+10
-9
@@ -1,26 +1,27 @@
|
||||
{
|
||||
"name": "@effect-fc/monorepo",
|
||||
"packageManager": "bun@1.3.3",
|
||||
"name": "@effect-view/monorepo",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
"lint:tsc": "turbo lint:tsc",
|
||||
"lint:biome": "turbo lint:biome",
|
||||
"test": "turbo test",
|
||||
"build": "turbo build",
|
||||
"pack": "turbo pack",
|
||||
"clean:cache": "turbo clean:cache",
|
||||
"clean:dist": "turbo clean:dist",
|
||||
"clean:modules": "turbo clean:modules && rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.8",
|
||||
"@effect/language-service": "^0.65.0",
|
||||
"@types/bun": "^1.3.3",
|
||||
"npm-check-updates": "^19.1.2",
|
||||
"@biomejs/biome": "^2.5.5",
|
||||
"@effect/language-service": "^0.87.1",
|
||||
"@types/bun": "^1.3.14",
|
||||
"npm-check-updates": "^23.0.0",
|
||||
"npm-sort": "^0.0.4",
|
||||
"turbo": "^2.6.1",
|
||||
"typescript": "^5.9.3"
|
||||
"turbo": "^2.10.7",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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*
|
||||
@@ -0,0 +1,17 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"files": {
|
||||
"includes": ["./src/**"]
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
"complexity": {
|
||||
"noImportantStyles": "off"
|
||||
},
|
||||
"style": {
|
||||
"noDescendingSpecificity": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Async
|
||||
---
|
||||
|
||||
# Async
|
||||
|
||||
Effect View components run synchronously by default. Apply `Async.async` when a
|
||||
component must wait for an asynchronous Effect before it can return JSX. The
|
||||
component suspends while the Effect runs and accepts React Suspense props such
|
||||
as `fallback`.
|
||||
|
||||
```tsx title="src/UserCard.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Async, Component } from "effect-view"
|
||||
import { loadUser } from "./api"
|
||||
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { readonly userId: string }) {
|
||||
const user = yield* Component.useOnChange(
|
||||
() => loadUser(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return <article>{user.name}</article>
|
||||
},
|
||||
).pipe(Async.async)
|
||||
```
|
||||
|
||||
Pass a fallback when rendering the component:
|
||||
|
||||
```tsx
|
||||
const User = yield* UserCard.use
|
||||
|
||||
return <User userId="123" fallback={<p>Loading user...</p>} />
|
||||
```
|
||||
|
||||
Or configure a default fallback once:
|
||||
|
||||
```tsx
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { readonly userId: string }) {
|
||||
const user = yield* Component.useOnChange(
|
||||
() => loadUser(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return <article>{user.name}</article>
|
||||
},
|
||||
).pipe(
|
||||
Async.async,
|
||||
Async.withOptions({ defaultFallback: <p>Loading user...</p> }),
|
||||
)
|
||||
```
|
||||
|
||||
## Hook ordering
|
||||
|
||||
**Important:** Do not use React hooks or Effect View hook helpers after an
|
||||
asynchronous computation in the component body. After the computation suspends,
|
||||
the generator continuation runs outside React's synchronous render phase.
|
||||
|
||||
Place every hook before the first operation that may suspend:
|
||||
|
||||
```tsx
|
||||
import { useState } from "react"
|
||||
|
||||
const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { readonly userId: string }) {
|
||||
// Hooks and hook helpers go before asynchronous work.
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
// This may suspend, so no hooks may be used after it.
|
||||
const user = yield* Component.useOnChange(
|
||||
() => loadUser(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return (
|
||||
<article>
|
||||
<button onClick={() => setShowDetails((value) => !value)}>
|
||||
{user.name}
|
||||
</button>
|
||||
{showDetails && <p>{user.bio}</p>}
|
||||
</article>
|
||||
)
|
||||
},
|
||||
).pipe(Async.async)
|
||||
```
|
||||
|
||||
## Memoization
|
||||
|
||||
An async computation starts whenever its component renders. Apply
|
||||
`Memoized.memoized` after `Async.async` to prevent an unrelated parent render
|
||||
from restarting an async child whose props have not changed:
|
||||
|
||||
```tsx
|
||||
import { Async, Component, Memoized } from "effect-view"
|
||||
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { readonly userId: string }) {
|
||||
const user = yield* Component.useOnChange(
|
||||
() => loadUser(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return <article>{user.name}</article>
|
||||
},
|
||||
).pipe(
|
||||
Async.async,
|
||||
Memoized.memoized,
|
||||
)
|
||||
```
|
||||
|
||||
Async components compare props with `Object.is` by default and ignore the
|
||||
`fallback` prop during that comparison. A changed `userId` renders the child
|
||||
again, closes the previous dependency scope, and runs `loadUser` for the new
|
||||
value.
|
||||
|
||||
If props contain immutable objects or arrays that may be recreated with the same
|
||||
content, use `Equal.asEquivalence()` for full structural equality:
|
||||
|
||||
```tsx
|
||||
import { Equal } from "effect"
|
||||
import { Async, Component, Memoized } from "effect-view"
|
||||
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* (props: { readonly user: { readonly id: string } }) {
|
||||
const details = yield* Component.useOnChange(
|
||||
() => loadUser(props.user.id),
|
||||
[props.user.id],
|
||||
)
|
||||
|
||||
return <article>{details.name}</article>
|
||||
},
|
||||
).pipe(
|
||||
Async.async,
|
||||
Memoized.memoized,
|
||||
Memoized.withOptions({
|
||||
propsEquivalence: Equal.asEquivalence(),
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Supplying `propsEquivalence` replaces the async component's default comparison,
|
||||
so `fallback` is also included in this structural comparison.
|
||||
@@ -0,0 +1,475 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
title: Forms
|
||||
---
|
||||
|
||||
# Forms
|
||||
|
||||
Effect View forms are built from an Effect `Schema`. The schema is the source of
|
||||
truth for the shape of the form, its validation rules, and the value your
|
||||
application receives. The form layer supplies reactive state and lifecycle; it
|
||||
does not replace your UI components.
|
||||
|
||||
A schema distinguishes the **encoded value** edited by the UI from the
|
||||
**decoded value** used by the application. For example, an `<input>` edits an
|
||||
age as a string, while the submit handler or application state receives a
|
||||
number:
|
||||
|
||||
```tsx
|
||||
import { Schema } from "effect"
|
||||
|
||||
const ProfileSchema = Schema.Struct({
|
||||
displayName: Schema.String.check(
|
||||
Schema.isMinLength(1, { message: "Enter a display name" }),
|
||||
),
|
||||
age: Schema.NumberFromString,
|
||||
contact: Schema.Struct({
|
||||
email: Schema.String.check(
|
||||
Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {
|
||||
message: "Enter a valid email address",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
That schema gives the form several useful properties:
|
||||
|
||||
- Validation stays next to the data definition instead of being duplicated in
|
||||
components and submit handlers.
|
||||
- Inputs can keep UI-friendly encoded values while business logic receives
|
||||
decoded, typed values.
|
||||
- Nested schema issue paths can be routed automatically to the matching
|
||||
subform or field.
|
||||
- Synchronous and effectful schema validation use the same form model.
|
||||
- The same schema can validate a local draft or protect writes to shared state.
|
||||
|
||||
There are two concrete root form implementations. Choose one based on where a
|
||||
valid decoded value should go:
|
||||
|
||||
| Implementation | Owns a local draft | What happens to a valid value |
|
||||
| --- | --- | --- |
|
||||
| `MutationForm` | Yes | It is passed to a mutation when `submit` runs. |
|
||||
| `LensForm` | Yes | It is written automatically to a target `Lens`. |
|
||||
|
||||
## Create forms once
|
||||
|
||||
`MutationForm.service` and `LensForm.service` are Effect constructors, not
|
||||
hooks. Create each root form once and keep it stable. For a component-owned
|
||||
form, call the constructor inside `Component.useOnMount`; for a form shared by
|
||||
multiple components, create it in an Effect service. Update the existing form's
|
||||
Lenses rather than reconstructing the form during render.
|
||||
|
||||
## MutationForm: validate, then submit
|
||||
|
||||
Use `MutationForm` for registration, checkout, search, and other workflows with
|
||||
an explicit submit action. It owns the encoded draft, continuously decodes it
|
||||
with the schema, and exposes the last valid decoded value. Calling `submit`
|
||||
runs the mutation only when the form can commit.
|
||||
|
||||
```tsx
|
||||
import { Effect } from "effect"
|
||||
import { Component, MutationForm, View } from "effect-view"
|
||||
|
||||
const CreateProfileView = Component.make("CreateProfile")(function* () {
|
||||
const form = yield* Component.useOnMount(() =>
|
||||
MutationForm.service({
|
||||
schema: ProfileSchema,
|
||||
initialEncodedValue: {
|
||||
displayName: "",
|
||||
age: "",
|
||||
contact: { email: "" },
|
||||
},
|
||||
f: ([profile]) =>
|
||||
Effect.log(
|
||||
`Creating ${profile.displayName}, age ${profile.age}`,
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
const [canCommit, isCommitting] = yield* View.useAll([
|
||||
form.canCommit,
|
||||
form.isCommitting,
|
||||
])
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
// Focused inputs are added in "Focus into subforms" below.
|
||||
return (
|
||||
<button
|
||||
disabled={!canCommit || isCommitting}
|
||||
onClick={() => void runPromise(form.submit)}
|
||||
>
|
||||
{isCommitting ? "Creating..." : "Create profile"}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Notice that `age` starts as a string because it is an encoded input value. The
|
||||
mutation receives the schema's decoded profile, so `profile.age` is a number.
|
||||
Schema transformations happen before the mutation and schema issues prevent an
|
||||
invalid draft from being submitted.
|
||||
|
||||
`MutationForm.service` also starts initial validation in the current scope. In
|
||||
the example above, `Component.useOnMount` keeps that validation and the form's
|
||||
mutation work tied to the component that owns them.
|
||||
|
||||
## LensForm: validate, then synchronize
|
||||
|
||||
Use `LensForm` for settings panels, inspectors, and edit screens where valid
|
||||
changes should update existing state without a final submit. Its target holds
|
||||
decoded application data; the form derives an encoded draft from that target
|
||||
and keeps the two synchronized in both directions.
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, LensForm, View } from "effect-view"
|
||||
|
||||
const EditProfileView = Component.make("EditProfile")(function* () {
|
||||
const [form, profile] = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
const profile = Lens.fromSubscriptionRef(
|
||||
yield* SubscriptionRef.make({
|
||||
displayName: "Ada",
|
||||
age: 37,
|
||||
contact: { email: "ada@example.com" },
|
||||
}),
|
||||
)
|
||||
|
||||
const form = yield* LensForm.service({
|
||||
schema: ProfileSchema,
|
||||
target: profile,
|
||||
})
|
||||
|
||||
return [form, profile] as const
|
||||
}),
|
||||
)
|
||||
|
||||
const [savedProfile, isCommitting] = yield* View.useAll([
|
||||
profile,
|
||||
form.isCommitting,
|
||||
])
|
||||
|
||||
// Focused inputs are added in "Focus into subforms" below.
|
||||
return (
|
||||
<output>
|
||||
{isCommitting
|
||||
? "Saving..."
|
||||
: `${savedProfile.displayName} is ${savedProfile.age}`}
|
||||
</output>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Here the target contains `age: 37`, while the form exposes the encoded value
|
||||
`age: "37"` to an input. A valid edit is decoded and written to `profile`.
|
||||
Invalid input remains in the form so the user can correct it, but it never
|
||||
reaches the target. If another part of the application updates the target,
|
||||
`LensForm` encodes that value back into the draft.
|
||||
|
||||
Pass `initialEncodedValue` only when the first draft should differ from the
|
||||
encoded target. Otherwise `LensForm.service` obtains the initial draft by
|
||||
encoding the target through the schema.
|
||||
|
||||
## Focus into subforms
|
||||
|
||||
The root `MutationForm` or `LensForm` represents the complete schema. UI
|
||||
components usually need a smaller form representing one field, so they do not
|
||||
have to receive or understand the entire root form. **Focusing** creates that
|
||||
smaller form.
|
||||
|
||||
Every root form and every focused subform implements the same `Form.Form`
|
||||
interface. There is no separate field abstraction: an individual field is
|
||||
simply a `Form` focused on that part of its parent.
|
||||
|
||||
```tsx
|
||||
const displayNameField = Form.focusObjectOn(form, "displayName")
|
||||
const ageField = Form.focusObjectOn(form, "age")
|
||||
|
||||
const contactForm = Form.focusObjectOn(form, "contact")
|
||||
const emailField = Form.focusObjectOn(contactForm, "email")
|
||||
```
|
||||
|
||||
Form focus helpers also have a dual API. Pass only the key or index to get a curried function. Curried helpers can be chained
|
||||
with `pipe` to focus through a deeper path:
|
||||
|
||||
```tsx
|
||||
const emailField = form.pipe(
|
||||
Form.focusObjectOn("contact"),
|
||||
Form.focusObjectOn("email"),
|
||||
)
|
||||
```
|
||||
|
||||
`displayNameField`, `ageField`, and `emailField` can each be passed to the input
|
||||
component responsible for that field. Focusing can also be repeated:
|
||||
`contactForm` represents the complete contact section, and `emailField`
|
||||
represents its email field.
|
||||
|
||||
A focused form exposes only the relevant:
|
||||
|
||||
- `encodedValue`: the UI-facing value that can be edited.
|
||||
- `value`: the decoded value as an `Option`.
|
||||
- `issues`: the schema issues for that subform.
|
||||
|
||||
Validation and commit state remain connected to the root form. An input can
|
||||
show its own issues while still observing the root form's `isValidating`,
|
||||
`canCommit`, and `isCommitting` state.
|
||||
|
||||
Use the focusing helper that matches the value being represented:
|
||||
|
||||
```tsx
|
||||
const property = Form.focusObjectOn(form, "contact")
|
||||
const item = Form.focusArrayAt(itemsForm, 0)
|
||||
const tupleMember = Form.focusTupleAt(tupleForm, 1)
|
||||
const chunkItem = Form.focusChunkAt(chunkForm, 0)
|
||||
```
|
||||
|
||||
Schema issue paths are focused at the same time. A component receiving
|
||||
`emailField` gets its email issues directly instead of searching through every
|
||||
issue on the root form.
|
||||
|
||||
## Bind a subform to an input
|
||||
|
||||
`Form.useInput` is the liaison between an Effect form and a React controlled
|
||||
component. It reads the subform's encoded value and returns the familiar React
|
||||
`{ value, setValue }` interface. Calling `setValue` updates the form, which in
|
||||
turn runs the schema pipeline and updates validation state.
|
||||
|
||||
```tsx
|
||||
const input = yield* Form.useInput(emailField, {
|
||||
debounce: "250 millis",
|
||||
})
|
||||
|
||||
return (
|
||||
<input
|
||||
value={input.value}
|
||||
onChange={(event) => input.setValue(event.currentTarget.value)}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
The debounce is useful for text inputs, which can otherwise update the form and
|
||||
run schema validation on every keystroke. The displayed value still updates
|
||||
immediately; propagation to the form waits until the user pauses typing.
|
||||
|
||||
Use `Form.useOptionalInput` when the encoded field is an Effect `Option`. It
|
||||
returns `value` and `setValue` together with `enabled` and `setEnabled`, making
|
||||
it suitable for an optional input that the user can toggle on and off.
|
||||
|
||||
`useInput` and `useOptionalInput` can be used directly, but they are primarily
|
||||
building blocks for your own reusable input components. A component library
|
||||
can wrap them to consistently handle labels, validation issues, validating
|
||||
indicators, disabled state, accessibility, styling, and a standard debounce.
|
||||
Because both hooks accept a `Form.Form`, the same input components work with
|
||||
subforms from both `MutationForm` and `LensForm`.
|
||||
|
||||
```tsx title="TextFieldFormInputView.tsx"
|
||||
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
|
||||
import { Array, Option, Struct } from "effect"
|
||||
import { Component, Form, View } from "effect-view"
|
||||
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* View.useAll([
|
||||
props.form.issues,
|
||||
props.form.isValidating,
|
||||
props.form.isCommitting,
|
||||
])
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="1">
|
||||
<TextField.Root
|
||||
value={input.value}
|
||||
onChange={(event) => input.setValue(event.target.value)}
|
||||
disabled={isCommitting}
|
||||
{...Struct.omit(props, ["form", "debounce"])}
|
||||
>
|
||||
{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: () => null,
|
||||
})}
|
||||
</Flex>
|
||||
)
|
||||
}).pipe(
|
||||
Component.withSignature<TextFieldFormInputView.Signature>(),
|
||||
)
|
||||
```
|
||||
|
||||
## The common Form model
|
||||
|
||||
Both root implementations and every focused subform implement `Form.Form`.
|
||||
They expose the schema pipeline as reactive Lenses and Views:
|
||||
|
||||
| Member | Meaning |
|
||||
| --- | --- |
|
||||
| `encodedValue` | Writable input-shaped state. |
|
||||
| `value` | The decoded value as an `Option`; `None` until decoding succeeds. |
|
||||
| `issues` | Standard Schema issues scoped to this form's path. |
|
||||
| `isValidating` | Whether schema decoding is currently running. |
|
||||
| `canCommit` | Whether the root has a valid value and is ready to commit. |
|
||||
| `isCommitting` | Whether a mutation or target write is in progress. |
|
||||
|
||||
The form model itself is Effect code. Effect View adds the React-facing hooks,
|
||||
including `Form.useInput` and `Form.useOptionalInput`, while your own component
|
||||
library remains responsible for rendering controls and layout.
|
||||
|
||||
## Example: local date input, UTC domain value
|
||||
|
||||
The encoded/decoded distinction is especially useful for dates. An HTML
|
||||
`datetime-local` input produces a wall-clock string such as
|
||||
`"2026-07-22T14:30"`. It contains no time-zone or UTC offset, while application
|
||||
state and APIs usually need an unambiguous UTC instant.
|
||||
|
||||
A schema can own that entire conversion. The following schema interprets the
|
||||
input in the current time zone and decodes it to `DateTime.Utc`:
|
||||
|
||||
```tsx title="src/DateTimeUtcFromZonedInput.ts"
|
||||
import { DateTime, Effect, Option, ParseResult, Schema } from "effect"
|
||||
|
||||
class DateTimeUtcFromZoned extends Schema.transformOrFail(
|
||||
Schema.DateTimeZonedFromSelf,
|
||||
Schema.DateTimeUtcFromSelf,
|
||||
{
|
||||
strict: true,
|
||||
decode: (input) => ParseResult.succeed(DateTime.toUtc(input)),
|
||||
encode: DateTime.setZoneCurrent,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class DateTimeUtcFromZonedInput extends Schema.transformOrFail(
|
||||
Schema.String,
|
||||
DateTimeUtcFromZoned,
|
||||
{
|
||||
strict: true,
|
||||
decode: (input, _options, ast) =>
|
||||
Effect.flatMap(DateTime.CurrentTimeZone, (timeZone) =>
|
||||
Option.match(
|
||||
DateTime.makeZoned(input, {
|
||||
timeZone,
|
||||
adjustForTimeZone: true,
|
||||
}),
|
||||
{
|
||||
onSome: ParseResult.succeed,
|
||||
onNone: () =>
|
||||
ParseResult.fail(
|
||||
new ParseResult.Type(
|
||||
ast,
|
||||
input,
|
||||
"Enter a valid date and time",
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
encode: (value) =>
|
||||
ParseResult.succeed(
|
||||
DateTime.formatIsoZoned(value).slice(0, 16),
|
||||
),
|
||||
},
|
||||
) {}
|
||||
```
|
||||
|
||||
Use it like any other field schema. The form and input work with a string, but
|
||||
the mutation receives UTC:
|
||||
|
||||
```tsx
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Component, Form, MutationForm, View } from "effect-view"
|
||||
import { DateTimeUtcFromZonedInput } from "./DateTimeUtcFromZonedInput"
|
||||
|
||||
const AppointmentSchema = Schema.Struct({
|
||||
startsAt: DateTimeUtcFromZonedInput,
|
||||
})
|
||||
|
||||
const AppointmentView = Component.make("Appointment")(function* () {
|
||||
const [form, startsAtField] = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
const form = yield* MutationForm.service({
|
||||
schema: AppointmentSchema,
|
||||
initialEncodedValue: { startsAt: "" },
|
||||
f: ([appointment]) =>
|
||||
Effect.log(
|
||||
`Saving ${DateTime.formatIso(appointment.startsAt)}`,
|
||||
),
|
||||
})
|
||||
|
||||
return [form, Form.focusObjectOn(form, "startsAt")] as const
|
||||
}),
|
||||
)
|
||||
|
||||
const startsAt = yield* Form.useInput(startsAtField)
|
||||
const [canCommit] = yield* View.useAll([form.canCommit])
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void runPromise(form.submit)
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startsAt.value}
|
||||
onChange={(event) =>
|
||||
startsAt.setValue(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<button disabled={!canCommit}>Save appointment</button>
|
||||
</form>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
The schema requires `DateTime.CurrentTimeZone`; provide
|
||||
`DateTime.layerCurrentZoneLocal` in the application runtime. If the current
|
||||
zone is `Europe/Paris`, for example, entering `2026-07-22T14:30` decodes to the
|
||||
UTC instant `2026-07-22T12:30:00.000Z`.
|
||||
|
||||
Encoding works in the other direction. A `LensForm` whose target contains that
|
||||
UTC instant presents `2026-07-22T14:30` to the local input. The component never
|
||||
manually parses dates, applies offsets, or maintains a second representation;
|
||||
the schema defines both directions and the form keeps them synchronized.
|
||||
@@ -0,0 +1,531 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Getting Started
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
|
||||
`effect-view` lets a React function component be described as an Effect
|
||||
program. Inside a component you can yield services, create scoped resources,
|
||||
subscribe to Effect-powered state, and turn Effects into React callbacks. At a
|
||||
React boundary, that description becomes a normal function component.
|
||||
|
||||
The core model has four pieces:
|
||||
|
||||
1. `ReactRuntime` builds the Effect services available to the UI.
|
||||
2. `Component.make` defines an Effect View component.
|
||||
3. `Component.withContext` converts an Effect View component into a normal
|
||||
React component at an application or router boundary.
|
||||
4. Effect View children are composed through their `.use` Effect.
|
||||
|
||||
## Install
|
||||
|
||||
For a web application, install Effect View with Effect 4 and React 19.2 or
|
||||
newer:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install effect-view effect@beta react react-dom
|
||||
```
|
||||
|
||||
```bash npm2yarn
|
||||
npm install --save-dev @types/react @types/react-dom
|
||||
```
|
||||
|
||||
Effect View is not tied to React DOM. For React Native or another renderer,
|
||||
install that renderer instead of `react-dom` and keep the rest of the setup the
|
||||
same.
|
||||
|
||||
## Set up hot reloading with Vite
|
||||
|
||||
Install the Effect View Vite plugin to preserve component state while editing
|
||||
Effect View components:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install --save-dev @effect-view/vite-plugin
|
||||
```
|
||||
|
||||
Add `effectView()` before the React plugin in your Vite configuration:
|
||||
|
||||
```ts title="vite.config.ts"
|
||||
import { effectView } from "@effect-view/vite-plugin"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
effectView(),
|
||||
react(),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
Vite is the only bundler supported for Effect View hot reloading at the moment.
|
||||
Support for other bundlers will follow.
|
||||
|
||||
## Create the runtime
|
||||
|
||||
`ReactRuntime` owns a managed Effect runtime. Define it at module scope from the
|
||||
layers needed by the UI:
|
||||
|
||||
```tsx title="src/runtime.ts"
|
||||
import { Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { ReactRuntime } from "effect-view"
|
||||
|
||||
const AppLive = Layer.empty.pipe(
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
```
|
||||
|
||||
Keep the runtime stable. Creating it during a React render would create new
|
||||
managed resources and a new React context on every render.
|
||||
|
||||
## Provide the runtime
|
||||
|
||||
Place `ReactRuntime.Provider` above every Effect View entrypoint. The provider
|
||||
builds the runtime layer, makes its Effect context available through React, and
|
||||
disposes the managed runtime when the provider unmounts.
|
||||
|
||||
Runtime construction can suspend, so give the provider a fallback:
|
||||
|
||||
```tsx title="src/main.tsx"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { ReactRuntime } from "effect-view"
|
||||
import { App } from "./App"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ReactRuntime.Provider
|
||||
runtime={runtime}
|
||||
fallback={<p>Starting application...</p>}
|
||||
>
|
||||
<App />
|
||||
</ReactRuntime.Provider>
|
||||
</StrictMode>,
|
||||
)
|
||||
```
|
||||
|
||||
With a router, keep the provider above the router provider:
|
||||
|
||||
```tsx
|
||||
<ReactRuntime.Provider runtime={runtime} fallback={<p>Starting...</p>}>
|
||||
<RouterProvider router={router} />
|
||||
</ReactRuntime.Provider>
|
||||
```
|
||||
|
||||
If runtime layer construction can fail, place an appropriate React error
|
||||
boundary above `ReactRuntime.Provider` as well.
|
||||
|
||||
## Write your first component
|
||||
|
||||
`Component.make` defines a component body using the same generator style as
|
||||
`Effect.gen` and `Effect.fn`. Providing a name creates a tracing span and also
|
||||
sets the React DevTools display name:
|
||||
|
||||
```tsx title="src/HelloView.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-view"
|
||||
|
||||
export const HelloView = Component.make("HelloView")(
|
||||
function* (props: { readonly name: string }) {
|
||||
const message = yield* Effect.succeed(`Hello, ${props.name}`)
|
||||
|
||||
return <h1>{message}</h1>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Use `Component.makeUntraced("HelloView")` when you want the display name but do
|
||||
not want an automatic tracing span.
|
||||
|
||||
`HelloView` is an Effect View component description, not yet a normal React
|
||||
component. Convert it at the point where plain React, a router, or a third-party
|
||||
library needs a function component:
|
||||
|
||||
```tsx title="src/Hello.tsx"
|
||||
import { Component } from "effect-view"
|
||||
import { HelloView } from "./HelloView"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
export const Hello = HelloView.pipe(
|
||||
Component.withContext(runtime.context),
|
||||
)
|
||||
```
|
||||
|
||||
It can now be rendered by ordinary React:
|
||||
|
||||
```tsx title="src/App.tsx"
|
||||
import { Hello } from "./Hello"
|
||||
|
||||
export function App() {
|
||||
return <Hello name="Effect" />
|
||||
}
|
||||
```
|
||||
|
||||
`Component.withContext` reads the context populated by the matching
|
||||
`ReactRuntime.Provider`. It does not build or provide the runtime by itself.
|
||||
|
||||
## Compose Effect View components
|
||||
|
||||
Inside another Effect View component, yield a child's `.use` Effect. This binds
|
||||
the child to the current Effect context and returns a component that can be used
|
||||
in JSX:
|
||||
|
||||
```tsx title="src/GreetingCardView.tsx"
|
||||
import { Component } from "effect-view"
|
||||
import { HelloView } from "./HelloView"
|
||||
|
||||
export const GreetingCardView = Component.make("GreetingCard")(
|
||||
function* () {
|
||||
const Hello = yield* HelloView.use
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Hello name="Effect" />
|
||||
<p>Both components use the same Effect context.</p>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Only apply `Component.withContext` when crossing from Effect View into plain
|
||||
React. Applying it to every nested component is unnecessary and makes it harder
|
||||
to provide local services to a subtree.
|
||||
|
||||
## Synchronous and asynchronous components
|
||||
|
||||
A regular Effect View component runs its body during React render. Effects
|
||||
executed directly by that body—including setup passed to `useOnMount`,
|
||||
`useOnChange`, or `useLayer`—must complete synchronously every time they run.
|
||||
Yielding services and reading synchronous state is fine; sleeping, fetching, or
|
||||
awaiting a promise is not.
|
||||
|
||||
Choose the integration that matches the asynchronous work:
|
||||
|
||||
- Make the component asynchronous with [`Async.async`](./async) when it must
|
||||
wait for a one-off asynchronous Effect before producing JSX. The component
|
||||
suspends while it waits.
|
||||
- Use [Query](./query) for server reads that need caching, sharing, refresh, or
|
||||
invalidation.
|
||||
- Use [Mutation](./mutation) for user-triggered writes with observable pending
|
||||
and error state.
|
||||
- Use `useRunPromise` or `useCallbackPromise` for asynchronous event work that
|
||||
does not need Mutation state.
|
||||
- Use a post-commit hook with a scoped fiber for subscriptions or background
|
||||
work tied to the component lifecycle.
|
||||
|
||||
## Use Effect services
|
||||
|
||||
Components can yield Effect service tags directly. Their required service type
|
||||
becomes part of the component type, so the final runtime or a local layer must
|
||||
provide it:
|
||||
|
||||
```tsx title="src/GreetingService.ts"
|
||||
import { Context, Layer } from "effect"
|
||||
|
||||
export class GreetingService extends Context.Service<
|
||||
GreetingService,
|
||||
{ readonly greet: (name: string) => string }
|
||||
>()("GreetingService") {
|
||||
static readonly layer = Layer.succeed(GreetingService, {
|
||||
greet: (name) => `Hello, ${name}`,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```tsx title="src/GreetingView.tsx"
|
||||
import { Component } from "effect-view"
|
||||
import { GreetingService } from "./GreetingService"
|
||||
|
||||
export const GreetingView = Component.make("Greeting")(
|
||||
function* (props: { readonly name: string }) {
|
||||
const greeting = yield* GreetingService
|
||||
|
||||
return <p>{greeting.greet(props.name)}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Add `GreetingService.layer` to the application runtime, or provide it only to a
|
||||
subtree as shown later on this page.
|
||||
|
||||
Effect service instances are reactive at component boundaries. If the supplied
|
||||
context changes to contain a different service instance, dependent Effect View
|
||||
components are recreated so they read the new environment and restart their
|
||||
scoped lifecycle.
|
||||
|
||||
## Understand lifecycle hooks
|
||||
|
||||
Effect View hooks are still React hooks internally. Call them unconditionally
|
||||
at the top level of the component body, in a consistent order, and never inside
|
||||
branches, loops, event handlers, or nested callbacks.
|
||||
|
||||
### The component root scope
|
||||
|
||||
Every rendered Effect View component gets a root `Scope.Scope`. Effect View
|
||||
creates it when the component instance is rendered, provides it to the entire
|
||||
component body, and closes it when that React component unmounts.
|
||||
|
||||
Effects yielded directly from the body therefore see the root scope. This also
|
||||
means that `Effect.addFinalizer`, `Effect.acquireRelease`, and
|
||||
`Effect.forkScoped` can be used naturally inside component setup:
|
||||
|
||||
```tsx
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-view"
|
||||
|
||||
const ResourceView = Component.make("Resource")(function* () {
|
||||
const resource = yield* Component.useOnMount(() =>
|
||||
Effect.acquireRelease(
|
||||
openResource,
|
||||
(resource) => closeResource(resource),
|
||||
),
|
||||
)
|
||||
|
||||
return <p>{resource.name}</p>
|
||||
})
|
||||
```
|
||||
|
||||
`useOnMount` does **not** create or provide another scope. It runs its Effect
|
||||
with the context already available in the component body, so the resource above
|
||||
is owned by the component's root scope.
|
||||
|
||||
`useOnChange`, `useReactEffect`, and `useReactLayoutEffect` each manage their
|
||||
own resources. When their dependencies change, those resources are cleaned up
|
||||
and recreated while the rest of the component remains active.
|
||||
|
||||
The main lifecycle choices are:
|
||||
|
||||
| Hook | What it does | Scope seen by setup | Scope closes when |
|
||||
| --- | --- | --- | --- |
|
||||
| Component body | Produces the component's rendered value | Component root scope | The component unmounts |
|
||||
| `useOnMount` | Computes and caches a value for the component instance | Component root scope | The component unmounts |
|
||||
| `useOnChange` | Recomputes a cached value when dependencies change | A new dependency scope | Dependencies change or the component unmounts |
|
||||
| `useReactEffect` | Runs a post-commit side effect | A new effect scope | Dependencies change or the component unmounts |
|
||||
| `useReactLayoutEffect` | Runs a layout effect before the browser paints | A new layout-effect scope | Dependencies change or the component unmounts |
|
||||
| `useLayer` | Builds and provides a layer context | A new scope tied to the current layer reference | The layer reference changes or the component unmounts |
|
||||
|
||||
`useRunSync` and `useRunPromise` do not create a new scope. The runner they
|
||||
return includes the component root scope in its context by default.
|
||||
`useCallbackSync` and `useCallbackPromise` similarly capture the component
|
||||
context required by their Effect.
|
||||
|
||||
### useOnMount
|
||||
|
||||
`Component.useOnMount` computes a value during the initial render and caches it
|
||||
for later renders. It uses the component root scope, so its resources live
|
||||
until the component unmounts.
|
||||
|
||||
```tsx title="src/LocalStateView.tsx"
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-view"
|
||||
|
||||
export const LocalStateView = Component.make("LocalState")(
|
||||
function* () {
|
||||
const count = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.log("LocalState disposed"),
|
||||
)
|
||||
|
||||
return Lens.fromSubscriptionRef(
|
||||
yield* SubscriptionRef.make(0),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const [value] = yield* View.useAll([count])
|
||||
return <p>Count: {value}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
In a regular component, the setup Effect must be synchronous because it runs
|
||||
during render. In a component enhanced with `Async.async`, it may be
|
||||
asynchronous and will suspend the component.
|
||||
|
||||
### useOnChange
|
||||
|
||||
`Component.useOnChange` computes and caches a value, then recomputes it when its
|
||||
dependencies change. Each dependency set gets its own scope, which closes when
|
||||
the dependencies change or the component unmounts.
|
||||
|
||||
```tsx
|
||||
const label = yield* Component.useOnChange(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.log(`Stopped viewing ${props.userId}`),
|
||||
)
|
||||
|
||||
return `Viewing user ${props.userId}`
|
||||
}),
|
||||
[props.userId],
|
||||
)
|
||||
```
|
||||
|
||||
Dependency arrays follow normal React semantics. Include every reactive value
|
||||
read by the setup Effect.
|
||||
|
||||
### useReactEffect and useReactLayoutEffect
|
||||
|
||||
`Component.useReactEffect` runs side effects after React commits, while
|
||||
`Component.useReactLayoutEffect` runs before the browser paints. Each hook owns
|
||||
a scope that closes when its dependencies change or the component unmounts.
|
||||
|
||||
Setup must complete synchronously, but it can fork asynchronous work into the
|
||||
hook scope:
|
||||
|
||||
```tsx
|
||||
yield* Component.useReactEffect(
|
||||
() => Effect.forkScoped(listenForNotifications(props.userId)),
|
||||
[props.userId],
|
||||
)
|
||||
```
|
||||
|
||||
React Strict Mode may intentionally repeat development-only render and effect
|
||||
setup. Initializers and resource acquisition should therefore be safe to run
|
||||
more than once; production retains the normal component lifetime semantics.
|
||||
|
||||
## Run Effects from event handlers
|
||||
|
||||
React event handlers are plain functions. Use Effect View runners to execute an
|
||||
Effect with the component's current context and scope:
|
||||
|
||||
```tsx
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
return (
|
||||
<button onClick={() => void runPromise(saveUser(user))}>
|
||||
Save
|
||||
</button>
|
||||
)
|
||||
```
|
||||
|
||||
Use `Component.useRunSync` only for Effects known to complete synchronously.
|
||||
Use `Component.useRunPromise` for Effects that may suspend, sleep, fetch, or
|
||||
otherwise continue asynchronously.
|
||||
|
||||
Both runners provide `Scope.Scope` by default, so they can run scoped Effects
|
||||
without a type argument. When an Effect also requires application services,
|
||||
declare the complete runner context explicitly:
|
||||
|
||||
```tsx
|
||||
import { Scope } from "effect"
|
||||
|
||||
const runPromise = yield* Component.useRunPromise<
|
||||
Scope.Scope | UserRepository
|
||||
>()
|
||||
```
|
||||
|
||||
The resulting runner accepts Effects that require the component scope,
|
||||
`UserRepository`, or both.
|
||||
|
||||
When a callback is passed to a memoized child or used as a dependency, use the
|
||||
callback variants to preserve its identity:
|
||||
|
||||
```tsx
|
||||
const save = yield* Component.useCallbackPromise(
|
||||
(nextUser: User) => saveUser(nextUser),
|
||||
[saveUser],
|
||||
)
|
||||
|
||||
return <SaveButton onSave={() => void save(user)} />
|
||||
```
|
||||
|
||||
`useCallbackSync` and `useCallbackPromise` follow the same dependency rules as
|
||||
`React.useCallback`, while also supplying the Effect context when invoked.
|
||||
|
||||
## Use React normally
|
||||
|
||||
Effect View components can use regular React hooks, refs, context, event
|
||||
handlers, and JSX composition:
|
||||
|
||||
```tsx
|
||||
import { Component } from "effect-view"
|
||||
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) => count + 1)}
|
||||
>
|
||||
Count: {count}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Prefer regular React state for simple, component-local UI concerns. Use
|
||||
`Lens`/`View` when state needs Effect integration, subscriptions, focusing, or
|
||||
sharing. The [State Management guide](./state-management) covers that model.
|
||||
|
||||
## Provide services to a subtree
|
||||
|
||||
Use `Component.useLayer` when only one Effect View subtree needs extra
|
||||
services. It builds the layer in a scope and returns the resulting Effect
|
||||
context:
|
||||
|
||||
```tsx title="src/GreetingPageView.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-view"
|
||||
import { GreetingView } from "./GreetingView"
|
||||
import { GreetingService } from "./GreetingService"
|
||||
|
||||
export const GreetingPageView = Component.make("GreetingPage")(
|
||||
function* () {
|
||||
const context = yield* Component.useLayer(GreetingService.layer)
|
||||
const Greeting = yield* GreetingView.use.pipe(
|
||||
Effect.provide(context),
|
||||
)
|
||||
|
||||
return <Greeting name="Effect" />
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Keep the layer reference stable. Define static layers outside the component or
|
||||
memoize a layer that depends on props with `React.useMemo`; a new layer object
|
||||
causes reconstruction and scoped cleanup.
|
||||
|
||||
Layer construction runs during render through `useOnChange`. A layer with
|
||||
asynchronous acquisition requires the component to be enhanced with
|
||||
`Async.async`. Resources built by the layer are released when the owning
|
||||
component unmounts or the layer reference changes.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- Effect View hooks obey React's Rules of Hooks even though they are called
|
||||
with `yield*`.
|
||||
- Do not yield an asynchronous Effect from a regular component body. Use
|
||||
`Async.async`, Query, a Mutation callback, or a post-commit scoped fiber.
|
||||
- `Component.withContext` needs a matching `ReactRuntime.Provider` above it.
|
||||
- Apply `withContext` at React boundaries, not between Effect View components.
|
||||
- Keep runtimes and layers stable instead of creating them on every render.
|
||||
- Use `useRunPromise` rather than `useRunSync` for asynchronous event work.
|
||||
- Suspense fallbacks handle waiting; React error boundaries handle failed
|
||||
component Effects.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [State Management](./state-management) explains `Lens`, `View`, local state,
|
||||
and focused state.
|
||||
- [Query](./query) adds reactive keys, caching, refresh, and invalidation to
|
||||
Effect-based server reads.
|
||||
- [Mutation](./mutation) models user-triggered Effect operations and their
|
||||
`AsyncResult` state.
|
||||
- [Forms](./forms) builds schema-driven editable state with `MutationForm` and
|
||||
`LensForm`.
|
||||
|
||||
The repeatable pattern is small: provide one runtime, define components as
|
||||
Effect programs, cross into plain React with `Component.withContext`, and use
|
||||
`.use` everywhere inside the Effect View tree.
|
||||
@@ -0,0 +1,275 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: Mutation
|
||||
---
|
||||
|
||||
# Mutation
|
||||
|
||||
The Mutation module is Effect View's counterpart to TanStack Query mutations.
|
||||
It models user-triggered asynchronous work such as saving a profile, deleting a
|
||||
record, uploading a file, or sending a command to an API.
|
||||
|
||||
The familiar concepts are present, but the operation itself is an Effect:
|
||||
|
||||
| TanStack Mutation concept | Effect View equivalent |
|
||||
| --- | --- |
|
||||
| Mutation variables | The input key `K` |
|
||||
| `mutationFn` | `f: (key: K) => Effect<A, E, R>` |
|
||||
| Mutation result | `mutation.state`, a `View<AsyncResult<A, E>>` |
|
||||
| `isPending` | `result.waiting` |
|
||||
| `mutateAsync` | `mutation.mutate(key)` |
|
||||
| Start without awaiting | `mutation.mutateView(key)` |
|
||||
|
||||
Unlike a Query, a Mutation has no cache, reactive key, or automatic execution.
|
||||
It runs only when `mutate` or `mutateView` is called. This makes it suitable for
|
||||
commands, while Query remains the model for cached server state.
|
||||
|
||||
## Create a mutation
|
||||
|
||||
Create a mutation in a component scope with `Mutation.make`. Its function can
|
||||
be any Effect, including one that requires services from the application
|
||||
runtime:
|
||||
|
||||
`Mutation.make` is an Effect constructor, not a hook. Create each mutation
|
||||
instance once and keep it stable instead of calling `Mutation.make` again on
|
||||
every render. Use `Component.useOnMount` for a component-owned mutation or
|
||||
create it in an Effect service when it should be shared.
|
||||
|
||||
```tsx
|
||||
import { Effect } from "effect"
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
import { Component, Mutation, View } from "effect-view"
|
||||
import { sendInvite } from "./api"
|
||||
|
||||
interface InviteInput {
|
||||
readonly email: string
|
||||
readonly role: "member" | "admin"
|
||||
}
|
||||
|
||||
const InviteButtonView = Component.make("InviteButton")(
|
||||
function* (props: { readonly email: string }) {
|
||||
const mutation = yield* Component.useOnMount(() =>
|
||||
Mutation.make({
|
||||
f: (input: InviteInput) => sendInvite(input),
|
||||
}),
|
||||
)
|
||||
|
||||
const [result] = yield* View.useAll([mutation.state])
|
||||
const runSync = yield* Component.useRunSync()
|
||||
|
||||
return (
|
||||
<section>
|
||||
<button
|
||||
disabled={result.waiting}
|
||||
onClick={() =>
|
||||
runSync(mutation.mutateView({
|
||||
email: props.email,
|
||||
role: "member",
|
||||
}))
|
||||
}
|
||||
>
|
||||
{result.waiting ? "Sending..." : "Send invite"}
|
||||
</button>
|
||||
|
||||
{AsyncResult.match(result, {
|
||||
onInitial: () => null,
|
||||
onFailure: ({ cause }) => (
|
||||
<p>Could not send invite: {cause.toString()}</p>
|
||||
),
|
||||
onSuccess: ({ value }) => (
|
||||
<p>Invite sent to {value.email}</p>
|
||||
),
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
`Mutation.make` captures the current Effect context. If `sendInvite` requires
|
||||
an HTTP client, authentication service, tracer, or another service, provide it
|
||||
to the runtime or component layer before creating the mutation. The click
|
||||
handler does not need to reconstruct those dependencies.
|
||||
|
||||
The mutation also belongs to the creation scope. Any mutation fibers still
|
||||
running when that scope closes are interrupted automatically.
|
||||
|
||||
## Understand AsyncResult
|
||||
|
||||
`mutation.state` starts as `Initial` with `waiting: false`. Starting a mutation
|
||||
changes it to a waiting state, then publishes either `Success` or `Failure`:
|
||||
|
||||
```tsx
|
||||
const [result] = yield* View.useAll([mutation.state])
|
||||
|
||||
return AsyncResult.match(result, {
|
||||
onInitial: ({ waiting }) =>
|
||||
waiting ? <p>Starting...</p> : <p>Ready.</p>,
|
||||
onFailure: ({ cause, previousSuccess, waiting }) => (
|
||||
<div>
|
||||
<p>{cause.toString()}</p>
|
||||
{previousSuccess._tag === "Some" && (
|
||||
<p>Last saved value: {previousSuccess.value.value.name}</p>
|
||||
)}
|
||||
{waiting && <p>Trying again...</p>}
|
||||
</div>
|
||||
),
|
||||
onSuccess: ({ value, waiting }) => (
|
||||
<div>
|
||||
<p>Saved {value.name}</p>
|
||||
{waiting && <p>Saving a newer value...</p>}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
The `waiting` flag is independent from the result tag. After one successful
|
||||
call, starting another keeps the successful value and sets `waiting: true`.
|
||||
If that next call fails, the failure can retain the earlier success in
|
||||
`previousSuccess`. Components can therefore keep useful feedback visible
|
||||
during retries or repeated submissions.
|
||||
|
||||
A failure contains an Effect `Cause<E>`, not only `E`. Typed failures, defects,
|
||||
and interruption information remain available for logging or presentation.
|
||||
|
||||
## Choose mutate or mutateView
|
||||
|
||||
Both methods start the same mutation effect. They differ in what the caller
|
||||
waits for:
|
||||
|
||||
| Method | Return value | Best suited to |
|
||||
| --- | --- | --- |
|
||||
| `mutate(key)` | The final `Success` or `Failure` | Effect workflows that need the outcome. |
|
||||
| `mutateView(key)` | A live `View<AsyncResult<A, E>>` | UI callbacks that should return after starting the work. |
|
||||
|
||||
Use `mutate` with an asynchronous component runner when later logic depends on
|
||||
the final result:
|
||||
|
||||
```tsx
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
const save = () =>
|
||||
void runPromise(
|
||||
Effect.gen(function* () {
|
||||
const result = yield* mutation.mutate(input)
|
||||
|
||||
if (AsyncResult.isSuccess(result)) {
|
||||
yield* Effect.log(`Saved record ${result.value.id}`)
|
||||
}
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
The mutation Effect does not fail with `E`. It captures the operation's `Exit`
|
||||
and returns a final `AsyncResult.Success` or `AsyncResult.Failure`. Inspect or
|
||||
match that value when control flow depends on the outcome.
|
||||
|
||||
Use `mutateView` when the UI only needs to start the operation and react to its
|
||||
state:
|
||||
|
||||
```tsx
|
||||
const runSync = yield* Component.useRunSync()
|
||||
|
||||
const startSave = () => {
|
||||
const state = runSync(mutation.mutateView(input))
|
||||
// `state` is a View for this specific call.
|
||||
}
|
||||
```
|
||||
|
||||
`mutateView` starts the scoped work and returns immediately with a per-call
|
||||
View. The shared `mutation.state` is also updated as that call progresses.
|
||||
|
||||
## Track the latest call
|
||||
|
||||
In addition to `state`, a mutation exposes reactive metadata:
|
||||
|
||||
| Member | Meaning |
|
||||
| --- | --- |
|
||||
| `state` | The latest mutation state published to the shared View. |
|
||||
| `latestKey` | The most recently supplied input as an `Option<K>`. |
|
||||
| `latestFinalResult` | The latest completed success or failure as an `Option`. |
|
||||
| `fiber` | The most recently started mutation fiber as an `Option`. |
|
||||
|
||||
Subscribe to any of them with `View.useAll`:
|
||||
|
||||
```tsx
|
||||
const [result, latestInput, latestFinal] = yield* View.useAll([
|
||||
mutation.state,
|
||||
mutation.latestKey,
|
||||
mutation.latestFinalResult,
|
||||
])
|
||||
```
|
||||
|
||||
The input is called a `key` in the generic API, but it is equivalent to
|
||||
mutation variables in TanStack Query. It can be a primitive, tuple, struct, or
|
||||
any other value accepted by the mutation function.
|
||||
|
||||
## Concurrent mutations
|
||||
|
||||
Starting a mutation does not automatically interrupt an earlier mutation.
|
||||
Calls may overlap, and each call has its own state View. This is useful for
|
||||
independent operations such as uploading several files.
|
||||
|
||||
When calls overlap, `mutation.state` reflects updates published by all calls;
|
||||
the last update to arrive wins. Use the View returned by `mutateView` when each
|
||||
concurrent operation needs its own progress indicator:
|
||||
|
||||
```tsx
|
||||
const upload = (file: File) =>
|
||||
Effect.gen(function* () {
|
||||
const uploadState = yield* mutation.mutateView(file)
|
||||
// Store or pass `uploadState` to the row rendering this file.
|
||||
return uploadState
|
||||
})
|
||||
```
|
||||
|
||||
For a single submit button, disabling it while `mutation.state.waiting` is
|
||||
usually enough to prevent accidental overlap. For “latest request wins”
|
||||
behavior, model that policy explicitly or use Query when the operation is
|
||||
actually a reactive read.
|
||||
|
||||
## Update queries after a mutation
|
||||
|
||||
Mutations do not invalidate Query caches automatically. Compose invalidation
|
||||
with the mutation result so the relationship remains explicit and typed:
|
||||
|
||||
```tsx
|
||||
const saveAndRefresh = (input: UpdatePostInput) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* updatePost.mutate(input)
|
||||
|
||||
if (AsyncResult.isSuccess(result)) {
|
||||
yield* posts.invalidateCacheEntry(["post", result.value.id] as const)
|
||||
yield* posts.refreshView
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
```
|
||||
|
||||
This is the Effect equivalent of an `onSuccess` callback that invalidates a
|
||||
TanStack Query. Because it is ordinary Effect composition, the workflow can
|
||||
also include tracing, transactions, retries, notifications, or parallel cache
|
||||
updates without introducing a separate callback API.
|
||||
|
||||
Remember that Query invalidation removes cached data but does not refetch by
|
||||
itself. Follow it with `refreshView` when the current screen should update
|
||||
immediately.
|
||||
|
||||
## The Effect touch
|
||||
|
||||
Mutation follows the ergonomics of TanStack mutations while retaining Effect's
|
||||
execution model:
|
||||
|
||||
- The mutation function has the full `Effect<A, E, R>` type.
|
||||
- Required services are captured from the creation context.
|
||||
- Failures are represented as `Cause<E>`, including defects and interruption.
|
||||
- Fibers are scoped, so component unmounting cleans up in-flight operations.
|
||||
- `mutate` composes directly inside larger Effect workflows.
|
||||
- `mutateView` exposes call-specific progress as a View for React or Effect
|
||||
consumers.
|
||||
- Retry schedules, timeouts, tracing, logging, metrics, schema validation, and
|
||||
concurrency controls can be applied with normal Effect operators.
|
||||
|
||||
Mutation is therefore a small bridge: TanStack-style mutation state on one
|
||||
side, and an ordinary, typed Effect program on the other.
|
||||
@@ -0,0 +1,303 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: Query
|
||||
---
|
||||
|
||||
# Query
|
||||
|
||||
The Query module is Effect View's take on TanStack Query. It provides the same
|
||||
kind of server-state workflow—reactive query keys, cached results, stale times,
|
||||
background refreshes, window-focus refetching, and cache invalidation—but the
|
||||
query function is an `Effect` and the observable state is a `View`.
|
||||
|
||||
If you already know TanStack Query, the main concepts translate directly:
|
||||
|
||||
| TanStack Query concept | Effect View equivalent |
|
||||
| --- | --- |
|
||||
| `QueryClient` | The `QueryClient` Effect service |
|
||||
| `queryKey` | A reactive key supplied as a `View<K>` |
|
||||
| `queryFn` | `f: (key: K) => Effect<A, E, R>` |
|
||||
| `useQuery` result | `query.state`, a `View<QueryState<K, A, E>>` |
|
||||
| `isFetching` | `result.waiting` |
|
||||
| `refetch` | `query.refresh` or `query.refreshView` |
|
||||
| `invalidateQueries` | `query.invalidateCache` or `invalidateCacheEntry` |
|
||||
|
||||
The goal is familiar query behavior without leaving Effect's model. Query
|
||||
functions keep their typed success, error, and service channels. They can use
|
||||
services from the runtime, be composed with schema decoding and retry policies,
|
||||
and are interrupted automatically when their scope ends or a new key supersedes
|
||||
the current request.
|
||||
|
||||
## Provide a QueryClient
|
||||
|
||||
Queries share successful results through a `QueryClient`. Add its layer to the
|
||||
application runtime once, alongside the services used by your query effects:
|
||||
|
||||
```tsx title="src/runtime.ts"
|
||||
import { Layer } from "effect"
|
||||
import { QueryClient, ReactRuntime } from "effect-view"
|
||||
|
||||
const AppLive = Layer.empty.pipe(
|
||||
Layer.provideMerge(QueryClient.layer({
|
||||
defaultStaleTime: "30 seconds",
|
||||
defaultRefreshOnWindowFocus: true,
|
||||
cacheGcTime: "5 minutes",
|
||||
})),
|
||||
)
|
||||
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
```
|
||||
|
||||
The client owns the cache and its cleanup lifecycle. Individual queries can
|
||||
override `staleTime` and `refreshOnWindowFocus`; otherwise they inherit the
|
||||
client defaults. Window-focus refresh also requires the optional
|
||||
`@effect/platform-browser` package, as described under
|
||||
[Staleness and cache lifetime](#staleness-and-cache-lifetime).
|
||||
|
||||
## Create a reactive query
|
||||
|
||||
A query is driven by a `View` rather than by a value read during one React
|
||||
render. Whenever that key changes, `Query.service` checks the cache and starts
|
||||
the query effect when necessary.
|
||||
|
||||
`Query.service` is an Effect constructor, not a hook. Create each query instance
|
||||
once and keep it stable. In a component, the usual place is
|
||||
`Component.useOnMount`; a query shared by multiple components can instead be
|
||||
owned by an Effect service. Change the existing query's reactive key rather
|
||||
than reconstructing the query during render.
|
||||
|
||||
```tsx
|
||||
import { Effect, Schema, SubscriptionRef } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Component, Lens, Query, View } from "effect-view"
|
||||
|
||||
const Post = Schema.Struct({
|
||||
id: Schema.Int,
|
||||
title: Schema.String,
|
||||
body: Schema.String,
|
||||
})
|
||||
|
||||
const PostView = Component.make("Post")(function* () {
|
||||
const [postId, query] = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
const key = Lens.fromSubscriptionRef(
|
||||
yield* SubscriptionRef.make(["post", 1 as number] as const),
|
||||
)
|
||||
|
||||
const query = yield* Query.service({
|
||||
key,
|
||||
staleTime: "1 minute",
|
||||
f: ([, id]) =>
|
||||
HttpClient.HttpClient.pipe(
|
||||
Effect.andThen((client) =>
|
||||
client.get(`https://example.com/posts/${id}`),
|
||||
),
|
||||
Effect.andThen((response) => response.json),
|
||||
Effect.andThen(Schema.decodeUnknownEffect(Post)),
|
||||
),
|
||||
})
|
||||
|
||||
return [Lens.focusTupleAt(key, 1), query] as const
|
||||
}),
|
||||
)
|
||||
|
||||
const [state] = yield* View.useAll([query.state])
|
||||
const [id, setId] = yield* Lens.useState(postId)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<select
|
||||
value={id}
|
||||
onChange={(event) => setId(Number(event.currentTarget.value))}
|
||||
>
|
||||
<option value={1}>Post 1</option>
|
||||
<option value={2}>Post 2</option>
|
||||
</select>
|
||||
<pre>{state.result._tag}</pre>
|
||||
</section>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
The tuple key plays the same role as `['post', id]` in TanStack Query. Keys use
|
||||
Effect equality by default, so structurally equal Effect data types work well
|
||||
as query keys. Supply `keyEquivalence` when the key needs different equality
|
||||
semantics.
|
||||
|
||||
`Query.service` starts watching its key in the current scope. The
|
||||
`Component.useOnMount` call above keeps both the query instance and its query
|
||||
function identity stable for the component's lifetime.
|
||||
|
||||
## Render AsyncResult
|
||||
|
||||
`query.state` contains both the current key and an `AsyncResult`:
|
||||
|
||||
```ts
|
||||
interface QueryState<K, A, E> {
|
||||
readonly key: K
|
||||
readonly result: AsyncResult.AsyncResult<A, E>
|
||||
}
|
||||
```
|
||||
|
||||
Subscribe with `View.useAll`, then match the result explicitly:
|
||||
|
||||
```tsx
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
|
||||
const [state] = yield* View.useAll([query.state])
|
||||
|
||||
return AsyncResult.match(state.result, {
|
||||
onInitial: ({ waiting }) =>
|
||||
waiting ? <p>Loading...</p> : <p>Not loaded.</p>,
|
||||
onFailure: ({ cause, previousSuccess, waiting }) => (
|
||||
<section>
|
||||
<p>Request failed: {cause.toString()}</p>
|
||||
{previousSuccess._tag === "Some" && (
|
||||
<p>Last post: {previousSuccess.value.value.title}</p>
|
||||
)}
|
||||
{waiting && <p>Trying again...</p>}
|
||||
</section>
|
||||
),
|
||||
onSuccess: ({ value, waiting }) => (
|
||||
<article>
|
||||
{waiting && <small>Refreshing...</small>}
|
||||
<h2>{value.title}</h2>
|
||||
<p>{value.body}</p>
|
||||
</article>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
The `waiting` flag separates “do I have a result?” from “is work currently in
|
||||
flight?”. During a background refresh, a successful result remains successful
|
||||
and keeps its value while `waiting` becomes `true`. A failed refresh can retain
|
||||
its `previousSuccess`. This avoids replacing useful content with an empty
|
||||
loading screen every time data is refreshed.
|
||||
|
||||
Failures contain an Effect `Cause<E>`, preserving typed failures, defects, and
|
||||
interruption information instead of flattening everything into an untyped
|
||||
exception.
|
||||
|
||||
## Change the query key
|
||||
|
||||
The key is reactive state, so changing it is enough to fetch the corresponding
|
||||
resource. There is no separate dependency array to keep synchronized:
|
||||
|
||||
```tsx
|
||||
const [id, setId] = yield* Lens.useState(postId)
|
||||
|
||||
return (
|
||||
<label>
|
||||
Post
|
||||
<select
|
||||
value={id}
|
||||
onChange={(event) => setId(Number(event.currentTarget.value))}
|
||||
>
|
||||
<option value={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
<option value={3}>3</option>
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
```
|
||||
|
||||
When the key changes, the previous in-flight request is interrupted. The query
|
||||
then reuses a fresh cached success or starts the effect for the new key.
|
||||
|
||||
## Refresh and invalidate
|
||||
|
||||
Refresh resolves the latest key again. A fresh cached result can satisfy it
|
||||
immediately; a stale or missing result runs the query effect while preserving
|
||||
previous data as background state. Invalidation removes cached data, so the
|
||||
next fetch for that key must run the effect again.
|
||||
|
||||
```tsx
|
||||
const runSync = yield* Component.useRunSync()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => runSync(query.refreshView)}>
|
||||
Refresh current post
|
||||
</button>
|
||||
<button onClick={() => runSync(query.invalidateCacheEntry(["post", id] as const))}>
|
||||
Invalidate current post
|
||||
</button>
|
||||
<button onClick={() => runSync(query.invalidateCache)}>
|
||||
Invalidate all posts from this query
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
```
|
||||
|
||||
The methods come in two styles:
|
||||
|
||||
| Method | Behavior |
|
||||
| --- | --- |
|
||||
| `fetch(key)` | Fetch a specific key and wait for its final state. |
|
||||
| `fetchView(key)` | Start fetching and immediately return a live state `View`. |
|
||||
| `refresh` | Resolve the current key again and wait for its final state. |
|
||||
| `refreshView` | Resolve the current key again and immediately return a live state `View`. |
|
||||
| `invalidateCacheEntry(key)` | Remove the cached success for one key. |
|
||||
| `invalidateCache` | Remove every cached success associated with this query function. |
|
||||
|
||||
The `*View` variants are convenient in synchronous UI callbacks: they return
|
||||
after the scoped request has started, while the returned View and `query.state`
|
||||
continue to publish progress. The non-View variants are useful in Effect
|
||||
workflows that need to wait for the final success or failure state.
|
||||
|
||||
Invalidating does not itself refetch. Follow it with `refreshView`, change the
|
||||
key, or allow a later fetch to repopulate the cache.
|
||||
|
||||
## Staleness and cache lifetime
|
||||
|
||||
`staleTime` controls how long a successful result can satisfy a fetch without
|
||||
running the effect again. A stale entry remains available as previous data
|
||||
while the query refreshes it.
|
||||
|
||||
`cacheGcTime` is configured on `QueryClient`. Entries that have not been
|
||||
accessed are eventually removed after their stale period plus the configured
|
||||
garbage-collection time.
|
||||
|
||||
By default, the client enables refresh on browser window focus. Set it globally
|
||||
or override it for one query:
|
||||
|
||||
```tsx
|
||||
const query = yield* Query.service({
|
||||
key,
|
||||
f: loadPost,
|
||||
staleTime: "10 seconds",
|
||||
refreshOnWindowFocus: false,
|
||||
})
|
||||
```
|
||||
|
||||
Window-focus refresh depends on the optional `@effect/platform-browser`
|
||||
integration. Install it in browser applications that use this behavior:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install @effect/platform-browser@beta
|
||||
```
|
||||
|
||||
When the package is available, focusing the window resolves the current key
|
||||
again, subject to the same freshness check. Without it,
|
||||
`refreshOnWindowFocus` has no effect; the rest of the Query API continues to
|
||||
work normally. The integration is also ignored in non-browser environments.
|
||||
|
||||
## The Effect touch
|
||||
|
||||
The API emulates TanStack Query's server-state ergonomics, but query execution
|
||||
remains ordinary Effect code:
|
||||
|
||||
- `f` has the full `Effect<A, E, R>` type, including required services.
|
||||
- The query captures its Effect context when it is created, so callbacks do not
|
||||
need to manually reconstruct dependencies.
|
||||
- Effects can use `Schema`, retry schedules, tracing, logging, metrics,
|
||||
cancellation, and any other Effect operator before becoming query state.
|
||||
- Request fibers belong to the creation scope and are interrupted on unmount,
|
||||
key replacement, or scope closure.
|
||||
- Results are `View`s, so the same state can drive React through `View.useAll`
|
||||
or participate in Effect streams and application logic outside React.
|
||||
- Failures retain `Cause<E>` rather than losing Effect's error model.
|
||||
|
||||
`Query` is therefore best understood as a TanStack Query-shaped coordinator
|
||||
for Effect programs: it handles when and whether to run them, while Effect
|
||||
continues to describe what the work requires and how it behaves.
|
||||
@@ -0,0 +1,297 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: State Management
|
||||
---
|
||||
|
||||
# State Management
|
||||
|
||||
`Lens` is the main type used for state management in `effect-view`.
|
||||
|
||||
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.
|
||||
|
||||
`effect-view` re-exports the `Lens` and `View` modules from
|
||||
[`effect-lens`](https://www.npmjs.com/package/effect-lens/v/beta) for convenience. The
|
||||
core data model and transformation APIs belong to `effect-lens`, so check the
|
||||
[`effect-lens` documentation](https://www.npmjs.com/package/effect-lens/v/beta) for the full Lens/View API.
|
||||
|
||||
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
|
||||
`View.useAll`.
|
||||
|
||||
`View` is the read-only side of this model. Every Lens is also a View.
|
||||
|
||||
## Where To Store State
|
||||
|
||||
State can live pretty much anywhere as a `Lens` or `View`: 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/View
|
||||
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 { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-view"
|
||||
|
||||
class CounterState extends Context.Service<
|
||||
CounterState,
|
||||
{ readonly count: Lens.Lens<number> }
|
||||
>()("CounterState") {
|
||||
static readonly layer = Layer.effect(
|
||||
CounterState,
|
||||
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* View.useAll([state.count])
|
||||
|
||||
return <p>Count: {count}</p>
|
||||
})
|
||||
```
|
||||
|
||||
If state belongs to a single Effect View 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, View } from "effect-view"
|
||||
|
||||
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* View.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.
|
||||
|
||||
## View.useAll
|
||||
|
||||
A `View<A>` is reactive state with a current value and a stream of changes. Use
|
||||
`View.useAll` whenever a component needs to bind View values into render output.
|
||||
|
||||
`Lens` is a `View`, so this is also the default way to read Lens values
|
||||
from a component.
|
||||
|
||||
```tsx
|
||||
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-view"
|
||||
|
||||
class CounterState extends Context.Service<
|
||||
CounterState,
|
||||
{
|
||||
readonly count: Lens.Lens<number>
|
||||
readonly doubled: View.View<number>
|
||||
}
|
||||
>()("CounterState") {
|
||||
static readonly layer = Layer.effect(
|
||||
CounterState,
|
||||
Effect.gen(function*() {
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
const doubled = View.map(count, (n) => n * 2)
|
||||
|
||||
return { count, doubled } as const
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const CounterReadOnlyView = Component.make("CounterReadOnly")(
|
||||
function* () {
|
||||
const state = yield* CounterState
|
||||
const [count, doubled] = yield* View.useAll([
|
||||
state.count,
|
||||
state.doubled,
|
||||
])
|
||||
|
||||
return <p>Count: {count}, doubled: {doubled}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
`View.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* View.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 `View.useAll`.
|
||||
`Lens.useState` is for places where reading and writing need to be wired
|
||||
together in React's local-state shape.
|
||||
|
||||
```tsx
|
||||
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||
import { Component, Lens } from "effect-view"
|
||||
|
||||
class FormState extends Context.Service<
|
||||
FormState,
|
||||
{ readonly name: Lens.Lens<string> }
|
||||
>()("FormState") {
|
||||
static readonly layer = Layer.effect(
|
||||
FormState,
|
||||
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
|
||||
`View.useAll` or used with `Lens.useState` when React needs a
|
||||
read/write tuple.
|
||||
|
||||
```tsx
|
||||
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-view"
|
||||
|
||||
interface UserProfile {
|
||||
readonly name: string
|
||||
readonly email: string
|
||||
readonly role: string
|
||||
readonly contact: {
|
||||
readonly address: {
|
||||
readonly city: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileState extends Context.Service<
|
||||
ProfileState,
|
||||
{ readonly profile: Lens.Lens<UserProfile> }
|
||||
>()("ProfileState") {
|
||||
static readonly layer = Layer.effect(
|
||||
ProfileState,
|
||||
Effect.gen(function*() {
|
||||
const profile = Lens.fromSubscriptionRef(
|
||||
yield* SubscriptionRef.make<UserProfile>({
|
||||
name: "",
|
||||
email: "",
|
||||
role: "reader",
|
||||
contact: {
|
||||
address: {
|
||||
city: "",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
return { profile } as const
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const ProfileNameView = Component.make("ProfileName")(function*() {
|
||||
const state = yield* ProfileState
|
||||
const [nameLens, roleLens, cityLens] = yield* Component.useOnMount(() =>
|
||||
Effect.succeed([
|
||||
Lens.focusObjectOn(state.profile, "name"),
|
||||
Lens.focusObjectOn(state.profile, "role"),
|
||||
state.profile.pipe(
|
||||
Lens.focusObjectOn("contact"),
|
||||
Lens.focusObjectOn("address"),
|
||||
Lens.focusObjectOn("city"),
|
||||
),
|
||||
] as const),
|
||||
)
|
||||
|
||||
const [name, setName] = yield* Lens.useState(nameLens)
|
||||
const [role, city] = yield* View.useAll([roleLens, cityLens])
|
||||
|
||||
return (
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
/>
|
||||
<span>Role: {role}</span>
|
||||
<span>City: {city}</span>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
The service owns only the root `profile` Lens. The component chooses the fields
|
||||
it needs, creates those focused Lenses once in `Component.useOnMount`, and
|
||||
subscribes only to them. Updating `nameLens` through `Lens.useState` still
|
||||
updates the parent `profile` Lens.
|
||||
|
||||
Lens focus helpers have a dual API. They can be called in data-first form, such
|
||||
as `Lens.focusObjectOn(profile, "name")`, or in curried form with only the key
|
||||
or index. The `cityLens` above uses the curried form with `pipe` to focus through
|
||||
a deeper path without storing intermediate Lenses.
|
||||
|
||||
For focusing into nested state, deriving lenses, custom write behavior, and the
|
||||
complete API, refer to the
|
||||
[`effect-lens` documentation](https://www.npmjs.com/package/effect-lens/v/beta).
|
||||
@@ -0,0 +1,147 @@
|
||||
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 View",
|
||||
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-view/",
|
||||
|
||||
// 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-view", // 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-view/tree/main/packages/docs/",
|
||||
lastVersion: "current",
|
||||
versions: {
|
||||
current: {
|
||||
label: "effect-view v0",
|
||||
},
|
||||
"effect-fc-v0": {
|
||||
label: "effect-fc v0",
|
||||
path: "effect-fc/v0",
|
||||
},
|
||||
},
|
||||
},
|
||||
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 View",
|
||||
logo: {
|
||||
alt: "Effect View logo",
|
||||
src: "img/logo.svg",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: "docSidebar",
|
||||
sidebarId: "docsSidebar",
|
||||
position: "left",
|
||||
label: "Docs",
|
||||
},
|
||||
{
|
||||
type: "docsVersionDropdown",
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
href: "https://www.npmjs.com/package/effect-view",
|
||||
label: "npm",
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
href: "https://github.com/Thiladev/effect-view",
|
||||
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-view",
|
||||
},
|
||||
{
|
||||
label: "Example App",
|
||||
href: "https://github.com/Thiladev/effect-view/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 View contributors. Built with Docusaurus.`,
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.github,
|
||||
darkTheme: prismThemes.dracula,
|
||||
},
|
||||
} satisfies Preset.ThemeConfig,
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint:tsc": "tsc -b --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build --out-dir dist",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve --dir dist",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"clean:cache": "rm -rf .turbo *.tsbuildinfo && docusaurus clear",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.10.2",
|
||||
"@docusaurus/faster": "^3.10.2",
|
||||
"@docusaurus/preset-classic": "^3.10.2",
|
||||
"@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.2",
|
||||
"@docusaurus/tsconfig": "^3.10.2",
|
||||
"@docusaurus/types": "^3.10.2",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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",
|
||||
"async",
|
||||
"query",
|
||||
"mutation",
|
||||
"forms",
|
||||
],
|
||||
}
|
||||
|
||||
export default sidebars
|
||||
@@ -0,0 +1,37 @@
|
||||
: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);
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
.page {
|
||||
--home-ink: #102f31;
|
||||
--home-muted: #506a6c;
|
||||
--home-teal: #08777b;
|
||||
--home-teal-bright: #16a3a1;
|
||||
--home-amber: #e99526;
|
||||
--home-paper: #fffdf8;
|
||||
--home-card: rgba(255, 255, 255, 0.76);
|
||||
--home-line: rgba(16, 47, 49, 0.12);
|
||||
min-height: calc(100vh - var(--ifm-navbar-height));
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background:
|
||||
radial-gradient(circle at 8% 5%, rgba(14, 145, 148, 0.2), transparent 28rem),
|
||||
radial-gradient(circle at 92% 14%, rgba(240, 159, 48, 0.2), transparent 27rem),
|
||||
linear-gradient(145deg, #f7f2e7 0%, #edf8f6 48%, #fff8ed 100%);
|
||||
color: var(--home-ink);
|
||||
}
|
||||
|
||||
.gridBackdrop {
|
||||
background-image:
|
||||
linear-gradient(rgba(16, 47, 49, 0.045) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(16, 47, 49, 0.045) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
inset: 0;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 62%);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.hero {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: clamp(3rem, 6vw, 6rem);
|
||||
grid-template-columns: minmax(0, 0.88fr) minmax(560px, 1.12fr);
|
||||
min-height: min(820px, calc(100vh - var(--ifm-navbar-height)));
|
||||
padding-bottom: 6rem;
|
||||
padding-top: 6rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
max-width: 650px;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.sectionKicker {
|
||||
align-items: center;
|
||||
color: var(--home-teal);
|
||||
display: flex;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 850;
|
||||
gap: 0.65rem;
|
||||
letter-spacing: 0.18em;
|
||||
margin: 0 0 1.35rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
background: var(--home-teal-bright);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 5px rgba(22, 163, 161, 0.14);
|
||||
height: 0.55rem;
|
||||
width: 0.55rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
color: var(--home-ink);
|
||||
font-size: clamp(3.4rem, 6.4vw, 6.4rem);
|
||||
font-weight: 780;
|
||||
letter-spacing: -0.075em;
|
||||
line-height: 0.92;
|
||||
margin: 0;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.hero h1 .unbreakable {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero h1 .heroAccent {
|
||||
background: linear-gradient(105deg, var(--home-teal) 5%, #1a9793 48%, var(--home-amber));
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
display: inline;
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--home-muted);
|
||||
font-size: clamp(1.08rem, 1.55vw, 1.32rem);
|
||||
line-height: 1.65;
|
||||
margin: 2rem 0 0;
|
||||
max-width: 630px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
margin-top: 2.3rem;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction,
|
||||
.ctaAction {
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
font-weight: 750;
|
||||
gap: 0.8rem;
|
||||
justify-content: center;
|
||||
padding: 0.88rem 1.35rem;
|
||||
text-decoration: none !important;
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
background: var(--home-ink);
|
||||
box-shadow: 0 12px 28px rgba(16, 47, 49, 0.2);
|
||||
color: #f6fffd;
|
||||
}
|
||||
|
||||
.primaryAction:hover {
|
||||
box-shadow: 0 16px 36px rgba(16, 47, 49, 0.28);
|
||||
color: #fff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
border: 1px solid var(--home-line);
|
||||
color: var(--home-ink);
|
||||
}
|
||||
|
||||
.secondaryAction:hover {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--home-teal);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.install {
|
||||
align-items: center;
|
||||
color: var(--home-muted);
|
||||
display: flex;
|
||||
font-size: 0.84rem;
|
||||
gap: 0.7rem;
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.install span {
|
||||
color: var(--home-amber);
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.install code {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hotReload {
|
||||
align-items: center;
|
||||
color: var(--home-teal);
|
||||
display: inline-flex;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 750;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.hotReload span {
|
||||
color: var(--home-amber);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hotReload:hover {
|
||||
color: var(--home-teal-bright);
|
||||
}
|
||||
|
||||
.codeStage {
|
||||
min-width: 0;
|
||||
perspective: 1200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.codeGlow {
|
||||
background: linear-gradient(115deg, rgba(11, 133, 136, 0.48), rgba(238, 156, 40, 0.38));
|
||||
filter: blur(55px);
|
||||
inset: 8% 5%;
|
||||
opacity: 0.55;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.codeWindow {
|
||||
background: #101b1e;
|
||||
border: 1px solid rgba(166, 225, 220, 0.2);
|
||||
border-radius: 1.25rem;
|
||||
box-shadow:
|
||||
0 34px 90px rgba(18, 49, 52, 0.3),
|
||||
0 2px 0 rgba(255, 255, 255, 0.06) inset;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transform: rotateY(-2deg) rotateX(1deg);
|
||||
}
|
||||
|
||||
.windowBar {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #b6c9c9;
|
||||
display: grid;
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.73rem;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
padding: 0.78rem 1rem;
|
||||
}
|
||||
|
||||
.windowDots {
|
||||
display: flex;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.windowDots span {
|
||||
background: #4f6d6e;
|
||||
border-radius: 50%;
|
||||
height: 0.55rem;
|
||||
width: 0.55rem;
|
||||
}
|
||||
|
||||
.windowDots span:first-child {
|
||||
background: #ef8d5b;
|
||||
}
|
||||
|
||||
.windowDots span:nth-child(2) {
|
||||
background: #e9bc50;
|
||||
}
|
||||
|
||||
.windowDots span:last-child {
|
||||
background: #65bd92;
|
||||
}
|
||||
|
||||
.windowStatus {
|
||||
color: #74d2cd;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.codeWindow :global(.theme-code-block) {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.codeWindow :global(.theme-code-block pre) {
|
||||
background: #101b1e !important;
|
||||
color: #d7e5e4 !important;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.62;
|
||||
max-height: none;
|
||||
padding: 1.45rem 1.6rem !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token-line),
|
||||
.codeWindow :global(.token.plain),
|
||||
.codeWindow :global(.token.imports),
|
||||
.codeWindow :global(.token.maybe-class-name),
|
||||
.codeWindow :global(.token.punctuation),
|
||||
.codeWindow :global(.token.operator),
|
||||
.codeWindow :global(.token.plain-text),
|
||||
.codeWindow :global(.token.property-access) {
|
||||
color: #d7e5e4 !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.keyword) {
|
||||
color: #c792ea !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.string) {
|
||||
color: #c3e88d !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.function),
|
||||
.codeWindow :global(.token.method) {
|
||||
color: #82aaff !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.tag) {
|
||||
color: #f07178 !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.builtin) {
|
||||
color: #ffcb6b !important;
|
||||
}
|
||||
|
||||
.codeLegend {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #a9bcbc;
|
||||
display: grid;
|
||||
font-size: 0.7rem;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.codeLegend span {
|
||||
padding: 0.8rem 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.codeLegend span + span {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.codeLegend b {
|
||||
color: #e8a94b;
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.featuresSection {
|
||||
padding-bottom: 7rem;
|
||||
padding-top: 4rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
display: grid;
|
||||
gap: 0 3rem;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
|
||||
margin-bottom: 2.8rem;
|
||||
}
|
||||
|
||||
.sectionHeading .sectionKicker {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.sectionHeading h2,
|
||||
.cta h2 {
|
||||
color: var(--home-ink);
|
||||
font-size: clamp(2.25rem, 4vw, 4rem);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sectionHeading > p:last-child {
|
||||
align-self: end;
|
||||
color: var(--home-muted);
|
||||
font-size: 1.03rem;
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.featureGrid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.featureCard {
|
||||
background: var(--home-card);
|
||||
border: 1px solid var(--home-line);
|
||||
border-radius: 1.35rem;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 310px;
|
||||
overflow: hidden;
|
||||
padding: 1.45rem;
|
||||
position: relative;
|
||||
text-decoration: none !important;
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.featureCard::before {
|
||||
background: linear-gradient(90deg, var(--home-teal-bright), var(--home-amber));
|
||||
content: "";
|
||||
height: 3px;
|
||||
left: 1.45rem;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 1.45rem;
|
||||
top: 0;
|
||||
transform: scaleX(0.5);
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.featureCard:hover {
|
||||
border-color: rgba(8, 119, 123, 0.35);
|
||||
box-shadow: 0 24px 60px rgba(28, 65, 66, 0.12);
|
||||
color: inherit;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.featureCard:hover::before {
|
||||
opacity: 1;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.cardTopline {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.featureIcon {
|
||||
align-items: center;
|
||||
background: rgba(8, 119, 123, 0.1);
|
||||
border: 1px solid rgba(8, 119, 123, 0.12);
|
||||
border-radius: 0.8rem;
|
||||
color: var(--home-teal);
|
||||
display: flex;
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 1rem;
|
||||
font-weight: 850;
|
||||
height: 2.7rem;
|
||||
justify-content: center;
|
||||
width: 2.7rem;
|
||||
}
|
||||
|
||||
.cardIndex {
|
||||
color: rgba(16, 47, 49, 0.32);
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.featureCard h3 {
|
||||
color: var(--home-ink);
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 2rem 0 0.7rem;
|
||||
}
|
||||
|
||||
.featureCard > p {
|
||||
color: var(--home-muted);
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.cardFooter code {
|
||||
background: rgba(8, 119, 123, 0.08);
|
||||
border: 0;
|
||||
color: var(--home-teal);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.cardFooter > span {
|
||||
color: var(--home-amber);
|
||||
font-size: 1.1rem;
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.featureCard:hover .cardFooter > span {
|
||||
transform: translate(3px, -3px);
|
||||
}
|
||||
|
||||
.ctaWrap {
|
||||
padding-bottom: 7rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cta {
|
||||
align-items: center;
|
||||
background:
|
||||
radial-gradient(circle at 90% 20%, rgba(233, 149, 38, 0.22), transparent 20rem),
|
||||
linear-gradient(125deg, #12383a, #10292c);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1.75rem;
|
||||
box-shadow: 0 28px 80px rgba(16, 47, 49, 0.2);
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
padding: clamp(2rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
.cta .sectionKicker {
|
||||
color: #79d5d1;
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
color: #f7fffd;
|
||||
}
|
||||
|
||||
.cta p:last-child {
|
||||
color: #b9cccc;
|
||||
line-height: 1.65;
|
||||
margin: 1rem 0 0;
|
||||
max-width: 650px;
|
||||
}
|
||||
|
||||
.ctaAction {
|
||||
background: #f0a13b;
|
||||
color: #162d2e;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ctaAction:hover {
|
||||
box-shadow: 0 14px 35px rgba(0, 0, 0, 0.24);
|
||||
color: #102526;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .page {
|
||||
--home-ink: #e9f7f5;
|
||||
--home-muted: #a9bfbd;
|
||||
--home-teal: #79d5d1;
|
||||
--home-teal-bright: #5bc8c5;
|
||||
--home-amber: #f1ad4e;
|
||||
--home-card: rgba(20, 42, 45, 0.76);
|
||||
--home-line: rgba(174, 221, 218, 0.14);
|
||||
background:
|
||||
radial-gradient(circle at 8% 5%, rgba(33, 154, 154, 0.17), transparent 28rem),
|
||||
radial-gradient(circle at 92% 14%, rgba(209, 130, 31, 0.13), transparent 27rem),
|
||||
linear-gradient(145deg, #0f1b1e 0%, #102426 48%, #161e1e 100%);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .gridBackdrop {
|
||||
background-image:
|
||||
linear-gradient(rgba(174, 221, 218, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(174, 221, 218, 0.04) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .secondaryAction {
|
||||
background: rgba(19, 43, 46, 0.75);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .secondaryAction:hover {
|
||||
background: rgba(27, 57, 60, 0.95);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .primaryAction {
|
||||
background: #dff8f5;
|
||||
color: #123638;
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .primaryAction:hover {
|
||||
color: #0b282a;
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .cardIndex {
|
||||
color: rgba(220, 242, 239, 0.35);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1180px) {
|
||||
.hero {
|
||||
gap: 3rem;
|
||||
grid-template-columns: minmax(0, 0.85fr) minmax(500px, 1.15fr);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(3.2rem, 6vw, 5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 996px) {
|
||||
.hero {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding-bottom: 4rem;
|
||||
padding-top: 4.5rem;
|
||||
}
|
||||
|
||||
.heroCopy,
|
||||
.codeStage,
|
||||
.codeWindow,
|
||||
.codeWindow :global(.theme-code-block) {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.codeStage {
|
||||
margin: 0 auto;
|
||||
max-width: 760px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.codeWindow {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.featureGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
.hero {
|
||||
box-sizing: border-box;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
max-width: 100vw;
|
||||
padding-left: 1.15rem;
|
||||
padding-right: 1.15rem;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.heroCopy,
|
||||
.codeStage {
|
||||
max-width: calc(100vw - 2.3rem);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(3rem, 15vw, 4.3rem);
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sectionHeading > p:last-child {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.featureGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.featureCard {
|
||||
min-height: 275px;
|
||||
}
|
||||
|
||||
.codeLegend {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.codeLegend span + span {
|
||||
border-left: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.windowStatus {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.windowBar {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.codeWindow :global(.theme-code-block pre) {
|
||||
font-size: 0.69rem;
|
||||
overflow-x: auto;
|
||||
padding: 1.1rem !important;
|
||||
}
|
||||
|
||||
.cta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.featureCard,
|
||||
.featureCard::before,
|
||||
.cardFooter > span,
|
||||
.primaryAction,
|
||||
.secondaryAction,
|
||||
.ctaAction {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import Link from "@docusaurus/Link"
|
||||
import CodeBlock from "@theme/CodeBlock"
|
||||
import Layout from "@theme/Layout"
|
||||
import clsx from "clsx"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import styles from "./index.module.css"
|
||||
|
||||
const componentExample = `import { Effect } from "effect"
|
||||
import { Async, Component } from "effect-view"
|
||||
import { Users } from "./services"
|
||||
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { userId: string }) {
|
||||
const users = yield* Users
|
||||
|
||||
const user = yield* Component.useOnChange(
|
||||
() => users.find(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
yield* Component.useReactEffect(
|
||||
() => Effect.forkScoped(users.watch(userId)),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return (
|
||||
<article>
|
||||
<h2>{user.name}</h2>
|
||||
<p>{user.email}</p>
|
||||
</article>
|
||||
)
|
||||
},
|
||||
).pipe(Async.async)`
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: "fx",
|
||||
title: "Components are Effect programs",
|
||||
description:
|
||||
"Yield Effects and services directly from a component body, then return ordinary JSX. Types track every dependency all the way to the runtime boundary.",
|
||||
tag: "Component.make",
|
||||
to: "/docs/getting-started#write-your-first-component",
|
||||
},
|
||||
{
|
||||
icon: "R",
|
||||
title: "One typed runtime",
|
||||
description:
|
||||
"Build your application Layer once. Components access HTTP clients, repositories, configuration, tracing, and your own services without React context plumbing.",
|
||||
tag: "ReactRuntime",
|
||||
to: "/docs/getting-started#create-the-runtime",
|
||||
},
|
||||
{
|
||||
icon: "S",
|
||||
title: "Scopes follow React",
|
||||
description:
|
||||
"Every component owns an Effect Scope. Resources, subscriptions, and fibers are finalized when their component or dependency lifecycle ends.",
|
||||
tag: "Scope.Scope",
|
||||
to: "/docs/getting-started#the-component-root-scope",
|
||||
},
|
||||
{
|
||||
icon: "↔",
|
||||
title: "Reactive state without a new universe",
|
||||
description:
|
||||
"Lens and View connect Effect state to React. Focus large models into small writable values and subscribe only where rendering needs them.",
|
||||
tag: "Lens + View",
|
||||
to: "/docs/state-management",
|
||||
},
|
||||
{
|
||||
icon: "Q",
|
||||
title: "TanStack-shaped server state",
|
||||
description:
|
||||
"Reactive keys, caching, stale times, background refresh, mutations, and invalidation—with typed Effect services, Causes, and scoped fibers underneath.",
|
||||
tag: "Query + Mutation",
|
||||
to: "/docs/query",
|
||||
},
|
||||
{
|
||||
icon: "Σ",
|
||||
title: "Forms driven by Schema",
|
||||
description:
|
||||
"Keep input-friendly encoded values while application code receives validated, decoded types. Focus one root into reusable field-sized subforms.",
|
||||
tag: "MutationForm + LensForm",
|
||||
to: "/docs/forms",
|
||||
},
|
||||
] as const
|
||||
|
||||
export default function Home(): ReactNode {
|
||||
return (
|
||||
<Layout
|
||||
title="Effect View"
|
||||
description="Write React function components as typed Effect programs"
|
||||
>
|
||||
<main className={styles.page}>
|
||||
<div className={styles.gridBackdrop} aria-hidden="true" />
|
||||
|
||||
<section className={clsx("container", styles.hero)}>
|
||||
<div className={styles.heroCopy}>
|
||||
<div className={styles.eyebrow}>
|
||||
<span className={styles.pulse} />
|
||||
Effect View for React 19
|
||||
</div>
|
||||
<h1>
|
||||
React <span className={styles.unbreakable}>components,</span>
|
||||
<span className={styles.heroAccent}> powered by Effect.</span>
|
||||
</h1>
|
||||
<p className={styles.lede}>
|
||||
Bring typed services, scoped resources, reactive state, server
|
||||
queries, and schema-driven forms into React without hiding either
|
||||
framework.
|
||||
</p>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link className={styles.primaryAction} to="/docs/getting-started">
|
||||
Start building
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
<Link
|
||||
className={styles.secondaryAction}
|
||||
to="https://github.com/Thiladev/effect-view"
|
||||
>
|
||||
View on GitHub
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.install}>
|
||||
<span aria-hidden="true">$</span>
|
||||
<code>npm install effect-view effect@beta</code>
|
||||
</div>
|
||||
<Link
|
||||
className={styles.hotReload}
|
||||
to="/docs/getting-started#set-up-hot-reloading-with-vite"
|
||||
>
|
||||
<span aria-hidden="true">↻</span>
|
||||
Hot reload with Vite Fast Refresh
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.codeStage}>
|
||||
<div className={styles.codeGlow} aria-hidden="true" />
|
||||
<div className={styles.codeWindow}>
|
||||
<div className={styles.windowBar}>
|
||||
<div className={styles.windowDots} aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<span>UserCard.tsx</span>
|
||||
<span className={styles.windowStatus}>Effect + JSX</span>
|
||||
</div>
|
||||
<CodeBlock language="tsx">{componentExample}</CodeBlock>
|
||||
<div className={styles.codeLegend}>
|
||||
<span><b>01</b> Yield services</span>
|
||||
<span><b>02</b> Own the lifecycle</span>
|
||||
<span><b>03</b> Return JSX</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={clsx("container", styles.featuresSection)}>
|
||||
<div className={styles.sectionHeading}>
|
||||
<p className={styles.sectionKicker}>Why Effect View</p>
|
||||
<h2>One model from render to resources.</h2>
|
||||
<p>
|
||||
Effect View adds the pieces React deliberately leaves open while
|
||||
preserving the React component model you already know.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.featureGrid}>
|
||||
{features.map((feature, index) => (
|
||||
<Link
|
||||
className={styles.featureCard}
|
||||
to={feature.to}
|
||||
key={feature.title}
|
||||
>
|
||||
<div className={styles.cardTopline}>
|
||||
<span className={styles.featureIcon}>{feature.icon}</span>
|
||||
<span className={styles.cardIndex}>
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<h3>{feature.title}</h3>
|
||||
<p>{feature.description}</p>
|
||||
<div className={styles.cardFooter}>
|
||||
<code>{feature.tag}</code>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={clsx("container", styles.ctaWrap)}>
|
||||
<div className={styles.cta}>
|
||||
<div>
|
||||
<p className={styles.sectionKicker}>Ready when React is</p>
|
||||
<h2>Start with one Effect component.</h2>
|
||||
<p>
|
||||
Add a runtime, cross the React boundary once, and grow into the
|
||||
rest of the toolkit only when you need it.
|
||||
</p>
|
||||
</div>
|
||||
<Link className={styles.ctaAction} to="/docs/getting-started">
|
||||
Read the guide <span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
Vendored
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Vendored
+47
@@ -0,0 +1,47 @@
|
||||
<svg
|
||||
width="128"
|
||||
height="128"
|
||||
viewBox="0 0 128 128"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Effect View</title>
|
||||
<defs>
|
||||
<linearGradient id="surface" x1="14" y1="10" x2="116" y2="122" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#16A3A1" />
|
||||
<stop offset="0.58" stop-color="#08777B" />
|
||||
<stop offset="1" stop-color="#102F31" />
|
||||
</linearGradient>
|
||||
<radialGradient id="warmth" cx="0" cy="0" r="1" gradientTransform="translate(112 111) rotate(-135) scale(65)">
|
||||
<stop stop-color="#F0A13B" stop-opacity="0.85" />
|
||||
<stop offset="1" stop-color="#F0A13B" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||
<feDropShadow dx="0" dy="3" stdDeviation="2.5" flood-color="#062426" flood-opacity="0.22" />
|
||||
</filter>
|
||||
<clipPath id="tile">
|
||||
<rect width="128" height="128" rx="30" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<g clip-path="url(#tile)">
|
||||
<rect width="128" height="128" fill="url(#surface)" />
|
||||
<circle cx="112" cy="111" r="65" fill="url(#warmth)" />
|
||||
<path d="M-8 30L55 -4H128V10L-8 83V30Z" fill="white" fill-opacity="0.055" />
|
||||
</g>
|
||||
<rect x="1" y="1" width="126" height="126" rx="29" stroke="white" stroke-opacity="0.14" stroke-width="2" />
|
||||
|
||||
<g transform="translate(24 24) scale(2.5)" fill="white" filter="url(#shadow)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.8022 24.317C30.2747 24.05 30.4361 23.4582 30.1636 22.9953C29.891 22.5329 29.2873 22.3741 28.8148 22.6411L15.9211 29.9362L3.07463 22.6683C2.60281 22.4012 1.999 22.5594 1.72597 23.0225C1.45347 23.4854 1.61541 24.077 2.08741 24.3441L15.3897 31.8698C15.5053 31.9353 15.6327 31.9771 15.7645 31.9929C15.8963 32.0087 16.0299 31.9981 16.1576 31.9617C16.278 31.9433 16.3941 31.9031 16.5002 31.8431L29.8022 24.317Z" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.1298 16.6012C31.1974 16.1929 31.0061 15.7675 30.6177 15.5488L16.555 7.63105C16.4443 7.56873 16.3234 7.52682 16.198 7.50732C16.0631 7.46888 15.922 7.45758 15.7827 7.47405C15.6434 7.49053 15.5088 7.53446 15.3865 7.60332L1.32289 15.5214C0.913972 15.7518 0.723686 16.2117 0.824499 16.6391C0.780205 16.9913 0.91787 17.3598 1.32768 17.5916L15.3904 25.5478C15.5127 25.6169 15.6475 25.661 15.7869 25.6776C15.9263 25.6942 16.0675 25.6829 16.2026 25.6445C16.3297 25.6253 16.4522 25.583 16.5642 25.5197L30.6275 17.563C31.0408 17.329 31.1776 16.9562 31.1298 16.6012ZM28.2266 16.5591L15.9459 9.64453L3.67206 16.5554L15.9528 23.5034L28.2266 16.5591Z" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.3429 10.6097C31.8677 10.3131 32.0476 9.65608 31.7442 9.14178C31.4416 8.62819 30.7712 8.45201 30.2464 8.74854L15.9269 16.8501L1.66063 8.77876C1.13584 8.48152 0.465408 8.65787 0.162793 9.172C-0.14053 9.68541 0.0391253 10.3432 0.564095 10.6397L15.337 18.9976C15.4654 19.0702 15.607 19.1165 15.7534 19.1339C15.8998 19.1514 16.0482 19.1395 16.19 19.0991C16.3236 19.0791 16.4524 19.0347 16.5701 18.9681L31.3429 10.6097Z" />
|
||||
<path d="M2.7403 9.6795L15.8991 1.62024L29.0577 9.67879L15.8989 17.2013L2.7403 9.6795Z" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.3255 8.49027C31.8513 8.78627 32.0333 9.44279 31.7325 9.95692C31.4307 10.4707 30.7603 10.6474 30.2344 10.3514L15.9128 2.28787L1.64317 10.3224C1.11731 10.6184 0.44688 10.4415 0.145328 9.92794C-0.15587 9.41381 0.0262664 8.75729 0.55159 8.46129L15.325 0.143725C15.4534 0.0713274 15.5949 0.0251207 15.7412 0.00776107C15.8875 -0.00959854 16.0358 0.00223134 16.1775 0.0425706C16.3093 0.0631109 16.4364 0.107185 16.5526 0.172702L31.3255 8.49027Z" />
|
||||
</g>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<path d="M43 61.5C48.8 54.1 55.8 50.5 64 50.5C72.2 50.5 79.2 54.1 85 61.5C79.2 68.9 72.2 72.5 64 72.5C55.8 72.5 48.8 68.9 43 61.5Z" fill="#102F31" />
|
||||
<circle cx="64" cy="61.5" r="7.5" fill="#F0A13B" />
|
||||
<circle cx="66.5" cy="59" r="2.2" fill="white" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
// 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"]
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: Forms
|
||||
---
|
||||
|
||||
# Forms
|
||||
|
||||
The form modules provide an Effect-first model for editable, schema-validated
|
||||
state. They are not a visual component library. Instead, they give you
|
||||
Lenses/Subscribables for form values, validation issues, and commit state, so
|
||||
you can build the UI with whatever components your app already uses.
|
||||
|
||||
The base `Form` type represents a field. A field tracks both the raw encoded
|
||||
value used by inputs and the decoded value produced by an Effect `Schema`:
|
||||
|
||||
- `encodedValue`: a `Lens` containing the raw input value.
|
||||
- `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.
|
||||
|
||||
There are two root form flavors:
|
||||
|
||||
- `SubmittableForm`: owns local encoded form state, validates it, and submits a
|
||||
decoded value through a mutation.
|
||||
- `SynchronizedForm`: wraps an existing target `Lens` and writes valid changes
|
||||
back to that target.
|
||||
|
||||
Most apps create one of those root forms, then focus it into fields with
|
||||
`Form.focusObjectOn`, `Form.focusArrayAt`, or the other focusing helpers.
|
||||
|
||||
The core form model is Effect code and can technically be used outside
|
||||
Effect-FC. Effect-FC adds the React-facing hooks, such as `Form.useInput`, that
|
||||
make those forms convenient to bind to components.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,415 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
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).
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"docsSidebar": [
|
||||
"getting-started",
|
||||
"state-management",
|
||||
"forms"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"effect-fc-v0"
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.tanstack
|
||||
@@ -0,0 +1,51 @@
|
||||
# Effect FC Example
|
||||
|
||||
Example application for the legacy `effect-fc` package, built with React,
|
||||
TypeScript, and Vite.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import react from 'eslint-plugin-react'
|
||||
|
||||
export default tseslint.config({
|
||||
// Set the react version
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
// Add the react plugin
|
||||
react,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended rules
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"files": {
|
||||
"includes": ["./src/**", "!src/routeTree.gen.ts"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Effect FC Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@effect-fc/example",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"lint:tsc": "tsc -b --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
"preview": "vite preview",
|
||||
"clean:cache": "rm -rf .turbo node_modules/.tmp node_modules/.vite* .tanstack",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-router": "^1.170.10",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/router-plugin": "^1.168.13",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"globals": "^17.6.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"type-fest": "^5.7.0",
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform": "^0.96.1",
|
||||
"@effect/platform-browser": "^0.76.0",
|
||||
"@radix-ui/themes": "^3.3.0",
|
||||
"@typed/id": "^0.17.2",
|
||||
"effect": "^3.21.2",
|
||||
"effect-fc": "workspace:*",
|
||||
"react-icons": "^5.6.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "^19.2.15",
|
||||
"effect": "^3.21.2",
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,56 @@
|
||||
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>()
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
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>()
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
|
||||
const router = createRouter({ routeTree })
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: React entrypoint
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ReactRuntime.Provider runtime={runtime}>
|
||||
<RouterProvider router={router} />
|
||||
</ReactRuntime.Provider>
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as ResultRouteImport } from './routes/result'
|
||||
import { Route as QueryRouteImport } from './routes/query'
|
||||
import { Route as FormRouteImport } from './routes/form'
|
||||
import { Route as BlankRouteImport } from './routes/blank'
|
||||
import { Route as AsyncRouteImport } from './routes/async'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as DevMemoRouteImport } from './routes/dev/memo'
|
||||
import { Route as DevContextRouteImport } from './routes/dev/context'
|
||||
|
||||
const ResultRoute = ResultRouteImport.update({
|
||||
id: '/result',
|
||||
path: '/result',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const QueryRoute = QueryRouteImport.update({
|
||||
id: '/query',
|
||||
path: '/query',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const FormRoute = FormRouteImport.update({
|
||||
id: '/form',
|
||||
path: '/form',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const BlankRoute = BlankRouteImport.update({
|
||||
id: '/blank',
|
||||
path: '/blank',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AsyncRoute = AsyncRouteImport.update({
|
||||
id: '/async',
|
||||
path: '/async',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DevMemoRoute = DevMemoRouteImport.update({
|
||||
id: '/dev/memo',
|
||||
path: '/dev/memo',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DevContextRoute = DevContextRouteImport.update({
|
||||
id: '/dev/context',
|
||||
path: '/dev/context',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/query': typeof QueryRoute
|
||||
'/result': typeof ResultRoute
|
||||
'/dev/context': typeof DevContextRoute
|
||||
'/dev/memo': typeof DevMemoRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/query': typeof QueryRoute
|
||||
'/result': typeof ResultRoute
|
||||
'/dev/context': typeof DevContextRoute
|
||||
'/dev/memo': typeof DevMemoRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/query': typeof QueryRoute
|
||||
'/result': typeof ResultRoute
|
||||
'/dev/context': typeof DevContextRoute
|
||||
'/dev/memo': typeof DevMemoRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/async'
|
||||
| '/blank'
|
||||
| '/form'
|
||||
| '/query'
|
||||
| '/result'
|
||||
| '/dev/context'
|
||||
| '/dev/memo'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/async'
|
||||
| '/blank'
|
||||
| '/form'
|
||||
| '/query'
|
||||
| '/result'
|
||||
| '/dev/context'
|
||||
| '/dev/memo'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/async'
|
||||
| '/blank'
|
||||
| '/form'
|
||||
| '/query'
|
||||
| '/result'
|
||||
| '/dev/context'
|
||||
| '/dev/memo'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AsyncRoute: typeof AsyncRoute
|
||||
BlankRoute: typeof BlankRoute
|
||||
FormRoute: typeof FormRoute
|
||||
QueryRoute: typeof QueryRoute
|
||||
ResultRoute: typeof ResultRoute
|
||||
DevContextRoute: typeof DevContextRoute
|
||||
DevMemoRoute: typeof DevMemoRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/result': {
|
||||
id: '/result'
|
||||
path: '/result'
|
||||
fullPath: '/result'
|
||||
preLoaderRoute: typeof ResultRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/query': {
|
||||
id: '/query'
|
||||
path: '/query'
|
||||
fullPath: '/query'
|
||||
preLoaderRoute: typeof QueryRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/form': {
|
||||
id: '/form'
|
||||
path: '/form'
|
||||
fullPath: '/form'
|
||||
preLoaderRoute: typeof FormRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/blank': {
|
||||
id: '/blank'
|
||||
path: '/blank'
|
||||
fullPath: '/blank'
|
||||
preLoaderRoute: typeof BlankRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/async': {
|
||||
id: '/async'
|
||||
path: '/async'
|
||||
fullPath: '/async'
|
||||
preLoaderRoute: typeof AsyncRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/dev/memo': {
|
||||
id: '/dev/memo'
|
||||
path: '/dev/memo'
|
||||
fullPath: '/dev/memo'
|
||||
preLoaderRoute: typeof DevMemoRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/dev/context': {
|
||||
id: '/dev/context'
|
||||
path: '/dev/context'
|
||||
fullPath: '/dev/context'
|
||||
preLoaderRoute: typeof DevContextRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AsyncRoute: AsyncRoute,
|
||||
BlankRoute: BlankRoute,
|
||||
FormRoute: FormRoute,
|
||||
QueryRoute: QueryRoute,
|
||||
ResultRoute: ResultRoute,
|
||||
DevContextRoute: DevContextRoute,
|
||||
DevMemoRoute: DevMemoRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Container, Flex, Theme } from "@radix-ui/themes"
|
||||
import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||
|
||||
import "@radix-ui/themes/styles.css"
|
||||
import "../index.css"
|
||||
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Root
|
||||
})
|
||||
|
||||
function Root() {
|
||||
return (
|
||||
<Theme>
|
||||
<Container mb="4">
|
||||
<Flex direction="row" justify="center" align="center" gap="2">
|
||||
<Link to="/">Index</Link>
|
||||
<Link to="/blank">Blank</Link>
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
<Outlet />
|
||||
|
||||
<TanStackRouterDevtools />
|
||||
</Theme>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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,10 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
|
||||
export const Route = createFileRoute("/blank")({
|
||||
component: RouteComponent
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/blank"!</div>
|
||||
}
|
||||
+1
-1
@@ -23,7 +23,7 @@ const SubComponent = Component.makeUntraced("SubComponent")(function*() {
|
||||
const ContextView = Component.makeUntraced("ContextView")(function*() {
|
||||
const [serviceValue, setServiceValue] = React.useState("test")
|
||||
const SubServiceLayer = React.useMemo(() => SubService.Default(serviceValue), [serviceValue])
|
||||
const SubComponentFC = yield* Effect.provide(SubComponent, yield* Component.useContext(SubServiceLayer))
|
||||
const SubComponentFC = yield* Effect.provide(SubComponent.use, yield* Component.useLayer(SubServiceLayer))
|
||||
|
||||
return (
|
||||
<Container>
|
||||
+2
-2
@@ -17,8 +17,8 @@ const RouteComponent = Component.makeUntraced("RouteComponent")(function*() {
|
||||
onChange={e => setValue(e.target.value)}
|
||||
/>
|
||||
|
||||
{yield* Effect.map(SubComponent, FC => <FC />)}
|
||||
{yield* Effect.map(MemoizedSubComponent, FC => <FC />)}
|
||||
{yield* Effect.map(SubComponent.use, FC => <FC />)}
|
||||
{yield* Effect.map(MemoizedSubComponent.use, FC => <FC />)}
|
||||
</Flex>
|
||||
)
|
||||
}).pipe(
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Button, Container, Flex, Text } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
|
||||
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
|
||||
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
||||
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
|
||||
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
const email = Schema.pattern<typeof Schema.String>(
|
||||
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i,
|
||||
|
||||
{
|
||||
identifier: "email",
|
||||
title: "email",
|
||||
message: () => "Not an email address",
|
||||
},
|
||||
)
|
||||
|
||||
const RegisterFormSchema = Schema.Struct({
|
||||
email: Schema.String.pipe(email),
|
||||
password: Schema.String.pipe(Schema.minLength(3)),
|
||||
birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
|
||||
})
|
||||
|
||||
const RegisterFormSubmitSchema = Schema.Struct({
|
||||
email: Schema.transformOrFail(
|
||||
Schema.String,
|
||||
Schema.String,
|
||||
{
|
||||
decode: (input, _options, ast) => input !== "admin@admin.com"
|
||||
? ParseResult.succeed(input)
|
||||
: ParseResult.fail(new ParseResult.Type(ast, input, "This email is already in use.")),
|
||||
encode: ParseResult.succeed,
|
||||
},
|
||||
),
|
||||
password: Schema.String,
|
||||
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
|
||||
})
|
||||
|
||||
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
|
||||
scoped: Effect.gen(function*() {
|
||||
const form = yield* SubmittableForm.service({
|
||||
schema: RegisterFormSchema.pipe(
|
||||
Schema.compose(
|
||||
Schema.transformOrFail(
|
||||
Schema.typeSchema(RegisterFormSchema),
|
||||
Schema.typeSchema(RegisterFormSchema),
|
||||
{
|
||||
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
|
||||
encode: ParseResult.succeed,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
initialEncodedValue: { email: "", password: "", birth: Option.none() },
|
||||
f: Effect.fnUntraced(function*([value]) {
|
||||
yield* Effect.sleep("500 millis")
|
||||
return yield* Schema.decode(RegisterFormSubmitSchema)(value)
|
||||
}),
|
||||
})
|
||||
|
||||
return {
|
||||
form,
|
||||
emailField: Form.focusObjectOn(form, "email"),
|
||||
passwordField: Form.focusObjectOn(form, "password"),
|
||||
birthField: Form.focusObjectOn(form, "birth"),
|
||||
} as const
|
||||
})
|
||||
}) {}
|
||||
|
||||
class RegisterFormView extends Component.make("RegisterFormView")(function*() {
|
||||
const form = yield* RegisterFormService
|
||||
const [canCommit, submitResult] = yield* Subscribable.useAll([
|
||||
form.form.canCommit,
|
||||
form.form.mutation.result,
|
||||
])
|
||||
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
const TextFieldFormInput = yield* TextFieldFormInputView.use
|
||||
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
|
||||
|
||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
|
||||
yield* Console.log("RegisterFormView mounted")
|
||||
}))
|
||||
|
||||
|
||||
return (
|
||||
<Container width="300">
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault()
|
||||
void runPromise(form.form.submit)
|
||||
}}>
|
||||
<Flex direction="column" gap="2">
|
||||
<TextFieldFormInput
|
||||
form={form.emailField}
|
||||
debounce="250 millis"
|
||||
/>
|
||||
|
||||
<TextFieldFormInput
|
||||
form={form.passwordField}
|
||||
debounce="250 millis"
|
||||
/>
|
||||
|
||||
<TextFieldOptionalFormInput
|
||||
type="datetime-local"
|
||||
form={form.birthField}
|
||||
defaultValue=""
|
||||
/>
|
||||
|
||||
<Button disabled={!canCommit}>Submit</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
|
||||
{Match.value(submitResult).pipe(
|
||||
Match.tag("Initial", () => <></>),
|
||||
Match.tag("Running", () => <Text>Submitting...</Text>),
|
||||
Match.tag("Success", () => <Text>Submitted successfully!</Text>),
|
||||
Match.tag("Failure", e => <Text>Error: {e.cause.toString()}</Text>),
|
||||
Match.exhaustive,
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}) {}
|
||||
|
||||
const RegisterPage = Component.make("RegisterPageView")(function*() {
|
||||
const RegisterForm = yield* Effect.provide(
|
||||
RegisterFormView.use,
|
||||
yield* Component.useLayer(RegisterFormService.Default),
|
||||
)
|
||||
|
||||
return <RegisterForm />
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
|
||||
export const Route = createFileRoute("/form")({
|
||||
component: RegisterPage
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
import { runtime } from "@/runtime"
|
||||
import { TodosState } from "@/todo/TodosState"
|
||||
import { TodosView } from "@/todo/TodosView"
|
||||
|
||||
|
||||
const TodosStateLive = TodosState.Default("todos")
|
||||
|
||||
const Index = Component.make("IndexView")(function*() {
|
||||
const Todos = yield* Effect.provide(
|
||||
TodosView.use,
|
||||
yield* Component.useLayer(TodosStateLive),
|
||||
)
|
||||
|
||||
return <Todos />
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Index
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { HttpClient, type HttpClientError } from "@effect/platform"
|
||||
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect"
|
||||
import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
const Post = Schema.Struct({
|
||||
userId: Schema.Int,
|
||||
id: Schema.Int,
|
||||
title: Schema.String,
|
||||
body: Schema.String,
|
||||
})
|
||||
|
||||
const ResultView = Component.make("ResultView")(function*() {
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
|
||||
const key = Stream.map(idLens.changes, id => [id] as const)
|
||||
|
||||
const query = yield* Query.service({
|
||||
key,
|
||||
f: ([id]) => HttpClient.HttpClient.pipe(
|
||||
Effect.tap(Effect.sleep("500 millis")),
|
||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
||||
Effect.andThen(response => response.json),
|
||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
||||
),
|
||||
staleTime: "10 seconds",
|
||||
})
|
||||
|
||||
const mutation = yield* Mutation.make({
|
||||
f: ([id]: readonly [id: number]) => HttpClient.HttpClient.pipe(
|
||||
Effect.tap(Effect.sleep("500 millis")),
|
||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
||||
Effect.andThen(response => response.json),
|
||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
||||
),
|
||||
})
|
||||
|
||||
return [idLens, query, mutation] as const
|
||||
}))
|
||||
|
||||
const [id, setId] = yield* Lens.useState(idLens)
|
||||
const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result])
|
||||
|
||||
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
||||
Effect.andThen(observer => observer.subscribe),
|
||||
Effect.andThen(Stream.fromQueue),
|
||||
Stream.unwrapScoped,
|
||||
Stream.runForEach(flow(
|
||||
Cause.failures,
|
||||
Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"),
|
||||
Option.match({
|
||||
onSome: e => Console.log("ResultView HttpClient error", e),
|
||||
onNone: () => Effect.void,
|
||||
}),
|
||||
)),
|
||||
Effect.forkScoped,
|
||||
))
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Flex direction="column" align="center" gap="2">
|
||||
<Slider
|
||||
value={[id]}
|
||||
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{Match.value(queryResult).pipe(
|
||||
Match.tag("Running", () => <Text>Loading...</Text>),
|
||||
Match.tag("Success", result => <>
|
||||
<Heading>{result.value.title}</Heading>
|
||||
<Text>{result.value.body}</Text>
|
||||
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
|
||||
</>),
|
||||
Match.tag("Failure", result =>
|
||||
<Text>An error has occured: {result.cause.toString()}</Text>
|
||||
),
|
||||
Match.orElse(() => <></>),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Flex direction="row" justify="center" align="center" gap="1">
|
||||
<Button onClick={() => runPromise(query.refresh)}>Refresh</Button>
|
||||
<Button onClick={() => runPromise(query.invalidateCache)}>Invalidate cache</Button>
|
||||
</Flex>
|
||||
|
||||
<div>
|
||||
{Match.value(mutationResult).pipe(
|
||||
Match.tag("Running", () => <Text>Loading...</Text>),
|
||||
Match.tag("Success", result => <>
|
||||
<Heading>{result.value.title}</Heading>
|
||||
<Text>{result.value.body}</Text>
|
||||
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
|
||||
</>),
|
||||
Match.tag("Failure", result =>
|
||||
<Text>An error has occured: {result.cause.toString()}</Text>
|
||||
),
|
||||
Match.orElse(() => <></>),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Flex direction="row" justify="center" align="center" gap="1">
|
||||
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/query")({
|
||||
component: Component.withRuntime(ResultView, runtime.context)
|
||||
})
|
||||
+1
-1
@@ -21,7 +21,7 @@ const ResultView = Component.makeUntraced("Result")(function*() {
|
||||
Effect.tap(Effect.sleep("250 millis")),
|
||||
Result.forkEffect,
|
||||
))
|
||||
const [result] = yield* Subscribable.useSubscribables([resultSubscribable])
|
||||
const [result] = yield* Subscribable.useAll([resultSubscribable])
|
||||
|
||||
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
||||
Effect.andThen(observer => observer.subscribe),
|
||||
@@ -0,0 +1,15 @@
|
||||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
|
||||
import { DateTime, Layer } from "effect"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
|
||||
|
||||
export const AppLive = Layer.empty.pipe(
|
||||
Layer.provideMerge(DateTime.layerCurrentZoneLocal),
|
||||
Layer.provideMerge(Clipboard.layer),
|
||||
Layer.provideMerge(Geolocation.layer),
|
||||
Layer.provideMerge(Permissions.layer),
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
@@ -0,0 +1,88 @@
|
||||
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>
|
||||
)
|
||||
}) {}
|
||||
@@ -0,0 +1,78 @@
|
||||
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,9 @@
|
||||
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)
|
||||
+20
-18
@@ -1,7 +1,7 @@
|
||||
import { KeyValueStore } from "@effect/platform"
|
||||
import { BrowserKeyValueStore } from "@effect/platform-browser"
|
||||
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
|
||||
import { Subscribable, SubscriptionSubRef } from "effect-fc"
|
||||
import { Lens, Subscribable } from "effect-fc"
|
||||
import { Todo } from "@/domain"
|
||||
|
||||
|
||||
@@ -30,27 +30,29 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
||||
: kv.remove(key)
|
||||
)
|
||||
|
||||
const ref = yield* SubscriptionRef.make(yield* readFromLocalStorage)
|
||||
yield* Effect.forkScoped(ref.changes.pipe(
|
||||
const lens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(yield* readFromLocalStorage))
|
||||
yield* Effect.forkScoped(lens.changes.pipe(
|
||||
Stream.debounce("500 millis"),
|
||||
Stream.runForEach(saveToLocalStorage),
|
||||
))
|
||||
yield* Effect.addFinalizer(() => ref.pipe(
|
||||
yield* Effect.addFinalizer(() => Lens.get(lens).pipe(
|
||||
Effect.andThen(saveToLocalStorage),
|
||||
Effect.ignore,
|
||||
))
|
||||
|
||||
const sizeSubscribable = Subscribable.make({
|
||||
get: Effect.andThen(ref, Chunk.size),
|
||||
get changes() { return Stream.map(ref.changes, Chunk.size) },
|
||||
})
|
||||
const getElementRef = (id: string) => SubscriptionSubRef.makeFromChunkFindFirst(ref, v => v.id === id)
|
||||
const getIndexSubscribable = (id: string) => Subscribable.make({
|
||||
get: Effect.flatMap(ref, Chunk.findFirstIndex(v => v.id === id)),
|
||||
get changes() { return Stream.flatMap(ref.changes, Chunk.findFirstIndex(v => v.id === id)) },
|
||||
})
|
||||
const sizeSubscribable = Subscribable.map(lens, Chunk.size)
|
||||
|
||||
const moveLeft = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
|
||||
const getElementLens = (id: string) => Lens.mapEffect(
|
||||
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("todo", ({ index }) => Chunk.get(todos, index)),
|
||||
Effect.bind("previous", ({ index }) => Chunk.get(todos, index - 1)),
|
||||
@@ -62,7 +64,7 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
||||
: todos
|
||||
),
|
||||
))
|
||||
const moveRight = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
|
||||
const moveRight = (id: string) => Lens.updateEffect(lens, todos => Effect.Do.pipe(
|
||||
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
|
||||
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
|
||||
Effect.bind("next", ({ index }) => Chunk.get(todos, index + 1)),
|
||||
@@ -74,15 +76,15 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
||||
: todos
|
||||
),
|
||||
))
|
||||
const remove = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.andThen(
|
||||
const remove = (id: string) => Lens.updateEffect(lens, todos => Effect.andThen(
|
||||
Chunk.findFirstIndex(todos, v => v.id === id),
|
||||
index => Chunk.remove(todos, index),
|
||||
))
|
||||
|
||||
return {
|
||||
ref,
|
||||
lens,
|
||||
sizeSubscribable,
|
||||
getElementRef,
|
||||
getElementLens,
|
||||
getIndexSubscribable,
|
||||
moveLeft,
|
||||
moveRight,
|
||||
+9
-7
@@ -1,30 +1,32 @@
|
||||
import { Container, Flex, Heading } from "@radix-ui/themes"
|
||||
import { Chunk, Console, Effect } from "effect"
|
||||
import { Component, Subscribable } from "effect-fc"
|
||||
import { Todo } from "./Todo"
|
||||
import { TodosState } from "./TodosState.service"
|
||||
import { EditTodoView } from "./EditTodoView"
|
||||
import { NewTodoView } from "./NewTodoView"
|
||||
import { TodosState } from "./TodosState"
|
||||
|
||||
|
||||
export class Todos extends Component.makeUntraced("Todos")(function*() {
|
||||
export class TodosView extends Component.make("TodosView")(function*() {
|
||||
const state = yield* TodosState
|
||||
const [todos] = yield* Subscribable.useSubscribables([state.ref])
|
||||
const [todos] = yield* Subscribable.useAll([state.lens])
|
||||
|
||||
yield* Component.useOnMount(() => Effect.andThen(
|
||||
Console.log("Todos mounted"),
|
||||
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
||||
))
|
||||
|
||||
const TodoFC = yield* Todo
|
||||
const NewTodo = yield* NewTodoView.use
|
||||
const EditTodo = yield* EditTodoView.use
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Heading align="center">Todos</Heading>
|
||||
|
||||
<Flex direction="column" align="stretch" gap="2" mt="2">
|
||||
<TodoFC _tag="new" />
|
||||
<NewTodo />
|
||||
|
||||
{Chunk.map(todos, todo =>
|
||||
<TodoFC key={todo.id} _tag="edit" id={todo.id} />
|
||||
<EditTodo key={todo.id} id={todo.id} />
|
||||
)}
|
||||
</Flex>
|
||||
</Container>
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
"plugins": [
|
||||
{ "name": "@effect/language-service" }
|
||||
]
|
||||
},
|
||||
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import path from "node:path"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tanstackRouter({
|
||||
target: "react",
|
||||
autoCodeSplitting: true,
|
||||
}),
|
||||
react(),
|
||||
],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,59 +1,51 @@
|
||||
# Effect FC
|
||||
|
||||
[Effect-TS](https://effect.website/) integration for React 19+ that allows you to write function components using Effect generators.
|
||||
[Effect-TS](https://effect.website/) integration for React 19.2+ 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.
|
||||
|
||||
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/effect-fc-example` directory.
|
||||
|
||||
## Peer dependencies
|
||||
- `effect` 3.15+
|
||||
- `react` & `@types/react` 19+
|
||||
- `effect` 3.19+
|
||||
- `react` & `@types/react` 19.2+
|
||||
|
||||
## Known issues
|
||||
- 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
|
||||
```typescript
|
||||
import { Component } from "effect-fc"
|
||||
import { useOnce, useSubscribables } from "effect-fc/Hooks"
|
||||
import { Todo } from "./Todo"
|
||||
import { TodosState } from "./TodosState.service"
|
||||
|
||||
|
||||
export class Todos extends Component.makeUntraced("Todos")(function*() {
|
||||
export class TodosView extends Component.make("TodosView")(function*() {
|
||||
const state = yield* TodosState
|
||||
const [todos] = yield* useSubscribables(state.ref)
|
||||
const [todos] = yield* Component.useSubscribables([state.subscriptionRef])
|
||||
|
||||
yield* useOnce(() => Effect.andThen(
|
||||
yield* Component.useOnMount(() => Effect.andThen(
|
||||
Console.log("Todos mounted"),
|
||||
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
||||
))
|
||||
|
||||
const TodoFC = yield* Todo
|
||||
const Todo = yield* TodoView.use
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Heading align="center">Todos</Heading>
|
||||
|
||||
<Flex direction="column" align="stretch" gap="2" mt="2">
|
||||
<TodoFC _tag="new" />
|
||||
<Todo _tag="new" />
|
||||
|
||||
{Chunk.map(todos, todo =>
|
||||
<TodoFC key={todo.id} _tag="edit" id={todo.id} />
|
||||
<Todo key={todo.id} _tag="edit" id={todo.id} />
|
||||
)}
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
}) {}
|
||||
|
||||
const TodosStateLive = TodosState.Default("todos")
|
||||
const Index = Component.make("IndexView")(function*() {
|
||||
const context = yield* Component.useLayer(TodosState.Default)
|
||||
const Todos = yield* Effect.provide(TodosView.use, context)
|
||||
|
||||
const Index = Component.makeUntraced("Index")(function*() {
|
||||
const context = yield* useContext(TodosStateLive, { finalizerExecutionMode: "fork" })
|
||||
const TodosFC = yield* Effect.provide(Todos, context)
|
||||
|
||||
return <TodosFC />
|
||||
return <Todos />
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "effect-fc",
|
||||
"description": "Write React function components with Effect",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"./README.md",
|
||||
@@ -9,7 +9,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "git+https://github.com/Thiladev/effect-fc.git"
|
||||
"url": "git+https://github.com/Thiladev/effect-view.git"
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
@@ -29,20 +29,27 @@
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint:tsc": "tsc --noEmit",
|
||||
"lint:tsc": "tsc -b --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
"test": "vitest run",
|
||||
"build": "tsc -b tsconfig.build.json",
|
||||
"pack": "npm pack",
|
||||
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
|
||||
"clean:cache": "rm -rf .turbo *.tsbuildinfo",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-browser": "^0.74.0"
|
||||
"@effect/platform-browser": "^0.76.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
"effect": "^3.19.0",
|
||||
"effect": "^3.21.0",
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect-lens": "^0.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
+117
-31
@@ -1,35 +1,49 @@
|
||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||
import { Effect, Function, Predicate, Runtime, Scope } from "effect"
|
||||
import { Effect, type Equivalence, Function, Predicate, Runtime, Scope } from "effect"
|
||||
import * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
|
||||
|
||||
export const TypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
|
||||
export type TypeId = typeof TypeId
|
||||
export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
|
||||
export type AsyncTypeId = typeof AsyncTypeId
|
||||
|
||||
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 {
|
||||
export interface Options {
|
||||
readonly defaultFallback?: React.ReactNode
|
||||
}
|
||||
|
||||
export type Props = Omit<React.SuspenseProps, "children">
|
||||
/**
|
||||
* Configuration options for `Async` components.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for `Async` components.
|
||||
*/
|
||||
export type AsyncProps = Omit<React.SuspenseProps, "children">
|
||||
|
||||
const AsyncProto = Object.freeze({
|
||||
[TypeId]: TypeId,
|
||||
|
||||
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
|
||||
this: Component.Component<P, A, E, R> & Async,
|
||||
export const AsyncPrototype: AsyncPrototype = Object.freeze({
|
||||
[AsyncTypeId]: AsyncTypeId,
|
||||
|
||||
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>>>,
|
||||
) {
|
||||
const SuspenseInner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
|
||||
const Inner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
|
||||
|
||||
return ({ fallback, name, ...props }: Async.Props) => {
|
||||
return ({ fallback, name, ...props }: AsyncProps) => {
|
||||
const promise = Runtime.runPromise(runtimeRef.current)(
|
||||
Effect.andThen(
|
||||
Component.useScope([], this),
|
||||
@@ -40,45 +54,117 @@ const AsyncProto = Object.freeze({
|
||||
return React.createElement(
|
||||
React.Suspense,
|
||||
{ fallback: fallback ?? this.defaultFallback, name },
|
||||
React.createElement(SuspenseInner, { promise }),
|
||||
React.createElement(Inner, { promise }),
|
||||
)
|
||||
}
|
||||
},
|
||||
} 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
|
||||
|
||||
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, TypeId)
|
||||
for (const key in self) {
|
||||
if (key === "fallback")
|
||||
continue
|
||||
if (!(key in that) || !Object.is(self[key], that[key]))
|
||||
return false
|
||||
}
|
||||
|
||||
export const async = <T extends Component.Component<any, any, any, any>>(
|
||||
self: T
|
||||
for (const key in that) {
|
||||
if (key === "fallback")
|
||||
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>>
|
||||
& Component.Component<
|
||||
Component.Component.Props<T> & Async.Props,
|
||||
Component.Component.Props<T> & AsyncProps,
|
||||
Component.Component.Success<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
|
||||
) => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self),
|
||||
Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }),
|
||||
Object.freeze(Object.setPrototypeOf(
|
||||
Object.assign({}, AsyncProto),
|
||||
Object.assign({}, AsyncPrototype),
|
||||
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: {
|
||||
<T extends Component.Component<any, any, any, any> & Async>(
|
||||
options: Partial<Async.Options>
|
||||
<T extends Component.Component.Any & Async>(
|
||||
options: Partial<AsyncOptions>
|
||||
): (self: T) => T
|
||||
<T extends Component.Component<any, any, any, any> & Async>(
|
||||
<T extends Component.Component.Any & Async>(
|
||||
self: T,
|
||||
options: Partial<Async.Options>,
|
||||
options: Partial<AsyncOptions>,
|
||||
): T
|
||||
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Async>(
|
||||
} = Function.dual(2, <T extends Component.Component.Any & Async>(
|
||||
self: T,
|
||||
options: Partial<Async.Options>,
|
||||
options: Partial<AsyncOptions>,
|
||||
): T => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self, options),
|
||||
Object.getPrototypeOf(self),
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
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 "./Component.js"
|
||||
import * as ReactRuntime from "./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")
|
||||
vi.useFakeTimers()
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
expect(screen.getByText("second")).toBeTruthy()
|
||||
expect(cleanup).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(19)
|
||||
expect(cleanup).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(cleanup).toHaveBeenCalledWith("first")
|
||||
|
||||
view.unmount()
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(cleanup).toHaveBeenCalledWith("second")
|
||||
vi.useRealTimers()
|
||||
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.useLayer(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)
|
||||
})
|
||||
})
|
||||
+636
-124
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"
|
||||
|
||||
|
||||
export const TypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
|
||||
export type TypeId = typeof TypeId
|
||||
export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
|
||||
export type ErrorObserverTypeId = typeof ErrorObserverTypeId
|
||||
|
||||
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly [ErrorObserverTypeId]: ErrorObserverTypeId
|
||||
handle<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
|
||||
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
|
||||
}
|
||||
|
||||
export const ErrorObserver = <E = never>(): Context.Tag<ErrorObserver, ErrorObserver<E>> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver")
|
||||
|
||||
class ErrorObserverImpl<in out E = never>
|
||||
export class ErrorObserverImpl<in out E = never>
|
||||
extends Pipeable.Class() implements ErrorObserver<E> {
|
||||
readonly [TypeId]: TypeId = TypeId
|
||||
readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId
|
||||
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
|
||||
|
||||
constructor(
|
||||
@@ -29,7 +29,7 @@ extends Pipeable.Class() implements ErrorObserver<E> {
|
||||
}
|
||||
}
|
||||
|
||||
class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
|
||||
export class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
|
||||
readonly value = Effect.void
|
||||
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) {
|
||||
super()
|
||||
@@ -43,7 +43,7 @@ class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
|
||||
}
|
||||
|
||||
|
||||
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, TypeId)
|
||||
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, ErrorObserverTypeId)
|
||||
|
||||
export const layer: Layer.Layer<ErrorObserver> = Layer.unwrapEffect(Effect.map(
|
||||
PubSub.unbounded<Cause.Cause<never>>(),
|
||||
|
||||
+176
-298
@@ -1,293 +1,157 @@
|
||||
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 { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, type ParseResult, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
|
||||
import type * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
import * as Mutation from "./Mutation.js"
|
||||
import * as PropertyPath from "./PropertyPath.js"
|
||||
import * as Result from "./Result.js"
|
||||
import * as Lens from "./Lens.js"
|
||||
import * as Subscribable from "./Subscribable.js"
|
||||
import * as SubscriptionRef from "./SubscriptionRef.js"
|
||||
import * as SubscriptionSubRef from "./SubscriptionSubRef.js"
|
||||
|
||||
|
||||
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
|
||||
export type FormTypeId = typeof FormTypeId
|
||||
|
||||
export interface Form<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
||||
export interface Form<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
|
||||
extends Pipeable.Pipeable {
|
||||
readonly [FormTypeId]: FormTypeId
|
||||
|
||||
readonly schema: Schema.Schema<A, I, R>
|
||||
readonly context: Context.Context<Scope.Scope | R>
|
||||
readonly mutation: Mutation.Mutation<
|
||||
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>],
|
||||
MA, ME, MR, MP
|
||||
>
|
||||
readonly autosubmit: boolean
|
||||
readonly debounce: Option.Option<Duration.DurationInput>
|
||||
|
||||
readonly value: Subscribable.Subscribable<Option.Option<A>>
|
||||
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
|
||||
readonly error: Subscribable.Subscribable<Option.Option<ParseResult.ParseError>>
|
||||
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>
|
||||
|
||||
readonly canSubmit: Subscribable.Subscribable<boolean>
|
||||
|
||||
field<const P extends PropertyPath.Paths<I>>(
|
||||
path: P
|
||||
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>>
|
||||
|
||||
readonly run: Effect.Effect<void>
|
||||
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
|
||||
readonly path: P
|
||||
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>
|
||||
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>
|
||||
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>
|
||||
readonly isValidating: Subscribable.Subscribable<boolean, ER, never>
|
||||
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
|
||||
readonly isCommitting: Subscribable.Subscribable<boolean, never, 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<A, I, R, MA, ME, MR, MP> {
|
||||
export class FormImpl<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
|
||||
extends Pipeable.Class() implements Form<P, A, I, ER, EW> {
|
||||
readonly [FormTypeId]: FormTypeId = FormTypeId
|
||||
|
||||
constructor(
|
||||
readonly schema: Schema.Schema<A, I, R>,
|
||||
readonly context: Context.Context<Scope.Scope | R>,
|
||||
readonly mutation: Mutation.Mutation<
|
||||
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>],
|
||||
MA, ME, MR, MP
|
||||
>,
|
||||
readonly autosubmit: boolean,
|
||||
readonly debounce: Option.Option<Duration.DurationInput>,
|
||||
|
||||
readonly value: SubscriptionRef.SubscriptionRef<Option.Option<A>>,
|
||||
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
|
||||
readonly error: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>,
|
||||
readonly validationFiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>,
|
||||
|
||||
readonly runSemaphore: Effect.Semaphore,
|
||||
readonly fieldCache: Ref.Ref<HashMap.HashMap<FormFieldKey, FormField<unknown, unknown>>>,
|
||||
readonly path: P,
|
||||
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>,
|
||||
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>,
|
||||
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>,
|
||||
readonly isValidating: Subscribable.Subscribable<boolean, never, never>,
|
||||
readonly canCommit: Subscribable.Subscribable<boolean, never, never>,
|
||||
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>,
|
||||
) {
|
||||
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 isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
|
||||
|
||||
|
||||
const filterIssuesByPath = (
|
||||
issues: readonly ParseResult.ArrayFormatterIssue[],
|
||||
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: {
|
||||
<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>,
|
||||
key: K,
|
||||
): Form<readonly [...P, K], A[K], I[K], ER, EW>
|
||||
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
||||
key: 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 object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
||||
self: Form<P, A, I, ER, EW>,
|
||||
key: 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, key] as const
|
||||
|
||||
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>>()),
|
||||
path,
|
||||
Subscribable.mapOption(form.value, a => a[key]),
|
||||
Lens.focusObjectOn(form.encodedValue, key),
|
||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||
form.isValidating,
|
||||
form.canCommit,
|
||||
form.isCommitting,
|
||||
)
|
||||
})
|
||||
|
||||
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 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
|
||||
|
||||
export const service = <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
|
||||
options: service.Options<A, I, R, MA, ME, MR, MP>
|
||||
): Effect.Effect<
|
||||
Form<A, I, R, MA, ME, Result.forkEffect.OutputContext<MA, ME, MR, MP>, MP>,
|
||||
never,
|
||||
Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP>
|
||||
> => Effect.tap(
|
||||
make(options),
|
||||
form => Effect.forkScoped(form.run),
|
||||
)
|
||||
|
||||
|
||||
export const FormFieldTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormField")
|
||||
export type FormFieldTypeId = typeof FormFieldTypeId
|
||||
|
||||
export interface FormField<in out A, in out I = A>
|
||||
extends Pipeable.Pipeable {
|
||||
readonly [FormFieldTypeId]: FormFieldTypeId
|
||||
|
||||
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>
|
||||
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
|
||||
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
|
||||
readonly isValidating: Subscribable.Subscribable<boolean>
|
||||
readonly isSubmitting: Subscribable.Subscribable<boolean>
|
||||
}
|
||||
|
||||
class FormFieldImpl<in out A, in out I = A>
|
||||
extends Pipeable.Class() implements FormField<A, I> {
|
||||
readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId
|
||||
|
||||
constructor(
|
||||
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>,
|
||||
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
|
||||
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>,
|
||||
readonly isValidating: Subscribable.Subscribable<boolean>,
|
||||
readonly isSubmitting: Subscribable.Subscribable<boolean>,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
|
||||
const FormFieldKeyTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormFieldKey")
|
||||
type FormFieldKeyTypeId = typeof FormFieldKeyTypeId
|
||||
|
||||
class FormFieldKey implements Equal.Equal {
|
||||
readonly [FormFieldKeyTypeId]: FormFieldKeyTypeId = FormFieldKeyTypeId
|
||||
constructor(readonly path: PropertyPath.PropertyPath) {}
|
||||
|
||||
[Equal.symbol](that: Equal.Equal) {
|
||||
return isFormFieldKey(that) && PropertyPath.equivalence(this.path, that.path)
|
||||
}
|
||||
[Hash.symbol]() {
|
||||
return Hash.array(this.path)
|
||||
}
|
||||
}
|
||||
|
||||
export const isFormField = (u: unknown): u is FormField<unknown, unknown> => Predicate.hasProperty(u, FormFieldTypeId)
|
||||
const isFormFieldKey = (u: unknown): u is FormFieldKey => Predicate.hasProperty(u, FormFieldKeyTypeId)
|
||||
|
||||
export const makeFormField = <A, I, R, MA, ME, MR, MP, const P extends PropertyPath.Paths<NoInfer<I>>>(
|
||||
self: Form<A, I, R, MA, ME, MR, MP>,
|
||||
path: P,
|
||||
): FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>> => {
|
||||
return new FormFieldImpl(
|
||||
Subscribable.mapEffect(self.value, Option.match({
|
||||
onSome: v => Option.map(PropertyPath.get(v, path), Option.some),
|
||||
onNone: () => Option.some(Option.none()),
|
||||
})),
|
||||
SubscriptionSubRef.makeFromPath(self.encodedValue, path),
|
||||
Subscribable.mapEffect(self.error, Option.match({
|
||||
onSome: flow(
|
||||
ParseResult.ArrayFormatter.formatError,
|
||||
Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))),
|
||||
),
|
||||
onNone: () => Effect.succeed([]),
|
||||
})),
|
||||
Subscribable.map(self.validationFiber, Option.isSome),
|
||||
Subscribable.map(self.mutation.result, result => Result.isRunning(result) || Result.hasRefreshingFlag(result)),
|
||||
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 {
|
||||
@@ -301,33 +165,39 @@ export namespace useInput {
|
||||
}
|
||||
}
|
||||
|
||||
export const useInput = Effect.fnUntraced(function* <A, I>(
|
||||
field: FormField<A, I>,
|
||||
export const useInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
|
||||
form: Form<P, A, I, ER, EW>,
|
||||
options?: useInput.Options,
|
||||
): Effect.fn.Return<useInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
|
||||
const internalValueRef = yield* Component.useOnChange(() => Effect.tap(
|
||||
Effect.andThen(field.encodedValue, SubscriptionRef.make),
|
||||
internalValueRef => Effect.forkScoped(Effect.all([
|
||||
): Effect.fn.Return<useInput.Success<I>, ER, Scope.Scope> {
|
||||
const internalValueLens = yield* Component.useOnChange(() => Effect.gen(function*() {
|
||||
const internalValueLens = yield* Lens.get(form.encodedValue).pipe(
|
||||
Effect.flatMap(SubscriptionRef.make),
|
||||
Effect.map(Lens.fromSubscriptionRef),
|
||||
)
|
||||
|
||||
yield* Effect.forkScoped(Effect.all([
|
||||
Stream.runForEach(
|
||||
Stream.drop(field.encodedValue, 1),
|
||||
Stream.drop(form.encodedValue.changes, 1),
|
||||
upstreamEncodedValue => Effect.whenEffect(
|
||||
Ref.set(internalValueRef, upstreamEncodedValue),
|
||||
Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
|
||||
Lens.set(internalValueLens, upstreamEncodedValue),
|
||||
Effect.andThen(Lens.get(internalValueLens), internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
|
||||
),
|
||||
),
|
||||
|
||||
Stream.runForEach(
|
||||
internalValueRef.changes.pipe(
|
||||
internalValueLens.changes.pipe(
|
||||
Stream.drop(1),
|
||||
Stream.changesWith(Equal.equivalence()),
|
||||
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
||||
),
|
||||
internalValue => Ref.set(field.encodedValue, internalValue),
|
||||
internalValue => Lens.set(form.encodedValue, internalValue),
|
||||
),
|
||||
], { concurrency: "unbounded" })),
|
||||
), [field, options?.debounce])
|
||||
], { concurrency: "unbounded", discard: true }))
|
||||
|
||||
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
|
||||
return internalValueLens
|
||||
}), [form, options?.debounce])
|
||||
|
||||
const [value, setValue] = yield* Lens.useState(internalValueLens)
|
||||
return { value, setValue }
|
||||
})
|
||||
|
||||
@@ -342,55 +212,63 @@ export namespace useOptionalInput {
|
||||
}
|
||||
}
|
||||
|
||||
export const useOptionalInput = Effect.fnUntraced(function* <A, I>(
|
||||
field: FormField<A, Option.Option<I>>,
|
||||
export const useOptionalInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
|
||||
field: Form<P, A, Option.Option<I>, ER, EW>,
|
||||
options: useOptionalInput.Options<I>,
|
||||
): Effect.fn.Return<useOptionalInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
|
||||
const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap(
|
||||
Effect.andThen(
|
||||
field.encodedValue,
|
||||
): Effect.fn.Return<useOptionalInput.Success<I>, ER, Scope.Scope> {
|
||||
const [enabledLens, internalValueLens] = yield* Component.useOnChange(() => Effect.gen(function*() {
|
||||
const [enabledLens, internalValueLens] = yield* Effect.flatMap(
|
||||
Lens.get(field.encodedValue),
|
||||
Option.match({
|
||||
onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]),
|
||||
onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]),
|
||||
onSome: v => Effect.all([
|
||||
Effect.map(SubscriptionRef.make(true), Lens.fromSubscriptionRef),
|
||||
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),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
([enabledRef, internalValueRef]) => Effect.forkScoped(Effect.all([
|
||||
yield* Effect.forkScoped(Effect.all([
|
||||
Stream.runForEach(
|
||||
Stream.drop(field.encodedValue, 1),
|
||||
Stream.drop(field.encodedValue.changes, 1),
|
||||
|
||||
upstreamEncodedValue => Effect.whenEffect(
|
||||
Option.match(upstreamEncodedValue, {
|
||||
onSome: v => Effect.andThen(
|
||||
Ref.set(enabledRef, true),
|
||||
Ref.set(internalValueRef, v),
|
||||
Lens.set(enabledLens, true),
|
||||
Lens.set(internalValueLens, v),
|
||||
),
|
||||
onNone: () => Effect.andThen(
|
||||
Ref.set(enabledRef, false),
|
||||
Ref.set(internalValueRef, options.defaultValue),
|
||||
Lens.set(enabledLens, false),
|
||||
Lens.set(internalValueLens, options.defaultValue),
|
||||
),
|
||||
}),
|
||||
|
||||
Effect.andThen(
|
||||
Effect.all([enabledRef, internalValueRef]),
|
||||
Effect.all([Lens.get(enabledLens), Lens.get(internalValueLens)]),
|
||||
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Stream.runForEach(
|
||||
enabledRef.changes.pipe(
|
||||
Stream.zipLatest(internalValueRef.changes),
|
||||
enabledLens.changes.pipe(
|
||||
Stream.zipLatest(internalValueLens.changes),
|
||||
Stream.drop(1),
|
||||
Stream.changesWith(Equal.equivalence()),
|
||||
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
||||
),
|
||||
([enabled, internalValue]) => Ref.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
||||
([enabled, internalValue]) => Lens.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
||||
),
|
||||
], { concurrency: "unbounded" })),
|
||||
), [field, options.debounce])
|
||||
], { concurrency: "unbounded" }))
|
||||
|
||||
const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef)
|
||||
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
|
||||
return [enabledLens, internalValueLens] as const
|
||||
}), [field, options.debounce])
|
||||
|
||||
const [enabled, setEnabled] = yield* Lens.useState(enabledLens)
|
||||
const [value, setValue] = yield* Lens.useState(internalValueLens)
|
||||
return { enabled, setEnabled, value, setValue }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
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 "./Component.js"
|
||||
import * as Lens from "./Lens.js"
|
||||
import * as ReactRuntime from "./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),
|
||||
}
|
||||
}
|
||||
|
||||
const expectDefined = <A,>(value: A | undefined): A => {
|
||||
if (value === undefined)
|
||||
throw new Error("Expected value to be defined")
|
||||
return value
|
||||
}
|
||||
|
||||
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 type="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 type="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(expectDefined(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(expectDefined(lens), { id: 1, label: "ignored" }))
|
||||
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||
expect(screen.queryByText("ignored")).toBeNull()
|
||||
|
||||
await Effect.runPromise(Lens.set(expectDefined(lens), { id: 2, label: "updated" }))
|
||||
await screen.findByText("updated")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
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,50 +1,111 @@
|
||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||
import { type Equivalence, Function, Predicate } from "effect"
|
||||
import * as React from "react"
|
||||
import type * as Component from "./Component.js"
|
||||
|
||||
|
||||
export const TypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
|
||||
export type TypeId = typeof TypeId
|
||||
export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
|
||||
export type MemoizedTypeId = typeof MemoizedTypeId
|
||||
|
||||
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 {
|
||||
export interface Options<P> {
|
||||
readonly propsAreEqual?: Equivalence.Equivalence<P>
|
||||
}
|
||||
/**
|
||||
* Configuration options for Memoized components.
|
||||
*
|
||||
* @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>
|
||||
}
|
||||
|
||||
|
||||
const MemoizedProto = Object.freeze({
|
||||
[TypeId]: TypeId
|
||||
export const MemoizedPrototype: MemoizedPrototype = Object.freeze({
|
||||
[MemoizedTypeId]: MemoizedTypeId,
|
||||
|
||||
transformFunctionComponent<P extends {}>(
|
||||
this: Memoized<P>,
|
||||
f: React.FC<P>,
|
||||
) {
|
||||
return React.memo(f, this.propsEquivalence)
|
||||
},
|
||||
} as const)
|
||||
|
||||
|
||||
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, TypeId)
|
||||
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, MemoizedTypeId)
|
||||
|
||||
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
|
||||
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self),
|
||||
Object.freeze(Object.setPrototypeOf(
|
||||
Object.assign({}, MemoizedProto),
|
||||
Object.assign({}, MemoizedPrototype),
|
||||
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: {
|
||||
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||
options: Partial<Memoized.Options<Component.Component.Props<T>>>
|
||||
<T extends Component.Component.Any & Memoized<any>>(
|
||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>
|
||||
): (self: T) => T
|
||||
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||
<T extends Component.Component.Any & Memoized<any>>(
|
||||
self: T,
|
||||
options: Partial<Memoized.Options<Component.Component.Props<T>>>,
|
||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
|
||||
): T
|
||||
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||
} = Function.dual(2, <T extends Component.Component.Any & Memoized<any>>(
|
||||
self: T,
|
||||
options: Partial<Memoized.Options<Component.Component.Props<T>>>,
|
||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
|
||||
): T => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self, options),
|
||||
Object.getPrototypeOf(self),
|
||||
|
||||
@@ -99,8 +99,10 @@ 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 declare namespace make {
|
||||
export interface Options<K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never> {
|
||||
readonly f: (key: K) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
|
||||
@@ -111,12 +113,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>(
|
||||
options: make.Options<K, A, E, R, P>
|
||||
): Effect.fn.Return<
|
||||
Mutation<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
|
||||
Mutation<K, A, E, Result.forkEffect.OutputContext<R, P>, P>,
|
||||
never,
|
||||
Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>
|
||||
Scope.Scope | Result.forkEffect.OutputContext<R, P>
|
||||
> {
|
||||
return new MutationImpl(
|
||||
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>>(),
|
||||
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<R, P>>(),
|
||||
options.f as any,
|
||||
options.initialProgress as P,
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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"
|
||||
|
||||
|
||||
export const usePubSubFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
|
||||
export const useFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
|
||||
values: A
|
||||
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
|
||||
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Effect, Option, type Scope, Stream } from "effect"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Query from "./Query.js"
|
||||
import * as QueryClient from "./QueryClient.js"
|
||||
import * as Result from "./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,4 +1,4 @@
|
||||
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 { type Cause, type Context, type Duration, Effect, Equal, Fiber, identity, Option, Pipeable, Predicate, type Scope, Stream, Subscribable, SubscriptionRef } from "effect"
|
||||
import * as QueryClient from "./QueryClient.js"
|
||||
import * as Result from "./Result.js"
|
||||
|
||||
@@ -6,12 +6,12 @@ import * as Result from "./Result.js"
|
||||
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
|
||||
export type QueryTypeId = typeof QueryTypeId
|
||||
|
||||
export interface Query<in out K extends Query.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
|
||||
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>
|
||||
extends Pipeable.Pipeable {
|
||||
readonly [QueryTypeId]: QueryTypeId
|
||||
|
||||
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>
|
||||
readonly key: Stream.Stream<K>
|
||||
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>
|
||||
readonly key: Stream.Stream<K, KE, KR>
|
||||
readonly f: (key: K) => Effect.Effect<A, E, R>
|
||||
readonly initialProgress: P
|
||||
|
||||
@@ -37,13 +37,13 @@ export declare namespace Query {
|
||||
export type AnyKey = readonly any[]
|
||||
}
|
||||
|
||||
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, E, R, P> {
|
||||
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>
|
||||
extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
|
||||
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
|
||||
|
||||
constructor(
|
||||
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>,
|
||||
readonly key: Stream.Stream<K>,
|
||||
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>,
|
||||
readonly key: Stream.Stream<K, KE, KR>,
|
||||
readonly f: (key: K) => Effect.Effect<A, E, R>,
|
||||
readonly initialProgress: P,
|
||||
|
||||
@@ -77,10 +77,11 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
|
||||
], { concurrency: "unbounded" }).pipe(
|
||||
Effect.ignore,
|
||||
this.runSemaphore.withPermits(1),
|
||||
Effect.provide(this.context),
|
||||
)
|
||||
}
|
||||
|
||||
get interrupt(): Effect.Effect<void, never, never> {
|
||||
get interrupt(): Effect.Effect<void> {
|
||||
return Effect.andThen(this.fiber, Option.match({
|
||||
onSome: Fiber.interrupt,
|
||||
onNone: () => Effect.void,
|
||||
@@ -159,7 +160,7 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
|
||||
> {
|
||||
return Effect.andThen(this.getCacheEntry(key), Option.match({
|
||||
onSome: entry => Effect.andThen(
|
||||
QueryClient.isQueryClientCacheEntryStale(entry, this.staleTime),
|
||||
QueryClient.isQueryClientCacheEntryStale(entry),
|
||||
isStale => isStale
|
||||
? this.start(key, Result.willRefresh(entry.result) as Result.Final<A, E, P>)
|
||||
: Effect.succeed(Subscribable.make({
|
||||
@@ -212,7 +213,7 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
|
||||
) as Effect.Effect<Result.Final<A, E, P>>),
|
||||
Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
|
||||
Effect.tap(result => Result.isSuccess(result)
|
||||
? this.updateCacheEntry(key, result)
|
||||
? this.setCacheEntry(key, result)
|
||||
: Effect.void
|
||||
),
|
||||
)
|
||||
@@ -225,54 +226,53 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
|
||||
getCacheEntry(
|
||||
key: K
|
||||
): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> {
|
||||
return QueryClient.QueryClient.pipe(
|
||||
Effect.andThen(client => client.cache),
|
||||
Effect.map(HashMap.get(this.makeCacheKey(key))),
|
||||
return Effect.andThen(
|
||||
Effect.all([
|
||||
Effect.succeed(this.makeCacheKey(key)),
|
||||
QueryClient.QueryClient,
|
||||
]),
|
||||
([key, client]) => client.getCacheEntry(key),
|
||||
)
|
||||
}
|
||||
|
||||
updateCacheEntry(
|
||||
setCacheEntry(
|
||||
key: K,
|
||||
result: Result.Success<A>,
|
||||
): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> {
|
||||
return Effect.Do.pipe(
|
||||
Effect.bind("client", () => QueryClient.QueryClient),
|
||||
Effect.bind("now", () => DateTime.now),
|
||||
Effect.let("entry", ({ now }) => new QueryClient.QueryClientCacheEntry(result, now)),
|
||||
Effect.tap(({ client, entry }) => SubscriptionRef.update(
|
||||
client.cache,
|
||||
HashMap.set(this.makeCacheKey(key), entry),
|
||||
)),
|
||||
Effect.map(({ entry }) => entry),
|
||||
return Effect.andThen(
|
||||
Effect.all([
|
||||
Effect.succeed(this.makeCacheKey(key)),
|
||||
QueryClient.QueryClient,
|
||||
]),
|
||||
([key, client]) => client.setCacheEntry(key, result, this.staleTime),
|
||||
)
|
||||
}
|
||||
|
||||
get invalidateCache(): Effect.Effect<void> {
|
||||
return QueryClient.QueryClient.pipe(
|
||||
Effect.andThen(client => SubscriptionRef.update(
|
||||
client.cache,
|
||||
HashMap.filter((_, key) => !Equivalence.strict()(key.f, this.f)),
|
||||
)),
|
||||
Effect.andThen(client => client.invalidateCacheEntries(this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>)),
|
||||
Effect.provide(this.context),
|
||||
)
|
||||
}
|
||||
|
||||
invalidateCacheEntry(key: K): Effect.Effect<void> {
|
||||
return QueryClient.QueryClient.pipe(
|
||||
Effect.andThen(client => SubscriptionRef.update(
|
||||
client.cache,
|
||||
HashMap.remove(this.makeCacheKey(key)),
|
||||
)),
|
||||
return Effect.all([
|
||||
Effect.succeed(this.makeCacheKey(key)),
|
||||
QueryClient.QueryClient,
|
||||
]).pipe(
|
||||
Effect.andThen(([key, client]) => client.invalidateCacheEntry(key)),
|
||||
Effect.provide(this.context),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown, 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 interface Options<K extends Query.AnyKey, A, E = never, R = never, P = never> {
|
||||
readonly key: Stream.Stream<K>
|
||||
export interface Options<K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never> {
|
||||
readonly key: Stream.Stream<K, KE, KR>
|
||||
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
|
||||
readonly initialProgress?: P
|
||||
readonly staleTime?: Duration.DurationInput
|
||||
@@ -280,17 +280,17 @@ export declare namespace make {
|
||||
}
|
||||
}
|
||||
|
||||
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, E = never, R = never, P = never>(
|
||||
options: make.Options<K, A, E, R, P>
|
||||
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
|
||||
options: make.Options<K, A, KE, KR, E, R, P>
|
||||
): Effect.fn.Return<
|
||||
Query<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
|
||||
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
|
||||
never,
|
||||
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>
|
||||
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
|
||||
> {
|
||||
const client = yield* QueryClient.QueryClient
|
||||
|
||||
return new QueryImpl(
|
||||
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>>(),
|
||||
return new QueryImpl<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>(
|
||||
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>>(),
|
||||
options.key,
|
||||
options.f as any,
|
||||
options.initialProgress as P,
|
||||
@@ -307,12 +307,12 @@ export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, E =
|
||||
)
|
||||
})
|
||||
|
||||
export const service = <K extends Query.AnyKey, A, E = never, R = never, P = never>(
|
||||
options: make.Options<K, A, E, R, P>
|
||||
export const service = <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
|
||||
options: make.Options<K, A, KE, KR, E, R, P>
|
||||
): Effect.Effect<
|
||||
Query<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
|
||||
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
|
||||
never,
|
||||
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>
|
||||
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
|
||||
> => Effect.tap(
|
||||
make(options),
|
||||
query => Effect.forkScoped(query.run),
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, Pipeable, Predicate, type Scope, SubscriptionRef } from "effect"
|
||||
import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, type Option, Pipeable, Predicate, Schedule, type Scope, type Subscribable, SubscriptionRef } from "effect"
|
||||
import type * as Query from "./Query.js"
|
||||
import type * as Result from "./Result.js"
|
||||
|
||||
|
||||
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientServiceTypeId")
|
||||
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService")
|
||||
export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
|
||||
|
||||
export interface QueryClientService extends Pipeable.Pipeable {
|
||||
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
|
||||
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
|
||||
readonly gcTime: Duration.DurationInput
|
||||
|
||||
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
|
||||
readonly cacheGcTime: Duration.DurationInput
|
||||
readonly defaultStaleTime: Duration.DurationInput
|
||||
readonly defaultRefreshOnWindowFocus: boolean
|
||||
|
||||
readonly run: Effect.Effect<void>
|
||||
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>>
|
||||
setCacheEntry(
|
||||
key: QueryClientCacheKey,
|
||||
result: Result.Success<unknown>,
|
||||
staleTime: Duration.DurationInput,
|
||||
): Effect.Effect<QueryClientCacheEntry>
|
||||
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void>
|
||||
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class QueryClient extends Effect.Service<QueryClient>()("@effect-fc/QueryClient/QueryClient", {
|
||||
@@ -25,20 +36,64 @@ implements QueryClientService {
|
||||
|
||||
constructor(
|
||||
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
|
||||
readonly gcTime: Duration.DurationInput,
|
||||
readonly cacheGcTime: Duration.DurationInput,
|
||||
readonly defaultStaleTime: Duration.DurationInput,
|
||||
readonly defaultRefreshOnWindowFocus: boolean,
|
||||
readonly runSemaphore: Effect.Semaphore,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
get run(): Effect.Effect<void> {
|
||||
return this.runSemaphore.withPermits(1)(Effect.repeat(
|
||||
Effect.andThen(
|
||||
DateTime.now,
|
||||
now => SubscriptionRef.update(this.cache, HashMap.filter(entry =>
|
||||
Duration.lessThan(
|
||||
DateTime.distanceDuration(entry.lastAccessedAt, now),
|
||||
Duration.sum(entry.staleTime, this.cacheGcTime),
|
||||
)
|
||||
)),
|
||||
),
|
||||
Schedule.spaced("30 second"),
|
||||
))
|
||||
}
|
||||
|
||||
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>> {
|
||||
return Effect.all([
|
||||
Effect.andThen(this.cache, HashMap.get(key)),
|
||||
DateTime.now,
|
||||
]).pipe(
|
||||
Effect.map(([entry, now]) => new QueryClientCacheEntry(entry.result, entry.staleTime, entry.createdAt, now)),
|
||||
Effect.tap(entry => SubscriptionRef.update(this.cache, HashMap.set(key, entry))),
|
||||
Effect.option,
|
||||
)
|
||||
}
|
||||
|
||||
setCacheEntry(
|
||||
key: QueryClientCacheKey,
|
||||
result: Result.Success<unknown>,
|
||||
staleTime: Duration.DurationInput,
|
||||
): Effect.Effect<QueryClientCacheEntry> {
|
||||
return DateTime.now.pipe(
|
||||
Effect.map(now => new QueryClientCacheEntry(result, staleTime, now, now)),
|
||||
Effect.tap(entry => SubscriptionRef.update(this.cache, HashMap.set(key, entry))),
|
||||
)
|
||||
}
|
||||
|
||||
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void> {
|
||||
return SubscriptionRef.update(this.cache, HashMap.filter((_, key) => !Equivalence.strict()(key.f, f)))
|
||||
}
|
||||
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void> {
|
||||
return SubscriptionRef.update(this.cache, HashMap.remove(key))
|
||||
}
|
||||
}
|
||||
|
||||
export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId)
|
||||
|
||||
export declare namespace make {
|
||||
export interface Options {
|
||||
readonly gcTime?: Duration.DurationInput
|
||||
readonly cacheGcTime?: Duration.DurationInput
|
||||
readonly defaultStaleTime?: Duration.DurationInput
|
||||
readonly defaultRefreshOnWindowFocus?: boolean
|
||||
}
|
||||
@@ -47,22 +102,20 @@ export declare namespace make {
|
||||
export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return<QueryClientService> {
|
||||
return new QueryClientServiceImpl(
|
||||
yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()),
|
||||
options.gcTime ?? "5 minutes",
|
||||
options.cacheGcTime ?? "5 minutes",
|
||||
options.defaultStaleTime ?? "0 minutes",
|
||||
options.defaultRefreshOnWindowFocus ?? true,
|
||||
yield* Effect.makeSemaphore(1),
|
||||
)
|
||||
})
|
||||
|
||||
export const run = (_self: QueryClientService): Effect.Effect<void> => Effect.void
|
||||
|
||||
export declare namespace service {
|
||||
export interface Options extends make.Options {}
|
||||
}
|
||||
|
||||
export const service = (options?: service.Options): Effect.Effect<QueryClientService, never, Scope.Scope> => Effect.tap(
|
||||
make(options),
|
||||
client => Effect.forkScoped(run(client)),
|
||||
client => Effect.forkScoped(client.run),
|
||||
)
|
||||
|
||||
|
||||
@@ -102,7 +155,9 @@ implements Pipeable.Pipeable {
|
||||
|
||||
constructor(
|
||||
readonly result: Result.Success<unknown>,
|
||||
readonly staleTime: Duration.DurationInput,
|
||||
readonly createdAt: DateTime.DateTime,
|
||||
readonly lastAccessedAt: DateTime.DateTime,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@@ -111,9 +166,8 @@ implements Pipeable.Pipeable {
|
||||
export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId)
|
||||
|
||||
export const isQueryClientCacheEntryStale = (
|
||||
self: QueryClientCacheEntry,
|
||||
staleTime: Duration.DurationInput,
|
||||
self: QueryClientCacheEntry
|
||||
): Effect.Effect<boolean> => Effect.andThen(
|
||||
DateTime.now,
|
||||
now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), staleTime),
|
||||
now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), self.staleTime),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, Subscribable } from "effect"
|
||||
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 { Lens } from "effect-lens"
|
||||
|
||||
|
||||
export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result")
|
||||
@@ -15,10 +16,6 @@ export type Final<A, E = never, P = never> = (Success<A> | Failure<E>) & ({} | F
|
||||
export type Flags<P = never> = WillFetch | WillRefresh | Refreshing<P>
|
||||
|
||||
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 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
|
||||
@@ -28,21 +25,21 @@ export declare namespace Flags {
|
||||
export type Keys = keyof WillFetch & WillRefresh & Refreshing<any>
|
||||
}
|
||||
|
||||
export interface Initial extends Result.Prototype {
|
||||
export interface Initial extends ResultPrototype {
|
||||
readonly _tag: "Initial"
|
||||
}
|
||||
|
||||
export interface Running<P = never> extends Result.Prototype {
|
||||
export interface Running<P = never> extends ResultPrototype {
|
||||
readonly _tag: "Running"
|
||||
readonly progress: P
|
||||
}
|
||||
|
||||
export interface Success<A> extends Result.Prototype {
|
||||
export interface Success<A> extends ResultPrototype {
|
||||
readonly _tag: "Success"
|
||||
readonly value: A
|
||||
}
|
||||
|
||||
export interface Failure<E = never> extends Result.Prototype {
|
||||
export interface Failure<E = never> extends ResultPrototype {
|
||||
readonly _tag: "Failure"
|
||||
readonly cause: Cause.Cause<E>
|
||||
}
|
||||
@@ -61,7 +58,11 @@ export interface Refreshing<P = never> {
|
||||
}
|
||||
|
||||
|
||||
const ResultPrototype = Object.freeze({
|
||||
export interface ResultPrototype extends Pipeable.Pipeable, Equal.Equal {
|
||||
readonly [ResultTypeId]: ResultTypeId
|
||||
}
|
||||
|
||||
export const ResultPrototype: ResultPrototype = Object.freeze({
|
||||
...Pipeable.Prototype,
|
||||
[ResultTypeId]: ResultTypeId,
|
||||
|
||||
@@ -95,7 +96,7 @@ const ResultPrototype = Object.freeze({
|
||||
Hash.cached(this),
|
||||
)
|
||||
},
|
||||
} as const satisfies Result.Prototype)
|
||||
} as const)
|
||||
|
||||
|
||||
export const isResult = (u: unknown): u is Result<unknown, unknown, unknown> => Predicate.hasProperty(u, ResultTypeId)
|
||||
@@ -162,52 +163,40 @@ export const toExit: {
|
||||
}
|
||||
|
||||
|
||||
export interface State<A, E = never, P = 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>
|
||||
readonly progress: Lens.Lens<P, PreviousResultNotRunningNorRefreshing, never, never, never>
|
||||
}
|
||||
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")<{
|
||||
readonly previous: Result<unknown, unknown, unknown>
|
||||
}> {}
|
||||
|
||||
export const Progress = <P = never>(): Context.Tag<Progress<P>, Progress<P>> => Context.GenericTag("@effect-fc/Result/Progress")
|
||||
|
||||
export const makeProgressLayer = <A, E, P = never>(): Layer.Layer<
|
||||
Progress<P>,
|
||||
never,
|
||||
State<A, E, P>
|
||||
> => Layer.effect(Progress<P>(), Effect.gen(function*() {
|
||||
const state = yield* State<A, E, P>()
|
||||
|
||||
return {
|
||||
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>
|
||||
export const makeProgressLayer = <A, E, P = never>(
|
||||
state: Lens.Lens<Result<A, E, P>, never, never, never, never>
|
||||
): Layer.Layer<Progress<P> | Progress<never>, never, never> => Layer.succeed(
|
||||
Progress<P>() as Context.Tag<Progress<P> | Progress<never>, Progress<P> | Progress<never>>,
|
||||
{
|
||||
progress: state.pipe(
|
||||
Lens.mapEffect(
|
||||
a => (isRunning(a) || hasRefreshingFlag(a))
|
||||
? Effect.succeed(a)
|
||||
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous: a })),
|
||||
(_, b) => Effect.succeed(b),
|
||||
),
|
||||
Effect.andThen(({ next }) => state.set(next)),
|
||||
),
|
||||
}
|
||||
}))
|
||||
Lens.map(
|
||||
a => a.progress,
|
||||
(a, b) => isRunning(a)
|
||||
? running(b)
|
||||
: refreshing(a, b) as Final<A, E, P> & Refreshing<P>,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
export namespace unsafeForkEffect {
|
||||
export type OutputContext<A, E, R, P> = Exclude<R, State<A, E, P> | Progress<P> | Progress<never>>
|
||||
export type OutputContext<R, P> = Exclude<R, Progress<P> | Progress<never>>
|
||||
|
||||
export interface Options<A, E, P> {
|
||||
readonly initial?: Initial | Final<A, E, P>
|
||||
@@ -215,55 +204,56 @@ export namespace unsafeForkEffect {
|
||||
}
|
||||
}
|
||||
|
||||
export const unsafeForkEffect = <A, E, R, P = never>(
|
||||
export const unsafeForkEffect = Effect.fnUntraced(function* <A, E, R, P = never>(
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
options?: unsafeForkEffect.Options<NoInfer<A>, NoInfer<E>, P>,
|
||||
): Effect.Effect<
|
||||
): Effect.fn.Return<
|
||||
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
|
||||
never,
|
||||
Scope.Scope | unsafeForkEffect.OutputContext<A, E, R, P>
|
||||
> => Effect.Do.pipe(
|
||||
Effect.bind("ref", () => Ref.make(options?.initial ?? initial<A, E, P>())),
|
||||
Effect.bind("pubsub", () => PubSub.unbounded<Result<A, E, P>>()),
|
||||
Effect.bind("fiber", ({ ref, pubsub }) => Effect.forkScoped(State<A, E, P>().pipe(
|
||||
Effect.andThen(state => state.set(
|
||||
Scope.Scope | unsafeForkEffect.OutputContext<R, P>
|
||||
> {
|
||||
const ref = (yield* SynchronizedRef.make(
|
||||
options?.initial ?? initial<A, E, P>()
|
||||
)) as Lens.SynchronizedRefLensImpl.SynchronizedRefWithInternals<Result<A, E, P>>
|
||||
const pubsub = yield* PubSub.unbounded<Result<A, E, P>>()
|
||||
|
||||
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))
|
||||
? refreshing(options.initial, options?.initialProgress) as Result<A, E, P>
|
||||
: running(options?.initialProgress)
|
||||
).pipe(
|
||||
Effect.andThen(effect),
|
||||
Effect.onExit(exit => Effect.andThen(
|
||||
state.set(fromExit(exit)),
|
||||
Effect.forkScoped(PubSub.shutdown(pubsub)),
|
||||
)),
|
||||
)),
|
||||
Effect.provide(Layer.empty.pipe(
|
||||
Layer.provideMerge(makeProgressLayer<A, E, P>()),
|
||||
Layer.provideMerge(Layer.succeed(State<A, E, P>(), {
|
||||
get: ref,
|
||||
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>
|
||||
>
|
||||
: running(options?.initialProgress),
|
||||
)
|
||||
return yield* Effect.onExit(effect, exit => Effect.andThen(
|
||||
Lens.set(state, fromExit(exit)),
|
||||
Effect.forkScoped(PubSub.shutdown(pubsub)),
|
||||
))
|
||||
}).pipe(
|
||||
Effect.forkScoped,
|
||||
Effect.provide(makeProgressLayer(state)),
|
||||
)
|
||||
|
||||
return [state, fiber] as const
|
||||
})
|
||||
|
||||
export namespace forkEffect {
|
||||
export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R
|
||||
export type OutputContext<A, E, R, P> = unsafeForkEffect.OutputContext<A, E, R, P>
|
||||
export type OutputContext<R, P> = unsafeForkEffect.OutputContext<R, P>
|
||||
export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {}
|
||||
}
|
||||
|
||||
@@ -274,6 +264,6 @@ export const forkEffect: {
|
||||
): Effect.Effect<
|
||||
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
|
||||
never,
|
||||
Scope.Scope | forkEffect.OutputContext<A, E, R, P>
|
||||
Scope.Scope | forkEffect.OutputContext<R, P>
|
||||
>
|
||||
} = unsafeForkEffect
|
||||
|
||||
@@ -3,8 +3,8 @@ import type * as React from "react"
|
||||
|
||||
|
||||
export const value: {
|
||||
<S>(prevState: S): (self: React.SetStateAction<S>) => S
|
||||
<S>(self: React.SetStateAction<S>, prevState: S): S
|
||||
<S>(prevState: S): (self: React.SetStateAction<S>) => S
|
||||
} = Function.dual(2, <S>(self: React.SetStateAction<S>, prevState: S): S =>
|
||||
typeof self === "function"
|
||||
? (self as (prevState: S) => S)(prevState)
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
|
||||
|
||||
export const useStream: {
|
||||
export const use: {
|
||||
<A, E, R>(
|
||||
stream: Stream.Stream<A, E, R>
|
||||
): Effect.Effect<Option.Option<A>, never, R>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
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),
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
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 "./Component.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
import * as Subscribable from "./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()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Effect, Equivalence, Stream, Subscribable } from "effect"
|
||||
import { Effect, Equivalence, Stream } from "effect"
|
||||
import { Subscribable } from "effect-lens"
|
||||
import * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
|
||||
|
||||
export * from "effect-lens/Subscribable"
|
||||
|
||||
export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
|
||||
...elements: T
|
||||
): Subscribable.Subscribable<
|
||||
@@ -16,7 +19,7 @@ export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<
|
||||
changes: Stream.zipLatestAll(...elements.map(v => v.changes)),
|
||||
}) as any
|
||||
|
||||
export declare namespace useSubscribables {
|
||||
export declare namespace useAll {
|
||||
export type Success<T extends readonly Subscribable.Subscribable<any, any, any>[]> = [T[number]] extends [never]
|
||||
? never
|
||||
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never }
|
||||
@@ -26,11 +29,11 @@ export declare namespace useSubscribables {
|
||||
}
|
||||
}
|
||||
|
||||
export const useSubscribables = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
|
||||
export const useAll = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
|
||||
elements: T,
|
||||
options?: useSubscribables.Options<useSubscribables.Success<NoInfer<T>>>,
|
||||
options?: useAll.Options<useAll.Success<NoInfer<T>>>,
|
||||
): Effect.fn.Return<
|
||||
useSubscribables.Success<T>,
|
||||
useAll.Success<T>,
|
||||
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer E, infer _R> ? E : never,
|
||||
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
|
||||
> {
|
||||
@@ -48,5 +51,3 @@ export const useSubscribables = Effect.fnUntraced(function* <const T extends rea
|
||||
|
||||
return reactStateValue as any
|
||||
})
|
||||
|
||||
export * from "effect/Subscribable"
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Effect, Equivalence, Ref, Stream, SubscriptionRef } from "effect"
|
||||
import * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
import * as SetStateAction from "./SetStateAction.js"
|
||||
|
||||
|
||||
export declare namespace useSubscriptionRefState {
|
||||
export interface Options<A> {
|
||||
readonly equivalence?: Equivalence.Equivalence<A>
|
||||
}
|
||||
}
|
||||
|
||||
export const useSubscriptionRefState = Effect.fnUntraced(function* <A>(
|
||||
ref: SubscriptionRef.SubscriptionRef<A>,
|
||||
options?: useSubscriptionRefState.Options<NoInfer<A>>,
|
||||
): Effect.fn.Return<readonly [A, React.Dispatch<React.SetStateAction<A>>]> {
|
||||
const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => ref))
|
||||
|
||||
yield* Component.useReactEffect(() => Effect.forkScoped(
|
||||
Stream.runForEach(
|
||||
Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()),
|
||||
v => Effect.sync(() => setReactStateValue(v)),
|
||||
)
|
||||
), [ref])
|
||||
|
||||
const setValue = yield* Component.useCallbackSync(
|
||||
(setStateAction: React.SetStateAction<A>) => Effect.andThen(
|
||||
Ref.updateAndGet(ref, prevState => SetStateAction.value(setStateAction, prevState)),
|
||||
v => setReactStateValue(v),
|
||||
),
|
||||
[ref],
|
||||
)
|
||||
|
||||
return [reactStateValue, setValue]
|
||||
})
|
||||
|
||||
export declare namespace useSubscriptionRefFromState {
|
||||
export interface Options<A> {
|
||||
readonly equivalence?: Equivalence.Equivalence<A>
|
||||
}
|
||||
}
|
||||
|
||||
export const useSubscriptionRefFromState = Effect.fnUntraced(function* <A>(
|
||||
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
|
||||
options?: useSubscriptionRefFromState.Options<NoInfer<A>>,
|
||||
): Effect.fn.Return<SubscriptionRef.SubscriptionRef<A>> {
|
||||
const ref = yield* Component.useOnChange(() => Effect.tap(
|
||||
SubscriptionRef.make(value),
|
||||
ref => Effect.forkScoped(
|
||||
Stream.runForEach(
|
||||
Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()),
|
||||
v => Effect.sync(() => setValue(v)),
|
||||
)
|
||||
),
|
||||
), [setValue])
|
||||
|
||||
yield* Component.useReactEffect(() => Ref.set(ref, value), [value])
|
||||
return ref
|
||||
})
|
||||
|
||||
export * from "effect/SubscriptionRef"
|
||||
@@ -1,186 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,223 @@
|
||||
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 ErrorObserver from "./ErrorObserver.js"
|
||||
export * as Form from "./Form.js"
|
||||
export * as Lens from "./Lens.js"
|
||||
export * as Memoized from "./Memoized.js"
|
||||
export * as Mutation from "./Mutation.js"
|
||||
export * as PropertyPath from "./PropertyPath.js"
|
||||
export * as PubSub from "./PubSub.js"
|
||||
export * as Query from "./Query.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 SetStateAction from "./SetStateAction.js"
|
||||
export * as Stream from "./Stream.js"
|
||||
export * as SubmittableForm from "./SubmittableForm.js"
|
||||
export * as Subscribable from "./Subscribable.js"
|
||||
export * as SubscriptionRef from "./SubscriptionRef.js"
|
||||
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"
|
||||
export * as SynchronizedForm from "./SynchronizedForm.js"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["**/setup-tests.ts", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"]
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
// Build
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
@@ -34,5 +35,5 @@
|
||||
]
|
||||
},
|
||||
|
||||
"include": ["./src"]
|
||||
"include": ["./src"],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["./src/**/*.test.ts?(x)"],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
<p align="center">
|
||||
<a href="https://thila.dev/effect-view">
|
||||
<img src="https://github.com/Thiladev/effect-view/raw/master/packages/docs/static/img/logo.svg" width="104" height="104" alt="Effect View logo" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Effect View</h1>
|
||||
|
||||
<p align="center">
|
||||
Write React components as typed Effect programs.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/effect-view"><img src="https://img.shields.io/npm/v/effect-view?color=08777b" alt="npm version" /></a>
|
||||
<a href="https://thila.dev/effect-view"><img src="https://img.shields.io/badge/docs-Effect_View-16a3a1" alt="Effect View documentation" /></a>
|
||||
<a href="https://github.com/Thiladev/effect-view/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/effect-view?color=e99526" alt="MIT license" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong><a href="https://thila.dev/effect-view">Read the documentation →</a></strong>
|
||||
</p>
|
||||
|
||||
Effect View brings Effect's typed services, resource safety, concurrency, and
|
||||
data modeling into React 19 without replacing React's component model. Yield
|
||||
Effects and services from a component body, let scopes follow the React
|
||||
lifecycle, and return ordinary JSX.
|
||||
|
||||
> **Effect v4 beta:** Effect View is built for the Effect v4 beta release. If
|
||||
> your application uses Effect v3, use the legacy
|
||||
> [`effect-fc` package](https://www.npmjs.com/package/effect-fc) instead.
|
||||
|
||||
```bash
|
||||
npm install effect-view effect@beta react
|
||||
```
|
||||
|
||||
Effect View does not depend on `react-dom`. Install the renderer used by your
|
||||
application—for example, `react-dom` for the web:
|
||||
|
||||
```bash
|
||||
npm install react-dom
|
||||
```
|
||||
|
||||
You can use Effect View with React Native or any other React renderer instead.
|
||||
|
||||
## What it looks like
|
||||
|
||||
An Effect View component is an Effect program that produces JSX. It can create
|
||||
scoped state, access services, and use React-facing hooks without leaving the
|
||||
generator model:
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens } from "effect-view"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
const CounterValueView = Component.make("CounterValue")(
|
||||
function* ({ count }: { readonly count: Lens.Lens<number> }) {
|
||||
yield* Component.useOnMount(() =>
|
||||
Effect.addFinalizer(() =>
|
||||
Effect.log("CounterValue unmounted"),
|
||||
),
|
||||
)
|
||||
|
||||
const [value, setValue] = yield* Lens.useState(count)
|
||||
|
||||
return (
|
||||
<button onClick={() => setValue((n) => n + 1)}>
|
||||
Count: {value}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const CounterView = Component.make("Counter")(function* () {
|
||||
const count = yield* Component.useOnMount(() =>
|
||||
Effect.map(
|
||||
SubscriptionRef.make(0),
|
||||
Lens.fromSubscriptionRef,
|
||||
),
|
||||
)
|
||||
|
||||
const CounterValue = yield* CounterValueView.use
|
||||
|
||||
return <CounterValue count={count} />
|
||||
})
|
||||
|
||||
export const Counter = CounterView.pipe(
|
||||
Component.withContext(runtime.context),
|
||||
)
|
||||
```
|
||||
|
||||
`CounterView` composes another Effect View component by yielding its `.use`
|
||||
Effect, then renders the resulting component as ordinary JSX. The child can
|
||||
access the current Effect context and receives its own component scope.
|
||||
|
||||
`Effect.addFinalizer` uses the scope already provided to the component body, so
|
||||
the finalizer above runs when `CounterValue` unmounts.
|
||||
|
||||
At the outer boundary, `Counter` is still a normal React component and its Effect requirements remain
|
||||
typed until they reach the runtime.
|
||||
|
||||
[Build the runtime and render your first component →](https://thila.dev/effect-view/docs/getting-started)
|
||||
|
||||
## Why Effect View
|
||||
|
||||
| | Capability | What it gives you |
|
||||
| --- | --- | --- |
|
||||
| **Effect components** | `Component.make` | Yield Effects and typed services directly, then return JSX. |
|
||||
| **Managed lifecycles** | `Scope.Scope` | Finalizers, fibers, subscriptions, and resources close with their component or dependency lifecycle. |
|
||||
| **One application runtime** | `ReactRuntime` | Build your Layer once and make its services available throughout the UI. |
|
||||
| **Reactive state** | `Lens` and `View` | Focus large state models into small writable values and subscribe only where React needs them. |
|
||||
| **Server state** | `Query` and `Mutation` | TanStack Query-style caching, invalidation, staleness, and mutations with typed Effects underneath. |
|
||||
| **Schema-driven forms** | `MutationForm` and `LensForm` | Keep input-friendly encoded values while application code receives validated, decoded types. |
|
||||
| **Async rendering** | `Async` and `Memoized` | Integrate asynchronous Effects with Suspense while retaining Effect errors and cancellation. |
|
||||
|
||||
## Designed for both ecosystems
|
||||
|
||||
Effect View does not hide Effect behind callbacks or recreate React around a
|
||||
new rendering model. React still owns rendering, JSX, hooks, Suspense, and
|
||||
events. Effect owns services, errors, resource safety, concurrency, streams,
|
||||
and schemas. Effect View provides the lifecycle-aware boundary between them.
|
||||
|
||||
## Documentation
|
||||
|
||||
The complete documentation is available at **[thila.dev/effect-view](https://thila.dev/effect-view)**.
|
||||
|
||||
## Requirements
|
||||
|
||||
- React 19.2 or newer
|
||||
- **Effect v4 beta** (`effect@beta`)
|
||||
- TypeScript and `@types/react` for TypeScript projects
|
||||
|
||||
Effect View is renderer-independent and does not require `react-dom`.
|
||||
|
||||
## Project status
|
||||
|
||||
Effect View is currently beta software. The main APIs are available, but
|
||||
breaking changes and rough edges are still possible before a stable release.
|
||||
|
||||
Issues, ideas, and contributions are welcome on
|
||||
[GitHub](https://github.com/Thiladev/effect-view).
|
||||
|
||||
## License
|
||||
|
||||
[MIT](https://github.com/Thiladev/effect-view/blob/main/LICENSE)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"files": {
|
||||
"includes": ["./src/**"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"name": "effect-view",
|
||||
"description": "Write React function components with Effect",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"./README.md",
|
||||
"./dist"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "git+https://github.com/Thiladev/effect-view.git"
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./Async": {
|
||||
"types": "./dist/Async.d.ts",
|
||||
"default": "./dist/Async.js"
|
||||
},
|
||||
"./Component": {
|
||||
"types": "./dist/Component.d.ts",
|
||||
"default": "./dist/Component.js"
|
||||
},
|
||||
"./Form": {
|
||||
"types": "./dist/Form.d.ts",
|
||||
"default": "./dist/Form.js"
|
||||
},
|
||||
"./Lens": {
|
||||
"types": "./dist/Lens.d.ts",
|
||||
"default": "./dist/Lens.js"
|
||||
},
|
||||
"./LensForm": {
|
||||
"types": "./dist/LensForm.d.ts",
|
||||
"default": "./dist/LensForm.js"
|
||||
},
|
||||
"./Memoized": {
|
||||
"types": "./dist/Memoized.d.ts",
|
||||
"default": "./dist/Memoized.js"
|
||||
},
|
||||
"./Mutation": {
|
||||
"types": "./dist/Mutation.d.ts",
|
||||
"default": "./dist/Mutation.js"
|
||||
},
|
||||
"./MutationForm": {
|
||||
"types": "./dist/MutationForm.d.ts",
|
||||
"default": "./dist/MutationForm.js"
|
||||
},
|
||||
"./PubSub": {
|
||||
"types": "./dist/PubSub.d.ts",
|
||||
"default": "./dist/PubSub.js"
|
||||
},
|
||||
"./Query": {
|
||||
"types": "./dist/Query.d.ts",
|
||||
"default": "./dist/Query.js"
|
||||
},
|
||||
"./QueryClient": {
|
||||
"types": "./dist/QueryClient.d.ts",
|
||||
"default": "./dist/QueryClient.js"
|
||||
},
|
||||
"./ReactRuntime": {
|
||||
"types": "./dist/ReactRuntime.d.ts",
|
||||
"default": "./dist/ReactRuntime.js"
|
||||
},
|
||||
"./Refreshable": {
|
||||
"types": "./dist/Refreshable.d.ts",
|
||||
"default": "./dist/Refreshable.js"
|
||||
},
|
||||
"./ScopeRegistry": {
|
||||
"types": "./dist/ScopeRegistry.d.ts",
|
||||
"default": "./dist/ScopeRegistry.js"
|
||||
},
|
||||
"./SetStateAction": {
|
||||
"types": "./dist/SetStateAction.d.ts",
|
||||
"default": "./dist/SetStateAction.js"
|
||||
},
|
||||
"./Stream": {
|
||||
"types": "./dist/Stream.d.ts",
|
||||
"default": "./dist/Stream.js"
|
||||
},
|
||||
"./View": {
|
||||
"types": "./dist/View.d.ts",
|
||||
"default": "./dist/View.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint:tsc": "tsc -b --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
"test": "vitest run",
|
||||
"build": "tsc -b tsconfig.build.json",
|
||||
"pack": "npm pack",
|
||||
"clean:cache": "rm -rf .turbo *.tsbuildinfo",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-browser": "4.0.0-beta.101",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"effect": "4.0.0-beta.101",
|
||||
"jsdom": "^26.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
"effect": "4.0.0-beta.101",
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"effect-lens": "^2.0.1-beta.101"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import * as Async from "./Async.js"
|
||||
import * as Component from "./Component.js"
|
||||
import * as Memoized from "./Memoized.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
|
||||
describe("Async", () => {
|
||||
it("does not rerun for an unrelated parent state update", async () => {
|
||||
const load = vi.fn((_id: number) => Effect.never)
|
||||
const renderPost = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const context = await runtime.runtime.context()
|
||||
|
||||
const Post = Component.make("Post")(function*(props: { readonly id: number }) {
|
||||
renderPost()
|
||||
const value = yield* Component.useOnChange(() => load(props.id), [props.id])
|
||||
return <div>{value}</div>
|
||||
}).pipe(
|
||||
Async.async,
|
||||
Memoized.memoized,
|
||||
)
|
||||
|
||||
const Parent = Component.make("Parent")(function*() {
|
||||
const [text, setText] = React.useState("")
|
||||
const AsyncPost = yield* Post.use
|
||||
|
||||
return <>
|
||||
<input
|
||||
aria-label="text"
|
||||
value={text}
|
||||
onChange={event => setText(event.currentTarget.value)}
|
||||
/>
|
||||
<AsyncPost id={1} fallback={<div>loading</div>} />
|
||||
</>
|
||||
}).pipe(Component.withContext(runtime.context))
|
||||
|
||||
let view!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
view = render(
|
||||
<runtime.context.Provider value={context}>
|
||||
<Parent />
|
||||
</runtime.context.Provider>,
|
||||
)
|
||||
})
|
||||
|
||||
expect(screen.getByText("loading")).toBeTruthy()
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
const callsAfterLoad = load.mock.calls.length
|
||||
const rendersAfterLoad = renderPost.mock.calls.length
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByLabelText("text"), { target: { value: "a" } })
|
||||
})
|
||||
expect(load).toHaveBeenCalledTimes(callsAfterLoad)
|
||||
expect(renderPost).toHaveBeenCalledTimes(rendersAfterLoad)
|
||||
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user