Compare commits
4 Commits
next
...
19527856fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19527856fb | ||
|
|
2dc7381b3c | ||
| 0ae55bd02c | |||
|
|
bb044e766d |
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@effect-fc/monorepo",
|
"name": "@effect-fc/monorepo",
|
||||||
"packageManager": "bun@1.3.13",
|
"packageManager": "bun@1.3.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./packages/*"
|
"./packages/*"
|
||||||
@@ -15,12 +15,12 @@
|
|||||||
"clean:modules": "turbo clean:modules && rm -rf node_modules"
|
"clean:modules": "turbo clean:modules && rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.14",
|
"@biomejs/biome": "^2.3.11",
|
||||||
"@effect/language-service": "^0.85.1",
|
"@effect/language-service": "^0.75.0",
|
||||||
"@types/bun": "^1.3.13",
|
"@types/bun": "^1.3.6",
|
||||||
"npm-check-updates": "^22.1.0",
|
"npm-check-updates": "^19.3.1",
|
||||||
"npm-sort": "^0.0.4",
|
"npm-sort": "^0.0.4",
|
||||||
"turbo": "^2.9.8",
|
"turbo": "^2.7.5",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
|
||||||
"root": false,
|
|
||||||
"extends": "//",
|
|
||||||
"files": {
|
|
||||||
"includes": ["./src/**"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,4 +7,6 @@ tags: [hola, docusaurus]
|
|||||||
|
|
||||||
Lorem ipsum dolor sit amet...
|
Lorem ipsum dolor sit amet...
|
||||||
|
|
||||||
|
<!-- truncate -->
|
||||||
|
|
||||||
...consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
...consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ This is the summary of a very long blog post,
|
|||||||
|
|
||||||
Use a `<!--` `truncate` `-->` comment to limit blog post size in the list view.
|
Use a `<!--` `truncate` `-->` comment to limit blog post size in the list view.
|
||||||
|
|
||||||
|
<!-- truncate -->
|
||||||
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||||
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ tags: [facebook, hello, docusaurus]
|
|||||||
|
|
||||||
Here are a few tips you might find useful.
|
Here are a few tips you might find useful.
|
||||||
|
|
||||||
|
<!-- truncate -->
|
||||||
|
|
||||||
Simply add Markdown files (or folders) to the `blog` directory.
|
Simply add Markdown files (or folders) to the `blog` directory.
|
||||||
|
|
||||||
Regular blog authors can be added to `authors.yml`.
|
Regular blog authors can be added to `authors.yml`.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ sidebar_position: 1
|
|||||||
|
|
||||||
# Effect FC
|
# Effect FC
|
||||||
|
|
||||||
Welcome to **Effect FC** (as in: Effect **F**unction **C**omponent) – a powerful integration of [Effect](https://effect.website/) with React 19.2+ that enables you to write React function components using Effect generators.
|
Welcome to **Effect FC** – a powerful integration of [Effect-TS](https://effect.website/) with React 19.2+ that enables you to write function components using Effect generators.
|
||||||
|
|
||||||
## What is Effect FC?
|
## What is Effect FC?
|
||||||
|
|
||||||
@@ -12,54 +12,42 @@ Effect FC allows you to harness the full power of Effect-TS within your React co
|
|||||||
|
|
||||||
### Key Features
|
### Key Features
|
||||||
|
|
||||||
- **Effect Integration**: Write your function component logic using Effect.
|
- **Effect-TS Integration**: Write components using Effect generators for powerful, composable effects
|
||||||
- **Type Safety**: Full TypeScript support with Effect's comprehensive type system
|
- **Type Safety**: Full TypeScript support with Effect's comprehensive type system
|
||||||
- **Dependency Injection**: Built-in support for providing dependencies to components using Effect services.
|
- **Resource Management**: Automatic cleanup and finalization of resources
|
||||||
- **Resource Management**: Automatic cleanup and finalization of component resources using the `Scope` API.
|
- **Dependency Injection**: Built-in support for providing dependencies to components
|
||||||
|
- **React 19.2+ Compatible**: Leverages the latest React features
|
||||||
|
|
||||||
## Quick Example
|
## Quick Example
|
||||||
|
|
||||||
Here's what writing an Effect FC component looks like:
|
Here's what writing an Effect FC component looks like:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export class TodosView extends Component.make("TodosView")(function*() {
|
export class Todos extends Component.make("Todos")(function*() {
|
||||||
const state = yield* TodosState
|
const state = yield* TodosState
|
||||||
const [todos] = yield* Component.useSubscribables([state.subscriptionRef])
|
const [todos] = yield* useSubscribables(state.ref)
|
||||||
|
|
||||||
yield* Component.useOnMount(() => Effect.andThen(
|
yield* 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 TodoFC = yield* Todo
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Heading align="center">Todos</Heading>
|
<Heading align="center">Todos</Heading>
|
||||||
|
|
||||||
<Flex direction="column" align="stretch" gap="2" mt="2">
|
<Flex direction="column" align="stretch" gap="2" mt="2">
|
||||||
<Todo _tag="new" />
|
<TodoFC _tag="new" />
|
||||||
|
|
||||||
{Chunk.map(todos, todo =>
|
{Chunk.map(todos, todo =>
|
||||||
<Todo key={todo.id} _tag="edit" id={todo.id} />
|
<TodoFC key={todo.id} _tag="edit" id={todo.id} />
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
const Index = Component.make("IndexView")(function*() {
|
|
||||||
const context = yield* Component.useContextFromLayer(TodosState.Default)
|
|
||||||
const Todos = yield* Effect.provide(TodosView.use, context)
|
|
||||||
|
|
||||||
return <Todos />
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
|
||||||
component: Index
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -92,6 +80,7 @@ pnpm add effect-fc effect react
|
|||||||
|
|
||||||
- Explore the [Tutorial Basics](./tutorial-basics/create-a-document.md) to learn the fundamentals
|
- Explore the [Tutorial Basics](./tutorial-basics/create-a-document.md) to learn the fundamentals
|
||||||
- Check out the [Example Project](https://github.com/your-repo/packages/example) for a complete working application
|
- Check out the [Example Project](https://github.com/your-repo/packages/example) for a complete working application
|
||||||
|
- Read the [API Documentation](./api/) for detailed reference
|
||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type * as Preset from '@docusaurus/preset-classic';
|
|
||||||
import type {Config} from '@docusaurus/types';
|
|
||||||
import {themes as prismThemes} from 'prism-react-renderer';
|
import {themes as prismThemes} from 'prism-react-renderer';
|
||||||
|
import type {Config} from '@docusaurus/types';
|
||||||
|
import type * as Preset from '@docusaurus/preset-classic';
|
||||||
|
|
||||||
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||||
|
|
||||||
|
|||||||
@@ -15,20 +15,19 @@
|
|||||||
"typecheck": "tsc"
|
"typecheck": "tsc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docusaurus/core": "3.10.1",
|
"@docusaurus/core": "3.9.2",
|
||||||
"@docusaurus/faster": "^3.10.1",
|
"@docusaurus/preset-classic": "3.9.2",
|
||||||
"@docusaurus/preset-classic": "3.10.1",
|
"@mdx-js/react": "^3.0.0",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"clsx": "^2.0.0",
|
||||||
"clsx": "^2.1.1",
|
"prism-react-renderer": "^2.3.0",
|
||||||
"prism-react-renderer": "^2.4.1",
|
"react": "^19.0.0",
|
||||||
"react": "^19.2.5",
|
"react-dom": "^19.0.0"
|
||||||
"react-dom": "^19.2.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@docusaurus/module-type-aliases": "3.10.1",
|
"@docusaurus/module-type-aliases": "3.9.2",
|
||||||
"@docusaurus/tsconfig": "3.10.1",
|
"@docusaurus/tsconfig": "3.9.2",
|
||||||
"@docusaurus/types": "3.10.1",
|
"@docusaurus/types": "3.9.2",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~5.6.2"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import type {ReactNode} from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
import Link from '@docusaurus/Link';
|
import Link from '@docusaurus/Link';
|
||||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||||
|
import Layout from '@theme/Layout';
|
||||||
import HomepageFeatures from '@site/src/components/HomepageFeatures';
|
import HomepageFeatures from '@site/src/components/HomepageFeatures';
|
||||||
import Heading from '@theme/Heading';
|
import Heading from '@theme/Heading';
|
||||||
import Layout from '@theme/Layout';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import type {ReactNode} from 'react';
|
|
||||||
|
|
||||||
import styles from './index.module.css';
|
import styles from './index.module.css';
|
||||||
|
|
||||||
|
|||||||
@@ -15,37 +15,39 @@ 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 TodosView extends Component.make("TodosView")(function*() {
|
export class Todos extends Component.make("Todos")(function*() {
|
||||||
const state = yield* TodosState
|
const state = yield* TodosState
|
||||||
const [todos] = yield* Component.useSubscribables([state.subscriptionRef])
|
const [todos] = yield* useSubscribables(state.ref)
|
||||||
|
|
||||||
yield* Component.useOnMount(() => Effect.andThen(
|
yield* 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 TodoFC = yield* Todo
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Heading align="center">Todos</Heading>
|
<Heading align="center">Todos</Heading>
|
||||||
|
|
||||||
<Flex direction="column" align="stretch" gap="2" mt="2">
|
<Flex direction="column" align="stretch" gap="2" mt="2">
|
||||||
<Todo _tag="new" />
|
<TodoFC _tag="new" />
|
||||||
|
|
||||||
{Chunk.map(todos, todo =>
|
{Chunk.map(todos, todo =>
|
||||||
<Todo key={todo.id} _tag="edit" id={todo.id} />
|
<TodoFC key={todo.id} _tag="edit" id={todo.id} />
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
const Index = Component.make("IndexView")(function*() {
|
const TodosStateLive = TodosState.Default("todos")
|
||||||
const context = yield* Component.useContextFromLayer(TodosState.Default)
|
|
||||||
const Todos = yield* Effect.provide(TodosView.use, context)
|
|
||||||
|
|
||||||
return <Todos />
|
const Index = Component.make("Index")(function*() {
|
||||||
|
const context = yield* useContext(TodosStateLive)
|
||||||
|
const TodosFC = yield* Effect.provide(Todos, context)
|
||||||
|
|
||||||
|
return <TodosFC />
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Component.withRuntime(runtime.context)
|
Component.withRuntime(runtime.context)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "effect-fc",
|
"name": "effect-fc",
|
||||||
"description": "Write React function components with Effect",
|
"description": "Write React function components with Effect",
|
||||||
"version": "0.2.6",
|
"version": "0.2.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"./README.md",
|
"./README.md",
|
||||||
@@ -38,14 +38,11 @@
|
|||||||
"clean:modules": "rm -rf node_modules"
|
"clean:modules": "rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@effect/platform-browser": "^0.76.0"
|
"@effect/platform-browser": "^0.74.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"effect": "^3.21.0",
|
"effect": "^3.19.0",
|
||||||
"react": "^19.2.0"
|
"react": "^19.2.0"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"effect-lens": "^0.1.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,35 @@
|
|||||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||||
import { Effect, type Equivalence, Function, Predicate, Runtime, Scope } from "effect"
|
import { Effect, Function, Predicate, Runtime, Scope } from "effect"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
|
export const TypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
|
||||||
export type AsyncTypeId = typeof AsyncTypeId
|
export type TypeId = typeof TypeId
|
||||||
|
|
||||||
|
export interface Async extends Async.Options {
|
||||||
/**
|
readonly [TypeId]: TypeId
|
||||||
* A trait for `Component`'s that allows them running asynchronous effects.
|
|
||||||
*/
|
|
||||||
export interface Async extends AsyncPrototype, AsyncOptions {}
|
|
||||||
|
|
||||||
export interface AsyncPrototype {
|
|
||||||
readonly [AsyncTypeId]: AsyncTypeId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export namespace Async {
|
||||||
* Configuration options for `Async` components.
|
export interface Options {
|
||||||
*/
|
readonly defaultFallback?: React.ReactNode
|
||||||
export interface AsyncOptions {
|
}
|
||||||
/**
|
|
||||||
* The default fallback React node to display while the async operation is pending.
|
export type Props = Omit<React.SuspenseProps, "children">
|
||||||
* Used if no fallback is provided to the component when rendering.
|
|
||||||
*/
|
|
||||||
readonly defaultFallback?: React.ReactNode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for `Async` components.
|
|
||||||
*/
|
|
||||||
export type AsyncProps = Omit<React.SuspenseProps, "children">
|
|
||||||
|
|
||||||
|
const AsyncProto = Object.freeze({
|
||||||
|
[TypeId]: TypeId,
|
||||||
|
|
||||||
export const AsyncPrototype: AsyncPrototype = Object.freeze({
|
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
|
||||||
[AsyncTypeId]: AsyncTypeId,
|
|
||||||
|
|
||||||
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
|
|
||||||
this: Component.Component<P, A, E, R> & Async,
|
this: Component.Component<P, A, E, R> & Async,
|
||||||
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
|
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
|
||||||
) {
|
) {
|
||||||
const Inner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
|
const SuspenseInner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
|
||||||
|
|
||||||
return ({ fallback, name, ...props }: AsyncProps) => {
|
return ({ fallback, name, ...props }: Async.Props) => {
|
||||||
const promise = Runtime.runPromise(runtimeRef.current)(
|
const promise = Runtime.runPromise(runtimeRef.current)(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
Component.useScope([], this),
|
Component.useScope([], this),
|
||||||
@@ -54,116 +40,45 @@ export const AsyncPrototype: AsyncPrototype = Object.freeze({
|
|||||||
return React.createElement(
|
return React.createElement(
|
||||||
React.Suspense,
|
React.Suspense,
|
||||||
{ fallback: fallback ?? this.defaultFallback, name },
|
{ fallback: fallback ?? this.defaultFallback, name },
|
||||||
React.createElement(Inner, { promise }),
|
React.createElement(SuspenseInner, { promise }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
} as const)
|
} as const)
|
||||||
|
|
||||||
/**
|
|
||||||
* An equivalence function for comparing `AsyncProps` that ignores the `fallback` property.
|
|
||||||
* Used by default by async components with `Memoized.memoized` applied.
|
|
||||||
*/
|
|
||||||
export const defaultPropsEquivalence: Equivalence.Equivalence<AsyncProps> = (
|
|
||||||
self: Record<string, unknown>,
|
|
||||||
that: Record<string, unknown>,
|
|
||||||
) => {
|
|
||||||
if (self === that)
|
|
||||||
return true
|
|
||||||
|
|
||||||
for (const key in self) {
|
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, TypeId)
|
||||||
if (key === "fallback")
|
|
||||||
continue
|
|
||||||
if (!(key in that) || !Object.is(self[key], that[key]))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key in that) {
|
|
||||||
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, any, any, any>>(
|
export const async = <T extends Component.Component<any, any, any, any>>(
|
||||||
self: T & (
|
self: T
|
||||||
"promise" extends keyof Component.Component.Props<T>
|
|
||||||
? "The 'promise' prop name is restricted for Async components. Please rename the 'promise' prop to something else."
|
|
||||||
: T
|
|
||||||
)
|
|
||||||
): (
|
): (
|
||||||
& Omit<T, keyof Component.Component.AsComponent<T>>
|
& Omit<T, keyof Component.Component.AsComponent<T>>
|
||||||
& Component.Component<
|
& Component.Component<
|
||||||
Component.Component.Props<T> & AsyncProps,
|
Component.Component.Props<T> & Async.Props,
|
||||||
Component.Component.Success<T>,
|
Component.Component.Success<T>,
|
||||||
Component.Component.Error<T>,
|
Component.Component.Error<T>,
|
||||||
Component.Component.Context<T>
|
Component.Component.Context<T>
|
||||||
>
|
>
|
||||||
& Async
|
& Async
|
||||||
) => Object.setPrototypeOf(
|
) => Object.setPrototypeOf(
|
||||||
Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }),
|
Object.assign(function() {}, self),
|
||||||
Object.freeze(Object.setPrototypeOf(
|
Object.freeze(Object.setPrototypeOf(
|
||||||
Object.assign({}, AsyncPrototype),
|
Object.assign({}, AsyncProto),
|
||||||
Object.getPrototypeOf(self),
|
Object.getPrototypeOf(self),
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies options to an Async component, returning a new Async component with the updated configuration.
|
|
||||||
*
|
|
||||||
* Supports both curried and uncurried application styles.
|
|
||||||
*
|
|
||||||
* @param self - The Async component to apply options to (in uncurried form)
|
|
||||||
* @param options - The options to apply to the component
|
|
||||||
* @returns An Async component with the applied options
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // Curried
|
|
||||||
* const MyAsyncComponent = MyComponent.pipe(
|
|
||||||
* Async.async,
|
|
||||||
* Async.withOptions({ defaultFallback: <p>Loading...</p> }),
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* // Uncurried
|
|
||||||
* const MyAsyncComponent = Async.withOptions(
|
|
||||||
* Async.async(MyComponent),
|
|
||||||
* { defaultFallback: <p>Loading...</p> },
|
|
||||||
* )
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const withOptions: {
|
export const withOptions: {
|
||||||
<T extends Component.Component<any, any, any, any> & Async>(
|
<T extends Component.Component<any, any, any, any> & Async>(
|
||||||
options: Partial<AsyncOptions>
|
options: Partial<Async.Options>
|
||||||
): (self: T) => T
|
): (self: T) => T
|
||||||
<T extends Component.Component<any, any, any, any> & Async>(
|
<T extends Component.Component<any, any, any, any> & Async>(
|
||||||
self: T,
|
self: T,
|
||||||
options: Partial<AsyncOptions>,
|
options: Partial<Async.Options>,
|
||||||
): T
|
): T
|
||||||
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Async>(
|
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Async>(
|
||||||
self: T,
|
self: T,
|
||||||
options: Partial<AsyncOptions>,
|
options: Partial<Async.Options>,
|
||||||
): T => Object.setPrototypeOf(
|
): T => Object.setPrototypeOf(
|
||||||
Object.assign(function() {}, self, options),
|
Object.assign(function() {}, self, options),
|
||||||
Object.getPrototypeOf(self),
|
Object.getPrototypeOf(self),
|
||||||
|
|||||||
@@ -1,25 +1,38 @@
|
|||||||
/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */
|
/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */
|
||||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||||
import { Context, type Duration, Effect, Equivalence, ExecutionStrategy, Exit, Fiber, Function, HashMap, identity, Layer, ManagedRuntime, Option, Pipeable, Predicate, Ref, Runtime, Scope, Tracer, type Utils } from "effect"
|
import { Context, type Duration, Effect, Effectable, Equivalence, ExecutionStrategy, Exit, Fiber, Function, HashMap, Layer, ManagedRuntime, Option, Predicate, Ref, Runtime, Scope, Tracer, type Utils } from "effect"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { Memoized } from "./index.js"
|
||||||
|
|
||||||
|
|
||||||
export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component")
|
export const TypeId: unique symbol = Symbol.for("@effect-fc/Component/Component")
|
||||||
export type ComponentTypeId = typeof ComponentTypeId
|
export type TypeId = typeof TypeId
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents an Effect-based React Component that integrates the Effect system with React.
|
* Interface representing an Effect-based React Component.
|
||||||
|
*
|
||||||
|
* This is both:
|
||||||
|
* - an Effect that produces a React function component
|
||||||
|
* - a constructor-like object with component metadata and options
|
||||||
*/
|
*/
|
||||||
export interface Component<P extends {}, A extends React.ReactNode, E, R>
|
export interface Component<P extends {}, A extends React.ReactNode, E, R>
|
||||||
extends ComponentPrototype<P, A, R>, ComponentOptions {
|
extends
|
||||||
|
Effect.Effect<(props: P) => A, never, Exclude<R, Scope.Scope>>,
|
||||||
|
Component.Options
|
||||||
|
{
|
||||||
new(_: never): Record<string, never>
|
new(_: never): Record<string, never>
|
||||||
readonly [ComponentTypeId]: ComponentTypeId
|
readonly [TypeId]: TypeId
|
||||||
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 body: (props: P) => Effect.Effect<A, E, R>
|
readonly body: (props: P) => Effect.Effect<A, E, R>
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
makeFunctionComponent(
|
||||||
|
runtimeRef: React.Ref<Runtime.Runtime<Exclude<R, Scope.Scope>>>
|
||||||
|
): (props: P) => A
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare namespace Component {
|
export declare namespace Component {
|
||||||
@@ -29,29 +42,56 @@ export declare namespace Component {
|
|||||||
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 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 AsComponent<T extends Component<any, any, any, any>> = Component<Props<T>, Success<T>, Error<T>, Context<T>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options that can be set on the component
|
||||||
|
*/
|
||||||
|
export interface Options {
|
||||||
|
/** Custom displayName for React DevTools and debugging. */
|
||||||
|
readonly displayName?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strategy used when executing finalizers on unmount/scope close.
|
||||||
|
* @default ExecutionStrategy.sequential
|
||||||
|
*/
|
||||||
|
readonly finalizerExecutionStrategy: ExecutionStrategy.ExecutionStrategy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce time before executing finalizers after component unmount.
|
||||||
|
* Helps avoid unnecessary work during fast remount/remount cycles.
|
||||||
|
* @default "100 millis"
|
||||||
|
*/
|
||||||
|
readonly finalizerExecutionDebounce: Duration.DurationInput
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface ComponentPrototype<P extends {}, A extends React.ReactNode, R>
|
const ComponentProto = Object.freeze({
|
||||||
extends Pipeable.Pipeable {
|
...Effectable.CommitPrototype,
|
||||||
readonly [ComponentTypeId]: ComponentTypeId
|
[TypeId]: TypeId,
|
||||||
readonly use: Effect.Effect<(props: P) => A, never, Exclude<R, Scope.Scope>>
|
|
||||||
|
|
||||||
asFunctionComponent(
|
commit: Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode, E, R>(
|
||||||
runtimeRef: React.Ref<Runtime.Runtime<Exclude<R, Scope.Scope>>>
|
this: Component<P, A, E, R>
|
||||||
): (props: P) => A
|
) {
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: React ref initialization
|
||||||
|
const runtimeRef = React.useRef<Runtime.Runtime<Exclude<R, Scope.Scope>>>(null!)
|
||||||
|
runtimeRef.current = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
|
||||||
|
|
||||||
setFunctionComponentName(f: React.FC<P>): void
|
return yield* React.useState(() => Runtime.runSync(runtimeRef.current)(Effect.cachedFunction(
|
||||||
transformFunctionComponent(f: React.FC<P>): React.FC<P>
|
(_services: readonly any[]) => Effect.sync(() => {
|
||||||
}
|
const f: React.FC<P> = this.makeFunctionComponent(runtimeRef)
|
||||||
|
f.displayName = this.displayName ?? "Anonymous"
|
||||||
|
return Memoized.isMemoized(this)
|
||||||
|
? React.memo(f, this.propsAreEqual)
|
||||||
|
: f
|
||||||
|
}),
|
||||||
|
Equivalence.array(Equivalence.strict()),
|
||||||
|
)))[0](Array.from(
|
||||||
|
Context.omit(...nonReactiveTags)(runtimeRef.current.context).unsafeMap.values()
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
|
||||||
export const ComponentPrototype: ComponentPrototype<any, any, any> = Object.freeze({
|
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
|
||||||
[ComponentTypeId]: ComponentTypeId,
|
|
||||||
...Pipeable.Prototype,
|
|
||||||
|
|
||||||
get use() { return use(this) },
|
|
||||||
|
|
||||||
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
|
|
||||||
this: Component<P, A, E, R>,
|
this: Component<P, A, E, R>,
|
||||||
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
|
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
|
||||||
) {
|
) {
|
||||||
@@ -62,76 +102,17 @@ export const ComponentPrototype: ComponentPrototype<any, any, any> = Object.free
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
setFunctionComponentName<P extends {}, A extends React.ReactNode, E, R>(
|
|
||||||
this: Component<P, A, E, R>,
|
|
||||||
f: React.FC<P>,
|
|
||||||
) {
|
|
||||||
f.displayName = this.displayName ?? "Anonymous"
|
|
||||||
},
|
|
||||||
|
|
||||||
transformFunctionComponent: identity,
|
|
||||||
} as const)
|
} as const)
|
||||||
|
|
||||||
const use = Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode, E, R>(
|
const defaultOptions: Component.Options = {
|
||||||
self: Component<P, A, E, R>
|
|
||||||
) {
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: React ref initialization
|
|
||||||
const runtimeRef = React.useRef<Runtime.Runtime<Exclude<R, Scope.Scope>>>(null!)
|
|
||||||
runtimeRef.current = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
|
|
||||||
|
|
||||||
return yield* React.useState(() => Runtime.runSync(runtimeRef.current)(Effect.cachedFunction(
|
|
||||||
(_services: readonly any[]) => Effect.sync(() => {
|
|
||||||
const f: React.FC<P> = self.asFunctionComponent(runtimeRef)
|
|
||||||
self.setFunctionComponentName(f)
|
|
||||||
return self.transformFunctionComponent(f)
|
|
||||||
}),
|
|
||||||
Equivalence.array(Equivalence.strict()),
|
|
||||||
)))[0](Array.from(
|
|
||||||
Context.omit(...self.nonReactiveTags)(runtimeRef.current.context).unsafeMap.values()
|
|
||||||
))
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
export interface ComponentOptions {
|
|
||||||
/**
|
|
||||||
* Custom display name for the component in React DevTools and debugging utilities.
|
|
||||||
*/
|
|
||||||
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.
|
|
||||||
* Determines whether finalizers execute sequentially or in parallel.
|
|
||||||
*
|
|
||||||
* @default ExecutionStrategy.sequential
|
|
||||||
*/
|
|
||||||
readonly finalizerExecutionStrategy: ExecutionStrategy.ExecutionStrategy
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Debounce duration before executing finalizers after component unmount.
|
|
||||||
* Prevents unnecessary cleanup work during rapid remount/unmount cycles,
|
|
||||||
* which is common in development and certain UI patterns.
|
|
||||||
*
|
|
||||||
* @default "100 millis"
|
|
||||||
*/
|
|
||||||
readonly finalizerExecutionDebounce: Duration.DurationInput
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultOptions: ComponentOptions = {
|
|
||||||
nonReactiveTags: [Tracer.ParentSpan],
|
|
||||||
finalizerExecutionStrategy: ExecutionStrategy.sequential,
|
finalizerExecutionStrategy: ExecutionStrategy.sequential,
|
||||||
finalizerExecutionDebounce: "100 millis",
|
finalizerExecutionDebounce: "100 millis",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nonReactiveTags = [Tracer.ParentSpan] as const
|
||||||
|
|
||||||
export const isComponent = (u: unknown): u is Component<{}, 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 = {
|
||||||
@@ -359,51 +340,17 @@ export declare namespace make {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an Effect-FC Component using the same overloads and pipeline composition style as `Effect.fn`.
|
* Creates an Effect-FC Component following the same overloads and pipeline style as `Effect.fn`.
|
||||||
*
|
*
|
||||||
* This is the **recommended** approach for defining Effect-FC components. It provides comprehensive
|
* This is the **recommended** way to define components. It supports:
|
||||||
* support for multiple component definition patterns:
|
* - Generator syntax (yield* style) — most ergonomic and readable
|
||||||
|
* - Direct Effect return (non-generator)
|
||||||
|
* - Chained transformation functions (like Effect.fn pipelines)
|
||||||
|
* - Optional tracing span with automatic `displayName`
|
||||||
*
|
*
|
||||||
* - **Generator syntax** (yield* style): Most ergonomic and readable approach for sequential operations
|
* When you provide a `spanName` as the first argument, two things happen automatically:
|
||||||
* - **Direct Effect return**: For simple components that return an Effect directly
|
* 1. A tracing span is created with that name (unless using `makeUntraced`)
|
||||||
* - **Chained transformation functions**: Enables Effect.fn-style pipelines for composable transformations
|
* 2. The resulting React component gets `displayName = spanName`
|
||||||
* - **Automatic tracing**: Optional tracing span creation with automatic `displayName` assignment
|
|
||||||
*
|
|
||||||
* When a `spanName` string is provided, the following occurs automatically:
|
|
||||||
* 1. A distributed tracing span is created with the specified name
|
|
||||||
* 2. The resulting React component receives `displayName = spanName` for DevTools visibility
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const MyComponent = Component.make("MyComponent")(function* (props: { count: number }) {
|
|
||||||
* const value = yield* someEffect
|
|
||||||
* return <div>{value}</div>
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example As an opaque type using class syntax
|
|
||||||
* ```tsx
|
|
||||||
* class MyComponent extends Component.make("MyComponent")(function* (props: { count: number }) {
|
|
||||||
* const value = yield* someEffect
|
|
||||||
* return <div>{value}</div>
|
|
||||||
* }) {}
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Without name
|
|
||||||
* ```tsx
|
|
||||||
* class MyComponent extends Component.make(function* (props: { count: number }) {
|
|
||||||
* const value = yield* someEffect
|
|
||||||
* return <div>{value}</div>
|
|
||||||
* }) {}
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Using pipeline
|
|
||||||
* ```tsx
|
|
||||||
* class MyComponent extends Component.make("MyComponent")(
|
|
||||||
* (props: { count: number }) => someEffect,
|
|
||||||
* Effect.map(value => <div>{value}</div>),
|
|
||||||
* ) {}
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export const make: (
|
export const make: (
|
||||||
& make.Gen
|
& make.Gen
|
||||||
@@ -418,7 +365,7 @@ export const make: (
|
|||||||
Object.assign(function() {}, defaultOptions, {
|
Object.assign(function() {}, defaultOptions, {
|
||||||
body: Effect.fn(spanNameOrBody as any, ...pipeables),
|
body: Effect.fn(spanNameOrBody as any, ...pipeables),
|
||||||
}),
|
}),
|
||||||
ComponentPrototype,
|
ComponentProto,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -428,56 +375,21 @@ export const make: (
|
|||||||
body: Effect.fn(spanNameOrBody, spanOptions)(body, ...pipeables as []),
|
body: Effect.fn(spanNameOrBody, spanOptions)(body, ...pipeables as []),
|
||||||
displayName: spanNameOrBody,
|
displayName: spanNameOrBody,
|
||||||
}),
|
}),
|
||||||
ComponentPrototype,
|
ComponentProto,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an Effect-FC Component without automatic distributed tracing.
|
* Same as `make`, but creates an **untraced** version — no automatic tracing span is created.
|
||||||
*
|
*
|
||||||
* This function provides the same API surface as `make`, but does not create automatic tracing spans.
|
* Follows the exact same API shape as `Effect.fnUntraced`.
|
||||||
* It follows the exact same overload structure as `Effect.fnUntraced`.
|
* Useful for:
|
||||||
|
* - Components where you want full manual control over tracing
|
||||||
|
* - Avoiding span noise in deeply nested UI
|
||||||
*
|
*
|
||||||
* Use this variant when you need:
|
* When a string is provided as first argument, it is **only** used as the React component's `displayName`
|
||||||
* - Full manual control over tracing instrumentation
|
* (no tracing span is created).
|
||||||
* - To reduce tracing overhead in deeply nested component hierarchies
|
|
||||||
* - To avoid span noise in performance-sensitive applications
|
|
||||||
*
|
|
||||||
* When a `spanName` string is provided, it is used **exclusively** as the React component's
|
|
||||||
* `displayName` for DevTools identification. No tracing span is created.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const MyComponent = Component.makeUntraced("MyComponent")(function* (props: { count: number }) {
|
|
||||||
* const value = yield* someEffect
|
|
||||||
* return <div>{value}</div>
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example As an opaque type using class syntax
|
|
||||||
* ```tsx
|
|
||||||
* class MyComponent extends Component.makeUntraced("MyComponent")(function* (props: { count: number }) {
|
|
||||||
* const value = yield* someEffect
|
|
||||||
* return <div>{value}</div>
|
|
||||||
* }) {}
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Without name
|
|
||||||
* ```tsx
|
|
||||||
* class MyComponent extends Component.makeUntraced(function* (props: { count: number }) {
|
|
||||||
* const value = yield* someEffect
|
|
||||||
* return <div>{value}</div>
|
|
||||||
* }) {}
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Using pipeline
|
|
||||||
* ```tsx
|
|
||||||
* class MyComponent extends Component.makeUntraced("MyComponent")(
|
|
||||||
* (props: { count: number }) => someEffect,
|
|
||||||
* Effect.map(value => <div>{value}</div>),
|
|
||||||
* ) {}
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export const makeUntraced: (
|
export const makeUntraced: (
|
||||||
& make.Gen
|
& make.Gen
|
||||||
@@ -489,96 +401,52 @@ export const makeUntraced: (
|
|||||||
Object.assign(function() {}, defaultOptions, {
|
Object.assign(function() {}, defaultOptions, {
|
||||||
body: Effect.fnUntraced(spanNameOrBody as any, ...pipeables as []),
|
body: Effect.fnUntraced(spanNameOrBody as any, ...pipeables as []),
|
||||||
}),
|
}),
|
||||||
ComponentPrototype,
|
ComponentProto,
|
||||||
)
|
)
|
||||||
: (body: any, ...pipeables: any[]) => Object.setPrototypeOf(
|
: (body: any, ...pipeables: any[]) => Object.setPrototypeOf(
|
||||||
Object.assign(function() {}, defaultOptions, {
|
Object.assign(function() {}, defaultOptions, {
|
||||||
body: Effect.fnUntraced(body, ...pipeables as []),
|
body: Effect.fnUntraced(body, ...pipeables as []),
|
||||||
displayName: spanNameOrBody,
|
displayName: spanNameOrBody,
|
||||||
}),
|
}),
|
||||||
ComponentPrototype,
|
ComponentProto,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
export declare namespace withSignature {
|
|
||||||
export type Signature = (props: any) => React.ReactNode
|
|
||||||
|
|
||||||
export type Result<
|
|
||||||
T extends Component<any, any, any, any>,
|
|
||||||
F extends Signature,
|
|
||||||
> = Omit<T, "use" | "asFunctionComponent"> & {
|
|
||||||
readonly use: Effect.Effect<F, never, Exclude<Component.Context<T>, Scope.Scope>>
|
|
||||||
asFunctionComponent(
|
|
||||||
runtimeRef: React.Ref<Runtime.Runtime<Exclude<Component.Context<T>, Scope.Scope>>>
|
|
||||||
): F
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const withSignature: {
|
|
||||||
<F extends withSignature.Signature>(): <T extends Component<any, any, any, any>>(
|
|
||||||
self: T
|
|
||||||
) => withSignature.Result<T, F>
|
|
||||||
<F extends withSignature.Signature, T extends Component<any, any, any, any>>(
|
|
||||||
self: T
|
|
||||||
): withSignature.Result<T, F>
|
|
||||||
} = (self?: Component<any, any, any, 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 options while preserving original behavior.
|
||||||
*
|
|
||||||
* This function allows you to customize component-level options such as finalizer execution strategy
|
|
||||||
* and debounce timing.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const MyComponentWithCustomOptions = MyComponent.pipe(
|
|
||||||
* Component.withOptions({
|
|
||||||
* finalizerExecutionStrategy: ExecutionStrategy.parallel,
|
|
||||||
* finalizerExecutionDebounce: "50 millis",
|
|
||||||
* })
|
|
||||||
* )
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export const withOptions: {
|
export const withOptions: {
|
||||||
<T extends Component<any, any, any, any>>(
|
<T extends Component<any, any, any, any>>(
|
||||||
options: Partial<ComponentOptions>
|
options: Partial<Component.Options>
|
||||||
): (self: T) => T
|
): (self: T) => T
|
||||||
<T extends Component<any, any, any, any>>(
|
<T extends Component<any, any, any, any>>(
|
||||||
self: T,
|
self: T,
|
||||||
options: Partial<ComponentOptions>,
|
options: Partial<Component.Options>,
|
||||||
): T
|
): T
|
||||||
} = Function.dual(2, <T extends Component<any, any, any, any>>(
|
} = Function.dual(2, <T extends Component<any, any, any, any>>(
|
||||||
self: T,
|
self: T,
|
||||||
options: Partial<ComponentOptions>,
|
options: Partial<Component.Options>,
|
||||||
): T => Object.setPrototypeOf(
|
): T => Object.setPrototypeOf(
|
||||||
Object.assign(function() {}, self, options),
|
Object.assign(function() {}, self, options),
|
||||||
Object.getPrototypeOf(self),
|
Object.getPrototypeOf(self),
|
||||||
))
|
))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps an Effect-FC Component and converts it into a standard React function component,
|
* Wraps an Effect-FC `Component` and turns it into a regular React function component
|
||||||
* serving as an **entrypoint** into an Effect-FC component hierarchy.
|
* that serves as an **entrypoint** into an Effect-FC component hierarchy.
|
||||||
*
|
*
|
||||||
* This is how Effect-FC components are integrated with the broader React ecosystem,
|
* This is the recommended way to connect Effect-FC components to the rest of your React app,
|
||||||
* particularly when:
|
* especially when using routers (TanStack Router, React Router, etc.), lazy-loaded routes,
|
||||||
* - Using client-side routers (TanStack Router, React Router, etc.)
|
* or any place where a standard React component is expected.
|
||||||
* - Implementing lazy-loaded or code-split routes
|
|
||||||
* - Connecting to third-party libraries expecting standard React components
|
|
||||||
* - Creating component boundaries between Effect-FC and non-Effect-FC code
|
|
||||||
*
|
*
|
||||||
* The Effect runtime is obtained from the provided React Context.
|
* The runtime is obtained from the provided React Context, allowing you to:
|
||||||
|
* - Provide dependencies once at a high level
|
||||||
|
* - Use the same runtime across an entire route tree or feature
|
||||||
*
|
*
|
||||||
* @param self - The Effect-FC Component to be rendered as a standard React component
|
* @example Using TanStack Router
|
||||||
* @param context - React Context providing the Effect Runtime for this component tree.
|
|
||||||
* Create this using the `ReactRuntime` module.
|
|
||||||
*
|
|
||||||
* @example Integration with TanStack Router
|
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* // Application root
|
* // Main
|
||||||
* export const runtime = ReactRuntime.make(Layer.empty)
|
* export const runtime = ReactRuntime.make(Layer.empty)
|
||||||
*
|
|
||||||
* function App() {
|
* function App() {
|
||||||
* return (
|
* return (
|
||||||
* <ReactRuntime.Provider runtime={runtime}>
|
* <ReactRuntime.Provider runtime={runtime}>
|
||||||
@@ -587,12 +455,14 @@ export const withOptions: {
|
|||||||
* )
|
* )
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* // Route definition
|
* // Route
|
||||||
* export const Route = createFileRoute("/")({
|
* export const Route = createFileRoute("/")({
|
||||||
* component: Component.withRuntime(HomePage, runtime.context)
|
* component: Component.withRuntime(HomePage, runtime.context)
|
||||||
* })
|
* })
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
|
* @param self - The Effect-FC Component you want to render as a regular React component.
|
||||||
|
* @param context - React Context that holds the Runtime to use for this component tree. See the `ReactRuntime` module to create one.
|
||||||
*/
|
*/
|
||||||
export const withRuntime: {
|
export const withRuntime: {
|
||||||
<P extends {}, A extends React.ReactNode, E, R>(
|
<P extends {}, A extends React.ReactNode, E, R>(
|
||||||
@@ -607,17 +477,15 @@ export const withRuntime: {
|
|||||||
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),
|
||||||
props,
|
props,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal Effect service that maintains a registry of scopes associated with React component instances.
|
* Service that keeps track of scopes associated with React components
|
||||||
*
|
* (used internally by the `useScope` hook).
|
||||||
* This service is used internally by the `useScope` hook to manage the lifecycle of component scopes,
|
|
||||||
* including tracking active scopes and coordinating their cleanup when components unmount or dependencies change.
|
|
||||||
*/
|
*/
|
||||||
export class ScopeMap extends Effect.Service<ScopeMap>()("@effect-fc/Component/ScopeMap", {
|
export class ScopeMap extends Effect.Service<ScopeMap>()("@effect-fc/Component/ScopeMap", {
|
||||||
effect: Effect.bind(Effect.Do, "ref", () => Ref.make(HashMap.empty<object, ScopeMap.Entry>()))
|
effect: Effect.bind(Effect.Do, "ref", () => Ref.make(HashMap.empty<object, ScopeMap.Entry>()))
|
||||||
@@ -639,22 +507,13 @@ export declare namespace useScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook that creates and manages a `Scope` for the current component instance.
|
* Hook that creates and manages a `Scope` for the current component instance.
|
||||||
*
|
*
|
||||||
* This hook establishes a new scope that is automatically closed when:
|
* Automatically closes the scope whenever `deps` changes or the component unmounts.
|
||||||
* - The component unmounts
|
|
||||||
* - The dependency array `deps` changes
|
|
||||||
*
|
*
|
||||||
* The scope provides a resource management boundary for any Effects executed within the component,
|
* @param deps - dependency array like in `React.useEffect`
|
||||||
* ensuring proper cleanup of resources and execution of finalizers.
|
* @param options - finalizer execution control
|
||||||
*
|
*/
|
||||||
* @param deps - Dependency array following React.useEffect semantics. The scope is recreated
|
|
||||||
* whenever any dependency changes.
|
|
||||||
* @param options - Configuration for finalizer execution behavior, including execution strategy
|
|
||||||
* and debounce timing.
|
|
||||||
*
|
|
||||||
* @returns An Effect that produces a `Scope` for resource management
|
|
||||||
*/
|
|
||||||
export const useScope = Effect.fnUntraced(function*(
|
export const useScope = Effect.fnUntraced(function*(
|
||||||
deps: React.DependencyList,
|
deps: React.DependencyList,
|
||||||
options?: useScope.Options,
|
options?: useScope.Options,
|
||||||
@@ -708,23 +567,7 @@ export const useScope = Effect.fnUntraced(function*(
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook that executes an Effect once when the component mounts and caches the result.
|
* Runs an effect and returns its result only once on component mount.
|
||||||
*
|
|
||||||
* This hook is useful for one-time initialization logic that should not be re-executed
|
|
||||||
* when the component re-renders. The Effect is executed exactly once during the component's
|
|
||||||
* initial mount, and the cached result is returned on all subsequent renders.
|
|
||||||
*
|
|
||||||
* @param f - A function that returns the Effect to execute on mount
|
|
||||||
*
|
|
||||||
* @returns An Effect that produces the cached result of the Effect
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const MyComponent = Component.make(function*() {
|
|
||||||
* const initialData = yield* Component.useOnMount(() => getData)
|
|
||||||
* return <div>{initialData}</div>
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export const useOnMount = Effect.fnUntraced(function* <A, E, R>(
|
export const useOnMount = Effect.fnUntraced(function* <A, E, R>(
|
||||||
f: () => Effect.Effect<A, E, R>
|
f: () => Effect.Effect<A, E, R>
|
||||||
@@ -738,33 +581,9 @@ export declare namespace useOnChange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook that executes an Effect whenever dependencies change and caches the result.
|
* Runs an effect and returns its result whenever dependencies change.
|
||||||
*
|
*
|
||||||
* This hook combines the dependency-tracking behavior of React.useEffect with Effect caching.
|
* Provides its own `Scope` which closes whenever `deps` changes or the component unmounts.
|
||||||
* The Effect is re-executed whenever any dependency in the `deps` array changes, and the result
|
|
||||||
* is cached until the next dependency change.
|
|
||||||
*
|
|
||||||
* A dedicated scope is created for each dependency change, ensuring proper resource cleanup:
|
|
||||||
* - The scope closes when dependencies change
|
|
||||||
* - The scope closes when the component unmounts
|
|
||||||
* - All finalizers are executed according to the configured execution strategy
|
|
||||||
*
|
|
||||||
* @param f - A function that returns the Effect to execute
|
|
||||||
* @param deps - Dependency array following React.useEffect semantics
|
|
||||||
* @param options - Configuration for scope and finalizer behavior
|
|
||||||
*
|
|
||||||
* @returns An Effect that produces the cached result of the Effect
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const MyComponent = Component.make(function* (props: { userId: string }) {
|
|
||||||
* const userData = yield* Component.useOnChange(
|
|
||||||
* getUser(props.userId),
|
|
||||||
* [props.userId],
|
|
||||||
* )
|
|
||||||
* return <div>{userData.name}</div>
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export const useOnChange = Effect.fnUntraced(function* <A, E, R>(
|
export const useOnChange = Effect.fnUntraced(function* <A, E, R>(
|
||||||
f: () => Effect.Effect<A, E, R>,
|
f: () => Effect.Effect<A, E, R>,
|
||||||
@@ -788,36 +607,9 @@ export declare namespace useReactEffect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook that provides Effect-based semantics for React.useEffect.
|
* Like `React.useEffect` but accepts an effect.
|
||||||
*
|
*
|
||||||
* This hook bridges React's useEffect with the Effect system, allowing you to use Effects
|
* Cleanup logic is handled through the `Scope` API rather than using imperative cleanup.
|
||||||
* for React side effects while maintaining React's dependency tracking and lifecycle semantics.
|
|
||||||
*
|
|
||||||
* Unlike React.useEffect which uses imperative cleanup functions, this hook leverages the
|
|
||||||
* Effect Scope API for resource management. Cleanup logic is expressed declaratively through
|
|
||||||
* finalizers registered with the scope, providing better composability and error handling.
|
|
||||||
*
|
|
||||||
* @param f - A function that returns an Effect to execute as a side effect
|
|
||||||
* @param deps - Optional dependency array following React.useEffect semantics.
|
|
||||||
* If omitted, the effect runs after every render.
|
|
||||||
* @param options - Configuration for finalizer execution mode (sync or fork) and strategy
|
|
||||||
*
|
|
||||||
* @returns An Effect that produces void
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const MyComponent = Component.make(function* (props: { id: string }) {
|
|
||||||
* yield* Component.useReactEffect(
|
|
||||||
* () => getNotificationStreamForUser(props.id).pipe(
|
|
||||||
* Stream.unwrap,
|
|
||||||
* Stream.runForEach(notification => Console.log(`Notification received: ${ notification }`),
|
|
||||||
* Effect.forkScoped,
|
|
||||||
* ),
|
|
||||||
* [props.id],
|
|
||||||
* )
|
|
||||||
* return <div>Subscribed to notifications for {props.id}</div>
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export const useReactEffect = Effect.fnUntraced(function* <E, R>(
|
export const useReactEffect = Effect.fnUntraced(function* <E, R>(
|
||||||
f: () => Effect.Effect<void, E, R>,
|
f: () => Effect.Effect<void, E, R>,
|
||||||
@@ -856,43 +648,9 @@ export declare namespace useReactLayoutEffect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook that provides Effect-based semantics for React.useLayoutEffect.
|
* Like `React.useReactLayoutEffect` but accepts an effect.
|
||||||
*
|
*
|
||||||
* This hook is identical to `useReactEffect` but executes synchronously after DOM mutations
|
* Cleanup logic is handled through the `Scope` API rather than using imperative cleanup.
|
||||||
* but before the browser paints, following React.useLayoutEffect semantics.
|
|
||||||
*
|
|
||||||
* Use this hook when you need to:
|
|
||||||
* - Measure DOM elements (e.g., for layout calculations)
|
|
||||||
* - Synchronously update state based on DOM measurements
|
|
||||||
* - Avoid visual flicker from asynchronous updates
|
|
||||||
*
|
|
||||||
* Like `useReactEffect`, cleanup logic is handled through the Effect Scope API rather than
|
|
||||||
* imperative cleanup functions, providing declarative and composable resource management.
|
|
||||||
*
|
|
||||||
* @param f - A function that returns an Effect to execute as a layout side effect
|
|
||||||
* @param deps - Optional dependency array following React.useLayoutEffect semantics.
|
|
||||||
* If omitted, the effect runs after every render.
|
|
||||||
* @param options - Configuration for finalizer execution mode (sync or fork) and strategy
|
|
||||||
*
|
|
||||||
* @returns An Effect that produces void
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const MyComponent = Component.make(function*() {
|
|
||||||
* const ref = React.useRef<HTMLDivElement>(null)
|
|
||||||
* yield* Component.useReactLayoutEffect(
|
|
||||||
* () => Effect.gen(function* () {
|
|
||||||
* const element = ref.current
|
|
||||||
* if (element) {
|
|
||||||
* const rect = element.getBoundingClientRect()
|
|
||||||
* yield* Console.log(`Element dimensions: ${ rect.width }x${ rect.height }`)
|
|
||||||
* }
|
|
||||||
* }),
|
|
||||||
* [],
|
|
||||||
* )
|
|
||||||
* return <div ref={ref}>Content</div>
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export const useReactLayoutEffect = Effect.fnUntraced(function* <E, R>(
|
export const useReactLayoutEffect = Effect.fnUntraced(function* <E, R>(
|
||||||
f: () => Effect.Effect<void, E, R>,
|
f: () => Effect.Effect<void, E, R>,
|
||||||
@@ -905,23 +663,7 @@ export const useReactLayoutEffect = Effect.fnUntraced(function* <E, R>(
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook that provides a synchronous function to execute Effects within the current runtime context.
|
* Get a synchronous run function for the current runtime context.
|
||||||
*
|
|
||||||
* This hook returns a function that can execute Effects synchronously, blocking until completion.
|
|
||||||
* Use this when you need to run Effects from non-Effect code (e.g., event handlers, callbacks)
|
|
||||||
* within a component.
|
|
||||||
*
|
|
||||||
* @returns An Effect that produces a function capable of synchronously executing Effects
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const MyComponent = Component.make(function*() {
|
|
||||||
* const runSync = yield* Component.useRunSync<SomeService>() // Specify required services
|
|
||||||
* const runSync = yield* Component.useRunSync() // Or no service requirements
|
|
||||||
*
|
|
||||||
* return <button onClick={() => runSync(someEffect)}>Click me</button>
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export const useRunSync = <R = never>(): Effect.Effect<
|
export const useRunSync = <R = never>(): Effect.Effect<
|
||||||
<A, E = never>(effect: Effect.Effect<A, E, Scope.Scope | R>) => A,
|
<A, E = never>(effect: Effect.Effect<A, E, Scope.Scope | R>) => A,
|
||||||
@@ -930,23 +672,7 @@ export const useRunSync = <R = never>(): Effect.Effect<
|
|||||||
> => Effect.andThen(Effect.runtime(), Runtime.runSync)
|
> => Effect.andThen(Effect.runtime(), Runtime.runSync)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook that provides an asynchronous function to execute Effects within the current runtime context.
|
* Get a Promise-based run function for the current runtime context.
|
||||||
*
|
|
||||||
* This hook returns a function that executes Effects asynchronously, returning a Promise that resolves
|
|
||||||
* with the Effect's result. Use this when you need to run Effects from non-Effect code (e.g., event handlers,
|
|
||||||
* async callbacks) and want to handle the result asynchronously.
|
|
||||||
*
|
|
||||||
* @returns An Effect that produces a function capable of asynchronously executing Effects
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const MyComponent = Component.make(function*() {
|
|
||||||
* const runPromise = yield* Component.useRunPromise<SomeService>() // Specify required services
|
|
||||||
* const runPromise = yield* Component.useRunPromise() // Or no service requirements
|
|
||||||
*
|
|
||||||
* return <button onClick={() => runPromise(someEffect)}>Click me</button>
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export const useRunPromise = <R = never>(): Effect.Effect<
|
export const useRunPromise = <R = never>(): Effect.Effect<
|
||||||
<A, E = never>(effect: Effect.Effect<A, E, Scope.Scope | R>) => Promise<A>,
|
<A, E = never>(effect: Effect.Effect<A, E, Scope.Scope | R>) => Promise<A>,
|
||||||
@@ -955,32 +681,7 @@ export const useRunPromise = <R = never>(): Effect.Effect<
|
|||||||
> => Effect.andThen(Effect.runtime(), context => Runtime.runPromise(context))
|
> => Effect.andThen(Effect.runtime(), context => Runtime.runPromise(context))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook that memoizes a function that returns an Effect, providing synchronous execution.
|
* Turns a function returning an effect into a memoized synchronous function.
|
||||||
*
|
|
||||||
* This hook wraps a function that returns an Effect and returns a memoized version that:
|
|
||||||
* - Executes the Effect synchronously when called
|
|
||||||
* - Is memoized based on the provided dependency array
|
|
||||||
* - Maintains referential equality across renders when dependencies don't change
|
|
||||||
*
|
|
||||||
* Use this to create stable callback references for event handlers and other scenarios
|
|
||||||
* where you need to execute Effects synchronously from non-Effect code.
|
|
||||||
*
|
|
||||||
* @param f - A function that accepts arguments and returns an Effect
|
|
||||||
* @param deps - Dependency array. The memoized function is recreated when dependencies change.
|
|
||||||
*
|
|
||||||
* @returns An Effect that produces a memoized function with the same signature as `f`
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const MyComponent = Component.make(function* (props: { onSave: (data: Data) => void }) {
|
|
||||||
* const handleSave = yield* Component.useCallbackSync(
|
|
||||||
* (data: Data) => Effect.sync(() => props.onSave(data)),
|
|
||||||
* [props.onSave],
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* return <button onClick={() => handleSave(myData)}>Save</button>
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export const useCallbackSync = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
|
export const useCallbackSync = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
|
||||||
f: (...args: Args) => Effect.Effect<A, E, R>,
|
f: (...args: Args) => Effect.Effect<A, E, R>,
|
||||||
@@ -995,32 +696,7 @@ export const useCallbackSync = Effect.fnUntraced(function* <Args extends unknown
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook that memoizes a function that returns an Effect, providing asynchronous execution.
|
* Turns a function returning an effect into a memoized Promise-based asynchronous function.
|
||||||
*
|
|
||||||
* This hook wraps a function that returns an Effect and returns a memoized version that:
|
|
||||||
* - Executes the Effect asynchronously when called, returning a Promise
|
|
||||||
* - Is memoized based on the provided dependency array
|
|
||||||
* - Maintains referential equality across renders when dependencies don't change
|
|
||||||
*
|
|
||||||
* Use this to create stable callback references for async event handlers and other scenarios
|
|
||||||
* where you need to execute Effects asynchronously from non-Effect code.
|
|
||||||
*
|
|
||||||
* @param f - A function that accepts arguments and returns an Effect
|
|
||||||
* @param deps - Dependency array. The memoized function is recreated when dependencies change.
|
|
||||||
*
|
|
||||||
* @returns An Effect that produces a memoized function that returns a Promise
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const MyComponent = Component.make(function* (props: { onSave: (data: Data) => void }) {
|
|
||||||
* const handleSave = yield* Component.useCallbackPromise(
|
|
||||||
* (data: Data) => Effect.promise(() => props.onSave(data)),
|
|
||||||
* [props.onSave],
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* return <button onClick={() => handleSave(myData)}>Save</button>
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export const useCallbackPromise = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
|
export const useCallbackPromise = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
|
||||||
f: (...args: Args) => Effect.Effect<A, E, R>,
|
f: (...args: Args) => Effect.Effect<A, E, R>,
|
||||||
@@ -1039,70 +715,16 @@ export declare namespace useContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook that constructs an Effect Layer and returns the resulting context.
|
* Hook that constructs a layer and returns the created context.
|
||||||
*
|
*
|
||||||
* This hook creates a managed runtime from the provided layer and returns the context it produces.
|
* The layer gets reconstructed everytime `layer` changes, so make sure its value is stable.
|
||||||
* The layer is reconstructed whenever its value changes, so ensure the layer reference is stable
|
|
||||||
* (typically by memoizing it or defining it outside the component).
|
|
||||||
*
|
*
|
||||||
* The hook automatically manages the layer's lifecycle:
|
* Building a layer containing asynchronous effects require the component calling this hook to be made async using `Async.async`.
|
||||||
* - The layer is built when the component mounts or when the layer reference changes
|
|
||||||
* - Resources are properly released when the component unmounts or dependencies change
|
|
||||||
* - Finalizers are executed according to the configured execution strategy
|
|
||||||
*
|
|
||||||
* @param layer - The Effect Layer to construct. Should be a stable reference to avoid unnecessary
|
|
||||||
* reconstruction. Consider memoizing with React.useMemo if defined inline.
|
|
||||||
* @param options - Configuration for scope and finalizer behavior
|
|
||||||
*
|
|
||||||
* @returns An Effect that produces the context created by the layer
|
|
||||||
*
|
|
||||||
* @throws If the layer contains asynchronous effects, the component must be wrapped with `Async.async`
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const MyLayer = Layer.succeed(MyService, new MyServiceImpl())
|
|
||||||
* const MyComponent = Component.make(function*() {
|
|
||||||
* const context = yield* Component.useContextFromLayer(MyLayer)
|
|
||||||
* const Sub = yield* SubComponent.use.pipe(
|
|
||||||
* Effect.provide(context)
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* return <Sub />
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example With memoized layer
|
|
||||||
* ```tsx
|
|
||||||
* const MyComponent = Component.make(function*(props: { id: string })) {
|
|
||||||
* const context = yield* Component.useContextFromLayer(
|
|
||||||
* React.useMemo(() => Layer.succeed(MyService, new MyServiceImpl(props.id)), [props.id])
|
|
||||||
* )
|
|
||||||
* const Sub = yield* SubComponent.use.pipe(
|
|
||||||
* Effect.provide(context)
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* return <Sub />
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example With async layer
|
|
||||||
* ```tsx
|
|
||||||
* const MyAsyncLayer = Layer.effect(MyService, someAsyncEffect)
|
|
||||||
* const MyComponent = Component.make(function*() {
|
|
||||||
* const context = yield* Component.useContextFromLayer(MyAsyncLayer)
|
|
||||||
* const Sub = yield* SubComponent.use.pipe(
|
|
||||||
* Effect.provide(context)
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* return <Sub />
|
|
||||||
* }).pipe(
|
|
||||||
* Async.async // Required to handle async layer effects
|
|
||||||
* )
|
|
||||||
*/
|
*/
|
||||||
export const useContextFromLayer = <ROut, E, RIn>(
|
export const useContext = <ROut, E, RIn>(
|
||||||
layer: Layer.Layer<ROut, E, RIn>,
|
layer: Layer.Layer<ROut, E, RIn>,
|
||||||
options?: useContext.Options,
|
options?: useContext.Options,
|
||||||
): Effect.Effect<Context.Context<ROut>, E, RIn | Scope.Scope> => useOnChange(() => Effect.context<RIn>().pipe(
|
): Effect.Effect<Context.Context<ROut>, E, Exclude<RIn, Scope.Scope>> => useOnChange(() => Effect.context<RIn>().pipe(
|
||||||
Effect.map(context => ManagedRuntime.make(Layer.provide(layer, Layer.succeedContext(context)))),
|
Effect.map(context => ManagedRuntime.make(Layer.provide(layer, Layer.succeedContext(context)))),
|
||||||
Effect.tap(runtime => Effect.addFinalizer(() => runtime.disposeEffect)),
|
Effect.tap(runtime => Effect.addFinalizer(() => runtime.disposeEffect)),
|
||||||
Effect.andThen(runtime => runtime.runtimeEffect),
|
Effect.andThen(runtime => runtime.runtimeEffect),
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect"
|
import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect"
|
||||||
|
|
||||||
|
|
||||||
export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
|
export const TypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
|
||||||
export type ErrorObserverTypeId = typeof ErrorObserverTypeId
|
export type TypeId = typeof TypeId
|
||||||
|
|
||||||
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
|
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
|
||||||
readonly [ErrorObserverTypeId]: ErrorObserverTypeId
|
readonly [TypeId]: TypeId
|
||||||
handle<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
|
handle<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
|
||||||
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
|
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ErrorObserver = <E = never>(): Context.Tag<ErrorObserver, ErrorObserver<E>> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver")
|
export const ErrorObserver = <E = never>(): Context.Tag<ErrorObserver, ErrorObserver<E>> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver")
|
||||||
|
|
||||||
export class ErrorObserverImpl<in out E = never>
|
class ErrorObserverImpl<in out E = never>
|
||||||
extends Pipeable.Class() implements ErrorObserver<E> {
|
extends Pipeable.Class() implements ErrorObserver<E> {
|
||||||
readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId
|
readonly [TypeId]: TypeId = TypeId
|
||||||
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
|
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -29,7 +29,7 @@ extends Pipeable.Class() implements ErrorObserver<E> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
|
class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
|
||||||
readonly value = Effect.void
|
readonly value = Effect.void
|
||||||
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) {
|
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) {
|
||||||
super()
|
super()
|
||||||
@@ -43,7 +43,7 @@ export class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, ErrorObserverTypeId)
|
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, TypeId)
|
||||||
|
|
||||||
export const layer: Layer.Layer<ErrorObserver> = Layer.unwrapEffect(Effect.map(
|
export const layer: Layer.Layer<ErrorObserver> = Layer.unwrapEffect(Effect.map(
|
||||||
PubSub.unbounded<Cause.Cause<never>>(),
|
PubSub.unbounded<Cause.Cause<never>>(),
|
||||||
|
|||||||
@@ -1,157 +1,293 @@
|
|||||||
import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, type ParseResult, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
|
import { Array, Cause, Chunk, type Context, type Duration, Effect, Equal, Exit, Fiber, flow, Hash, HashMap, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect"
|
||||||
import type * as React from "react"
|
import type * as React from "react"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "./Component.js"
|
||||||
import * as Lens from "./Lens.js"
|
import * as Mutation from "./Mutation.js"
|
||||||
|
import * as PropertyPath from "./PropertyPath.js"
|
||||||
|
import * as Result from "./Result.js"
|
||||||
import * as Subscribable from "./Subscribable.js"
|
import * as Subscribable from "./Subscribable.js"
|
||||||
|
import * as SubscriptionRef from "./SubscriptionRef.js"
|
||||||
|
import * as SubscriptionSubRef from "./SubscriptionSubRef.js"
|
||||||
|
|
||||||
|
|
||||||
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
|
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
|
||||||
export type FormTypeId = typeof FormTypeId
|
export type FormTypeId = typeof FormTypeId
|
||||||
|
|
||||||
export interface Form<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
|
export interface Form<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
||||||
extends Pipeable.Pipeable {
|
extends Pipeable.Pipeable {
|
||||||
readonly [FormTypeId]: FormTypeId
|
readonly [FormTypeId]: FormTypeId
|
||||||
|
|
||||||
readonly path: P
|
readonly schema: Schema.Schema<A, I, R>
|
||||||
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>
|
readonly context: Context.Context<Scope.Scope | R>
|
||||||
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>
|
readonly mutation: Mutation.Mutation<
|
||||||
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>
|
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>],
|
||||||
readonly isValidating: Subscribable.Subscribable<boolean, ER, never>
|
MA, ME, MR, MP
|
||||||
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
|
>
|
||||||
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
|
readonly autosubmit: boolean
|
||||||
|
readonly debounce: Option.Option<Duration.DurationInput>
|
||||||
|
|
||||||
|
readonly value: Subscribable.Subscribable<Option.Option<A>>
|
||||||
|
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
|
||||||
|
readonly error: Subscribable.Subscribable<Option.Option<ParseResult.ParseError>>
|
||||||
|
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>
|
||||||
|
|
||||||
|
readonly canSubmit: Subscribable.Subscribable<boolean>
|
||||||
|
|
||||||
|
field<const P extends PropertyPath.Paths<I>>(
|
||||||
|
path: P
|
||||||
|
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>>
|
||||||
|
|
||||||
|
readonly run: Effect.Effect<void>
|
||||||
|
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FormImpl<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
|
export class FormImpl<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
||||||
extends Pipeable.Class() implements Form<P, A, I, ER, EW> {
|
extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
|
||||||
readonly [FormTypeId]: FormTypeId = FormTypeId
|
readonly [FormTypeId]: FormTypeId = FormTypeId
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly path: P,
|
readonly schema: Schema.Schema<A, I, R>,
|
||||||
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>,
|
readonly context: Context.Context<Scope.Scope | R>,
|
||||||
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>,
|
readonly mutation: Mutation.Mutation<
|
||||||
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>,
|
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>],
|
||||||
readonly isValidating: Subscribable.Subscribable<boolean, never, never>,
|
MA, ME, MR, MP
|
||||||
readonly canCommit: Subscribable.Subscribable<boolean, never, never>,
|
>,
|
||||||
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>,
|
readonly autosubmit: boolean,
|
||||||
|
readonly debounce: Option.Option<Duration.DurationInput>,
|
||||||
|
|
||||||
|
readonly value: SubscriptionRef.SubscriptionRef<Option.Option<A>>,
|
||||||
|
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
|
||||||
|
readonly error: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>,
|
||||||
|
readonly validationFiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>,
|
||||||
|
|
||||||
|
readonly runSemaphore: Effect.Semaphore,
|
||||||
|
readonly fieldCache: Ref.Ref<HashMap.HashMap<FormFieldKey, FormField<unknown, unknown>>>,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.canSubmit = Subscribable.map(
|
||||||
|
Subscribable.zipLatestAll(this.value, this.error, this.validationFiber, this.mutation.result),
|
||||||
|
([value, error, validationFiber, result]) => (
|
||||||
|
Option.isSome(value) &&
|
||||||
|
Option.isNone(error) &&
|
||||||
|
Option.isNone(validationFiber) &&
|
||||||
|
!(Result.isRunning(result) || Result.hasRefreshingFlag(result))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
field<const P extends PropertyPath.Paths<I>>(
|
||||||
|
path: P
|
||||||
|
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>> {
|
||||||
|
const key = new FormFieldKey(path)
|
||||||
|
return this.fieldCache.pipe(
|
||||||
|
Effect.map(HashMap.get(key)),
|
||||||
|
Effect.flatMap(Option.match({
|
||||||
|
onSome: v => Effect.succeed(v as FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>),
|
||||||
|
onNone: () => Effect.tap(
|
||||||
|
Effect.succeed(makeFormField(this as Form<A, I, R, MA, ME, MR, MP>, path)),
|
||||||
|
v => Ref.update(this.fieldCache, HashMap.set(key, v as FormField<unknown, unknown>)),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly canSubmit: Subscribable.Subscribable<boolean>
|
||||||
|
|
||||||
|
get run(): Effect.Effect<void> {
|
||||||
|
return this.runSemaphore.withPermits(1)(Stream.runForEach(
|
||||||
|
this.encodedValue.changes.pipe(
|
||||||
|
Option.isSome(this.debounce) ? Stream.debounce(this.debounce.value) : identity
|
||||||
|
),
|
||||||
|
|
||||||
|
encodedValue => this.validationFiber.pipe(
|
||||||
|
Effect.andThen(Option.match({
|
||||||
|
onSome: Fiber.interrupt,
|
||||||
|
onNone: () => Effect.void,
|
||||||
|
})),
|
||||||
|
Effect.andThen(
|
||||||
|
Effect.forkScoped(Effect.onExit(
|
||||||
|
Schema.decode(this.schema, { errors: "all" })(encodedValue),
|
||||||
|
exit => Effect.andThen(
|
||||||
|
Exit.matchEffect(exit, {
|
||||||
|
onSuccess: v => Effect.andThen(
|
||||||
|
Ref.set(this.value, Option.some(v)),
|
||||||
|
Ref.set(this.error, Option.none()),
|
||||||
|
),
|
||||||
|
onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), {
|
||||||
|
onSome: e => Ref.set(this.error, Option.some(e)),
|
||||||
|
onNone: () => Effect.void,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
Ref.set(this.validationFiber, Option.none()),
|
||||||
|
),
|
||||||
|
)).pipe(
|
||||||
|
Effect.tap(fiber => Ref.set(this.validationFiber, Option.some(fiber))),
|
||||||
|
Effect.andThen(Fiber.join),
|
||||||
|
Effect.andThen(value => this.autosubmit
|
||||||
|
? Effect.asVoid(Effect.forkScoped(this.submitValue(value)))
|
||||||
|
: Effect.void
|
||||||
|
),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Effect.provide(this.context),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException> {
|
||||||
|
return this.value.pipe(
|
||||||
|
Effect.andThen(identity),
|
||||||
|
Effect.andThen(value => this.submitValue(value)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>> {
|
||||||
|
return Effect.whenEffect(
|
||||||
|
Effect.tap(
|
||||||
|
this.mutation.mutate([value, this as any]),
|
||||||
|
result => Result.isFailure(result)
|
||||||
|
? Option.match(
|
||||||
|
Chunk.findFirst(
|
||||||
|
Cause.failures(result.cause as Cause.Cause<ParseResult.ParseError>),
|
||||||
|
e => e._tag === "ParseError",
|
||||||
|
),
|
||||||
|
{
|
||||||
|
onSome: e => Ref.set(this.error, Option.some(e)),
|
||||||
|
onNone: () => Effect.void,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Effect.void
|
||||||
|
),
|
||||||
|
this.canSubmit.get,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isForm = (u: unknown): u is Form<unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
|
||||||
|
|
||||||
|
export declare namespace make {
|
||||||
|
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
||||||
|
extends Mutation.make.Options<
|
||||||
|
readonly [value: NoInfer<A>, form: Form<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>],
|
||||||
|
MA, ME, MR, MP
|
||||||
|
> {
|
||||||
|
readonly schema: Schema.Schema<A, I, R>
|
||||||
|
readonly initialEncodedValue: NoInfer<I>
|
||||||
|
readonly autosubmit?: boolean
|
||||||
|
readonly debounce?: Duration.DurationInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
|
||||||
|
options: make.Options<A, I, R, MA, ME, MR, MP>
|
||||||
|
): Effect.fn.Return<
|
||||||
|
Form<A, I, R, MA, ME, Result.forkEffect.OutputContext<MA, ME, MR, MP>, MP>,
|
||||||
|
never,
|
||||||
|
Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP>
|
||||||
|
> {
|
||||||
|
return new FormImpl(
|
||||||
|
options.schema,
|
||||||
|
yield* Effect.context<Scope.Scope | R>(),
|
||||||
|
yield* Mutation.make(options),
|
||||||
|
options.autosubmit ?? false,
|
||||||
|
Option.fromNullable(options.debounce),
|
||||||
|
|
||||||
|
yield* SubscriptionRef.make(Option.none<A>()),
|
||||||
|
yield* SubscriptionRef.make(options.initialEncodedValue),
|
||||||
|
yield* SubscriptionRef.make(Option.none<ParseResult.ParseError>()),
|
||||||
|
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>()),
|
||||||
|
|
||||||
|
yield* Effect.makeSemaphore(1),
|
||||||
|
yield* Ref.make(HashMap.empty<FormFieldKey, FormField<unknown, unknown>>()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export declare namespace service {
|
||||||
|
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
||||||
|
extends make.Options<A, I, R, MA, ME, MR, MP> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const service = <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
|
||||||
|
options: service.Options<A, I, R, MA, ME, MR, MP>
|
||||||
|
): Effect.Effect<
|
||||||
|
Form<A, I, R, MA, ME, Result.forkEffect.OutputContext<MA, ME, MR, MP>, MP>,
|
||||||
|
never,
|
||||||
|
Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP>
|
||||||
|
> => Effect.tap(
|
||||||
|
make(options),
|
||||||
|
form => Effect.forkScoped(form.run),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
export const FormFieldTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormField")
|
||||||
|
export type FormFieldTypeId = typeof FormFieldTypeId
|
||||||
|
|
||||||
|
export interface FormField<in out A, in out I = A>
|
||||||
|
extends Pipeable.Pipeable {
|
||||||
|
readonly [FormFieldTypeId]: FormFieldTypeId
|
||||||
|
|
||||||
|
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>
|
||||||
|
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
|
||||||
|
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
|
||||||
|
readonly isValidating: Subscribable.Subscribable<boolean>
|
||||||
|
readonly isSubmitting: Subscribable.Subscribable<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
class FormFieldImpl<in out A, in out I = A>
|
||||||
|
extends Pipeable.Class() implements FormField<A, I> {
|
||||||
|
readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>,
|
||||||
|
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
|
||||||
|
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>,
|
||||||
|
readonly isValidating: Subscribable.Subscribable<boolean>,
|
||||||
|
readonly isSubmitting: Subscribable.Subscribable<boolean>,
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FormFieldKeyTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormFieldKey")
|
||||||
|
type FormFieldKeyTypeId = typeof FormFieldKeyTypeId
|
||||||
|
|
||||||
export const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
|
class FormFieldKey implements Equal.Equal {
|
||||||
|
readonly [FormFieldKeyTypeId]: FormFieldKeyTypeId = FormFieldKeyTypeId
|
||||||
|
constructor(readonly path: PropertyPath.PropertyPath) {}
|
||||||
|
|
||||||
|
[Equal.symbol](that: Equal.Equal) {
|
||||||
|
return isFormFieldKey(that) && PropertyPath.equivalence(this.path, that.path)
|
||||||
|
}
|
||||||
|
[Hash.symbol]() {
|
||||||
|
return Hash.array(this.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const filterIssuesByPath = (
|
export const isFormField = (u: unknown): u is FormField<unknown, unknown> => Predicate.hasProperty(u, FormFieldTypeId)
|
||||||
issues: readonly ParseResult.ArrayFormatterIssue[],
|
const isFormFieldKey = (u: unknown): u is FormFieldKey => Predicate.hasProperty(u, FormFieldKeyTypeId)
|
||||||
path: readonly PropertyKey[],
|
|
||||||
): readonly ParseResult.ArrayFormatterIssue[] => Array.filter(issues, issue =>
|
|
||||||
issue.path.length >= path.length && Array.every(path, (p, i) => p === issue.path[i])
|
|
||||||
)
|
|
||||||
|
|
||||||
export const focusObjectOn: {
|
export const makeFormField = <A, I, R, MA, ME, MR, MP, const P extends PropertyPath.Paths<NoInfer<I>>>(
|
||||||
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
self: Form<A, I, R, MA, ME, MR, MP>,
|
||||||
self: Form<P, A, I, ER, EW>,
|
path: P,
|
||||||
key: K,
|
): FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>> => {
|
||||||
): Form<readonly [...P, K], A[K], I[K], ER, EW>
|
return new FormFieldImpl(
|
||||||
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
Subscribable.mapEffect(self.value, Option.match({
|
||||||
key: K,
|
onSome: v => Option.map(PropertyPath.get(v, path), Option.some),
|
||||||
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
|
onNone: () => Option.some(Option.none()),
|
||||||
} = Function.dual(2, <P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
})),
|
||||||
self: Form<P, A, I, ER, EW>,
|
SubscriptionSubRef.makeFromPath(self.encodedValue, path),
|
||||||
key: K,
|
Subscribable.mapEffect(self.error, Option.match({
|
||||||
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
|
onSome: flow(
|
||||||
const form = self as FormImpl<P, A, I, ER, EW>
|
ParseResult.ArrayFormatter.formatError,
|
||||||
const path = [...form.path, key] as const
|
Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))),
|
||||||
|
),
|
||||||
return new FormImpl(
|
onNone: () => Effect.succeed([]),
|
||||||
path,
|
})),
|
||||||
Subscribable.mapOption(form.value, a => a[key]),
|
Subscribable.map(self.validationFiber, Option.isSome),
|
||||||
Lens.focusObjectOn(form.encodedValue, key),
|
Subscribable.map(self.mutation.result, result => Result.isRunning(result) || Result.hasRefreshingFlag(result)),
|
||||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
|
||||||
form.isValidating,
|
|
||||||
form.canCommit,
|
|
||||||
form.isCommitting,
|
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
export const focusArrayAt: {
|
|
||||||
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
|
|
||||||
self: Form<P, A, I, ER, EW>,
|
|
||||||
index: number,
|
|
||||||
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException>
|
|
||||||
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
|
|
||||||
index: number,
|
|
||||||
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException>
|
|
||||||
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
|
|
||||||
self: Form<P, A, I, ER, EW>,
|
|
||||||
index: number,
|
|
||||||
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException> => {
|
|
||||||
const form = self as FormImpl<P, A, I, ER, EW>
|
|
||||||
const path = [...form.path, index] as const
|
|
||||||
|
|
||||||
return new FormImpl(
|
|
||||||
path,
|
|
||||||
Subscribable.mapOptionEffect(form.value, Array.get(index)),
|
|
||||||
Lens.focusArrayAt(form.encodedValue, index),
|
|
||||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
|
||||||
form.isValidating,
|
|
||||||
form.canCommit,
|
|
||||||
form.isCommitting,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const focusTupleAt: {
|
|
||||||
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
|
|
||||||
self: Form<P, A, I, ER, EW>,
|
|
||||||
index: K,
|
|
||||||
): Form<readonly [...P, K], A[K], I[K], ER, EW>
|
|
||||||
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
|
|
||||||
index: K,
|
|
||||||
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
|
|
||||||
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
|
|
||||||
self: Form<P, A, I, ER, EW>,
|
|
||||||
index: K,
|
|
||||||
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
|
|
||||||
const form = self as FormImpl<P, A, I, ER, EW>
|
|
||||||
const path = [...form.path, index] as const
|
|
||||||
|
|
||||||
return new FormImpl(
|
|
||||||
path,
|
|
||||||
Subscribable.mapOption(form.value, Array.unsafeGet(index)),
|
|
||||||
Lens.focusTupleAt(form.encodedValue, index),
|
|
||||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
|
||||||
form.isValidating,
|
|
||||||
form.canCommit,
|
|
||||||
form.isCommitting,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const focusChunkAt: {
|
|
||||||
<P extends readonly PropertyKey[], A, I, ER, EW>(
|
|
||||||
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
|
|
||||||
index: number,
|
|
||||||
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW>
|
|
||||||
<P extends readonly PropertyKey[], A, I, ER, EW>(
|
|
||||||
index: number,
|
|
||||||
): (self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>) => Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW>
|
|
||||||
} = Function.dual(2, <P extends readonly PropertyKey[], A, I, ER, EW>(
|
|
||||||
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
|
|
||||||
index: number,
|
|
||||||
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW> => {
|
|
||||||
const form = self as FormImpl<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>
|
|
||||||
const path = [...form.path, index] as const
|
|
||||||
|
|
||||||
return new FormImpl(
|
|
||||||
path,
|
|
||||||
Subscribable.mapOptionEffect(form.value, Chunk.get(index)),
|
|
||||||
Lens.focusChunkAt(form.encodedValue, index),
|
|
||||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
|
||||||
form.isValidating,
|
|
||||||
form.canCommit,
|
|
||||||
form.isCommitting,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
export namespace useInput {
|
export namespace useInput {
|
||||||
@@ -165,39 +301,33 @@ export namespace useInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
|
export const useInput = Effect.fnUntraced(function* <A, I>(
|
||||||
form: Form<P, A, I, ER, EW>,
|
field: FormField<A, I>,
|
||||||
options?: useInput.Options,
|
options?: useInput.Options,
|
||||||
): Effect.fn.Return<useInput.Success<I>, ER, Scope.Scope> {
|
): Effect.fn.Return<useInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
|
||||||
const internalValueLens = yield* Component.useOnChange(() => Effect.gen(function*() {
|
const internalValueRef = yield* Component.useOnChange(() => Effect.tap(
|
||||||
const internalValueLens = yield* Lens.get(form.encodedValue).pipe(
|
Effect.andThen(field.encodedValue, SubscriptionRef.make),
|
||||||
Effect.flatMap(SubscriptionRef.make),
|
internalValueRef => Effect.forkScoped(Effect.all([
|
||||||
Effect.map(Lens.fromSubscriptionRef),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* Effect.forkScoped(Effect.all([
|
|
||||||
Stream.runForEach(
|
Stream.runForEach(
|
||||||
Stream.drop(form.encodedValue.changes, 1),
|
Stream.drop(field.encodedValue, 1),
|
||||||
upstreamEncodedValue => Effect.whenEffect(
|
upstreamEncodedValue => Effect.whenEffect(
|
||||||
Lens.set(internalValueLens, upstreamEncodedValue),
|
Ref.set(internalValueRef, upstreamEncodedValue),
|
||||||
Effect.andThen(Lens.get(internalValueLens), internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
|
Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
Stream.runForEach(
|
Stream.runForEach(
|
||||||
internalValueLens.changes.pipe(
|
internalValueRef.changes.pipe(
|
||||||
Stream.drop(1),
|
Stream.drop(1),
|
||||||
Stream.changesWith(Equal.equivalence()),
|
Stream.changesWith(Equal.equivalence()),
|
||||||
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
||||||
),
|
),
|
||||||
internalValue => Lens.set(form.encodedValue, internalValue),
|
internalValue => Ref.set(field.encodedValue, internalValue),
|
||||||
),
|
),
|
||||||
], { concurrency: "unbounded", discard: true }))
|
], { concurrency: "unbounded" })),
|
||||||
|
), [field, options?.debounce])
|
||||||
|
|
||||||
return internalValueLens
|
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
|
||||||
}), [form, options?.debounce])
|
|
||||||
|
|
||||||
const [value, setValue] = yield* Lens.useState(internalValueLens)
|
|
||||||
return { value, setValue }
|
return { value, setValue }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -212,63 +342,55 @@ export namespace useOptionalInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useOptionalInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
|
export const useOptionalInput = Effect.fnUntraced(function* <A, I>(
|
||||||
field: Form<P, A, Option.Option<I>, ER, EW>,
|
field: FormField<A, Option.Option<I>>,
|
||||||
options: useOptionalInput.Options<I>,
|
options: useOptionalInput.Options<I>,
|
||||||
): Effect.fn.Return<useOptionalInput.Success<I>, ER, Scope.Scope> {
|
): Effect.fn.Return<useOptionalInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
|
||||||
const [enabledLens, internalValueLens] = yield* Component.useOnChange(() => Effect.gen(function*() {
|
const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap(
|
||||||
const [enabledLens, internalValueLens] = yield* Effect.flatMap(
|
Effect.andThen(
|
||||||
Lens.get(field.encodedValue),
|
field.encodedValue,
|
||||||
Option.match({
|
Option.match({
|
||||||
onSome: v => Effect.all([
|
onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]),
|
||||||
Effect.map(SubscriptionRef.make(true), Lens.fromSubscriptionRef),
|
onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]),
|
||||||
Effect.map(SubscriptionRef.make(v), Lens.fromSubscriptionRef),
|
|
||||||
]),
|
|
||||||
onNone: () => Effect.all([
|
|
||||||
Effect.map(SubscriptionRef.make(false), Lens.fromSubscriptionRef),
|
|
||||||
Effect.map(SubscriptionRef.make(options.defaultValue), Lens.fromSubscriptionRef),
|
|
||||||
]),
|
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
|
|
||||||
yield* Effect.forkScoped(Effect.all([
|
([enabledRef, internalValueRef]) => Effect.forkScoped(Effect.all([
|
||||||
Stream.runForEach(
|
Stream.runForEach(
|
||||||
Stream.drop(field.encodedValue.changes, 1),
|
Stream.drop(field.encodedValue, 1),
|
||||||
|
|
||||||
upstreamEncodedValue => Effect.whenEffect(
|
upstreamEncodedValue => Effect.whenEffect(
|
||||||
Option.match(upstreamEncodedValue, {
|
Option.match(upstreamEncodedValue, {
|
||||||
onSome: v => Effect.andThen(
|
onSome: v => Effect.andThen(
|
||||||
Lens.set(enabledLens, true),
|
Ref.set(enabledRef, true),
|
||||||
Lens.set(internalValueLens, v),
|
Ref.set(internalValueRef, v),
|
||||||
),
|
),
|
||||||
onNone: () => Effect.andThen(
|
onNone: () => Effect.andThen(
|
||||||
Lens.set(enabledLens, false),
|
Ref.set(enabledRef, false),
|
||||||
Lens.set(internalValueLens, options.defaultValue),
|
Ref.set(internalValueRef, options.defaultValue),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
Effect.all([Lens.get(enabledLens), Lens.get(internalValueLens)]),
|
Effect.all([enabledRef, internalValueRef]),
|
||||||
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
Stream.runForEach(
|
Stream.runForEach(
|
||||||
enabledLens.changes.pipe(
|
enabledRef.changes.pipe(
|
||||||
Stream.zipLatest(internalValueLens.changes),
|
Stream.zipLatest(internalValueRef.changes),
|
||||||
Stream.drop(1),
|
Stream.drop(1),
|
||||||
Stream.changesWith(Equal.equivalence()),
|
Stream.changesWith(Equal.equivalence()),
|
||||||
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
||||||
),
|
),
|
||||||
([enabled, internalValue]) => Lens.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
([enabled, internalValue]) => Ref.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
||||||
),
|
),
|
||||||
], { concurrency: "unbounded" }))
|
], { concurrency: "unbounded" })),
|
||||||
|
), [field, options.debounce])
|
||||||
|
|
||||||
return [enabledLens, internalValueLens] as const
|
const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef)
|
||||||
}), [field, options.debounce])
|
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
|
||||||
|
|
||||||
const [enabled, setEnabled] = yield* Lens.useState(enabledLens)
|
|
||||||
const [value, setValue] = yield* Lens.useState(internalValueLens)
|
|
||||||
return { enabled, setEnabled, value, setValue }
|
return { enabled, setEnabled, value, setValue }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { Effect, Equivalence, Stream, SubscriptionRef } from "effect"
|
|
||||||
import { Lens } from "effect-lens"
|
|
||||||
import * as React from "react"
|
|
||||||
import * as Component from "./Component.js"
|
|
||||||
import * as SetStateAction from "./SetStateAction.js"
|
|
||||||
|
|
||||||
|
|
||||||
export * from "effect-lens/Lens"
|
|
||||||
|
|
||||||
export declare namespace useState {
|
|
||||||
export interface Options<A> {
|
|
||||||
readonly equivalence?: Equivalence.Equivalence<A>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useState = Effect.fnUntraced(function* <A, ER, EW, RR, RW>(
|
|
||||||
lens: Lens.Lens<A, ER, EW, RR, RW>,
|
|
||||||
options?: useState.Options<NoInfer<A>>,
|
|
||||||
): Effect.fn.Return<readonly [A, React.Dispatch<React.SetStateAction<A>>], ER, RR | RW> {
|
|
||||||
const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => Lens.get(lens)))
|
|
||||||
|
|
||||||
yield* Component.useReactEffect(() => Effect.forkScoped(
|
|
||||||
Stream.runForEach(
|
|
||||||
Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strict()),
|
|
||||||
v => Effect.sync(() => setReactStateValue(v)),
|
|
||||||
)
|
|
||||||
), [lens])
|
|
||||||
|
|
||||||
const setValue = yield* Component.useCallbackSync(
|
|
||||||
(setStateAction: React.SetStateAction<A>) => Effect.andThen(
|
|
||||||
Lens.updateAndGet(lens, prevState => SetStateAction.value(setStateAction, prevState)),
|
|
||||||
v => setReactStateValue(v),
|
|
||||||
),
|
|
||||||
[lens],
|
|
||||||
)
|
|
||||||
|
|
||||||
return [reactStateValue, setValue]
|
|
||||||
})
|
|
||||||
|
|
||||||
export declare namespace useFromReactState {
|
|
||||||
export interface Options<A> {
|
|
||||||
readonly equivalence?: Equivalence.Equivalence<A>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useFromReactState = Effect.fnUntraced(function* <A>(
|
|
||||||
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
|
|
||||||
options?: useFromReactState.Options<NoInfer<A>>,
|
|
||||||
): Effect.fn.Return<Lens.Lens<A, never, never, never, never>> {
|
|
||||||
const lens = yield* Component.useOnMount(() => Effect.map(
|
|
||||||
SubscriptionRef.make(value),
|
|
||||||
Lens.fromSubscriptionRef,
|
|
||||||
))
|
|
||||||
|
|
||||||
yield* Component.useReactEffect(() => Effect.forkScoped(Stream.runForEach(
|
|
||||||
Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strict()),
|
|
||||||
v => Effect.sync(() => setValue(v)),
|
|
||||||
)), [setValue])
|
|
||||||
yield* Component.useReactEffect(() => Lens.set(lens, value), [value])
|
|
||||||
|
|
||||||
return lens
|
|
||||||
})
|
|
||||||
@@ -1,111 +1,50 @@
|
|||||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||||
import { type Equivalence, Function, Predicate } from "effect"
|
import { type Equivalence, Function, Predicate } from "effect"
|
||||||
import * as React from "react"
|
|
||||||
import type * as Component from "./Component.js"
|
import type * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
|
export const TypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
|
||||||
export type MemoizedTypeId = typeof MemoizedTypeId
|
export type TypeId = typeof TypeId
|
||||||
|
|
||||||
|
export interface Memoized<P> extends Memoized.Options<P> {
|
||||||
/**
|
readonly [TypeId]: TypeId
|
||||||
* A trait for `Component`'s that uses `React.memo` to optimize re-renders based on prop equality.
|
|
||||||
*
|
|
||||||
* @template P The props type of the component
|
|
||||||
*/
|
|
||||||
export interface Memoized<P> extends MemoizedPrototype, MemoizedOptions<P> {}
|
|
||||||
|
|
||||||
export interface MemoizedPrototype {
|
|
||||||
readonly [MemoizedTypeId]: MemoizedTypeId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export namespace Memoized {
|
||||||
* Configuration options for Memoized components.
|
export interface Options<P> {
|
||||||
*
|
readonly propsAreEqual?: Equivalence.Equivalence<P>
|
||||||
* @template P The props type of the component
|
}
|
||||||
*/
|
|
||||||
export interface MemoizedOptions<P> {
|
|
||||||
/**
|
|
||||||
* An optional equivalence function for comparing component props.
|
|
||||||
* If provided, this function is used by React.memo to determine if props have changed.
|
|
||||||
* Returns `true` if props are equivalent (no re-render), `false` if they differ (re-render).
|
|
||||||
*/
|
|
||||||
readonly propsEquivalence?: Equivalence.Equivalence<P>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const MemoizedPrototype: MemoizedPrototype = Object.freeze({
|
const MemoizedProto = Object.freeze({
|
||||||
[MemoizedTypeId]: MemoizedTypeId,
|
[TypeId]: TypeId
|
||||||
|
|
||||||
transformFunctionComponent<P extends {}>(
|
|
||||||
this: Memoized<P>,
|
|
||||||
f: React.FC<P>,
|
|
||||||
) {
|
|
||||||
return React.memo(f, this.propsEquivalence)
|
|
||||||
},
|
|
||||||
} as const)
|
} as const)
|
||||||
|
|
||||||
|
|
||||||
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, MemoizedTypeId)
|
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, TypeId)
|
||||||
|
|
||||||
/**
|
|
||||||
* 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, any, any, any>>(
|
export const memoized = <T extends Component.Component<any, any, any, any>>(
|
||||||
self: T
|
self: T
|
||||||
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
|
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
|
||||||
Object.assign(function() {}, self),
|
Object.assign(function() {}, self),
|
||||||
Object.freeze(Object.setPrototypeOf(
|
Object.freeze(Object.setPrototypeOf(
|
||||||
Object.assign({}, MemoizedPrototype),
|
Object.assign({}, MemoizedProto),
|
||||||
Object.getPrototypeOf(self),
|
Object.getPrototypeOf(self),
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies options to a Memoized component, returning a new Memoized component with the updated configuration.
|
|
||||||
*
|
|
||||||
* Supports both curried and uncurried application styles.
|
|
||||||
*
|
|
||||||
* @param self - The Memoized component to apply options to (in uncurried form)
|
|
||||||
* @param options - The options to apply to the component
|
|
||||||
* @returns A Memoized component with the applied options
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // Curried
|
|
||||||
* const MyMemoizedComponent = MyComponent.pipe(
|
|
||||||
* Memoized.memoized,
|
|
||||||
* Memoized.withOptions({ propsEquivalence: (a, b) => a.id === b.id }),
|
|
||||||
* )
|
|
||||||
*
|
|
||||||
* // Uncurried
|
|
||||||
* const MyMemoizedComponent = Memoized.withOptions(
|
|
||||||
* Memoized.memoized(MyComponent),
|
|
||||||
* { propsEquivalence: (a, b) => a.id === b.id },
|
|
||||||
* )
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const withOptions: {
|
export const withOptions: {
|
||||||
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>
|
options: Partial<Memoized.Options<Component.Component.Props<T>>>
|
||||||
): (self: T) => T
|
): (self: T) => T
|
||||||
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||||
self: T,
|
self: T,
|
||||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
|
options: Partial<Memoized.Options<Component.Component.Props<T>>>,
|
||||||
): T
|
): T
|
||||||
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||||
self: T,
|
self: T,
|
||||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
|
options: Partial<Memoized.Options<Component.Component.Props<T>>>,
|
||||||
): T => Object.setPrototypeOf(
|
): T => Object.setPrototypeOf(
|
||||||
Object.assign(function() {}, self, options),
|
Object.assign(function() {}, self, options),
|
||||||
Object.getPrototypeOf(self),
|
Object.getPrototypeOf(self),
|
||||||
|
|||||||
@@ -99,10 +99,8 @@ extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const isMutation = (u: unknown): u is Mutation<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
|
export const isMutation = (u: unknown): u is Mutation<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
|
||||||
|
|
||||||
|
|
||||||
export declare namespace make {
|
export declare namespace make {
|
||||||
export interface Options<K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never> {
|
export interface Options<K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never> {
|
||||||
readonly f: (key: K) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
|
readonly f: (key: K) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
|
||||||
@@ -113,12 +111,12 @@ export declare namespace make {
|
|||||||
export const make = Effect.fnUntraced(function* <const K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never>(
|
export const make = Effect.fnUntraced(function* <const K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never>(
|
||||||
options: make.Options<K, A, E, R, P>
|
options: make.Options<K, A, E, R, P>
|
||||||
): Effect.fn.Return<
|
): Effect.fn.Return<
|
||||||
Mutation<K, A, E, Result.forkEffect.OutputContext<R, P>, P>,
|
Mutation<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
|
||||||
never,
|
never,
|
||||||
Scope.Scope | Result.forkEffect.OutputContext<R, P>
|
Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>
|
||||||
> {
|
> {
|
||||||
return new MutationImpl(
|
return new MutationImpl(
|
||||||
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<R, P>>(),
|
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>>(),
|
||||||
options.f as any,
|
options.f as any,
|
||||||
options.initialProgress as P,
|
options.initialProgress as P,
|
||||||
|
|
||||||
|
|||||||
98
packages/effect-fc/src/PropertyPath.ts
Normal file
98
packages/effect-fc/src/PropertyPath.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Array, Equivalence, Function, Option, Predicate } from "effect"
|
||||||
|
|
||||||
|
|
||||||
|
export type PropertyPath = readonly PropertyKey[]
|
||||||
|
|
||||||
|
type Prev = readonly [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||||
|
|
||||||
|
export type Paths<T, D extends number = 5, Seen = never> = readonly [] | (
|
||||||
|
D extends never ? readonly [] :
|
||||||
|
T extends Seen ? readonly [] :
|
||||||
|
T extends readonly any[] ? {
|
||||||
|
[K in keyof T as K extends number ? K : never]:
|
||||||
|
| readonly [K]
|
||||||
|
| readonly [K, ...Paths<T[K], Prev[D], Seen | T>]
|
||||||
|
} extends infer O
|
||||||
|
? O[keyof O]
|
||||||
|
: never
|
||||||
|
:
|
||||||
|
T extends object ? {
|
||||||
|
[K in keyof T as K extends string | number | symbol ? K : never]-?:
|
||||||
|
NonNullable<T[K]> extends infer V
|
||||||
|
? readonly [K] | readonly [K, ...Paths<V, Prev[D], Seen>]
|
||||||
|
: never
|
||||||
|
} extends infer O
|
||||||
|
? O[keyof O]
|
||||||
|
: never
|
||||||
|
:
|
||||||
|
never
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ValueFromPath<T, P extends readonly any[]> = P extends readonly [infer Head, ...infer Tail]
|
||||||
|
? Head extends keyof T
|
||||||
|
? ValueFromPath<T[Head], Tail>
|
||||||
|
: T extends readonly any[]
|
||||||
|
? Head extends number
|
||||||
|
? ValueFromPath<T[number], Tail>
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
: T
|
||||||
|
|
||||||
|
|
||||||
|
export const equivalence: Equivalence.Equivalence<PropertyPath> = Equivalence.array(Equivalence.strict())
|
||||||
|
|
||||||
|
export const unsafeGet: {
|
||||||
|
<T, const P extends Paths<T>>(path: P): (self: T) => ValueFromPath<T, P>
|
||||||
|
<T, const P extends Paths<T>>(self: T, path: P): ValueFromPath<T, P>
|
||||||
|
} = Function.dual(2, <T, const P extends Paths<T>>(self: T, path: P): ValueFromPath<T, P> =>
|
||||||
|
path.reduce((acc: any, key: any) => acc?.[key], self)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const get: {
|
||||||
|
<T, const P extends Paths<T>>(path: P): (self: T) => Option.Option<ValueFromPath<T, P>>
|
||||||
|
<T, const P extends Paths<T>>(self: T, path: P): Option.Option<ValueFromPath<T, P>>
|
||||||
|
} = Function.dual(2, <T, const P extends Paths<T>>(self: T, path: P): Option.Option<ValueFromPath<T, P>> =>
|
||||||
|
path.reduce(
|
||||||
|
(acc: Option.Option<any>, key: any): Option.Option<any> => Option.isSome(acc)
|
||||||
|
? Predicate.hasProperty(acc.value, key)
|
||||||
|
? Option.some(acc.value[key])
|
||||||
|
: Option.none()
|
||||||
|
: acc,
|
||||||
|
|
||||||
|
Option.some(self),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const immutableSet: {
|
||||||
|
<T, const P extends Paths<T>>(path: P, value: ValueFromPath<T, P>): (self: T) => Option.Option<T>
|
||||||
|
<T, const P extends Paths<T>>(self: T, path: P, value: ValueFromPath<T, P>): Option.Option<T>
|
||||||
|
} = Function.dual(3, <T, const P extends Paths<T>>(self: T, path: P, value: ValueFromPath<T, P>): Option.Option<T> => {
|
||||||
|
const key = Array.head(path as PropertyPath)
|
||||||
|
if (Option.isNone(key))
|
||||||
|
return Option.some(value as T)
|
||||||
|
if (!Predicate.hasProperty(self, key.value))
|
||||||
|
return Option.none()
|
||||||
|
|
||||||
|
const child = immutableSet<any, any>(self[key.value], Option.getOrThrow(Array.tail(path as PropertyPath)), value)
|
||||||
|
if (Option.isNone(child))
|
||||||
|
return child
|
||||||
|
|
||||||
|
if (Array.isArray(self))
|
||||||
|
return typeof key.value === "number"
|
||||||
|
? Option.some([
|
||||||
|
...self.slice(0, key.value),
|
||||||
|
child.value,
|
||||||
|
...self.slice(key.value + 1),
|
||||||
|
] as T)
|
||||||
|
: Option.none()
|
||||||
|
|
||||||
|
if (typeof self === "object")
|
||||||
|
return Option.some(
|
||||||
|
Object.assign(
|
||||||
|
Object.create(Object.getPrototypeOf(self)),
|
||||||
|
{ ...self, [key.value]: child.value },
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return Option.none()
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@ import type * as React from "react"
|
|||||||
import * as Component from "./Component.js"
|
import * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
export const useFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
|
export const usePubSubFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
|
||||||
values: A
|
values: A
|
||||||
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
|
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
|
||||||
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
|
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import * as Result from "./Result.js"
|
|||||||
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
|
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
|
||||||
export type QueryTypeId = typeof QueryTypeId
|
export type QueryTypeId = typeof QueryTypeId
|
||||||
|
|
||||||
export interface Query<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never, in out P = never>
|
export interface Query<in out K extends Query.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
|
||||||
extends Pipeable.Pipeable {
|
extends Pipeable.Pipeable {
|
||||||
readonly [QueryTypeId]: QueryTypeId
|
readonly [QueryTypeId]: QueryTypeId
|
||||||
|
|
||||||
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>
|
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>
|
||||||
readonly key: Stream.Stream<K, KE, KR>
|
readonly key: Stream.Stream<K>
|
||||||
readonly f: (key: K) => Effect.Effect<A, E, R>
|
readonly f: (key: K) => Effect.Effect<A, E, R>
|
||||||
readonly initialProgress: P
|
readonly initialProgress: P
|
||||||
|
|
||||||
@@ -37,13 +37,13 @@ export declare namespace Query {
|
|||||||
export type AnyKey = readonly any[]
|
export type AnyKey = readonly any[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class QueryImpl<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never, in out P = never>
|
export class QueryImpl<in out K extends Query.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
|
||||||
extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
|
extends Pipeable.Class() implements Query<K, A, E, R, P> {
|
||||||
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
|
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>,
|
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>,
|
||||||
readonly key: Stream.Stream<K, KE, KR>,
|
readonly key: Stream.Stream<K>,
|
||||||
readonly f: (key: K) => Effect.Effect<A, E, R>,
|
readonly f: (key: K) => Effect.Effect<A, E, R>,
|
||||||
readonly initialProgress: P,
|
readonly initialProgress: P,
|
||||||
|
|
||||||
@@ -77,7 +77,6 @@ extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
|
|||||||
], { concurrency: "unbounded" }).pipe(
|
], { concurrency: "unbounded" }).pipe(
|
||||||
Effect.ignore,
|
Effect.ignore,
|
||||||
this.runSemaphore.withPermits(1),
|
this.runSemaphore.withPermits(1),
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,13 +265,11 @@ extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId)
|
||||||
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown> => Predicate.hasProperty(u, QueryTypeId)
|
|
||||||
|
|
||||||
|
|
||||||
export declare namespace make {
|
export declare namespace make {
|
||||||
export interface Options<K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never> {
|
export interface Options<K extends Query.AnyKey, A, E = never, R = never, P = never> {
|
||||||
readonly key: Stream.Stream<K, KE, KR>
|
readonly key: Stream.Stream<K>
|
||||||
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
|
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
|
||||||
readonly initialProgress?: P
|
readonly initialProgress?: P
|
||||||
readonly staleTime?: Duration.DurationInput
|
readonly staleTime?: Duration.DurationInput
|
||||||
@@ -280,17 +277,17 @@ export declare namespace make {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
|
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, E = never, R = never, P = never>(
|
||||||
options: make.Options<K, A, KE, KR, E, R, P>
|
options: make.Options<K, A, E, R, P>
|
||||||
): Effect.fn.Return<
|
): Effect.fn.Return<
|
||||||
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
|
Query<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
|
||||||
never,
|
never,
|
||||||
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
|
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>
|
||||||
> {
|
> {
|
||||||
const client = yield* QueryClient.QueryClient
|
const client = yield* QueryClient.QueryClient
|
||||||
|
|
||||||
return new QueryImpl<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>(
|
return new QueryImpl(
|
||||||
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>>(),
|
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>>(),
|
||||||
options.key,
|
options.key,
|
||||||
options.f as any,
|
options.f as any,
|
||||||
options.initialProgress as P,
|
options.initialProgress as P,
|
||||||
@@ -307,12 +304,12 @@ export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE =
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const service = <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
|
export const service = <K extends Query.AnyKey, A, E = never, R = never, P = never>(
|
||||||
options: make.Options<K, A, KE, KR, E, R, P>
|
options: make.Options<K, A, E, R, P>
|
||||||
): Effect.Effect<
|
): Effect.Effect<
|
||||||
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
|
Query<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
|
||||||
never,
|
never,
|
||||||
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
|
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>
|
||||||
> => Effect.tap(
|
> => Effect.tap(
|
||||||
make(options),
|
make(options),
|
||||||
query => Effect.forkScoped(query.run),
|
query => Effect.forkScoped(query.run),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, type Subscribable, SynchronizedRef } from "effect"
|
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, Subscribable } from "effect"
|
||||||
import { Lens } from "effect-lens"
|
|
||||||
|
|
||||||
|
|
||||||
export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result")
|
export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result")
|
||||||
@@ -16,6 +15,10 @@ export type Final<A, E = never, P = never> = (Success<A> | Failure<E>) & ({} | F
|
|||||||
export type Flags<P = never> = WillFetch | WillRefresh | Refreshing<P>
|
export type Flags<P = never> = WillFetch | WillRefresh | Refreshing<P>
|
||||||
|
|
||||||
export declare namespace Result {
|
export declare namespace Result {
|
||||||
|
export interface Prototype extends Pipeable.Pipeable, Equal.Equal {
|
||||||
|
readonly [ResultTypeId]: ResultTypeId
|
||||||
|
}
|
||||||
|
|
||||||
export type Success<R extends Result<any, any, any>> = [R] extends [Result<infer A, infer _E, infer _P>] ? A : never
|
export type Success<R extends Result<any, any, any>> = [R] extends [Result<infer A, infer _E, infer _P>] ? A : never
|
||||||
export type Failure<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer E, infer _P>] ? E : never
|
export type Failure<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer E, infer _P>] ? E : never
|
||||||
export type Progress<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer _E, infer P>] ? P : never
|
export type Progress<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer _E, infer P>] ? P : never
|
||||||
@@ -25,21 +28,21 @@ export declare namespace Flags {
|
|||||||
export type Keys = keyof WillFetch & WillRefresh & Refreshing<any>
|
export type Keys = keyof WillFetch & WillRefresh & Refreshing<any>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Initial extends ResultPrototype {
|
export interface Initial extends Result.Prototype {
|
||||||
readonly _tag: "Initial"
|
readonly _tag: "Initial"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Running<P = never> extends ResultPrototype {
|
export interface Running<P = never> extends Result.Prototype {
|
||||||
readonly _tag: "Running"
|
readonly _tag: "Running"
|
||||||
readonly progress: P
|
readonly progress: P
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Success<A> extends ResultPrototype {
|
export interface Success<A> extends Result.Prototype {
|
||||||
readonly _tag: "Success"
|
readonly _tag: "Success"
|
||||||
readonly value: A
|
readonly value: A
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Failure<E = never> extends ResultPrototype {
|
export interface Failure<E = never> extends Result.Prototype {
|
||||||
readonly _tag: "Failure"
|
readonly _tag: "Failure"
|
||||||
readonly cause: Cause.Cause<E>
|
readonly cause: Cause.Cause<E>
|
||||||
}
|
}
|
||||||
@@ -58,11 +61,7 @@ export interface Refreshing<P = never> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface ResultPrototype extends Pipeable.Pipeable, Equal.Equal {
|
const ResultPrototype = Object.freeze({
|
||||||
readonly [ResultTypeId]: ResultTypeId
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ResultPrototype: ResultPrototype = Object.freeze({
|
|
||||||
...Pipeable.Prototype,
|
...Pipeable.Prototype,
|
||||||
[ResultTypeId]: ResultTypeId,
|
[ResultTypeId]: ResultTypeId,
|
||||||
|
|
||||||
@@ -96,7 +95,7 @@ export const ResultPrototype: ResultPrototype = Object.freeze({
|
|||||||
Hash.cached(this),
|
Hash.cached(this),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
} as const)
|
} as const satisfies Result.Prototype)
|
||||||
|
|
||||||
|
|
||||||
export const isResult = (u: unknown): u is Result<unknown, unknown, unknown> => Predicate.hasProperty(u, ResultTypeId)
|
export const isResult = (u: unknown): u is Result<unknown, unknown, unknown> => Predicate.hasProperty(u, ResultTypeId)
|
||||||
@@ -163,40 +162,52 @@ export const toExit: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface Progress<P = never> {
|
export interface State<A, E = never, P = never> {
|
||||||
readonly progress: Lens.Lens<P, PreviousResultNotRunningNorRefreshing, never, never, never>
|
readonly get: Effect.Effect<Result<A, E, P>>
|
||||||
|
readonly set: (v: Result<A, E, P>) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const State = <A, E = never, P = never>(): Context.Tag<State<A, E, P>, State<A, E, P>> => Context.GenericTag("@effect-fc/Result/State")
|
||||||
|
|
||||||
|
export interface Progress<P = never> {
|
||||||
|
readonly update: <E, R>(
|
||||||
|
f: (previous: P) => Effect.Effect<P, E, R>
|
||||||
|
) => Effect.Effect<void, PreviousResultNotRunningNorRefreshing | E, R>
|
||||||
}
|
}
|
||||||
export const Progress = <P = never>(): Context.Tag<Progress<P>, Progress<P>> => Context.GenericTag("@effect-fc/Result/Progress")
|
|
||||||
|
|
||||||
export class PreviousResultNotRunningNorRefreshing extends Data.TaggedError("@effect-fc/Result/PreviousResultNotRunningNorRefreshing")<{
|
export class PreviousResultNotRunningNorRefreshing extends Data.TaggedError("@effect-fc/Result/PreviousResultNotRunningNorRefreshing")<{
|
||||||
readonly previous: Result<unknown, unknown, unknown>
|
readonly previous: Result<unknown, unknown, unknown>
|
||||||
}> {}
|
}> {}
|
||||||
|
|
||||||
export const makeProgressLayer = <A, E, P = never>(
|
export const Progress = <P = never>(): Context.Tag<Progress<P>, Progress<P>> => Context.GenericTag("@effect-fc/Result/Progress")
|
||||||
state: Lens.Lens<Result<A, E, P>, never, never, never, never>
|
|
||||||
): Layer.Layer<Progress<P> | Progress<never>, never, never> => Layer.succeed(
|
export const makeProgressLayer = <A, E, P = never>(): Layer.Layer<
|
||||||
Progress<P>() as Context.Tag<Progress<P> | Progress<never>, Progress<P> | Progress<never>>,
|
Progress<P>,
|
||||||
{
|
never,
|
||||||
progress: state.pipe(
|
State<A, E, P>
|
||||||
Lens.mapEffect(
|
> => Layer.effect(Progress<P>(), Effect.gen(function*() {
|
||||||
a => (isRunning(a) || hasRefreshingFlag(a))
|
const state = yield* State<A, E, P>()
|
||||||
? Effect.succeed(a)
|
|
||||||
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous: a })),
|
return {
|
||||||
(_, b) => Effect.succeed(b),
|
update: <FE, FR>(f: (previous: P) => Effect.Effect<P, FE, FR>) => Effect.Do.pipe(
|
||||||
|
Effect.bind("previous", () => Effect.andThen(state.get, previous =>
|
||||||
|
(isRunning(previous) || hasRefreshingFlag(previous))
|
||||||
|
? Effect.succeed(previous)
|
||||||
|
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous })),
|
||||||
|
)),
|
||||||
|
Effect.bind("progress", ({ previous }) => f(previous.progress)),
|
||||||
|
Effect.let("next", ({ previous, progress }) => isRunning(previous)
|
||||||
|
? running(progress)
|
||||||
|
: refreshing(previous, progress) as Final<A, E, P> & Refreshing<P>
|
||||||
),
|
),
|
||||||
Lens.map(
|
Effect.andThen(({ next }) => state.set(next)),
|
||||||
a => a.progress,
|
),
|
||||||
(a, b) => isRunning(a)
|
}
|
||||||
? running(b)
|
}))
|
||||||
: refreshing(a, b) as Final<A, E, P> & Refreshing<P>,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
export namespace unsafeForkEffect {
|
export namespace unsafeForkEffect {
|
||||||
export type OutputContext<R, P> = Exclude<R, Progress<P> | Progress<never>>
|
export type OutputContext<A, E, R, P> = Exclude<R, State<A, E, P> | Progress<P> | Progress<never>>
|
||||||
|
|
||||||
export interface Options<A, E, P> {
|
export interface Options<A, E, P> {
|
||||||
readonly initial?: Initial | Final<A, E, P>
|
readonly initial?: Initial | Final<A, E, P>
|
||||||
@@ -204,56 +215,55 @@ export namespace unsafeForkEffect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const unsafeForkEffect = Effect.fnUntraced(function* <A, E, R, P = never>(
|
export const unsafeForkEffect = <A, E, R, P = never>(
|
||||||
effect: Effect.Effect<A, E, R>,
|
effect: Effect.Effect<A, E, R>,
|
||||||
options?: unsafeForkEffect.Options<NoInfer<A>, NoInfer<E>, P>,
|
options?: unsafeForkEffect.Options<NoInfer<A>, NoInfer<E>, P>,
|
||||||
): Effect.fn.Return<
|
): Effect.Effect<
|
||||||
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
|
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
|
||||||
never,
|
never,
|
||||||
Scope.Scope | unsafeForkEffect.OutputContext<R, P>
|
Scope.Scope | unsafeForkEffect.OutputContext<A, E, R, P>
|
||||||
> {
|
> => Effect.Do.pipe(
|
||||||
const ref = yield* SynchronizedRef.make<Result<A, E, P>>(options?.initial ?? initial<A, E, P>())
|
Effect.bind("ref", () => Ref.make(options?.initial ?? initial<A, E, P>())),
|
||||||
const pubsub = yield* PubSub.unbounded<Result<A, E, P>>()
|
Effect.bind("pubsub", () => PubSub.unbounded<Result<A, E, P>>()),
|
||||||
|
Effect.bind("fiber", ({ ref, pubsub }) => Effect.forkScoped(State<A, E, P>().pipe(
|
||||||
const state = Lens.make<Result<A, E, P>, never, never, never, never>({
|
Effect.andThen(state => state.set(
|
||||||
get get() { return Ref.get(ref) },
|
|
||||||
get changes() {
|
|
||||||
return Stream.unwrapScoped(Effect.map(
|
|
||||||
Effect.all([Ref.get(ref), Stream.fromPubSub(pubsub, { scoped: true })]),
|
|
||||||
([latest, stream]) => Stream.concat(Stream.make(latest), stream),
|
|
||||||
))
|
|
||||||
},
|
|
||||||
modify: f => Ref.get(ref).pipe(
|
|
||||||
Effect.flatMap(f),
|
|
||||||
Effect.flatMap(([b, a]) => Ref.set(ref, a).pipe(
|
|
||||||
Effect.as(b),
|
|
||||||
Effect.zipLeft(PubSub.publish(pubsub, a))
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
const fiber = yield* Effect.gen(function*() {
|
|
||||||
yield* Lens.set(
|
|
||||||
state,
|
|
||||||
(isFinal(options?.initial) && hasWillRefreshFlag(options?.initial))
|
(isFinal(options?.initial) && hasWillRefreshFlag(options?.initial))
|
||||||
? refreshing(options.initial, options?.initialProgress) as Result<A, E, P>
|
? refreshing(options.initial, options?.initialProgress) as Result<A, E, P>
|
||||||
: running(options?.initialProgress),
|
: running(options?.initialProgress)
|
||||||
)
|
).pipe(
|
||||||
return yield* Effect.onExit(effect, exit => Effect.andThen(
|
Effect.andThen(effect),
|
||||||
Lens.set(state, fromExit(exit)),
|
Effect.onExit(exit => Effect.andThen(
|
||||||
Effect.forkScoped(PubSub.shutdown(pubsub)),
|
state.set(fromExit(exit)),
|
||||||
))
|
Effect.forkScoped(PubSub.shutdown(pubsub)),
|
||||||
}).pipe(
|
)),
|
||||||
Effect.forkScoped,
|
)),
|
||||||
Effect.provide(makeProgressLayer(state)),
|
Effect.provide(Layer.empty.pipe(
|
||||||
)
|
Layer.provideMerge(makeProgressLayer<A, E, P>()),
|
||||||
|
Layer.provideMerge(Layer.succeed(State<A, E, P>(), {
|
||||||
return [state, fiber] as const
|
get: ref,
|
||||||
})
|
set: v => Effect.andThen(Ref.set(ref, v), PubSub.publish(pubsub, v))
|
||||||
|
})),
|
||||||
|
)),
|
||||||
|
))),
|
||||||
|
Effect.map(({ ref, pubsub, fiber }) => [
|
||||||
|
Subscribable.make({
|
||||||
|
get: ref,
|
||||||
|
changes: Stream.unwrapScoped(Effect.map(
|
||||||
|
Effect.all([ref, Stream.fromPubSub(pubsub, { scoped: true })]),
|
||||||
|
([latest, stream]) => Stream.concat(Stream.make(latest), stream),
|
||||||
|
)),
|
||||||
|
}),
|
||||||
|
fiber,
|
||||||
|
]),
|
||||||
|
) as Effect.Effect<
|
||||||
|
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
|
||||||
|
never,
|
||||||
|
Scope.Scope | unsafeForkEffect.OutputContext<A, E, R, P>
|
||||||
|
>
|
||||||
|
|
||||||
export namespace forkEffect {
|
export namespace forkEffect {
|
||||||
export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R
|
export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R
|
||||||
export type OutputContext<R, P> = unsafeForkEffect.OutputContext<R, P>
|
export type OutputContext<A, E, R, P> = unsafeForkEffect.OutputContext<A, E, R, P>
|
||||||
export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {}
|
export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +274,6 @@ export const forkEffect: {
|
|||||||
): Effect.Effect<
|
): Effect.Effect<
|
||||||
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
|
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
|
||||||
never,
|
never,
|
||||||
Scope.Scope | forkEffect.OutputContext<R, P>
|
Scope.Scope | forkEffect.OutputContext<A, E, R, P>
|
||||||
>
|
>
|
||||||
} = unsafeForkEffect
|
} = unsafeForkEffect
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import type * as React from "react"
|
|||||||
|
|
||||||
|
|
||||||
export const value: {
|
export const value: {
|
||||||
<S>(self: React.SetStateAction<S>, prevState: S): S
|
|
||||||
<S>(prevState: S): (self: React.SetStateAction<S>) => S
|
<S>(prevState: S): (self: React.SetStateAction<S>) => S
|
||||||
|
<S>(self: React.SetStateAction<S>, prevState: S): S
|
||||||
} = Function.dual(2, <S>(self: React.SetStateAction<S>, prevState: S): S =>
|
} = Function.dual(2, <S>(self: React.SetStateAction<S>, prevState: S): S =>
|
||||||
typeof self === "function"
|
typeof self === "function"
|
||||||
? (self as (prevState: S) => S)(prevState)
|
? (self as (prevState: S) => S)(prevState)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as React from "react"
|
|||||||
import * as Component from "./Component.js"
|
import * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
export const use: {
|
export const useStream: {
|
||||||
<A, E, R>(
|
<A, E, R>(
|
||||||
stream: Stream.Stream<A, E, R>
|
stream: Stream.Stream<A, E, R>
|
||||||
): Effect.Effect<Option.Option<A>, never, R>
|
): Effect.Effect<Option.Option<A>, never, R>
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
import { Array, Cause, Chunk, type Context, Effect, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Schema, type Scope, SubscriptionRef } from "effect"
|
|
||||||
import * as Form from "./Form.js"
|
|
||||||
import * as Lens from "./Lens.js"
|
|
||||||
import * as Mutation from "./Mutation.js"
|
|
||||||
import * as Result from "./Result.js"
|
|
||||||
import * as Subscribable from "./Subscribable.js"
|
|
||||||
|
|
||||||
|
|
||||||
export const SubmittableFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SubmittableForm")
|
|
||||||
export type SubmittableFormTypeId = typeof SubmittableFormTypeId
|
|
||||||
|
|
||||||
export interface SubmittableForm<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
|
||||||
extends Form.Form<readonly [], A, I, never, never> {
|
|
||||||
readonly [SubmittableFormTypeId]: SubmittableFormTypeId
|
|
||||||
|
|
||||||
readonly schema: Schema.Schema<A, I, R>
|
|
||||||
readonly context: Context.Context<Scope.Scope | R>
|
|
||||||
readonly mutation: Mutation.Mutation<
|
|
||||||
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
|
|
||||||
MA, ME, MR, MP
|
|
||||||
>
|
|
||||||
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never>
|
|
||||||
|
|
||||||
readonly run: Effect.Effect<void>
|
|
||||||
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SubmittableFormImpl<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
|
||||||
extends Pipeable.Class() implements SubmittableForm<A, I, R, MA, ME, MR, MP> {
|
|
||||||
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
|
|
||||||
readonly [SubmittableFormTypeId]: SubmittableFormTypeId = SubmittableFormTypeId
|
|
||||||
|
|
||||||
readonly path = [] as const
|
|
||||||
|
|
||||||
readonly encodedValue: Lens.Lens<I, never, never, never, never>
|
|
||||||
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
|
|
||||||
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
|
|
||||||
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
readonly schema: Schema.Schema<A, I, R>,
|
|
||||||
readonly context: Context.Context<Scope.Scope | R>,
|
|
||||||
readonly mutation: Mutation.Mutation<
|
|
||||||
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
|
|
||||||
MA, ME, MR, MP
|
|
||||||
>,
|
|
||||||
readonly value: Lens.Lens<Option.Option<A>, never, never, never, never>,
|
|
||||||
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
|
|
||||||
readonly issues: Lens.Lens<readonly ParseResult.ArrayFormatterIssue[], never, never, never, never>,
|
|
||||||
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never, never, never>,
|
|
||||||
|
|
||||||
readonly runSemaphore: Effect.Semaphore,
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
|
|
||||||
this.encodedValue = Effect.succeed(this).pipe(
|
|
||||||
Effect.map(self => Lens.make<I, never, never, never, never>({
|
|
||||||
get get() { return self.internalEncodedValue.get },
|
|
||||||
get changes() { return self.internalEncodedValue.changes },
|
|
||||||
modify: f => self.internalEncodedValue.modify(
|
|
||||||
encodedValue => Effect.map(
|
|
||||||
f(encodedValue),
|
|
||||||
([b, nextEncodedValue]) => [
|
|
||||||
[b, nextEncodedValue] as const,
|
|
||||||
nextEncodedValue,
|
|
||||||
] as const,
|
|
||||||
)
|
|
||||||
).pipe(
|
|
||||||
Effect.tap(([, nextEncodedValue]) =>
|
|
||||||
self.synchronizeEncodedValue(nextEncodedValue).pipe(
|
|
||||||
Effect.forkScoped,
|
|
||||||
Effect.provide(self.context),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
Effect.map(([b]) => b),
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
Lens.unwrap,
|
|
||||||
)
|
|
||||||
this.isValidating = Effect.succeed(this).pipe(
|
|
||||||
Effect.map(self => Subscribable.map(self.validationFiber, Option.isSome)),
|
|
||||||
Subscribable.unwrap,
|
|
||||||
)
|
|
||||||
this.canCommit = Effect.succeed(this).pipe(
|
|
||||||
Effect.map(self => Subscribable.map(
|
|
||||||
Subscribable.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.result),
|
|
||||||
([value, issues, validationFiber, result]) => (
|
|
||||||
Option.isSome(value) &&
|
|
||||||
Array.isEmptyReadonlyArray(issues) &&
|
|
||||||
Option.isNone(validationFiber) &&
|
|
||||||
!(Result.isRunning(result) || Result.hasRefreshingFlag(result))
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
Subscribable.unwrap,
|
|
||||||
)
|
|
||||||
this.isCommitting = Effect.succeed(this).pipe(
|
|
||||||
Effect.map(self => Subscribable.map(
|
|
||||||
self.mutation.result,
|
|
||||||
result => Result.isRunning(result) || Result.hasRefreshingFlag(result),
|
|
||||||
)),
|
|
||||||
Subscribable.unwrap,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, never, never> {
|
|
||||||
return Lens.get(this.validationFiber).pipe(
|
|
||||||
Effect.andThen(Option.match({
|
|
||||||
onSome: Fiber.interrupt,
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
})),
|
|
||||||
Effect.andThen(Effect.forkScoped(
|
|
||||||
Effect.ensuring(
|
|
||||||
Schema.decode(this.schema, { errors: "all" })(encodedValue),
|
|
||||||
Lens.set(this.validationFiber, Option.none()),
|
|
||||||
)
|
|
||||||
)),
|
|
||||||
Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
|
|
||||||
Effect.flatMap(Fiber.join),
|
|
||||||
|
|
||||||
Effect.tap(() => Lens.set(this.issues, Array.empty())),
|
|
||||||
Effect.flatMap(value => Lens.set(this.value, Option.some(value))),
|
|
||||||
Effect.catchIf(
|
|
||||||
ParseResult.isParseError,
|
|
||||||
flow(
|
|
||||||
ParseResult.ArrayFormatter.formatError,
|
|
||||||
Effect.flatMap(v => Lens.set(this.issues, v)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get run(): Effect.Effect<void, never, never> {
|
|
||||||
return Lens.get(this.encodedValue).pipe(
|
|
||||||
Effect.flatMap(v => Schema.decode(this.schema)(v)),
|
|
||||||
Effect.option,
|
|
||||||
Effect.flatMap(v => Lens.set(this.value, v)),
|
|
||||||
Effect.provide(this.context),
|
|
||||||
this.runSemaphore.withPermits(1),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException, never> {
|
|
||||||
return Lens.get(this.value).pipe(
|
|
||||||
Effect.flatMap(identity),
|
|
||||||
Effect.flatMap(value => this.submitValue(value)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, never, never> {
|
|
||||||
return Effect.whenEffect(
|
|
||||||
Effect.tap(
|
|
||||||
this.mutation.mutate([value, this as any]),
|
|
||||||
result => Result.isFailure(result)
|
|
||||||
? Option.match(
|
|
||||||
Chunk.findFirst(
|
|
||||||
Cause.failures(result.cause as Cause.Cause<ParseResult.ParseError>),
|
|
||||||
e => e._tag === "ParseError",
|
|
||||||
),
|
|
||||||
{
|
|
||||||
onSome: e => Effect.flatMap(
|
|
||||||
ParseResult.ArrayFormatter.formatError(e),
|
|
||||||
v => Lens.set(this.issues, v),
|
|
||||||
),
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: Effect.void
|
|
||||||
),
|
|
||||||
this.canCommit.get,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const isSubmittableForm = (u: unknown): u is SubmittableForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SubmittableFormTypeId)
|
|
||||||
|
|
||||||
|
|
||||||
export declare namespace make {
|
|
||||||
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
|
||||||
extends Mutation.make.Options<
|
|
||||||
readonly [value: NoInfer<A>, form: SubmittableForm<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>],
|
|
||||||
MA, ME, MR, MP
|
|
||||||
> {
|
|
||||||
readonly schema: Schema.Schema<A, I, R>
|
|
||||||
readonly initialEncodedValue: NoInfer<I>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
|
|
||||||
options: make.Options<A, I, R, MA, ME, MR, MP>
|
|
||||||
): Effect.fn.Return<
|
|
||||||
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
|
|
||||||
never,
|
|
||||||
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
|
|
||||||
> {
|
|
||||||
return new SubmittableFormImpl(
|
|
||||||
options.schema,
|
|
||||||
yield* Effect.context<Scope.Scope | R>(),
|
|
||||||
yield* Mutation.make(options),
|
|
||||||
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<A>())),
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)),
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly ParseResult.ArrayFormatterIssue[]>(Array.empty())),
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>())),
|
|
||||||
|
|
||||||
yield* Effect.makeSemaphore(1),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export declare namespace service {
|
|
||||||
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
|
||||||
extends make.Options<A, I, R, MA, ME, MR, MP> {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const service = <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
|
|
||||||
options: service.Options<A, I, R, MA, ME, MR, MP>
|
|
||||||
): Effect.Effect<
|
|
||||||
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
|
|
||||||
never,
|
|
||||||
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
|
|
||||||
> => Effect.tap(
|
|
||||||
make(options),
|
|
||||||
form => Effect.forkScoped(form.run),
|
|
||||||
)
|
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Effect, Equivalence, Stream } from "effect"
|
import { Effect, Equivalence, Stream, Subscribable } from "effect"
|
||||||
import { Subscribable } from "effect-lens"
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
export * from "effect-lens/Subscribable"
|
|
||||||
|
|
||||||
export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
|
export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
|
||||||
...elements: T
|
...elements: T
|
||||||
): Subscribable.Subscribable<
|
): Subscribable.Subscribable<
|
||||||
@@ -19,7 +16,7 @@ export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<
|
|||||||
changes: Stream.zipLatestAll(...elements.map(v => v.changes)),
|
changes: Stream.zipLatestAll(...elements.map(v => v.changes)),
|
||||||
}) as any
|
}) as any
|
||||||
|
|
||||||
export declare namespace useAll {
|
export declare namespace useSubscribables {
|
||||||
export type Success<T extends readonly Subscribable.Subscribable<any, any, any>[]> = [T[number]] extends [never]
|
export type Success<T extends readonly Subscribable.Subscribable<any, any, any>[]> = [T[number]] extends [never]
|
||||||
? never
|
? never
|
||||||
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never }
|
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never }
|
||||||
@@ -29,11 +26,11 @@ export declare namespace useAll {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAll = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
|
export const useSubscribables = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
|
||||||
elements: T,
|
elements: T,
|
||||||
options?: useAll.Options<useAll.Success<NoInfer<T>>>,
|
options?: useSubscribables.Options<useSubscribables.Success<NoInfer<T>>>,
|
||||||
): Effect.fn.Return<
|
): Effect.fn.Return<
|
||||||
useAll.Success<T>,
|
useSubscribables.Success<T>,
|
||||||
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer E, infer _R> ? E : never,
|
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer E, infer _R> ? E : never,
|
||||||
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
|
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
|
||||||
> {
|
> {
|
||||||
@@ -51,3 +48,5 @@ export const useAll = Effect.fnUntraced(function* <const T extends readonly Subs
|
|||||||
|
|
||||||
return reactStateValue as any
|
return reactStateValue as any
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export * from "effect/Subscribable"
|
||||||
|
|||||||
61
packages/effect-fc/src/SubscriptionRef.ts
Normal file
61
packages/effect-fc/src/SubscriptionRef.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Effect, Equivalence, Ref, Stream, SubscriptionRef } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import * as Component from "./Component.js"
|
||||||
|
import * as SetStateAction from "./SetStateAction.js"
|
||||||
|
|
||||||
|
|
||||||
|
export declare namespace useSubscriptionRefState {
|
||||||
|
export interface Options<A> {
|
||||||
|
readonly equivalence?: Equivalence.Equivalence<A>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSubscriptionRefState = Effect.fnUntraced(function* <A>(
|
||||||
|
ref: SubscriptionRef.SubscriptionRef<A>,
|
||||||
|
options?: useSubscriptionRefState.Options<NoInfer<A>>,
|
||||||
|
): Effect.fn.Return<readonly [A, React.Dispatch<React.SetStateAction<A>>]> {
|
||||||
|
const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => ref))
|
||||||
|
|
||||||
|
yield* Component.useReactEffect(() => Effect.forkScoped(
|
||||||
|
Stream.runForEach(
|
||||||
|
Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()),
|
||||||
|
v => Effect.sync(() => setReactStateValue(v)),
|
||||||
|
)
|
||||||
|
), [ref])
|
||||||
|
|
||||||
|
const setValue = yield* Component.useCallbackSync(
|
||||||
|
(setStateAction: React.SetStateAction<A>) => Effect.andThen(
|
||||||
|
Ref.updateAndGet(ref, prevState => SetStateAction.value(setStateAction, prevState)),
|
||||||
|
v => setReactStateValue(v),
|
||||||
|
),
|
||||||
|
[ref],
|
||||||
|
)
|
||||||
|
|
||||||
|
return [reactStateValue, setValue]
|
||||||
|
})
|
||||||
|
|
||||||
|
export declare namespace useSubscriptionRefFromState {
|
||||||
|
export interface Options<A> {
|
||||||
|
readonly equivalence?: Equivalence.Equivalence<A>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSubscriptionRefFromState = Effect.fnUntraced(function* <A>(
|
||||||
|
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
|
||||||
|
options?: useSubscriptionRefFromState.Options<NoInfer<A>>,
|
||||||
|
): Effect.fn.Return<SubscriptionRef.SubscriptionRef<A>> {
|
||||||
|
const ref = yield* Component.useOnChange(() => Effect.tap(
|
||||||
|
SubscriptionRef.make(value),
|
||||||
|
ref => Effect.forkScoped(
|
||||||
|
Stream.runForEach(
|
||||||
|
Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()),
|
||||||
|
v => Effect.sync(() => setValue(v)),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
), [setValue])
|
||||||
|
|
||||||
|
yield* Component.useReactEffect(() => Ref.set(ref, value), [value])
|
||||||
|
return ref
|
||||||
|
})
|
||||||
|
|
||||||
|
export * from "effect/SubscriptionRef"
|
||||||
186
packages/effect-fc/src/SubscriptionSubRef.ts
Normal file
186
packages/effect-fc/src/SubscriptionSubRef.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { Chunk, Effect, Effectable, Option, Predicate, Readable, Ref, Stream, Subscribable, SubscriptionRef, SynchronizedRef, type Types, type Unify } from "effect"
|
||||||
|
import * as PropertyPath from "./PropertyPath.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("@effect-fc/SubscriptionSubRef/SubscriptionSubRef")
|
||||||
|
export type SubscriptionSubRefTypeId = typeof SubscriptionSubRefTypeId
|
||||||
|
|
||||||
|
export interface SubscriptionSubRef<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
|
||||||
|
extends SubscriptionSubRef.Variance<A, B>, SubscriptionRef.SubscriptionRef<A> {
|
||||||
|
readonly parent: B
|
||||||
|
|
||||||
|
readonly [Unify.typeSymbol]?: unknown
|
||||||
|
readonly [Unify.unifySymbol]?: SubscriptionSubRefUnify<this>
|
||||||
|
readonly [Unify.ignoreSymbol]?: SubscriptionSubRefUnifyIgnore
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare namespace SubscriptionSubRef {
|
||||||
|
export interface Variance<in out A, in out B> {
|
||||||
|
readonly [SubscriptionSubRefTypeId]: {
|
||||||
|
readonly _A: Types.Invariant<A>
|
||||||
|
readonly _B: Types.Invariant<B>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionSubRefUnify<A extends { [Unify.typeSymbol]?: any }> extends SubscriptionRef.SubscriptionRefUnify<A> {
|
||||||
|
SubscriptionSubRef?: () => Extract<A[Unify.typeSymbol], SubscriptionSubRef<any, any>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionSubRefUnifyIgnore extends SubscriptionRef.SubscriptionRefUnifyIgnore {
|
||||||
|
SubscriptionRef?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const refVariance = { _A: (_: any) => _ }
|
||||||
|
const synchronizedRefVariance = { _A: (_: any) => _ }
|
||||||
|
const subscriptionRefVariance = { _A: (_: any) => _ }
|
||||||
|
const subscriptionSubRefVariance = { _A: (_: any) => _, _B: (_: any) => _ }
|
||||||
|
|
||||||
|
class SubscriptionSubRefImpl<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
|
||||||
|
extends Effectable.Class<A> implements SubscriptionSubRef<A, B> {
|
||||||
|
readonly [Readable.TypeId]: Readable.TypeId = Readable.TypeId
|
||||||
|
readonly [Subscribable.TypeId]: Subscribable.TypeId = Subscribable.TypeId
|
||||||
|
readonly [Ref.RefTypeId] = refVariance
|
||||||
|
readonly [SynchronizedRef.SynchronizedRefTypeId] = synchronizedRefVariance
|
||||||
|
readonly [SubscriptionRef.SubscriptionRefTypeId] = subscriptionRefVariance
|
||||||
|
readonly [SubscriptionSubRefTypeId] = subscriptionSubRefVariance
|
||||||
|
|
||||||
|
readonly get: Effect.Effect<A>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly parent: B,
|
||||||
|
readonly getter: (parentValue: Effect.Effect.Success<B>) => A,
|
||||||
|
readonly setter: (parentValue: Effect.Effect.Success<B>, value: A) => Effect.Effect.Success<B>,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
this.get = Effect.map(this.parent, this.getter)
|
||||||
|
}
|
||||||
|
|
||||||
|
commit() {
|
||||||
|
return this.get
|
||||||
|
}
|
||||||
|
|
||||||
|
get changes(): Stream.Stream<A> {
|
||||||
|
return Stream.unwrap(
|
||||||
|
Effect.map(this.get, a => Stream.concat(
|
||||||
|
Stream.make(a),
|
||||||
|
Stream.map(this.parent.changes, this.getter),
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
modify<C>(f: (a: A) => readonly [C, A]): Effect.Effect<C> {
|
||||||
|
return this.modifyEffect(a => Effect.succeed(f(a)))
|
||||||
|
}
|
||||||
|
|
||||||
|
modifyEffect<C, E, R>(f: (a: A) => Effect.Effect<readonly [C, A], E, R>): Effect.Effect<C, E, R> {
|
||||||
|
return Effect.Do.pipe(
|
||||||
|
Effect.bind("b", (): Effect.Effect<Effect.Effect.Success<B>> => this.parent),
|
||||||
|
Effect.bind("ca", ({ b }) => f(this.getter(b))),
|
||||||
|
Effect.tap(({ b, ca: [, a] }) => SubscriptionRef.set(this.parent, this.setter(b, a))),
|
||||||
|
Effect.map(({ ca: [c] }) => c),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const isSubscriptionSubRef = (u: unknown): u is SubscriptionSubRef<unknown, SubscriptionRef.SubscriptionRef<unknown>> => Predicate.hasProperty(u, SubscriptionSubRefTypeId)
|
||||||
|
|
||||||
|
export const makeFromGetSet = <A, B extends SubscriptionRef.SubscriptionRef<any>>(
|
||||||
|
parent: B,
|
||||||
|
options: {
|
||||||
|
readonly get: (parentValue: Effect.Effect.Success<B>) => A
|
||||||
|
readonly set: (parentValue: Effect.Effect.Success<B>, value: A) => Effect.Effect.Success<B>
|
||||||
|
},
|
||||||
|
): SubscriptionSubRef<A, B> => new SubscriptionSubRefImpl(parent, options.get, options.set)
|
||||||
|
|
||||||
|
export const makeFromPath = <
|
||||||
|
B extends SubscriptionRef.SubscriptionRef<any>,
|
||||||
|
const P extends PropertyPath.Paths<Effect.Effect.Success<B>>,
|
||||||
|
>(
|
||||||
|
parent: B,
|
||||||
|
path: P,
|
||||||
|
): SubscriptionSubRef<PropertyPath.ValueFromPath<Effect.Effect.Success<B>, P>, B> => new SubscriptionSubRefImpl(
|
||||||
|
parent,
|
||||||
|
parentValue => Option.getOrThrow(PropertyPath.get(parentValue, path)),
|
||||||
|
(parentValue, value) => Option.getOrThrow(PropertyPath.immutableSet(parentValue, path, value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const makeFromChunkIndex: {
|
||||||
|
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
|
||||||
|
parent: B,
|
||||||
|
index: number,
|
||||||
|
): SubscriptionSubRef<
|
||||||
|
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
|
||||||
|
B
|
||||||
|
>
|
||||||
|
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
|
||||||
|
parent: B,
|
||||||
|
index: number,
|
||||||
|
): SubscriptionSubRef<
|
||||||
|
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
|
||||||
|
B
|
||||||
|
>
|
||||||
|
} = (
|
||||||
|
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>,
|
||||||
|
index: number,
|
||||||
|
) => new SubscriptionSubRefImpl(
|
||||||
|
parent,
|
||||||
|
parentValue => Chunk.unsafeGet(parentValue, index),
|
||||||
|
(parentValue, value) => Chunk.replace(parentValue, index, value),
|
||||||
|
) as any
|
||||||
|
|
||||||
|
export const makeFromChunkFindFirst: {
|
||||||
|
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
|
||||||
|
parent: B,
|
||||||
|
findFirstPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never>,
|
||||||
|
): SubscriptionSubRef<
|
||||||
|
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
|
||||||
|
B
|
||||||
|
>
|
||||||
|
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
|
||||||
|
parent: B,
|
||||||
|
findFirstPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never>,
|
||||||
|
): SubscriptionSubRef<
|
||||||
|
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
|
||||||
|
B
|
||||||
|
>
|
||||||
|
} = (
|
||||||
|
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<never>>,
|
||||||
|
findFirstPredicate: Predicate.Predicate.Any,
|
||||||
|
) => new SubscriptionSubRefImpl(
|
||||||
|
parent,
|
||||||
|
parentValue => Option.getOrThrow(Chunk.findFirst(parentValue, findFirstPredicate)),
|
||||||
|
(parentValue, value) => Option.getOrThrow(Option.andThen(
|
||||||
|
Chunk.findFirstIndex(parentValue, findFirstPredicate),
|
||||||
|
index => Chunk.replace(parentValue, index, value),
|
||||||
|
)),
|
||||||
|
) as any
|
||||||
|
|
||||||
|
export const makeFromChunkFindLast: {
|
||||||
|
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
|
||||||
|
parent: B,
|
||||||
|
findLastPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never>,
|
||||||
|
): SubscriptionSubRef<
|
||||||
|
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
|
||||||
|
B
|
||||||
|
>
|
||||||
|
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
|
||||||
|
parent: B,
|
||||||
|
findLastPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never>,
|
||||||
|
): SubscriptionSubRef<
|
||||||
|
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
|
||||||
|
B
|
||||||
|
>
|
||||||
|
} = (
|
||||||
|
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<never>>,
|
||||||
|
findLastPredicate: Predicate.Predicate.Any,
|
||||||
|
) => new SubscriptionSubRefImpl(
|
||||||
|
parent,
|
||||||
|
parentValue => Option.getOrThrow(Chunk.findLast(parentValue, findLastPredicate)),
|
||||||
|
(parentValue, value) => Option.getOrThrow(Option.andThen(
|
||||||
|
Chunk.findLastIndex(parentValue, findLastPredicate),
|
||||||
|
index => Chunk.replace(parentValue, index, value),
|
||||||
|
)),
|
||||||
|
) as any
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
import { Array, type Context, Effect, Equal, Fiber, flow, Option, ParseResult, Pipeable, Predicate, Schema, type Scope, Stream, SubscriptionRef } from "effect"
|
|
||||||
import * as Form from "./Form.js"
|
|
||||||
import * as Lens from "./Lens.js"
|
|
||||||
import * as Subscribable from "./Subscribable.js"
|
|
||||||
|
|
||||||
|
|
||||||
export const SynchronizedFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SynchronizedForm")
|
|
||||||
export type SynchronizedFormTypeId = typeof SynchronizedFormTypeId
|
|
||||||
|
|
||||||
export interface SynchronizedForm<
|
|
||||||
in out A,
|
|
||||||
in out I = A,
|
|
||||||
in out R = never,
|
|
||||||
in out TER = never,
|
|
||||||
in out TEW = never,
|
|
||||||
in out TRR = never,
|
|
||||||
in out TRW = never,
|
|
||||||
> extends Form.Form<readonly [], A, I, TER, 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, 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 get() { return 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.succeed(this).pipe(
|
|
||||||
Effect.map(self => Lens.make<I, TER, TEW, never, never>({
|
|
||||||
get get() { return self.internalEncodedValue.get },
|
|
||||||
get changes() { return self.internalEncodedValue.changes },
|
|
||||||
modify: f => self.internalEncodedValue.modify(
|
|
||||||
encodedValue => Effect.map(
|
|
||||||
f(encodedValue),
|
|
||||||
([b, nextEncodedValue]) => [
|
|
||||||
[b, nextEncodedValue] as const,
|
|
||||||
nextEncodedValue,
|
|
||||||
] as const,
|
|
||||||
)
|
|
||||||
).pipe(
|
|
||||||
Effect.tap(([, nextEncodedValue]) => self.synchronizeEncodedValue(nextEncodedValue)),
|
|
||||||
Effect.map(([b]) => b),
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
Lens.unwrap,
|
|
||||||
)
|
|
||||||
this.isValidating = Effect.succeed(this).pipe(
|
|
||||||
Effect.map(self => Subscribable.map(self.validationFiber, Option.isSome)),
|
|
||||||
Subscribable.unwrap,
|
|
||||||
)
|
|
||||||
this.canCommit = Effect.succeed(this).pipe(
|
|
||||||
Effect.map(self => Subscribable.map(
|
|
||||||
Subscribable.zipLatestAll(self.issues, self.validationFiber, self.isCommitting),
|
|
||||||
([issues, validationFiber, isCommitting]) => (
|
|
||||||
Array.isEmptyReadonlyArray(issues) &&
|
|
||||||
Option.isNone(validationFiber) &&
|
|
||||||
!isCommitting
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
Subscribable.unwrap,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, TER | TEW, never> {
|
|
||||||
return Lens.get(this.validationFiber).pipe(
|
|
||||||
Effect.andThen(Option.match({
|
|
||||||
onSome: Fiber.interrupt,
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
})),
|
|
||||||
Effect.andThen(Effect.forkScoped(
|
|
||||||
Effect.ensuring(
|
|
||||||
Schema.decode(this.schema, { errors: "all" })(encodedValue),
|
|
||||||
Lens.set(this.validationFiber, Option.none()),
|
|
||||||
)
|
|
||||||
)),
|
|
||||||
Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
|
|
||||||
Effect.flatMap(Fiber.join),
|
|
||||||
|
|
||||||
Effect.flatMap(value => Effect.ensuring(
|
|
||||||
Lens.set(this.isCommitting, true).pipe(
|
|
||||||
Effect.andThen(Lens.set(this.issues, Array.empty())),
|
|
||||||
Effect.andThen(Lens.set(this.target, value)),
|
|
||||||
),
|
|
||||||
Lens.set(this.isCommitting, false),
|
|
||||||
)),
|
|
||||||
Effect.catchIf(
|
|
||||||
ParseResult.isParseError,
|
|
||||||
flow(
|
|
||||||
ParseResult.ArrayFormatter.formatError,
|
|
||||||
Effect.flatMap(v => Lens.set(this.issues, v)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
Effect.provide(this.context),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get run(): Effect.Effect<void, TER, never> {
|
|
||||||
return this.runSemaphore.withPermits(1)(Effect.provide(
|
|
||||||
Stream.runForEach(
|
|
||||||
Stream.drop(this.target.changes, 1),
|
|
||||||
targetValue => Schema.encode(this.schema, { errors: "all" })(targetValue).pipe(
|
|
||||||
Effect.flatMap(encodedValue => Effect.whenEffect(
|
|
||||||
Effect.andThen(
|
|
||||||
Lens.set(this.issues, Array.empty()),
|
|
||||||
Lens.set(this.internalEncodedValue, encodedValue),
|
|
||||||
),
|
|
||||||
Effect.map(
|
|
||||||
Lens.get(this.internalEncodedValue),
|
|
||||||
currentEncodedValue => !Equal.equals(encodedValue, currentEncodedValue),
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
Effect.ignore,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
this.context,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const isSynchronizedForm = (u: unknown): u is SynchronizedForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SynchronizedFormTypeId)
|
|
||||||
|
|
||||||
|
|
||||||
export declare namespace make {
|
|
||||||
export interface Options<in out A, in out I = A, in out R = never, in out TER = never, in out TEW = never, in out TRR = never, in out TRW = never> {
|
|
||||||
readonly schema: Schema.Schema<A, I, R>
|
|
||||||
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
|
|
||||||
readonly initialEncodedValue?: NoInfer<I>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const make = Effect.fnUntraced(function* <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
|
|
||||||
options: make.Options<A, I, R, TER, TEW, TRR, TRW>
|
|
||||||
): Effect.fn.Return<
|
|
||||||
SynchronizedForm<A, I, R, TER, TEW, TRR, TRW>,
|
|
||||||
ParseResult.ParseError | TER,
|
|
||||||
Scope.Scope | R | TRR | TRW
|
|
||||||
> {
|
|
||||||
const initialEncodedValue = options.initialEncodedValue !== undefined
|
|
||||||
? options.initialEncodedValue
|
|
||||||
: yield* Effect.flatMap(
|
|
||||||
Lens.get(options.target),
|
|
||||||
Schema.encode(options.schema, { errors: "all" }),
|
|
||||||
)
|
|
||||||
|
|
||||||
return new SynchronizedFormImpl(
|
|
||||||
options.schema,
|
|
||||||
yield* Effect.context<Scope.Scope | R | TRR | TRW>(),
|
|
||||||
options.target,
|
|
||||||
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(initialEncodedValue)),
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly ParseResult.ArrayFormatterIssue[]>(Array.empty())),
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>())),
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(false)),
|
|
||||||
|
|
||||||
yield* Effect.makeSemaphore(1),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export declare namespace service {
|
|
||||||
export interface Options<in out A, in out I = A, in out R = never, in out TER = never, in out TEW = never, in out TRR = never, in out TRW = never>
|
|
||||||
extends make.Options<A, I, R, TER, TEW, TRR, TRW> {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const service = <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
|
|
||||||
options: service.Options<A, I, R, TER, TEW, TRR, TRW>
|
|
||||||
): Effect.Effect<
|
|
||||||
SynchronizedForm<A, I, R, TER, TEW, TRR, TRW>,
|
|
||||||
ParseResult.ParseError | TER,
|
|
||||||
Scope.Scope | R | TRR | TRW
|
|
||||||
> => Effect.tap(
|
|
||||||
make(options),
|
|
||||||
form => Effect.forkScoped(form.run),
|
|
||||||
)
|
|
||||||
@@ -2,9 +2,9 @@ export * as Async from "./Async.js"
|
|||||||
export * as Component from "./Component.js"
|
export * as Component from "./Component.js"
|
||||||
export * as ErrorObserver from "./ErrorObserver.js"
|
export * as ErrorObserver from "./ErrorObserver.js"
|
||||||
export * as Form from "./Form.js"
|
export * as Form from "./Form.js"
|
||||||
export * as Lens from "./Lens.js"
|
|
||||||
export * as Memoized from "./Memoized.js"
|
export * as Memoized from "./Memoized.js"
|
||||||
export * as Mutation from "./Mutation.js"
|
export * as Mutation from "./Mutation.js"
|
||||||
|
export * as PropertyPath from "./PropertyPath.js"
|
||||||
export * as PubSub from "./PubSub.js"
|
export * as PubSub from "./PubSub.js"
|
||||||
export * as Query from "./Query.js"
|
export * as Query from "./Query.js"
|
||||||
export * as QueryClient from "./QueryClient.js"
|
export * as QueryClient from "./QueryClient.js"
|
||||||
@@ -12,6 +12,6 @@ export * as ReactRuntime from "./ReactRuntime.js"
|
|||||||
export * as Result from "./Result.js"
|
export * as Result from "./Result.js"
|
||||||
export * as SetStateAction from "./SetStateAction.js"
|
export * as SetStateAction from "./SetStateAction.js"
|
||||||
export * as Stream from "./Stream.js"
|
export * as Stream from "./Stream.js"
|
||||||
export * as SubmittableForm from "./SubmittableForm.js"
|
|
||||||
export * as Subscribable from "./Subscribable.js"
|
export * as Subscribable from "./Subscribable.js"
|
||||||
export * as SynchronizedForm from "./SynchronizedForm.js"
|
export * as SubscriptionRef from "./SubscriptionRef.js"
|
||||||
|
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
|
||||||
// Build
|
// Build
|
||||||
"rootDir": "./src",
|
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
@@ -35,6 +34,5 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
"include": ["./src"],
|
"include": ["./src"]
|
||||||
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,30 +13,30 @@
|
|||||||
"clean:modules": "rm -rf node_modules"
|
"clean:modules": "rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/react-router": "^1.169.1",
|
"@tanstack/react-router": "^1.154.12",
|
||||||
"@tanstack/react-router-devtools": "^1.166.13",
|
"@tanstack/react-router-devtools": "^1.154.12",
|
||||||
"@tanstack/router-plugin": "^1.167.32",
|
"@tanstack/router-plugin": "^1.154.12",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.9",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.0.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.3",
|
||||||
"type-fest": "^5.6.0",
|
"type-fest": "^5.4.1",
|
||||||
"vite": "^8.0.10"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform": "^0.96.1",
|
"@effect/platform": "^0.94.2",
|
||||||
"@effect/platform-browser": "^0.76.0",
|
"@effect/platform-browser": "^0.74.0",
|
||||||
"@radix-ui/themes": "^3.3.0",
|
"@radix-ui/themes": "^3.2.1",
|
||||||
"@typed/id": "^0.17.2",
|
"@typed/id": "^0.17.2",
|
||||||
"effect": "^3.21.2",
|
"effect": "^3.19.15",
|
||||||
"effect-fc": "workspace:*",
|
"effect-fc": "workspace:*",
|
||||||
"react-icons": "^5.6.0"
|
"react-icons": "^5.5.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.9",
|
||||||
"effect": "^3.21.2",
|
"effect": "^3.19.15",
|
||||||
"react": "^19.2.5"
|
"react": "^19.2.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
75
packages/example/src/lib/form/TextFieldFormInput.tsx
Normal file
75
packages/example/src/lib/form/TextFieldFormInput.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
|
||||||
|
import { Array, Option, Struct } from "effect"
|
||||||
|
import { Component, Form, Subscribable } from "effect-fc"
|
||||||
|
|
||||||
|
|
||||||
|
interface Props
|
||||||
|
extends TextField.RootProps, Form.useInput.Options {
|
||||||
|
readonly optional?: false
|
||||||
|
readonly field: Form.FormField<any, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OptionalProps
|
||||||
|
extends Omit<TextField.RootProps, "optional" | "defaultValue">, Form.useOptionalInput.Options<string> {
|
||||||
|
readonly optional: true
|
||||||
|
readonly field: Form.FormField<any, Option.Option<string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TextFieldFormInputProps = Props | OptionalProps
|
||||||
|
|
||||||
|
|
||||||
|
export class TextFieldFormInput extends Component.makeUntraced("TextFieldFormInput")(function*(props: TextFieldFormInputProps) {
|
||||||
|
const input: (
|
||||||
|
| { readonly optional: true } & Form.useOptionalInput.Success<string>
|
||||||
|
| { readonly optional: false } & Form.useInput.Success<string>
|
||||||
|
) = props.optional
|
||||||
|
// biome-ignore lint/correctness/useHookAtTopLevel: "optional" reactivity not supported
|
||||||
|
? { optional: true, ...yield* Form.useOptionalInput(props.field, props) }
|
||||||
|
// biome-ignore lint/correctness/useHookAtTopLevel: "optional" reactivity not supported
|
||||||
|
: { optional: false, ...yield* Form.useInput(props.field, props) }
|
||||||
|
|
||||||
|
const [issues, isValidating, isSubmitting] = yield* Subscribable.useSubscribables([
|
||||||
|
props.field.issues,
|
||||||
|
props.field.isValidating,
|
||||||
|
props.field.isSubmitting,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
<TextField.Root
|
||||||
|
value={input.value}
|
||||||
|
onChange={e => input.setValue(e.target.value)}
|
||||||
|
disabled={(input.optional && !input.enabled) || isSubmitting}
|
||||||
|
{...Struct.omit(props, "optional", "defaultValue")}
|
||||||
|
>
|
||||||
|
{input.optional &&
|
||||||
|
<TextField.Slot side="left">
|
||||||
|
<Switch
|
||||||
|
size="1"
|
||||||
|
checked={input.enabled}
|
||||||
|
onCheckedChange={input.setEnabled}
|
||||||
|
/>
|
||||||
|
</TextField.Slot>
|
||||||
|
}
|
||||||
|
|
||||||
|
{isValidating &&
|
||||||
|
<TextField.Slot side="right">
|
||||||
|
<Spinner />
|
||||||
|
</TextField.Slot>
|
||||||
|
}
|
||||||
|
|
||||||
|
{props.children}
|
||||||
|
</TextField.Root>
|
||||||
|
|
||||||
|
{Option.match(Array.head(issues), {
|
||||||
|
onSome: issue => (
|
||||||
|
<Callout.Root>
|
||||||
|
<Callout.Text>{issue.message}</Callout.Text>
|
||||||
|
</Callout.Root>
|
||||||
|
),
|
||||||
|
|
||||||
|
onNone: () => <></>,
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}) {}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
|
|
||||||
import { Array, Option, Struct } from "effect"
|
|
||||||
import { Component, Form, Subscribable } from "effect-fc"
|
|
||||||
import type * as React from "react"
|
|
||||||
|
|
||||||
|
|
||||||
export declare namespace TextFieldFormInputView {
|
|
||||||
export interface Props<out P extends readonly PropertyKey[], A, ER, EW>
|
|
||||||
extends Omit<TextField.RootProps, "form">, Form.useInput.Options {
|
|
||||||
readonly form: Form.Form<P, A, string, ER, EW>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Signature = <P extends readonly PropertyKey[], A, ER, EW>(props: Props<P, A, ER, EW>) => React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(function*(
|
|
||||||
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
|
|
||||||
) {
|
|
||||||
const input = yield* Form.useInput(props.form, props)
|
|
||||||
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
|
|
||||||
props.form.issues,
|
|
||||||
props.form.isValidating,
|
|
||||||
props.form.isCommitting,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex direction="column" gap="1">
|
|
||||||
<TextField.Root
|
|
||||||
value={input.value}
|
|
||||||
onChange={e => input.setValue(e.target.value)}
|
|
||||||
disabled={isCommitting}
|
|
||||||
{...Struct.omit(props, "form")}
|
|
||||||
>
|
|
||||||
{isValidating &&
|
|
||||||
<TextField.Slot side="right">
|
|
||||||
<Spinner />
|
|
||||||
</TextField.Slot>
|
|
||||||
}
|
|
||||||
|
|
||||||
{props.children}
|
|
||||||
</TextField.Root>
|
|
||||||
|
|
||||||
{Option.match(Array.head(issues), {
|
|
||||||
onSome: issue => (
|
|
||||||
<Callout.Root>
|
|
||||||
<Callout.Text>{issue.message}</Callout.Text>
|
|
||||||
</Callout.Root>
|
|
||||||
),
|
|
||||||
|
|
||||||
onNone: () => <></>,
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}).pipe(
|
|
||||||
Component.withSignature<TextFieldFormInputView.Signature>()
|
|
||||||
)
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
|
|
||||||
import { Array, Option, Struct } from "effect"
|
|
||||||
import { Component, Form, Subscribable } from "effect-fc"
|
|
||||||
import type * as React from "react"
|
|
||||||
|
|
||||||
|
|
||||||
export declare namespace TextFieldOptionalFormInputView {
|
|
||||||
export interface Props<out P extends readonly PropertyKey[], A, ER, EW>
|
|
||||||
extends Omit<TextField.RootProps, "form" | "defaultValue">, Form.useOptionalInput.Options<string> {
|
|
||||||
readonly form: Form.Form<P, A, Option.Option<string>, ER, EW>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Signature = <P extends readonly PropertyKey[], A, ER, EW>(props: Props<P, A, ER, EW>) => React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalFormInputView")(function*(
|
|
||||||
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
|
|
||||||
) {
|
|
||||||
const input = yield* Form.useOptionalInput(props.form, props)
|
|
||||||
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
|
|
||||||
props.form.issues,
|
|
||||||
props.form.isValidating,
|
|
||||||
props.form.isCommitting,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex direction="column" gap="1">
|
|
||||||
<TextField.Root
|
|
||||||
value={input.value}
|
|
||||||
onChange={e => input.setValue(e.target.value)}
|
|
||||||
disabled={!input.enabled || isCommitting}
|
|
||||||
{...Struct.omit(props, "form", "defaultValue")}
|
|
||||||
>
|
|
||||||
<TextField.Slot side="left">
|
|
||||||
<Switch
|
|
||||||
size="1"
|
|
||||||
checked={input.enabled}
|
|
||||||
onCheckedChange={input.setEnabled}
|
|
||||||
/>
|
|
||||||
</TextField.Slot>
|
|
||||||
|
|
||||||
{isValidating &&
|
|
||||||
<TextField.Slot side="right">
|
|
||||||
<Spinner />
|
|
||||||
</TextField.Slot>
|
|
||||||
}
|
|
||||||
|
|
||||||
{props.children}
|
|
||||||
</TextField.Root>
|
|
||||||
|
|
||||||
{Option.match(Array.head(issues), {
|
|
||||||
onSome: issue => (
|
|
||||||
<Callout.Root>
|
|
||||||
<Callout.Text>{issue.message}</Callout.Text>
|
|
||||||
</Callout.Root>
|
|
||||||
),
|
|
||||||
|
|
||||||
onNone: () => <></>,
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}).pipe(
|
|
||||||
Component.withSignature<TextFieldOptionalFormInputView.Signature>()
|
|
||||||
)
|
|
||||||
@@ -13,10 +13,10 @@ import { Route as ResultRouteImport } from './routes/result'
|
|||||||
import { Route as QueryRouteImport } from './routes/query'
|
import { Route as QueryRouteImport } from './routes/query'
|
||||||
import { Route as FormRouteImport } from './routes/form'
|
import { Route as FormRouteImport } from './routes/form'
|
||||||
import { Route as BlankRouteImport } from './routes/blank'
|
import { Route as BlankRouteImport } from './routes/blank'
|
||||||
import { Route as AsyncRouteImport } from './routes/async'
|
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as DevMemoRouteImport } from './routes/dev/memo'
|
import { Route as DevMemoRouteImport } from './routes/dev/memo'
|
||||||
import { Route as DevContextRouteImport } from './routes/dev/context'
|
import { Route as DevContextRouteImport } from './routes/dev/context'
|
||||||
|
import { Route as DevAsyncRenderingRouteImport } from './routes/dev/async-rendering'
|
||||||
|
|
||||||
const ResultRoute = ResultRouteImport.update({
|
const ResultRoute = ResultRouteImport.update({
|
||||||
id: '/result',
|
id: '/result',
|
||||||
@@ -38,11 +38,6 @@ const BlankRoute = BlankRouteImport.update({
|
|||||||
path: '/blank',
|
path: '/blank',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const AsyncRoute = AsyncRouteImport.update({
|
|
||||||
id: '/async',
|
|
||||||
path: '/async',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -58,35 +53,40 @@ const DevContextRoute = DevContextRouteImport.update({
|
|||||||
path: '/dev/context',
|
path: '/dev/context',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({
|
||||||
|
id: '/dev/async-rendering',
|
||||||
|
path: '/dev/async-rendering',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/async': typeof AsyncRoute
|
|
||||||
'/blank': typeof BlankRoute
|
'/blank': typeof BlankRoute
|
||||||
'/form': typeof FormRoute
|
'/form': typeof FormRoute
|
||||||
'/query': typeof QueryRoute
|
'/query': typeof QueryRoute
|
||||||
'/result': typeof ResultRoute
|
'/result': typeof ResultRoute
|
||||||
|
'/dev/async-rendering': typeof DevAsyncRenderingRoute
|
||||||
'/dev/context': typeof DevContextRoute
|
'/dev/context': typeof DevContextRoute
|
||||||
'/dev/memo': typeof DevMemoRoute
|
'/dev/memo': typeof DevMemoRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/async': typeof AsyncRoute
|
|
||||||
'/blank': typeof BlankRoute
|
'/blank': typeof BlankRoute
|
||||||
'/form': typeof FormRoute
|
'/form': typeof FormRoute
|
||||||
'/query': typeof QueryRoute
|
'/query': typeof QueryRoute
|
||||||
'/result': typeof ResultRoute
|
'/result': typeof ResultRoute
|
||||||
|
'/dev/async-rendering': typeof DevAsyncRenderingRoute
|
||||||
'/dev/context': typeof DevContextRoute
|
'/dev/context': typeof DevContextRoute
|
||||||
'/dev/memo': typeof DevMemoRoute
|
'/dev/memo': typeof DevMemoRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/async': typeof AsyncRoute
|
|
||||||
'/blank': typeof BlankRoute
|
'/blank': typeof BlankRoute
|
||||||
'/form': typeof FormRoute
|
'/form': typeof FormRoute
|
||||||
'/query': typeof QueryRoute
|
'/query': typeof QueryRoute
|
||||||
'/result': typeof ResultRoute
|
'/result': typeof ResultRoute
|
||||||
|
'/dev/async-rendering': typeof DevAsyncRenderingRoute
|
||||||
'/dev/context': typeof DevContextRoute
|
'/dev/context': typeof DevContextRoute
|
||||||
'/dev/memo': typeof DevMemoRoute
|
'/dev/memo': typeof DevMemoRoute
|
||||||
}
|
}
|
||||||
@@ -94,42 +94,42 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/async'
|
|
||||||
| '/blank'
|
| '/blank'
|
||||||
| '/form'
|
| '/form'
|
||||||
| '/query'
|
| '/query'
|
||||||
| '/result'
|
| '/result'
|
||||||
|
| '/dev/async-rendering'
|
||||||
| '/dev/context'
|
| '/dev/context'
|
||||||
| '/dev/memo'
|
| '/dev/memo'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/async'
|
|
||||||
| '/blank'
|
| '/blank'
|
||||||
| '/form'
|
| '/form'
|
||||||
| '/query'
|
| '/query'
|
||||||
| '/result'
|
| '/result'
|
||||||
|
| '/dev/async-rendering'
|
||||||
| '/dev/context'
|
| '/dev/context'
|
||||||
| '/dev/memo'
|
| '/dev/memo'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/async'
|
|
||||||
| '/blank'
|
| '/blank'
|
||||||
| '/form'
|
| '/form'
|
||||||
| '/query'
|
| '/query'
|
||||||
| '/result'
|
| '/result'
|
||||||
|
| '/dev/async-rendering'
|
||||||
| '/dev/context'
|
| '/dev/context'
|
||||||
| '/dev/memo'
|
| '/dev/memo'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AsyncRoute: typeof AsyncRoute
|
|
||||||
BlankRoute: typeof BlankRoute
|
BlankRoute: typeof BlankRoute
|
||||||
FormRoute: typeof FormRoute
|
FormRoute: typeof FormRoute
|
||||||
QueryRoute: typeof QueryRoute
|
QueryRoute: typeof QueryRoute
|
||||||
ResultRoute: typeof ResultRoute
|
ResultRoute: typeof ResultRoute
|
||||||
|
DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute
|
||||||
DevContextRoute: typeof DevContextRoute
|
DevContextRoute: typeof DevContextRoute
|
||||||
DevMemoRoute: typeof DevMemoRoute
|
DevMemoRoute: typeof DevMemoRoute
|
||||||
}
|
}
|
||||||
@@ -164,13 +164,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof BlankRouteImport
|
preLoaderRoute: typeof BlankRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/async': {
|
|
||||||
id: '/async'
|
|
||||||
path: '/async'
|
|
||||||
fullPath: '/async'
|
|
||||||
preLoaderRoute: typeof AsyncRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -192,16 +185,23 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DevContextRouteImport
|
preLoaderRoute: typeof DevContextRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/dev/async-rendering': {
|
||||||
|
id: '/dev/async-rendering'
|
||||||
|
path: '/dev/async-rendering'
|
||||||
|
fullPath: '/dev/async-rendering'
|
||||||
|
preLoaderRoute: typeof DevAsyncRenderingRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AsyncRoute: AsyncRoute,
|
|
||||||
BlankRoute: BlankRoute,
|
BlankRoute: BlankRoute,
|
||||||
FormRoute: FormRoute,
|
FormRoute: FormRoute,
|
||||||
QueryRoute: QueryRoute,
|
QueryRoute: QueryRoute,
|
||||||
ResultRoute: ResultRoute,
|
ResultRoute: ResultRoute,
|
||||||
|
DevAsyncRenderingRoute: DevAsyncRenderingRoute,
|
||||||
DevContextRoute: DevContextRoute,
|
DevContextRoute: DevContextRoute,
|
||||||
DevMemoRoute: DevMemoRoute,
|
DevMemoRoute: DevMemoRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { HttpClient } from "@effect/platform"
|
|
||||||
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
|
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
|
||||||
import { Array, Effect, flow, Option, Schema } from "effect"
|
|
||||||
import { Async, Component, Memoized } from "effect-fc"
|
|
||||||
import * as React from "react"
|
|
||||||
import { runtime } from "@/runtime"
|
|
||||||
|
|
||||||
|
|
||||||
const Post = Schema.Struct({
|
|
||||||
userId: Schema.Int,
|
|
||||||
id: Schema.Int,
|
|
||||||
title: Schema.String,
|
|
||||||
body: Schema.String,
|
|
||||||
})
|
|
||||||
|
|
||||||
interface AsyncFetchPostViewProps {
|
|
||||||
readonly id: number
|
|
||||||
}
|
|
||||||
|
|
||||||
class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) {
|
|
||||||
const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe(
|
|
||||||
Effect.tap(Effect.sleep("500 millis")),
|
|
||||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)),
|
|
||||||
Effect.andThen(response => response.json),
|
|
||||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
|
||||||
), [props.id])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Heading>{post.title}</Heading>
|
|
||||||
<Text>{post.body}</Text>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}).pipe(
|
|
||||||
Async.async,
|
|
||||||
Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }),
|
|
||||||
Memoized.memoized,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
|
|
||||||
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
|
|
||||||
const [text, setText] = React.useState("Typing here should not trigger a refetch of the post")
|
|
||||||
const [id, setId] = React.useState(1)
|
|
||||||
|
|
||||||
const AsyncFetchPost = yield* AsyncFetchPostView.use
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Flex direction="column" align="stretch" gap="2">
|
|
||||||
<TextField.Root
|
|
||||||
value={text}
|
|
||||||
onChange={e => setText(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Slider
|
|
||||||
value={[id]}
|
|
||||||
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
|
|
||||||
</Flex>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/async")({
|
|
||||||
component: AsyncRouteComponent,
|
|
||||||
})
|
|
||||||
78
packages/example/src/routes/dev/async-rendering.tsx
Normal file
78
packages/example/src/routes/dev/async-rendering.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Flex, Text, TextField } from "@radix-ui/themes"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Async, Component, Memoized } from "effect-fc"
|
||||||
|
import * as React from "react"
|
||||||
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
|
|
||||||
|
// Generator version
|
||||||
|
const RouteComponent = Component.makeUntraced(function* AsyncRendering() {
|
||||||
|
const MemoizedAsyncComponentFC = yield* MemoizedAsyncComponent
|
||||||
|
const AsyncComponentFC = yield* AsyncComponent
|
||||||
|
const [input, setInput] = React.useState("")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" align="stretch" gap="2">
|
||||||
|
<TextField.Root
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MemoizedAsyncComponentFC fallback={React.useMemo(() => <p>Loading memoized...</p>, [])} />
|
||||||
|
<AsyncComponentFC />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pipeline version
|
||||||
|
// const RouteComponent = Component.make("RouteComponent")(() => Effect.Do,
|
||||||
|
// Effect.bind("VMemoizedAsyncComponent", () => Component.useFC(MemoizedAsyncComponent)),
|
||||||
|
// Effect.bind("VAsyncComponent", () => Component.useFC(AsyncComponent)),
|
||||||
|
// Effect.let("input", () => React.useState("")),
|
||||||
|
|
||||||
|
// Effect.map(({ input: [input, setInput], VAsyncComponent, VMemoizedAsyncComponent }) =>
|
||||||
|
// <Flex direction="column" align="stretch" gap="2">
|
||||||
|
// <TextField.Root
|
||||||
|
// value={input}
|
||||||
|
// onChange={e => setInput(e.target.value)}
|
||||||
|
// />
|
||||||
|
|
||||||
|
// <VMemoizedAsyncComponent />
|
||||||
|
// <VAsyncComponent />
|
||||||
|
// </Flex>
|
||||||
|
// ),
|
||||||
|
// ).pipe(
|
||||||
|
// Component.withRuntime(runtime.context)
|
||||||
|
// )
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncComponent extends Component.makeUntraced("AsyncComponent")(function*() {
|
||||||
|
const SubComponentFC = yield* SubComponent
|
||||||
|
|
||||||
|
yield* Effect.sleep("500 millis") // Async operation
|
||||||
|
// Cannot use React hooks after the async operation
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" align="stretch">
|
||||||
|
<Text>Rendered!</Text>
|
||||||
|
<SubComponentFC />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Async.async,
|
||||||
|
Async.withOptions({ defaultFallback: <p>Loading...</p> }),
|
||||||
|
) {}
|
||||||
|
class MemoizedAsyncComponent extends Memoized.memoized(AsyncComponent) {}
|
||||||
|
|
||||||
|
class SubComponent extends Component.makeUntraced("SubComponent")(function*() {
|
||||||
|
const [state] = React.useState(yield* Component.useOnMount(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
|
||||||
|
return <Text>{state}</Text>
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dev/async-rendering")({
|
||||||
|
component: RouteComponent
|
||||||
|
})
|
||||||
@@ -23,7 +23,7 @@ const SubComponent = Component.makeUntraced("SubComponent")(function*() {
|
|||||||
const ContextView = Component.makeUntraced("ContextView")(function*() {
|
const ContextView = Component.makeUntraced("ContextView")(function*() {
|
||||||
const [serviceValue, setServiceValue] = React.useState("test")
|
const [serviceValue, setServiceValue] = React.useState("test")
|
||||||
const SubServiceLayer = React.useMemo(() => SubService.Default(serviceValue), [serviceValue])
|
const SubServiceLayer = React.useMemo(() => SubService.Default(serviceValue), [serviceValue])
|
||||||
const SubComponentFC = yield* Effect.provide(SubComponent.use, yield* Component.useContextFromLayer(SubServiceLayer))
|
const SubComponentFC = yield* Effect.provide(SubComponent, yield* Component.useContext(SubServiceLayer))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ const RouteComponent = Component.makeUntraced("RouteComponent")(function*() {
|
|||||||
onChange={e => setValue(e.target.value)}
|
onChange={e => setValue(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{yield* Effect.map(SubComponent.use, FC => <FC />)}
|
{yield* Effect.map(SubComponent, FC => <FC />)}
|
||||||
{yield* Effect.map(MemoizedSubComponent.use, FC => <FC />)}
|
{yield* Effect.map(MemoizedSubComponent, FC => <FC />)}
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Button, Container, Flex, Text } from "@radix-ui/themes"
|
import { Button, Container, Flex, Text } from "@radix-ui/themes"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
|
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
|
||||||
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
|
import { Component, Form, Subscribable } from "effect-fc"
|
||||||
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
import { TextFieldFormInput } from "@/lib/form/TextFieldFormInput"
|
||||||
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
|
|
||||||
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
||||||
import { runtime } from "@/runtime"
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
@@ -39,48 +38,39 @@ const RegisterFormSubmitSchema = Schema.Struct({
|
|||||||
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
|
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
|
||||||
})
|
})
|
||||||
|
|
||||||
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
|
class RegisterForm extends Effect.Service<RegisterForm>()("RegisterForm", {
|
||||||
scoped: Effect.gen(function*() {
|
scoped: Form.service({
|
||||||
const form = yield* SubmittableForm.service({
|
schema: RegisterFormSchema.pipe(
|
||||||
schema: RegisterFormSchema.pipe(
|
Schema.compose(
|
||||||
Schema.compose(
|
Schema.transformOrFail(
|
||||||
Schema.transformOrFail(
|
Schema.typeSchema(RegisterFormSchema),
|
||||||
Schema.typeSchema(RegisterFormSchema),
|
Schema.typeSchema(RegisterFormSchema),
|
||||||
Schema.typeSchema(RegisterFormSchema),
|
{
|
||||||
{
|
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
|
||||||
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
|
encode: ParseResult.succeed,
|
||||||
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.makeUntraced("RegisterFormView")(function*() {
|
||||||
const form = yield* RegisterFormService
|
const form = yield* RegisterForm
|
||||||
const [canCommit, submitResult] = yield* Subscribable.useAll([
|
const [canSubmit, submitResult] = yield* Subscribable.useSubscribables([
|
||||||
form.form.canCommit,
|
form.canSubmit,
|
||||||
form.form.mutation.result,
|
form.mutation.result,
|
||||||
])
|
])
|
||||||
|
|
||||||
const runPromise = yield* Component.useRunPromise()
|
const runPromise = yield* Component.useRunPromise()
|
||||||
const TextFieldFormInput = yield* TextFieldFormInputView.use
|
const TextFieldFormInputFC = yield* TextFieldFormInput
|
||||||
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
|
|
||||||
|
|
||||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||||
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
|
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
|
||||||
@@ -92,26 +82,25 @@ class RegisterFormView extends Component.make("RegisterFormView")(function*() {
|
|||||||
<Container width="300">
|
<Container width="300">
|
||||||
<form onSubmit={e => {
|
<form onSubmit={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void runPromise(form.form.submit)
|
void runPromise(form.submit)
|
||||||
}}>
|
}}>
|
||||||
<Flex direction="column" gap="2">
|
<Flex direction="column" gap="2">
|
||||||
<TextFieldFormInput
|
<TextFieldFormInputFC
|
||||||
form={form.emailField}
|
field={yield* form.field(["email"])}
|
||||||
debounce="250 millis"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextFieldFormInput
|
<TextFieldFormInputFC
|
||||||
form={form.passwordField}
|
field={yield* form.field(["password"])}
|
||||||
debounce="250 millis"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextFieldOptionalFormInput
|
<TextFieldFormInputFC
|
||||||
|
optional
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
form={form.birthField}
|
field={yield* form.field(["birth"])}
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button disabled={!canCommit}>Submit</Button>
|
<Button disabled={!canSubmit}>Submit</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -126,13 +115,13 @@ class RegisterFormView extends Component.make("RegisterFormView")(function*() {
|
|||||||
)
|
)
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
const RegisterPage = Component.make("RegisterPageView")(function*() {
|
const RegisterPage = Component.makeUntraced("RegisterPage")(function*() {
|
||||||
const RegisterForm = yield* Effect.provide(
|
const RegisterFormViewFC = yield* Effect.provide(
|
||||||
RegisterFormView.use,
|
RegisterFormView,
|
||||||
yield* Component.useContextFromLayer(RegisterFormService.Default),
|
yield* Component.useContext(RegisterForm.Default),
|
||||||
)
|
)
|
||||||
|
|
||||||
return <RegisterForm />
|
return <RegisterFormViewFC />
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Component.withRuntime(runtime.context)
|
Component.withRuntime(runtime.context)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,19 +2,19 @@ import { createFileRoute } from "@tanstack/react-router"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Component } from "effect-fc"
|
import { Component } from "effect-fc"
|
||||||
import { runtime } from "@/runtime"
|
import { runtime } from "@/runtime"
|
||||||
import { TodosState } from "@/todo/TodosState"
|
import { Todos } from "@/todo/Todos"
|
||||||
import { TodosView } from "@/todo/TodosView"
|
import { TodosState } from "@/todo/TodosState.service"
|
||||||
|
|
||||||
|
|
||||||
const TodosStateLive = TodosState.Default("todos")
|
const TodosStateLive = TodosState.Default("todos")
|
||||||
|
|
||||||
const Index = Component.make("IndexView")(function*() {
|
const Index = Component.makeUntraced("Index")(function*() {
|
||||||
const Todos = yield* Effect.provide(
|
const TodosFC = yield* Effect.provide(
|
||||||
TodosView.use,
|
Todos,
|
||||||
yield* Component.useContextFromLayer(TodosStateLive),
|
yield* Component.useContext(TodosStateLive),
|
||||||
)
|
)
|
||||||
|
|
||||||
return <Todos />
|
return <TodosFC />
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Component.withRuntime(runtime.context)
|
Component.withRuntime(runtime.context)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { HttpClient, type HttpClientError } from "@effect/platform"
|
import { HttpClient, type HttpClientError } from "@effect/platform"
|
||||||
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
|
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect"
|
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream } from "effect"
|
||||||
import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc"
|
import { Component, ErrorObserver, Mutation, Query, Result, Subscribable, SubscriptionRef } from "effect-fc"
|
||||||
import { runtime } from "@/runtime"
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
|
|
||||||
@@ -13,16 +13,15 @@ const Post = Schema.Struct({
|
|||||||
body: Schema.String,
|
body: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
const ResultView = Component.make("ResultView")(function*() {
|
const ResultView = Component.makeUntraced("Result")(function*() {
|
||||||
const runPromise = yield* Component.useRunPromise()
|
const runPromise = yield* Component.useRunPromise()
|
||||||
|
|
||||||
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
const [idRef, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||||
const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
|
const idRef = yield* SubscriptionRef.make(1)
|
||||||
const key = Stream.map(idLens.changes, id => [id] as const)
|
|
||||||
|
|
||||||
const query = yield* Query.service({
|
const query = yield* Query.service({
|
||||||
key,
|
key: Stream.zipLatest(Stream.make("posts" as const), idRef.changes),
|
||||||
f: ([id]) => HttpClient.HttpClient.pipe(
|
f: ([, id]) => HttpClient.HttpClient.pipe(
|
||||||
Effect.tap(Effect.sleep("500 millis")),
|
Effect.tap(Effect.sleep("500 millis")),
|
||||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
||||||
Effect.andThen(response => response.json),
|
Effect.andThen(response => response.json),
|
||||||
@@ -40,11 +39,11 @@ const ResultView = Component.make("ResultView")(function*() {
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
return [idLens, query, mutation] as const
|
return [idRef, query, mutation] as const
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const [id, setId] = yield* Lens.useState(idLens)
|
const [id, setId] = yield* SubscriptionRef.useSubscriptionRefState(idRef)
|
||||||
const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result])
|
const [queryResult, mutationResult] = yield* Subscribable.useSubscribables([query.result, mutation.result])
|
||||||
|
|
||||||
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
||||||
Effect.andThen(observer => observer.subscribe),
|
Effect.andThen(observer => observer.subscribe),
|
||||||
@@ -105,7 +104,7 @@ const ResultView = Component.make("ResultView")(function*() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Flex direction="row" justify="center" align="center" gap="1">
|
<Flex direction="row" justify="center" align="center" gap="1">
|
||||||
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button>
|
<Button onClick={() => runPromise(Effect.andThen(idRef, id => mutation.mutate([id])))}>Mutate</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const ResultView = Component.makeUntraced("Result")(function*() {
|
|||||||
Effect.tap(Effect.sleep("250 millis")),
|
Effect.tap(Effect.sleep("250 millis")),
|
||||||
Result.forkEffect,
|
Result.forkEffect,
|
||||||
))
|
))
|
||||||
const [result] = yield* Subscribable.useAll([resultSubscribable])
|
const [result] = yield* Subscribable.useSubscribables([resultSubscribable])
|
||||||
|
|
||||||
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
||||||
Effect.andThen(observer => observer.subscribe),
|
Effect.andThen(observer => observer.subscribe),
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import { Box, Flex, IconButton } from "@radix-ui/themes"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { Component, Form, Subscribable, SynchronizedForm } from "effect-fc"
|
|
||||||
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
|
|
||||||
import { FaDeleteLeft } from "react-icons/fa6"
|
|
||||||
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
|
||||||
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
|
|
||||||
import { TodoFormSchema } from "./TodoFormSchema"
|
|
||||||
import { TodosState } from "./TodosState"
|
|
||||||
|
|
||||||
|
|
||||||
export interface EditTodoViewProps {
|
|
||||||
readonly id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EditTodoView extends Component.make("TodoView")(function*(props: EditTodoViewProps) {
|
|
||||||
const state = yield* TodosState
|
|
||||||
|
|
||||||
const [
|
|
||||||
indexSubscribable,
|
|
||||||
contentField,
|
|
||||||
completedAtField,
|
|
||||||
] = yield* Component.useOnChange(() => Effect.gen(function*() {
|
|
||||||
const indexSubscribable = state.getIndexSubscribable(props.id)
|
|
||||||
|
|
||||||
const form = yield* SynchronizedForm.service({
|
|
||||||
schema: TodoFormSchema,
|
|
||||||
target: state.getElementLens(props.id),
|
|
||||||
})
|
|
||||||
|
|
||||||
return [
|
|
||||||
indexSubscribable,
|
|
||||||
Form.focusObjectOn(form, "content"),
|
|
||||||
Form.focusObjectOn(form, "completedAt"),
|
|
||||||
] as const
|
|
||||||
}), [props.id])
|
|
||||||
|
|
||||||
const [index, size] = yield* Subscribable.useAll([
|
|
||||||
indexSubscribable,
|
|
||||||
state.sizeSubscribable,
|
|
||||||
])
|
|
||||||
|
|
||||||
const runSync = yield* Component.useRunSync()
|
|
||||||
const TextFieldFormInput = yield* TextFieldFormInputView.use
|
|
||||||
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex direction="row" align="center" gap="2">
|
|
||||||
<Box flexGrow="1">
|
|
||||||
<Flex direction="column" align="stretch" gap="2">
|
|
||||||
<TextFieldFormInput
|
|
||||||
form={contentField}
|
|
||||||
debounce="250 millis"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Flex direction="row" justify="center" align="center" gap="2">
|
|
||||||
<TextFieldOptionalFormInput
|
|
||||||
form={completedAtField}
|
|
||||||
type="datetime-local"
|
|
||||||
defaultValue=""
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Flex direction="column" justify="center" align="center" gap="1">
|
|
||||||
<IconButton
|
|
||||||
disabled={index <= 0}
|
|
||||||
onClick={() => runSync(state.moveLeft(props.id))}
|
|
||||||
>
|
|
||||||
<FaArrowUp />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
disabled={index >= size - 1}
|
|
||||||
onClick={() => runSync(state.moveRight(props.id))}
|
|
||||||
>
|
|
||||||
<FaArrowDown />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<IconButton onClick={() => runSync(state.remove(props.id))}>
|
|
||||||
<FaDeleteLeft />
|
|
||||||
</IconButton>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}) {}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { Box, Button, Flex } from "@radix-ui/themes"
|
|
||||||
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
|
||||||
import { Chunk, type DateTime, Effect, Option, Schema } from "effect"
|
|
||||||
import { Component, Form, Lens, SubmittableForm, Subscribable } from "effect-fc"
|
|
||||||
import * as Domain from "@/domain"
|
|
||||||
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
|
||||||
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
|
|
||||||
import { TodoFormSchema } from "./TodoFormSchema"
|
|
||||||
import { TodosState } from "./TodosState"
|
|
||||||
|
|
||||||
|
|
||||||
const makeTodo = makeUuid4.pipe(
|
|
||||||
Effect.map(id => Domain.Todo.Todo.make({
|
|
||||||
id,
|
|
||||||
content: "",
|
|
||||||
completedAt: Option.none(),
|
|
||||||
})),
|
|
||||||
Effect.provide(GetRandomValues.CryptoRandom),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
export class NewTodoView extends Component.make("NewTodoView")(function*() {
|
|
||||||
const state = yield* TodosState
|
|
||||||
|
|
||||||
const [
|
|
||||||
form,
|
|
||||||
contentField,
|
|
||||||
completedAtField,
|
|
||||||
] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
|
||||||
const form = yield* SubmittableForm.service({
|
|
||||||
schema: TodoFormSchema,
|
|
||||||
initialEncodedValue: yield* Schema.encode(TodoFormSchema)(yield* makeTodo),
|
|
||||||
f: ([todo, form]) => Lens.update(state.lens, Chunk.prepend(todo)).pipe(
|
|
||||||
Effect.andThen(makeTodo),
|
|
||||||
Effect.andThen(Schema.encode(TodoFormSchema)),
|
|
||||||
Effect.andThen(v => Lens.set(form.encodedValue, v)),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
return [
|
|
||||||
form,
|
|
||||||
Form.focusObjectOn(form, "content"),
|
|
||||||
Form.focusObjectOn(form, "completedAt"),
|
|
||||||
] as const
|
|
||||||
}))
|
|
||||||
|
|
||||||
const [canCommit] = yield* Subscribable.useAll([form.canCommit])
|
|
||||||
|
|
||||||
const runPromise = yield* Component.useRunPromise<DateTime.CurrentTimeZone>()
|
|
||||||
const TextFieldFormInput = yield* TextFieldFormInputView.use
|
|
||||||
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex direction="row" align="center" gap="2">
|
|
||||||
<Box flexGrow="1">
|
|
||||||
<Flex direction="column" align="stretch" gap="2">
|
|
||||||
<TextFieldFormInput
|
|
||||||
form={contentField}
|
|
||||||
debounce="250 millis"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Flex direction="row" justify="center" align="center" gap="2">
|
|
||||||
<TextFieldOptionalFormInput
|
|
||||||
form={completedAtField}
|
|
||||||
type="datetime-local"
|
|
||||||
defaultValue=""
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button disabled={!canCommit} onClick={() => void runPromise(form.submit)}>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}) {}
|
|
||||||
135
packages/example/src/todo/Todo.tsx
Normal file
135
packages/example/src/todo/Todo.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { Box, Button, Flex, IconButton } from "@radix-ui/themes"
|
||||||
|
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
||||||
|
import { Chunk, type DateTime, Effect, Match, Option, Ref, Schema, Stream } from "effect"
|
||||||
|
import { Component, Form, Subscribable } from "effect-fc"
|
||||||
|
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
|
||||||
|
import { FaDeleteLeft } from "react-icons/fa6"
|
||||||
|
import * as Domain from "@/domain"
|
||||||
|
import { TextFieldFormInput } from "@/lib/form/TextFieldFormInput"
|
||||||
|
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
||||||
|
import { TodosState } from "./TodosState.service"
|
||||||
|
|
||||||
|
|
||||||
|
const TodoFormSchema = Schema.compose(Schema.Struct({
|
||||||
|
...Domain.Todo.Todo.fields,
|
||||||
|
completedAt: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
|
||||||
|
}), Domain.Todo.Todo)
|
||||||
|
|
||||||
|
const makeTodo = makeUuid4.pipe(
|
||||||
|
Effect.map(id => Domain.Todo.Todo.make({
|
||||||
|
id,
|
||||||
|
content: "",
|
||||||
|
completedAt: Option.none(),
|
||||||
|
})),
|
||||||
|
Effect.provide(GetRandomValues.CryptoRandom),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
export type TodoProps = (
|
||||||
|
| { readonly _tag: "new" }
|
||||||
|
| { readonly _tag: "edit", readonly id: string }
|
||||||
|
)
|
||||||
|
|
||||||
|
export class Todo extends Component.makeUntraced("Todo")(function*(props: TodoProps) {
|
||||||
|
const state = yield* TodosState
|
||||||
|
|
||||||
|
const [
|
||||||
|
indexRef,
|
||||||
|
form,
|
||||||
|
contentField,
|
||||||
|
completedAtField,
|
||||||
|
] = yield* Component.useOnChange(() => Effect.gen(function*() {
|
||||||
|
const indexRef = Match.value(props).pipe(
|
||||||
|
Match.tag("new", () => Subscribable.make({ get: Effect.succeed(-1), changes: Stream.make(-1) })),
|
||||||
|
Match.tag("edit", ({ id }) => state.getIndexSubscribable(id)),
|
||||||
|
Match.exhaustive,
|
||||||
|
)
|
||||||
|
|
||||||
|
const form = yield* Form.service({
|
||||||
|
schema: TodoFormSchema,
|
||||||
|
initialEncodedValue: yield* Schema.encode(TodoFormSchema)(
|
||||||
|
yield* Match.value(props).pipe(
|
||||||
|
Match.tag("new", () => makeTodo),
|
||||||
|
Match.tag("edit", ({ id }) => state.getElementRef(id)),
|
||||||
|
Match.exhaustive,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
f: ([todo, form]) => Match.value(props).pipe(
|
||||||
|
Match.tag("new", () => Ref.update(state.ref, Chunk.prepend(todo)).pipe(
|
||||||
|
Effect.andThen(makeTodo),
|
||||||
|
Effect.andThen(Schema.encode(TodoFormSchema)),
|
||||||
|
Effect.andThen(v => Ref.set(form.encodedValue, v)),
|
||||||
|
)),
|
||||||
|
Match.tag("edit", ({ id }) => Ref.set(state.getElementRef(id), todo)),
|
||||||
|
Match.exhaustive,
|
||||||
|
),
|
||||||
|
autosubmit: props._tag === "edit",
|
||||||
|
debounce: "250 millis",
|
||||||
|
})
|
||||||
|
|
||||||
|
return [
|
||||||
|
indexRef,
|
||||||
|
form,
|
||||||
|
yield* form.field(["content"]),
|
||||||
|
yield* form.field(["completedAt"]),
|
||||||
|
] as const
|
||||||
|
}), [props._tag, props._tag === "edit" ? props.id : undefined])
|
||||||
|
|
||||||
|
const [index, size, canSubmit] = yield* Subscribable.useSubscribables([
|
||||||
|
indexRef,
|
||||||
|
state.sizeSubscribable,
|
||||||
|
form.canSubmit,
|
||||||
|
])
|
||||||
|
|
||||||
|
const runSync = yield* Component.useRunSync()
|
||||||
|
const runPromise = yield* Component.useRunPromise<DateTime.CurrentTimeZone>()
|
||||||
|
const TextFieldFormInputFC = yield* TextFieldFormInput
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="row" align="center" gap="2">
|
||||||
|
<Box flexGrow="1">
|
||||||
|
<Flex direction="column" align="stretch" gap="2">
|
||||||
|
<TextFieldFormInputFC field={contentField} />
|
||||||
|
|
||||||
|
<Flex direction="row" justify="center" align="center" gap="2">
|
||||||
|
<TextFieldFormInputFC
|
||||||
|
optional
|
||||||
|
field={completedAtField}
|
||||||
|
type="datetime-local"
|
||||||
|
defaultValue=""
|
||||||
|
/>
|
||||||
|
|
||||||
|
{props._tag === "new" &&
|
||||||
|
<Button disabled={!canSubmit} onClick={() => void runPromise(form.submit)}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{props._tag === "edit" &&
|
||||||
|
<Flex direction="column" justify="center" align="center" gap="1">
|
||||||
|
<IconButton
|
||||||
|
disabled={index <= 0}
|
||||||
|
onClick={() => runSync(state.moveLeft(props.id))}
|
||||||
|
>
|
||||||
|
<FaArrowUp />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
disabled={index >= size - 1}
|
||||||
|
onClick={() => runSync(state.moveRight(props.id))}
|
||||||
|
>
|
||||||
|
<FaArrowDown />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton onClick={() => runSync(state.remove(props.id))}>
|
||||||
|
<FaDeleteLeft />
|
||||||
|
</IconButton>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}) {}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Schema } from "effect"
|
|
||||||
import * as Domain from "@/domain"
|
|
||||||
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
|
||||||
|
|
||||||
|
|
||||||
export const TodoFormSchema = Schema.compose(Schema.Struct({
|
|
||||||
...Domain.Todo.Todo.fields,
|
|
||||||
completedAt: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
|
|
||||||
}), Domain.Todo.Todo)
|
|
||||||
@@ -1,32 +1,30 @@
|
|||||||
import { Container, Flex, Heading } from "@radix-ui/themes"
|
import { Container, Flex, Heading } from "@radix-ui/themes"
|
||||||
import { Chunk, Console, Effect } from "effect"
|
import { Chunk, Console, Effect } from "effect"
|
||||||
import { Component, Subscribable } from "effect-fc"
|
import { Component, Subscribable } from "effect-fc"
|
||||||
import { EditTodoView } from "./EditTodoView"
|
import { Todo } from "./Todo"
|
||||||
import { NewTodoView } from "./NewTodoView"
|
import { TodosState } from "./TodosState.service"
|
||||||
import { TodosState } from "./TodosState"
|
|
||||||
|
|
||||||
|
|
||||||
export class TodosView extends Component.make("TodosView")(function*() {
|
export class Todos extends Component.makeUntraced("Todos")(function*() {
|
||||||
const state = yield* TodosState
|
const state = yield* TodosState
|
||||||
const [todos] = yield* Subscribable.useAll([state.lens])
|
const [todos] = yield* Subscribable.useSubscribables([state.ref])
|
||||||
|
|
||||||
yield* Component.useOnMount(() => Effect.andThen(
|
yield* Component.useOnMount(() => Effect.andThen(
|
||||||
Console.log("Todos mounted"),
|
Console.log("Todos mounted"),
|
||||||
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
||||||
))
|
))
|
||||||
|
|
||||||
const NewTodo = yield* NewTodoView.use
|
const TodoFC = yield* Todo
|
||||||
const EditTodo = yield* EditTodoView.use
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Heading align="center">Todos</Heading>
|
<Heading align="center">Todos</Heading>
|
||||||
|
|
||||||
<Flex direction="column" align="stretch" gap="2" mt="2">
|
<Flex direction="column" align="stretch" gap="2" mt="2">
|
||||||
<NewTodo />
|
<TodoFC _tag="new" />
|
||||||
|
|
||||||
{Chunk.map(todos, todo =>
|
{Chunk.map(todos, todo =>
|
||||||
<EditTodo key={todo.id} id={todo.id} />
|
<TodoFC key={todo.id} _tag="edit" id={todo.id} />
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { KeyValueStore } from "@effect/platform"
|
import { KeyValueStore } from "@effect/platform"
|
||||||
import { BrowserKeyValueStore } from "@effect/platform-browser"
|
import { BrowserKeyValueStore } from "@effect/platform-browser"
|
||||||
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
|
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
|
||||||
import { Lens, Subscribable } from "effect-fc"
|
import { Subscribable, SubscriptionSubRef } from "effect-fc"
|
||||||
import { Todo } from "@/domain"
|
import { Todo } from "@/domain"
|
||||||
|
|
||||||
|
|
||||||
@@ -30,29 +30,27 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
|||||||
: kv.remove(key)
|
: kv.remove(key)
|
||||||
)
|
)
|
||||||
|
|
||||||
const lens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(yield* readFromLocalStorage))
|
const ref = yield* SubscriptionRef.make(yield* readFromLocalStorage)
|
||||||
yield* Effect.forkScoped(lens.changes.pipe(
|
yield* Effect.forkScoped(ref.changes.pipe(
|
||||||
Stream.debounce("500 millis"),
|
Stream.debounce("500 millis"),
|
||||||
Stream.runForEach(saveToLocalStorage),
|
Stream.runForEach(saveToLocalStorage),
|
||||||
))
|
))
|
||||||
yield* Effect.addFinalizer(() => Lens.get(lens).pipe(
|
yield* Effect.addFinalizer(() => ref.pipe(
|
||||||
Effect.andThen(saveToLocalStorage),
|
Effect.andThen(saveToLocalStorage),
|
||||||
Effect.ignore,
|
Effect.ignore,
|
||||||
))
|
))
|
||||||
|
|
||||||
const sizeSubscribable = Subscribable.map(lens, Chunk.size)
|
const sizeSubscribable = Subscribable.make({
|
||||||
|
get: Effect.andThen(ref, Chunk.size),
|
||||||
|
get changes() { return Stream.map(ref.changes, Chunk.size) },
|
||||||
|
})
|
||||||
|
const getElementRef = (id: string) => SubscriptionSubRef.makeFromChunkFindFirst(ref, v => v.id === id)
|
||||||
|
const getIndexSubscribable = (id: string) => Subscribable.make({
|
||||||
|
get: Effect.flatMap(ref, Chunk.findFirstIndex(v => v.id === id)),
|
||||||
|
get changes() { return Stream.flatMap(ref.changes, Chunk.findFirstIndex(v => v.id === id)) },
|
||||||
|
})
|
||||||
|
|
||||||
const getElementLens = (id: string) => Lens.mapEffect(
|
const moveLeft = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
|
||||||
lens,
|
|
||||||
Chunk.findFirst(v => v.id === id),
|
|
||||||
(a, b) => Effect.flatMap(
|
|
||||||
Chunk.findFirstIndex(a, v => v.id === id),
|
|
||||||
i => Chunk.replaceOption(a, i, b),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const getIndexSubscribable = (id: string) => Subscribable.mapEffect(lens, Chunk.findFirstIndex(v => v.id === id))
|
|
||||||
|
|
||||||
const moveLeft = (id: string) => Lens.updateEffect(lens, todos => Effect.Do.pipe(
|
|
||||||
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
|
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
|
||||||
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
|
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
|
||||||
Effect.bind("previous", ({ index }) => Chunk.get(todos, index - 1)),
|
Effect.bind("previous", ({ index }) => Chunk.get(todos, index - 1)),
|
||||||
@@ -64,7 +62,7 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
|||||||
: todos
|
: todos
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
const moveRight = (id: string) => Lens.updateEffect(lens, todos => Effect.Do.pipe(
|
const moveRight = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
|
||||||
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
|
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
|
||||||
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
|
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
|
||||||
Effect.bind("next", ({ index }) => Chunk.get(todos, index + 1)),
|
Effect.bind("next", ({ index }) => Chunk.get(todos, index + 1)),
|
||||||
@@ -76,15 +74,15 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
|||||||
: todos
|
: todos
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
const remove = (id: string) => Lens.updateEffect(lens, todos => Effect.andThen(
|
const remove = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.andThen(
|
||||||
Chunk.findFirstIndex(todos, v => v.id === id),
|
Chunk.findFirstIndex(todos, v => v.id === id),
|
||||||
index => Chunk.remove(todos, index),
|
index => Chunk.remove(todos, index),
|
||||||
))
|
))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lens,
|
ref,
|
||||||
sizeSubscribable,
|
sizeSubscribable,
|
||||||
getElementLens,
|
getElementRef,
|
||||||
getIndexSubscribable,
|
getIndexSubscribable,
|
||||||
moveLeft,
|
moveLeft,
|
||||||
moveRight,
|
moveRight,
|
||||||
Reference in New Issue
Block a user