11 Commits

Author SHA1 Message Date
renovate-bot 388700b263 Update dependency jsdom to v29
Lint / lint (push) Failing after 7s
Test build / test-build (pull_request) Failing after 8s
2026-06-08 12:01:15 +00:00
Julien Valverdé 845aa193ba Add getting started docs
Lint / lint (push) Successful in 16s
2026-06-08 13:55:16 +02:00
Julien Valverdé 1292b9885a Fix
Lint / lint (push) Successful in 48s
2026-06-08 11:14:31 +02:00
Julien Valverdé 658e6bb8ea Fix tests
Lint / lint (push) Successful in 13s
2026-06-07 15:15:57 +02:00
Julien Valverdé 7638324f2f Add tests
Lint / lint (push) Successful in 14s
2026-06-07 15:10:26 +02:00
Julien Valverdé fcb29c0d76 Add tests
Lint / lint (push) Successful in 15s
2026-06-07 15:01:07 +02:00
Julien Valverdé ea0108650b Add tests
Lint / lint (push) Successful in 47s
2026-06-07 14:51:18 +02:00
Thilawyn 721ab7d736 0.3.0 (#50)
Publish / publish (push) Successful in 26s
Lint / lint (push) Successful in 13s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #50
2026-06-04 17:06:51 +02:00
Thilawyn e5d0808b02 0.2.6 (#49)
Publish / publish (push) Successful in 30s
Lint / lint (push) Successful in 14s
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #49
2026-05-04 02:10:53 +02:00
Thilawyn ff13e941e3 0.2.5 (#43)
Publish / publish (push) Successful in 52s
Lint / lint (push) Successful in 14s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Reviewed-on: #43
2026-03-31 21:01:12 +02:00
Thilawyn 67b01d4621 0.2.4 (#38)
Publish / publish (push) Successful in 59s
Lint / lint (push) Successful in 15s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Reviewed-on: #38
2026-03-16 00:30:17 +01:00
59 changed files with 6164 additions and 1375 deletions
View File
+2925 -178
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "@effect-fc/monorepo", "name": "@effect-fc/monorepo",
"packageManager": "bun@1.3.6", "packageManager": "bun@1.3.14",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"./packages/*" "./packages/*"
@@ -15,12 +15,12 @@
"clean:modules": "turbo clean:modules && rm -rf node_modules" "clean:modules": "turbo clean:modules && rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.11", "@biomejs/biome": "^2.4.16",
"@effect/language-service": "^0.75.0", "@effect/language-service": "^0.86.2",
"@types/bun": "^1.3.6", "@types/bun": "^1.3.14",
"npm-check-updates": "^19.3.1", "npm-check-updates": "^22.2.1",
"npm-sort": "^0.0.4", "npm-sort": "^0.0.4",
"turbo": "^2.7.5", "turbo": "^2.9.16",
"typescript": "^5.9.3" "typescript": "^6.0.3"
} }
} }
+20
View File
@@ -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*
+17
View File
@@ -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`.
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"root": false,
"extends": "//",
"files": {
"includes": ["./src/**"]
}
}
+208
View File
@@ -0,0 +1,208 @@
---
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.
This guide starts with the smallest useful setup:
1. Install `effect-fc` with its peer dependencies.
2. Create a React runtime from an Effect `Layer`.
3. Wrap your React app with `ReactRuntime.Provider`.
4. Write a component with `Component.make`.
5. Convert it to a React component with `Component.withRuntime`.
## Install
Install `effect-fc` alongside `effect` and React 19.2 or newer:
```bash npm2yarn
npm install effect-fc effect react react-dom
```
If your project uses TypeScript, also install React's type packages:
```bash npm2yarn
npm install --save-dev @types/react @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.
```tsx title="src/Hello.tsx"
import { Effect } from "effect"
import { Component } from "effect-fc"
import { runtime } from "./runtime"
const HelloEffect = Component.make("HelloEffect")(function* (props: {
readonly name: string
}) {
const message = yield* Effect.succeed(`Hello, ${props.name}`)
return <h1>{message}</h1>
})
export const Hello = HelloEffect.pipe(
Component.withRuntime(runtime.context),
)
```
`Hello` is now a regular React component:
```tsx title="src/App.tsx"
import { Hello } from "./Hello"
export function App() {
return <Hello name="Effect" />
}
```
## Use Services
Components can yield Effect services directly. Define services with Effect,
provide them in your runtime layer, then consume them from the component body.
```ts title="src/services.ts"
import { Effect } from "effect"
export class GreetingService extends Effect.Service<GreetingService>()(
"GreetingService",
{
succeed: {
greet: (name: string) => `Welcome, ${name}`,
},
},
) {}
```
Provide the service in your runtime:
```tsx title="src/runtime.ts"
import { Layer } from "effect"
import { ReactRuntime } from "effect-fc"
import { GreetingService } from "./services"
const AppLive = Layer.empty.pipe(
Layer.provideMerge(GreetingService.Default),
)
export const runtime = ReactRuntime.make(AppLive)
```
Then read it inside a component:
```tsx title="src/Greeting.tsx"
import { Component } from "effect-fc"
import { runtime } from "./runtime"
import { GreetingService } from "./services"
const GreetingEffect = Component.make("Greeting")(function* (props: {
readonly name: string
}) {
const greeting = yield* GreetingService
return <p>{greeting.greet(props.name)}</p>
})
export const Greeting = GreetingEffect.pipe(
Component.withRuntime(runtime.context),
)
```
## Mount And Cleanup Effects
Use `Component.useOnMount` for scoped work that should start when the component
mounts and finalize when it unmounts.
```tsx
import { Console, Effect } from "effect"
import { Component } from "effect-fc"
const Mounted = Component.make("Mounted")(function* () {
yield* Component.useOnMount(() =>
Effect.gen(function* () {
yield* Console.log("Mounted")
yield* Effect.addFinalizer(() => Console.log("Unmounted"))
}),
)
return <p>Open the console, then unmount me.</p>
})
```
Finalizers are tied to the component scope, so this is the right place for
subscriptions, resources, and other lifecycle-bound Effects.
## 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.
+128
View File
@@ -0,0 +1,128 @@
import type * as Preset from "@docusaurus/preset-classic"
import type { Config } from "@docusaurus/types"
import { themes as prismThemes } from "prism-react-renderer"
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
const config: Config = {
title: "effect-fc",
tagline: "Write React function components with Effect",
favicon: "img/favicon.ico",
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
future: {
v4: true, // Improve compatibility with the upcoming Docusaurus v4
},
// Set the production url of your site here
url: "https://thiladev.github.io",
// Set the /<baseUrl>/ pathname under which your site is served
// For GitHub pages deployment, it is often '/<projectName>/'
baseUrl: "/effect-fc/",
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
organizationName: "Thiladev", // Usually your GitHub org/user name.
projectName: "effect-fc", // Usually your repo name.
onBrokenLinks: "throw",
// Even if you don't use internationalization, you can use this field to set
// useful metadata like html lang. For example, if your site is Chinese, you
// may want to replace "en" with "zh-Hans".
i18n: {
defaultLocale: "en",
locales: ["en"],
},
presets: [
[
"classic",
{
docs: {
sidebarPath: "./sidebars.ts",
editUrl:
"https://github.com/Thiladev/effect-fc/tree/main/packages/docs/",
},
blog: false,
theme: {
customCss: "./src/css/custom.css",
},
} satisfies Preset.Options,
],
],
themeConfig: {
// Replace with your project's social card
colorMode: {
respectPrefersColorScheme: true,
},
navbar: {
title: "effect-fc",
logo: {
alt: "effect-fc logo",
src: "img/logo.svg",
},
items: [
{
type: "docSidebar",
sidebarId: "docsSidebar",
position: "left",
label: "Docs",
},
{
href: "https://github.com/Thiladev/effect-fc",
label: "GitHub",
position: "right",
},
],
},
footer: {
style: "dark",
links: [
{
title: "Docs",
items: [
{
label: "Getting Started",
to: "/docs/getting-started",
},
],
},
{
title: "Project",
items: [
{
label: "GitHub",
href: "https://github.com/Thiladev/effect-fc",
},
{
label: "Example App",
href: "https://github.com/Thiladev/effect-fc/tree/main/packages/example",
},
],
},
{
title: "Ecosystem",
items: [
{
label: "Effect",
href: "https://effect.website/",
},
{
label: "React",
href: "https://react.dev/",
},
],
},
],
copyright: `Copyright © ${new Date().getFullYear()} effect-fc contributors. Built with Docusaurus.`,
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
},
} satisfies Preset.ThemeConfig,
}
export default config
+50
View File
@@ -0,0 +1,50 @@
{
"name": "docs",
"version": "0.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "3.10.1",
"@docusaurus/faster": "^3.10.1",
"@docusaurus/preset-classic": "3.10.1",
"@mdx-js/react": "^3.1.1",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.10.1",
"@docusaurus/tsconfig": "3.10.1",
"@docusaurus/types": "3.10.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"typescript": "~6.0.3"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"engines": {
"node": ">=20.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
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"],
}
export default sidebars
+37
View File
@@ -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);
}
+80
View File
@@ -0,0 +1,80 @@
.page {
min-height: calc(100vh - var(--ifm-navbar-height));
background:
radial-gradient(circle at top left, rgba(11, 111, 116, 0.22), transparent 32rem),
radial-gradient(circle at 85% 20%, rgba(244, 166, 54, 0.18), transparent 28rem),
linear-gradient(135deg, #f8f4ea 0%, #eef8f7 48%, #fffaf1 100%);
}
.hero {
padding: 8rem 1rem 5rem;
}
.eyebrow {
color: #0b6f74;
font-size: 0.85rem;
font-weight: 800;
letter-spacing: 0.16em;
margin-bottom: 1.25rem;
text-transform: uppercase;
}
.hero h1 {
color: #132c2f;
font-size: clamp(3rem, 9vw, 6.75rem);
letter-spacing: -0.08em;
line-height: 0.9;
margin: 0;
max-width: 880px;
}
.lede {
color: #385256;
font-size: clamp(1.15rem, 2vw, 1.45rem);
line-height: 1.65;
margin: 2rem 0 0;
max-width: 720px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 2.5rem;
}
.cards {
display: grid;
gap: 1.25rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: 0 1rem 6rem;
}
.cards article {
background: rgba(255, 255, 255, 0.74);
border: 1px solid rgba(19, 44, 47, 0.12);
border-radius: 1.5rem;
box-shadow: 0 24px 80px rgba(19, 44, 47, 0.08);
padding: 1.5rem;
}
.cards h2 {
color: #132c2f;
font-size: 1.2rem;
margin-bottom: 0.75rem;
}
.cards p {
color: #486267;
margin: 0;
}
@media screen and (max-width: 996px) {
.hero {
padding-top: 5rem;
}
.cards {
grid-template-columns: 1fr;
}
}
+62
View File
@@ -0,0 +1,62 @@
import Link from "@docusaurus/Link"
import Layout from "@theme/Layout"
import clsx from "clsx"
import type { ReactNode } from "react"
import styles from "./index.module.css"
export default function Home(): ReactNode {
return (
<Layout
title="effect-fc"
description="Write React function components with Effect"
>
<main className={styles.page}>
<section className={clsx("container", styles.hero)}>
<p className={styles.eyebrow}>Effect for React function components</p>
<h1>Write components as Effect programs.</h1>
<p className={styles.lede}>
effect-fc gives React 19 components access to Effect services,
scopes, subscriptions, and async workflows without giving up normal
React boundaries.
</p>
<div className={styles.actions}>
<Link className="button button--primary button--lg" to="/docs/getting-started">
Get Started
</Link>
<Link
className="button button--secondary button--lg"
to="https://github.com/Thiladev/effect-fc"
>
GitHub
</Link>
</div>
</section>
<section className={clsx("container", styles.cards)}>
<article>
<h2>Generator components</h2>
<p>
Use <code>Component.make</code> to yield Effects and return JSX
from the same component body.
</p>
</article>
<article>
<h2>Runtime at the edge</h2>
<p>
Provide your app layer once with <code>ReactRuntime.Provider</code>
and convert Effect-FC components at React boundaries.
</p>
</article>
<article>
<h2>Scoped lifecycles</h2>
<p>
Tie subscriptions and resources to component scopes so finalizers
run when React unmounts.
</p>
</article>
</section>
</main>
</Layout>
)
}
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="128" height="128" rx="32" fill="#0B6F74"/>
<path d="M34 39H78C88.4934 39 97 47.5066 97 58C97 68.4934 88.4934 77 78 77H51V95H34V39Z" fill="#FFF9ED"/>
<path d="M51 55V62H78C80.2091 62 82 60.2091 82 58C82 55.7909 80.2091 55 78 55H51Z" fill="#0B6F74"/>
<path d="M51 77H86V95H51V77Z" fill="#F4A636"/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

+9
View File
@@ -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"]
}
+10 -12
View File
@@ -15,39 +15,37 @@ Documentation is currently being written. In the meantime, you can take a look a
## What writing components looks like ## What writing components looks like
```typescript ```typescript
export class Todos extends Component.make("Todos")(function*() { export class TodosView extends Component.make("TodosView")(function*() {
const state = yield* TodosState const state = yield* TodosState
const [todos] = yield* useSubscribables(state.ref) const [todos] = yield* Component.useSubscribables([state.subscriptionRef])
yield* useOnMount(() => Effect.andThen( yield* Component.useOnMount(() => Effect.andThen(
Console.log("Todos mounted"), Console.log("Todos mounted"),
Effect.addFinalizer(() => Console.log("Todos unmounted")), Effect.addFinalizer(() => Console.log("Todos unmounted")),
)) ))
const TodoFC = yield* Todo const Todo = yield* TodoView.use
return ( return (
<Container> <Container>
<Heading align="center">Todos</Heading> <Heading align="center">Todos</Heading>
<Flex direction="column" align="stretch" gap="2" mt="2"> <Flex direction="column" align="stretch" gap="2" mt="2">
<TodoFC _tag="new" /> <Todo _tag="new" />
{Chunk.map(todos, todo => {Chunk.map(todos, todo =>
<TodoFC key={todo.id} _tag="edit" id={todo.id} /> <Todo key={todo.id} _tag="edit" id={todo.id} />
)} )}
</Flex> </Flex>
</Container> </Container>
) )
}) {} }) {}
const TodosStateLive = TodosState.Default("todos") const Index = Component.make("IndexView")(function*() {
const context = yield* Component.useContextFromLayer(TodosState.Default)
const Todos = yield* Effect.provide(TodosView.use, context)
const Index = Component.make("Index")(function*() { return <Todos />
const context = yield* useContext(TodosStateLive)
const TodosFC = yield* Effect.provide(Todos, context)
return <TodosFC />
}).pipe( }).pipe(
Component.withRuntime(runtime.context) Component.withRuntime(runtime.context)
) )
+10 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "effect-fc", "name": "effect-fc",
"description": "Write React function components with Effect", "description": "Write React function components with Effect",
"version": "0.2.3", "version": "0.3.0",
"type": "module", "type": "module",
"files": [ "files": [
"./README.md", "./README.md",
@@ -32,18 +32,24 @@
"build": "tsc", "build": "tsc",
"lint:tsc": "tsc --noEmit", "lint:tsc": "tsc --noEmit",
"lint:biome": "biome lint", "lint:biome": "biome lint",
"test": "vitest run",
"pack": "npm pack", "pack": "npm pack",
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo", "clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
"clean:dist": "rm -rf dist", "clean:dist": "rm -rf dist",
"clean:modules": "rm -rf node_modules" "clean:modules": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@effect/platform-browser": "^0.74.0" "@effect/platform-browser": "^0.76.0",
"@testing-library/react": "^16.3.0",
"jsdom": "^29.0.0",
"vitest": "^3.2.4"
}, },
"peerDependencies": { "peerDependencies": {
"@effect-atom/atom": "^0.5.0",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"effect": "^3.19.0", "effect": "^3.21.0",
"react": "^19.2.0" "react": "^19.2.0"
},
"dependencies": {
"effect-lens": "^0.2.0"
} }
} }
+107 -19
View File
@@ -1,31 +1,47 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ /** 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 React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
export const TypeId: unique symbol = Symbol.for("@effect-fc/Async/Async") export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
export type TypeId = typeof TypeId export type AsyncTypeId = typeof AsyncTypeId
export interface Async extends AsyncOptions {
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
} }
/**
* Configuration options for `Async` components.
*/
export interface AsyncOptions { export interface AsyncOptions {
/**
* The default fallback React node to display while the async operation is pending.
* Used if no fallback is provided to the component when rendering.
*/
readonly defaultFallback?: React.ReactNode readonly defaultFallback?: React.ReactNode
} }
/**
* Props for `Async` components.
*/
export type AsyncProps = Omit<React.SuspenseProps, "children"> export type AsyncProps = Omit<React.SuspenseProps, "children">
export const AsyncPrototype = Object.freeze({ export const AsyncPrototype: AsyncPrototype = Object.freeze({
[TypeId]: TypeId, [AsyncTypeId]: AsyncTypeId,
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R>( asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Component.Signature>(
this: Component.Component<P, A, E, R> & Async, this: Component.Component<P, A, E, R, F> & Async,
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>, runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
) { ) {
const 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 }: AsyncProps) => { return ({ fallback, name, ...props }: AsyncProps) => {
const promise = Runtime.runPromise(runtimeRef.current)( const promise = Runtime.runPromise(runtimeRef.current)(
@@ -38,43 +54,115 @@ export const AsyncPrototype = Object.freeze({
return React.createElement( return React.createElement(
React.Suspense, React.Suspense,
{ fallback: fallback ?? this.defaultFallback, name }, { fallback: fallback ?? this.defaultFallback, name },
React.createElement(SuspenseInner, { promise }), React.createElement(Inner, { promise }),
) )
} }
}, },
} as const) } as const)
/**
* An equivalence function for comparing `AsyncProps` that ignores the `fallback` property.
* Used by default by async components with `Memoized.memoized` applied.
*/
export const defaultPropsEquivalence: Equivalence.Equivalence<AsyncProps> = (
self: Record<string, unknown>,
that: Record<string, unknown>,
) => {
if (self === that)
return true
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>>( for (const key in that) {
self: T 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>> & Omit<T, keyof Component.Component.AsComponent<T>>
& Component.Component< & Component.Component<
Component.Component.Props<T> & AsyncProps, Component.Component.Props<T> & AsyncProps,
Component.Component.Success<T>, Component.Component.Success<T>,
Component.Component.Error<T>, Component.Component.Error<T>,
Component.Component.Context<T> Component.Component.Context<T>,
Component.Component.DefaultSignature<Component.Component.Props<T> & AsyncProps, Component.Component.Success<T>>
> >
& Async & Async
) => Object.setPrototypeOf( ) => Object.setPrototypeOf(
Object.assign(function() {}, self), Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }),
Object.freeze(Object.setPrototypeOf( Object.freeze(Object.setPrototypeOf(
Object.assign({}, AsyncPrototype), Object.assign({}, AsyncPrototype),
Object.getPrototypeOf(self), Object.getPrototypeOf(self),
)), )),
) )
/**
* Applies options to an Async component, returning a new Async component with the updated configuration.
*
* Supports both curried and uncurried application styles.
*
* @param self - The Async component to apply options to (in uncurried form)
* @param options - The options to apply to the component
* @returns An Async component with the applied options
*
* @example
* ```ts
* // Curried
* const MyAsyncComponent = MyComponent.pipe(
* Async.async,
* Async.withOptions({ defaultFallback: <p>Loading...</p> }),
* )
*
* // Uncurried
* const MyAsyncComponent = Async.withOptions(
* Async.async(MyComponent),
* { defaultFallback: <p>Loading...</p> },
* )
* ```
*/
export const withOptions: { export const withOptions: {
<T extends Component.Component<any, any, any, any> & Async>( <T extends Component.Component.Any & Async>(
options: Partial<AsyncOptions> options: Partial<AsyncOptions>
): (self: T) => T ): (self: T) => T
<T extends Component.Component<any, any, any, any> & Async>( <T extends Component.Component.Any & Async>(
self: T, self: T,
options: Partial<AsyncOptions>, options: Partial<AsyncOptions>,
): T ): T
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Async>( } = Function.dual(2, <T extends Component.Component.Any & Async>(
self: T, self: T,
options: Partial<AsyncOptions>, options: Partial<AsyncOptions>,
): T => Object.setPrototypeOf( ): T => Object.setPrototypeOf(
+110 -68
View File
@@ -4,55 +4,58 @@ import { Context, type Duration, Effect, Equivalence, ExecutionStrategy, Exit, F
import * as React from "react" import * as React from "react"
export const TypeId: unique symbol = Symbol.for("@effect-fc/Component/Component") export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component")
export type TypeId = typeof TypeId export type ComponentTypeId = typeof ComponentTypeId
/** /**
* Represents an Effect-based React Component that integrates the Effect system with React. * Represents an Effect-based React Component that integrates the Effect system with React.
*/ */
export interface Component<P extends {}, A extends React.ReactNode, E, R> export interface Component<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>
extends ComponentPrototype<P, A, R>, ComponentOptions { extends ComponentPrototype<R, F>, ComponentOptions {
new(_: never): Record<string, never> new(_: never): Record<string, never>
readonly [TypeId]: TypeId readonly [ComponentTypeId]: ComponentTypeId
readonly "~Props": P readonly "~Props": P
readonly "~Success": A readonly "~Success": A
readonly "~Error": E readonly "~Error": E
readonly "~Context": R readonly "~Context": R
readonly "~Function": F
readonly body: (props: P) => Effect.Effect<A, E, R> readonly body: (props: P) => Effect.Effect<A, E, R>
} }
export declare namespace Component { export declare namespace Component {
export type Props<T extends Component<any, any, any, any>> = [T] extends [Component<infer P, infer _A, infer _E, infer _R>] ? P : never export type Default<P extends {}, A extends React.ReactNode, E, R> = Component<P, A, E, R, DefaultSignature<P, A>>
export type Success<T extends Component<any, any, any, any>> = [T] extends [Component<infer _P, infer A, infer _E, infer _R>] ? A : never export type Any = Component<any, any, any, any, any>
export type Error<T extends Component<any, any, any, any>> = [T] extends [Component<infer _P, infer _A, infer E, infer _R>] ? E : never
export type Context<T extends Component<any, any, any, any>> = [T] extends [Component<infer _P, infer _A, infer _E, infer R>] ? R : never
export type AsComponent<T extends Component<any, any, any, any>> = Component<Props<T>, Success<T>, Error<T>, Context<T>> export type Signature = (props: any) => React.ReactNode
export type DefaultSignature<P extends {}, A extends React.ReactNode> = (props: P) => A
export type Props<T extends Any> = [T] extends [Component<infer P, infer _A, infer _E, infer _R, infer _F>] ? P : never
export type Success<T extends Any> = [T] extends [Component<infer _P, infer A, infer _E, infer _R, infer _F>] ? A : never
export type Error<T extends Any> = [T] extends [Component<infer _P, infer _A, infer E, infer _R, infer _F>] ? E : never
export type Context<T extends Any> = [T] extends [Component<infer _P, infer _A, infer _E, infer R, infer _F>] ? R : never
export type Function<T extends Any> = [T] extends [Component<infer _P, infer _A, infer _E, infer _R, infer F>] ? F : never
export type AsComponent<T extends Any> = Component<Props<T>, Success<T>, Error<T>, Context<T>, Function<T>>
} }
export interface ComponentPrototype<P extends {}, A extends React.ReactNode, R> export interface ComponentImpl<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>
extends Pipeable.Pipeable { extends Component<P, A, E, R, F>, ComponentImplPrototype<R, F> {}
readonly [TypeId]: TypeId
readonly use: Effect.Effect<(props: P) => A, never, Exclude<R, Scope.Scope>>
asFunctionComponent( export interface ComponentImplPrototype<R, F extends Component.Signature> {
runtimeRef: React.Ref<Runtime.Runtime<Exclude<R, Scope.Scope>>> readonly use: Effect.Effect<F, never, Exclude<R, Scope.Scope>>
): (props: P) => A
setFunctionComponentName(f: React.FC<P>): void asFunctionComponent(runtimeRef: React.Ref<Runtime.Runtime<Exclude<R, Scope.Scope>>>): F
transformFunctionComponent(f: React.FC<P>): React.FC<P> setFunctionComponentName(f: F): void
transformFunctionComponent(f: F): F
} }
export const ComponentPrototype: ComponentPrototype<any, any, any> = Object.freeze({ export const ComponentImplPrototype: ComponentImplPrototype<any, any> = Object.freeze({
[TypeId]: TypeId,
...Pipeable.Prototype,
get use() { return use(this) }, get use() { return use(this) },
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R>( asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
this: Component<P, A, E, R>, this: ComponentImpl<P, A, E, R, F>,
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>, runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
) { ) {
return (props: P) => Runtime.runSync(runtimeRef.current)( return (props: P) => Runtime.runSync(runtimeRef.current)(
@@ -63,8 +66,8 @@ export const ComponentPrototype: ComponentPrototype<any, any, any> = Object.free
) )
}, },
setFunctionComponentName<P extends {}, A extends React.ReactNode, E, R>( setFunctionComponentName<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
this: Component<P, A, E, R>, this: ComponentImpl<P, A, E, R, F>,
f: React.FC<P>, f: React.FC<P>,
) { ) {
f.displayName = this.displayName ?? "Anonymous" f.displayName = this.displayName ?? "Anonymous"
@@ -73,8 +76,8 @@ export const ComponentPrototype: ComponentPrototype<any, any, any> = Object.free
transformFunctionComponent: identity, transformFunctionComponent: identity,
} as const) } as const)
const use = Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode, E, R>( const use = Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
self: Component<P, A, E, R> self: ComponentImpl<P, A, E, R, F>
) { ) {
// biome-ignore lint/style/noNonNullAssertion: React ref initialization // biome-ignore lint/style/noNonNullAssertion: React ref initialization
const runtimeRef = React.useRef<Runtime.Runtime<Exclude<R, Scope.Scope>>>(null!) const runtimeRef = React.useRef<Runtime.Runtime<Exclude<R, Scope.Scope>>>(null!)
@@ -82,24 +85,47 @@ const use = Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode
return yield* React.useState(() => Runtime.runSync(runtimeRef.current)(Effect.cachedFunction( return yield* React.useState(() => Runtime.runSync(runtimeRef.current)(Effect.cachedFunction(
(_services: readonly any[]) => Effect.sync(() => { (_services: readonly any[]) => Effect.sync(() => {
const f: React.FC<P> = self.asFunctionComponent(runtimeRef) const f = self.asFunctionComponent(runtimeRef)
self.setFunctionComponentName(f) self.setFunctionComponentName(f)
return self.transformFunctionComponent(f) return self.transformFunctionComponent(f)
}), }),
Equivalence.array(Equivalence.strict()), Equivalence.array(Equivalence.strict()),
)))[0](Array.from( )))[0](Array.from(
Context.omit(...nonReactiveTags)(runtimeRef.current.context).unsafeMap.values() Context.omit(...self.nonReactiveTags)(runtimeRef.current.context).unsafeMap.values()
)) ))
}) })
export interface ComponentPrototype<R, F extends Component.Signature>
extends Pipeable.Pipeable {
readonly [ComponentTypeId]: ComponentTypeId
readonly use: Effect.Effect<F, never, Exclude<R, Scope.Scope>>
}
export const ComponentPrototype: ComponentPrototype<any, any> = Object.freeze(
Object.defineProperties(
{
[ComponentTypeId]: ComponentTypeId,
...Pipeable.Prototype,
},
Object.getOwnPropertyDescriptors(ComponentImplPrototype),
) as ComponentPrototype<any, any>
)
export interface ComponentOptions { export interface ComponentOptions {
/** /**
* Custom display name for the component in React DevTools and debugging utilities. * Custom display name for the component in React DevTools and debugging utilities.
* Improves developer experience by providing meaningful component identification.
*/ */
readonly displayName?: string readonly displayName?: string
/**
* Context tags that should not trigger component remount when their values change.
*
* @default [Tracer.ParentSpan]
*/
readonly nonReactiveTags: readonly Context.Tag<any, any>[]
/** /**
* Specifies the execution strategy for finalizers when the component unmounts or its scope closes. * Specifies the execution strategy for finalizers when the component unmounts or its scope closes.
* Determines whether finalizers execute sequentially or in parallel. * Determines whether finalizers execute sequentially or in parallel.
@@ -119,21 +145,19 @@ export interface ComponentOptions {
} }
export const defaultOptions: ComponentOptions = { export const defaultOptions: ComponentOptions = {
nonReactiveTags: [Tracer.ParentSpan],
finalizerExecutionStrategy: ExecutionStrategy.sequential, finalizerExecutionStrategy: ExecutionStrategy.sequential,
finalizerExecutionDebounce: "100 millis", finalizerExecutionDebounce: "100 millis",
} }
export const nonReactiveTags = [Tracer.ParentSpan] as const export const isComponent = (u: unknown): u is Component.Default<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, ComponentTypeId)
export const isComponent = (u: unknown): u is Component<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, TypeId)
export declare namespace make { export declare namespace make {
export type Gen = { export type Gen = {
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A extends React.ReactNode, P extends {} = {}>( <Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A extends React.ReactNode, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never> body: (props: P) => Generator<Eff, A, never>
): Component< ): Component.Default<
P, A, P, A,
[Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap<Effect.Effect<infer _A, infer E, infer _R>>] ? E : never, [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap<Effect.Effect<infer _A, infer E, infer _R>>] ? E : never,
[Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap<Effect.Effect<infer _A, infer _E, infer R>>] ? R : never [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap<Effect.Effect<infer _A, infer _E, infer R>>] ? R : never
@@ -148,7 +172,7 @@ export declare namespace make {
>, >,
props: NoInfer<P>, props: NoInfer<P>,
) => B, ) => B,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<B>>, Effect.Effect.Error<B>, Effect.Effect.Context<B>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<B>>, Effect.Effect.Error<B>, Effect.Effect.Context<B>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>( <Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>, body: (props: P) => Generator<Eff, A, never>,
a: ( a: (
@@ -160,7 +184,7 @@ export declare namespace make {
props: NoInfer<P>, props: NoInfer<P>,
) => B, ) => B,
b: (_: B, props: NoInfer<P>) => C, b: (_: B, props: NoInfer<P>) => C,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<C>>, Effect.Effect.Error<C>, Effect.Effect.Context<C>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<C>>, Effect.Effect.Error<C>, Effect.Effect.Context<C>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>( <Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>, body: (props: P) => Generator<Eff, A, never>,
a: ( a: (
@@ -173,7 +197,7 @@ export declare namespace make {
) => B, ) => B,
b: (_: B, props: NoInfer<P>) => C, b: (_: B, props: NoInfer<P>) => C,
c: (_: C, props: NoInfer<P>) => D, c: (_: C, props: NoInfer<P>) => D,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<D>>, Effect.Effect.Error<D>, Effect.Effect.Context<D>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<D>>, Effect.Effect.Error<D>, Effect.Effect.Context<D>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>( <Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>, body: (props: P) => Generator<Eff, A, never>,
a: ( a: (
@@ -187,7 +211,7 @@ export declare namespace make {
b: (_: B, props: NoInfer<P>) => C, b: (_: B, props: NoInfer<P>) => C,
c: (_: C, props: NoInfer<P>) => D, c: (_: C, props: NoInfer<P>) => D,
d: (_: D, props: NoInfer<P>) => E, d: (_: D, props: NoInfer<P>) => E,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<E>>, Effect.Effect.Error<E>, Effect.Effect.Context<E>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<E>>, Effect.Effect.Error<E>, Effect.Effect.Context<E>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>( <Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>, body: (props: P) => Generator<Eff, A, never>,
a: ( a: (
@@ -202,7 +226,7 @@ export declare namespace make {
c: (_: C, props: NoInfer<P>) => D, c: (_: C, props: NoInfer<P>) => D,
d: (_: D, props: NoInfer<P>) => E, d: (_: D, props: NoInfer<P>) => E,
e: (_: E, props: NoInfer<P>) => F, e: (_: E, props: NoInfer<P>) => F,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<F>>, Effect.Effect.Error<F>, Effect.Effect.Context<F>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<F>>, Effect.Effect.Error<F>, Effect.Effect.Context<F>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>( <Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>, body: (props: P) => Generator<Eff, A, never>,
a: ( a: (
@@ -218,7 +242,7 @@ export declare namespace make {
d: (_: D, props: NoInfer<P>) => E, d: (_: D, props: NoInfer<P>) => E,
e: (_: E, props: NoInfer<P>) => F, e: (_: E, props: NoInfer<P>) => F,
f: (_: F, props: NoInfer<P>) => G, f: (_: F, props: NoInfer<P>) => G,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<G>>, Effect.Effect.Error<G>, Effect.Effect.Context<G>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<G>>, Effect.Effect.Error<G>, Effect.Effect.Context<G>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G, H extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>( <Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G, H extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>, body: (props: P) => Generator<Eff, A, never>,
a: ( a: (
@@ -235,7 +259,7 @@ export declare namespace make {
e: (_: E, props: NoInfer<P>) => F, e: (_: E, props: NoInfer<P>) => F,
f: (_: F, props: NoInfer<P>) => G, f: (_: F, props: NoInfer<P>) => G,
g: (_: G, props: NoInfer<P>) => H, g: (_: G, props: NoInfer<P>) => H,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<H>>, Effect.Effect.Error<H>, Effect.Effect.Context<H>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<H>>, Effect.Effect.Error<H>, Effect.Effect.Context<H>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G, H, I extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>( <Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G, H, I extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>, body: (props: P) => Generator<Eff, A, never>,
a: ( a: (
@@ -253,7 +277,7 @@ export declare namespace make {
f: (_: F, props: NoInfer<P>) => G, f: (_: F, props: NoInfer<P>) => G,
g: (_: G, props: NoInfer<P>) => H, g: (_: G, props: NoInfer<P>) => H,
h: (_: H, props: NoInfer<P>) => I, h: (_: H, props: NoInfer<P>) => I,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<I>>, Effect.Effect.Error<I>, Effect.Effect.Context<I>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<I>>, Effect.Effect.Error<I>, Effect.Effect.Context<I>>
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G, H, I, J extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>( <Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G, H, I, J extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Generator<Eff, A, never>, body: (props: P) => Generator<Eff, A, never>,
a: ( a: (
@@ -272,35 +296,35 @@ export declare namespace make {
g: (_: G, props: NoInfer<P>) => H, g: (_: G, props: NoInfer<P>) => H,
h: (_: H, props: NoInfer<P>) => I, h: (_: H, props: NoInfer<P>) => I,
i: (_: I, props: NoInfer<P>) => J, i: (_: I, props: NoInfer<P>) => J,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<J>>, Effect.Effect.Error<J>, Effect.Effect.Context<J>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<J>>, Effect.Effect.Error<J>, Effect.Effect.Context<J>>
} }
export type NonGen = { export type NonGen = {
<Eff extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>( <Eff extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
body: (props: P) => Eff body: (props: P) => Eff
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, P extends {} = {}>( <Eff extends Effect.Effect<React.ReactNode, any, any>, A, P extends {} = {}>(
body: (props: P) => A, body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => Eff, a: (_: A, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, P extends {} = {}>( <Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, P extends {} = {}>(
body: (props: P) => A, body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B, a: (_: A, props: NoInfer<P>) => B,
b: (_: B, props: NoInfer<P>) => Eff, b: (_: B, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, P extends {} = {}>( <Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, P extends {} = {}>(
body: (props: P) => A, body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B, a: (_: A, props: NoInfer<P>) => B,
b: (_: B, props: NoInfer<P>) => C, b: (_: B, props: NoInfer<P>) => C,
c: (_: C, props: NoInfer<P>) => Eff, c: (_: C, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, P extends {} = {}>( <Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, P extends {} = {}>(
body: (props: P) => A, body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B, a: (_: A, props: NoInfer<P>) => B,
b: (_: B, props: NoInfer<P>) => C, b: (_: B, props: NoInfer<P>) => C,
c: (_: C, props: NoInfer<P>) => D, c: (_: C, props: NoInfer<P>) => D,
d: (_: D, props: NoInfer<P>) => Eff, d: (_: D, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, P extends {} = {}>( <Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, P extends {} = {}>(
body: (props: P) => A, body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B, a: (_: A, props: NoInfer<P>) => B,
@@ -308,7 +332,7 @@ export declare namespace make {
c: (_: C, props: NoInfer<P>) => D, c: (_: C, props: NoInfer<P>) => D,
d: (_: D, props: NoInfer<P>) => E, d: (_: D, props: NoInfer<P>) => E,
e: (_: E, props: NoInfer<P>) => Eff, e: (_: E, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, P extends {} = {}>( <Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, P extends {} = {}>(
body: (props: P) => A, body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B, a: (_: A, props: NoInfer<P>) => B,
@@ -317,7 +341,7 @@ export declare namespace make {
d: (_: D, props: NoInfer<P>) => E, d: (_: D, props: NoInfer<P>) => E,
e: (_: E, props: NoInfer<P>) => F, e: (_: E, props: NoInfer<P>) => F,
f: (_: F, props: NoInfer<P>) => Eff, f: (_: F, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, G, P extends {} = {}>( <Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, G, P extends {} = {}>(
body: (props: P) => A, body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B, a: (_: A, props: NoInfer<P>) => B,
@@ -327,7 +351,7 @@ export declare namespace make {
e: (_: E, props: NoInfer<P>) => F, e: (_: E, props: NoInfer<P>) => F,
f: (_: F, props: NoInfer<P>) => G, f: (_: F, props: NoInfer<P>) => G,
g: (_: G, props: NoInfer<P>) => Eff, g: (_: G, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, G, H, P extends {} = {}>( <Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, G, H, P extends {} = {}>(
body: (props: P) => A, body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B, a: (_: A, props: NoInfer<P>) => B,
@@ -338,7 +362,7 @@ export declare namespace make {
f: (_: F, props: NoInfer<P>) => G, f: (_: F, props: NoInfer<P>) => G,
g: (_: G, props: NoInfer<P>) => H, g: (_: G, props: NoInfer<P>) => H,
h: (_: H, props: NoInfer<P>) => Eff, h: (_: H, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, G, H, I, P extends {} = {}>( <Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, G, H, I, P extends {} = {}>(
body: (props: P) => A, body: (props: P) => A,
a: (_: A, props: NoInfer<P>) => B, a: (_: A, props: NoInfer<P>) => B,
@@ -350,7 +374,7 @@ export declare namespace make {
g: (_: G, props: NoInfer<P>) => H, g: (_: G, props: NoInfer<P>) => H,
h: (_: H, props: NoInfer<P>) => I, h: (_: H, props: NoInfer<P>) => I,
i: (_: I, props: NoInfer<P>) => Eff, i: (_: I, props: NoInfer<P>) => Eff,
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>> ): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
} }
} }
@@ -496,6 +520,24 @@ export const makeUntraced: (
) )
) )
export declare namespace withSignature {
export type Result<T extends Component.Any, F extends Component.Signature> = (
& Omit<T, keyof Component.AsComponent<T>>
& Component<Component.Props<T>, Component.Success<T>, Component.Error<T>, Component.Context<T>, F>
)
}
export const withSignature: {
<F extends Component.Signature>(): <T extends Component.Any>(
self: T
) => withSignature.Result<T, F>
<F extends Component.Signature, T extends Component.Any>(
self: T
): withSignature.Result<T, F>
} = (self?: Component.Any): any => self === undefined
? identity
: self
/** /**
* Creates a new component with modified configuration options while preserving all original behavior. * Creates a new component with modified configuration options while preserving all original behavior.
* *
@@ -507,20 +549,20 @@ export const makeUntraced: (
* const MyComponentWithCustomOptions = MyComponent.pipe( * const MyComponentWithCustomOptions = MyComponent.pipe(
* Component.withOptions({ * Component.withOptions({
* finalizerExecutionStrategy: ExecutionStrategy.parallel, * finalizerExecutionStrategy: ExecutionStrategy.parallel,
* finalizerExecutionDebounce: "50 millis" * finalizerExecutionDebounce: "50 millis",
* }) * })
* ) * )
* ``` * ```
*/ */
export const withOptions: { export const withOptions: {
<T extends Component<any, any, any, any>>( <T extends Component.Any>(
options: Partial<ComponentOptions> options: Partial<ComponentOptions>
): (self: T) => T ): (self: T) => T
<T extends Component<any, any, any, any>>( <T extends Component.Any>(
self: T, self: T,
options: Partial<ComponentOptions>, options: Partial<ComponentOptions>,
): T ): T
} = Function.dual(2, <T extends Component<any, any, any, any>>( } = Function.dual(2, <T extends Component.Any>(
self: T, self: T,
options: Partial<ComponentOptions>, options: Partial<ComponentOptions>,
): T => Object.setPrototypeOf( ): T => Object.setPrototypeOf(
@@ -566,19 +608,19 @@ export const withOptions: {
* *
*/ */
export const withRuntime: { export const withRuntime: {
<P extends {}, A extends React.ReactNode, E, R>( <P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
context: React.Context<Runtime.Runtime<R>>, context: React.Context<Runtime.Runtime<R>>,
): (self: Component<P, A, E, Scope.Scope | NoInfer<R>>) => (props: P) => A ): (self: Component<P, A, E, Scope.Scope | NoInfer<R>, F>) => F
<P extends {}, A extends React.ReactNode, E, R>( <P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
self: Component<P, A, E, Scope.Scope | NoInfer<R>>, self: Component<P, A, E, Scope.Scope | NoInfer<R>, F>,
context: React.Context<Runtime.Runtime<R>>, context: React.Context<Runtime.Runtime<R>>,
): (props: P) => A ): F
} = Function.dual(2, <P extends {}, A extends React.ReactNode, E, R>( } = Function.dual(2, <P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
self: Component<P, A, E, R>, self: Component<P, A, E, R, F>,
context: React.Context<Runtime.Runtime<R>>, context: React.Context<Runtime.Runtime<R>>,
) => function WithRuntime(props: P) { ) => function WithRuntime(props: P) {
return React.createElement( return React.createElement(
Runtime.runSync(React.useContext(context))(self.use), Runtime.runSync(React.useContext(context))(self.use) as React.FC<P>,
props, props,
) )
}) })
+7 -7
View File
@@ -1,20 +1,20 @@
import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect" import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect"
export const TypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver") export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
export type TypeId = typeof TypeId export type ErrorObserverTypeId = typeof ErrorObserverTypeId
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable { 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> handle<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope> readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
} }
export const ErrorObserver = <E = never>(): Context.Tag<ErrorObserver, ErrorObserver<E>> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver") export const ErrorObserver = <E = never>(): Context.Tag<ErrorObserver, ErrorObserver<E>> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver")
class ErrorObserverImpl<in out E = never> export class ErrorObserverImpl<in out E = never>
extends Pipeable.Class() implements ErrorObserver<E> { 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> readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
constructor( 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 readonly value = Effect.void
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) { constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) {
super() super()
@@ -43,7 +43,7 @@ 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( export const layer: Layer.Layer<ErrorObserver> = Layer.unwrapEffect(Effect.map(
PubSub.unbounded<Cause.Cause<never>>(), PubSub.unbounded<Cause.Cause<never>>(),
+176 -298
View File
@@ -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 type * as React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
import * as Mutation from "./Mutation.js" import * as Lens from "./Lens.js"
import * as PropertyPath from "./PropertyPath.js"
import * as Result from "./Result.js"
import * as Subscribable from "./Subscribable.js" import * as Subscribable from "./Subscribable.js"
import * as SubscriptionRef from "./SubscriptionRef.js"
import * as SubscriptionSubRef from "./SubscriptionSubRef.js"
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form") export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
export type FormTypeId = typeof FormTypeId export type FormTypeId = typeof FormTypeId
export interface Form<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 { extends Pipeable.Pipeable {
readonly [FormTypeId]: FormTypeId readonly [FormTypeId]: FormTypeId
readonly schema: Schema.Schema<A, I, R> readonly path: P
readonly context: Context.Context<Scope.Scope | R> readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>
readonly mutation: Mutation.Mutation< readonly encodedValue: Lens.Lens<I, ER, EW, never, never>
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>], readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>
MA, ME, MR, MP readonly isValidating: Subscribable.Subscribable<boolean, ER, never>
> readonly canCommit: Subscribable.Subscribable<boolean, never, never>
readonly autosubmit: boolean readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
readonly debounce: Option.Option<Duration.DurationInput>
readonly value: Subscribable.Subscribable<Option.Option<A>>
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
readonly error: Subscribable.Subscribable<Option.Option<ParseResult.ParseError>>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>
readonly canSubmit: Subscribable.Subscribable<boolean>
field<const P extends PropertyPath.Paths<I>>(
path: P
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>>
readonly run: Effect.Effect<void>
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
} }
export class FormImpl<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never> 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<A, I, R, MA, ME, MR, MP> { extends Pipeable.Class() implements Form<P, A, I, ER, EW> {
readonly [FormTypeId]: FormTypeId = FormTypeId readonly [FormTypeId]: FormTypeId = FormTypeId
constructor( constructor(
readonly schema: Schema.Schema<A, I, R>, readonly path: P,
readonly context: Context.Context<Scope.Scope | R>, readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>,
readonly mutation: Mutation.Mutation< readonly encodedValue: Lens.Lens<I, ER, EW, never, never>,
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>], readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>,
MA, ME, MR, MP readonly isValidating: Subscribable.Subscribable<boolean, never, never>,
>, readonly canCommit: Subscribable.Subscribable<boolean, never, never>,
readonly autosubmit: boolean, readonly isCommitting: Subscribable.Subscribable<boolean, never, never>,
readonly debounce: Option.Option<Duration.DurationInput>,
readonly value: SubscriptionRef.SubscriptionRef<Option.Option<A>>,
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
readonly error: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>,
readonly validationFiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>,
readonly runSemaphore: Effect.Semaphore,
readonly fieldCache: Ref.Ref<HashMap.HashMap<FormFieldKey, FormField<unknown, unknown>>>,
) { ) {
super() 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 const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
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>], const filterIssuesByPath = (
MA, ME, MR, MP issues: readonly ParseResult.ArrayFormatterIssue[],
> { path: readonly PropertyKey[],
readonly schema: Schema.Schema<A, I, R> ): readonly ParseResult.ArrayFormatterIssue[] => Array.filter(issues, issue =>
readonly initialEncodedValue: NoInfer<I> issue.path.length >= path.length && Array.every(path, (p, i) => p === issue.path[i])
readonly autosubmit?: boolean )
readonly debounce?: Duration.DurationInput
} 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( return new FormImpl(
options.schema, path,
yield* Effect.context<Scope.Scope | R>(), Subscribable.mapOption(form.value, a => a[key]),
yield* Mutation.make(options), Lens.focusObjectOn(form.encodedValue, key),
options.autosubmit ?? false, Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
Option.fromNullable(options.debounce), form.isValidating,
form.canCommit,
yield* SubscriptionRef.make(Option.none<A>()), form.isCommitting,
yield* SubscriptionRef.make(options.initialEncodedValue),
yield* SubscriptionRef.make(Option.none<ParseResult.ParseError>()),
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>()),
yield* Effect.makeSemaphore(1),
yield* Ref.make(HashMap.empty<FormFieldKey, FormField<unknown, unknown>>()),
) )
}) })
export declare namespace service { export const focusArrayAt: {
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> <P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
extends make.Options<A, I, R, MA, ME, MR, MP> {} 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>( return new FormImpl(
options: service.Options<A, I, R, MA, ME, MR, MP> path,
): Effect.Effect< Subscribable.mapOptionEffect(form.value, Array.get(index)),
Form<A, I, R, MA, ME, Result.forkEffect.OutputContext<MA, ME, MR, MP>, MP>, Lens.focusArrayAt(form.encodedValue, index),
never, Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP> form.isValidating,
> => Effect.tap( form.canCommit,
make(options), form.isCommitting,
form => Effect.forkScoped(form.run),
)
export const FormFieldTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormField")
export type FormFieldTypeId = typeof FormFieldTypeId
export interface FormField<in out A, in out I = A>
extends Pipeable.Pipeable {
readonly [FormFieldTypeId]: FormFieldTypeId
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
readonly isValidating: Subscribable.Subscribable<boolean>
readonly isSubmitting: Subscribable.Subscribable<boolean>
}
class FormFieldImpl<in out A, in out I = A>
extends Pipeable.Class() implements FormField<A, I> {
readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId
constructor(
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>,
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>,
readonly isValidating: Subscribable.Subscribable<boolean>,
readonly isSubmitting: Subscribable.Subscribable<boolean>,
) {
super()
}
}
const FormFieldKeyTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormFieldKey")
type FormFieldKeyTypeId = typeof FormFieldKeyTypeId
class FormFieldKey implements Equal.Equal {
readonly [FormFieldKeyTypeId]: FormFieldKeyTypeId = FormFieldKeyTypeId
constructor(readonly path: PropertyPath.PropertyPath) {}
[Equal.symbol](that: Equal.Equal) {
return isFormFieldKey(that) && PropertyPath.equivalence(this.path, that.path)
}
[Hash.symbol]() {
return Hash.array(this.path)
}
}
export const isFormField = (u: unknown): u is FormField<unknown, unknown> => Predicate.hasProperty(u, FormFieldTypeId)
const isFormFieldKey = (u: unknown): u is FormFieldKey => Predicate.hasProperty(u, FormFieldKeyTypeId)
export const makeFormField = <A, I, R, MA, ME, MR, MP, const P extends PropertyPath.Paths<NoInfer<I>>>(
self: Form<A, I, R, MA, ME, MR, MP>,
path: P,
): FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>> => {
return new FormFieldImpl(
Subscribable.mapEffect(self.value, Option.match({
onSome: v => Option.map(PropertyPath.get(v, path), Option.some),
onNone: () => Option.some(Option.none()),
})),
SubscriptionSubRef.makeFromPath(self.encodedValue, path),
Subscribable.mapEffect(self.error, Option.match({
onSome: flow(
ParseResult.ArrayFormatter.formatError,
Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))),
),
onNone: () => Effect.succeed([]),
})),
Subscribable.map(self.validationFiber, Option.isSome),
Subscribable.map(self.mutation.result, result => Result.isRunning(result) || Result.hasRefreshingFlag(result)),
) )
} })
export const focusTupleAt: {
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
self: Form<P, A, I, ER, EW>,
index: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW>
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
index: K,
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
self: Form<P, A, I, ER, EW>,
index: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
const form = self as FormImpl<P, A, I, ER, EW>
const path = [...form.path, index] as const
return new FormImpl(
path,
Subscribable.mapOption(form.value, Array.unsafeGet(index)),
Lens.focusTupleAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export const focusChunkAt: {
<P extends readonly PropertyKey[], A, I, ER, EW>(
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
index: number,
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW>
<P extends readonly PropertyKey[], A, I, ER, EW>(
index: number,
): (self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>) => Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW>
} = Function.dual(2, <P extends readonly PropertyKey[], A, I, ER, EW>(
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
index: number,
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW> => {
const form = self as FormImpl<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>
const path = [...form.path, index] as const
return new FormImpl(
path,
Subscribable.mapOptionEffect(form.value, Chunk.get(index)),
Lens.focusChunkAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export namespace useInput { export namespace useInput {
@@ -301,33 +165,39 @@ export namespace useInput {
} }
} }
export const useInput = Effect.fnUntraced(function* <A, I>( export const useInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
field: FormField<A, I>, form: Form<P, A, I, ER, EW>,
options?: useInput.Options, options?: useInput.Options,
): Effect.fn.Return<useInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> { ): Effect.fn.Return<useInput.Success<I>, ER, Scope.Scope> {
const internalValueRef = yield* Component.useOnChange(() => Effect.tap( const internalValueLens = yield* Component.useOnChange(() => Effect.gen(function*() {
Effect.andThen(field.encodedValue, SubscriptionRef.make), const internalValueLens = yield* Lens.get(form.encodedValue).pipe(
internalValueRef => Effect.forkScoped(Effect.all([ Effect.flatMap(SubscriptionRef.make),
Effect.map(Lens.fromSubscriptionRef),
)
yield* Effect.forkScoped(Effect.all([
Stream.runForEach( Stream.runForEach(
Stream.drop(field.encodedValue, 1), Stream.drop(form.encodedValue.changes, 1),
upstreamEncodedValue => Effect.whenEffect( upstreamEncodedValue => Effect.whenEffect(
Ref.set(internalValueRef, upstreamEncodedValue), Lens.set(internalValueLens, upstreamEncodedValue),
Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)), Effect.andThen(Lens.get(internalValueLens), internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
), ),
), ),
Stream.runForEach( Stream.runForEach(
internalValueRef.changes.pipe( internalValueLens.changes.pipe(
Stream.drop(1), Stream.drop(1),
Stream.changesWith(Equal.equivalence()), Stream.changesWith(Equal.equivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity, options?.debounce ? Stream.debounce(options.debounce) : identity,
), ),
internalValue => Ref.set(field.encodedValue, internalValue), internalValue => Lens.set(form.encodedValue, internalValue),
), ),
], { concurrency: "unbounded" })), ], { concurrency: "unbounded", discard: true }))
), [field, options?.debounce])
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef) return internalValueLens
}), [form, options?.debounce])
const [value, setValue] = yield* Lens.useState(internalValueLens)
return { value, setValue } return { value, setValue }
}) })
@@ -342,55 +212,63 @@ export namespace useOptionalInput {
} }
} }
export const useOptionalInput = Effect.fnUntraced(function* <A, I>( export const useOptionalInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
field: FormField<A, Option.Option<I>>, field: Form<P, A, Option.Option<I>, ER, EW>,
options: useOptionalInput.Options<I>, options: useOptionalInput.Options<I>,
): Effect.fn.Return<useOptionalInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> { ): Effect.fn.Return<useOptionalInput.Success<I>, ER, Scope.Scope> {
const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap( const [enabledLens, internalValueLens] = yield* Component.useOnChange(() => Effect.gen(function*() {
Effect.andThen( const [enabledLens, internalValueLens] = yield* Effect.flatMap(
field.encodedValue, Lens.get(field.encodedValue),
Option.match({ Option.match({
onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]), onSome: v => Effect.all([
onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]), 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.runForEach(
Stream.drop(field.encodedValue, 1), Stream.drop(field.encodedValue.changes, 1),
upstreamEncodedValue => Effect.whenEffect( upstreamEncodedValue => Effect.whenEffect(
Option.match(upstreamEncodedValue, { Option.match(upstreamEncodedValue, {
onSome: v => Effect.andThen( onSome: v => Effect.andThen(
Ref.set(enabledRef, true), Lens.set(enabledLens, true),
Ref.set(internalValueRef, v), Lens.set(internalValueLens, v),
), ),
onNone: () => Effect.andThen( onNone: () => Effect.andThen(
Ref.set(enabledRef, false), Lens.set(enabledLens, false),
Ref.set(internalValueRef, options.defaultValue), Lens.set(internalValueLens, options.defaultValue),
), ),
}), }),
Effect.andThen( 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()), ([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
), ),
), ),
), ),
Stream.runForEach( Stream.runForEach(
enabledRef.changes.pipe( enabledLens.changes.pipe(
Stream.zipLatest(internalValueRef.changes), Stream.zipLatest(internalValueLens.changes),
Stream.drop(1), Stream.drop(1),
Stream.changesWith(Equal.equivalence()), Stream.changesWith(Equal.equivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity, options?.debounce ? Stream.debounce(options.debounce) : identity,
), ),
([enabled, internalValue]) => Ref.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()), ([enabled, internalValue]) => Lens.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
), ),
], { concurrency: "unbounded" })), ], { concurrency: "unbounded" }))
), [field, options.debounce])
const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef) return [enabledLens, internalValueLens] as const
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef) }), [field, options.debounce])
const [enabled, setEnabled] = yield* Lens.useState(enabledLens)
const [value, setValue] = yield* Lens.useState(internalValueLens)
return { enabled, setEnabled, value, setValue } return { enabled, setEnabled, value, setValue }
}) })
+62
View File
@@ -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
})
+66 -11
View File
@@ -4,20 +4,38 @@ import * as React from "react"
import type * as Component from "./Component.js" import type * as Component from "./Component.js"
export const TypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized") export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
export type TypeId = typeof TypeId export type MemoizedTypeId = typeof MemoizedTypeId
export interface Memoized<P> extends MemoizedOptions<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
} }
/**
* Configuration options for Memoized components.
*
* @template P The props type of the component
*/
export interface MemoizedOptions<P> { 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> readonly propsEquivalence?: Equivalence.Equivalence<P>
} }
export const MemoizedPrototype = Object.freeze({ export const MemoizedPrototype: MemoizedPrototype = Object.freeze({
[TypeId]: TypeId, [MemoizedTypeId]: MemoizedTypeId,
transformFunctionComponent<P extends {}>( transformFunctionComponent<P extends {}>(
this: Memoized<P>, this: Memoized<P>,
@@ -28,9 +46,22 @@ export const MemoizedPrototype = Object.freeze({
} as const) } as const)
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, TypeId) export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, 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 self: T
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf( ): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
Object.assign(function() {}, self), Object.assign(function() {}, self),
@@ -40,15 +71,39 @@ export const memoized = <T extends Component.Component<any, any, any, any>>(
)), )),
) )
/**
* Applies options to a Memoized component, returning a new Memoized component with the updated configuration.
*
* Supports both curried and uncurried application styles.
*
* @param self - The Memoized component to apply options to (in uncurried form)
* @param options - The options to apply to the component
* @returns A Memoized component with the applied options
*
* @example
* ```ts
* // Curried
* const MyMemoizedComponent = MyComponent.pipe(
* Memoized.memoized,
* Memoized.withOptions({ propsEquivalence: (a, b) => a.id === b.id }),
* )
*
* // Uncurried
* const MyMemoizedComponent = Memoized.withOptions(
* Memoized.memoized(MyComponent),
* { propsEquivalence: (a, b) => a.id === b.id },
* )
* ```
*/
export const withOptions: { export const withOptions: {
<T extends Component.Component<any, any, any, any> & Memoized<any>>( <T extends Component.Component.Any & Memoized<any>>(
options: Partial<MemoizedOptions<Component.Component.Props<T>>> options: Partial<MemoizedOptions<Component.Component.Props<T>>>
): (self: T) => T ): (self: T) => T
<T extends Component.Component<any, any, any, any> & Memoized<any>>( <T extends Component.Component.Any & Memoized<any>>(
self: T, self: T,
options: Partial<MemoizedOptions<Component.Component.Props<T>>>, options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
): 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, self: T,
options: Partial<MemoizedOptions<Component.Component.Props<T>>>, options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
): T => Object.setPrototypeOf( ): T => Object.setPrototypeOf(
+5 -3
View File
@@ -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 const isMutation = (u: unknown): u is Mutation<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
export declare namespace make { export declare namespace make {
export interface Options<K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never> { export interface Options<K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never> {
readonly f: (key: K) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>> readonly f: (key: K) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
@@ -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>( export const make = Effect.fnUntraced(function* <const K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never>(
options: make.Options<K, A, E, R, P> options: make.Options<K, A, E, R, P>
): Effect.fn.Return< ): Effect.fn.Return<
Mutation<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>, Mutation<K, A, E, Result.forkEffect.OutputContext<R, P>, P>,
never, never,
Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P> Scope.Scope | Result.forkEffect.OutputContext<R, P>
> { > {
return new MutationImpl( 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.f as any,
options.initialProgress as P, options.initialProgress as P,
-98
View File
@@ -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()
})
+1 -1
View File
@@ -3,7 +3,7 @@ import type * as React from "react"
import * as Component from "./Component.js" 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 values: A
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> { ): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown)) const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
+43 -54
View File
@@ -6,27 +6,28 @@ import * as Result from "./Result.js"
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query") export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
export type QueryTypeId = typeof QueryTypeId export type QueryTypeId = typeof QueryTypeId
export interface Query<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never> 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 { extends Pipeable.Pipeable {
readonly [QueryTypeId]: QueryTypeId readonly [QueryTypeId]: QueryTypeId
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R> readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>
readonly key: Stream.Stream<K, KE, KR> readonly key: Stream.Stream<K, KE, KR>
readonly f: (key: K) => Effect.Effect<A, E, R> readonly f: (key: K) => Effect.Effect<A, E, R>
readonly initialProgress: P
readonly staleTime: Duration.DurationInput readonly staleTime: Duration.DurationInput
readonly refreshOnWindowFocus: boolean readonly refreshOnWindowFocus: boolean
readonly latestKey: Subscribable.Subscribable<Option.Option<K>> readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>> readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
readonly result: Subscribable.Subscribable<Result.Result<A, E>> readonly result: Subscribable.Subscribable<Result.Result<A, E, P>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Success<A, E> | Result.Failure<A, E>>> readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Final<A, E, P>>>
readonly run: Effect.Effect<void> readonly run: Effect.Effect<void>
fetch(key: K): Effect.Effect<Result.Success<A, E> | Result.Failure<A, E>> fetch(key: K): Effect.Effect<Result.Final<A, E, P>>
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E>>> fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>>
readonly refresh: Effect.Effect<Result.Success<A, E> | Result.Failure<A, E>, Cause.NoSuchElementException> readonly refresh: Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException>
readonly refreshSubscribable: Effect.Effect<Subscribable.Subscribable<Result.Result<A, E>>, Cause.NoSuchElementException> readonly refreshSubscribable: Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException>
readonly invalidateCache: Effect.Effect<void> readonly invalidateCache: Effect.Effect<void>
invalidateCacheEntry(key: K): Effect.Effect<void> invalidateCacheEntry(key: K): Effect.Effect<void>
@@ -36,22 +37,23 @@ export declare namespace Query {
export type AnyKey = readonly any[] export type AnyKey = readonly any[]
} }
export class QueryImpl<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never> 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> { extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
readonly [QueryTypeId]: QueryTypeId = QueryTypeId readonly [QueryTypeId]: QueryTypeId = QueryTypeId
constructor( constructor(
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>, readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>,
readonly key: Stream.Stream<K, KE, KR>, readonly key: Stream.Stream<K, KE, KR>,
readonly f: (key: K) => Effect.Effect<A, E, R>, readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly initialProgress: P,
readonly staleTime: Duration.DurationInput, readonly staleTime: Duration.DurationInput,
readonly refreshOnWindowFocus: boolean, readonly refreshOnWindowFocus: boolean,
readonly latestKey: SubscriptionRef.SubscriptionRef<Option.Option<K>>, readonly latestKey: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
readonly fiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>, readonly fiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
readonly result: SubscriptionRef.SubscriptionRef<Result.Success<A, E> | Result.Failure<A, E>>, readonly result: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>,
readonly latestFinalResult: SubscriptionRef.SubscriptionRef<Option.Option<Result.Success<A, E> | Result.Failure<A, E>>>, readonly latestFinalResult: SubscriptionRef.SubscriptionRef<Option.Option<Result.Final<A, E, P>>>,
readonly runSemaphore: Effect.Semaphore, readonly runSemaphore: Effect.Semaphore,
) { ) {
@@ -86,7 +88,7 @@ extends Pipeable.Class() implements Query<K, A, KE, KR, E, R> {
})) }))
} }
fetch(key: K): Effect.Effect<Result.Success<A, E> | Result.Failure<A, E>> { fetch(key: K): Effect.Effect<Result.Final<A, E, P>> {
return this.interrupt.pipe( return this.interrupt.pipe(
Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))), Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))),
Effect.andThen(this.latestFinalResult), Effect.andThen(this.latestFinalResult),
@@ -150,7 +152,7 @@ extends Pipeable.Class() implements Query<K, A, KE, KR, E, R> {
startCached( startCached(
key: K, key: K,
previous: Result.Success<A, E> | Result.Failure<A, E>, initial: Result.Initial | Result.Final<A, E, P>,
): Effect.Effect< ): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E, P>>, Subscribable.Subscribable<Result.Result<A, E, P>>,
never, never,
@@ -172,46 +174,31 @@ extends Pipeable.Class() implements Query<K, A, KE, KR, E, R> {
start( start(
key: K, key: K,
previous: Result.Success<A, E> | Result.Failure<A, E>, initial: Result.Initial | Result.Final<A, E, P>,
): Effect.Effect< ): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E>>, Subscribable.Subscribable<Result.Result<A, E, P>>,
never, never,
Scope.Scope | R Scope.Scope | R
> { > {
return Effect.Do.pipe( return Result.unsafeForkEffect(
Effect.bind("ref", () => SubscriptionRef.make<Result.Result<A, E>>(Result.initial())), Effect.onExit(this.f(key), () => Effect.andThen(
Effect.all([Effect.fiberId, this.fiber]),
([currentFiberId, fiber]) => Option.match(fiber, {
onSome: v => Equal.equals(currentFiberId, v.id())
? SubscriptionRef.set(this.fiber, Option.none())
: Effect.void,
onNone: () => Effect.void,
}),
)),
{
initial,
initialProgress: this.initialProgress,
} as Result.unsafeForkEffect.Options<A, E, P>,
).pipe(
Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))),
Effect.map(([sub]) => sub),
) )
Effect.onExit(this.f(key), () => Effect.andThen(
Effect.all([Effect.fiberId, this.fiber]),
([currentFiberId, fiber]) => Option.match(fiber, {
onSome: v => Equal.equals(currentFiberId, v.id())
? SubscriptionRef.set(this.fiber, Option.none())
: Effect.void,
onNone: () => Effect.void,
}),
))
// return Result.unsafeForkEffect(
// Effect.onExit(this.f(key), () => Effect.andThen(
// Effect.all([Effect.fiberId, this.fiber]),
// ([currentFiberId, fiber]) => Option.match(fiber, {
// onSome: v => Equal.equals(currentFiberId, v.id())
// ? SubscriptionRef.set(this.fiber, Option.none())
// : Effect.void,
// onNone: () => Effect.void,
// }),
// )),
// {
// initial,
// initialProgress: this.initialProgress,
// } as Result.unsafeForkEffect.Options<A, E, P>,
// ).pipe(
// Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))),
// Effect.map(([sub]) => sub),
// )
} }
watch( watch(
@@ -279,8 +266,10 @@ extends Pipeable.Class() implements Query<K, A, KE, KR, E, R> {
} }
} }
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown> => Predicate.hasProperty(u, QueryTypeId) export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown> => Predicate.hasProperty(u, QueryTypeId)
export declare namespace make { export declare namespace make {
export interface Options<K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never> { export interface Options<K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never> {
readonly key: Stream.Stream<K, KE, KR> readonly key: Stream.Stream<K, KE, KR>
@@ -294,14 +283,14 @@ export declare namespace make {
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>( export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
options: make.Options<K, A, KE, KR, E, R, P> options: make.Options<K, A, KE, KR, E, R, P>
): Effect.fn.Return< ): Effect.fn.Return<
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<A, E, R, P>, P>, Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
never, never,
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<A, E, R, P> Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
> { > {
const client = yield* QueryClient.QueryClient const client = yield* QueryClient.QueryClient
return new QueryImpl( 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<A, E, R, P>>(), yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>>(),
options.key, options.key,
options.f as any, options.f as any,
options.initialProgress as P, options.initialProgress as P,
@@ -321,9 +310,9 @@ export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE =
export const service = <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>( export const service = <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
options: make.Options<K, A, KE, KR, E, R, P> options: make.Options<K, A, KE, KR, E, R, P>
): Effect.Effect< ): Effect.Effect<
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<A, E, R, P>, P>, Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
never, never,
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<A, E, R, P> Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
> => Effect.tap( > => Effect.tap(
make(options), make(options),
query => Effect.forkScoped(query.run), query => Effect.forkScoped(query.run),
+269 -1
View File
@@ -1 +1,269 @@
export * from "@effect-atom/atom/Result" 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")
export type ResultTypeId = typeof ResultTypeId
export type Result<A, E = never, P = never> = (
| Initial
| Running<P>
| Final<A, E, P>
)
// biome-ignore lint/complexity/noBannedTypes: "{}" is relevant here
export type Final<A, E = never, P = never> = (Success<A> | Failure<E>) & ({} | Flags<P>)
export type Flags<P = never> = WillFetch | WillRefresh | Refreshing<P>
export declare namespace Result {
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
}
export declare namespace Flags {
export type Keys = keyof WillFetch & WillRefresh & Refreshing<any>
}
export interface Initial extends ResultPrototype {
readonly _tag: "Initial"
}
export interface Running<P = never> extends ResultPrototype {
readonly _tag: "Running"
readonly progress: P
}
export interface Success<A> extends ResultPrototype {
readonly _tag: "Success"
readonly value: A
}
export interface Failure<E = never> extends ResultPrototype {
readonly _tag: "Failure"
readonly cause: Cause.Cause<E>
}
export interface WillFetch {
readonly _flag: "WillFetch"
}
export interface WillRefresh {
readonly _flag: "WillRefresh"
}
export interface Refreshing<P = never> {
readonly _flag: "Refreshing"
readonly progress: P
}
export interface ResultPrototype extends Pipeable.Pipeable, Equal.Equal {
readonly [ResultTypeId]: ResultTypeId
}
export const ResultPrototype: ResultPrototype = Object.freeze({
...Pipeable.Prototype,
[ResultTypeId]: ResultTypeId,
[Equal.symbol](this: Result<any, any, any>, that: Result<any, any, any>): boolean {
if (this._tag !== that._tag || (this as Flags)._flag !== (that as Flags)._flag)
return false
if (hasRefreshingFlag(this) && !Equal.equals(this.progress, (that as Refreshing<any>).progress))
return false
return Match.value(this).pipe(
Match.tag("Initial", () => true),
Match.tag("Running", self => Equal.equals(self.progress, (that as Running<any>).progress)),
Match.tag("Success", self => Equal.equals(self.value, (that as Success<any>).value)),
Match.tag("Failure", self => Equal.equals(self.cause, (that as Failure<any>).cause)),
Match.exhaustive,
)
},
[Hash.symbol](this: Result<any, any, any>): number {
return pipe(Hash.string(this._tag),
tagHash => Match.value(this).pipe(
Match.tag("Initial", () => tagHash),
Match.tag("Running", self => Hash.combine(Hash.hash(self.progress))(tagHash)),
Match.tag("Success", self => Hash.combine(Hash.hash(self.value))(tagHash)),
Match.tag("Failure", self => Hash.combine(Hash.hash(self.cause))(tagHash)),
Match.exhaustive,
),
Hash.combine(Hash.hash((this as Flags)._flag)),
hash => hasRefreshingFlag(this)
? Hash.combine(Hash.hash(this.progress))(hash)
: hash,
Hash.cached(this),
)
},
} as const)
export const isResult = (u: unknown): u is Result<unknown, unknown, unknown> => Predicate.hasProperty(u, ResultTypeId)
export const isFinal = (u: unknown): u is Final<unknown, unknown, unknown> => isResult(u) && (isSuccess(u) || isFailure(u))
export const isInitial = (u: unknown): u is Initial => isResult(u) && u._tag === "Initial"
export const isRunning = (u: unknown): u is Running<unknown> => isResult(u) && u._tag === "Running"
export const isSuccess = (u: unknown): u is Success<unknown> => isResult(u) && u._tag === "Success"
export const isFailure = (u: unknown): u is Failure<unknown> => isResult(u) && u._tag === "Failure"
export const hasFlag = (u: unknown): u is Flags => isResult(u) && Predicate.hasProperty(u, "_flag")
export const hasWillFetchFlag = (u: unknown): u is WillFetch => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "WillFetch"
export const hasWillRefreshFlag = (u: unknown): u is WillRefresh => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "WillRefresh"
export const hasRefreshingFlag = (u: unknown): u is Refreshing<unknown> => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "Refreshing"
export const initial: {
(): Initial
<A, E = never, P = never>(): Result<A, E, P>
} = (): Initial => Object.setPrototypeOf({ _tag: "Initial" }, ResultPrototype)
export const running = <P = never>(progress?: P): Running<P> => Object.setPrototypeOf({ _tag: "Running", progress }, ResultPrototype)
export const succeed = <A>(value: A): Success<A> => Object.setPrototypeOf({ _tag: "Success", value }, ResultPrototype)
export const fail = <E>(cause: Cause.Cause<E> ): Failure<E> => Object.setPrototypeOf({ _tag: "Failure", cause }, ResultPrototype)
export const willFetch = <R extends Final<any, any, any>>(
result: R
): Omit<R, keyof Flags.Keys> & WillFetch => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "WillFetch" }),
Object.getPrototypeOf(result),
)
export const willRefresh = <R extends Final<any, any, any>>(
result: R
): Omit<R, keyof Flags.Keys> & WillRefresh => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "WillRefresh" }),
Object.getPrototypeOf(result),
)
export const refreshing = <R extends Final<any, any, any>, P = never>(
result: R,
progress?: P,
): Omit<R, keyof Flags.Keys> & Refreshing<P> => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "Refreshing", progress }),
Object.getPrototypeOf(result),
)
export const fromExit: {
<A, E>(exit: Exit.Success<A, E>): Success<A>
<A, E>(exit: Exit.Failure<A, E>): Failure<E>
<A, E>(exit: Exit.Exit<A, E>): Success<A> | Failure<E>
} = exit => (exit._tag === "Success" ? succeed(exit.value) : fail(exit.cause)) as any
export const toExit: {
<A>(self: Success<A>): Exit.Success<A, never>
<E>(self: Failure<E>): Exit.Failure<never, E>
<A, E, P>(self: Final<A, E, P>): Exit.Exit<A, E>
<A, E, P>(self: Result<A, E, P>): Exit.Exit<A, E | Cause.NoSuchElementException>
} = <A, E, P>(self: Result<A, E, P>): any => {
switch (self._tag) {
case "Success":
return Exit.succeed(self.value)
case "Failure":
return Exit.failCause(self.cause)
default:
return Exit.fail(new Cause.NoSuchElementException())
}
}
export interface Progress<P = never> {
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 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),
),
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<R, P> = Exclude<R, Progress<P> | Progress<never>>
export interface Options<A, E, P> {
readonly initial?: Initial | Final<A, E, P>
readonly initialProgress?: P
}
}
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.fn.Return<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
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),
)
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<R, P> = unsafeForkEffect.OutputContext<R, P>
export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {}
}
export const forkEffect: {
<A, E, R, P = never>(
effect: Effect.Effect<A, E, forkEffect.InputContext<R, NoInfer<P>>>,
options?: forkEffect.Options<NoInfer<A>, NoInfer<E>, P>,
): Effect.Effect<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
Scope.Scope | forkEffect.OutputContext<R, P>
>
} = unsafeForkEffect
+1 -1
View File
@@ -3,8 +3,8 @@ import type * as React from "react"
export const value: { export const value: {
<S>(prevState: S): (self: React.SetStateAction<S>) => S
<S>(self: React.SetStateAction<S>, prevState: 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 => } = Function.dual(2, <S>(self: React.SetStateAction<S>, prevState: S): S =>
typeof self === "function" typeof self === "function"
? (self as (prevState: S) => S)(prevState) ? (self as (prevState: S) => S)(prevState)
+1 -1
View File
@@ -3,7 +3,7 @@ import * as React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
export const useStream: { export const use: {
<A, E, R>( <A, E, R>(
stream: Stream.Stream<A, E, R> stream: Stream.Stream<A, E, R>
): Effect.Effect<Option.Option<A>, never, R> ): Effect.Effect<Option.Option<A>, never, R>
+220
View File
@@ -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),
)
+8 -7
View File
@@ -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 React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
export * from "effect-lens/Subscribable"
export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<any, any, any>[]>( export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
...elements: T ...elements: T
): Subscribable.Subscribable< ): Subscribable.Subscribable<
@@ -16,7 +19,7 @@ export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<
changes: Stream.zipLatestAll(...elements.map(v => v.changes)), changes: Stream.zipLatestAll(...elements.map(v => v.changes)),
}) as any }) as any
export declare namespace useSubscribables { export declare namespace useAll {
export type Success<T extends readonly Subscribable.Subscribable<any, any, any>[]> = [T[number]] extends [never] export type Success<T extends readonly Subscribable.Subscribable<any, any, any>[]> = [T[number]] extends [never]
? never ? never
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never } : { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never }
@@ -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, elements: T,
options?: useSubscribables.Options<useSubscribables.Success<NoInfer<T>>>, options?: useAll.Options<useAll.Success<NoInfer<T>>>,
): Effect.fn.Return< ): 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> ? E : never,
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
> { > {
@@ -48,5 +51,3 @@ export const useSubscribables = Effect.fnUntraced(function* <const T extends rea
return reactStateValue as any return reactStateValue as any
}) })
export * from "effect/Subscribable"
-61
View File
@@ -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
+223
View File
@@ -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),
)
+3 -3
View File
@@ -2,9 +2,9 @@ export * as Async from "./Async.js"
export * as Component from "./Component.js" export * as Component from "./Component.js"
export * as ErrorObserver from "./ErrorObserver.js" export * as ErrorObserver from "./ErrorObserver.js"
export * as Form from "./Form.js" export * as Form from "./Form.js"
export * as Lens from "./Lens.js"
export * as Memoized from "./Memoized.js" export * as Memoized from "./Memoized.js"
export * as Mutation from "./Mutation.js" export * as Mutation from "./Mutation.js"
export * as PropertyPath from "./PropertyPath.js"
export * as PubSub from "./PubSub.js" export * as PubSub from "./PubSub.js"
export * as Query from "./Query.js" export * as Query from "./Query.js"
export * as QueryClient from "./QueryClient.js" export * as QueryClient from "./QueryClient.js"
@@ -12,6 +12,6 @@ export * as ReactRuntime from "./ReactRuntime.js"
export * as Result from "./Result.js" export * as Result from "./Result.js"
export * as SetStateAction from "./SetStateAction.js" export * as SetStateAction from "./SetStateAction.js"
export * as Stream from "./Stream.js" export * as Stream from "./Stream.js"
export * as SubmittableForm from "./SubmittableForm.js"
export * as Subscribable from "./Subscribable.js" export * as Subscribable from "./Subscribable.js"
export * as SubscriptionRef from "./SubscriptionRef.js" export * as SynchronizedForm from "./SynchronizedForm.js"
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"
+354
View File
@@ -0,0 +1,354 @@
import { render, screen, waitFor } from "@testing-library/react"
import { Context, Effect, Layer } from "effect"
import * as React from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
import * as Component from "../src/Component.js"
import * as ReactRuntime from "../src/ReactRuntime.js"
class ValueService extends Context.Tag("ValueService")<ValueService, { readonly value: string }>() {}
afterEach(() => {
vi.useRealTimers()
})
describe("Component", () => {
it("runs useOnMount only once across rerenders", async () => {
const onMount = vi.fn(() => Effect.succeed("mounted"))
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
const Probe = Component.makeUntraced("UseOnMountProbe")(function*() {
const value = yield* Component.useOnMount(onMount)
return <div>{value}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("mounted")
expect(onMount).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
expect(await screen.findByText("mounted")).toBeTruthy()
expect(onMount).toHaveBeenCalledTimes(1)
view.unmount()
await Effect.runPromise(runtime.runtime.disposeEffect)
})
it("recomputes useOnChange only when dependencies change", async () => {
const onChange = vi.fn((value: number) => Effect.succeed(`value:${value}`))
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
const Probe = Component.makeUntraced("UseOnChangeProbe")(function*(props: { readonly value: number }) {
const result = yield* Component.useOnChange(() => onChange(props.value), [props.value], {
finalizerExecutionDebounce: 0,
})
return <div>{result}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value={1} />
</runtime.context.Provider>
)
await screen.findByText("value:1")
expect(onChange).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value={1} />
</runtime.context.Provider>
)
expect(await screen.findByText("value:1")).toBeTruthy()
expect(onChange).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value={2} />
</runtime.context.Provider>
)
await screen.findByText("value:2")
expect(onChange).toHaveBeenCalledTimes(2)
view.unmount()
await Effect.runPromise(runtime.runtime.disposeEffect)
})
it("closes the previous scope on dependency changes and unmount", async () => {
const cleanup = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
const Probe = Component.makeUntraced("ScopeCleanupProbe")(function*(props: { readonly value: string }) {
const result = yield* Component.useOnChange(
() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
return props.value
}),
[props.value],
{ finalizerExecutionDebounce: 0 },
)
return <div>{result}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
expect(cleanup).not.toHaveBeenCalled()
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value="second" />
</runtime.context.Provider>
)
await screen.findByText("second")
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"))
expect(cleanup).toHaveBeenCalledTimes(1)
view.unmount()
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"))
expect(cleanup).toHaveBeenCalledTimes(2)
await Effect.runPromise(runtime.runtime.disposeEffect)
})
it("runs useReactEffect setup and cleanup when dependencies change", async () => {
const lifecycle = vi.fn<(message: string) => void>()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
const Probe = Component.makeUntraced("UseReactEffectProbe")(function*(props: { readonly value: string }) {
yield* Component.useReactEffect(() =>
Effect.gen(function*() {
yield* Effect.sync(() => lifecycle(`mount:${props.value}`))
yield* Effect.addFinalizer(() => Effect.sync(() => lifecycle(`cleanup:${props.value}`)))
}),
[props.value])
return <div>{props.value}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:first"))
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value="second" />
</runtime.context.Provider>
)
await screen.findByText("second")
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:first"))
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:second"))
view.unmount()
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:second"))
expect(lifecycle.mock.calls.map(([message]) => message)).toEqual([
"mount:first",
"cleanup:first",
"mount:second",
"cleanup:second",
])
await Effect.runPromise(runtime.runtime.disposeEffect)
})
it("keeps useCallbackSync stable until dependencies change", async () => {
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
const seenCallbacks: Array<(value: number) => string> = []
const Probe = Component.makeUntraced("UseCallbackSyncProbe")(function*(props: { readonly prefix: string }) {
const callback = yield* Component.useCallbackSync(
(value: number) => Effect.succeed(`${props.prefix}:${value}`),
[props.prefix],
)
yield* Component.useOnMount(() => Effect.sync(() => {
seenCallbacks.push(callback)
}))
React.useEffect(() => {
seenCallbacks.push(callback)
}, [callback])
return <div>{callback(1)}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe prefix="a" />
</runtime.context.Provider>
)
await screen.findByText("a:1")
expect(seenCallbacks).toHaveLength(2)
expect(seenCallbacks[0]).toBe(seenCallbacks[1])
expect(seenCallbacks[0]?.(2)).toBe("a:2")
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe prefix="a" />
</runtime.context.Provider>
)
await screen.findByText("a:1")
expect(seenCallbacks).toHaveLength(2)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe prefix="b" />
</runtime.context.Provider>
)
await screen.findByText("b:1")
await waitFor(() => expect(seenCallbacks).toHaveLength(3))
expect(seenCallbacks[2]).not.toBe(seenCallbacks[1])
expect(seenCallbacks[2]?.(2)).toBe("b:2")
view.unmount()
await Effect.runPromise(runtime.runtime.disposeEffect)
})
it("delays cleanup according to finalizerExecutionDebounce", async () => {
const cleanup = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
const Probe = Component.makeUntraced("DebouncedCleanupProbe")(function*(props: { readonly value: string }) {
const result = yield* Component.useOnChange(
() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
return props.value
}),
[props.value],
{ finalizerExecutionDebounce: "20 millis" },
)
return <div>{result}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value="second" />
</runtime.context.Provider>
)
await screen.findByText("second")
expect(cleanup).not.toHaveBeenCalled()
await new Promise(resolve => setTimeout(resolve, 5))
expect(cleanup).not.toHaveBeenCalled()
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"), { timeout: 100 })
view.unmount()
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"), { timeout: 100 })
await Effect.runPromise(runtime.runtime.disposeEffect)
})
it("does not remount a component when only nonReactiveTags change", async () => {
const mounts = vi.fn()
const unmounts = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
const SubComponent = Component.makeUntraced("NonReactiveSubComponent")(function*() {
const service = yield* ValueService
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.sync(() => mounts())
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
}))
return <div>{service.value}</div>
}).pipe(
Component.withOptions({ nonReactiveTags: [ValueService] })
)
const Parent = Component.makeUntraced("NonReactiveParent")(function*(props: { readonly value: string }) {
const serviceLayer = React.useMemo(
() => Layer.succeed(ValueService, { value: props.value }),
[props.value],
)
const context = yield* Component.useContextFromLayer(serviceLayer, {
finalizerExecutionDebounce: 0,
})
const Child = yield* Effect.provide(SubComponent.use, context)
return <Child />
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Parent value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
expect(mounts).toHaveBeenCalledTimes(1)
expect(unmounts).not.toHaveBeenCalled()
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Parent value="second" />
</runtime.context.Provider>
)
await screen.findByText("second")
expect(mounts).toHaveBeenCalledTimes(1)
expect(unmounts).not.toHaveBeenCalled()
view.unmount()
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
await Effect.runPromise(runtime.runtime.disposeEffect)
})
})
+165
View File
@@ -0,0 +1,165 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { Effect, Layer, SubscriptionRef } from "effect"
import * as React from "react"
import { describe, expect, it } from "vitest"
import * as Component from "../src/Component.js"
import * as Lens from "../src/Lens.js"
import * as ReactRuntime from "../src/ReactRuntime.js"
const makeRuntime = async () => {
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
return {
runtime,
effectRuntime,
dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
}
}
describe("Lens", () => {
it("useState stays in sync with lens updates in both directions", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
const ref = await Effect.runPromise(SubscriptionRef.make(0))
const lens = Lens.fromSubscriptionRef(ref)
const Probe = Component.makeUntraced("LensUseStateProbe")(function*() {
const [value, setValue] = yield* Lens.useState(lens)
return (
<>
<div>{value}</div>
<button onClick={() => setValue(previous => previous + 1)}>increment</button>
</>
)
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("0")
await Effect.runPromise(Lens.set(lens, 5))
await screen.findByText("5")
fireEvent.click(screen.getByRole("button", { name: "increment" }))
await screen.findByText("6")
expect(await Effect.runPromise(Lens.get(lens))).toBe(6)
view.unmount()
await dispose()
})
it("useState respects the provided equivalence when subscribing to lens changes", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
const ref = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
const lens = Lens.fromSubscriptionRef(ref)
const Probe = Component.makeUntraced("LensUseStateEquivalenceProbe")(function*() {
const [value] = yield* Lens.useState(lens, {
equivalence: (self, that) => self.id === that.id,
})
return <div>{value.label}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("first")
await Effect.runPromise(Lens.set(lens, { id: 1, label: "ignored" }))
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
expect(screen.queryByText("ignored")).toBeNull()
await Effect.runPromise(Lens.set(lens, { id: 2, label: "updated" }))
await screen.findByText("updated")
view.unmount()
await dispose()
})
it("useFromReactState writes React state changes into the returned lens", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
let lens: Lens.Lens<string, never, never, never, never> | undefined
const Probe = Component.makeUntraced("LensUseFromReactStateProbe")(function*() {
const [value, setValue] = React.useState("hello")
const reactLens = yield* Lens.useFromReactState([value, setValue])
yield* Component.useOnMount(() => Effect.sync(() => {
lens = reactLens
}))
return <button onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("hello")
await waitFor(() => expect(lens).toBeDefined())
fireEvent.click(screen.getByRole("button", { name: "hello" }))
await screen.findByText("hello!")
await waitFor(async () => expect(await Effect.runPromise(Lens.get(lens!))).toBe("hello!"))
view.unmount()
await dispose()
})
it("useFromReactState respects equivalence when lens updates flow back into React state", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
let lens: Lens.Lens<{ readonly id: number; readonly label: string }, never, never, never, never> | undefined
const Probe = Component.makeUntraced("LensUseFromReactStateEquivalenceProbe")(function*() {
const [value, setValue] = React.useState({ id: 1, label: "first" })
const reactLens = yield* Lens.useFromReactState([value, setValue], {
equivalence: (self, that) => self.id === that.id,
})
yield* Component.useOnMount(() => Effect.sync(() => {
lens = reactLens
}))
return <div>{value.label}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("first")
await waitFor(() => expect(lens).toBeDefined())
await Effect.runPromise(Lens.set(lens!, { id: 1, label: "ignored" }))
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
expect(screen.queryByText("ignored")).toBeNull()
await Effect.runPromise(Lens.set(lens!, { id: 2, label: "updated" }))
await screen.findByText("updated")
view.unmount()
await dispose()
})
})
+169
View File
@@ -0,0 +1,169 @@
import { Effect, Option, type Scope, Stream } from "effect"
import { describe, expect, it } from "vitest"
import * as Query from "../src/Query.js"
import * as QueryClient from "../src/QueryClient.js"
import * as Result from "../src/Result.js"
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
Effect.runPromise(Effect.scoped(effect.pipe(
Effect.provide(QueryClient.QueryClient.Default),
)))
const expectSuccessValue = <A, E, P>(
result: Result.Result<A, E, P>,
): A => {
expect(Result.isSuccess(result)).toBe(true)
if (!Result.isSuccess(result))
throw new Error(`Expected Success result, received ${result._tag}`)
return result.value
}
const expectSomeValue = <A>(option: Option.Option<A>): A => {
expect(Option.isSome(option)).toBe(true)
if (!Option.isSome(option))
throw new Error("Expected Some option, received None")
return option.value
}
describe("Query", () => {
it("fetch caches successful results until they are invalidated or stale", async () => {
let calls = 0
const key = Stream.empty as Stream.Stream<readonly [number]>
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
const first = yield* query.fetch([1])
const second = yield* query.fetch([1])
return [first, second] as const
}))
expect(calls).toBe(1)
expect(result[0]._tag).toBe("Success")
expect(result[1]._tag).toBe("Success")
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:1")
})
it("refresh reruns the latest query key", async () => {
let calls = 0
const key = Stream.empty as Stream.Stream<readonly [number]>
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "0 millis",
})
const first = yield* query.fetch([1])
yield* Effect.sleep("1 millis")
const refreshed = yield* query.refresh
return [first, refreshed] as const
}))
expect(calls).toBe(2)
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:2")
})
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
let calls = 0
const key = Stream.empty as Stream.Stream<readonly [number]>
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
const first = yield* query.fetch([1])
yield* query.invalidateCacheEntry([1])
const second = yield* query.fetch([1])
return [first, second] as const
}))
expect(calls).toBe(2)
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:2")
})
it("invalidateCache clears cached entries for the query function", async () => {
let calls = 0
const key = Stream.empty as Stream.Stream<readonly [number]>
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
const first = yield* query.fetch([1])
yield* query.invalidateCache
const second = yield* query.fetch([1])
return [first, second] as const
}))
expect(calls).toBe(2)
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:2")
})
it("service starts the key stream automatically and updates latest state", async () => {
let calls = 0
const key = Stream.make([1] as const) as Stream.Stream<readonly [number]>
const effect = Effect.gen(function*() {
const query = yield* Query.service({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
yield* Effect.sleep("10 millis")
return {
final: yield* query.result.get,
latestKey: yield* query.latestKey.get,
latestFinalResult: yield* query.latestFinalResult.get,
}
})
const result = await runQueryTest(effect)
expect(calls).toBe(1)
expect(expectSuccessValue(result.final)).toBe("value:1:1")
expect(expectSomeValue(result.latestKey)).toEqual([1])
expect(expectSuccessValue(expectSomeValue(result.latestFinalResult))).toBe("value:1:1")
})
})
@@ -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 "../src/Component.js"
import * as ReactRuntime from "../src/ReactRuntime.js"
import * as Subscribable from "../src/Subscribable.js"
const makeRuntime = async () => {
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
return {
runtime,
effectRuntime,
dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
}
}
describe("Subscribable", () => {
it("zipLatestAll reads current values from all inputs", async () => {
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
const left = Lens.fromSubscriptionRef(leftRef)
const right = Lens.fromSubscriptionRef(rightRef)
const zipped = Subscribable.zipLatestAll(left, right)
expect(await Effect.runPromise(zipped.get)).toEqual([1, "a"])
})
it("zipLatestAll emits updates when any input changes", async () => {
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
const left = Lens.fromSubscriptionRef(leftRef)
const right = Lens.fromSubscriptionRef(rightRef)
const zipped = Subscribable.zipLatestAll(left, right)
const values: Array<readonly [number, string]> = []
const collector = Effect.runFork(Effect.scoped(zipped.changes.pipe(
Stream.runForEach(value => Effect.sync(() => {
values.push(value as readonly [number, string])
})),
)))
await Effect.runPromise(Lens.set(left, 2))
await waitFor(() => expect(values).toContainEqual([2, "a"]))
await Effect.runPromise(Lens.set(right, "b"))
await waitFor(() => expect(values).toContainEqual([2, "b"]))
Fiber.interruptFork(collector)
})
it("useAll returns the latest values and rerenders when any input changes", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
const countRef = await Effect.runPromise(SubscriptionRef.make(1))
const labelRef = await Effect.runPromise(SubscriptionRef.make("a"))
const count = Lens.fromSubscriptionRef(countRef)
const label = Lens.fromSubscriptionRef(labelRef)
const Probe = Component.makeUntraced("SubscribableUseAllProbe")(function*() {
const [currentCount, currentLabel] = yield* Subscribable.useAll([count, label])
return <div>{`${currentCount}:${currentLabel}`}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("1:a")
await Effect.runPromise(Lens.set(count, 2))
await screen.findByText("2:a")
await Effect.runPromise(Lens.set(label, "b"))
await screen.findByText("2:b")
view.unmount()
await dispose()
})
it("useAll respects the provided equivalence when processing updates", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
const itemRef = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
const flagRef = await Effect.runPromise(SubscriptionRef.make(true))
const item = Lens.fromSubscriptionRef(itemRef)
const flag = Lens.fromSubscriptionRef(flagRef)
const Probe = Component.makeUntraced("SubscribableUseAllEquivalenceProbe")(function*() {
const [currentItem, currentFlag] = yield* Subscribable.useAll([item, flag], {
equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) =>
selfItem.id === thatItem.id && selfFlag === thatFlag,
})
return <div>{`${currentItem.label}:${currentFlag ? "on" : "off"}`}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("first:on")
await Effect.runPromise(Lens.set(item, { id: 1, label: "ignored" }))
await waitFor(() => expect(screen.getByText("first:on")).toBeTruthy())
expect(screen.queryByText("ignored:on")).toBeNull()
await Effect.runPromise(Lens.set(flag, false))
await screen.findByText("ignored:off")
await Effect.runPromise(Lens.set(item, { id: 2, label: "updated" }))
await screen.findByText("updated:off")
view.unmount()
await dispose()
})
})
+3 -1
View File
@@ -25,6 +25,7 @@
"noPropertyAccessFromIndexSignature": false, "noPropertyAccessFromIndexSignature": false,
// Build // Build
"rootDir": "./src",
"outDir": "./dist", "outDir": "./dist",
"declaration": true, "declaration": true,
"sourceMap": true, "sourceMap": true,
@@ -34,5 +35,6 @@
] ]
}, },
"include": ["./src"] "include": ["./src"],
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
} }
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "jsdom",
include: ["test/**/*.test.ts?(x)"],
},
})
+18 -18
View File
@@ -13,30 +13,30 @@
"clean:modules": "rm -rf node_modules" "clean:modules": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/react-router": "^1.154.12", "@tanstack/react-router": "^1.170.10",
"@tanstack/react-router-devtools": "^1.154.12", "@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.154.12", "@tanstack/router-plugin": "^1.168.13",
"@types/react": "^19.2.9", "@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^6.0.2",
"globals": "^17.0.0", "globals": "^17.6.0",
"react": "^19.2.3", "react": "^19.2.6",
"react-dom": "^19.2.3", "react-dom": "^19.2.6",
"type-fest": "^5.4.1", "type-fest": "^5.7.0",
"vite": "^7.3.1" "vite": "^8.0.16"
}, },
"dependencies": { "dependencies": {
"@effect/platform": "^0.94.2", "@effect/platform": "^0.96.1",
"@effect/platform-browser": "^0.74.0", "@effect/platform-browser": "^0.76.0",
"@radix-ui/themes": "^3.2.1", "@radix-ui/themes": "^3.3.0",
"@typed/id": "^0.17.2", "@typed/id": "^0.17.2",
"effect": "^3.19.15", "effect": "^3.21.2",
"effect-fc": "workspace:*", "effect-fc": "workspace:*",
"react-icons": "^5.5.0" "react-icons": "^5.6.0"
}, },
"overrides": { "overrides": {
"@types/react": "^19.2.9", "@types/react": "^19.2.15",
"effect": "^3.19.15", "effect": "^3.21.2",
"react": "^19.2.3" "react": "^19.2.6"
} }
} }
@@ -1,37 +1,26 @@
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes" import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect" import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc" import { Component, Form, Subscribable } from "effect-fc"
import type * as React from "react"
interface Props export declare namespace TextFieldFormInputView {
extends TextField.RootProps, Form.useInput.Options { export interface Props<out P extends readonly PropertyKey[], A, ER, EW>
readonly optional?: false extends Omit<TextField.RootProps, "form">, Form.useInput.Options {
readonly field: Form.FormField<any, string> 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
} }
interface OptionalProps export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(function*(
extends Omit<TextField.RootProps, "optional" | "defaultValue">, Form.useOptionalInput.Options<string> { props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
readonly optional: true ) {
readonly field: Form.FormField<any, Option.Option<string>> const input = yield* Form.useInput(props.form, props)
} const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
props.form.issues,
export type TextFieldFormInputProps = Props | OptionalProps props.form.isValidating,
props.form.isCommitting,
export class TextFieldFormInputView extends Component.make("TextFieldFormInputView")(function*(props: TextFieldFormInputProps) {
const input: (
| { readonly optional: true } & Form.useOptionalInput.Success<string>
| { readonly optional: false } & Form.useInput.Success<string>
) = props.optional
// biome-ignore lint/correctness/useHookAtTopLevel: "optional" reactivity not supported
? { optional: true, ...yield* Form.useOptionalInput(props.field, props) }
// biome-ignore lint/correctness/useHookAtTopLevel: "optional" reactivity not supported
: { optional: false, ...yield* Form.useInput(props.field, props) }
const [issues, isValidating, isSubmitting] = yield* Subscribable.useSubscribables([
props.field.issues,
props.field.isValidating,
props.field.isSubmitting,
]) ])
return ( return (
@@ -39,19 +28,9 @@ export class TextFieldFormInputView extends Component.make("TextFieldFormInputVi
<TextField.Root <TextField.Root
value={input.value} value={input.value}
onChange={e => input.setValue(e.target.value)} onChange={e => input.setValue(e.target.value)}
disabled={(input.optional && !input.enabled) || isSubmitting} disabled={isCommitting}
{...Struct.omit(props, "optional", "defaultValue")} {...Struct.omit(props, "form")}
> >
{input.optional &&
<TextField.Slot side="left">
<Switch
size="1"
checked={input.enabled}
onCheckedChange={input.setEnabled}
/>
</TextField.Slot>
}
{isValidating && {isValidating &&
<TextField.Slot side="right"> <TextField.Slot side="right">
<Spinner /> <Spinner />
@@ -72,4 +51,6 @@ export class TextFieldFormInputView extends Component.make("TextFieldFormInputVi
})} })}
</Flex> </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>()
)
+21 -21
View File
@@ -13,10 +13,10 @@ import { Route as ResultRouteImport } from './routes/result'
import { Route as QueryRouteImport } from './routes/query' import { Route as QueryRouteImport } from './routes/query'
import { Route as FormRouteImport } from './routes/form' import { Route as FormRouteImport } from './routes/form'
import { Route as BlankRouteImport } from './routes/blank' import { Route as BlankRouteImport } from './routes/blank'
import { Route as AsyncRouteImport } from './routes/async'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as DevMemoRouteImport } from './routes/dev/memo' import { Route as DevMemoRouteImport } from './routes/dev/memo'
import { Route as DevContextRouteImport } from './routes/dev/context' import { Route as DevContextRouteImport } from './routes/dev/context'
import { Route as DevAsyncRenderingRouteImport } from './routes/dev/async-rendering'
const ResultRoute = ResultRouteImport.update({ const ResultRoute = ResultRouteImport.update({
id: '/result', id: '/result',
@@ -38,6 +38,11 @@ const BlankRoute = BlankRouteImport.update({
path: '/blank', path: '/blank',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AsyncRoute = AsyncRouteImport.update({
id: '/async',
path: '/async',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@@ -53,40 +58,35 @@ const DevContextRoute = DevContextRouteImport.update({
path: '/dev/context', path: '/dev/context',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({
id: '/dev/async-rendering',
path: '/dev/async-rendering',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/context': typeof DevContextRoute '/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute '/dev/memo': typeof DevMemoRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/context': typeof DevContextRoute '/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute '/dev/memo': typeof DevMemoRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/context': typeof DevContextRoute '/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute '/dev/memo': typeof DevMemoRoute
} }
@@ -94,42 +94,42 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/' | '/'
| '/async'
| '/blank' | '/blank'
| '/form' | '/form'
| '/query' | '/query'
| '/result' | '/result'
| '/dev/async-rendering'
| '/dev/context' | '/dev/context'
| '/dev/memo' | '/dev/memo'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/async'
| '/blank' | '/blank'
| '/form' | '/form'
| '/query' | '/query'
| '/result' | '/result'
| '/dev/async-rendering'
| '/dev/context' | '/dev/context'
| '/dev/memo' | '/dev/memo'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/async'
| '/blank' | '/blank'
| '/form' | '/form'
| '/query' | '/query'
| '/result' | '/result'
| '/dev/async-rendering'
| '/dev/context' | '/dev/context'
| '/dev/memo' | '/dev/memo'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AsyncRoute: typeof AsyncRoute
BlankRoute: typeof BlankRoute BlankRoute: typeof BlankRoute
FormRoute: typeof FormRoute FormRoute: typeof FormRoute
QueryRoute: typeof QueryRoute QueryRoute: typeof QueryRoute
ResultRoute: typeof ResultRoute ResultRoute: typeof ResultRoute
DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute
DevContextRoute: typeof DevContextRoute DevContextRoute: typeof DevContextRoute
DevMemoRoute: typeof DevMemoRoute DevMemoRoute: typeof DevMemoRoute
} }
@@ -164,6 +164,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof BlankRouteImport preLoaderRoute: typeof BlankRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/async': {
id: '/async'
path: '/async'
fullPath: '/async'
preLoaderRoute: typeof AsyncRouteImport
parentRoute: typeof rootRouteImport
}
'/': { '/': {
id: '/' id: '/'
path: '/' path: '/'
@@ -185,23 +192,16 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DevContextRouteImport preLoaderRoute: typeof DevContextRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/dev/async-rendering': {
id: '/dev/async-rendering'
path: '/dev/async-rendering'
fullPath: '/dev/async-rendering'
preLoaderRoute: typeof DevAsyncRenderingRouteImport
parentRoute: typeof rootRouteImport
}
} }
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AsyncRoute: AsyncRoute,
BlankRoute: BlankRoute, BlankRoute: BlankRoute,
FormRoute: FormRoute, FormRoute: FormRoute,
QueryRoute: QueryRoute, QueryRoute: QueryRoute,
ResultRoute: ResultRoute, ResultRoute: ResultRoute,
DevAsyncRenderingRoute: DevAsyncRenderingRoute,
DevContextRoute: DevContextRoute, DevContextRoute: DevContextRoute,
DevMemoRoute: DevMemoRoute, DevMemoRoute: DevMemoRoute,
} }
+71
View File
@@ -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,
})
@@ -1,78 +0,0 @@
import { Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Effect } from "effect"
import { Async, Component, Memoized } from "effect-fc"
import * as React from "react"
import { runtime } from "@/runtime"
// Generator version
const RouteComponent = Component.makeUntraced(function* AsyncRendering() {
const MemoizedAsyncComponentFC = yield* MemoizedAsyncComponent.use
const AsyncComponentFC = yield* AsyncComponent.use
const [input, setInput] = React.useState("")
return (
<Flex direction="column" align="stretch" gap="2">
<TextField.Root
value={input}
onChange={e => setInput(e.target.value)}
/>
<MemoizedAsyncComponentFC fallback={React.useMemo(() => <p>Loading memoized...</p>, [])} />
<AsyncComponentFC />
</Flex>
)
}).pipe(
Component.withRuntime(runtime.context)
)
// Pipeline version
// const RouteComponent = Component.make("RouteComponent")(() => Effect.Do,
// Effect.bind("VMemoizedAsyncComponent", () => Component.useFC(MemoizedAsyncComponent)),
// Effect.bind("VAsyncComponent", () => Component.useFC(AsyncComponent)),
// Effect.let("input", () => React.useState("")),
// Effect.map(({ input: [input, setInput], VAsyncComponent, VMemoizedAsyncComponent }) =>
// <Flex direction="column" align="stretch" gap="2">
// <TextField.Root
// value={input}
// onChange={e => setInput(e.target.value)}
// />
// <VMemoizedAsyncComponent />
// <VAsyncComponent />
// </Flex>
// ),
// ).pipe(
// Component.withRuntime(runtime.context)
// )
class AsyncComponent extends Component.makeUntraced("AsyncComponent")(function*() {
const SubComponentFC = yield* SubComponent.use
yield* Effect.sleep("500 millis") // Async operation
// Cannot use React hooks after the async operation
return (
<Flex direction="column" align="stretch">
<Text>Rendered!</Text>
<SubComponentFC />
</Flex>
)
}).pipe(
Async.async,
Async.withOptions({ defaultFallback: <p>Loading...</p> }),
) {}
class MemoizedAsyncComponent extends Memoized.memoized(AsyncComponent) {}
class SubComponent extends Component.makeUntraced("SubComponent")(function*() {
const [state] = React.useState(yield* Component.useOnMount(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
return <Text>{state}</Text>
}) {}
export const Route = createFileRoute("/dev/async-rendering")({
component: RouteComponent
})
+39 -28
View File
@@ -1,8 +1,9 @@
import { Button, Container, Flex, Text } from "@radix-ui/themes" import { Button, Container, Flex, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect" import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
import { Component, Form, Subscribable } from "effect-fc" import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView" import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { DateTimeUtcFromZonedInput } from "@/lib/schema" import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
@@ -39,38 +40,47 @@ const RegisterFormSubmitSchema = Schema.Struct({
}) })
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", { class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
scoped: Form.service({ scoped: Effect.gen(function*() {
schema: RegisterFormSchema.pipe( const form = yield* SubmittableForm.service({
Schema.compose( schema: RegisterFormSchema.pipe(
Schema.transformOrFail( Schema.compose(
Schema.typeSchema(RegisterFormSchema), Schema.transformOrFail(
Schema.typeSchema(RegisterFormSchema), Schema.typeSchema(RegisterFormSchema),
{ Schema.typeSchema(RegisterFormSchema),
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)), {
encode: ParseResult.succeed, decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
}, encode: ParseResult.succeed,
},
),
), ),
), ),
),
initialEncodedValue: { email: "", password: "", birth: Option.none() }, initialEncodedValue: { email: "", password: "", birth: Option.none() },
f: Effect.fnUntraced(function*([value]) { f: Effect.fnUntraced(function*([value]) {
yield* Effect.sleep("500 millis") yield* Effect.sleep("500 millis")
return yield* Schema.decode(RegisterFormSubmitSchema)(value) return yield* Schema.decode(RegisterFormSubmitSchema)(value)
}), }),
debounce: "500 millis", })
return {
form,
emailField: Form.focusObjectOn(form, "email"),
passwordField: Form.focusObjectOn(form, "password"),
birthField: Form.focusObjectOn(form, "birth"),
} as const
}) })
}) {} }) {}
class RegisterFormView extends Component.make("RegisterFormView")(function*() { class RegisterFormView extends Component.make("RegisterFormView")(function*() {
const form = yield* RegisterFormService const form = yield* RegisterFormService
const [canSubmit, submitResult] = yield* Subscribable.useSubscribables([ const [canCommit, submitResult] = yield* Subscribable.useAll([
form.canSubmit, form.form.canCommit,
form.mutation.result, form.form.mutation.result,
]) ])
const runPromise = yield* Component.useRunPromise() const runPromise = yield* Component.useRunPromise()
const TextFieldFormInput = yield* TextFieldFormInputView.use const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
yield* Component.useOnMount(() => Effect.gen(function*() { yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted")) yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
@@ -82,25 +92,26 @@ class RegisterFormView extends Component.make("RegisterFormView")(function*() {
<Container width="300"> <Container width="300">
<form onSubmit={e => { <form onSubmit={e => {
e.preventDefault() e.preventDefault()
void runPromise(form.submit) void runPromise(form.form.submit)
}}> }}>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<TextFieldFormInput <TextFieldFormInput
field={yield* form.field(["email"])} form={form.emailField}
debounce="250 millis"
/> />
<TextFieldFormInput <TextFieldFormInput
field={yield* form.field(["password"])} form={form.passwordField}
debounce="250 millis"
/> />
<TextFieldFormInput <TextFieldOptionalFormInput
optional
type="datetime-local" type="datetime-local"
field={yield* form.field(["birth"])} form={form.birthField}
defaultValue="" defaultValue=""
/> />
<Button disabled={!canSubmit}>Submit</Button> <Button disabled={!canCommit}>Submit</Button>
</Flex> </Flex>
</form> </form>
+9 -9
View File
@@ -1,8 +1,8 @@
import { HttpClient, type HttpClientError } from "@effect/platform" import { HttpClient, type HttpClientError } from "@effect/platform"
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes" import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream } from "effect" import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect"
import { Component, ErrorObserver, Mutation, Query, Result, Subscribable, SubscriptionRef } from "effect-fc" import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
@@ -16,9 +16,9 @@ const Post = Schema.Struct({
const ResultView = Component.make("ResultView")(function*() { const ResultView = Component.make("ResultView")(function*() {
const runPromise = yield* Component.useRunPromise() const runPromise = yield* Component.useRunPromise()
const [idRef, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() { const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
const idRef = yield* SubscriptionRef.make(1) const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
const key = Stream.map(idRef.changes, id => [id] as const) const key = Stream.map(idLens.changes, id => [id] as const)
const query = yield* Query.service({ const query = yield* Query.service({
key, key,
@@ -40,11 +40,11 @@ const ResultView = Component.make("ResultView")(function*() {
), ),
}) })
return [idRef, query, mutation] as const return [idLens, query, mutation] as const
})) }))
const [id, setId] = yield* SubscriptionRef.useSubscriptionRefState(idRef) const [id, setId] = yield* Lens.useState(idLens)
const [queryResult, mutationResult] = yield* Subscribable.useSubscribables([query.result, mutation.result]) const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result])
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe( yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
Effect.andThen(observer => observer.subscribe), Effect.andThen(observer => observer.subscribe),
@@ -105,7 +105,7 @@ const ResultView = Component.make("ResultView")(function*() {
</div> </div>
<Flex direction="row" justify="center" align="center" gap="1"> <Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runPromise(Effect.andThen(idRef, id => mutation.mutate([id])))}>Mutate</Button> <Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button>
</Flex> </Flex>
</Flex> </Flex>
</Container> </Container>
+1 -1
View File
@@ -21,7 +21,7 @@ const ResultView = Component.makeUntraced("Result")(function*() {
Effect.tap(Effect.sleep("250 millis")), Effect.tap(Effect.sleep("250 millis")),
Result.forkEffect, Result.forkEffect,
)) ))
const [result] = yield* Subscribable.useSubscribables([resultSubscribable]) const [result] = yield* Subscribable.useAll([resultSubscribable])
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe( yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
Effect.andThen(observer => observer.subscribe), Effect.andThen(observer => observer.subscribe),
@@ -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>
)
}) {}
+78
View File
@@ -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)
-135
View File
@@ -1,135 +0,0 @@
import { Box, Button, Flex, IconButton } from "@radix-ui/themes"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Chunk, type DateTime, Effect, Match, Option, Ref, Schema, Stream } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
import { FaDeleteLeft } from "react-icons/fa6"
import * as Domain from "@/domain"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { TodosState } from "./TodosState"
const TodoFormSchema = Schema.compose(Schema.Struct({
...Domain.Todo.Todo.fields,
completedAt: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
}), Domain.Todo.Todo)
const makeTodo = makeUuid4.pipe(
Effect.map(id => Domain.Todo.Todo.make({
id,
content: "",
completedAt: Option.none(),
})),
Effect.provide(GetRandomValues.CryptoRandom),
)
export type TodoProps = (
| { readonly _tag: "new" }
| { readonly _tag: "edit", readonly id: string }
)
export class TodoView extends Component.make("TodoView")(function*(props: TodoProps) {
const state = yield* TodosState
const [
indexRef,
form,
contentField,
completedAtField,
] = yield* Component.useOnChange(() => Effect.gen(function*() {
const indexRef = Match.value(props).pipe(
Match.tag("new", () => Subscribable.make({ get: Effect.succeed(-1), changes: Stream.make(-1) })),
Match.tag("edit", ({ id }) => state.getIndexSubscribable(id)),
Match.exhaustive,
)
const form = yield* Form.service({
schema: TodoFormSchema,
initialEncodedValue: yield* Schema.encode(TodoFormSchema)(
yield* Match.value(props).pipe(
Match.tag("new", () => makeTodo),
Match.tag("edit", ({ id }) => state.getElementRef(id)),
Match.exhaustive,
)
),
f: ([todo, form]) => Match.value(props).pipe(
Match.tag("new", () => Ref.update(state.ref, Chunk.prepend(todo)).pipe(
Effect.andThen(makeTodo),
Effect.andThen(Schema.encode(TodoFormSchema)),
Effect.andThen(v => Ref.set(form.encodedValue, v)),
)),
Match.tag("edit", ({ id }) => Ref.set(state.getElementRef(id), todo)),
Match.exhaustive,
),
autosubmit: props._tag === "edit",
debounce: "250 millis",
})
return [
indexRef,
form,
yield* form.field(["content"]),
yield* form.field(["completedAt"]),
] as const
}), [props._tag, props._tag === "edit" ? props.id : undefined])
const [index, size, canSubmit] = yield* Subscribable.useSubscribables([
indexRef,
state.sizeSubscribable,
form.canSubmit,
])
const runSync = yield* Component.useRunSync()
const runPromise = yield* Component.useRunPromise<DateTime.CurrentTimeZone>()
const TextFieldFormInput = yield* TextFieldFormInputView.use
return (
<Flex direction="row" align="center" gap="2">
<Box flexGrow="1">
<Flex direction="column" align="stretch" gap="2">
<TextFieldFormInput field={contentField} />
<Flex direction="row" justify="center" align="center" gap="2">
<TextFieldFormInput
optional
field={completedAtField}
type="datetime-local"
defaultValue=""
/>
{props._tag === "new" &&
<Button disabled={!canSubmit} onClick={() => void runPromise(form.submit)}>
Add
</Button>
}
</Flex>
</Flex>
</Box>
{props._tag === "edit" &&
<Flex direction="column" justify="center" align="center" gap="1">
<IconButton
disabled={index <= 0}
onClick={() => runSync(state.moveLeft(props.id))}
>
<FaArrowUp />
</IconButton>
<IconButton
disabled={index >= size - 1}
onClick={() => runSync(state.moveRight(props.id))}
>
<FaArrowDown />
</IconButton>
<IconButton onClick={() => runSync(state.remove(props.id))}>
<FaDeleteLeft />
</IconButton>
</Flex>
}
</Flex>
)
}) {}
+20 -18
View File
@@ -1,7 +1,7 @@
import { KeyValueStore } from "@effect/platform" import { KeyValueStore } from "@effect/platform"
import { BrowserKeyValueStore } from "@effect/platform-browser" import { BrowserKeyValueStore } from "@effect/platform-browser"
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect" import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
import { Subscribable, SubscriptionSubRef } from "effect-fc" import { Lens, Subscribable } from "effect-fc"
import { Todo } from "@/domain" import { Todo } from "@/domain"
@@ -30,27 +30,29 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
: kv.remove(key) : kv.remove(key)
) )
const ref = yield* SubscriptionRef.make(yield* readFromLocalStorage) const lens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(yield* readFromLocalStorage))
yield* Effect.forkScoped(ref.changes.pipe( yield* Effect.forkScoped(lens.changes.pipe(
Stream.debounce("500 millis"), Stream.debounce("500 millis"),
Stream.runForEach(saveToLocalStorage), Stream.runForEach(saveToLocalStorage),
)) ))
yield* Effect.addFinalizer(() => ref.pipe( yield* Effect.addFinalizer(() => Lens.get(lens).pipe(
Effect.andThen(saveToLocalStorage), Effect.andThen(saveToLocalStorage),
Effect.ignore, Effect.ignore,
)) ))
const sizeSubscribable = Subscribable.make({ const sizeSubscribable = Subscribable.map(lens, Chunk.size)
get: Effect.andThen(ref, Chunk.size),
get changes() { return Stream.map(ref.changes, Chunk.size) },
})
const getElementRef = (id: string) => SubscriptionSubRef.makeFromChunkFindFirst(ref, v => v.id === id)
const getIndexSubscribable = (id: string) => Subscribable.make({
get: Effect.flatMap(ref, Chunk.findFirstIndex(v => v.id === id)),
get changes() { return Stream.flatMap(ref.changes, Chunk.findFirstIndex(v => v.id === id)) },
})
const moveLeft = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe( 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("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)), Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
Effect.bind("previous", ({ index }) => Chunk.get(todos, index - 1)), Effect.bind("previous", ({ index }) => Chunk.get(todos, index - 1)),
@@ -62,7 +64,7 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
: todos : 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("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)), Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
Effect.bind("next", ({ index }) => Chunk.get(todos, index + 1)), Effect.bind("next", ({ index }) => Chunk.get(todos, index + 1)),
@@ -74,15 +76,15 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
: todos : 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), Chunk.findFirstIndex(todos, v => v.id === id),
index => Chunk.remove(todos, index), index => Chunk.remove(todos, index),
)) ))
return { return {
ref, lens,
sizeSubscribable, sizeSubscribable,
getElementRef, getElementLens,
getIndexSubscribable, getIndexSubscribable,
moveLeft, moveLeft,
moveRight, moveRight,
+7 -5
View File
@@ -1,30 +1,32 @@
import { Container, Flex, Heading } from "@radix-ui/themes" import { Container, Flex, Heading } from "@radix-ui/themes"
import { Chunk, Console, Effect } from "effect" import { Chunk, Console, Effect } from "effect"
import { Component, Subscribable } from "effect-fc" import { Component, Subscribable } from "effect-fc"
import { EditTodoView } from "./EditTodoView"
import { NewTodoView } from "./NewTodoView"
import { TodosState } from "./TodosState" import { TodosState } from "./TodosState"
import { TodoView } from "./TodoView"
export class TodosView extends Component.make("TodosView")(function*() { export class TodosView extends Component.make("TodosView")(function*() {
const state = yield* TodosState const state = yield* TodosState
const [todos] = yield* Subscribable.useSubscribables([state.ref]) const [todos] = yield* Subscribable.useAll([state.lens])
yield* Component.useOnMount(() => Effect.andThen( yield* Component.useOnMount(() => Effect.andThen(
Console.log("Todos mounted"), Console.log("Todos mounted"),
Effect.addFinalizer(() => Console.log("Todos unmounted")), Effect.addFinalizer(() => Console.log("Todos unmounted")),
)) ))
const Todo = yield* TodoView.use const NewTodo = yield* NewTodoView.use
const EditTodo = yield* EditTodoView.use
return ( return (
<Container> <Container>
<Heading align="center">Todos</Heading> <Heading align="center">Todos</Heading>
<Flex direction="column" align="stretch" gap="2" mt="2"> <Flex direction="column" align="stretch" gap="2" mt="2">
<Todo _tag="new" /> <NewTodo />
{Chunk.map(todos, todo => {Chunk.map(todos, todo =>
<Todo key={todo.id} _tag="edit" id={todo.id} /> <EditTodo key={todo.id} id={todo.id} />
)} )}
</Flex> </Flex>
</Container> </Container>