Compare commits
93 Commits
master
..
8ae04b7fbd
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ae04b7fbd | |||
| 9f4b3a1be5 | |||
| 6c31b39ddb | |||
| 22672c98dc | |||
| 1588bd0a1b | |||
| a373aee08b | |||
| 4d76b35c01 | |||
| 7572caea91 | |||
| 3d3491e8c9 | |||
| 717f264aa1 | |||
| 9978f6811f | |||
| 891488b3f5 | |||
| 165b87e86c | |||
| b9ab700139 | |||
| 0de3256692 | |||
| 8ec900b14a | |||
| de774a8517 | |||
| 56f4e2d1f1 | |||
| ee1c0c45cd | |||
| 0f20587827 | |||
| 075e92cb66 | |||
| 212da47e12 | |||
| 0d1567f4a8 | |||
| 7a16e17341 | |||
| 4fdaca2973 | |||
| c6655ba168 | |||
| 8cc8aa4b56 | |||
| 5f8965ce11 | |||
| 1f36891e37 | |||
| 7be29b7270 | |||
| 113d105fac | |||
| cbdcee039a | |||
| 47389bb842 | |||
| 8ced53b6af | |||
| efdf58d8f9 | |||
| 1bf7e1dc4c | |||
| 571592b3dc | |||
| 47073f2bf1 | |||
| 13a12f5938 | |||
| a2f3a07834 | |||
| 8fb997a2a0 | |||
| ff72c83ef0 | |||
| 80c434d390 | |||
| f1d0771356 | |||
| 2646e295d9 | |||
| 45bf604381 | |||
| 6fa34069ea | |||
| c338682bf2 | |||
| 087317171a | |||
| f08cc59fef | |||
| 34b9452c1c | |||
| 74dd87f4ea | |||
| 6e939884cc | |||
| 8c86c1ce76 | |||
| e175eac701 | |||
| 7f18fc5553 | |||
| 3ff4e8758a | |||
| 4ae32fce49 | |||
| 1a25214984 | |||
| 580c6ec3d3 | |||
| c6db61c258 | |||
| eea6bcac4d | |||
| ef1de00020 | |||
| 99f5e089f5 | |||
| 8430b4ddf6 | |||
| 88ad7cb1ac | |||
| 11d23aa10c | |||
| 3f05a5099e | |||
| a30c527803 | |||
| 10d69f977b | |||
| 7f8411e83c | |||
| e89babe223 | |||
| aab613030d | |||
| 84bf50032b | |||
| b8ad8a94c9 | |||
| 99bdd6a3ec | |||
| 64d6c20d06 | |||
| 285fc84275 | |||
| 821ba95247 | |||
| 45c854a8d0 | |||
| 54b05ed8da | |||
| 0cb85adca0 | |||
| 7f57e034e4 | |||
| 4f4dde988a | |||
| ba911ad598 | |||
| 672225d037 | |||
| 39125943ec | |||
| bba8d838b7 | |||
| 7fcf3b825d | |||
| 64c3ee8395 | |||
| 588ab5832f | |||
| 94e838af43 | |||
| 154e27bcc0 |
@@ -16,5 +16,3 @@ jobs:
|
||||
run: bun lint:tsc
|
||||
- name: Lint Biome
|
||||
run: bun lint:biome
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
@@ -11,42 +11,16 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.valverde.cloud
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v6
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Lint TypeScript
|
||||
run: bun lint:tsc
|
||||
- name: Lint Biome
|
||||
run: bun lint:biome
|
||||
- name: Test
|
||||
run: bun run test
|
||||
- name: Build
|
||||
run: bun run build
|
||||
- name: Publish effect-view
|
||||
uses: JS-DevTools/npm-publish@v4
|
||||
with:
|
||||
package: packages/effect-view
|
||||
access: public
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
registry: https://registry.npmjs.org
|
||||
- name: Publish @effect-view/vite-plugin
|
||||
uses: JS-DevTools/npm-publish@v4
|
||||
with:
|
||||
package: packages/vite-plugin
|
||||
access: public
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
registry: https://registry.npmjs.org
|
||||
- name: Publish effect-fc
|
||||
uses: JS-DevTools/npm-publish@v4
|
||||
with:
|
||||
@@ -54,30 +28,3 @@ jobs:
|
||||
access: public
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
registry: https://registry.npmjs.org
|
||||
|
||||
- name: Clean before Docker build
|
||||
run: |
|
||||
bun clean:cache
|
||||
bun clean:dist
|
||||
bun clean:modules
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.valverde.cloud/thilawyn/effect-view
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
flavor: |
|
||||
latest=true
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -12,49 +12,16 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
node-version: "24"
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v6
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Lint TypeScript
|
||||
run: bun lint:tsc
|
||||
- name: Lint Biome
|
||||
run: bun lint:biome
|
||||
- name: Test
|
||||
run: bun run test
|
||||
- name: Build
|
||||
run: bun run build
|
||||
- name: Pack
|
||||
run: bun pack
|
||||
|
||||
- name: Clean before Docker build
|
||||
run: |
|
||||
bun clean:cache
|
||||
bun clean:dist
|
||||
bun clean:modules
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.valverde.cloud/thilawyn/effect-view
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
flavor: |
|
||||
latest=true
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
FROM oven/bun:1.3.12-debian@sha256:1b709c9dd883fc1af38c210f7ea5222c552a8d470ea73efbd4b8fcfee798a64b AS bun
|
||||
|
||||
FROM node:22.21.1-trixie-slim@sha256:98e1429d1a0b99378b4de43fa385f0746fd6276faf4feeb6104d91f6bad290f9
|
||||
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bunx /usr/local/bin/
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
RUN bun install --frozen-lockfile && \
|
||||
bun run build && \
|
||||
bun clean:cache && \
|
||||
bun clean:modules && \
|
||||
bun install --production --frozen-lockfile
|
||||
@@ -1,61 +1,7 @@
|
||||
<p align="center">
|
||||
<a href="https://thila.dev/effect-view">
|
||||
<img src="https://github.com/Thiladev/effect-view/raw/master/packages/docs/static/img/logo.svg" width="104" height="104" alt="Effect View logo" />
|
||||
</a>
|
||||
</p>
|
||||
# Effect FC Monorepo
|
||||
|
||||
<h1 align="center">Effect View Monorepo</h1>
|
||||
[Effect-TS](https://effect.website/) integration for React 19.2+ that allows you to write function components using Effect generators.
|
||||
|
||||
<p align="center">
|
||||
Write React components as typed Effect programs.
|
||||
</p>
|
||||
|
||||
This repository contains Effect View, its documentation, development examples,
|
||||
and supporting tooling. Effect View brings Effect's typed services, resource
|
||||
safety, concurrency, and data modeling to React while keeping ordinary React
|
||||
components, hooks, and JSX.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Description |
|
||||
| --- | --- |
|
||||
| [`effect-view`](packages/effect-view) | The Effect View library for Effect v4 and React 19. |
|
||||
| [`@effect-view/vite-plugin`](packages/vite-plugin) | Vite Fast Refresh support for Effect View components. |
|
||||
| [`docs`](packages/docs) | The [Effect View documentation](https://thila.dev/effect-view), built with Docusaurus. |
|
||||
| [`example`](packages/example) | Example application using Effect View and Effect v4. |
|
||||
| [`effect-fc`](packages/effect-fc) | Legacy Effect v3 package. |
|
||||
| [`effect-fc-example`](packages/effect-fc-example) | Example application for the legacy package. |
|
||||
|
||||
## Development
|
||||
|
||||
The monorepo uses [Bun](https://bun.sh/) workspaces and
|
||||
[Turborepo](https://turbo.build/).
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
Run checks and builds from the repository root:
|
||||
|
||||
```bash
|
||||
bun run lint:tsc
|
||||
bun run lint:biome
|
||||
bun run test
|
||||
bun run build
|
||||
```
|
||||
|
||||
Start the Effect View example or documentation site:
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/example dev
|
||||
bun run --cwd packages/docs start
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Read the documentation at
|
||||
[thila.dev/effect-view](https://thila.dev/effect-view).
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
This monorepo contains:
|
||||
- [The `effect-fc` library](packages/effect-fc)
|
||||
- [An example project](packages/example)
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
{
|
||||
"name": "@effect-view/monorepo",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"name": "@effect-fc/monorepo",
|
||||
"packageManager": "bun@1.3.11",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
"lint:tsc": "turbo lint:tsc",
|
||||
"lint:biome": "turbo lint:biome",
|
||||
"test": "turbo test",
|
||||
"build": "turbo build",
|
||||
"pack": "turbo pack",
|
||||
"clean:cache": "turbo clean:cache",
|
||||
"clean:dist": "turbo clean:dist",
|
||||
"clean:modules": "turbo clean:modules && rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.5",
|
||||
"@effect/language-service": "^0.87.1",
|
||||
"@types/bun": "^1.3.14",
|
||||
"npm-check-updates": "^23.0.0",
|
||||
"@biomejs/biome": "^2.4.9",
|
||||
"@effect/language-service": "^0.84.2",
|
||||
"@types/bun": "^1.3.11",
|
||||
"npm-check-updates": "^19.6.6",
|
||||
"npm-sort": "^0.0.4",
|
||||
"turbo": "^2.10.7",
|
||||
"typescript": "^7.0.2"
|
||||
"turbo": "^2.8.21",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
# effect-fc Docs
|
||||
# Website
|
||||
|
||||
The documentation site is built with Docusaurus.
|
||||
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
yarn
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/docs start
|
||||
yarn start
|
||||
```
|
||||
|
||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/docs build
|
||||
yarn build
|
||||
```
|
||||
|
||||
The static site is written to `packages/docs/build`.
|
||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||
|
||||
## Deployment
|
||||
|
||||
Using SSH:
|
||||
|
||||
```bash
|
||||
USE_SSH=true yarn deploy
|
||||
```
|
||||
|
||||
Not using SSH:
|
||||
|
||||
```bash
|
||||
GIT_USER=<Your GitHub username> yarn deploy
|
||||
```
|
||||
|
||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||
|
||||
@@ -4,15 +4,5 @@
|
||||
"extends": "//",
|
||||
"files": {
|
||||
"includes": ["./src/**"]
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
"complexity": {
|
||||
"noImportantStyles": "off"
|
||||
},
|
||||
"style": {
|
||||
"noDescendingSpecificity": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
slug: first-blog-post
|
||||
title: First Blog Post
|
||||
authors: [slorber, yangshun]
|
||||
tags: [hola, docusaurus]
|
||||
---
|
||||
|
||||
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
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
slug: long-blog-post
|
||||
title: Long Blog Post
|
||||
authors: yangshun
|
||||
tags: [hello, docusaurus]
|
||||
---
|
||||
|
||||
This is the summary of a very long blog post,
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
slug: mdx-blog-post
|
||||
title: MDX Blog Post
|
||||
authors: [slorber]
|
||||
tags: [docusaurus]
|
||||
---
|
||||
|
||||
Blog posts support [Docusaurus Markdown features](https://docusaurus.io/docs/markdown-features), such as [MDX](https://mdxjs.com/).
|
||||
|
||||
:::tip
|
||||
|
||||
Use the power of React to create interactive blog posts.
|
||||
|
||||
:::
|
||||
|
||||
{/* truncate */}
|
||||
|
||||
For example, use JSX to create an interactive button:
|
||||
|
||||
```js
|
||||
<button onClick={() => alert('button clicked!')}>Click me!</button>
|
||||
```
|
||||
|
||||
<button onClick={() => alert('button clicked!')}>Click me!</button>
|
||||
|
After Width: | Height: | Size: 94 KiB |
@@ -0,0 +1,29 @@
|
||||
---
|
||||
slug: welcome
|
||||
title: Welcome
|
||||
authors: [slorber, yangshun]
|
||||
tags: [facebook, hello, docusaurus]
|
||||
---
|
||||
|
||||
[Docusaurus blogging features](https://docusaurus.io/docs/blog) are powered by the [blog plugin](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-blog).
|
||||
|
||||
Here are a few tips you might find useful.
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
Simply add Markdown files (or folders) to the `blog` directory.
|
||||
|
||||
Regular blog authors can be added to `authors.yml`.
|
||||
|
||||
The blog post date can be extracted from filenames, such as:
|
||||
|
||||
- `2019-05-30-welcome.md`
|
||||
- `2019-05-30-welcome/index.md`
|
||||
|
||||
A blog post folder can be convenient to co-locate blog post images:
|
||||
|
||||

|
||||
|
||||
The blog supports tags as well!
|
||||
|
||||
**And if you don't want a blog**: just delete this directory, and use `blog: false` in your Docusaurus config.
|
||||
@@ -0,0 +1,25 @@
|
||||
yangshun:
|
||||
name: Yangshun Tay
|
||||
title: Ex-Meta Staff Engineer, Co-founder GreatFrontEnd
|
||||
url: https://linkedin.com/in/yangshun
|
||||
image_url: https://github.com/yangshun.png
|
||||
page: true
|
||||
socials:
|
||||
x: yangshunz
|
||||
linkedin: yangshun
|
||||
github: yangshun
|
||||
newsletter: https://www.greatfrontend.com
|
||||
|
||||
slorber:
|
||||
name: Sébastien Lorber
|
||||
title: Docusaurus maintainer
|
||||
url: https://sebastienlorber.com
|
||||
image_url: https://github.com/slorber.png
|
||||
page:
|
||||
# customize the url of the author page at /blog/authors/<permalink>
|
||||
permalink: '/all-sebastien-lorber-articles'
|
||||
socials:
|
||||
x: sebastienlorber
|
||||
linkedin: sebastienlorber
|
||||
github: slorber
|
||||
newsletter: https://thisweekinreact.com
|
||||
@@ -0,0 +1,19 @@
|
||||
facebook:
|
||||
label: Facebook
|
||||
permalink: /facebook
|
||||
description: Facebook tag description
|
||||
|
||||
hello:
|
||||
label: Hello
|
||||
permalink: /hello
|
||||
description: Hello tag description
|
||||
|
||||
docusaurus:
|
||||
label: Docusaurus
|
||||
permalink: /docusaurus
|
||||
description: Docusaurus tag description
|
||||
|
||||
hola:
|
||||
label: Hola
|
||||
permalink: /hola
|
||||
description: Hola tag description
|
||||
@@ -1,145 +0,0 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Async
|
||||
---
|
||||
|
||||
# Async
|
||||
|
||||
Effect View components run synchronously by default. Apply `Async.async` when a
|
||||
component must wait for an asynchronous Effect before it can return JSX. The
|
||||
component suspends while the Effect runs and accepts React Suspense props such
|
||||
as `fallback`.
|
||||
|
||||
```tsx title="src/UserCard.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Async, Component } from "effect-view"
|
||||
import { loadUser } from "./api"
|
||||
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { readonly userId: string }) {
|
||||
const user = yield* Component.useOnChange(
|
||||
() => loadUser(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return <article>{user.name}</article>
|
||||
},
|
||||
).pipe(Async.async)
|
||||
```
|
||||
|
||||
Pass a fallback when rendering the component:
|
||||
|
||||
```tsx
|
||||
const User = yield* UserCard.use
|
||||
|
||||
return <User userId="123" fallback={<p>Loading user...</p>} />
|
||||
```
|
||||
|
||||
Or configure a default fallback once:
|
||||
|
||||
```tsx
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { readonly userId: string }) {
|
||||
const user = yield* Component.useOnChange(
|
||||
() => loadUser(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return <article>{user.name}</article>
|
||||
},
|
||||
).pipe(
|
||||
Async.async,
|
||||
Async.withOptions({ defaultFallback: <p>Loading user...</p> }),
|
||||
)
|
||||
```
|
||||
|
||||
## Hook ordering
|
||||
|
||||
**Important:** Do not use React hooks or Effect View hook helpers after an
|
||||
asynchronous computation in the component body. After the computation suspends,
|
||||
the generator continuation runs outside React's synchronous render phase.
|
||||
|
||||
Place every hook before the first operation that may suspend:
|
||||
|
||||
```tsx
|
||||
import { useState } from "react"
|
||||
|
||||
const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { readonly userId: string }) {
|
||||
// Hooks and hook helpers go before asynchronous work.
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
// This may suspend, so no hooks may be used after it.
|
||||
const user = yield* Component.useOnChange(
|
||||
() => loadUser(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return (
|
||||
<article>
|
||||
<button onClick={() => setShowDetails((value) => !value)}>
|
||||
{user.name}
|
||||
</button>
|
||||
{showDetails && <p>{user.bio}</p>}
|
||||
</article>
|
||||
)
|
||||
},
|
||||
).pipe(Async.async)
|
||||
```
|
||||
|
||||
## Memoization
|
||||
|
||||
An async computation starts whenever its component renders. Apply
|
||||
`Memoized.memoized` after `Async.async` to prevent an unrelated parent render
|
||||
from restarting an async child whose props have not changed:
|
||||
|
||||
```tsx
|
||||
import { Async, Component, Memoized } from "effect-view"
|
||||
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { readonly userId: string }) {
|
||||
const user = yield* Component.useOnChange(
|
||||
() => loadUser(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return <article>{user.name}</article>
|
||||
},
|
||||
).pipe(
|
||||
Async.async,
|
||||
Memoized.memoized,
|
||||
)
|
||||
```
|
||||
|
||||
Async components compare props with `Object.is` by default and ignore the
|
||||
`fallback` prop during that comparison. A changed `userId` renders the child
|
||||
again, closes the previous dependency scope, and runs `loadUser` for the new
|
||||
value.
|
||||
|
||||
If props contain immutable objects or arrays that may be recreated with the same
|
||||
content, use `Equal.asEquivalence()` for full structural equality:
|
||||
|
||||
```tsx
|
||||
import { Equal } from "effect"
|
||||
import { Async, Component, Memoized } from "effect-view"
|
||||
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* (props: { readonly user: { readonly id: string } }) {
|
||||
const details = yield* Component.useOnChange(
|
||||
() => loadUser(props.user.id),
|
||||
[props.user.id],
|
||||
)
|
||||
|
||||
return <article>{details.name}</article>
|
||||
},
|
||||
).pipe(
|
||||
Async.async,
|
||||
Memoized.memoized,
|
||||
Memoized.withOptions({
|
||||
propsEquivalence: Equal.asEquivalence(),
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Supplying `propsEquivalence` replaces the async component's default comparison,
|
||||
so `fallback` is also included in this structural comparison.
|
||||
@@ -1,473 +0,0 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
title: Forms
|
||||
---
|
||||
|
||||
# Forms
|
||||
|
||||
Effect View forms are built from an Effect `Schema`. The schema is the source of
|
||||
truth for the shape of the form, its validation rules, and the value your
|
||||
application receives. The form layer supplies reactive state and lifecycle; it
|
||||
does not replace your UI components.
|
||||
|
||||
A schema distinguishes the **encoded value** edited by the UI from the
|
||||
**decoded value** used by the application. For example, an `<input>` edits an
|
||||
age as a string, while the submit handler or application state receives a
|
||||
number:
|
||||
|
||||
```tsx
|
||||
import { Schema } from "effect"
|
||||
|
||||
const ProfileSchema = Schema.Struct({
|
||||
displayName: Schema.String.check(
|
||||
Schema.isMinLength(1, { message: "Enter a display name" }),
|
||||
),
|
||||
age: Schema.NumberFromString,
|
||||
contact: Schema.Struct({
|
||||
email: Schema.String.check(
|
||||
Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {
|
||||
message: "Enter a valid email address",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
That schema gives the form several useful properties:
|
||||
|
||||
- Validation stays next to the data definition instead of being duplicated in
|
||||
components and submit handlers.
|
||||
- Inputs can keep UI-friendly encoded values while business logic receives
|
||||
decoded, typed values.
|
||||
- Nested schema issue paths can be routed automatically to the matching
|
||||
subform or field.
|
||||
- Synchronous and effectful schema validation use the same form model.
|
||||
- The same schema can validate a local draft or protect writes to shared state.
|
||||
|
||||
There are two concrete root form implementations. Choose one based on where a
|
||||
valid decoded value should go:
|
||||
|
||||
| Implementation | Owns a local draft | What happens to a valid value |
|
||||
| --- | --- | --- |
|
||||
| `MutationForm` | Yes | It is passed to a mutation when `submit` runs. |
|
||||
| `LensForm` | Yes | It is written automatically to a target `Lens`. |
|
||||
|
||||
## Create forms once
|
||||
|
||||
`MutationForm.make` and `LensForm.make` construct forms. Pipe them through the
|
||||
module's `run` operator to start validation or synchronization in the current
|
||||
scope. Create each root form once and keep it stable.
|
||||
|
||||
## MutationForm: validate, then submit
|
||||
|
||||
Use `MutationForm` for registration, checkout, search, and other workflows with
|
||||
an explicit submit action. It owns the encoded draft, continuously decodes it
|
||||
with the schema, and exposes the last valid decoded value. Calling `submit`
|
||||
runs the mutation only when the form can commit.
|
||||
|
||||
```tsx
|
||||
import { Effect } from "effect"
|
||||
import { Component, MutationForm, View } from "effect-view"
|
||||
|
||||
const CreateProfileView = Component.make("CreateProfile")(function* () {
|
||||
const form = yield* Component.useOnMount(() =>
|
||||
MutationForm.make({
|
||||
schema: ProfileSchema,
|
||||
initialEncodedValue: {
|
||||
displayName: "",
|
||||
age: "",
|
||||
contact: { email: "" },
|
||||
},
|
||||
f: ([profile]) =>
|
||||
Effect.log(
|
||||
`Creating ${profile.displayName}, age ${profile.age}`,
|
||||
),
|
||||
}).pipe(MutationForm.thenRun),
|
||||
)
|
||||
|
||||
const [canCommit, isCommitting] = yield* View.useAll([
|
||||
form.canCommit,
|
||||
form.isCommitting,
|
||||
])
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
// Focused inputs are added in "Focus into subforms" below.
|
||||
return (
|
||||
<button
|
||||
disabled={!canCommit || isCommitting}
|
||||
onClick={() => void runPromise(form.submit)}
|
||||
>
|
||||
{isCommitting ? "Creating..." : "Create profile"}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Notice that `age` starts as a string because it is an encoded input value. The
|
||||
mutation receives the schema's decoded profile, so `profile.age` is a number.
|
||||
Schema transformations happen before the mutation and schema issues prevent an
|
||||
invalid draft from being submitted.
|
||||
|
||||
`MutationForm.thenRun` starts initial validation. In the example above,
|
||||
`Component.useOnMount` keeps that validation and the form's mutation work tied
|
||||
to the component that owns them.
|
||||
|
||||
## LensForm: validate, then synchronize
|
||||
|
||||
Use `LensForm` for settings panels, inspectors, and edit screens where valid
|
||||
changes should update existing state without a final submit. Its target holds
|
||||
decoded application data; the form derives an encoded draft from that target
|
||||
and keeps the two synchronized in both directions.
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, LensForm, View } from "effect-view"
|
||||
|
||||
const EditProfileView = Component.make("EditProfile")(function* () {
|
||||
const [form, profile] = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
const profile = Lens.fromSubscriptionRef(
|
||||
yield* SubscriptionRef.make({
|
||||
displayName: "Ada",
|
||||
age: 37,
|
||||
contact: { email: "ada@example.com" },
|
||||
}),
|
||||
)
|
||||
|
||||
const form = yield* LensForm.make({
|
||||
schema: ProfileSchema,
|
||||
target: profile,
|
||||
}).pipe(LensForm.thenRun)
|
||||
|
||||
return [form, profile] as const
|
||||
}),
|
||||
)
|
||||
|
||||
const [savedProfile, isCommitting] = yield* View.useAll([
|
||||
profile,
|
||||
form.isCommitting,
|
||||
])
|
||||
|
||||
// Focused inputs are added in "Focus into subforms" below.
|
||||
return (
|
||||
<output>
|
||||
{isCommitting
|
||||
? "Saving..."
|
||||
: `${savedProfile.displayName} is ${savedProfile.age}`}
|
||||
</output>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Here the target contains `age: 37`, while the form exposes the encoded value
|
||||
`age: "37"` to an input. A valid edit is decoded and written to `profile`.
|
||||
Invalid input remains in the form so the user can correct it, but it never
|
||||
reaches the target. If another part of the application updates the target,
|
||||
`LensForm` encodes that value back into the draft.
|
||||
|
||||
Pass `initialEncodedValue` only when the first draft should differ from the
|
||||
encoded target. Otherwise `LensForm.make` obtains the initial draft by encoding
|
||||
the target through the schema.
|
||||
|
||||
## Focus into subforms
|
||||
|
||||
The root `MutationForm` or `LensForm` represents the complete schema. UI
|
||||
components usually need a smaller form representing one field, so they do not
|
||||
have to receive or understand the entire root form. **Focusing** creates that
|
||||
smaller form.
|
||||
|
||||
Every root form and every focused subform implements the same `Form.Form`
|
||||
interface. There is no separate field abstraction: an individual field is
|
||||
simply a `Form` focused on that part of its parent.
|
||||
|
||||
```tsx
|
||||
const displayNameField = Form.focusObjectOn(form, "displayName")
|
||||
const ageField = Form.focusObjectOn(form, "age")
|
||||
|
||||
const contactForm = Form.focusObjectOn(form, "contact")
|
||||
const emailField = Form.focusObjectOn(contactForm, "email")
|
||||
```
|
||||
|
||||
Form focus helpers also have a dual API. Pass only the key or index to get a curried function. Curried helpers can be chained
|
||||
with `pipe` to focus through a deeper path:
|
||||
|
||||
```tsx
|
||||
const emailField = form.pipe(
|
||||
Form.focusObjectOn("contact"),
|
||||
Form.focusObjectOn("email"),
|
||||
)
|
||||
```
|
||||
|
||||
`displayNameField`, `ageField`, and `emailField` can each be passed to the input
|
||||
component responsible for that field. Focusing can also be repeated:
|
||||
`contactForm` represents the complete contact section, and `emailField`
|
||||
represents its email field.
|
||||
|
||||
A focused form exposes only the relevant:
|
||||
|
||||
- `encodedValue`: the UI-facing value that can be edited.
|
||||
- `value`: the decoded value as an `Option`.
|
||||
- `issues`: the schema issues for that subform.
|
||||
|
||||
Validation and commit state remain connected to the root form. An input can
|
||||
show its own issues while still observing the root form's `isValidating`,
|
||||
`canCommit`, and `isCommitting` state.
|
||||
|
||||
Use the focusing helper that matches the value being represented:
|
||||
|
||||
```tsx
|
||||
const property = Form.focusObjectOn(form, "contact")
|
||||
const item = Form.focusArrayAt(itemsForm, 0)
|
||||
const tupleMember = Form.focusTupleAt(tupleForm, 1)
|
||||
const chunkItem = Form.focusChunkAt(chunkForm, 0)
|
||||
```
|
||||
|
||||
Schema issue paths are focused at the same time. A component receiving
|
||||
`emailField` gets its email issues directly instead of searching through every
|
||||
issue on the root form.
|
||||
|
||||
## Bind a subform to an input
|
||||
|
||||
`Form.useInput` is the liaison between an Effect form and a React controlled
|
||||
component. It reads the subform's encoded value and returns the familiar React
|
||||
`{ value, setValue }` interface. Calling `setValue` updates the form, which in
|
||||
turn runs the schema pipeline and updates validation state.
|
||||
|
||||
```tsx
|
||||
const input = yield* Form.useInput(emailField, {
|
||||
debounce: "250 millis",
|
||||
})
|
||||
|
||||
return (
|
||||
<input
|
||||
value={input.value}
|
||||
onChange={(event) => input.setValue(event.currentTarget.value)}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
The debounce is useful for text inputs, which can otherwise update the form and
|
||||
run schema validation on every keystroke. The displayed value still updates
|
||||
immediately; propagation to the form waits until the user pauses typing.
|
||||
|
||||
Use `Form.useOptionalInput` when the encoded field is an Effect `Option`. It
|
||||
returns `value` and `setValue` together with `enabled` and `setEnabled`, making
|
||||
it suitable for an optional input that the user can toggle on and off.
|
||||
|
||||
`useInput` and `useOptionalInput` can be used directly, but they are primarily
|
||||
building blocks for your own reusable input components. A component library
|
||||
can wrap them to consistently handle labels, validation issues, validating
|
||||
indicators, disabled state, accessibility, styling, and a standard debounce.
|
||||
Because both hooks accept a `Form.Form`, the same input components work with
|
||||
subforms from both `MutationForm` and `LensForm`.
|
||||
|
||||
```tsx title="TextFieldFormInputView.tsx"
|
||||
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
|
||||
import { Array, Option, Struct } from "effect"
|
||||
import { Component, Form, View } from "effect-view"
|
||||
import type * as React from "react"
|
||||
|
||||
export declare namespace TextFieldFormInputView {
|
||||
export interface Props<
|
||||
out P extends readonly PropertyKey[],
|
||||
A,
|
||||
ER,
|
||||
EW,
|
||||
> extends Omit<TextField.RootProps, "form">,
|
||||
Form.useInput.Options {
|
||||
readonly form: Form.Form<P, A, string, ER, EW>
|
||||
}
|
||||
|
||||
export type Signature = <
|
||||
P extends readonly PropertyKey[],
|
||||
A,
|
||||
ER,
|
||||
EW,
|
||||
>(
|
||||
props: Props<P, A, ER, EW>,
|
||||
) => React.ReactNode
|
||||
}
|
||||
|
||||
export const TextFieldFormInputView = Component.make(
|
||||
"TextFieldFormInputView",
|
||||
)(function* (
|
||||
props: TextFieldFormInputView.Props<
|
||||
readonly PropertyKey[],
|
||||
any,
|
||||
any,
|
||||
any
|
||||
>,
|
||||
) {
|
||||
const input = yield* Form.useInput(props.form, props)
|
||||
const [issues, isValidating, isCommitting] = yield* View.useAll([
|
||||
props.form.issues,
|
||||
props.form.isValidating,
|
||||
props.form.isCommitting,
|
||||
])
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="1">
|
||||
<TextField.Root
|
||||
value={input.value}
|
||||
onChange={(event) => input.setValue(event.target.value)}
|
||||
disabled={isCommitting}
|
||||
{...Struct.omit(props, ["form", "debounce"])}
|
||||
>
|
||||
{isValidating && (
|
||||
<TextField.Slot side="right">
|
||||
<Spinner />
|
||||
</TextField.Slot>
|
||||
)}
|
||||
{props.children}
|
||||
</TextField.Root>
|
||||
|
||||
{Option.match(Array.head(issues), {
|
||||
onSome: (issue) => (
|
||||
<Callout.Root>
|
||||
<Callout.Text>{issue.message}</Callout.Text>
|
||||
</Callout.Root>
|
||||
),
|
||||
onNone: () => null,
|
||||
})}
|
||||
</Flex>
|
||||
)
|
||||
}).pipe(
|
||||
Component.withSignature<TextFieldFormInputView.Signature>(),
|
||||
)
|
||||
```
|
||||
|
||||
## The common Form model
|
||||
|
||||
Both root implementations and every focused subform implement `Form.Form`.
|
||||
They expose the schema pipeline as reactive Lenses and Views:
|
||||
|
||||
| Member | Meaning |
|
||||
| --- | --- |
|
||||
| `encodedValue` | Writable input-shaped state. |
|
||||
| `value` | The decoded value as an `Option`; `None` until decoding succeeds. |
|
||||
| `issues` | Standard Schema issues scoped to this form's path. |
|
||||
| `isValidating` | Whether schema decoding is currently running. |
|
||||
| `canCommit` | Whether the root has a valid value and is ready to commit. |
|
||||
| `isCommitting` | Whether a mutation or target write is in progress. |
|
||||
|
||||
The form model itself is Effect code. Effect View adds the React-facing hooks,
|
||||
including `Form.useInput` and `Form.useOptionalInput`, while your own component
|
||||
library remains responsible for rendering controls and layout.
|
||||
|
||||
## Example: local date input, UTC domain value
|
||||
|
||||
The encoded/decoded distinction is especially useful for dates. An HTML
|
||||
`datetime-local` input produces a wall-clock string such as
|
||||
`"2026-07-22T14:30"`. It contains no time-zone or UTC offset, while application
|
||||
state and APIs usually need an unambiguous UTC instant.
|
||||
|
||||
A schema can own that entire conversion. The following schema interprets the
|
||||
input in the current time zone and decodes it to `DateTime.Utc`:
|
||||
|
||||
```tsx title="src/DateTimeUtcFromZonedInput.ts"
|
||||
import { DateTime, Effect, Option, ParseResult, Schema } from "effect"
|
||||
|
||||
class DateTimeUtcFromZoned extends Schema.transformOrFail(
|
||||
Schema.DateTimeZonedFromSelf,
|
||||
Schema.DateTimeUtcFromSelf,
|
||||
{
|
||||
strict: true,
|
||||
decode: (input) => ParseResult.succeed(DateTime.toUtc(input)),
|
||||
encode: DateTime.setZoneCurrent,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class DateTimeUtcFromZonedInput extends Schema.transformOrFail(
|
||||
Schema.String,
|
||||
DateTimeUtcFromZoned,
|
||||
{
|
||||
strict: true,
|
||||
decode: (input, _options, ast) =>
|
||||
Effect.flatMap(DateTime.CurrentTimeZone, (timeZone) =>
|
||||
Option.match(
|
||||
DateTime.makeZoned(input, {
|
||||
timeZone,
|
||||
adjustForTimeZone: true,
|
||||
}),
|
||||
{
|
||||
onSome: ParseResult.succeed,
|
||||
onNone: () =>
|
||||
ParseResult.fail(
|
||||
new ParseResult.Type(
|
||||
ast,
|
||||
input,
|
||||
"Enter a valid date and time",
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
encode: (value) =>
|
||||
ParseResult.succeed(
|
||||
DateTime.formatIsoZoned(value).slice(0, 16),
|
||||
),
|
||||
},
|
||||
) {}
|
||||
```
|
||||
|
||||
Use it like any other field schema. The form and input work with a string, but
|
||||
the mutation receives UTC:
|
||||
|
||||
```tsx
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Component, Form, MutationForm, View } from "effect-view"
|
||||
import { DateTimeUtcFromZonedInput } from "./DateTimeUtcFromZonedInput"
|
||||
|
||||
const AppointmentSchema = Schema.Struct({
|
||||
startsAt: DateTimeUtcFromZonedInput,
|
||||
})
|
||||
|
||||
const AppointmentView = Component.make("Appointment")(function* () {
|
||||
const [form, startsAtField] = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
const form = yield* MutationForm.make({
|
||||
schema: AppointmentSchema,
|
||||
initialEncodedValue: { startsAt: "" },
|
||||
f: ([appointment]) =>
|
||||
Effect.log(
|
||||
`Saving ${DateTime.formatIso(appointment.startsAt)}`,
|
||||
),
|
||||
}).pipe(MutationForm.thenRun)
|
||||
|
||||
return [form, Form.focusObjectOn(form, "startsAt")] as const
|
||||
}),
|
||||
)
|
||||
|
||||
const startsAt = yield* Form.useInput(startsAtField)
|
||||
const [canCommit] = yield* View.useAll([form.canCommit])
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void runPromise(form.submit)
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startsAt.value}
|
||||
onChange={(event) =>
|
||||
startsAt.setValue(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<button disabled={!canCommit}>Save appointment</button>
|
||||
</form>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
The schema requires `DateTime.CurrentTimeZone`; provide
|
||||
`DateTime.layerCurrentZoneLocal` in the application runtime. If the current
|
||||
zone is `Europe/Paris`, for example, entering `2026-07-22T14:30` decodes to the
|
||||
UTC instant `2026-07-22T12:30:00.000Z`.
|
||||
|
||||
Encoding works in the other direction. A `LensForm` whose target contains that
|
||||
UTC instant presents `2026-07-22T14:30` to the local input. The component never
|
||||
manually parses dates, applies offsets, or maintains a second representation;
|
||||
the schema defines both directions and the form keeps them synchronized.
|
||||
@@ -1,531 +0,0 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Getting Started
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
|
||||
`effect-view` lets a React function component be described as an Effect
|
||||
program. Inside a component you can yield services, create scoped resources,
|
||||
subscribe to Effect-powered state, and turn Effects into React callbacks. At a
|
||||
React boundary, that description becomes a normal function component.
|
||||
|
||||
The core model has four pieces:
|
||||
|
||||
1. `ReactRuntime` builds the Effect services available to the UI.
|
||||
2. `Component.make` defines an Effect View component.
|
||||
3. `Component.withContext` converts an Effect View component into a normal
|
||||
React component at an application or router boundary.
|
||||
4. Effect View children are composed through their `.use` Effect.
|
||||
|
||||
## Install
|
||||
|
||||
For a web application, install Effect View with Effect 4 and React 19.2 or
|
||||
newer:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install effect-view effect@beta react react-dom
|
||||
```
|
||||
|
||||
```bash npm2yarn
|
||||
npm install --save-dev @types/react @types/react-dom
|
||||
```
|
||||
|
||||
Effect View is not tied to React DOM. For React Native or another renderer,
|
||||
install that renderer instead of `react-dom` and keep the rest of the setup the
|
||||
same.
|
||||
|
||||
## Set up hot reloading with Vite
|
||||
|
||||
Install the Effect View Vite plugin to preserve component state while editing
|
||||
Effect View components:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install --save-dev @effect-view/vite-plugin
|
||||
```
|
||||
|
||||
Add `effectView()` before the React plugin in your Vite configuration:
|
||||
|
||||
```ts title="vite.config.ts"
|
||||
import { effectView } from "@effect-view/vite-plugin"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
effectView(),
|
||||
react(),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
Vite is the only bundler supported for Effect View hot reloading at the moment.
|
||||
Support for other bundlers will follow.
|
||||
|
||||
## Create the runtime
|
||||
|
||||
`ReactRuntime` owns a managed Effect runtime. Define it at module scope from the
|
||||
layers needed by the UI:
|
||||
|
||||
```tsx title="src/runtime.ts"
|
||||
import { Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { ReactRuntime } from "effect-view"
|
||||
|
||||
const AppLive = Layer.empty.pipe(
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
```
|
||||
|
||||
Keep the runtime stable. Creating it during a React render would create new
|
||||
managed resources and a new React context on every render.
|
||||
|
||||
## Provide the runtime
|
||||
|
||||
Place `ReactRuntime.Provider` above every Effect View entrypoint. The provider
|
||||
builds the runtime layer, makes its Effect context available through React, and
|
||||
disposes the managed runtime when the provider unmounts.
|
||||
|
||||
Runtime construction can suspend, so give the provider a fallback:
|
||||
|
||||
```tsx title="src/main.tsx"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { ReactRuntime } from "effect-view"
|
||||
import { App } from "./App"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ReactRuntime.Provider
|
||||
runtime={runtime}
|
||||
fallback={<p>Starting application...</p>}
|
||||
>
|
||||
<App />
|
||||
</ReactRuntime.Provider>
|
||||
</StrictMode>,
|
||||
)
|
||||
```
|
||||
|
||||
With a router, keep the provider above the router provider:
|
||||
|
||||
```tsx
|
||||
<ReactRuntime.Provider runtime={runtime} fallback={<p>Starting...</p>}>
|
||||
<RouterProvider router={router} />
|
||||
</ReactRuntime.Provider>
|
||||
```
|
||||
|
||||
If runtime layer construction can fail, place an appropriate React error
|
||||
boundary above `ReactRuntime.Provider` as well.
|
||||
|
||||
## Write your first component
|
||||
|
||||
`Component.make` defines a component body using the same generator style as
|
||||
`Effect.gen` and `Effect.fn`. Providing a name creates a tracing span and also
|
||||
sets the React DevTools display name:
|
||||
|
||||
```tsx title="src/HelloView.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-view"
|
||||
|
||||
export const HelloView = Component.make("HelloView")(
|
||||
function* (props: { readonly name: string }) {
|
||||
const message = yield* Effect.succeed(`Hello, ${props.name}`)
|
||||
|
||||
return <h1>{message}</h1>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Use `Component.makeUntraced("HelloView")` when you want the display name but do
|
||||
not want an automatic tracing span.
|
||||
|
||||
`HelloView` is an Effect View component description, not yet a normal React
|
||||
component. Convert it at the point where plain React, a router, or a third-party
|
||||
library needs a function component:
|
||||
|
||||
```tsx title="src/Hello.tsx"
|
||||
import { Component } from "effect-view"
|
||||
import { HelloView } from "./HelloView"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
export const Hello = HelloView.pipe(
|
||||
Component.withContext(runtime.context),
|
||||
)
|
||||
```
|
||||
|
||||
It can now be rendered by ordinary React:
|
||||
|
||||
```tsx title="src/App.tsx"
|
||||
import { Hello } from "./Hello"
|
||||
|
||||
export function App() {
|
||||
return <Hello name="Effect" />
|
||||
}
|
||||
```
|
||||
|
||||
`Component.withContext` reads the context populated by the matching
|
||||
`ReactRuntime.Provider`. It does not build or provide the runtime by itself.
|
||||
|
||||
## Compose Effect View components
|
||||
|
||||
Inside another Effect View component, yield a child's `.use` Effect. This binds
|
||||
the child to the current Effect context and returns a component that can be used
|
||||
in JSX:
|
||||
|
||||
```tsx title="src/GreetingCardView.tsx"
|
||||
import { Component } from "effect-view"
|
||||
import { HelloView } from "./HelloView"
|
||||
|
||||
export const GreetingCardView = Component.make("GreetingCard")(
|
||||
function* () {
|
||||
const Hello = yield* HelloView.use
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Hello name="Effect" />
|
||||
<p>Both components use the same Effect context.</p>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Only apply `Component.withContext` when crossing from Effect View into plain
|
||||
React. Applying it to every nested component is unnecessary and makes it harder
|
||||
to provide local services to a subtree.
|
||||
|
||||
## Synchronous and asynchronous components
|
||||
|
||||
A regular Effect View component runs its body during React render. Effects
|
||||
executed directly by that body—including setup passed to `useOnMount`,
|
||||
`useOnChange`, or `useLayer`—must complete synchronously every time they run.
|
||||
Yielding services and reading synchronous state is fine; sleeping, fetching, or
|
||||
awaiting a promise is not.
|
||||
|
||||
Choose the integration that matches the asynchronous work:
|
||||
|
||||
- Make the component asynchronous with [`Async.async`](./async) when it must
|
||||
wait for a one-off asynchronous Effect before producing JSX. The component
|
||||
suspends while it waits.
|
||||
- Use [Query](./query) for server reads that need caching, sharing, refresh, or
|
||||
invalidation.
|
||||
- Use [Mutation](./mutation) for user-triggered writes with observable pending
|
||||
and error state.
|
||||
- Use `useRunPromise` or `useCallbackPromise` for asynchronous event work that
|
||||
does not need Mutation state.
|
||||
- Use a post-commit hook with a scoped fiber for subscriptions or background
|
||||
work tied to the component lifecycle.
|
||||
|
||||
## Use Effect services
|
||||
|
||||
Components can yield Effect service tags directly. Their required service type
|
||||
becomes part of the component type, so the final runtime or a local layer must
|
||||
provide it:
|
||||
|
||||
```tsx title="src/GreetingService.ts"
|
||||
import { Context, Layer } from "effect"
|
||||
|
||||
export class GreetingService extends Context.Service<
|
||||
GreetingService,
|
||||
{ readonly greet: (name: string) => string }
|
||||
>()("GreetingService") {
|
||||
static readonly layer = Layer.succeed(GreetingService, {
|
||||
greet: (name) => `Hello, ${name}`,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```tsx title="src/GreetingView.tsx"
|
||||
import { Component } from "effect-view"
|
||||
import { GreetingService } from "./GreetingService"
|
||||
|
||||
export const GreetingView = Component.make("Greeting")(
|
||||
function* (props: { readonly name: string }) {
|
||||
const greeting = yield* GreetingService
|
||||
|
||||
return <p>{greeting.greet(props.name)}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Add `GreetingService.layer` to the application runtime, or provide it only to a
|
||||
subtree as shown later on this page.
|
||||
|
||||
Effect service instances are reactive at component boundaries. If the supplied
|
||||
context changes to contain a different service instance, dependent Effect View
|
||||
components are recreated so they read the new environment and restart their
|
||||
scoped lifecycle.
|
||||
|
||||
## Understand lifecycle hooks
|
||||
|
||||
Effect View hooks are still React hooks internally. Call them unconditionally
|
||||
at the top level of the component body, in a consistent order, and never inside
|
||||
branches, loops, event handlers, or nested callbacks.
|
||||
|
||||
### The component root scope
|
||||
|
||||
Every rendered Effect View component gets a root `Scope.Scope`. Effect View
|
||||
creates it when the component instance is rendered, provides it to the entire
|
||||
component body, and closes it when that React component unmounts.
|
||||
|
||||
Effects yielded directly from the body therefore see the root scope. This also
|
||||
means that `Effect.addFinalizer`, `Effect.acquireRelease`, and
|
||||
`Effect.forkScoped` can be used naturally inside component setup:
|
||||
|
||||
```tsx
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-view"
|
||||
|
||||
const ResourceView = Component.make("Resource")(function* () {
|
||||
const resource = yield* Component.useOnMount(() =>
|
||||
Effect.acquireRelease(
|
||||
openResource,
|
||||
(resource) => closeResource(resource),
|
||||
),
|
||||
)
|
||||
|
||||
return <p>{resource.name}</p>
|
||||
})
|
||||
```
|
||||
|
||||
`useOnMount` does **not** create or provide another scope. It runs its Effect
|
||||
with the context already available in the component body, so the resource above
|
||||
is owned by the component's root scope.
|
||||
|
||||
`useOnChange`, `useReactEffect`, and `useReactLayoutEffect` each manage their
|
||||
own resources. When their dependencies change, those resources are cleaned up
|
||||
and recreated while the rest of the component remains active.
|
||||
|
||||
The main lifecycle choices are:
|
||||
|
||||
| Hook | What it does | Scope seen by setup | Scope closes when |
|
||||
| --- | --- | --- | --- |
|
||||
| Component body | Produces the component's rendered value | Component root scope | The component unmounts |
|
||||
| `useOnMount` | Computes and caches a value for the component instance | Component root scope | The component unmounts |
|
||||
| `useOnChange` | Recomputes a cached value when dependencies change | A new dependency scope | Dependencies change or the component unmounts |
|
||||
| `useReactEffect` | Runs a post-commit side effect | A new effect scope | Dependencies change or the component unmounts |
|
||||
| `useReactLayoutEffect` | Runs a layout effect before the browser paints | A new layout-effect scope | Dependencies change or the component unmounts |
|
||||
| `useLayer` | Builds and provides a layer context | A new scope tied to the current layer reference | The layer reference changes or the component unmounts |
|
||||
|
||||
`useRunSync` and `useRunPromise` do not create a new scope. The runner they
|
||||
return includes the component root scope in its context by default.
|
||||
`useCallbackSync` and `useCallbackPromise` similarly capture the component
|
||||
context required by their Effect.
|
||||
|
||||
### useOnMount
|
||||
|
||||
`Component.useOnMount` computes a value during the initial render and caches it
|
||||
for later renders. It uses the component root scope, so its resources live
|
||||
until the component unmounts.
|
||||
|
||||
```tsx title="src/LocalStateView.tsx"
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-view"
|
||||
|
||||
export const LocalStateView = Component.make("LocalState")(
|
||||
function* () {
|
||||
const count = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.log("LocalState disposed"),
|
||||
)
|
||||
|
||||
return Lens.fromSubscriptionRef(
|
||||
yield* SubscriptionRef.make(0),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const [value] = yield* View.useAll([count])
|
||||
return <p>Count: {value}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
In a regular component, the setup Effect must be synchronous because it runs
|
||||
during render. In a component enhanced with `Async.async`, it may be
|
||||
asynchronous and will suspend the component.
|
||||
|
||||
### useOnChange
|
||||
|
||||
`Component.useOnChange` computes and caches a value, then recomputes it when its
|
||||
dependencies change. Each dependency set gets its own scope, which closes when
|
||||
the dependencies change or the component unmounts.
|
||||
|
||||
```tsx
|
||||
const label = yield* Component.useOnChange(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.log(`Stopped viewing ${props.userId}`),
|
||||
)
|
||||
|
||||
return `Viewing user ${props.userId}`
|
||||
}),
|
||||
[props.userId],
|
||||
)
|
||||
```
|
||||
|
||||
Dependency arrays follow normal React semantics. Include every reactive value
|
||||
read by the setup Effect.
|
||||
|
||||
### useReactEffect and useReactLayoutEffect
|
||||
|
||||
`Component.useReactEffect` runs side effects after React commits, while
|
||||
`Component.useReactLayoutEffect` runs before the browser paints. Each hook owns
|
||||
a scope that closes when its dependencies change or the component unmounts.
|
||||
|
||||
Setup must complete synchronously, but it can fork asynchronous work into the
|
||||
hook scope:
|
||||
|
||||
```tsx
|
||||
yield* Component.useReactEffect(
|
||||
() => Effect.forkScoped(listenForNotifications(props.userId)),
|
||||
[props.userId],
|
||||
)
|
||||
```
|
||||
|
||||
React Strict Mode may intentionally repeat development-only render and effect
|
||||
setup. Initializers and resource acquisition should therefore be safe to run
|
||||
more than once; production retains the normal component lifetime semantics.
|
||||
|
||||
## Run Effects from event handlers
|
||||
|
||||
React event handlers are plain functions. Use Effect View runners to execute an
|
||||
Effect with the component's current context and scope:
|
||||
|
||||
```tsx
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
return (
|
||||
<button onClick={() => void runPromise(saveUser(user))}>
|
||||
Save
|
||||
</button>
|
||||
)
|
||||
```
|
||||
|
||||
Use `Component.useRunSync` only for Effects known to complete synchronously.
|
||||
Use `Component.useRunPromise` for Effects that may suspend, sleep, fetch, or
|
||||
otherwise continue asynchronously.
|
||||
|
||||
Both runners provide `Scope.Scope` by default, so they can run scoped Effects
|
||||
without a type argument. When an Effect also requires application services,
|
||||
declare the complete runner context explicitly:
|
||||
|
||||
```tsx
|
||||
import { Scope } from "effect"
|
||||
|
||||
const runPromise = yield* Component.useRunPromise<
|
||||
Scope.Scope | UserRepository
|
||||
>()
|
||||
```
|
||||
|
||||
The resulting runner accepts Effects that require the component scope,
|
||||
`UserRepository`, or both.
|
||||
|
||||
When a callback is passed to a memoized child or used as a dependency, use the
|
||||
callback variants to preserve its identity:
|
||||
|
||||
```tsx
|
||||
const save = yield* Component.useCallbackPromise(
|
||||
(nextUser: User) => saveUser(nextUser),
|
||||
[saveUser],
|
||||
)
|
||||
|
||||
return <SaveButton onSave={() => void save(user)} />
|
||||
```
|
||||
|
||||
`useCallbackSync` and `useCallbackPromise` follow the same dependency rules as
|
||||
`React.useCallback`, while also supplying the Effect context when invoked.
|
||||
|
||||
## Use React normally
|
||||
|
||||
Effect View components can use regular React hooks, refs, context, event
|
||||
handlers, and JSX composition:
|
||||
|
||||
```tsx
|
||||
import { Component } from "effect-view"
|
||||
import * as React from "react"
|
||||
|
||||
const CounterView = Component.make("Counter")(function* () {
|
||||
const [count, setCount] = React.useState(0)
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count: {count}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Prefer regular React state for simple, component-local UI concerns. Use
|
||||
`Lens`/`View` when state needs Effect integration, subscriptions, focusing, or
|
||||
sharing. The [State Management guide](./state-management) covers that model.
|
||||
|
||||
## Provide services to a subtree
|
||||
|
||||
Use `Component.useLayer` when only one Effect View subtree needs extra
|
||||
services. It builds the layer in a scope and returns the resulting Effect
|
||||
context:
|
||||
|
||||
```tsx title="src/GreetingPageView.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-view"
|
||||
import { GreetingView } from "./GreetingView"
|
||||
import { GreetingService } from "./GreetingService"
|
||||
|
||||
export const GreetingPageView = Component.make("GreetingPage")(
|
||||
function* () {
|
||||
const context = yield* Component.useLayer(GreetingService.layer)
|
||||
const Greeting = yield* GreetingView.use.pipe(
|
||||
Effect.provide(context),
|
||||
)
|
||||
|
||||
return <Greeting name="Effect" />
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Keep the layer reference stable. Define static layers outside the component or
|
||||
memoize a layer that depends on props with `React.useMemo`; a new layer object
|
||||
causes reconstruction and scoped cleanup.
|
||||
|
||||
Layer construction runs during render through `useOnChange`. A layer with
|
||||
asynchronous acquisition requires the component to be enhanced with
|
||||
`Async.async`. Resources built by the layer are released when the owning
|
||||
component unmounts or the layer reference changes.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- Effect View hooks obey React's Rules of Hooks even though they are called
|
||||
with `yield*`.
|
||||
- Do not yield an asynchronous Effect from a regular component body. Use
|
||||
`Async.async`, Query, a Mutation callback, or a post-commit scoped fiber.
|
||||
- `Component.withContext` needs a matching `ReactRuntime.Provider` above it.
|
||||
- Apply `withContext` at React boundaries, not between Effect View components.
|
||||
- Keep runtimes and layers stable instead of creating them on every render.
|
||||
- Use `useRunPromise` rather than `useRunSync` for asynchronous event work.
|
||||
- Suspense fallbacks handle waiting; React error boundaries handle failed
|
||||
component Effects.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [State Management](./state-management) explains `Lens`, `View`, local state,
|
||||
and focused state.
|
||||
- [Query](./query) adds reactive keys, caching, refresh, and invalidation to
|
||||
Effect-based server reads.
|
||||
- [Mutation](./mutation) models user-triggered Effect operations and their
|
||||
`AsyncResult` state.
|
||||
- [Forms](./forms) builds schema-driven editable state with `MutationForm` and
|
||||
`LensForm`.
|
||||
|
||||
The repeatable pattern is small: provide one runtime, define components as
|
||||
Effect programs, cross into plain React with `Component.withContext`, and use
|
||||
`.use` everywhere inside the Effect View tree.
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
## What is Effect FC?
|
||||
|
||||
Effect FC allows you to harness the full power of Effect-TS within your React components. Instead of writing traditional React hooks, you can use Effect generators to compose complex, type-safe component logic with built-in error handling, resource management, and dependency injection.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Effect Integration**: Write your function component logic using Effect.
|
||||
- **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 component resources using the `Scope` API.
|
||||
|
||||
## Quick Example
|
||||
|
||||
Here's what writing an Effect FC component looks like:
|
||||
|
||||
```typescript
|
||||
export class TodosView extends Component.make("TodosView")(function*() {
|
||||
const state = yield* TodosState
|
||||
const [todos] = yield* Component.useSubscribables([state.subscriptionRef])
|
||||
|
||||
yield* Component.useOnMount(() => Effect.andThen(
|
||||
Console.log("Todos mounted"),
|
||||
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
||||
))
|
||||
|
||||
const Todo = yield* TodoView.use
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Heading align="center">Todos</Heading>
|
||||
|
||||
<Flex direction="column" align="stretch" gap="2" mt="2">
|
||||
<Todo _tag="new" />
|
||||
|
||||
{Chunk.map(todos, todo =>
|
||||
<Todo key={todo.id} _tag="edit" id={todo.id} />
|
||||
)}
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
}) {}
|
||||
|
||||
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
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before using Effect FC, make sure you have:
|
||||
|
||||
- **Node.js** version 20.0 or above
|
||||
- **React** 19.2 or higher
|
||||
- **Effect** 3.19 or higher
|
||||
|
||||
### Installation
|
||||
|
||||
Install Effect FC and its peer dependencies:
|
||||
|
||||
```bash
|
||||
npm install effect-fc effect react
|
||||
```
|
||||
|
||||
Or with your preferred package manager:
|
||||
|
||||
```bash
|
||||
yarn add effect-fc effect react
|
||||
bun add effect-fc effect react
|
||||
pnpm add effect-fc effect react
|
||||
```
|
||||
|
||||
### Next Steps
|
||||
|
||||
- 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
|
||||
|
||||
## Important Notes
|
||||
|
||||
:::info Early Development
|
||||
This library is in early development. While it is mostly feature-complete and usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are welcome!
|
||||
:::
|
||||
|
||||
:::warning Known Issues
|
||||
- React Refresh doesn't work for Effect FC components yet. Page reload is required to view changes. Regular React components are unaffected.
|
||||
:::
|
||||
|
||||
## Community & Support
|
||||
|
||||
Have questions or want to contribute? We'd love to hear from you! Check out the project repository and feel free to open issues or discussions.
|
||||
@@ -1,275 +0,0 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: Mutation
|
||||
---
|
||||
|
||||
# Mutation
|
||||
|
||||
The Mutation module is Effect View's counterpart to TanStack Query mutations.
|
||||
It models user-triggered asynchronous work such as saving a profile, deleting a
|
||||
record, uploading a file, or sending a command to an API.
|
||||
|
||||
The familiar concepts are present, but the operation itself is an Effect:
|
||||
|
||||
| TanStack Mutation concept | Effect View equivalent |
|
||||
| --- | --- |
|
||||
| Mutation variables | The input key `K` |
|
||||
| `mutationFn` | `f: (key: K) => Effect<A, E, R>` |
|
||||
| Mutation result | `mutation.state`, a `View<AsyncResult<A, E>>` |
|
||||
| `isPending` | `result.waiting` |
|
||||
| `mutateAsync` | `mutation.mutate(key)` |
|
||||
| Start without awaiting | `mutation.mutateView(key)` |
|
||||
|
||||
Unlike a Query, a Mutation has no cache, reactive key, or automatic execution.
|
||||
It runs only when `mutate` or `mutateView` is called. This makes it suitable for
|
||||
commands, while Query remains the model for cached server state.
|
||||
|
||||
## Create a mutation
|
||||
|
||||
Create a mutation in a component scope with `Mutation.make`. Its function can
|
||||
be any Effect, including one that requires services from the application
|
||||
runtime:
|
||||
|
||||
`Mutation.make` is an Effect constructor, not a hook. Create each mutation
|
||||
instance once and keep it stable instead of calling `Mutation.make` again on
|
||||
every render. Use `Component.useOnMount` for a component-owned mutation or
|
||||
create it in an Effect service when it should be shared.
|
||||
|
||||
```tsx
|
||||
import { Effect } from "effect"
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
import { Component, Mutation, View } from "effect-view"
|
||||
import { sendInvite } from "./api"
|
||||
|
||||
interface InviteInput {
|
||||
readonly email: string
|
||||
readonly role: "member" | "admin"
|
||||
}
|
||||
|
||||
const InviteButtonView = Component.make("InviteButton")(
|
||||
function* (props: { readonly email: string }) {
|
||||
const mutation = yield* Component.useOnMount(() =>
|
||||
Mutation.make({
|
||||
f: (input: InviteInput) => sendInvite(input),
|
||||
}),
|
||||
)
|
||||
|
||||
const [result] = yield* View.useAll([mutation.state])
|
||||
const runSync = yield* Component.useRunSync()
|
||||
|
||||
return (
|
||||
<section>
|
||||
<button
|
||||
disabled={result.waiting}
|
||||
onClick={() =>
|
||||
runSync(mutation.mutateView({
|
||||
email: props.email,
|
||||
role: "member",
|
||||
}))
|
||||
}
|
||||
>
|
||||
{result.waiting ? "Sending..." : "Send invite"}
|
||||
</button>
|
||||
|
||||
{AsyncResult.match(result, {
|
||||
onInitial: () => null,
|
||||
onFailure: ({ cause }) => (
|
||||
<p>Could not send invite: {cause.toString()}</p>
|
||||
),
|
||||
onSuccess: ({ value }) => (
|
||||
<p>Invite sent to {value.email}</p>
|
||||
),
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
`Mutation.make` captures the current Effect context. If `sendInvite` requires
|
||||
an HTTP client, authentication service, tracer, or another service, provide it
|
||||
to the runtime or component layer before creating the mutation. The click
|
||||
handler does not need to reconstruct those dependencies.
|
||||
|
||||
The mutation also belongs to the creation scope. Any mutation fibers still
|
||||
running when that scope closes are interrupted automatically.
|
||||
|
||||
## Understand AsyncResult
|
||||
|
||||
`mutation.state` starts as `Initial` with `waiting: false`. Starting a mutation
|
||||
changes it to a waiting state, then publishes either `Success` or `Failure`:
|
||||
|
||||
```tsx
|
||||
const [result] = yield* View.useAll([mutation.state])
|
||||
|
||||
return AsyncResult.match(result, {
|
||||
onInitial: ({ waiting }) =>
|
||||
waiting ? <p>Starting...</p> : <p>Ready.</p>,
|
||||
onFailure: ({ cause, previousSuccess, waiting }) => (
|
||||
<div>
|
||||
<p>{cause.toString()}</p>
|
||||
{previousSuccess._tag === "Some" && (
|
||||
<p>Last saved value: {previousSuccess.value.value.name}</p>
|
||||
)}
|
||||
{waiting && <p>Trying again...</p>}
|
||||
</div>
|
||||
),
|
||||
onSuccess: ({ value, waiting }) => (
|
||||
<div>
|
||||
<p>Saved {value.name}</p>
|
||||
{waiting && <p>Saving a newer value...</p>}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
The `waiting` flag is independent from the result tag. After one successful
|
||||
call, starting another keeps the successful value and sets `waiting: true`.
|
||||
If that next call fails, the failure can retain the earlier success in
|
||||
`previousSuccess`. Components can therefore keep useful feedback visible
|
||||
during retries or repeated submissions.
|
||||
|
||||
A failure contains an Effect `Cause<E>`, not only `E`. Typed failures, defects,
|
||||
and interruption information remain available for logging or presentation.
|
||||
|
||||
## Choose mutate or mutateView
|
||||
|
||||
Both methods start the same mutation effect. They differ in what the caller
|
||||
waits for:
|
||||
|
||||
| Method | Return value | Best suited to |
|
||||
| --- | --- | --- |
|
||||
| `mutate(key)` | The final `Success` or `Failure` | Effect workflows that need the outcome. |
|
||||
| `mutateView(key)` | A live `View<AsyncResult<A, E>>` | UI callbacks that should return after starting the work. |
|
||||
|
||||
Use `mutate` with an asynchronous component runner when later logic depends on
|
||||
the final result:
|
||||
|
||||
```tsx
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
const save = () =>
|
||||
void runPromise(
|
||||
Effect.gen(function* () {
|
||||
const result = yield* mutation.mutate(input)
|
||||
|
||||
if (AsyncResult.isSuccess(result)) {
|
||||
yield* Effect.log(`Saved record ${result.value.id}`)
|
||||
}
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
The mutation Effect does not fail with `E`. It captures the operation's `Exit`
|
||||
and returns a final `AsyncResult.Success` or `AsyncResult.Failure`. Inspect or
|
||||
match that value when control flow depends on the outcome.
|
||||
|
||||
Use `mutateView` when the UI only needs to start the operation and react to its
|
||||
state:
|
||||
|
||||
```tsx
|
||||
const runSync = yield* Component.useRunSync()
|
||||
|
||||
const startSave = () => {
|
||||
const state = runSync(mutation.mutateView(input))
|
||||
// `state` is a View for this specific call.
|
||||
}
|
||||
```
|
||||
|
||||
`mutateView` starts the scoped work and returns immediately with a per-call
|
||||
View. The shared `mutation.state` is also updated as that call progresses.
|
||||
|
||||
## Track the latest call
|
||||
|
||||
In addition to `state`, a mutation exposes reactive metadata:
|
||||
|
||||
| Member | Meaning |
|
||||
| --- | --- |
|
||||
| `state` | The latest mutation state published to the shared View. |
|
||||
| `latestKey` | The most recently supplied input as an `Option<K>`. |
|
||||
| `latestFinalResult` | The latest completed success or failure as an `Option`. |
|
||||
| `fiber` | The most recently started mutation fiber as an `Option`. |
|
||||
|
||||
Subscribe to any of them with `View.useAll`:
|
||||
|
||||
```tsx
|
||||
const [result, latestInput, latestFinal] = yield* View.useAll([
|
||||
mutation.state,
|
||||
mutation.latestKey,
|
||||
mutation.latestFinalResult,
|
||||
])
|
||||
```
|
||||
|
||||
The input is called a `key` in the generic API, but it is equivalent to
|
||||
mutation variables in TanStack Query. It can be a primitive, tuple, struct, or
|
||||
any other value accepted by the mutation function.
|
||||
|
||||
## Concurrent mutations
|
||||
|
||||
Starting a mutation does not automatically interrupt an earlier mutation.
|
||||
Calls may overlap, and each call has its own state View. This is useful for
|
||||
independent operations such as uploading several files.
|
||||
|
||||
When calls overlap, `mutation.state` reflects updates published by all calls;
|
||||
the last update to arrive wins. Use the View returned by `mutateView` when each
|
||||
concurrent operation needs its own progress indicator:
|
||||
|
||||
```tsx
|
||||
const upload = (file: File) =>
|
||||
Effect.gen(function* () {
|
||||
const uploadState = yield* mutation.mutateView(file)
|
||||
// Store or pass `uploadState` to the row rendering this file.
|
||||
return uploadState
|
||||
})
|
||||
```
|
||||
|
||||
For a single submit button, disabling it while `mutation.state.waiting` is
|
||||
usually enough to prevent accidental overlap. For “latest request wins”
|
||||
behavior, model that policy explicitly or use Query when the operation is
|
||||
actually a reactive read.
|
||||
|
||||
## Update queries after a mutation
|
||||
|
||||
Mutations do not invalidate Query caches automatically. Compose invalidation
|
||||
with the mutation result so the relationship remains explicit and typed:
|
||||
|
||||
```tsx
|
||||
const saveAndRefresh = (input: UpdatePostInput) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* updatePost.mutate(input)
|
||||
|
||||
if (AsyncResult.isSuccess(result)) {
|
||||
yield* posts.invalidateCacheEntry(["post", result.value.id] as const)
|
||||
yield* posts.refreshView
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
```
|
||||
|
||||
This is the Effect equivalent of an `onSuccess` callback that invalidates a
|
||||
TanStack Query. Because it is ordinary Effect composition, the workflow can
|
||||
also include tracing, transactions, retries, notifications, or parallel cache
|
||||
updates without introducing a separate callback API.
|
||||
|
||||
Remember that Query invalidation removes cached data but does not refetch by
|
||||
itself. Follow it with `refreshView` when the current screen should update
|
||||
immediately.
|
||||
|
||||
## The Effect touch
|
||||
|
||||
Mutation follows the ergonomics of TanStack mutations while retaining Effect's
|
||||
execution model:
|
||||
|
||||
- The mutation function has the full `Effect<A, E, R>` type.
|
||||
- Required services are captured from the creation context.
|
||||
- Failures are represented as `Cause<E>`, including defects and interruption.
|
||||
- Fibers are scoped, so component unmounting cleans up in-flight operations.
|
||||
- `mutate` composes directly inside larger Effect workflows.
|
||||
- `mutateView` exposes call-specific progress as a View for React or Effect
|
||||
consumers.
|
||||
- Retry schedules, timeouts, tracing, logging, metrics, schema validation, and
|
||||
concurrency controls can be applied with normal Effect operators.
|
||||
|
||||
Mutation is therefore a small bridge: TanStack-style mutation state on one
|
||||
side, and an ordinary, typed Effect program on the other.
|
||||
@@ -1,332 +0,0 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: Query
|
||||
---
|
||||
|
||||
# Query
|
||||
|
||||
The Query module is Effect View's take on TanStack Query. It provides the same
|
||||
kind of server-state workflow—reactive query keys, cached results, stale times,
|
||||
background refreshes, window-focus refetching, and cache invalidation—but the
|
||||
query function is an `Effect` and the observable state is a `View`.
|
||||
|
||||
If you already know TanStack Query, the main concepts translate directly:
|
||||
|
||||
| TanStack Query concept | Effect View equivalent |
|
||||
| --- | --- |
|
||||
| `QueryClient` | The `QueryClient` Effect service |
|
||||
| `queryKey` | A reactive key supplied as a `View<K>` |
|
||||
| `queryFn` | `f: (key: K) => Effect<A, E, R>` |
|
||||
| `useQuery` result | `query.state`, a `View<QueryState<K, A, E>>` |
|
||||
| `isFetching` | `result.waiting` |
|
||||
| `refetch` | `query.refresh` or `query.refreshView` |
|
||||
| `invalidateQueries` | `query.invalidateCache` or `invalidateCacheEntry` |
|
||||
|
||||
The goal is familiar query behavior without leaving Effect's model. Query
|
||||
functions keep their typed success, error, and service channels. They can use
|
||||
services from the runtime, be composed with schema decoding and retry policies,
|
||||
and are interrupted automatically when their scope ends or a new key supersedes
|
||||
the current request.
|
||||
|
||||
## Provide a QueryClient
|
||||
|
||||
Queries share successful results through a `QueryClient`. Add its layer to the
|
||||
application runtime once, alongside the services used by your query effects:
|
||||
|
||||
```tsx title="src/runtime.ts"
|
||||
import { Layer } from "effect"
|
||||
import { QueryClient, ReactRuntime } from "effect-view"
|
||||
|
||||
const AppLive = Layer.empty.pipe(
|
||||
Layer.provideMerge(QueryClient.layer({
|
||||
defaultStaleTime: "30 seconds",
|
||||
defaultRefreshOnWindowFocus: true,
|
||||
cacheGcTime: "5 minutes",
|
||||
})),
|
||||
)
|
||||
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
```
|
||||
|
||||
The client owns the cache and its cleanup lifecycle. Individual queries can
|
||||
override `staleTime` and `refreshOnWindowFocus`; otherwise they inherit the
|
||||
client defaults. Window-focus refresh also requires the optional
|
||||
`@effect/platform-browser` package, as described under
|
||||
[Staleness and cache lifetime](#staleness-and-cache-lifetime).
|
||||
|
||||
## Create a reactive query
|
||||
|
||||
A query is driven by a `View` rather than by a value read during one React
|
||||
render. Whenever that key changes, a running Query checks the cache and starts
|
||||
the query effect when necessary.
|
||||
|
||||
`Query.make` constructs the Query and `Query.thenRun` starts it in the current
|
||||
scope. Create each query instance once and keep it stable. In a component, the usual place is
|
||||
`Component.useOnMount`; a query shared by multiple components can instead be
|
||||
owned by an Effect service. Change the existing query's reactive key rather
|
||||
than reconstructing the query during render.
|
||||
|
||||
```tsx
|
||||
import { Effect, Schema, SubscriptionRef } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Component, Lens, Query, View } from "effect-view"
|
||||
|
||||
const Post = Schema.Struct({
|
||||
id: Schema.Int,
|
||||
title: Schema.String,
|
||||
body: Schema.String,
|
||||
})
|
||||
|
||||
const PostView = Component.make("Post")(function* () {
|
||||
const [postId, query] = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
const key = Lens.fromSubscriptionRef(
|
||||
yield* SubscriptionRef.make(["post", 1 as number] as const),
|
||||
)
|
||||
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
staleTime: "1 minute",
|
||||
f: ([, id]) =>
|
||||
HttpClient.HttpClient.pipe(
|
||||
Effect.andThen((client) =>
|
||||
client.get(`https://example.com/posts/${id}`),
|
||||
),
|
||||
Effect.andThen((response) => response.json),
|
||||
Effect.andThen(Schema.decodeUnknownEffect(Post)),
|
||||
),
|
||||
}).pipe(Query.thenRun)
|
||||
|
||||
return [Lens.focusTupleAt(key, 1), query] as const
|
||||
}),
|
||||
)
|
||||
|
||||
const [state] = yield* View.useAll([query.state])
|
||||
const [id, setId] = yield* Lens.useState(postId)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<select
|
||||
value={id}
|
||||
onChange={(event) => setId(Number(event.currentTarget.value))}
|
||||
>
|
||||
<option value={1}>Post 1</option>
|
||||
<option value={2}>Post 2</option>
|
||||
</select>
|
||||
<pre>{state.result._tag}</pre>
|
||||
</section>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
The tuple key plays the same role as `['post', id]` in TanStack Query. Keys use
|
||||
Effect equality by default, so structurally equal Effect data types work well
|
||||
as query keys. Supply `keyEquivalence` when the key needs different equality
|
||||
semantics.
|
||||
|
||||
`Query.thenRun` starts watching its key in the current scope. The
|
||||
`Component.useOnMount` call above keeps both the query instance and its query
|
||||
function identity stable for the component's lifetime.
|
||||
|
||||
## Render AsyncResult
|
||||
|
||||
`query.state` contains both the current key and an `AsyncResult`:
|
||||
|
||||
```ts
|
||||
interface QueryState<K, A, E> {
|
||||
readonly key: K
|
||||
readonly result: AsyncResult.AsyncResult<A, E>
|
||||
}
|
||||
```
|
||||
|
||||
Subscribe with `View.useAll`, then match the result explicitly:
|
||||
|
||||
```tsx
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
|
||||
const [state] = yield* View.useAll([query.state])
|
||||
|
||||
return AsyncResult.match(state.result, {
|
||||
onInitial: ({ waiting }) =>
|
||||
waiting ? <p>Loading...</p> : <p>Not loaded.</p>,
|
||||
onFailure: ({ cause, previousSuccess, waiting }) => (
|
||||
<section>
|
||||
<p>Request failed: {cause.toString()}</p>
|
||||
{previousSuccess._tag === "Some" && (
|
||||
<p>Last post: {previousSuccess.value.value.title}</p>
|
||||
)}
|
||||
{waiting && <p>Trying again...</p>}
|
||||
</section>
|
||||
),
|
||||
onSuccess: ({ value, waiting }) => (
|
||||
<article>
|
||||
{waiting && <small>Refreshing...</small>}
|
||||
<h2>{value.title}</h2>
|
||||
<p>{value.body}</p>
|
||||
</article>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
The `waiting` flag separates “do I have a result?” from “is work currently in
|
||||
flight?”. During a background refresh, a successful result remains successful
|
||||
and keeps its value while `waiting` becomes `true`. A failed refresh can retain
|
||||
its `previousSuccess`. This avoids replacing useful content with an empty
|
||||
loading screen every time data is refreshed.
|
||||
|
||||
Failures contain an Effect `Cause<E>`, preserving typed failures, defects, and
|
||||
interruption information instead of flattening everything into an untyped
|
||||
exception.
|
||||
|
||||
## Change the query key
|
||||
|
||||
The key is reactive state, so changing it is enough to fetch the corresponding
|
||||
resource. There is no separate dependency array to keep synchronized:
|
||||
|
||||
```tsx
|
||||
const [id, setId] = yield* Lens.useState(postId)
|
||||
|
||||
return (
|
||||
<label>
|
||||
Post
|
||||
<select
|
||||
value={id}
|
||||
onChange={(event) => setId(Number(event.currentTarget.value))}
|
||||
>
|
||||
<option value={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
<option value={3}>3</option>
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
```
|
||||
|
||||
When the key changes, the previous in-flight request is interrupted. The query
|
||||
then reuses a fresh cached success or starts the effect for the new key.
|
||||
|
||||
## Refresh and invalidate
|
||||
|
||||
Refresh resolves the latest key again. A fresh cached result can satisfy it
|
||||
immediately; a stale or missing result runs the query effect while preserving
|
||||
previous data as background state. Invalidation removes cached data, so the
|
||||
next fetch for that key must run the effect again.
|
||||
|
||||
```tsx
|
||||
const runSync = yield* Component.useRunSync()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => runSync(query.refreshView)}>
|
||||
Refresh current post
|
||||
</button>
|
||||
<button onClick={() => runSync(query.invalidateCacheEntry(["post", id] as const))}>
|
||||
Invalidate current post
|
||||
</button>
|
||||
<button onClick={() => runSync(query.invalidateCache)}>
|
||||
Invalidate all posts from this query
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
```
|
||||
|
||||
The methods come in two styles:
|
||||
|
||||
| Method | Behavior |
|
||||
| --- | --- |
|
||||
| `fetch(key)` | Fetch a specific key and wait for its final state. |
|
||||
| `fetchView(key)` | Start fetching and immediately return a live state `View`. |
|
||||
| `refresh` | Resolve the current key again and wait for its final state. |
|
||||
| `refreshView` | Resolve the current key again and immediately return a live state `View`. |
|
||||
| `invalidateCacheEntry(key)` | Remove the cached success for one key. |
|
||||
| `invalidateCache` | Remove every cached success associated with this query function. |
|
||||
|
||||
The `*View` variants are convenient in synchronous UI callbacks: they return
|
||||
after the scoped request has started, while the returned View and `query.state`
|
||||
continue to publish progress. The non-View variants are useful in Effect
|
||||
workflows that need to wait for the final success or failure state.
|
||||
|
||||
Invalidating does not itself refetch. Follow it with `refreshView`, change the
|
||||
key, or allow a later fetch to repopulate the cache.
|
||||
|
||||
### Refresh on an interval
|
||||
|
||||
Refresh every five minutes, starting after five minutes:
|
||||
|
||||
```ts
|
||||
import { Schedule } from "effect"
|
||||
|
||||
const query = yield* Query.make(options).pipe(
|
||||
Query.thenRun,
|
||||
Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
|
||||
)
|
||||
```
|
||||
|
||||
Limit the number of refreshes:
|
||||
|
||||
```ts
|
||||
const query = yield* Query.make(options).pipe(
|
||||
Query.thenRun,
|
||||
Query.withScheduledRefresh(
|
||||
Schedule.spaced("5 minutes").pipe(
|
||||
Schedule.upTo({ times: 3 }),
|
||||
),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
The refresh fiber stops with the surrounding scope. The Effect returns the
|
||||
original query. Cache and `staleTime` rules still apply.
|
||||
|
||||
## Staleness and cache lifetime
|
||||
|
||||
`staleTime` controls how long a successful result can satisfy a fetch without
|
||||
running the effect again. A stale entry remains available as previous data
|
||||
while the query refreshes it.
|
||||
|
||||
`cacheGcTime` is configured on `QueryClient`. Entries that have not been
|
||||
accessed are eventually removed after their stale period plus the configured
|
||||
garbage-collection time.
|
||||
|
||||
By default, the client enables refresh on browser window focus. Set it globally
|
||||
or override it for one query:
|
||||
|
||||
```tsx
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: loadPost,
|
||||
staleTime: "10 seconds",
|
||||
refreshOnWindowFocus: false,
|
||||
}).pipe(Query.thenRun)
|
||||
```
|
||||
|
||||
Window-focus refresh depends on the optional `@effect/platform-browser`
|
||||
integration. Install it in browser applications that use this behavior:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install @effect/platform-browser@beta
|
||||
```
|
||||
|
||||
When the package is available, focusing the window resolves the current key
|
||||
again, subject to the same freshness check. Without it,
|
||||
`refreshOnWindowFocus` has no effect; the rest of the Query API continues to
|
||||
work normally. The integration is also ignored in non-browser environments.
|
||||
|
||||
## The Effect touch
|
||||
|
||||
The API emulates TanStack Query's server-state ergonomics, but query execution
|
||||
remains ordinary Effect code:
|
||||
|
||||
- `f` has the full `Effect<A, E, R>` type, including required services.
|
||||
- The query captures its Effect context when it is created, so callbacks do not
|
||||
need to manually reconstruct dependencies.
|
||||
- Effects can use `Schema`, retry schedules, tracing, logging, metrics,
|
||||
cancellation, and any other Effect operator before becoming query state.
|
||||
- Request fibers belong to the creation scope and are interrupted on unmount,
|
||||
key replacement, or scope closure.
|
||||
- Results are `View`s, so the same state can drive React through `View.useAll`
|
||||
or participate in Effect streams and application logic outside React.
|
||||
- Failures retain `Cause<E>` rather than losing Effect's error model.
|
||||
|
||||
`Query` is therefore best understood as a TanStack Query-shaped coordinator
|
||||
for Effect programs: it handles when and whether to run them, while Effect
|
||||
continues to describe what the work requires and how it behaves.
|
||||
@@ -1,297 +0,0 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: State Management
|
||||
---
|
||||
|
||||
# State Management
|
||||
|
||||
`Lens` is the main type used for state management in `effect-view`.
|
||||
|
||||
A Lens is an effectful handle to a piece of state. It can read the current value,
|
||||
subscribe to changes, and write updates back to the underlying source. A Lens
|
||||
can point at a whole state object or focus on one nested field inside it.
|
||||
|
||||
`effect-view` re-exports the `Lens` and `View` modules from
|
||||
[`effect-lens`](https://www.npmjs.com/package/effect-lens/v/beta) for convenience. The
|
||||
core data model and transformation APIs belong to `effect-lens`, so check the
|
||||
[`effect-lens` documentation](https://www.npmjs.com/package/effect-lens/v/beta) for the full Lens/View API.
|
||||
|
||||
The usual pattern is to create state with Effect primitives such as
|
||||
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor
|
||||
such as `Lens.fromSubscriptionRef`, and bind Lens values into components with
|
||||
`View.useAll`.
|
||||
|
||||
`View` is the read-only side of this model. Every Lens is also a View.
|
||||
|
||||
## Where To Store State
|
||||
|
||||
State can live pretty much anywhere as a `Lens` or `View`: in a
|
||||
service, in a layer, in a component scope, or alongside plain React state. Pick
|
||||
the owner based on who needs the state. Once you have a Lens/View
|
||||
handle, pass it around however you like, including through React props.
|
||||
|
||||
If state is shared by multiple components or belongs to application logic, store
|
||||
it in an Effect service:
|
||||
|
||||
```tsx
|
||||
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-view"
|
||||
|
||||
class CounterState extends Context.Service<
|
||||
CounterState,
|
||||
{ readonly count: Lens.Lens<number> }
|
||||
>()("CounterState") {
|
||||
static readonly layer = Layer.effect(
|
||||
CounterState,
|
||||
Effect.gen(function*() {
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
|
||||
return { count } as const
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const CounterValueView = Component.make("CounterValue")(function*() {
|
||||
const state = yield* CounterState
|
||||
const [count] = yield* View.useAll([state.count])
|
||||
|
||||
return <p>Count: {count}</p>
|
||||
})
|
||||
```
|
||||
|
||||
If state belongs to a single Effect View component instance, or to a shallow
|
||||
hierarchy of subcomponents that receive it through props, create it with
|
||||
`Component.useOnMount`:
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-view"
|
||||
|
||||
const LocalCounterView = Component.make("LocalCounter")(function*() {
|
||||
const state = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function*() {
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
|
||||
return { count } as const
|
||||
}),
|
||||
)
|
||||
const [count] = yield* View.useAll([state.count])
|
||||
|
||||
return <p>Count: {count}</p>
|
||||
})
|
||||
```
|
||||
|
||||
For simple UI state that is not shared and does not need Effect integration,
|
||||
prefer regular React state. A local "show details" toggle is usually better as
|
||||
`React.useState(false)` than as a Lens.
|
||||
|
||||
## View.useAll
|
||||
|
||||
A `View<A>` is reactive state with a current value and a stream of changes. Use
|
||||
`View.useAll` whenever a component needs to bind View values into render output.
|
||||
|
||||
`Lens` is a `View`, so this is also the default way to read Lens values
|
||||
from a component.
|
||||
|
||||
```tsx
|
||||
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-view"
|
||||
|
||||
class CounterState extends Context.Service<
|
||||
CounterState,
|
||||
{
|
||||
readonly count: Lens.Lens<number>
|
||||
readonly doubled: View.View<number>
|
||||
}
|
||||
>()("CounterState") {
|
||||
static readonly layer = Layer.effect(
|
||||
CounterState,
|
||||
Effect.gen(function*() {
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
const doubled = View.map(count, (n) => n * 2)
|
||||
|
||||
return { count, doubled } as const
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const CounterReadOnlyView = Component.make("CounterReadOnly")(
|
||||
function* () {
|
||||
const state = yield* CounterState
|
||||
const [count, doubled] = yield* View.useAll([
|
||||
state.count,
|
||||
state.doubled,
|
||||
])
|
||||
|
||||
return <p>Count: {count}, doubled: {doubled}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
`View.useAll` reads the current values during render and uses scoped subscriptions to update React state when changes arrive.
|
||||
|
||||
When you need to modify state, write to the Lens with `Lens.set` or `Lens.update`:
|
||||
|
||||
```tsx
|
||||
const CounterControlsView = Component.make("CounterControls")(
|
||||
function* () {
|
||||
const state = yield* CounterState
|
||||
const [count] = yield* View.useAll([state.count])
|
||||
|
||||
const increment = yield* Component.useCallbackSync(
|
||||
() => Lens.update(state.count, (n) => n + 1),
|
||||
[],
|
||||
)
|
||||
const reset = yield* Component.useCallbackSync(
|
||||
() => Lens.set(state.count, 0),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p>Count: {count}</p>
|
||||
<button onClick={increment}>Increment</button>
|
||||
<button onClick={reset}>Reset</button>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## Lens.useState
|
||||
|
||||
`Lens.useState` is useful when React needs the familiar `[value, setValue]`
|
||||
tuple, backed by a Lens. Reach for it when the JSX API expects a synchronous
|
||||
setter, especially controlled inputs such as text fields, checkboxes, selects,
|
||||
or third-party components with `value` / `onChange` props.
|
||||
|
||||
If a component only needs to display the value, prefer `View.useAll`.
|
||||
`Lens.useState` is for places where reading and writing need to be wired
|
||||
together in React's local-state shape.
|
||||
|
||||
```tsx
|
||||
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||
import { Component, Lens } from "effect-view"
|
||||
|
||||
class FormState extends Context.Service<
|
||||
FormState,
|
||||
{ readonly name: Lens.Lens<string> }
|
||||
>()("FormState") {
|
||||
static readonly layer = Layer.effect(
|
||||
FormState,
|
||||
Effect.gen(function*() {
|
||||
const name = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(""))
|
||||
|
||||
return { name } as const
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const NameInputView = Component.make("NameInput")(function* () {
|
||||
const state = yield* FormState
|
||||
const [name, setName] = yield* Lens.useState(state.name)
|
||||
|
||||
return (
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
`Lens.useState` returns the current value and a React-compatible setter. Calling
|
||||
the setter writes through the Lens, so every other component subscribed to the
|
||||
same Lens sees the update.
|
||||
|
||||
## Focused Lenses
|
||||
|
||||
Use focused Lenses when a component should work with one part of a larger state
|
||||
object. A focused Lens is still a Lens, so it can be read with
|
||||
`View.useAll` or used with `Lens.useState` when React needs a
|
||||
read/write tuple.
|
||||
|
||||
```tsx
|
||||
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-view"
|
||||
|
||||
interface UserProfile {
|
||||
readonly name: string
|
||||
readonly email: string
|
||||
readonly role: string
|
||||
readonly contact: {
|
||||
readonly address: {
|
||||
readonly city: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileState extends Context.Service<
|
||||
ProfileState,
|
||||
{ readonly profile: Lens.Lens<UserProfile> }
|
||||
>()("ProfileState") {
|
||||
static readonly layer = Layer.effect(
|
||||
ProfileState,
|
||||
Effect.gen(function*() {
|
||||
const profile = Lens.fromSubscriptionRef(
|
||||
yield* SubscriptionRef.make<UserProfile>({
|
||||
name: "",
|
||||
email: "",
|
||||
role: "reader",
|
||||
contact: {
|
||||
address: {
|
||||
city: "",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
return { profile } as const
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const ProfileNameView = Component.make("ProfileName")(function*() {
|
||||
const state = yield* ProfileState
|
||||
const [nameLens, roleLens, cityLens] = yield* Component.useOnMount(() =>
|
||||
Effect.succeed([
|
||||
Lens.focusObjectOn(state.profile, "name"),
|
||||
Lens.focusObjectOn(state.profile, "role"),
|
||||
state.profile.pipe(
|
||||
Lens.focusObjectOn("contact"),
|
||||
Lens.focusObjectOn("address"),
|
||||
Lens.focusObjectOn("city"),
|
||||
),
|
||||
] as const),
|
||||
)
|
||||
|
||||
const [name, setName] = yield* Lens.useState(nameLens)
|
||||
const [role, city] = yield* View.useAll([roleLens, cityLens])
|
||||
|
||||
return (
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
/>
|
||||
<span>Role: {role}</span>
|
||||
<span>City: {city}</span>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
The service owns only the root `profile` Lens. The component chooses the fields
|
||||
it needs, creates those focused Lenses once in `Component.useOnMount`, and
|
||||
subscribes only to them. Updating `nameLens` through `Lens.useState` still
|
||||
updates the parent `profile` Lens.
|
||||
|
||||
Lens focus helpers have a dual API. They can be called in data-first form, such
|
||||
as `Lens.focusObjectOn(profile, "name")`, or in curried form with only the key
|
||||
or index. The `cityLens` above uses the curried form with `pipe` to focus through
|
||||
a deeper path without storing intermediate Lenses.
|
||||
|
||||
For focusing into nested state, deriving lenses, custom write behavior, and the
|
||||
complete API, refer to the
|
||||
[`effect-lens` documentation](https://www.npmjs.com/package/effect-lens/v/beta).
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Tutorial - Basics",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "5 minutes to learn the most important Docusaurus concepts."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
sidebar_position: 6
|
||||
---
|
||||
|
||||
# Congratulations!
|
||||
|
||||
You have just learned the **basics of Docusaurus** and made some changes to the **initial template**.
|
||||
|
||||
Docusaurus has **much more to offer**!
|
||||
|
||||
Have **5 more minutes**? Take a look at **[versioning](../tutorial-extras/manage-docs-versions.md)** and **[i18n](../tutorial-extras/translate-your-site.md)**.
|
||||
|
||||
Anything **unclear** or **buggy** in this tutorial? [Please report it!](https://github.com/facebook/docusaurus/discussions/4610)
|
||||
|
||||
## What's next?
|
||||
|
||||
- Read the [official documentation](https://docusaurus.io/)
|
||||
- Modify your site configuration with [`docusaurus.config.js`](https://docusaurus.io/docs/api/docusaurus-config)
|
||||
- Add navbar and footer items with [`themeConfig`](https://docusaurus.io/docs/api/themes/configuration)
|
||||
- Add a custom [Design and Layout](https://docusaurus.io/docs/styling-layout)
|
||||
- Add a [search bar](https://docusaurus.io/docs/search)
|
||||
- Find inspirations in the [Docusaurus showcase](https://docusaurus.io/showcase)
|
||||
- Get involved in the [Docusaurus Community](https://docusaurus.io/community/support)
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
# Create a Blog Post
|
||||
|
||||
Docusaurus creates a **page for each blog post**, but also a **blog index page**, a **tag system**, an **RSS** feed...
|
||||
|
||||
## Create your first Post
|
||||
|
||||
Create a file at `blog/2021-02-28-greetings.md`:
|
||||
|
||||
```md title="blog/2021-02-28-greetings.md"
|
||||
---
|
||||
slug: greetings
|
||||
title: Greetings!
|
||||
authors:
|
||||
- name: Joel Marcey
|
||||
title: Co-creator of Docusaurus 1
|
||||
url: https://github.com/JoelMarcey
|
||||
image_url: https://github.com/JoelMarcey.png
|
||||
- name: Sébastien Lorber
|
||||
title: Docusaurus maintainer
|
||||
url: https://sebastienlorber.com
|
||||
image_url: https://github.com/slorber.png
|
||||
tags: [greetings]
|
||||
---
|
||||
|
||||
Congratulations, you have made your first post!
|
||||
|
||||
Feel free to play around and edit this post as much as you like.
|
||||
```
|
||||
|
||||
A new blog post is now available at [http://localhost:3000/blog/greetings](http://localhost:3000/blog/greetings).
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
# Create a Document
|
||||
|
||||
Documents are **groups of pages** connected through:
|
||||
|
||||
- a **sidebar**
|
||||
- **previous/next navigation**
|
||||
- **versioning**
|
||||
|
||||
## Create your first Doc
|
||||
|
||||
Create a Markdown file at `docs/hello.md`:
|
||||
|
||||
```md title="docs/hello.md"
|
||||
# Hello
|
||||
|
||||
This is my **first Docusaurus document**!
|
||||
```
|
||||
|
||||
A new document is now available at [http://localhost:3000/docs/hello](http://localhost:3000/docs/hello).
|
||||
|
||||
## Configure the Sidebar
|
||||
|
||||
Docusaurus automatically **creates a sidebar** from the `docs` folder.
|
||||
|
||||
Add metadata to customize the sidebar label and position:
|
||||
|
||||
```md title="docs/hello.md" {1-4}
|
||||
---
|
||||
sidebar_label: 'Hi!'
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
# Hello
|
||||
|
||||
This is my **first Docusaurus document**!
|
||||
```
|
||||
|
||||
It is also possible to create your sidebar explicitly in `sidebars.js`:
|
||||
|
||||
```js title="sidebars.js"
|
||||
export default {
|
||||
tutorialSidebar: [
|
||||
'intro',
|
||||
// highlight-next-line
|
||||
'hello',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Tutorial',
|
||||
items: ['tutorial-basics/create-a-document'],
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Create a Page
|
||||
|
||||
Add **Markdown or React** files to `src/pages` to create a **standalone page**:
|
||||
|
||||
- `src/pages/index.js` → `localhost:3000/`
|
||||
- `src/pages/foo.md` → `localhost:3000/foo`
|
||||
- `src/pages/foo/bar.js` → `localhost:3000/foo/bar`
|
||||
|
||||
## Create your first React Page
|
||||
|
||||
Create a file at `src/pages/my-react-page.js`:
|
||||
|
||||
```jsx title="src/pages/my-react-page.js"
|
||||
import React from 'react';
|
||||
import Layout from '@theme/Layout';
|
||||
|
||||
export default function MyReactPage() {
|
||||
return (
|
||||
<Layout>
|
||||
<h1>My React page</h1>
|
||||
<p>This is a React page</p>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
A new page is now available at [http://localhost:3000/my-react-page](http://localhost:3000/my-react-page).
|
||||
|
||||
## Create your first Markdown Page
|
||||
|
||||
Create a file at `src/pages/my-markdown-page.md`:
|
||||
|
||||
```mdx title="src/pages/my-markdown-page.md"
|
||||
# My Markdown page
|
||||
|
||||
This is a Markdown page
|
||||
```
|
||||
|
||||
A new page is now available at [http://localhost:3000/my-markdown-page](http://localhost:3000/my-markdown-page).
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
---
|
||||
|
||||
# Deploy your site
|
||||
|
||||
Docusaurus is a **static-site-generator** (also called **[Jamstack](https://jamstack.org/)**).
|
||||
|
||||
It builds your site as simple **static HTML, JavaScript and CSS files**.
|
||||
|
||||
## Build your site
|
||||
|
||||
Build your site **for production**:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
The static files are generated in the `build` folder.
|
||||
|
||||
## Deploy your site
|
||||
|
||||
Test your production build locally:
|
||||
|
||||
```bash
|
||||
npm run serve
|
||||
```
|
||||
|
||||
The `build` folder is now served at [http://localhost:3000/](http://localhost:3000/).
|
||||
|
||||
You can now deploy the `build` folder **almost anywhere** easily, **for free** or very small cost (read the **[Deployment Guide](https://docusaurus.io/docs/deployment)**).
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# Markdown Features
|
||||
|
||||
Docusaurus supports **[Markdown](https://daringfireball.net/projects/markdown/syntax)** and a few **additional features**.
|
||||
|
||||
## Front Matter
|
||||
|
||||
Markdown documents have metadata at the top called [Front Matter](https://jekyllrb.com/docs/front-matter/):
|
||||
|
||||
```text title="my-doc.md"
|
||||
// highlight-start
|
||||
---
|
||||
id: my-doc-id
|
||||
title: My document title
|
||||
description: My document description
|
||||
slug: /my-custom-url
|
||||
---
|
||||
// highlight-end
|
||||
|
||||
## Markdown heading
|
||||
|
||||
Markdown text with [links](./hello.md)
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
Regular Markdown links are supported, using url paths or relative file paths.
|
||||
|
||||
```md
|
||||
Let's see how to [Create a page](/create-a-page).
|
||||
```
|
||||
|
||||
```md
|
||||
Let's see how to [Create a page](./create-a-page.md).
|
||||
```
|
||||
|
||||
**Result:** Let's see how to [Create a page](./create-a-page.md).
|
||||
|
||||
## Images
|
||||
|
||||
Regular Markdown images are supported.
|
||||
|
||||
You can use absolute paths to reference images in the static directory (`static/img/docusaurus.png`):
|
||||
|
||||
```md
|
||||

|
||||
```
|
||||
|
||||

|
||||
|
||||
You can reference images relative to the current file as well. This is particularly useful to colocate images close to the Markdown files using them:
|
||||
|
||||
```md
|
||||

|
||||
```
|
||||
|
||||
## Code Blocks
|
||||
|
||||
Markdown code blocks are supported with Syntax highlighting.
|
||||
|
||||
````md
|
||||
```jsx title="src/components/HelloDocusaurus.js"
|
||||
function HelloDocusaurus() {
|
||||
return <h1>Hello, Docusaurus!</h1>;
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
```jsx title="src/components/HelloDocusaurus.js"
|
||||
function HelloDocusaurus() {
|
||||
return <h1>Hello, Docusaurus!</h1>;
|
||||
}
|
||||
```
|
||||
|
||||
## Admonitions
|
||||
|
||||
Docusaurus has a special syntax to create admonitions and callouts:
|
||||
|
||||
```md
|
||||
:::tip My tip
|
||||
|
||||
Use this awesome feature option
|
||||
|
||||
:::
|
||||
|
||||
:::danger Take care
|
||||
|
||||
This action is dangerous
|
||||
|
||||
:::
|
||||
```
|
||||
|
||||
:::tip My tip
|
||||
|
||||
Use this awesome feature option
|
||||
|
||||
:::
|
||||
|
||||
:::danger Take care
|
||||
|
||||
This action is dangerous
|
||||
|
||||
:::
|
||||
|
||||
## MDX and React Components
|
||||
|
||||
[MDX](https://mdxjs.com/) can make your documentation more **interactive** and allows using any **React components inside Markdown**:
|
||||
|
||||
```jsx
|
||||
export const Highlight = ({children, color}) => (
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderRadius: '20px',
|
||||
color: '#fff',
|
||||
padding: '10px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => {
|
||||
alert(`You clicked the color ${color} with label ${children}`)
|
||||
}}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
This is <Highlight color="#25c2a0">Docusaurus green</Highlight> !
|
||||
|
||||
This is <Highlight color="#1877F2">Facebook blue</Highlight> !
|
||||
```
|
||||
|
||||
export const Highlight = ({children, color}) => (
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderRadius: '20px',
|
||||
color: '#fff',
|
||||
padding: '10px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => {
|
||||
alert(`You clicked the color ${color} with label ${children}`);
|
||||
}}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
This is <Highlight color="#25c2a0">Docusaurus green</Highlight> !
|
||||
|
||||
This is <Highlight color="#1877F2">Facebook blue</Highlight> !
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"label": "Tutorial - Extras",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,55 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Manage Docs Versions
|
||||
|
||||
Docusaurus can manage multiple versions of your docs.
|
||||
|
||||
## Create a docs version
|
||||
|
||||
Release a version 1.0 of your project:
|
||||
|
||||
```bash
|
||||
npm run docusaurus docs:version 1.0
|
||||
```
|
||||
|
||||
The `docs` folder is copied into `versioned_docs/version-1.0` and `versions.json` is created.
|
||||
|
||||
Your docs now have 2 versions:
|
||||
|
||||
- `1.0` at `http://localhost:3000/docs/` for the version 1.0 docs
|
||||
- `current` at `http://localhost:3000/docs/next/` for the **upcoming, unreleased docs**
|
||||
|
||||
## Add a Version Dropdown
|
||||
|
||||
To navigate seamlessly across versions, add a version dropdown.
|
||||
|
||||
Modify the `docusaurus.config.js` file:
|
||||
|
||||
```js title="docusaurus.config.js"
|
||||
export default {
|
||||
themeConfig: {
|
||||
navbar: {
|
||||
items: [
|
||||
// highlight-start
|
||||
{
|
||||
type: 'docsVersionDropdown',
|
||||
},
|
||||
// highlight-end
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
The docs version dropdown appears in your navbar:
|
||||
|
||||

|
||||
|
||||
## Update an existing version
|
||||
|
||||
It is possible to edit versioned docs in their respective folder:
|
||||
|
||||
- `versioned_docs/version-1.0/hello.md` updates `http://localhost:3000/docs/hello`
|
||||
- `docs/hello.md` updates `http://localhost:3000/docs/next/hello`
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
# Translate your site
|
||||
|
||||
Let's translate `docs/intro.md` to French.
|
||||
|
||||
## Configure i18n
|
||||
|
||||
Modify `docusaurus.config.js` to add support for the `fr` locale:
|
||||
|
||||
```js title="docusaurus.config.js"
|
||||
export default {
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'fr'],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Translate a doc
|
||||
|
||||
Copy the `docs/intro.md` file to the `i18n/fr` folder:
|
||||
|
||||
```bash
|
||||
mkdir -p i18n/fr/docusaurus-plugin-content-docs/current/
|
||||
|
||||
cp docs/intro.md i18n/fr/docusaurus-plugin-content-docs/current/intro.md
|
||||
```
|
||||
|
||||
Translate `i18n/fr/docusaurus-plugin-content-docs/current/intro.md` in French.
|
||||
|
||||
## Start your localized site
|
||||
|
||||
Start your site on the French locale:
|
||||
|
||||
```bash
|
||||
npm run start -- --locale fr
|
||||
```
|
||||
|
||||
Your localized site is accessible at [http://localhost:3000/fr/](http://localhost:3000/fr/) and the `Getting Started` page is translated.
|
||||
|
||||
:::caution
|
||||
|
||||
In development, you can only use one locale at a time.
|
||||
|
||||
:::
|
||||
|
||||
## Add a Locale Dropdown
|
||||
|
||||
To navigate seamlessly across languages, add a locale dropdown.
|
||||
|
||||
Modify the `docusaurus.config.js` file:
|
||||
|
||||
```js title="docusaurus.config.js"
|
||||
export default {
|
||||
themeConfig: {
|
||||
navbar: {
|
||||
items: [
|
||||
// highlight-start
|
||||
{
|
||||
type: 'localeDropdown',
|
||||
},
|
||||
// highlight-end
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
The locale dropdown now appears in your navbar:
|
||||
|
||||

|
||||
|
||||
## Build your localized site
|
||||
|
||||
Build your site for a specific locale:
|
||||
|
||||
```bash
|
||||
npm run build -- --locale fr
|
||||
```
|
||||
|
||||
Or build your site to include all the locales at once:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
@@ -1,13 +1,13 @@
|
||||
import type * as Preset from "@docusaurus/preset-classic"
|
||||
import type { Config } from "@docusaurus/types"
|
||||
import { themes as prismThemes } from "prism-react-renderer"
|
||||
import type * as Preset from '@docusaurus/preset-classic';
|
||||
import type {Config} from '@docusaurus/types';
|
||||
import {themes as prismThemes} from 'prism-react-renderer';
|
||||
|
||||
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||
|
||||
const config: Config = {
|
||||
title: "Effect View",
|
||||
tagline: "Write React function components with Effect",
|
||||
favicon: "img/favicon.ico",
|
||||
title: 'My Site',
|
||||
tagline: 'Dinosaurs are cool',
|
||||
favicon: 'img/favicon.ico',
|
||||
|
||||
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
|
||||
future: {
|
||||
@@ -15,48 +15,54 @@ const config: Config = {
|
||||
},
|
||||
|
||||
// Set the production url of your site here
|
||||
url: "https://thiladev.github.io",
|
||||
url: 'https://your-docusaurus-site.example.com',
|
||||
// Set the /<baseUrl>/ pathname under which your site is served
|
||||
// For GitHub pages deployment, it is often '/<projectName>/'
|
||||
baseUrl: "/effect-view/",
|
||||
baseUrl: '/',
|
||||
|
||||
// GitHub pages deployment config.
|
||||
// If you aren't using GitHub pages, you don't need these.
|
||||
organizationName: "Thiladev", // Usually your GitHub org/user name.
|
||||
projectName: "effect-view", // Usually your repo name.
|
||||
organizationName: 'facebook', // Usually your GitHub org/user name.
|
||||
projectName: 'docusaurus', // Usually your repo name.
|
||||
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenLinks: 'throw',
|
||||
|
||||
// Even if you don't use internationalization, you can use this field to set
|
||||
// useful metadata like html lang. For example, if your site is Chinese, you
|
||||
// may want to replace "en" with "zh-Hans".
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
defaultLocale: 'en',
|
||||
locales: ['en'],
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
"classic",
|
||||
'classic',
|
||||
{
|
||||
docs: {
|
||||
sidebarPath: "./sidebars.ts",
|
||||
sidebarPath: './sidebars.ts',
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl:
|
||||
"https://github.com/Thiladev/effect-view/tree/main/packages/docs/",
|
||||
lastVersion: "current",
|
||||
versions: {
|
||||
current: {
|
||||
label: "effect-view v0",
|
||||
},
|
||||
"effect-fc-v0": {
|
||||
label: "effect-fc v0",
|
||||
path: "effect-fc/v0",
|
||||
},
|
||||
},
|
||||
'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
|
||||
},
|
||||
blog: {
|
||||
showReadingTime: true,
|
||||
feedOptions: {
|
||||
type: ['rss', 'atom'],
|
||||
xslt: true,
|
||||
},
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl:
|
||||
'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
|
||||
// Useful options to enforce blogging best practices
|
||||
onInlineTags: 'warn',
|
||||
onInlineAuthors: 'warn',
|
||||
onUntruncatedBlogPosts: 'warn',
|
||||
},
|
||||
blog: false,
|
||||
theme: {
|
||||
customCss: "./src/css/custom.css",
|
||||
customCss: './src/css/custom.css',
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
@@ -64,84 +70,81 @@ const config: Config = {
|
||||
|
||||
themeConfig: {
|
||||
// Replace with your project's social card
|
||||
image: 'img/docusaurus-social-card.jpg',
|
||||
colorMode: {
|
||||
respectPrefersColorScheme: true,
|
||||
},
|
||||
navbar: {
|
||||
title: "Effect View",
|
||||
title: 'My Site',
|
||||
logo: {
|
||||
alt: "Effect View logo",
|
||||
src: "img/logo.svg",
|
||||
alt: 'My Site Logo',
|
||||
src: 'img/logo.svg',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: "docSidebar",
|
||||
sidebarId: "docsSidebar",
|
||||
position: "left",
|
||||
label: "Docs",
|
||||
type: 'docSidebar',
|
||||
sidebarId: 'tutorialSidebar',
|
||||
position: 'left',
|
||||
label: 'Tutorial',
|
||||
},
|
||||
{to: '/blog', label: 'Blog', position: 'left'},
|
||||
{
|
||||
type: "docsVersionDropdown",
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
href: "https://www.npmjs.com/package/effect-view",
|
||||
label: "npm",
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
href: "https://github.com/Thiladev/effect-view",
|
||||
label: "GitHub",
|
||||
position: "right",
|
||||
href: 'https://github.com/facebook/docusaurus',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: "dark",
|
||||
style: 'dark',
|
||||
links: [
|
||||
{
|
||||
title: "Docs",
|
||||
title: 'Docs',
|
||||
items: [
|
||||
{
|
||||
label: "Getting Started",
|
||||
to: "/docs/getting-started",
|
||||
label: 'Tutorial',
|
||||
to: '/docs/intro',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Project",
|
||||
title: 'Community',
|
||||
items: [
|
||||
{
|
||||
label: "GitHub",
|
||||
href: "https://github.com/Thiladev/effect-view",
|
||||
label: 'Stack Overflow',
|
||||
href: 'https://stackoverflow.com/questions/tagged/docusaurus',
|
||||
},
|
||||
{
|
||||
label: "Example App",
|
||||
href: "https://github.com/Thiladev/effect-view/tree/main/packages/example",
|
||||
label: 'Discord',
|
||||
href: 'https://discordapp.com/invite/docusaurus',
|
||||
},
|
||||
{
|
||||
label: 'X',
|
||||
href: 'https://x.com/docusaurus',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Ecosystem",
|
||||
title: 'More',
|
||||
items: [
|
||||
{
|
||||
label: "Effect",
|
||||
href: "https://effect.website/",
|
||||
label: 'Blog',
|
||||
to: '/blog',
|
||||
},
|
||||
{
|
||||
label: "React",
|
||||
href: "https://react.dev/",
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/facebook/docusaurus',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
copyright: `Copyright © ${new Date().getFullYear()} Effect View contributors. Built with Docusaurus.`,
|
||||
copyright: `Copyright © ${new Date().getFullYear()} My Project, Inc. Built with Docusaurus.`,
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.github,
|
||||
darkTheme: prismThemes.dracula,
|
||||
},
|
||||
} satisfies Preset.ThemeConfig,
|
||||
}
|
||||
};
|
||||
|
||||
export default config
|
||||
export default config;
|
||||
|
||||
@@ -3,38 +3,31 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint:tsc": "tsc -b --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build --out-dir dist",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve --dir dist",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"clean:cache": "rm -rf .turbo *.tsbuildinfo && docusaurus clear",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.10.2",
|
||||
"@docusaurus/faster": "^3.10.2",
|
||||
"@docusaurus/preset-classic": "^3.10.2",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
"@docusaurus/core": "3.9.2",
|
||||
"@docusaurus/preset-classic": "3.9.2",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.10.2",
|
||||
"@docusaurus/tsconfig": "^3.10.2",
|
||||
"@docusaurus/types": "^3.10.2",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "~6.0.3"
|
||||
"@docusaurus/module-type-aliases": "3.9.2",
|
||||
"@docusaurus/tsconfig": "3.9.2",
|
||||
"@docusaurus/types": "3.9.2",
|
||||
"typescript": "~6.0.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import type { SidebarsConfig } from "@docusaurus/plugin-content-docs"
|
||||
import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
|
||||
|
||||
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||
|
||||
const sidebars: SidebarsConfig = {
|
||||
docsSidebar: [
|
||||
"getting-started",
|
||||
"state-management",
|
||||
"async",
|
||||
"query",
|
||||
"mutation",
|
||||
"forms",
|
||||
],
|
||||
}
|
||||
/**
|
||||
* Creating a sidebar enables you to:
|
||||
- create an ordered group of docs
|
||||
- render a sidebar for each doc of that group
|
||||
- provide next/previous navigation
|
||||
|
||||
export default sidebars
|
||||
The sidebars can be generated from the filesystem, or explicitly defined here.
|
||||
|
||||
Create as many sidebars as you want.
|
||||
*/
|
||||
const sidebars: SidebarsConfig = {
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
|
||||
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
tutorialSidebar: [
|
||||
'intro',
|
||||
'hello',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Tutorial',
|
||||
items: ['tutorial-basics/create-a-document'],
|
||||
},
|
||||
],
|
||||
*/
|
||||
};
|
||||
|
||||
export default sidebars;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import Heading from '@theme/Heading';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
Svg: React.ComponentType<React.ComponentProps<'svg'>>;
|
||||
description: ReactNode;
|
||||
};
|
||||
|
||||
const FeatureList: FeatureItem[] = [
|
||||
{
|
||||
title: 'Easy to Use',
|
||||
Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default,
|
||||
description: (
|
||||
<>
|
||||
Docusaurus was designed from the ground up to be easily installed and
|
||||
used to get your website up and running quickly.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Focus on What Matters',
|
||||
Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default,
|
||||
description: (
|
||||
<>
|
||||
Docusaurus lets you focus on your docs, and we'll do the chores. Go
|
||||
ahead and move your docs into the <code>docs</code> directory.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Powered by React',
|
||||
Svg: require('@site/static/img/undraw_docusaurus_react.svg').default,
|
||||
description: (
|
||||
<>
|
||||
Extend or customize your website layout by reusing React. Docusaurus can
|
||||
be extended while reusing the same header and footer.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function Feature({title, Svg, description}: FeatureItem) {
|
||||
return (
|
||||
<div className={clsx('col col--4')}>
|
||||
<div className="text--center">
|
||||
<Svg className={styles.featureSvg} role="img" />
|
||||
</div>
|
||||
<div className="text--center padding-horiz--md">
|
||||
<Heading as="h3">{title}</Heading>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomepageFeatures(): ReactNode {
|
||||
return (
|
||||
<section className={styles.features}>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
{FeatureList.map((props, idx) => (
|
||||
<Feature key={idx} {...props} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.features {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.featureSvg {
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
}
|
||||
@@ -1,37 +1,30 @@
|
||||
/**
|
||||
* Any CSS included here will be global. The classic template
|
||||
* bundles Infima by default. Infima is a CSS framework designed to
|
||||
* work well for content-centric websites.
|
||||
*/
|
||||
|
||||
/* You can override the default Infima variables here. */
|
||||
:root {
|
||||
--ifm-color-primary: #0b6f74;
|
||||
--ifm-color-primary-dark: #096469;
|
||||
--ifm-color-primary-darker: #085e63;
|
||||
--ifm-color-primary-darkest: #074d51;
|
||||
--ifm-color-primary-light: #0d7a7f;
|
||||
--ifm-color-primary-lighter: #0e8085;
|
||||
--ifm-color-primary-lightest: #109096;
|
||||
--ifm-background-color: #fffaf1;
|
||||
--ifm-color-primary: #2e8555;
|
||||
--ifm-color-primary-dark: #29784c;
|
||||
--ifm-color-primary-darker: #277148;
|
||||
--ifm-color-primary-darkest: #205d3b;
|
||||
--ifm-color-primary-light: #33925d;
|
||||
--ifm-color-primary-lighter: #359962;
|
||||
--ifm-color-primary-lightest: #3cad6e;
|
||||
--ifm-code-font-size: 95%;
|
||||
--ifm-font-family-base:
|
||||
"Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(11, 111, 116, 0.1);
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
--ifm-button-background-color: #0b6f74;
|
||||
--ifm-button-border-color: #0b6f74;
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
--ifm-button-background-color: #f4a636;
|
||||
--ifm-button-border-color: #f4a636;
|
||||
--ifm-button-color: #1f2526;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--ifm-color-primary: #7ad6d4;
|
||||
--ifm-color-primary-dark: #5cccca;
|
||||
--ifm-color-primary-darker: #4cc6c4;
|
||||
--ifm-color-primary-darkest: #34aaa8;
|
||||
--ifm-color-primary-light: #98e0de;
|
||||
--ifm-color-primary-lighter: #a8e6e4;
|
||||
--ifm-color-primary-lightest: #d5f4f3;
|
||||
--ifm-background-color: #111c1f;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(122, 214, 212, 0.18);
|
||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||
[data-theme='dark'] {
|
||||
--ifm-color-primary: #25c2a0;
|
||||
--ifm-color-primary-dark: #21af90;
|
||||
--ifm-color-primary-darker: #1fa588;
|
||||
--ifm-color-primary-darkest: #1a8870;
|
||||
--ifm-color-primary-light: #29d5b0;
|
||||
--ifm-color-primary-lighter: #32d8b4;
|
||||
--ifm-color-primary-lightest: #4fddbf;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@@ -1,678 +1,23 @@
|
||||
.page {
|
||||
--home-ink: #102f31;
|
||||
--home-muted: #506a6c;
|
||||
--home-teal: #08777b;
|
||||
--home-teal-bright: #16a3a1;
|
||||
--home-amber: #e99526;
|
||||
--home-paper: #fffdf8;
|
||||
--home-card: rgba(255, 255, 255, 0.76);
|
||||
--home-line: rgba(16, 47, 49, 0.12);
|
||||
min-height: calc(100vh - var(--ifm-navbar-height));
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background:
|
||||
radial-gradient(circle at 8% 5%, rgba(14, 145, 148, 0.2), transparent 28rem),
|
||||
radial-gradient(circle at 92% 14%, rgba(240, 159, 48, 0.2), transparent 27rem),
|
||||
linear-gradient(145deg, #f7f2e7 0%, #edf8f6 48%, #fff8ed 100%);
|
||||
color: var(--home-ink);
|
||||
}
|
||||
/**
|
||||
* CSS files with the .module.css suffix will be treated as CSS modules
|
||||
* and scoped locally.
|
||||
*/
|
||||
|
||||
.gridBackdrop {
|
||||
background-image:
|
||||
linear-gradient(rgba(16, 47, 49, 0.045) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(16, 47, 49, 0.045) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
inset: 0;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 62%);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.hero {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: clamp(3rem, 6vw, 6rem);
|
||||
grid-template-columns: minmax(0, 0.88fr) minmax(560px, 1.12fr);
|
||||
min-height: min(820px, calc(100vh - var(--ifm-navbar-height)));
|
||||
padding-bottom: 6rem;
|
||||
padding-top: 6rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
max-width: 650px;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.sectionKicker {
|
||||
align-items: center;
|
||||
color: var(--home-teal);
|
||||
display: flex;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 850;
|
||||
gap: 0.65rem;
|
||||
letter-spacing: 0.18em;
|
||||
margin: 0 0 1.35rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
background: var(--home-teal-bright);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 5px rgba(22, 163, 161, 0.14);
|
||||
height: 0.55rem;
|
||||
width: 0.55rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
color: var(--home-ink);
|
||||
font-size: clamp(3.4rem, 6.4vw, 6.4rem);
|
||||
font-weight: 780;
|
||||
letter-spacing: -0.075em;
|
||||
line-height: 0.92;
|
||||
margin: 0;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.hero h1 .unbreakable {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero h1 .heroAccent {
|
||||
background: linear-gradient(105deg, var(--home-teal) 5%, #1a9793 48%, var(--home-amber));
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
display: inline;
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--home-muted);
|
||||
font-size: clamp(1.08rem, 1.55vw, 1.32rem);
|
||||
line-height: 1.65;
|
||||
margin: 2rem 0 0;
|
||||
max-width: 630px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
margin-top: 2.3rem;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction,
|
||||
.ctaAction {
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
font-weight: 750;
|
||||
gap: 0.8rem;
|
||||
justify-content: center;
|
||||
padding: 0.88rem 1.35rem;
|
||||
text-decoration: none !important;
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
background: var(--home-ink);
|
||||
box-shadow: 0 12px 28px rgba(16, 47, 49, 0.2);
|
||||
color: #f6fffd;
|
||||
}
|
||||
|
||||
.primaryAction:hover {
|
||||
box-shadow: 0 16px 36px rgba(16, 47, 49, 0.28);
|
||||
color: #fff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
border: 1px solid var(--home-line);
|
||||
color: var(--home-ink);
|
||||
}
|
||||
|
||||
.secondaryAction:hover {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--home-teal);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.install {
|
||||
align-items: center;
|
||||
color: var(--home-muted);
|
||||
display: flex;
|
||||
font-size: 0.84rem;
|
||||
gap: 0.7rem;
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.install span {
|
||||
color: var(--home-amber);
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.install code {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hotReload {
|
||||
align-items: center;
|
||||
color: var(--home-teal);
|
||||
display: inline-flex;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 750;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.hotReload span {
|
||||
color: var(--home-amber);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hotReload:hover {
|
||||
color: var(--home-teal-bright);
|
||||
}
|
||||
|
||||
.codeStage {
|
||||
min-width: 0;
|
||||
perspective: 1200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.codeGlow {
|
||||
background: linear-gradient(115deg, rgba(11, 133, 136, 0.48), rgba(238, 156, 40, 0.38));
|
||||
filter: blur(55px);
|
||||
inset: 8% 5%;
|
||||
opacity: 0.55;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.codeWindow {
|
||||
background: #101b1e;
|
||||
border: 1px solid rgba(166, 225, 220, 0.2);
|
||||
border-radius: 1.25rem;
|
||||
box-shadow:
|
||||
0 34px 90px rgba(18, 49, 52, 0.3),
|
||||
0 2px 0 rgba(255, 255, 255, 0.06) inset;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transform: rotateY(-2deg) rotateX(1deg);
|
||||
}
|
||||
|
||||
.windowBar {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #b6c9c9;
|
||||
display: grid;
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.73rem;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
padding: 0.78rem 1rem;
|
||||
}
|
||||
|
||||
.windowDots {
|
||||
display: flex;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.windowDots span {
|
||||
background: #4f6d6e;
|
||||
border-radius: 50%;
|
||||
height: 0.55rem;
|
||||
width: 0.55rem;
|
||||
}
|
||||
|
||||
.windowDots span:first-child {
|
||||
background: #ef8d5b;
|
||||
}
|
||||
|
||||
.windowDots span:nth-child(2) {
|
||||
background: #e9bc50;
|
||||
}
|
||||
|
||||
.windowDots span:last-child {
|
||||
background: #65bd92;
|
||||
}
|
||||
|
||||
.windowStatus {
|
||||
color: #74d2cd;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.codeWindow :global(.theme-code-block) {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.codeWindow :global(.theme-code-block pre) {
|
||||
background: #101b1e !important;
|
||||
color: #d7e5e4 !important;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.62;
|
||||
max-height: none;
|
||||
padding: 1.45rem 1.6rem !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token-line),
|
||||
.codeWindow :global(.token.plain),
|
||||
.codeWindow :global(.token.imports),
|
||||
.codeWindow :global(.token.maybe-class-name),
|
||||
.codeWindow :global(.token.punctuation),
|
||||
.codeWindow :global(.token.operator),
|
||||
.codeWindow :global(.token.plain-text),
|
||||
.codeWindow :global(.token.property-access) {
|
||||
color: #d7e5e4 !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.keyword) {
|
||||
color: #c792ea !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.string) {
|
||||
color: #c3e88d !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.function),
|
||||
.codeWindow :global(.token.method) {
|
||||
color: #82aaff !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.tag) {
|
||||
color: #f07178 !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.builtin) {
|
||||
color: #ffcb6b !important;
|
||||
}
|
||||
|
||||
.codeLegend {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #a9bcbc;
|
||||
display: grid;
|
||||
font-size: 0.7rem;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.codeLegend span {
|
||||
padding: 0.8rem 0.9rem;
|
||||
.heroBanner {
|
||||
padding: 4rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.codeLegend span + span {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.codeLegend b {
|
||||
color: #e8a94b;
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.featuresSection {
|
||||
padding-bottom: 7rem;
|
||||
padding-top: 4rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
display: grid;
|
||||
gap: 0 3rem;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
|
||||
margin-bottom: 2.8rem;
|
||||
}
|
||||
|
||||
.sectionHeading .sectionKicker {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.sectionHeading h2,
|
||||
.cta h2 {
|
||||
color: var(--home-ink);
|
||||
font-size: clamp(2.25rem, 4vw, 4rem);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sectionHeading > p:last-child {
|
||||
align-self: end;
|
||||
color: var(--home-muted);
|
||||
font-size: 1.03rem;
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.featureGrid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.featureCard {
|
||||
background: var(--home-card);
|
||||
border: 1px solid var(--home-line);
|
||||
border-radius: 1.35rem;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 310px;
|
||||
overflow: hidden;
|
||||
padding: 1.45rem;
|
||||
position: relative;
|
||||
text-decoration: none !important;
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.featureCard::before {
|
||||
background: linear-gradient(90deg, var(--home-teal-bright), var(--home-amber));
|
||||
content: "";
|
||||
height: 3px;
|
||||
left: 1.45rem;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 1.45rem;
|
||||
top: 0;
|
||||
transform: scaleX(0.5);
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.featureCard:hover {
|
||||
border-color: rgba(8, 119, 123, 0.35);
|
||||
box-shadow: 0 24px 60px rgba(28, 65, 66, 0.12);
|
||||
color: inherit;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.featureCard:hover::before {
|
||||
opacity: 1;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.cardTopline {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.featureIcon {
|
||||
align-items: center;
|
||||
background: rgba(8, 119, 123, 0.1);
|
||||
border: 1px solid rgba(8, 119, 123, 0.12);
|
||||
border-radius: 0.8rem;
|
||||
color: var(--home-teal);
|
||||
display: flex;
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 1rem;
|
||||
font-weight: 850;
|
||||
height: 2.7rem;
|
||||
justify-content: center;
|
||||
width: 2.7rem;
|
||||
}
|
||||
|
||||
.cardIndex {
|
||||
color: rgba(16, 47, 49, 0.32);
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.featureCard h3 {
|
||||
color: var(--home-ink);
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 2rem 0 0.7rem;
|
||||
}
|
||||
|
||||
.featureCard > p {
|
||||
color: var(--home-muted);
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.cardFooter code {
|
||||
background: rgba(8, 119, 123, 0.08);
|
||||
border: 0;
|
||||
color: var(--home-teal);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.cardFooter > span {
|
||||
color: var(--home-amber);
|
||||
font-size: 1.1rem;
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.featureCard:hover .cardFooter > span {
|
||||
transform: translate(3px, -3px);
|
||||
}
|
||||
|
||||
.ctaWrap {
|
||||
padding-bottom: 7rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cta {
|
||||
align-items: center;
|
||||
background:
|
||||
radial-gradient(circle at 90% 20%, rgba(233, 149, 38, 0.22), transparent 20rem),
|
||||
linear-gradient(125deg, #12383a, #10292c);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1.75rem;
|
||||
box-shadow: 0 28px 80px rgba(16, 47, 49, 0.2);
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
padding: clamp(2rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
.cta .sectionKicker {
|
||||
color: #79d5d1;
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
color: #f7fffd;
|
||||
}
|
||||
|
||||
.cta p:last-child {
|
||||
color: #b9cccc;
|
||||
line-height: 1.65;
|
||||
margin: 1rem 0 0;
|
||||
max-width: 650px;
|
||||
}
|
||||
|
||||
.ctaAction {
|
||||
background: #f0a13b;
|
||||
color: #162d2e;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ctaAction:hover {
|
||||
box-shadow: 0 14px 35px rgba(0, 0, 0, 0.24);
|
||||
color: #102526;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .page {
|
||||
--home-ink: #e9f7f5;
|
||||
--home-muted: #a9bfbd;
|
||||
--home-teal: #79d5d1;
|
||||
--home-teal-bright: #5bc8c5;
|
||||
--home-amber: #f1ad4e;
|
||||
--home-card: rgba(20, 42, 45, 0.76);
|
||||
--home-line: rgba(174, 221, 218, 0.14);
|
||||
background:
|
||||
radial-gradient(circle at 8% 5%, rgba(33, 154, 154, 0.17), transparent 28rem),
|
||||
radial-gradient(circle at 92% 14%, rgba(209, 130, 31, 0.13), transparent 27rem),
|
||||
linear-gradient(145deg, #0f1b1e 0%, #102426 48%, #161e1e 100%);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .gridBackdrop {
|
||||
background-image:
|
||||
linear-gradient(rgba(174, 221, 218, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(174, 221, 218, 0.04) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .secondaryAction {
|
||||
background: rgba(19, 43, 46, 0.75);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .secondaryAction:hover {
|
||||
background: rgba(27, 57, 60, 0.95);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .primaryAction {
|
||||
background: #dff8f5;
|
||||
color: #123638;
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .primaryAction:hover {
|
||||
color: #0b282a;
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .cardIndex {
|
||||
color: rgba(220, 242, 239, 0.35);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1180px) {
|
||||
.hero {
|
||||
gap: 3rem;
|
||||
grid-template-columns: minmax(0, 0.85fr) minmax(500px, 1.15fr);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(3.2rem, 6vw, 5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 996px) {
|
||||
.hero {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding-bottom: 4rem;
|
||||
padding-top: 4.5rem;
|
||||
}
|
||||
|
||||
.heroCopy,
|
||||
.codeStage,
|
||||
.codeWindow,
|
||||
.codeWindow :global(.theme-code-block) {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.codeStage {
|
||||
margin: 0 auto;
|
||||
max-width: 760px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.codeWindow {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.featureGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
.heroBanner {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
.hero {
|
||||
box-sizing: border-box;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
max-width: 100vw;
|
||||
padding-left: 1.15rem;
|
||||
padding-right: 1.15rem;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.heroCopy,
|
||||
.codeStage {
|
||||
max-width: calc(100vw - 2.3rem);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(3rem, 15vw, 4.3rem);
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sectionHeading > p:last-child {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.featureGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.featureCard {
|
||||
min-height: 275px;
|
||||
}
|
||||
|
||||
.codeLegend {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.codeLegend span + span {
|
||||
border-left: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.windowStatus {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.windowBar {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.codeWindow :global(.theme-code-block pre) {
|
||||
font-size: 0.69rem;
|
||||
overflow-x: auto;
|
||||
padding: 1.1rem !important;
|
||||
}
|
||||
|
||||
.cta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.featureCard,
|
||||
.featureCard::before,
|
||||
.cardFooter > span,
|
||||
.primaryAction,
|
||||
.secondaryAction,
|
||||
.ctaAction {
|
||||
transition: none;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -1,212 +1,44 @@
|
||||
import Link from "@docusaurus/Link"
|
||||
import CodeBlock from "@theme/CodeBlock"
|
||||
import Layout from "@theme/Layout"
|
||||
import clsx from "clsx"
|
||||
import type { ReactNode } from "react"
|
||||
import Link from '@docusaurus/Link';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import HomepageFeatures from '@site/src/components/HomepageFeatures';
|
||||
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';
|
||||
|
||||
const componentExample = `import { Effect } from "effect"
|
||||
import { Async, Component } from "effect-view"
|
||||
import { Users } from "./services"
|
||||
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { userId: string }) {
|
||||
const users = yield* Users
|
||||
|
||||
const user = yield* Component.useOnChange(
|
||||
() => users.find(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
yield* Component.useReactEffect(
|
||||
() => Effect.forkScoped(users.watch(userId)),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return (
|
||||
<article>
|
||||
<h2>{user.name}</h2>
|
||||
<p>{user.email}</p>
|
||||
</article>
|
||||
)
|
||||
},
|
||||
).pipe(Async.async)`
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: "fx",
|
||||
title: "Components are Effect programs",
|
||||
description:
|
||||
"Yield Effects and services directly from a component body, then return ordinary JSX. Types track every dependency all the way to the runtime boundary.",
|
||||
tag: "Component.make",
|
||||
to: "/docs/getting-started#write-your-first-component",
|
||||
},
|
||||
{
|
||||
icon: "R",
|
||||
title: "One typed runtime",
|
||||
description:
|
||||
"Build your application Layer once. Components access HTTP clients, repositories, configuration, tracing, and your own services without React context plumbing.",
|
||||
tag: "ReactRuntime",
|
||||
to: "/docs/getting-started#create-the-runtime",
|
||||
},
|
||||
{
|
||||
icon: "S",
|
||||
title: "Scopes follow React",
|
||||
description:
|
||||
"Every component owns an Effect Scope. Resources, subscriptions, and fibers are finalized when their component or dependency lifecycle ends.",
|
||||
tag: "Scope.Scope",
|
||||
to: "/docs/getting-started#the-component-root-scope",
|
||||
},
|
||||
{
|
||||
icon: "↔",
|
||||
title: "Reactive state without a new universe",
|
||||
description:
|
||||
"Lens and View connect Effect state to React. Focus large models into small writable values and subscribe only where rendering needs them.",
|
||||
tag: "Lens + View",
|
||||
to: "/docs/state-management",
|
||||
},
|
||||
{
|
||||
icon: "Q",
|
||||
title: "TanStack-shaped server state",
|
||||
description:
|
||||
"Reactive keys, caching, stale times, background refresh, mutations, and invalidation—with typed Effect services, Causes, and scoped fibers underneath.",
|
||||
tag: "Query + Mutation",
|
||||
to: "/docs/query",
|
||||
},
|
||||
{
|
||||
icon: "Σ",
|
||||
title: "Forms driven by Schema",
|
||||
description:
|
||||
"Keep input-friendly encoded values while application code receives validated, decoded types. Focus one root into reusable field-sized subforms.",
|
||||
tag: "MutationForm + LensForm",
|
||||
to: "/docs/forms",
|
||||
},
|
||||
] as const
|
||||
function HomepageHeader() {
|
||||
const {siteConfig} = useDocusaurusContext();
|
||||
return (
|
||||
<header className={clsx('hero hero--primary', styles.heroBanner)}>
|
||||
<div className="container">
|
||||
<Heading as="h1" className="hero__title">
|
||||
{siteConfig.title}
|
||||
</Heading>
|
||||
<p className="hero__subtitle">{siteConfig.tagline}</p>
|
||||
<div className={styles.buttons}>
|
||||
<Link
|
||||
className="button button--secondary button--lg"
|
||||
to="/docs/intro">
|
||||
Docusaurus Tutorial - 5min ⏱️
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home(): ReactNode {
|
||||
const {siteConfig} = useDocusaurusContext();
|
||||
return (
|
||||
<Layout
|
||||
title="Effect View"
|
||||
description="Write React function components as typed Effect programs"
|
||||
>
|
||||
<main className={styles.page}>
|
||||
<div className={styles.gridBackdrop} aria-hidden="true" />
|
||||
|
||||
<section className={clsx("container", styles.hero)}>
|
||||
<div className={styles.heroCopy}>
|
||||
<div className={styles.eyebrow}>
|
||||
<span className={styles.pulse} />
|
||||
Effect View for React 19
|
||||
</div>
|
||||
<h1>
|
||||
React <span className={styles.unbreakable}>components,</span>
|
||||
<span className={styles.heroAccent}> powered by Effect.</span>
|
||||
</h1>
|
||||
<p className={styles.lede}>
|
||||
Bring typed services, scoped resources, reactive state, server
|
||||
queries, and schema-driven forms into React without hiding either
|
||||
framework.
|
||||
</p>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link className={styles.primaryAction} to="/docs/getting-started">
|
||||
Start building
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
<Link
|
||||
className={styles.secondaryAction}
|
||||
to="https://github.com/Thiladev/effect-view"
|
||||
>
|
||||
View on GitHub
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.install}>
|
||||
<span aria-hidden="true">$</span>
|
||||
<code>npm install effect-view effect@beta</code>
|
||||
</div>
|
||||
<Link
|
||||
className={styles.hotReload}
|
||||
to="/docs/getting-started#set-up-hot-reloading-with-vite"
|
||||
>
|
||||
<span aria-hidden="true">↻</span>
|
||||
Hot reload with Vite Fast Refresh
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.codeStage}>
|
||||
<div className={styles.codeGlow} aria-hidden="true" />
|
||||
<div className={styles.codeWindow}>
|
||||
<div className={styles.windowBar}>
|
||||
<div className={styles.windowDots} aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<span>UserCard.tsx</span>
|
||||
<span className={styles.windowStatus}>Effect + JSX</span>
|
||||
</div>
|
||||
<CodeBlock language="tsx">{componentExample}</CodeBlock>
|
||||
<div className={styles.codeLegend}>
|
||||
<span><b>01</b> Yield services</span>
|
||||
<span><b>02</b> Own the lifecycle</span>
|
||||
<span><b>03</b> Return JSX</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={clsx("container", styles.featuresSection)}>
|
||||
<div className={styles.sectionHeading}>
|
||||
<p className={styles.sectionKicker}>Why Effect View</p>
|
||||
<h2>One model from render to resources.</h2>
|
||||
<p>
|
||||
Effect View adds the pieces React deliberately leaves open while
|
||||
preserving the React component model you already know.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.featureGrid}>
|
||||
{features.map((feature, index) => (
|
||||
<Link
|
||||
className={styles.featureCard}
|
||||
to={feature.to}
|
||||
key={feature.title}
|
||||
>
|
||||
<div className={styles.cardTopline}>
|
||||
<span className={styles.featureIcon}>{feature.icon}</span>
|
||||
<span className={styles.cardIndex}>
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<h3>{feature.title}</h3>
|
||||
<p>{feature.description}</p>
|
||||
<div className={styles.cardFooter}>
|
||||
<code>{feature.tag}</code>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={clsx("container", styles.ctaWrap)}>
|
||||
<div className={styles.cta}>
|
||||
<div>
|
||||
<p className={styles.sectionKicker}>Ready when React is</p>
|
||||
<h2>Start with one Effect component.</h2>
|
||||
<p>
|
||||
Add a runtime, cross the React boundary once, and grow into the
|
||||
rest of the toolkit only when you need it.
|
||||
</p>
|
||||
</div>
|
||||
<Link className={styles.ctaAction} to="/docs/getting-started">
|
||||
Read the guide <span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
title={`Hello from ${siteConfig.title}`}
|
||||
description="Description will go into a meta tag in <head />">
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
</main>
|
||||
</Layout>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Markdown page example
|
||||
---
|
||||
|
||||
# Markdown page example
|
||||
|
||||
You don't need React to write simple standalone pages.
|
||||
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1,171 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1088" height="687.962" viewBox="0 0 1088 687.962">
|
||||
<title>Easy to Use</title>
|
||||
<g id="Group_12" data-name="Group 12" transform="translate(-57 -56)">
|
||||
<g id="Group_11" data-name="Group 11" transform="translate(57 56)">
|
||||
<path id="Path_83" data-name="Path 83" d="M1017.81,560.461c-5.27,45.15-16.22,81.4-31.25,110.31-20,38.52-54.21,54.04-84.77,70.28a193.275,193.275,0,0,1-27.46,11.94c-55.61,19.3-117.85,14.18-166.74,3.99a657.282,657.282,0,0,0-104.09-13.16q-14.97-.675-29.97-.67c-15.42.02-293.07,5.29-360.67-131.57-16.69-33.76-28.13-75-32.24-125.27-11.63-142.12,52.29-235.46,134.74-296.47,155.97-115.41,369.76-110.57,523.43,7.88C941.15,276.621,1036.99,396.031,1017.81,560.461Z" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||
<path id="Path_84" data-name="Path 84" d="M986.56,670.771c-20,38.52-47.21,64.04-77.77,80.28a193.272,193.272,0,0,1-27.46,11.94c-55.61,19.3-117.85,14.18-166.74,3.99a657.3,657.3,0,0,0-104.09-13.16q-14.97-.675-29.97-.67-23.13.03-46.25,1.72c-100.17,7.36-253.82-6.43-321.42-143.29L382,283.981,444.95,445.6l20.09,51.59,55.37-75.98L549,381.981l130.2,149.27,36.8-81.27L970.78,657.9l14.21,11.59Z" transform="translate(-56 -106.019)" fill="#f2f2f2"/>
|
||||
<path id="Path_85" data-name="Path 85" d="M302,282.962l26-57,36,83-31-60Z" opacity="0.1"/>
|
||||
<path id="Path_86" data-name="Path 86" d="M610.5,753.821q-14.97-.675-29.97-.67L465.04,497.191Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||
<path id="Path_87" data-name="Path 87" d="M464.411,315.191,493,292.962l130,150-132-128Z" opacity="0.1"/>
|
||||
<path id="Path_88" data-name="Path 88" d="M908.79,751.051a193.265,193.265,0,0,1-27.46,11.94L679.2,531.251Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||
<circle id="Ellipse_11" data-name="Ellipse 11" cx="3" cy="3" r="3" transform="translate(479 98.962)" fill="#f2f2f2"/>
|
||||
<circle id="Ellipse_12" data-name="Ellipse 12" cx="3" cy="3" r="3" transform="translate(396 201.962)" fill="#f2f2f2"/>
|
||||
<circle id="Ellipse_13" data-name="Ellipse 13" cx="2" cy="2" r="2" transform="translate(600 220.962)" fill="#f2f2f2"/>
|
||||
<circle id="Ellipse_14" data-name="Ellipse 14" cx="2" cy="2" r="2" transform="translate(180 265.962)" fill="#f2f2f2"/>
|
||||
<circle id="Ellipse_15" data-name="Ellipse 15" cx="2" cy="2" r="2" transform="translate(612 96.962)" fill="#f2f2f2"/>
|
||||
<circle id="Ellipse_16" data-name="Ellipse 16" cx="2" cy="2" r="2" transform="translate(736 192.962)" fill="#f2f2f2"/>
|
||||
<circle id="Ellipse_17" data-name="Ellipse 17" cx="2" cy="2" r="2" transform="translate(858 344.962)" fill="#f2f2f2"/>
|
||||
<path id="Path_89" data-name="Path 89" d="M306,121.222h-2.76v-2.76h-1.48v2.76H299V122.7h2.76v2.759h1.48V122.7H306Z" fill="#f2f2f2"/>
|
||||
<path id="Path_90" data-name="Path 90" d="M848,424.222h-2.76v-2.76h-1.48v2.76H841V425.7h2.76v2.759h1.48V425.7H848Z" fill="#f2f2f2"/>
|
||||
<path id="Path_91" data-name="Path 91" d="M1144,719.981c0,16.569-243.557,74-544,74s-544-57.431-544-74,243.557,14,544,14S1144,703.413,1144,719.981Z" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||
<path id="Path_92" data-name="Path 92" d="M1144,719.981c0,16.569-243.557,74-544,74s-544-57.431-544-74,243.557,14,544,14S1144,703.413,1144,719.981Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||
<ellipse id="Ellipse_18" data-name="Ellipse 18" cx="544" cy="30" rx="544" ry="30" transform="translate(0 583.962)" fill="#3f3d56"/>
|
||||
<path id="Path_93" data-name="Path 93" d="M624,677.981c0,33.137-14.775,24-33,24s-33,9.137-33-24,33-96,33-96S624,644.844,624,677.981Z" transform="translate(-56 -106.019)" fill="#ff6584"/>
|
||||
<path id="Path_94" data-name="Path 94" d="M606,690.66c0,15.062-6.716,10.909-15,10.909s-15,4.153-15-10.909,15-43.636,15-43.636S606,675.6,606,690.66Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||
<rect id="Rectangle_97" data-name="Rectangle 97" width="92" height="18" rx="9" transform="translate(489 604.962)" fill="#2f2e41"/>
|
||||
<rect id="Rectangle_98" data-name="Rectangle 98" width="92" height="18" rx="9" transform="translate(489 586.962)" fill="#2f2e41"/>
|
||||
<path id="Path_95" data-name="Path 95" d="M193,596.547c0,55.343,34.719,100.126,77.626,100.126" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||
<path id="Path_96" data-name="Path 96" d="M270.626,696.673c0-55.965,38.745-101.251,86.626-101.251" transform="translate(-56 -106.019)" fill="#6c63ff"/>
|
||||
<path id="Path_97" data-name="Path 97" d="M221.125,601.564c0,52.57,22.14,95.109,49.5,95.109" transform="translate(-56 -106.019)" fill="#6c63ff"/>
|
||||
<path id="Path_98" data-name="Path 98" d="M270.626,696.673c0-71.511,44.783-129.377,100.126-129.377" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||
<path id="Path_99" data-name="Path 99" d="M254.3,697.379s11.009-.339,14.326-2.7,16.934-5.183,17.757-1.395,16.544,18.844,4.115,18.945-28.879-1.936-32.19-3.953S254.3,697.379,254.3,697.379Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||
<path id="Path_100" data-name="Path 100" d="M290.716,710.909c-12.429.1-28.879-1.936-32.19-3.953-2.522-1.536-3.527-7.048-3.863-9.591l-.368.014s.7,8.879,4.009,10.9,19.761,4.053,32.19,3.953c3.588-.029,4.827-1.305,4.759-3.2C294.755,710.174,293.386,710.887,290.716,710.909Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||
<path id="Path_101" data-name="Path 101" d="M777.429,633.081c0,38.029,23.857,68.8,53.341,68.8" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||
<path id="Path_102" data-name="Path 102" d="M830.769,701.882c0-38.456,26.623-69.575,59.525-69.575" transform="translate(-56 -106.019)" fill="#6c63ff"/>
|
||||
<path id="Path_103" data-name="Path 103" d="M796.755,636.528c0,36.124,15.213,65.354,34.014,65.354" transform="translate(-56 -106.019)" fill="#6c63ff"/>
|
||||
<path id="Path_104" data-name="Path 104" d="M830.769,701.882c0-49.139,30.773-88.9,68.8-88.9" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||
<path id="Path_105" data-name="Path 105" d="M819.548,702.367s7.565-.233,9.844-1.856,11.636-3.562,12.2-.958,11.368,12.949,2.828,13.018-19.844-1.33-22.119-2.716S819.548,702.367,819.548,702.367Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||
<path id="Path_106" data-name="Path 106" d="M844.574,711.664c-8.54.069-19.844-1.33-22.119-2.716-1.733-1.056-2.423-4.843-2.654-6.59l-.253.01s.479,6.1,2.755,7.487,13.579,2.785,22.119,2.716c2.465-.02,3.317-.9,3.27-2.2C847.349,711.159,846.409,711.649,844.574,711.664Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||
<path id="Path_107" data-name="Path 107" d="M949.813,724.718s11.36-1.729,14.5-4.591,16.89-7.488,18.217-3.667,19.494,17.447,6.633,19.107-30.153,1.609-33.835-.065S949.813,724.718,949.813,724.718Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||
<path id="Path_108" data-name="Path 108" d="M989.228,734.173c-12.86,1.659-30.153,1.609-33.835-.065-2.8-1.275-4.535-6.858-5.2-9.45l-.379.061s1.833,9.109,5.516,10.783,20.975,1.725,33.835.065c3.712-.479,4.836-1.956,4.529-3.906C993.319,732.907,991.991,733.817,989.228,734.173Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||
<path id="Path_109" data-name="Path 109" d="M670.26,723.9s9.587-1.459,12.237-3.875,14.255-6.32,15.374-3.095,16.452,14.725,5.6,16.125-25.448,1.358-28.555-.055S670.26,723.9,670.26,723.9Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||
<path id="Path_110" data-name="Path 110" d="M703.524,731.875c-10.853,1.4-25.448,1.358-28.555-.055-2.367-1.076-3.827-5.788-4.39-7.976l-.32.051s1.547,7.687,4.655,9.1,17.7,1.456,28.555.055c3.133-.4,4.081-1.651,3.822-3.3C706.977,730.807,705.856,731.575,703.524,731.875Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||
<path id="Path_111" data-name="Path 111" d="M178.389,719.109s7.463-1.136,9.527-3.016,11.1-4.92,11.969-2.409,12.808,11.463,4.358,12.553-19.811,1.057-22.23-.043S178.389,719.109,178.389,719.109Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||
<path id="Path_112" data-name="Path 112" d="M204.285,725.321c-8.449,1.09-19.811,1.057-22.23-.043-1.842-.838-2.979-4.506-3.417-6.209l-.249.04s1.2,5.984,3.624,7.085,13.781,1.133,22.23.043c2.439-.315,3.177-1.285,2.976-2.566C206.973,724.489,206.1,725.087,204.285,725.321Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||
<path id="Path_113" data-name="Path 113" d="M439.7,707.337c0,30.22-42.124,20.873-93.7,20.873s-93.074,9.347-93.074-20.873,42.118-36.793,93.694-36.793S439.7,677.117,439.7,707.337Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||
<path id="Path_114" data-name="Path 114" d="M439.7,699.9c0,30.22-42.124,20.873-93.7,20.873s-93.074,9.347-93.074-20.873S295.04,663.1,346.616,663.1,439.7,669.676,439.7,699.9Z" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||
</g>
|
||||
<g id="docusaurus_keytar" transform="translate(312.271 493.733)">
|
||||
<path id="Path_40" data-name="Path 40" d="M99,52h91.791V89.153H99Z" transform="translate(5.904 -14.001)" fill="#fff" fill-rule="evenodd"/>
|
||||
<path id="Path_41" data-name="Path 41" d="M24.855,163.927A21.828,21.828,0,0,1,5.947,153a21.829,21.829,0,0,0,18.908,32.782H46.71V163.927Z" transform="translate(-3 -4.634)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_42" data-name="Path 42" d="M121.861,61.1l76.514-4.782V45.39A21.854,21.854,0,0,0,176.52,23.535H78.173L75.441,18.8a3.154,3.154,0,0,0-5.464,0l-2.732,4.732L64.513,18.8a3.154,3.154,0,0,0-5.464,0l-2.732,4.732L53.586,18.8a3.154,3.154,0,0,0-5.464,0L45.39,23.535c-.024,0-.046,0-.071,0l-4.526-4.525a3.153,3.153,0,0,0-5.276,1.414l-1.5,5.577-5.674-1.521a3.154,3.154,0,0,0-3.863,3.864L26,34.023l-5.575,1.494a3.155,3.155,0,0,0-1.416,5.278l4.526,4.526c0,.023,0,.046,0,.07L18.8,48.122a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,59.05a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,69.977a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,80.9a3.154,3.154,0,0,0,0,5.464L23.535,89.1,18.8,91.832a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,102.76a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,113.687a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,124.615a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,135.542a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,146.469a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,157.4a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,168.324a3.154,3.154,0,0,0,0,5.464l4.732,2.732A21.854,21.854,0,0,0,45.39,198.375H176.52a21.854,21.854,0,0,0,21.855-21.855V89.1l-76.514-4.782a11.632,11.632,0,0,1,0-23.219" transform="translate(-1.681 -17.226)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_43" data-name="Path 43" d="M143,186.71h32.782V143H143Z" transform="translate(9.984 -5.561)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_44" data-name="Path 44" d="M196.71,159.855a5.438,5.438,0,0,0-.7.07c-.042-.164-.081-.329-.127-.493a5.457,5.457,0,1,0-5.4-9.372q-.181-.185-.366-.367a5.454,5.454,0,1,0-9.384-5.4c-.162-.046-.325-.084-.486-.126a5.467,5.467,0,1,0-10.788,0c-.162.042-.325.08-.486.126a5.457,5.457,0,1,0-9.384,5.4,21.843,21.843,0,1,0,36.421,21.02,5.452,5.452,0,1,0,.7-10.858" transform="translate(10.912 -6.025)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_45" data-name="Path 45" d="M153,124.855h32.782V103H153Z" transform="translate(10.912 -9.271)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_46" data-name="Path 46" d="M194.855,116.765a2.732,2.732,0,1,0,0-5.464,2.811,2.811,0,0,0-.349.035c-.022-.082-.04-.164-.063-.246a2.733,2.733,0,0,0-1.052-5.253,2.7,2.7,0,0,0-1.648.566q-.09-.093-.184-.184a2.7,2.7,0,0,0,.553-1.633,2.732,2.732,0,0,0-5.245-1.07,10.928,10.928,0,1,0,0,21.031,2.732,2.732,0,0,0,5.245-1.07,2.7,2.7,0,0,0-.553-1.633q.093-.09.184-.184a2.7,2.7,0,0,0,1.648.566,2.732,2.732,0,0,0,1.052-5.253c.023-.081.042-.164.063-.246a2.814,2.814,0,0,0,.349.035" transform="translate(12.767 -9.377)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_47" data-name="Path 47" d="M65.087,56.891a2.732,2.732,0,0,1-2.732-2.732,8.2,8.2,0,0,0-16.391,0,2.732,2.732,0,0,1-5.464,0,13.659,13.659,0,0,1,27.319,0,2.732,2.732,0,0,1-2.732,2.732" transform="translate(0.478 -15.068)" fill-rule="evenodd"/>
|
||||
<path id="Path_48" data-name="Path 48" d="M103,191.347h65.565a21.854,21.854,0,0,0,21.855-21.855V93H124.855A21.854,21.854,0,0,0,103,114.855Z" transform="translate(6.275 -10.199)" fill="#ffff50" fill-rule="evenodd"/>
|
||||
<path id="Path_49" data-name="Path 49" d="M173.216,129.787H118.535a1.093,1.093,0,1,1,0-2.185h54.681a1.093,1.093,0,0,1,0,2.185m0,21.855H118.535a1.093,1.093,0,1,1,0-2.186h54.681a1.093,1.093,0,0,1,0,2.186m0,21.855H118.535a1.093,1.093,0,1,1,0-2.185h54.681a1.093,1.093,0,0,1,0,2.185m0-54.434H118.535a1.093,1.093,0,1,1,0-2.185h54.681a1.093,1.093,0,0,1,0,2.185m0,21.652H118.535a1.093,1.093,0,1,1,0-2.186h54.681a1.093,1.093,0,0,1,0,2.186m0,21.855H118.535a1.093,1.093,0,1,1,0-2.186h54.681a1.093,1.093,0,0,1,0,2.186M189.585,61.611c-.013,0-.024-.007-.037-.005-3.377.115-4.974,3.492-6.384,6.472-1.471,3.114-2.608,5.139-4.473,5.078-2.064-.074-3.244-2.406-4.494-4.874-1.436-2.835-3.075-6.049-6.516-5.929-3.329.114-4.932,3.053-6.346,5.646-1.5,2.762-2.529,4.442-4.5,4.364-2.106-.076-3.225-1.972-4.52-4.167-1.444-2.443-3.112-5.191-6.487-5.1-3.272.113-4.879,2.606-6.3,4.808-1.5,2.328-2.552,3.746-4.551,3.662-2.156-.076-3.27-1.65-4.558-3.472-1.447-2.047-3.077-4.363-6.442-4.251-3.2.109-4.807,2.153-6.224,3.954-1.346,1.709-2.4,3.062-4.621,2.977a1.093,1.093,0,0,0-.079,2.186c3.3.11,4.967-1.967,6.417-3.81,1.286-1.635,2.4-3.045,4.582-3.12,2.1-.09,3.091,1.218,4.584,3.327,1.417,2,3.026,4.277,6.263,4.394,3.391.114,5.022-2.42,6.467-4.663,1.292-2,2.406-3.734,4.535-3.807,1.959-.073,3.026,1.475,4.529,4.022,1.417,2.4,3.023,5.121,6.324,5.241,3.415.118,5.064-2.863,6.5-5.5,1.245-2.282,2.419-4.437,4.5-4.509,1.959-.046,2.981,1.743,4.492,4.732,1.412,2.79,3.013,5.95,6.365,6.071l.185,0c3.348,0,4.937-3.36,6.343-6.331,1.245-2.634,2.423-5.114,4.444-5.216Z" transform="translate(7.109 -13.11)" fill-rule="evenodd"/>
|
||||
<path id="Path_50" data-name="Path 50" d="M83,186.71h43.71V143H83Z" transform="translate(4.42 -5.561)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<g id="Group_8" data-name="Group 8" transform="matrix(0.966, -0.259, 0.259, 0.966, 109.327, 91.085)">
|
||||
<rect id="Rectangle_3" data-name="Rectangle 3" width="92.361" height="36.462" rx="2" transform="translate(0 0)" fill="#d8d8d8"/>
|
||||
<g id="Group_2" data-name="Group 2" transform="translate(1.531 23.03)">
|
||||
<rect id="Rectangle_4" data-name="Rectangle 4" width="5.336" height="5.336" rx="1" transform="translate(16.797 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_5" data-name="Rectangle 5" width="5.336" height="5.336" rx="1" transform="translate(23.12 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_6" data-name="Rectangle 6" width="5.336" height="5.336" rx="1" transform="translate(29.444 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_7" data-name="Rectangle 7" width="5.336" height="5.336" rx="1" transform="translate(35.768 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_8" data-name="Rectangle 8" width="5.336" height="5.336" rx="1" transform="translate(42.091 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_9" data-name="Rectangle 9" width="5.336" height="5.336" rx="1" transform="translate(48.415 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_10" data-name="Rectangle 10" width="5.336" height="5.336" rx="1" transform="translate(54.739 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_11" data-name="Rectangle 11" width="5.336" height="5.336" rx="1" transform="translate(61.063 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_12" data-name="Rectangle 12" width="5.336" height="5.336" rx="1" transform="translate(67.386 0)" fill="#4a4a4a"/>
|
||||
<path id="Path_51" data-name="Path 51" d="M1.093,0H14.518a1.093,1.093,0,0,1,1.093,1.093V4.243a1.093,1.093,0,0,1-1.093,1.093H1.093A1.093,1.093,0,0,1,0,4.243V1.093A1.093,1.093,0,0,1,1.093,0ZM75,0H88.426a1.093,1.093,0,0,1,1.093,1.093V4.243a1.093,1.093,0,0,1-1.093,1.093H75a1.093,1.093,0,0,1-1.093-1.093V1.093A1.093,1.093,0,0,1,75,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<g id="Group_3" data-name="Group 3" transform="translate(1.531 10.261)">
|
||||
<path id="Path_52" data-name="Path 52" d="M1.093,0H6.218A1.093,1.093,0,0,1,7.31,1.093V4.242A1.093,1.093,0,0,1,6.218,5.335H1.093A1.093,1.093,0,0,1,0,4.242V1.093A1.093,1.093,0,0,1,1.093,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<rect id="Rectangle_13" data-name="Rectangle 13" width="5.336" height="5.336" rx="1" transform="translate(8.299 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_14" data-name="Rectangle 14" width="5.336" height="5.336" rx="1" transform="translate(14.623 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_15" data-name="Rectangle 15" width="5.336" height="5.336" rx="1" transform="translate(20.947 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_16" data-name="Rectangle 16" width="5.336" height="5.336" rx="1" transform="translate(27.271 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_17" data-name="Rectangle 17" width="5.336" height="5.336" rx="1" transform="translate(33.594 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_18" data-name="Rectangle 18" width="5.336" height="5.336" rx="1" transform="translate(39.918 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_19" data-name="Rectangle 19" width="5.336" height="5.336" rx="1" transform="translate(46.242 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_20" data-name="Rectangle 20" width="5.336" height="5.336" rx="1" transform="translate(52.565 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_21" data-name="Rectangle 21" width="5.336" height="5.336" rx="1" transform="translate(58.888 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_22" data-name="Rectangle 22" width="5.336" height="5.336" rx="1" transform="translate(65.212 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_23" data-name="Rectangle 23" width="5.336" height="5.336" rx="1" transform="translate(71.536 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_24" data-name="Rectangle 24" width="5.336" height="5.336" rx="1" transform="translate(77.859 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_25" data-name="Rectangle 25" width="5.336" height="5.336" rx="1" transform="translate(84.183 0)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<g id="Group_4" data-name="Group 4" transform="translate(91.05 9.546) rotate(180)">
|
||||
<path id="Path_53" data-name="Path 53" d="M1.093,0H6.219A1.093,1.093,0,0,1,7.312,1.093v3.15A1.093,1.093,0,0,1,6.219,5.336H1.093A1.093,1.093,0,0,1,0,4.243V1.093A1.093,1.093,0,0,1,1.093,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<rect id="Rectangle_26" data-name="Rectangle 26" width="5.336" height="5.336" rx="1" transform="translate(8.299 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_27" data-name="Rectangle 27" width="5.336" height="5.336" rx="1" transform="translate(14.623 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_28" data-name="Rectangle 28" width="5.336" height="5.336" rx="1" transform="translate(20.947 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_29" data-name="Rectangle 29" width="5.336" height="5.336" rx="1" transform="translate(27.271 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_30" data-name="Rectangle 30" width="5.336" height="5.336" rx="1" transform="translate(33.594 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_31" data-name="Rectangle 31" width="5.336" height="5.336" rx="1" transform="translate(39.918 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_32" data-name="Rectangle 32" width="5.336" height="5.336" rx="1" transform="translate(46.242 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_33" data-name="Rectangle 33" width="5.336" height="5.336" rx="1" transform="translate(52.565 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_34" data-name="Rectangle 34" width="5.336" height="5.336" rx="1" transform="translate(58.889 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_35" data-name="Rectangle 35" width="5.336" height="5.336" rx="1" transform="translate(65.213 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_36" data-name="Rectangle 36" width="5.336" height="5.336" rx="1" transform="translate(71.537 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_37" data-name="Rectangle 37" width="5.336" height="5.336" rx="1" transform="translate(77.86 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_38" data-name="Rectangle 38" width="5.336" height="5.336" rx="1" transform="translate(84.183 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_39" data-name="Rectangle 39" width="5.336" height="5.336" rx="1" transform="translate(8.299 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_40" data-name="Rectangle 40" width="5.336" height="5.336" rx="1" transform="translate(14.623 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_41" data-name="Rectangle 41" width="5.336" height="5.336" rx="1" transform="translate(20.947 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_42" data-name="Rectangle 42" width="5.336" height="5.336" rx="1" transform="translate(27.271 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_43" data-name="Rectangle 43" width="5.336" height="5.336" rx="1" transform="translate(33.594 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_44" data-name="Rectangle 44" width="5.336" height="5.336" rx="1" transform="translate(39.918 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_45" data-name="Rectangle 45" width="5.336" height="5.336" rx="1" transform="translate(46.242 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_46" data-name="Rectangle 46" width="5.336" height="5.336" rx="1" transform="translate(52.565 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_47" data-name="Rectangle 47" width="5.336" height="5.336" rx="1" transform="translate(58.889 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_48" data-name="Rectangle 48" width="5.336" height="5.336" rx="1" transform="translate(65.213 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_49" data-name="Rectangle 49" width="5.336" height="5.336" rx="1" transform="translate(71.537 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_50" data-name="Rectangle 50" width="5.336" height="5.336" rx="1" transform="translate(77.86 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_51" data-name="Rectangle 51" width="5.336" height="5.336" rx="1" transform="translate(84.183 0)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<g id="Group_6" data-name="Group 6" transform="translate(1.531 16.584)">
|
||||
<path id="Path_54" data-name="Path 54" d="M1.093,0h7.3A1.093,1.093,0,0,1,9.485,1.093v3.15A1.093,1.093,0,0,1,8.392,5.336h-7.3A1.093,1.093,0,0,1,0,4.243V1.094A1.093,1.093,0,0,1,1.093,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<g id="Group_5" data-name="Group 5" transform="translate(10.671 0)">
|
||||
<rect id="Rectangle_52" data-name="Rectangle 52" width="5.336" height="5.336" rx="1" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_53" data-name="Rectangle 53" width="5.336" height="5.336" rx="1" transform="translate(6.324 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_54" data-name="Rectangle 54" width="5.336" height="5.336" rx="1" transform="translate(12.647 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_55" data-name="Rectangle 55" width="5.336" height="5.336" rx="1" transform="translate(18.971 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_56" data-name="Rectangle 56" width="5.336" height="5.336" rx="1" transform="translate(25.295 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_57" data-name="Rectangle 57" width="5.336" height="5.336" rx="1" transform="translate(31.619 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_58" data-name="Rectangle 58" width="5.336" height="5.336" rx="1" transform="translate(37.942 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_59" data-name="Rectangle 59" width="5.336" height="5.336" rx="1" transform="translate(44.265 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_60" data-name="Rectangle 60" width="5.336" height="5.336" rx="1" transform="translate(50.589 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_61" data-name="Rectangle 61" width="5.336" height="5.336" rx="1" transform="translate(56.912 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_62" data-name="Rectangle 62" width="5.336" height="5.336" rx="1" transform="translate(63.236 0)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<path id="Path_55" data-name="Path 55" d="M1.094,0H8A1.093,1.093,0,0,1,9.091,1.093v3.15A1.093,1.093,0,0,1,8,5.336H1.093A1.093,1.093,0,0,1,0,4.243V1.094A1.093,1.093,0,0,1,1.093,0Z" transform="translate(80.428 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<g id="Group_7" data-name="Group 7" transform="translate(1.531 29.627)">
|
||||
<rect id="Rectangle_63" data-name="Rectangle 63" width="5.336" height="5.336" rx="1" transform="translate(0 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_64" data-name="Rectangle 64" width="5.336" height="5.336" rx="1" transform="translate(6.324 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_65" data-name="Rectangle 65" width="5.336" height="5.336" rx="1" transform="translate(12.647 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_66" data-name="Rectangle 66" width="5.336" height="5.336" rx="1" transform="translate(18.971 0)" fill="#4a4a4a"/>
|
||||
<path id="Path_56" data-name="Path 56" d="M1.093,0H31.515a1.093,1.093,0,0,1,1.093,1.093V4.244a1.093,1.093,0,0,1-1.093,1.093H1.093A1.093,1.093,0,0,1,0,4.244V1.093A1.093,1.093,0,0,1,1.093,0ZM34.687,0h3.942a1.093,1.093,0,0,1,1.093,1.093V4.244a1.093,1.093,0,0,1-1.093,1.093H34.687a1.093,1.093,0,0,1-1.093-1.093V1.093A1.093,1.093,0,0,1,34.687,0Z" transform="translate(25.294 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<rect id="Rectangle_67" data-name="Rectangle 67" width="5.336" height="5.336" rx="1" transform="translate(66.003 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_68" data-name="Rectangle 68" width="5.336" height="5.336" rx="1" transform="translate(72.327 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_69" data-name="Rectangle 69" width="5.336" height="5.336" rx="1" transform="translate(84.183 0)" fill="#4a4a4a"/>
|
||||
<path id="Path_57" data-name="Path 57" d="M5.336,0V1.18A1.093,1.093,0,0,1,4.243,2.273H1.093A1.093,1.093,0,0,1,0,1.18V0Z" transform="translate(83.59 2.273) rotate(180)" fill="#4a4a4a"/>
|
||||
<path id="Path_58" data-name="Path 58" d="M5.336,0V1.18A1.093,1.093,0,0,1,4.243,2.273H1.093A1.093,1.093,0,0,1,0,1.18V0Z" transform="translate(78.255 3.063)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<rect id="Rectangle_70" data-name="Rectangle 70" width="88.927" height="2.371" rx="1.085" transform="translate(1.925 1.17)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_71" data-name="Rectangle 71" width="4.986" height="1.581" rx="0.723" transform="translate(4.1 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_72" data-name="Rectangle 72" width="4.986" height="1.581" rx="0.723" transform="translate(10.923 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_73" data-name="Rectangle 73" width="4.986" height="1.581" rx="0.723" transform="translate(16.173 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_74" data-name="Rectangle 74" width="4.986" height="1.581" rx="0.723" transform="translate(21.421 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_75" data-name="Rectangle 75" width="4.986" height="1.581" rx="0.723" transform="translate(26.671 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_76" data-name="Rectangle 76" width="4.986" height="1.581" rx="0.723" transform="translate(33.232 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_77" data-name="Rectangle 77" width="4.986" height="1.581" rx="0.723" transform="translate(38.48 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_78" data-name="Rectangle 78" width="4.986" height="1.581" rx="0.723" transform="translate(43.73 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_79" data-name="Rectangle 79" width="4.986" height="1.581" rx="0.723" transform="translate(48.978 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_80" data-name="Rectangle 80" width="4.986" height="1.581" rx="0.723" transform="translate(55.54 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_81" data-name="Rectangle 81" width="4.986" height="1.581" rx="0.723" transform="translate(60.788 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_82" data-name="Rectangle 82" width="4.986" height="1.581" rx="0.723" transform="translate(66.038 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_83" data-name="Rectangle 83" width="4.986" height="1.581" rx="0.723" transform="translate(72.599 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_84" data-name="Rectangle 84" width="4.986" height="1.581" rx="0.723" transform="translate(77.847 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_85" data-name="Rectangle 85" width="4.986" height="1.581" rx="0.723" transform="translate(83.097 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
</g>
|
||||
<path id="Path_59" data-name="Path 59" d="M146.71,159.855a5.439,5.439,0,0,0-.7.07c-.042-.164-.081-.329-.127-.493a5.457,5.457,0,1,0-5.4-9.372q-.181-.185-.366-.367a5.454,5.454,0,1,0-9.384-5.4c-.162-.046-.325-.084-.486-.126a5.467,5.467,0,1,0-10.788,0c-.162.042-.325.08-.486.126a5.457,5.457,0,1,0-9.384,5.4,21.843,21.843,0,1,0,36.421,21.02,5.452,5.452,0,1,0,.7-10.858" transform="translate(6.275 -6.025)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_60" data-name="Path 60" d="M83,124.855h43.71V103H83Z" transform="translate(4.42 -9.271)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_61" data-name="Path 61" d="M134.855,116.765a2.732,2.732,0,1,0,0-5.464,2.811,2.811,0,0,0-.349.035c-.022-.082-.04-.164-.063-.246a2.733,2.733,0,0,0-1.052-5.253,2.7,2.7,0,0,0-1.648.566q-.09-.093-.184-.184a2.7,2.7,0,0,0,.553-1.633,2.732,2.732,0,0,0-5.245-1.07,10.928,10.928,0,1,0,0,21.031,2.732,2.732,0,0,0,5.245-1.07,2.7,2.7,0,0,0-.553-1.633q.093-.09.184-.184a2.7,2.7,0,0,0,1.648.566,2.732,2.732,0,0,0,1.052-5.253c.023-.081.042-.164.063-.246a2.811,2.811,0,0,0,.349.035" transform="translate(7.202 -9.377)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_62" data-name="Path 62" d="M143.232,42.33a2.967,2.967,0,0,1-.535-.055,2.754,2.754,0,0,1-.514-.153,2.838,2.838,0,0,1-.471-.251,4.139,4.139,0,0,1-.415-.339,3.2,3.2,0,0,1-.338-.415A2.7,2.7,0,0,1,140.5,39.6a2.968,2.968,0,0,1,.055-.535,3.152,3.152,0,0,1,.152-.514,2.874,2.874,0,0,1,.252-.47,2.633,2.633,0,0,1,.753-.754,2.837,2.837,0,0,1,.471-.251,2.753,2.753,0,0,1,.514-.153,2.527,2.527,0,0,1,1.071,0,2.654,2.654,0,0,1,.983.4,4.139,4.139,0,0,1,.415.339,4.019,4.019,0,0,1,.339.415,2.786,2.786,0,0,1,.251.47,2.864,2.864,0,0,1,.208,1.049,2.77,2.77,0,0,1-.8,1.934,4.139,4.139,0,0,1-.415.339,2.722,2.722,0,0,1-1.519.459m21.855-1.366a2.789,2.789,0,0,1-1.935-.8,4.162,4.162,0,0,1-.338-.415,2.7,2.7,0,0,1-.459-1.519,2.789,2.789,0,0,1,.8-1.934,4.139,4.139,0,0,1,.415-.339,2.838,2.838,0,0,1,.471-.251,2.752,2.752,0,0,1,.514-.153,2.527,2.527,0,0,1,1.071,0,2.654,2.654,0,0,1,.983.4,4.139,4.139,0,0,1,.415.339,2.79,2.79,0,0,1,.8,1.934,3.069,3.069,0,0,1-.055.535,2.779,2.779,0,0,1-.153.514,3.885,3.885,0,0,1-.251.47,4.02,4.02,0,0,1-.339.415,4.138,4.138,0,0,1-.415.339,2.722,2.722,0,0,1-1.519.459" transform="translate(9.753 -15.532)" fill-rule="evenodd"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,170 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1041.277" height="554.141" viewBox="0 0 1041.277 554.141">
|
||||
<title>Powered by React</title>
|
||||
<g id="Group_24" data-name="Group 24" transform="translate(-440 -263)">
|
||||
<g id="Group_23" data-name="Group 23" transform="translate(439.989 262.965)">
|
||||
<path id="Path_299" data-name="Path 299" d="M1040.82,611.12q-1.74,3.75-3.47,7.4-2.7,5.67-5.33,11.12c-.78,1.61-1.56,3.19-2.32,4.77-8.6,17.57-16.63,33.11-23.45,45.89A73.21,73.21,0,0,1,942.44,719l-151.65,1.65h-1.6l-13,.14-11.12.12-34.1.37h-1.38l-17.36.19h-.53l-107,1.16-95.51,1-11.11.12-69,.75H429l-44.75.48h-.48l-141.5,1.53-42.33.46a87.991,87.991,0,0,1-10.79-.54h0c-1.22-.14-2.44-.3-3.65-.49a87.38,87.38,0,0,1-51.29-27.54C116,678.37,102.75,655,93.85,629.64q-1.93-5.49-3.6-11.12C59.44,514.37,97,380,164.6,290.08q4.25-5.64,8.64-11l.07-.08c20.79-25.52,44.1-46.84,68.93-62,44-26.91,92.75-34.49,140.7-11.9,40.57,19.12,78.45,28.11,115.17,30.55,3.71.24,7.42.42,11.11.53,84.23,2.65,163.17-27.7,255.87-47.29,3.69-.78,7.39-1.55,11.12-2.28,66.13-13.16,139.49-20.1,226.73-5.51a189.089,189.089,0,0,1,26.76,6.4q5.77,1.86,11.12,4c41.64,16.94,64.35,48.24,74,87.46q1.37,5.46,2.37,11.11C1134.3,384.41,1084.19,518.23,1040.82,611.12Z" transform="translate(-79.34 -172.91)" fill="#f2f2f2"/>
|
||||
<path id="Path_300" data-name="Path 300" d="M576.36,618.52a95.21,95.21,0,0,1-1.87,11.12h93.7V618.52Zm-78.25,62.81,11.11-.09V653.77c-3.81-.17-7.52-.34-11.11-.52ZM265.19,618.52v11.12h198.5V618.52ZM1114.87,279h-74V191.51q-5.35-2.17-11.12-4V279H776.21V186.58c-3.73.73-7.43,1.5-11.12,2.28V279H509.22V236.15c-3.69-.11-7.4-.29-11.11-.53V279H242.24V217c-24.83,15.16-48.14,36.48-68.93,62h-.07v.08q-4.4,5.4-8.64,11h8.64V618.52h-83q1.66,5.63,3.6,11.12h79.39v93.62a87,87,0,0,0,12.2,2.79c1.21.19,2.43.35,3.65.49h0a87.991,87.991,0,0,0,10.79.54l42.33-.46v-97H498.11v94.21l11.11-.12V629.64H765.09V721l11.12-.12V629.64H1029.7v4.77c.76-1.58,1.54-3.16,2.32-4.77q2.63-5.45,5.33-11.12,1.73-3.64,3.47-7.4v-321h76.42Q1116.23,284.43,1114.87,279ZM242.24,618.52V290.08H498.11V618.52Zm267,0V290.08H765.09V618.52Zm520.48,0H776.21V290.08H1029.7Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_301" data-name="Path 301" d="M863.09,533.65v13l-151.92,1.4-1.62.03-57.74.53-1.38.02-17.55.15h-.52l-106.98.99L349.77,551.4h-.15l-44.65.42-.48.01-198.4,1.82v-15l46.65-28,93.6-.78,2-.01.66-.01,2-.03,44.94-.37,2.01-.01.64-.01,2-.01L315,509.3l.38-.01,35.55-.3h.29l277.4-2.34,6.79-.05h.68l5.18-.05,37.65-.31,2-.03,1.85-.02h.96l11.71-.09,2.32-.03,3.11-.02,9.75-.09,15.47-.13,2-.02,3.48-.02h.65l74.71-.64Z" fill="#65617d"/>
|
||||
<path id="Path_302" data-name="Path 302" d="M863.09,533.65v13l-151.92,1.4-1.62.03-57.74.53-1.38.02-17.55.15h-.52l-106.98.99L349.77,551.4h-.15l-44.65.42-.48.01-198.4,1.82v-15l46.65-28,93.6-.78,2-.01.66-.01,2-.03,44.94-.37,2.01-.01.64-.01,2-.01L315,509.3l.38-.01,35.55-.3h.29l277.4-2.34,6.79-.05h.68l5.18-.05,37.65-.31,2-.03,1.85-.02h.96l11.71-.09,2.32-.03,3.11-.02,9.75-.09,15.47-.13,2-.02,3.48-.02h.65l74.71-.64Z" opacity="0.2"/>
|
||||
<path id="Path_303" data-name="Path 303" d="M375.44,656.57v24.49a6.13,6.13,0,0,1-3.5,5.54,6,6,0,0,1-2.5.6l-34.9.74a6,6,0,0,1-2.7-.57,6.12,6.12,0,0,1-3.57-5.57V656.57Z" transform="translate(-79.34 -172.91)" fill="#3f3d56"/>
|
||||
<path id="Path_304" data-name="Path 304" d="M375.44,656.57v24.49a6.13,6.13,0,0,1-3.5,5.54,6,6,0,0,1-2.5.6l-34.9.74a6,6,0,0,1-2.7-.57,6.12,6.12,0,0,1-3.57-5.57V656.57Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_305" data-name="Path 305" d="M377.44,656.57v24.49a6.13,6.13,0,0,1-3.5,5.54,6,6,0,0,1-2.5.6l-34.9.74a6,6,0,0,1-2.7-.57,6.12,6.12,0,0,1-3.57-5.57V656.57Z" transform="translate(-79.34 -172.91)" fill="#3f3d56"/>
|
||||
<rect id="Rectangle_137" data-name="Rectangle 137" width="47.17" height="31.5" transform="translate(680.92 483.65)" fill="#3f3d56"/>
|
||||
<rect id="Rectangle_138" data-name="Rectangle 138" width="47.17" height="31.5" transform="translate(680.92 483.65)" opacity="0.1"/>
|
||||
<rect id="Rectangle_139" data-name="Rectangle 139" width="47.17" height="31.5" transform="translate(678.92 483.65)" fill="#3f3d56"/>
|
||||
<path id="Path_306" data-name="Path 306" d="M298.09,483.65v4.97l-47.17,1.26v-6.23Z" opacity="0.1"/>
|
||||
<path id="Path_307" data-name="Path 307" d="M460.69,485.27v168.2a4,4,0,0,1-3.85,3.95l-191.65,5.1h-.05a4,4,0,0,1-3.95-3.95V485.27a4,4,0,0,1,3.95-3.95h191.6a4,4,0,0,1,3.95,3.95Z" transform="translate(-79.34 -172.91)" fill="#65617d"/>
|
||||
<path id="Path_308" data-name="Path 308" d="M265.19,481.32v181.2h-.05a4,4,0,0,1-3.95-3.95V485.27a4,4,0,0,1,3.95-3.95Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_309" data-name="Path 309" d="M194.59,319.15h177.5V467.4l-177.5,4Z" fill="#39374d"/>
|
||||
<path id="Path_310" data-name="Path 310" d="M726.09,483.65v6.41l-47.17-1.26v-5.15Z" opacity="0.1"/>
|
||||
<path id="Path_311" data-name="Path 311" d="M867.69,485.27v173.3a4,4,0,0,1-4,3.95h0L672,657.42a4,4,0,0,1-3.85-3.95V485.27a4,4,0,0,1,3.95-3.95H863.7a4,4,0,0,1,3.99,3.95Z" transform="translate(-79.34 -172.91)" fill="#65617d"/>
|
||||
<path id="Path_312" data-name="Path 312" d="M867.69,485.27v173.3a4,4,0,0,1-4,3.95h0V481.32h0a4,4,0,0,1,4,3.95Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_313" data-name="Path 313" d="M775.59,319.15H598.09V467.4l177.5,4Z" fill="#39374d"/>
|
||||
<path id="Path_314" data-name="Path 314" d="M663.19,485.27v168.2a4,4,0,0,1-3.85,3.95l-191.65,5.1h0a4,4,0,0,1-4-3.95V485.27a4,4,0,0,1,3.95-3.95h191.6A4,4,0,0,1,663.19,485.27Z" transform="translate(-79.34 -172.91)" fill="#65617d"/>
|
||||
<path id="Path_315" data-name="Path 315" d="M397.09,319.15h177.5V467.4l-177.5,4Z" fill="#4267b2"/>
|
||||
<path id="Path_316" data-name="Path 316" d="M863.09,533.65v13l-151.92,1.4-1.62.03-57.74.53-1.38.02-17.55.15h-.52l-106.98.99L349.77,551.4h-.15l-44.65.42-.48.01-198.4,1.82v-15l202.51-1.33h.48l40.99-.28h.19l283.08-1.87h.29l.17-.01h.47l4.79-.03h1.46l74.49-.5,4.4-.02.98-.01Z" opacity="0.1"/>
|
||||
<circle id="Ellipse_111" data-name="Ellipse 111" cx="51.33" cy="51.33" r="51.33" transform="translate(435.93 246.82)" fill="#fbbebe"/>
|
||||
<path id="Path_317" data-name="Path 317" d="M617.94,550.07s-99.5,12-90,0c3.44-4.34,4.39-17.2,4.2-31.85-.06-4.45-.22-9.06-.45-13.65-1.1-22-3.75-43.5-3.75-43.5s87-41,77-8.5c-4,13.13-2.69,31.57.35,48.88.89,5.05,1.92,10,3,14.7a344.66,344.66,0,0,0,9.65,33.92Z" transform="translate(-79.34 -172.91)" fill="#fbbebe"/>
|
||||
<path id="Path_318" data-name="Path 318" d="M585.47,546c11.51-2.13,23.7-6,34.53-1.54,2.85,1.17,5.47,2.88,8.39,3.86s6.12,1.22,9.16,1.91c10.68,2.42,19.34,10.55,24.9,20s8.44,20.14,11.26,30.72l6.9,25.83c6,22.45,12,45.09,13.39,68.3a2437.506,2437.506,0,0,1-250.84,1.43c5.44-10.34,11-21.31,10.54-33s-7.19-23.22-4.76-34.74c1.55-7.34,6.57-13.39,9.64-20.22,8.75-19.52,1.94-45.79,17.32-60.65,6.92-6.68,17-9.21,26.63-8.89,12.28.41,24.85,4.24,37,6.11C555.09,547.48,569.79,548.88,585.47,546Z" transform="translate(-79.34 -172.91)" fill="#ff6584"/>
|
||||
<path id="Path_319" data-name="Path 319" d="M716.37,657.17l-.1,1.43v.1l-.17,2.3-1.33,18.51-1.61,22.3-.46,6.28-1,13.44v.17l-107,1-175.59,1.9v.84h-.14v-1.12l.45-14.36.86-28.06.74-23.79.07-2.37a10.53,10.53,0,0,1,11.42-10.17c4.72.4,10.85.89,18.18,1.41l3,.22c42.33,2.94,120.56,6.74,199.5,2,1.66-.09,3.33-.19,5-.31,12.24-.77,24.47-1.76,36.58-3a10.53,10.53,0,0,1,11.6,11.23Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_320" data-name="Path 320" d="M429.08,725.44v-.84l175.62-1.91,107-1h.3v-.17l1-13.44.43-6,1.64-22.61,1.29-17.9v-.44a10.617,10.617,0,0,0-.11-2.47.3.3,0,0,0,0-.1,10.391,10.391,0,0,0-2-4.64,10.54,10.54,0,0,0-9.42-4c-12.11,1.24-24.34,2.23-36.58,3-1.67.12-3.34.22-5,.31-78.94,4.69-157.17.89-199.5-2l-3-.22c-7.33-.52-13.46-1-18.18-1.41a10.54,10.54,0,0,0-11.24,8.53,11,11,0,0,0-.18,1.64l-.68,22.16L429.54,710l-.44,14.36v1.12Z" transform="translate(-79.34 -172.91)" fill="#3f3d56"/>
|
||||
<path id="Path_321" data-name="Path 321" d="M716.67,664.18l-1.23,15.33-1.83,22.85-.46,5.72-1,12.81-.06.64v.17h0l-.15,1.48.11-1.48h-.29l-107,1-175.65,1.9v-.28l.49-14.36,1-28.06.64-18.65A6.36,6.36,0,0,1,434.3,658a6.25,6.25,0,0,1,3.78-.9c2.1.17,4.68.37,7.69.59,4.89.36,10.92.78,17.94,1.22,13,.82,29.31,1.7,48,2.42,52,2,122.2,2.67,188.88-3.17,3-.26,6.1-.55,9.13-.84a6.26,6.26,0,0,1,3.48.66,5.159,5.159,0,0,1,.86.54,6.14,6.14,0,0,1,2,2.46,3.564,3.564,0,0,1,.25.61A6.279,6.279,0,0,1,716.67,664.18Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_322" data-name="Path 322" d="M377.44,677.87v3.19a6.13,6.13,0,0,1-3.5,5.54l-40.1.77a6.12,6.12,0,0,1-3.57-5.57v-3Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_323" data-name="Path 323" d="M298.59,515.57l-52.25,1V507.9l52.25-1Z" fill="#3f3d56"/>
|
||||
<path id="Path_324" data-name="Path 324" d="M298.59,515.57l-52.25,1V507.9l52.25-1Z" opacity="0.1"/>
|
||||
<path id="Path_325" data-name="Path 325" d="M300.59,515.57l-52.25,1V507.9l52.25-1Z" fill="#3f3d56"/>
|
||||
<path id="Path_326" data-name="Path 326" d="M758.56,679.87v3.19a6.13,6.13,0,0,0,3.5,5.54l40.1.77a6.12,6.12,0,0,0,3.57-5.57v-3Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_327" data-name="Path 327" d="M678.72,517.57l52.25,1V509.9l-52.25-1Z" opacity="0.1"/>
|
||||
<path id="Path_328" data-name="Path 328" d="M676.72,517.57l52.25,1V509.9l-52.25-1Z" fill="#3f3d56"/>
|
||||
<path id="Path_329" data-name="Path 329" d="M534.13,486.79c.08,7-3.16,13.6-5.91,20.07a163.491,163.491,0,0,0-12.66,74.71c.73,11,2.58,22,.73,32.9s-8.43,21.77-19,24.9c17.53,10.45,41.26,9.35,57.76-2.66,8.79-6.4,15.34-15.33,21.75-24.11a97.86,97.86,0,0,1-13.31,44.75A103.43,103.43,0,0,0,637,616.53c4.31-5.81,8.06-12.19,9.72-19.23,3.09-13-1.22-26.51-4.51-39.5a266.055,266.055,0,0,1-6.17-33c-.43-3.56-.78-7.22.1-10.7,1-4.07,3.67-7.51,5.64-11.22,5.6-10.54,5.73-23.3,2.86-34.88s-8.49-22.26-14.06-32.81c-4.46-8.46-9.3-17.31-17.46-22.28-5.1-3.1-11-4.39-16.88-5.64l-25.37-5.43c-5.55-1.19-11.26-2.38-16.87-1.51-9.47,1.48-16.14,8.32-22,15.34-4.59,5.46-15.81,15.71-16.6,22.86-.72,6.59,5.1,17.63,6.09,24.58,1.3,9,2.22,6,7.3,11.52C532,478.05,534.07,482,534.13,486.79Z" transform="translate(-79.34 -172.91)" fill="#3f3d56"/>
|
||||
</g>
|
||||
<g id="docusaurus_keytar" transform="translate(670.271 615.768)">
|
||||
<path id="Path_40" data-name="Path 40" d="M99,52h43.635V69.662H99Z" transform="translate(-49.132 -33.936)" fill="#fff" fill-rule="evenodd"/>
|
||||
<path id="Path_41" data-name="Path 41" d="M13.389,158.195A10.377,10.377,0,0,1,4.4,153a10.377,10.377,0,0,0,8.988,15.584H23.779V158.195Z" transform="translate(-3 -82.47)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_42" data-name="Path 42" d="M66.967,38.083l36.373-2.273V30.615A10.389,10.389,0,0,0,92.95,20.226H46.2l-1.3-2.249a1.5,1.5,0,0,0-2.6,0L41,20.226l-1.3-2.249a1.5,1.5,0,0,0-2.6,0l-1.3,2.249-1.3-2.249a1.5,1.5,0,0,0-2.6,0l-1.3,2.249-.034,0-2.152-2.151a1.5,1.5,0,0,0-2.508.672L25.21,21.4l-2.7-.723a1.5,1.5,0,0,0-1.836,1.837l.722,2.7-2.65.71a1.5,1.5,0,0,0-.673,2.509l2.152,2.152c0,.011,0,.022,0,.033l-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6L20.226,41l-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3A10.389,10.389,0,0,0,30.615,103.34H92.95A10.389,10.389,0,0,0,103.34,92.95V51.393L66.967,49.12a5.53,5.53,0,0,1,0-11.038" transform="translate(-9.836 -17.226)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_43" data-name="Path 43" d="M143,163.779h15.584V143H143Z" transform="translate(-70.275 -77.665)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_44" data-name="Path 44" d="M173.779,148.389a2.582,2.582,0,0,0-.332.033c-.02-.078-.038-.156-.06-.234a2.594,2.594,0,1,0-2.567-4.455q-.086-.088-.174-.175a2.593,2.593,0,1,0-4.461-2.569c-.077-.022-.154-.04-.231-.06a2.6,2.6,0,1,0-5.128,0c-.077.02-.154.038-.231.06a2.594,2.594,0,1,0-4.461,2.569,10.384,10.384,0,1,0,17.314,9.992,2.592,2.592,0,1,0,.332-5.161" transform="translate(-75.08 -75.262)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_45" data-name="Path 45" d="M153,113.389h15.584V103H153Z" transform="translate(-75.08 -58.444)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_46" data-name="Path 46" d="M183.389,108.944a1.3,1.3,0,1,0,0-2.6,1.336,1.336,0,0,0-.166.017c-.01-.039-.019-.078-.03-.117a1.3,1.3,0,0,0-.5-2.5,1.285,1.285,0,0,0-.783.269q-.043-.044-.087-.087a1.285,1.285,0,0,0,.263-.776,1.3,1.3,0,0,0-2.493-.509,5.195,5.195,0,1,0,0,10,1.3,1.3,0,0,0,2.493-.509,1.285,1.285,0,0,0-.263-.776q.044-.043.087-.087a1.285,1.285,0,0,0,.783.269,1.3,1.3,0,0,0,.5-2.5c.011-.038.02-.078.03-.117a1.337,1.337,0,0,0,.166.017" transform="translate(-84.691 -57.894)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_47" data-name="Path 47" d="M52.188,48.292a1.3,1.3,0,0,1-1.3-1.3,3.9,3.9,0,0,0-7.792,0,1.3,1.3,0,1,1-2.6,0,6.493,6.493,0,0,1,12.987,0,1.3,1.3,0,0,1-1.3,1.3" transform="translate(-21.02 -28.41)" fill-rule="evenodd"/>
|
||||
<path id="Path_48" data-name="Path 48" d="M103,139.752h31.168a10.389,10.389,0,0,0,10.389-10.389V93H113.389A10.389,10.389,0,0,0,103,103.389Z" transform="translate(-51.054 -53.638)" fill="#ffff50" fill-rule="evenodd"/>
|
||||
<path id="Path_49" data-name="Path 49" d="M141.1,94.017H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0,10.389H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0,10.389H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0-25.877H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0,10.293H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0,10.389H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m7.782-47.993c-.006,0-.011,0-.018,0-1.605.055-2.365,1.66-3.035,3.077-.7,1.48-1.24,2.443-2.126,2.414-.981-.035-1.542-1.144-2.137-2.317-.683-1.347-1.462-2.876-3.1-2.819-1.582.054-2.344,1.451-3.017,2.684-.715,1.313-1.2,2.112-2.141,2.075-1-.036-1.533-.938-2.149-1.981-.686-1.162-1.479-2.467-3.084-2.423-1.555.053-2.319,1.239-2.994,2.286-.713,1.106-1.213,1.781-2.164,1.741-1.025-.036-1.554-.784-2.167-1.65-.688-.973-1.463-2.074-3.062-2.021a3.815,3.815,0,0,0-2.959,1.879c-.64.812-1.14,1.456-2.2,1.415a.52.52,0,0,0-.037,1.039,3.588,3.588,0,0,0,3.05-1.811c.611-.777,1.139-1.448,2.178-1.483,1-.043,1.47.579,2.179,1.582.674.953,1.438,2.033,2.977,2.089,1.612.054,2.387-1.151,3.074-2.217.614-.953,1.144-1.775,2.156-1.81.931-.035,1.438.7,2.153,1.912.674,1.141,1.437,2.434,3.006,2.491,1.623.056,2.407-1.361,3.09-2.616.592-1.085,1.15-2.109,2.14-2.143.931-.022,1.417.829,2.135,2.249.671,1.326,1.432,2.828,3.026,2.886l.088,0c1.592,0,2.347-1.6,3.015-3.01.592-1.252,1.152-2.431,2.113-2.479Z" transform="translate(-55.378 -38.552)" fill-rule="evenodd"/>
|
||||
<path id="Path_50" data-name="Path 50" d="M83,163.779h20.779V143H83Z" transform="translate(-41.443 -77.665)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<g id="Group_8" data-name="Group 8" transform="matrix(0.966, -0.259, 0.259, 0.966, 51.971, 43.3)">
|
||||
<rect id="Rectangle_3" data-name="Rectangle 3" width="43.906" height="17.333" rx="2" transform="translate(0 0)" fill="#d8d8d8"/>
|
||||
<g id="Group_2" data-name="Group 2" transform="translate(0.728 10.948)">
|
||||
<rect id="Rectangle_4" data-name="Rectangle 4" width="2.537" height="2.537" rx="1" transform="translate(7.985 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_5" data-name="Rectangle 5" width="2.537" height="2.537" rx="1" transform="translate(10.991 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_6" data-name="Rectangle 6" width="2.537" height="2.537" rx="1" transform="translate(13.997 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_7" data-name="Rectangle 7" width="2.537" height="2.537" rx="1" transform="translate(17.003 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_8" data-name="Rectangle 8" width="2.537" height="2.537" rx="1" transform="translate(20.009 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_9" data-name="Rectangle 9" width="2.537" height="2.537" rx="1" transform="translate(23.015 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_10" data-name="Rectangle 10" width="2.537" height="2.537" rx="1" transform="translate(26.021 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_11" data-name="Rectangle 11" width="2.537" height="2.537" rx="1" transform="translate(29.028 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_12" data-name="Rectangle 12" width="2.537" height="2.537" rx="1" transform="translate(32.034 0)" fill="#4a4a4a"/>
|
||||
<path id="Path_51" data-name="Path 51" d="M.519,0H6.9A.519.519,0,0,1,7.421.52v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.519A.519.519,0,0,1,.519,0ZM35.653,0h6.383a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H35.652a.519.519,0,0,1-.519-.519V.519A.519.519,0,0,1,35.652,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<g id="Group_3" data-name="Group 3" transform="translate(0.728 4.878)">
|
||||
<path id="Path_52" data-name="Path 52" d="M.519,0H2.956a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.519A.519.519,0,0,1,.519,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<rect id="Rectangle_13" data-name="Rectangle 13" width="2.537" height="2.537" rx="1" transform="translate(3.945 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_14" data-name="Rectangle 14" width="2.537" height="2.537" rx="1" transform="translate(6.951 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_15" data-name="Rectangle 15" width="2.537" height="2.537" rx="1" transform="translate(9.958 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_16" data-name="Rectangle 16" width="2.537" height="2.537" rx="1" transform="translate(12.964 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_17" data-name="Rectangle 17" width="2.537" height="2.537" rx="1" transform="translate(15.97 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_18" data-name="Rectangle 18" width="2.537" height="2.537" rx="1" transform="translate(18.976 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_19" data-name="Rectangle 19" width="2.537" height="2.537" rx="1" transform="translate(21.982 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_20" data-name="Rectangle 20" width="2.537" height="2.537" rx="1" transform="translate(24.988 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_21" data-name="Rectangle 21" width="2.537" height="2.537" rx="1" transform="translate(27.994 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_22" data-name="Rectangle 22" width="2.537" height="2.537" rx="1" transform="translate(31 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_23" data-name="Rectangle 23" width="2.537" height="2.537" rx="1" transform="translate(34.006 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_24" data-name="Rectangle 24" width="2.537" height="2.537" rx="1" transform="translate(37.012 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_25" data-name="Rectangle 25" width="2.537" height="2.537" rx="1" transform="translate(40.018 0)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<g id="Group_4" data-name="Group 4" transform="translate(43.283 4.538) rotate(180)">
|
||||
<path id="Path_53" data-name="Path 53" d="M.519,0H2.956a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.519A.519.519,0,0,1,.519,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<rect id="Rectangle_26" data-name="Rectangle 26" width="2.537" height="2.537" rx="1" transform="translate(3.945 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_27" data-name="Rectangle 27" width="2.537" height="2.537" rx="1" transform="translate(6.951 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_28" data-name="Rectangle 28" width="2.537" height="2.537" rx="1" transform="translate(9.958 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_29" data-name="Rectangle 29" width="2.537" height="2.537" rx="1" transform="translate(12.964 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_30" data-name="Rectangle 30" width="2.537" height="2.537" rx="1" transform="translate(15.97 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_31" data-name="Rectangle 31" width="2.537" height="2.537" rx="1" transform="translate(18.976 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_32" data-name="Rectangle 32" width="2.537" height="2.537" rx="1" transform="translate(21.982 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_33" data-name="Rectangle 33" width="2.537" height="2.537" rx="1" transform="translate(24.988 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_34" data-name="Rectangle 34" width="2.537" height="2.537" rx="1" transform="translate(27.994 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_35" data-name="Rectangle 35" width="2.537" height="2.537" rx="1" transform="translate(31.001 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_36" data-name="Rectangle 36" width="2.537" height="2.537" rx="1" transform="translate(34.007 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_37" data-name="Rectangle 37" width="2.537" height="2.537" rx="1" transform="translate(37.013 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_38" data-name="Rectangle 38" width="2.537" height="2.537" rx="1" transform="translate(40.018 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_39" data-name="Rectangle 39" width="2.537" height="2.537" rx="1" transform="translate(3.945 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_40" data-name="Rectangle 40" width="2.537" height="2.537" rx="1" transform="translate(6.951 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_41" data-name="Rectangle 41" width="2.537" height="2.537" rx="1" transform="translate(9.958 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_42" data-name="Rectangle 42" width="2.537" height="2.537" rx="1" transform="translate(12.964 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_43" data-name="Rectangle 43" width="2.537" height="2.537" rx="1" transform="translate(15.97 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_44" data-name="Rectangle 44" width="2.537" height="2.537" rx="1" transform="translate(18.976 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_45" data-name="Rectangle 45" width="2.537" height="2.537" rx="1" transform="translate(21.982 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_46" data-name="Rectangle 46" width="2.537" height="2.537" rx="1" transform="translate(24.988 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_47" data-name="Rectangle 47" width="2.537" height="2.537" rx="1" transform="translate(27.994 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_48" data-name="Rectangle 48" width="2.537" height="2.537" rx="1" transform="translate(31.001 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_49" data-name="Rectangle 49" width="2.537" height="2.537" rx="1" transform="translate(34.007 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_50" data-name="Rectangle 50" width="2.537" height="2.537" rx="1" transform="translate(37.013 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_51" data-name="Rectangle 51" width="2.537" height="2.537" rx="1" transform="translate(40.018 0)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<g id="Group_6" data-name="Group 6" transform="translate(0.728 7.883)">
|
||||
<path id="Path_54" data-name="Path 54" d="M.519,0h3.47a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.52A.519.519,0,0,1,.519,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<g id="Group_5" data-name="Group 5" transform="translate(5.073 0)">
|
||||
<rect id="Rectangle_52" data-name="Rectangle 52" width="2.537" height="2.537" rx="1" transform="translate(0 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_53" data-name="Rectangle 53" width="2.537" height="2.537" rx="1" transform="translate(3.006 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_54" data-name="Rectangle 54" width="2.537" height="2.537" rx="1" transform="translate(6.012 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_55" data-name="Rectangle 55" width="2.537" height="2.537" rx="1" transform="translate(9.018 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_56" data-name="Rectangle 56" width="2.537" height="2.537" rx="1" transform="translate(12.025 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_57" data-name="Rectangle 57" width="2.537" height="2.537" rx="1" transform="translate(15.031 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_58" data-name="Rectangle 58" width="2.537" height="2.537" rx="1" transform="translate(18.037 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_59" data-name="Rectangle 59" width="2.537" height="2.537" rx="1" transform="translate(21.042 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_60" data-name="Rectangle 60" width="2.537" height="2.537" rx="1" transform="translate(24.049 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_61" data-name="Rectangle 61" width="2.537" height="2.537" rx="1" transform="translate(27.055 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_62" data-name="Rectangle 62" width="2.537" height="2.537" rx="1" transform="translate(30.061 0)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<path id="Path_55" data-name="Path 55" d="M.52,0H3.8a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.52A.519.519,0,0,1,.519,0Z" transform="translate(38.234 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<g id="Group_7" data-name="Group 7" transform="translate(0.728 14.084)">
|
||||
<rect id="Rectangle_63" data-name="Rectangle 63" width="2.537" height="2.537" rx="1" transform="translate(0 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_64" data-name="Rectangle 64" width="2.537" height="2.537" rx="1" transform="translate(3.006 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_65" data-name="Rectangle 65" width="2.537" height="2.537" rx="1" transform="translate(6.012 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_66" data-name="Rectangle 66" width="2.537" height="2.537" rx="1" transform="translate(9.018 0)" fill="#4a4a4a"/>
|
||||
<path id="Path_56" data-name="Path 56" d="M.519,0H14.981A.519.519,0,0,1,15.5.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.018V.519A.519.519,0,0,1,.519,0Zm15.97,0h1.874a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H16.489a.519.519,0,0,1-.519-.519V.519A.519.519,0,0,1,16.489,0Z" transform="translate(12.024 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<rect id="Rectangle_67" data-name="Rectangle 67" width="2.537" height="2.537" rx="1" transform="translate(31.376 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_68" data-name="Rectangle 68" width="2.537" height="2.537" rx="1" transform="translate(34.382 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_69" data-name="Rectangle 69" width="2.537" height="2.537" rx="1" transform="translate(40.018 0)" fill="#4a4a4a"/>
|
||||
<path id="Path_57" data-name="Path 57" d="M2.537,0V.561a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,.561V0Z" transform="translate(39.736 1.08) rotate(180)" fill="#4a4a4a"/>
|
||||
<path id="Path_58" data-name="Path 58" d="M2.537,0V.561a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,.561V0Z" transform="translate(37.2 1.456)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<rect id="Rectangle_70" data-name="Rectangle 70" width="42.273" height="1.127" rx="0.564" transform="translate(0.915 0.556)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_71" data-name="Rectangle 71" width="2.37" height="0.752" rx="0.376" transform="translate(1.949 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_72" data-name="Rectangle 72" width="2.37" height="0.752" rx="0.376" transform="translate(5.193 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_73" data-name="Rectangle 73" width="2.37" height="0.752" rx="0.376" transform="translate(7.688 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_74" data-name="Rectangle 74" width="2.37" height="0.752" rx="0.376" transform="translate(10.183 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_75" data-name="Rectangle 75" width="2.37" height="0.752" rx="0.376" transform="translate(12.679 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_76" data-name="Rectangle 76" width="2.37" height="0.752" rx="0.376" transform="translate(15.797 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_77" data-name="Rectangle 77" width="2.37" height="0.752" rx="0.376" transform="translate(18.292 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_78" data-name="Rectangle 78" width="2.37" height="0.752" rx="0.376" transform="translate(20.788 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_79" data-name="Rectangle 79" width="2.37" height="0.752" rx="0.376" transform="translate(23.283 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_80" data-name="Rectangle 80" width="2.37" height="0.752" rx="0.376" transform="translate(26.402 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_81" data-name="Rectangle 81" width="2.37" height="0.752" rx="0.376" transform="translate(28.897 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_82" data-name="Rectangle 82" width="2.37" height="0.752" rx="0.376" transform="translate(31.393 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_83" data-name="Rectangle 83" width="2.37" height="0.752" rx="0.376" transform="translate(34.512 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_84" data-name="Rectangle 84" width="2.37" height="0.752" rx="0.376" transform="translate(37.007 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_85" data-name="Rectangle 85" width="2.37" height="0.752" rx="0.376" transform="translate(39.502 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
</g>
|
||||
<path id="Path_59" data-name="Path 59" d="M123.779,148.389a2.583,2.583,0,0,0-.332.033c-.02-.078-.038-.156-.06-.234a2.594,2.594,0,1,0-2.567-4.455q-.086-.088-.174-.175a2.593,2.593,0,1,0-4.461-2.569c-.077-.022-.154-.04-.231-.06a2.6,2.6,0,1,0-5.128,0c-.077.02-.154.038-.231.06a2.594,2.594,0,1,0-4.461,2.569,10.384,10.384,0,1,0,17.314,9.992,2.592,2.592,0,1,0,.332-5.161" transform="translate(-51.054 -75.262)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_60" data-name="Path 60" d="M83,113.389h20.779V103H83Z" transform="translate(-41.443 -58.444)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_61" data-name="Path 61" d="M123.389,108.944a1.3,1.3,0,1,0,0-2.6,1.338,1.338,0,0,0-.166.017c-.01-.039-.019-.078-.03-.117a1.3,1.3,0,0,0-.5-2.5,1.285,1.285,0,0,0-.783.269q-.043-.044-.087-.087a1.285,1.285,0,0,0,.263-.776,1.3,1.3,0,0,0-2.493-.509,5.195,5.195,0,1,0,0,10,1.3,1.3,0,0,0,2.493-.509,1.285,1.285,0,0,0-.263-.776q.044-.043.087-.087a1.285,1.285,0,0,0,.783.269,1.3,1.3,0,0,0,.5-2.5c.011-.038.02-.078.03-.117a1.335,1.335,0,0,0,.166.017" transform="translate(-55.859 -57.894)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_62" data-name="Path 62" d="M141.8,38.745a1.41,1.41,0,0,1-.255-.026,1.309,1.309,0,0,1-.244-.073,1.349,1.349,0,0,1-.224-.119,1.967,1.967,0,0,1-.2-.161,1.52,1.52,0,0,1-.161-.2,1.282,1.282,0,0,1-.218-.722,1.41,1.41,0,0,1,.026-.255,1.5,1.5,0,0,1,.072-.244,1.364,1.364,0,0,1,.12-.223,1.252,1.252,0,0,1,.358-.358,1.349,1.349,0,0,1,.224-.119,1.309,1.309,0,0,1,.244-.073,1.2,1.2,0,0,1,.509,0,1.262,1.262,0,0,1,.468.192,1.968,1.968,0,0,1,.2.161,1.908,1.908,0,0,1,.161.2,1.322,1.322,0,0,1,.12.223,1.361,1.361,0,0,1,.1.5,1.317,1.317,0,0,1-.379.919,1.968,1.968,0,0,1-.2.161,1.346,1.346,0,0,1-.223.119,1.332,1.332,0,0,1-.5.1m10.389-.649a1.326,1.326,0,0,1-.92-.379,1.979,1.979,0,0,1-.161-.2,1.282,1.282,0,0,1-.218-.722,1.326,1.326,0,0,1,.379-.919,1.967,1.967,0,0,1,.2-.161,1.351,1.351,0,0,1,.224-.119,1.308,1.308,0,0,1,.244-.073,1.2,1.2,0,0,1,.509,0,1.262,1.262,0,0,1,.468.192,1.967,1.967,0,0,1,.2.161,1.326,1.326,0,0,1,.379.919,1.461,1.461,0,0,1-.026.255,1.323,1.323,0,0,1-.073.244,1.847,1.847,0,0,1-.119.223,1.911,1.911,0,0,1-.161.2,1.967,1.967,0,0,1-.2.161,1.294,1.294,0,0,1-.722.218" transform="translate(-69.074 -26.006)" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<g id="React-icon" transform="translate(906.3 541.56)">
|
||||
<path id="Path_330" data-name="Path 330" d="M263.668,117.179c0-5.827-7.3-11.35-18.487-14.775,2.582-11.4,1.434-20.477-3.622-23.382a7.861,7.861,0,0,0-4.016-1v4a4.152,4.152,0,0,1,2.044.466c2.439,1.4,3.5,6.724,2.672,13.574-.2,1.685-.52,3.461-.914,5.272a86.9,86.9,0,0,0-11.386-1.954,87.469,87.469,0,0,0-7.459-8.965c5.845-5.433,11.332-8.41,15.062-8.41V78h0c-4.931,0-11.386,3.514-17.913,9.611-6.527-6.061-12.982-9.539-17.913-9.539v4c3.712,0,9.216,2.959,15.062,8.356a84.687,84.687,0,0,0-7.405,8.947,83.732,83.732,0,0,0-11.4,1.972c-.412-1.793-.717-3.532-.932-5.2-.843-6.85.2-12.175,2.618-13.592a3.991,3.991,0,0,1,2.062-.466v-4h0a8,8,0,0,0-4.052,1c-5.039,2.9-6.168,11.96-3.568,23.328-11.153,3.443-18.415,8.947-18.415,14.757,0,5.828,7.3,11.35,18.487,14.775-2.582,11.4-1.434,20.477,3.622,23.382a7.882,7.882,0,0,0,4.034,1c4.931,0,11.386-3.514,17.913-9.611,6.527,6.061,12.982,9.539,17.913,9.539a8,8,0,0,0,4.052-1c5.039-2.9,6.168-11.96,3.568-23.328C256.406,128.511,263.668,122.988,263.668,117.179Zm-23.346-11.96c-.663,2.313-1.488,4.7-2.421,7.083-.735-1.434-1.506-2.869-2.349-4.3-.825-1.434-1.7-2.833-2.582-4.2C235.517,104.179,237.974,104.645,240.323,105.219Zm-8.212,19.1c-1.4,2.421-2.833,4.716-4.321,6.85-2.672.233-5.379.359-8.1.359-2.708,0-5.415-.126-8.069-.341q-2.232-3.2-4.339-6.814-2.044-3.523-3.73-7.136c1.112-2.4,2.367-4.805,3.712-7.154,1.4-2.421,2.833-4.716,4.321-6.85,2.672-.233,5.379-.359,8.1-.359,2.708,0,5.415.126,8.069.341q2.232,3.2,4.339,6.814,2.044,3.523,3.73,7.136C234.692,119.564,233.455,121.966,232.11,124.315Zm5.792-2.331c.968,2.4,1.793,4.805,2.474,7.136-2.349.574-4.823,1.058-7.387,1.434.879-1.381,1.757-2.8,2.582-4.25C236.4,124.871,237.167,123.419,237.9,121.984ZM219.72,141.116a73.921,73.921,0,0,1-4.985-5.738c1.614.072,3.263.126,4.931.126,1.685,0,3.353-.036,4.985-.126A69.993,69.993,0,0,1,219.72,141.116ZM206.38,130.555c-2.546-.377-5-.843-7.352-1.417.663-2.313,1.488-4.7,2.421-7.083.735,1.434,1.506,2.869,2.349,4.3S205.5,129.192,206.38,130.555ZM219.63,93.241a73.924,73.924,0,0,1,4.985,5.738c-1.614-.072-3.263-.126-4.931-.126-1.686,0-3.353.036-4.985.126A69.993,69.993,0,0,1,219.63,93.241ZM206.362,103.8c-.879,1.381-1.757,2.8-2.582,4.25-.825,1.434-1.6,2.869-2.331,4.3-.968-2.4-1.793-4.805-2.474-7.136C201.323,104.663,203.8,104.179,206.362,103.8Zm-16.227,22.449c-6.348-2.708-10.454-6.258-10.454-9.073s4.106-6.383,10.454-9.073c1.542-.663,3.228-1.255,4.967-1.811a86.122,86.122,0,0,0,4.034,10.92,84.9,84.9,0,0,0-3.981,10.866C193.38,127.525,191.694,126.915,190.134,126.252Zm9.647,25.623c-2.439-1.4-3.5-6.724-2.672-13.574.2-1.686.52-3.461.914-5.272a86.9,86.9,0,0,0,11.386,1.954,87.465,87.465,0,0,0,7.459,8.965c-5.845,5.433-11.332,8.41-15.062,8.41A4.279,4.279,0,0,1,199.781,151.875Zm42.532-13.663c.843,6.85-.2,12.175-2.618,13.592a3.99,3.99,0,0,1-2.062.466c-3.712,0-9.216-2.959-15.062-8.356a84.689,84.689,0,0,0,7.405-8.947,83.731,83.731,0,0,0,11.4-1.972A50.194,50.194,0,0,1,242.313,138.212Zm6.9-11.96c-1.542.663-3.228,1.255-4.967,1.811a86.12,86.12,0,0,0-4.034-10.92,84.9,84.9,0,0,0,3.981-10.866c1.775.556,3.461,1.165,5.039,1.829,6.348,2.708,10.454,6.258,10.454,9.073C259.67,119.994,255.564,123.562,249.216,126.252Z" fill="#61dafb"/>
|
||||
<path id="Path_331" data-name="Path 331" d="M320.8,78.4Z" transform="translate(-119.082 -0.328)" fill="#61dafb"/>
|
||||
<circle id="Ellipse_112" data-name="Ellipse 112" cx="8.194" cy="8.194" r="8.194" transform="translate(211.472 108.984)" fill="#61dafb"/>
|
||||
<path id="Path_332" data-name="Path 332" d="M520.5,78.1Z" transform="translate(-282.975 -0.082)" fill="#61dafb"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,40 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1129" height="663" viewBox="0 0 1129 663">
|
||||
<title>Focus on What Matters</title>
|
||||
<circle cx="321" cy="321" r="321" fill="#f2f2f2" />
|
||||
<ellipse cx="559" cy="635.49998" rx="514" ry="27.50002" fill="#3f3d56" />
|
||||
<ellipse cx="558" cy="627" rx="460" ry="22" opacity="0.2" />
|
||||
<rect x="131" y="152.5" width="840" height="50" fill="#3f3d56" />
|
||||
<path d="M166.5,727.3299A21.67009,21.67009,0,0,0,188.1701,749H984.8299A21.67009,21.67009,0,0,0,1006.5,727.3299V296h-840Z" transform="translate(-35.5 -118.5)" fill="#3f3d56" />
|
||||
<path d="M984.8299,236H188.1701A21.67009,21.67009,0,0,0,166.5,257.6701V296h840V257.6701A21.67009,21.67009,0,0,0,984.8299,236Z" transform="translate(-35.5 -118.5)" fill="#3f3d56" />
|
||||
<path d="M984.8299,236H188.1701A21.67009,21.67009,0,0,0,166.5,257.6701V296h840V257.6701A21.67009,21.67009,0,0,0,984.8299,236Z" transform="translate(-35.5 -118.5)" opacity="0.2" />
|
||||
<circle cx="181" cy="147.5" r="13" fill="#3f3d56" />
|
||||
<circle cx="217" cy="147.5" r="13" fill="#3f3d56" />
|
||||
<circle cx="253" cy="147.5" r="13" fill="#3f3d56" />
|
||||
<rect x="168" y="213.5" width="337" height="386" rx="5.33505" fill="#606060" />
|
||||
<rect x="603" y="272.5" width="284" height="22" rx="5.47638" fill="#2e8555" />
|
||||
<rect x="537" y="352.5" width="416" height="15" rx="5.47638" fill="#2e8555" />
|
||||
<rect x="537" y="396.5" width="416" height="15" rx="5.47638" fill="#2e8555" />
|
||||
<rect x="537" y="440.5" width="416" height="15" rx="5.47638" fill="#2e8555" />
|
||||
<rect x="537" y="484.5" width="416" height="15" rx="5.47638" fill="#2e8555" />
|
||||
<rect x="865" y="552.5" width="88" height="26" rx="7.02756" fill="#3ecc5f" />
|
||||
<path d="M1088.60287,624.61594a30.11371,30.11371,0,0,0,3.98291-15.266c0-13.79652-8.54358-24.98081-19.08256-24.98081s-19.08256,11.18429-19.08256,24.98081a30.11411,30.11411,0,0,0,3.98291,15.266,31.248,31.248,0,0,0,0,30.53213,31.248,31.248,0,0,0,0,30.53208,31.248,31.248,0,0,0,0,30.53208,30.11408,30.11408,0,0,0-3.98291,15.266c0,13.79652,8.54353,24.98081,19.08256,24.98081s19.08256-11.18429,19.08256-24.98081a30.11368,30.11368,0,0,0-3.98291-15.266,31.248,31.248,0,0,0,0-30.53208,31.248,31.248,0,0,0,0-30.53208,31.248,31.248,0,0,0,0-30.53213Z" transform="translate(-35.5 -118.5)" fill="#3f3d56" />
|
||||
<ellipse cx="1038.00321" cy="460.31783" rx="19.08256" ry="24.9808" fill="#3f3d56" />
|
||||
<ellipse cx="1038.00321" cy="429.78574" rx="19.08256" ry="24.9808" fill="#3f3d56" />
|
||||
<path d="M1144.93871,339.34489a91.61081,91.61081,0,0,0,7.10658-10.46092l-50.141-8.23491,54.22885.4033a91.566,91.566,0,0,0,1.74556-72.42605l-72.75449,37.74139,67.09658-49.32086a91.41255,91.41255,0,1,0-150.971,102.29805,91.45842,91.45842,0,0,0-10.42451,16.66946l65.0866,33.81447-69.40046-23.292a91.46011,91.46011,0,0,0,14.73837,85.83669,91.40575,91.40575,0,1,0,143.68892,0,91.41808,91.41808,0,0,0,0-113.02862Z" transform="translate(-35.5 -118.5)" fill="#3ecc5f" fill-rule="evenodd" />
|
||||
<path d="M981.6885,395.8592a91.01343,91.01343,0,0,0,19.56129,56.51431,91.40575,91.40575,0,1,0,143.68892,0C1157.18982,436.82067,981.6885,385.60008,981.6885,395.8592Z" transform="translate(-35.5 -118.5)" opacity="0.1" />
|
||||
<path d="M365.62,461.43628H477.094v45.12043H365.62Z" transform="translate(-35.5 -118.5)" fill="#fff" fill-rule="evenodd" />
|
||||
<path d="M264.76252,608.74122a26.50931,26.50931,0,0,1-22.96231-13.27072,26.50976,26.50976,0,0,0,22.96231,39.81215H291.304V608.74122Z" transform="translate(-35.5 -118.5)" fill="#3ecc5f" fill-rule="evenodd" />
|
||||
<path d="M384.17242,468.57061l92.92155-5.80726V449.49263a26.54091,26.54091,0,0,0-26.54143-26.54143H331.1161l-3.31768-5.74622a3.83043,3.83043,0,0,0-6.63536,0l-3.31768,5.74622-3.31767-5.74622a3.83043,3.83043,0,0,0-6.63536,0l-3.31768,5.74622L301.257,417.205a3.83043,3.83043,0,0,0-6.63536,0L291.304,422.9512c-.02919,0-.05573.004-.08625.004l-5.49674-5.49541a3.8293,3.8293,0,0,0-6.4071,1.71723l-1.81676,6.77338L270.607,424.1031a3.82993,3.82993,0,0,0-4.6912,4.69253l1.84463,6.89148-6.77072,1.81411a3.8315,3.8315,0,0,0-1.71988,6.40975l5.49673,5.49673c0,.02787-.004.05574-.004.08493l-5.74622,3.31768a3.83043,3.83043,0,0,0,0,6.63536l5.74621,3.31768L259.0163,466.081a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768-5.74622,3.31767a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768-5.74622,3.31768a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768-5.74622,3.31767a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768-5.74622,3.31768a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768-5.74622,3.31768a3.83042,3.83042,0,0,0,0,6.63535l5.74622,3.31768-5.74622,3.31768a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768L259.0163,558.976a3.83042,3.83042,0,0,0,0,6.63535l5.74622,3.31768-5.74622,3.31768a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768-5.74622,3.31768a3.83042,3.83042,0,0,0,0,6.63535l5.74622,3.31768-5.74622,3.31768a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768A26.54091,26.54091,0,0,0,291.304,635.28265H450.55254A26.5409,26.5409,0,0,0,477.094,608.74122V502.5755l-92.92155-5.80727a14.12639,14.12639,0,0,1,0-28.19762" transform="translate(-35.5 -118.5)" fill="#3ecc5f" fill-rule="evenodd" />
|
||||
<path d="M424.01111,635.28265h39.81214V582.19979H424.01111Z" transform="translate(-35.5 -118.5)" fill="#3ecc5f" fill-rule="evenodd" />
|
||||
<path d="M490.36468,602.10586a6.60242,6.60242,0,0,0-.848.08493c-.05042-.19906-.09821-.39945-.15393-.59852A6.62668,6.62668,0,1,0,482.80568,590.21q-.2203-.22491-.44457-.44589a6.62391,6.62391,0,1,0-11.39689-6.56369c-.1964-.05575-.39414-.10218-.59056-.15262a6.63957,6.63957,0,1,0-13.10086,0c-.1964.05042-.39414.09687-.59056.15262a6.62767,6.62767,0,1,0-11.39688,6.56369,26.52754,26.52754,0,1,0,44.23127,25.52756,6.6211,6.6211,0,1,0,.848-13.18579" transform="translate(-35.5 -118.5)" fill="#44d860" fill-rule="evenodd" />
|
||||
<path d="M437.28182,555.65836H477.094V529.11693H437.28182Z" transform="translate(-35.5 -118.5)" fill="#3ecc5f" fill-rule="evenodd" />
|
||||
<path d="M490.36468,545.70532a3.31768,3.31768,0,0,0,0-6.63536,3.41133,3.41133,0,0,0-.42333.04247c-.02655-.09953-.04911-.19907-.077-.29859a3.319,3.319,0,0,0-1.278-6.37923,3.28174,3.28174,0,0,0-2.00122.68742q-.10947-.11346-.22294-.22295a3.282,3.282,0,0,0,.67149-1.98265,3.31768,3.31768,0,0,0-6.37-1.2992,13.27078,13.27078,0,1,0,0,25.54082,3.31768,3.31768,0,0,0,6.37-1.2992,3.282,3.282,0,0,0-.67149-1.98265q.11347-.10947.22294-.22294a3.28174,3.28174,0,0,0,2.00122.68742,3.31768,3.31768,0,0,0,1.278-6.37923c.02786-.0982.05042-.19907.077-.29859a3.41325,3.41325,0,0,0,.42333.04246" transform="translate(-35.5 -118.5)" fill="#44d860" fill-rule="evenodd" />
|
||||
<path d="M317.84538,466.081a3.31768,3.31768,0,0,1-3.31767-3.31768,9.953,9.953,0,1,0-19.90608,0,3.31768,3.31768,0,1,1-6.63535,0,16.58839,16.58839,0,1,1,33.17678,0,3.31768,3.31768,0,0,1-3.31768,3.31768" transform="translate(-35.5 -118.5)" fill-rule="evenodd" />
|
||||
<path d="M370.92825,635.28265h79.62429A26.5409,26.5409,0,0,0,477.094,608.74122v-92.895H397.46968a26.54091,26.54091,0,0,0-26.54143,26.54143Z" transform="translate(-35.5 -118.5)" fill="#ffff50" fill-rule="evenodd" />
|
||||
<path d="M457.21444,556.98543H390.80778a1.32707,1.32707,0,0,1,0-2.65414h66.40666a1.32707,1.32707,0,0,1,0,2.65414m0,26.54143H390.80778a1.32707,1.32707,0,1,1,0-2.65414h66.40666a1.32707,1.32707,0,0,1,0,2.65414m0,26.54143H390.80778a1.32707,1.32707,0,1,1,0-2.65414h66.40666a1.32707,1.32707,0,0,1,0,2.65414m0-66.10674H390.80778a1.32707,1.32707,0,0,1,0-2.65414h66.40666a1.32707,1.32707,0,0,1,0,2.65414m0,26.29459H390.80778a1.32707,1.32707,0,0,1,0-2.65414h66.40666a1.32707,1.32707,0,0,1,0,2.65414m0,26.54143H390.80778a1.32707,1.32707,0,0,1,0-2.65414h66.40666a1.32707,1.32707,0,0,1,0,2.65414M477.094,474.19076c-.01592,0-.0292-.008-.04512-.00663-4.10064.13934-6.04083,4.24132-7.75274,7.86024-1.78623,3.78215-3.16771,6.24122-5.43171,6.16691-2.50685-.09024-3.94007-2.92222-5.45825-5.91874-1.74377-3.44243-3.73438-7.34667-7.91333-7.20069-4.04227.138-5.98907,3.70784-7.70631,6.857-1.82738,3.35484-3.07084,5.39455-5.46887,5.30033-2.55727-.09289-3.91619-2.39536-5.48877-5.06013-1.75306-2.96733-3.77951-6.30359-7.8775-6.18946-3.97326.13669-5.92537,3.16507-7.64791,5.83912-1.82207,2.82666-3.09872,4.5492-5.52725,4.447-2.61832-.09289-3.9706-2.00388-5.53522-4.21611-1.757-2.4856-3.737-5.299-7.82308-5.16231-3.88567.13271-5.83779,2.61434-7.559,4.80135-1.635,2.07555-2.9116,3.71846-5.61218,3.615a1.32793,1.32793,0,1,0-.09555,2.65414c4.00377.134,6.03154-2.38873,7.79257-4.6275,1.562-1.9853,2.91027-3.69855,5.56441-3.78879,2.55594-.10882,3.75429,1.47968,5.56707,4.04093,1.7212,2.43385,3.67465,5.19416,7.60545,5.33616,4.11789.138,6.09921-2.93946,7.8536-5.66261,1.56861-2.43385,2.92221-4.53461,5.50734-4.62352,2.37944-.08892,3.67466,1.79154,5.50072,4.885,1.72121,2.91557,3.67069,6.21865,7.67977,6.36463,4.14709.14332,6.14965-3.47693,7.89475-6.68181,1.51155-2.77092,2.93814-5.38791,5.46621-5.4755,2.37944-.05573,3.62025,2.11668,5.45558,5.74622,1.71459,3.388,3.65875,7.22591,7.73019,7.37321l.22429.004c4.06614,0,5.99571-4.08074,7.70364-7.68905,1.51154-3.19825,2.94211-6.21069,5.3972-6.33411Z" transform="translate(-35.5 -118.5)" fill-rule="evenodd" />
|
||||
<path d="M344.38682,635.28265h53.08286V582.19979H344.38682Z" transform="translate(-35.5 -118.5)" fill="#3ecc5f" fill-rule="evenodd" />
|
||||
<path d="M424.01111,602.10586a6.60242,6.60242,0,0,0-.848.08493c-.05042-.19906-.09821-.39945-.15394-.59852A6.62667,6.62667,0,1,0,416.45211,590.21q-.2203-.22491-.44458-.44589a6.62391,6.62391,0,1,0-11.39689-6.56369c-.1964-.05575-.39413-.10218-.59054-.15262a6.63957,6.63957,0,1,0-13.10084,0c-.19641.05042-.39414.09687-.59055.15262a6.62767,6.62767,0,1,0-11.39689,6.56369,26.52755,26.52755,0,1,0,44.2313,25.52756,6.6211,6.6211,0,1,0,.848-13.18579" transform="translate(-35.5 -118.5)" fill="#44d860" fill-rule="evenodd" />
|
||||
<path d="M344.38682,555.65836h53.08286V529.11693H344.38682Z" transform="translate(-35.5 -118.5)" fill="#3ecc5f" fill-rule="evenodd" />
|
||||
<path d="M410.74039,545.70532a3.31768,3.31768,0,1,0,0-6.63536,3.41133,3.41133,0,0,0-.42333.04247c-.02655-.09953-.04911-.19907-.077-.29859a3.319,3.319,0,0,0-1.278-6.37923,3.28174,3.28174,0,0,0-2.00122.68742q-.10947-.11346-.22294-.22295a3.282,3.282,0,0,0,.67149-1.98265,3.31768,3.31768,0,0,0-6.37-1.2992,13.27078,13.27078,0,1,0,0,25.54082,3.31768,3.31768,0,0,0,6.37-1.2992,3.282,3.282,0,0,0-.67149-1.98265q.11347-.10947.22294-.22294a3.28174,3.28174,0,0,0,2.00122.68742,3.31768,3.31768,0,0,0,1.278-6.37923c.02786-.0982.05042-.19907.077-.29859a3.41325,3.41325,0,0,0,.42333.04246" transform="translate(-35.5 -118.5)" fill="#44d860" fill-rule="evenodd" />
|
||||
<path d="M424.01111,447.8338a3.60349,3.60349,0,0,1-.65028-.06636,3.34415,3.34415,0,0,1-.62372-.18579,3.44679,3.44679,0,0,1-.572-.30522,5.02708,5.02708,0,0,1-.50429-.4114,3.88726,3.88726,0,0,1-.41007-.50428,3.27532,3.27532,0,0,1-.55737-1.84463,3.60248,3.60248,0,0,1,.06636-.65027,3.82638,3.82638,0,0,1,.18447-.62373,3.48858,3.48858,0,0,1,.30656-.57064,3.197,3.197,0,0,1,.91436-.91568,3.44685,3.44685,0,0,1,.572-.30523,3.344,3.344,0,0,1,.62372-.18578,3.06907,3.06907,0,0,1,1.30053,0,3.22332,3.22332,0,0,1,1.19436.491,5.02835,5.02835,0,0,1,.50429.41139,4.8801,4.8801,0,0,1,.41139.50429,3.38246,3.38246,0,0,1,.30522.57064,3.47806,3.47806,0,0,1,.25215,1.274A3.36394,3.36394,0,0,1,426.36,446.865a5.02708,5.02708,0,0,1-.50429.4114,3.3057,3.3057,0,0,1-1.84463.55737m26.54143-1.65884a3.38754,3.38754,0,0,1-2.35024-.96877,5.04185,5.04185,0,0,1-.41007-.50428,3.27532,3.27532,0,0,1-.55737-1.84463,3.38659,3.38659,0,0,1,.96744-2.34892,5.02559,5.02559,0,0,1,.50429-.41139,3.44685,3.44685,0,0,1,.572-.30523,3.3432,3.3432,0,0,1,.62373-.18579,3.06952,3.06952,0,0,1,1.30052,0,3.22356,3.22356,0,0,1,1.19436.491,5.02559,5.02559,0,0,1,.50429.41139,3.38792,3.38792,0,0,1,.96876,2.34892,3.72635,3.72635,0,0,1-.06636.65026,3.37387,3.37387,0,0,1-.18579.62373,4.71469,4.71469,0,0,1-.30522.57064,4.8801,4.8801,0,0,1-.41139.50429,5.02559,5.02559,0,0,1-.50429.41139,3.30547,3.30547,0,0,1-1.84463.55737" transform="translate(-35.5 -118.5)" fill-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -2,8 +2,7 @@
|
||||
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0"
|
||||
"baseUrl": "."
|
||||
},
|
||||
"exclude": [".docusaurus", "build"]
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: Forms
|
||||
---
|
||||
|
||||
# Forms
|
||||
|
||||
The form modules provide an Effect-first model for editable, schema-validated
|
||||
state. They are not a visual component library. Instead, they give you
|
||||
Lenses/Subscribables for form values, validation issues, and commit state, so
|
||||
you can build the UI with whatever components your app already uses.
|
||||
|
||||
The base `Form` type represents a field. A field tracks both the raw encoded
|
||||
value used by inputs and the decoded value produced by an Effect `Schema`:
|
||||
|
||||
- `encodedValue`: a `Lens` containing the raw input value.
|
||||
- `value`: a `Subscribable` containing the decoded value as an `Option`.
|
||||
- `issues`: a `Subscribable` containing schema validation issues for that field.
|
||||
- `canCommit`, `isValidating`, and `isCommitting`: `Subscribable` flags for UI
|
||||
state.
|
||||
|
||||
There are two root form flavors:
|
||||
|
||||
- `SubmittableForm`: owns local encoded form state, validates it, and submits a
|
||||
decoded value through a mutation.
|
||||
- `SynchronizedForm`: wraps an existing target `Lens` and writes valid changes
|
||||
back to that target.
|
||||
|
||||
Most apps create one of those root forms, then focus it into fields with
|
||||
`Form.focusObjectOn`, `Form.focusArrayAt`, or the other focusing helpers.
|
||||
|
||||
The core form model is Effect code and can technically be used outside
|
||||
Effect-FC. Effect-FC adds the React-facing hooks, such as `Form.useInput`, that
|
||||
make those forms convenient to bind to components.
|
||||
|
||||
## Field Inputs
|
||||
|
||||
Use `Form.useInput` to connect a `Form` field to a controlled input. It returns
|
||||
a React-style `{ value, setValue }` pair and writes changes back to the field's
|
||||
`encodedValue` Lens.
|
||||
|
||||
```tsx
|
||||
import { Component, Form, Subscribable } from "effect-fc"
|
||||
|
||||
const TextInputView = Component.make("TextInput")(
|
||||
function* (props: {
|
||||
readonly form: Form.Form<readonly PropertyKey[], string, string>
|
||||
readonly label: string
|
||||
}) {
|
||||
const input = yield* Form.useInput(props.form, {
|
||||
debounce: "250 millis",
|
||||
})
|
||||
const [issues] = yield* Subscribable.useAll([props.form.issues])
|
||||
|
||||
return (
|
||||
<label>
|
||||
{props.label}
|
||||
<input
|
||||
value={input.value}
|
||||
onChange={(event) => input.setValue(event.currentTarget.value)}
|
||||
/>
|
||||
{issues.map((issue) => (
|
||||
<small key={issue.path.join(".")}>
|
||||
{issue.message}
|
||||
</small>
|
||||
))}
|
||||
</label>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Use `Form.useOptionalInput` for `Option` fields. It returns the same value/setter
|
||||
pair plus `enabled` and `setEnabled`, which is useful for optional inputs that
|
||||
can be toggled on and off.
|
||||
|
||||
## Submittable Forms
|
||||
|
||||
Use `SubmittableForm` when the user edits local encoded form state, validates it
|
||||
with a schema, and then submits a decoded value.
|
||||
|
||||
```tsx
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
|
||||
import { TextInputView } from "./TextInputView"
|
||||
|
||||
const RegisterSchema = Schema.Struct({
|
||||
email: Schema.String,
|
||||
password: Schema.String,
|
||||
})
|
||||
|
||||
class RegisterFormState extends Effect.Service<RegisterFormState>()(
|
||||
"RegisterFormState",
|
||||
{
|
||||
scoped: Effect.gen(function* () {
|
||||
const form = yield* SubmittableForm.service({
|
||||
schema: RegisterSchema,
|
||||
initialEncodedValue: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
f: ([value]) =>
|
||||
Effect.log(`Registering ${value.email}`),
|
||||
})
|
||||
|
||||
return {
|
||||
form,
|
||||
email: Form.focusObjectOn(form, "email"),
|
||||
password: Form.focusObjectOn(form, "password"),
|
||||
} as const
|
||||
}),
|
||||
},
|
||||
) {}
|
||||
|
||||
const RegisterFormView = Component.make("RegisterForm")(
|
||||
function* () {
|
||||
const state = yield* RegisterFormState
|
||||
const [canCommit, isCommitting] = yield* Subscribable.useAll([
|
||||
state.form.canCommit,
|
||||
state.form.isCommitting,
|
||||
])
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
const TextInput = yield* TextInputView.use
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void runPromise(state.form.submit)
|
||||
}}
|
||||
>
|
||||
<TextInput label="Email" form={state.email} />
|
||||
<TextInput label="Password" form={state.password} />
|
||||
<button disabled={!canCommit}>
|
||||
{isCommitting ? "Submitting..." : "Submit"}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
`SubmittableForm.service` starts validation in the form's scope. The form keeps
|
||||
its encoded input state, decoded value, validation issues, submit mutation, and
|
||||
commit flags available as Lenses/Subscribables.
|
||||
|
||||
## Synchronized Forms
|
||||
|
||||
Use `SynchronizedForm` when a form should edit an existing target `Lens`.
|
||||
Instead of submitting a final value, valid encoded changes are synchronized back
|
||||
to the target Lens.
|
||||
|
||||
This is useful for edit screens where the form is a validated view over existing
|
||||
state. Use `SubmittableForm` for "submit this draft" flows, and
|
||||
`SynchronizedForm` for "keep this target state synchronized when valid" flows.
|
||||
|
||||
## Focusing Fields
|
||||
|
||||
Forms can be focused just like Lenses:
|
||||
|
||||
```tsx
|
||||
const emailField = Form.focusObjectOn(form, "email")
|
||||
const firstItemField = Form.focusArrayAt(form, 0)
|
||||
```
|
||||
|
||||
Focused fields keep the parent form's validation and commit state, but narrow
|
||||
`encodedValue`, `value`, and `issues` to the field path. This lets each input
|
||||
component receive only the field it needs.
|
||||
@@ -1,415 +0,0 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Getting Started
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
|
||||
`effect-fc` lets React components be written as Effect programs. Inside a
|
||||
component body you can yield services, run Effects, subscribe to Effect-powered
|
||||
state, and still export a normal React function component at the edge of your
|
||||
app.
|
||||
|
||||
## Install
|
||||
|
||||
Install `effect-fc` alongside `effect` and React 19.2 or newer:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install effect-fc effect react
|
||||
```
|
||||
```bash npm2yarn
|
||||
npm install --save-dev @types/react
|
||||
```
|
||||
|
||||
`effect-fc` is not opinionated about the React platform. Use it with web,
|
||||
native, custom renderers, or any environment where React components can run.
|
||||
Then install the platform-specific React packages for your target.
|
||||
|
||||
For web apps, install React DOM:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install react-dom
|
||||
```
|
||||
```bash npm2yarn
|
||||
npm install --save-dev @types/react-dom
|
||||
```
|
||||
|
||||
## Create A Runtime
|
||||
|
||||
An Effect-FC app needs an Effect runtime. Build one from the services your UI
|
||||
needs, then share it with React through `ReactRuntime.Provider`.
|
||||
|
||||
For an empty app, `Layer.empty` is enough:
|
||||
|
||||
```tsx title="src/runtime.ts"
|
||||
import { Layer } from "effect"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
|
||||
export const runtime = ReactRuntime.make(Layer.empty)
|
||||
```
|
||||
|
||||
As your app grows, add services to the layer:
|
||||
|
||||
```tsx title="src/runtime.ts"
|
||||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { Layer } from "effect"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
|
||||
const AppLive = Layer.empty.pipe(
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
```
|
||||
|
||||
## Provide The Runtime
|
||||
|
||||
At the React root, wrap your app with `ReactRuntime.Provider`:
|
||||
|
||||
```tsx title="src/main.tsx"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
import { App } from "./App"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ReactRuntime.Provider runtime={runtime}>
|
||||
<App />
|
||||
</ReactRuntime.Provider>
|
||||
</StrictMode>,
|
||||
)
|
||||
```
|
||||
|
||||
`ReactRuntime.Provider` also works with routers. Keep it above your router
|
||||
provider so route components can be converted with the same runtime context.
|
||||
|
||||
## Write Your First Component
|
||||
|
||||
Use `Component.make` when you want automatic tracing spans, or
|
||||
`Component.makeUntraced` when you only want the component behavior. This creates
|
||||
an Effect-FC component, not a plain React component yet.
|
||||
|
||||
The Effect run during render must be synchronous. Effect-FC follows React's
|
||||
render model, so component bodies should produce JSX without waiting on async
|
||||
work. Use lifecycle hooks, callbacks, queries, or other Effect-FC helpers for
|
||||
async work that happens outside render.
|
||||
|
||||
```tsx title="src/HelloView.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
|
||||
export const HelloView = Component.make("HelloView")(function* (props: {
|
||||
readonly name: string
|
||||
}) {
|
||||
const message = yield* Effect.succeed(`Hello, ${props.name}`)
|
||||
|
||||
return <h1>{message}</h1>
|
||||
})
|
||||
```
|
||||
|
||||
At this point `HelloView` can yield Effects, services, and scoped lifecycle
|
||||
work, but React cannot render it directly.
|
||||
|
||||
## Apply A Runtime
|
||||
|
||||
Use `Component.withRuntime` at the boundary where an Effect-FC component needs
|
||||
to become a regular React function component.
|
||||
|
||||
```tsx title="src/Hello.tsx"
|
||||
import { Component } from "effect-fc"
|
||||
import { HelloView } from "./HelloView"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
export const Hello = HelloView.pipe(
|
||||
Component.withRuntime(runtime.context),
|
||||
)
|
||||
```
|
||||
|
||||
`Hello` is now a normal React component:
|
||||
|
||||
```tsx title="src/App.tsx"
|
||||
import { Hello } from "./Hello"
|
||||
|
||||
export function App() {
|
||||
return <Hello name="Effect" />
|
||||
}
|
||||
```
|
||||
|
||||
## Use Effect-FC Components Together
|
||||
|
||||
Inside an Effect-FC component, other Effect-FC components are available through
|
||||
their `.use` effect. Yield `.use` to get a React component for the current
|
||||
runtime context, then render it like JSX.
|
||||
|
||||
```tsx title="src/GreetingCardView.tsx"
|
||||
import { Component } from "effect-fc"
|
||||
import { HelloView } from "./HelloView"
|
||||
|
||||
export const GreetingCardView = Component.make("GreetingCard")(
|
||||
function* () {
|
||||
const Hello = yield* HelloView.use
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Hello name="Effect" />
|
||||
<p>This component is still running inside Effect-FC.</p>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Use `Component.withRuntime` only when you leave the Effect-FC tree and need a
|
||||
normal React component again:
|
||||
|
||||
```tsx title="src/GreetingCard.tsx"
|
||||
import { Component } from "effect-fc"
|
||||
import { GreetingCardView } from "./GreetingCardView"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
export const GreetingCard = GreetingCardView.pipe(
|
||||
Component.withRuntime(runtime.context),
|
||||
)
|
||||
```
|
||||
|
||||
## Component Lifecycle
|
||||
|
||||
Every Effect-FC component instance exposes an Effect `Scope`. Effects that need
|
||||
`Scope.Scope` can use that component scope to register finalizers, fork scoped
|
||||
work, or acquire scoped resources.
|
||||
|
||||
The component scope is created when React mounts the component and closes when
|
||||
React unmounts it. When the scope closes, Effect runs the finalizers registered
|
||||
inside that scope.
|
||||
|
||||
Finalizers are forked when the scope closes, so cleanup logic can run
|
||||
asynchronous Effects even though the component body itself must stay
|
||||
synchronous.
|
||||
|
||||
```tsx title="src/MountedMessageView.tsx"
|
||||
import { Console, Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
|
||||
export const MountedMessageView = Component.make("MountedMessage")(
|
||||
function* () {
|
||||
const message = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Console.log("MountedMessage mounted")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Console.log("MountedMessage unmounted"),
|
||||
)
|
||||
|
||||
return "This value was loaded on mount."
|
||||
}),
|
||||
)
|
||||
|
||||
return <p>{message}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Use `Component.useOnMount` when the component needs a value produced by an
|
||||
Effect during its first render. The value is then cached for the component
|
||||
instance. You can also use it to set up scoped component logic, subscriptions,
|
||||
or resources that should live for the lifetime of that component instance.
|
||||
|
||||
Use `Component.useOnChange` when render needs the value computed by
|
||||
scoped work that depends on changing inputs. When a dependency changes, React
|
||||
re-renders the component and the hook computes the next value during that
|
||||
render.
|
||||
|
||||
```tsx
|
||||
import { Console, Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
|
||||
const UserPanelView = Component.make("UserPanel")(
|
||||
function* (props: { readonly userId: string }) {
|
||||
const label = yield* Component.useOnChange(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Console.log(`Preparing view for ${props.userId}`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Console.log(`Cleaning up ${props.userId}`),
|
||||
)
|
||||
|
||||
return `Viewing user ${props.userId}`
|
||||
}),
|
||||
[props.userId],
|
||||
)
|
||||
|
||||
return <p>{label}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
In this example, each `userId` gets its own scope. When `userId` changes,
|
||||
Effect-FC closes the previous scope, runs its finalizers, and creates a new
|
||||
scope for the next load. Unlike `useOnMount`, `useOnChange` does not expose the
|
||||
component's root scope directly. It creates and provides its own scope for that
|
||||
dependency window. Some other Effect-FC hooks follow the same pattern when they
|
||||
need a lifecycle that is narrower than the whole component instance.
|
||||
|
||||
## Useful Effect-FC Hooks
|
||||
|
||||
Unlike plain React hooks, Effect-FC hooks return Effects. Use `yield*` to run
|
||||
them inside the component body.
|
||||
|
||||
The most common Effect-FC hooks are:
|
||||
|
||||
- `Component.useOnMount`: run an Effect once during the component's first render
|
||||
after mount, and return its value. The Effect must be synchronous.
|
||||
|
||||
```tsx
|
||||
const initialData = yield* Component.useOnMount(() => loadInitialData)
|
||||
```
|
||||
|
||||
- `Component.useOnChange`: when a dependency changes, React re-renders the
|
||||
component and the Effect is run again inside the component body. It returns
|
||||
the latest value, so the Effect must be synchronous too.
|
||||
|
||||
```tsx
|
||||
const label = yield* Component.useOnChange(() => formatUserLabel(id), [id])
|
||||
```
|
||||
|
||||
- `Component.useReactEffect`: Effect-powered `React.useEffect` for side effects
|
||||
that do not need to compute render output or trigger a re-render. The Effect
|
||||
must be synchronous and can register scoped finalizers.
|
||||
|
||||
```tsx
|
||||
yield* Component.useReactEffect(
|
||||
() => Effect.forkScoped(subscribeToUser(id)),
|
||||
[id],
|
||||
)
|
||||
```
|
||||
|
||||
- `Component.useReactLayoutEffect`: Effect-powered `React.useLayoutEffect` with scoped
|
||||
finalizers.
|
||||
|
||||
```tsx
|
||||
yield* Component.useReactLayoutEffect(() => measure(ref), [])
|
||||
```
|
||||
|
||||
- `Component.useRunSync` and `Component.useRunPromise`: run Effects from React
|
||||
event handlers.
|
||||
|
||||
```tsx
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
return (
|
||||
<button onClick={() => void runPromise(saveUser)}>
|
||||
Save
|
||||
</button>
|
||||
)
|
||||
```
|
||||
|
||||
- `Component.useCallbackSync` and `Component.useCallbackPromise`: create stable
|
||||
React callbacks.
|
||||
|
||||
```tsx
|
||||
const save = yield* Component.useCallbackPromise(
|
||||
(user: User) => saveUser(user),
|
||||
[],
|
||||
)
|
||||
|
||||
return <button onClick={() => void save(user)}>Save</button>
|
||||
```
|
||||
|
||||
## Use React Normally
|
||||
|
||||
An Effect-FC component is still a React function component. Anything you can do
|
||||
in a regular React component can also be done in an Effect-FC component,
|
||||
including React hooks, refs, event handlers, context, and JSX composition.
|
||||
|
||||
```tsx
|
||||
import { Component } from "effect-fc"
|
||||
import * as React from "react"
|
||||
|
||||
const CounterView = Component.make("Counter")(function* () {
|
||||
const [count, setCount] = React.useState(0)
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<button ref={buttonRef} onClick={() => setCount(count + 1)}>
|
||||
Count: {count}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Use regular React hooks for local UI concerns, and reach for Effect-FC helpers
|
||||
when the component needs Effect services, scopes, resources, or Effect-powered
|
||||
callbacks.
|
||||
|
||||
## Use Tags
|
||||
|
||||
Components can yield Effect tags directly in the component body. There is no
|
||||
need to memoize the service yourself; just yield the tag wherever the component
|
||||
needs it.
|
||||
|
||||
```tsx title="src/Greeting.tsx"
|
||||
const GreetingView = Component.make("Greeting")(function* (props: {
|
||||
readonly name: string
|
||||
}) {
|
||||
const greeting = yield* GreetingService
|
||||
|
||||
return <p>{greeting.greet(props.name)}</p>
|
||||
})
|
||||
```
|
||||
|
||||
Service values in the runtime context are reactive by default. If a provided
|
||||
service instance changes, Effect-FC unmounts and mounts again the components
|
||||
that depend on that context, so they read the new service and restart their
|
||||
scoped lifecycle with the new environment. For services that should not trigger
|
||||
that behavior, pass their tags with `Component.withOptions({ nonReactiveTags:
|
||||
[...] })`.
|
||||
|
||||
## Provide Services To A Subtree
|
||||
|
||||
Use `Component.useContextFromLayer` when an Effect-FC component should provide
|
||||
extra services to another Effect-FC component. It turns a `Layer` into a runtime
|
||||
context that can be provided to `.use`.
|
||||
|
||||
```tsx title="src/GreetingPageView.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
import { GreetingView } from "./Greeting"
|
||||
import { GreetingService } from "./services"
|
||||
|
||||
const GreetingLive = GreetingService.Default({
|
||||
greet: (name: string) => `Welcome, ${name}`,
|
||||
})
|
||||
|
||||
export const GreetingPageView = Component.make("GreetingPage")(function* () {
|
||||
const context = yield* Component.useContextFromLayer(GreetingLive)
|
||||
const Greeting = yield* Effect.provide(GreetingView.use, context)
|
||||
|
||||
return <Greeting name="Effect" />
|
||||
})
|
||||
```
|
||||
|
||||
The layer passed to `useContextFromLayer` should be stable. If a new layer value
|
||||
is created on every render, Effect-FC has to build a new context, which can
|
||||
cause unnecessary re-renders and scoped lifecycle restarts. Define static layers
|
||||
outside the component, or memoize layers that depend on React props or state.
|
||||
|
||||
Layers built with `useContextFromLayer` are scoped to the component that builds
|
||||
them. That means service construction can register scoped logic, acquire
|
||||
resources, fork scoped fibers, and add finalizers that are tied to that
|
||||
component's lifecycle.
|
||||
|
||||
## Where To Go Next
|
||||
|
||||
Once the runtime and component boundary are in place, the rest of the library
|
||||
builds on the same idea:
|
||||
|
||||
- `Subscribable.useAll` reads Effect subscribables and rerenders when they
|
||||
change.
|
||||
- `Lens` connects React state and Effect `SubscriptionRef` values.
|
||||
- `Query` and `Mutation` model async data and user-triggered operations.
|
||||
- `Form`, `SubmittableForm`, and `SynchronizedForm` help build Effect-backed
|
||||
forms.
|
||||
|
||||
The important pattern is small and repeatable: write Effect-FC components inside
|
||||
the runtime, then use `Component.withRuntime` at React boundaries.
|
||||
@@ -1,245 +0,0 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: State Management
|
||||
---
|
||||
|
||||
# State Management
|
||||
|
||||
`Lens` is the main type used for state management in `effect-fc`.
|
||||
|
||||
A Lens is an effectful handle to a piece of state. It can read the current value,
|
||||
subscribe to changes, and write updates back to the underlying source. A Lens
|
||||
can point at a whole state object or focus on one nested field inside it.
|
||||
|
||||
The usual pattern is to create state with Effect primitives such as
|
||||
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor
|
||||
such as `Lens.fromSubscriptionRef`, and bind Lens values into components with
|
||||
`Subscribable.useAll`.
|
||||
|
||||
`Subscribable` is the read-only side of this model. Every Lens is also a
|
||||
Subscribable.
|
||||
|
||||
`effect-fc` re-exports the `Lens` and `Subscribable` modules from
|
||||
[`effect-lens`](https://github.com/Thiladev/effect-lens) for convenience. The
|
||||
core data model and transformation APIs belong to `effect-lens`, so check the
|
||||
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens) for the full Lens/Subscribable API.
|
||||
|
||||
## Where To Store State
|
||||
|
||||
State can live pretty much anywhere as a `Lens` or `Subscribable`: in a
|
||||
service, in a layer, in a component scope, or alongside plain React state. Pick
|
||||
the owner based on who needs the state. Once you have a Lens/Subscribable
|
||||
handle, pass it around however you like, including through React props.
|
||||
|
||||
If state is shared by multiple components or belongs to application logic, store
|
||||
it in an Effect service:
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, Subscribable } from "effect-fc"
|
||||
|
||||
class CounterState extends Effect.Service<CounterState>()("CounterState", {
|
||||
effect: Effect.gen(function* () {
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
|
||||
return { count } as const
|
||||
}),
|
||||
}) {}
|
||||
|
||||
const CounterValueView = Component.make("CounterValue")(function* () {
|
||||
const state = yield* CounterState
|
||||
const [count] = yield* Subscribable.useAll([state.count])
|
||||
|
||||
return <p>Count: {count}</p>
|
||||
})
|
||||
```
|
||||
|
||||
If state belongs to a single Effect-FC component instance, or to a shallow
|
||||
hierarchy of subcomponents that receive it through props, create it with
|
||||
`Component.useOnMount`:
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, Subscribable } from "effect-fc"
|
||||
|
||||
const LocalCounterView = Component.make("LocalCounter")(function* () {
|
||||
const state = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
|
||||
return { count } as const
|
||||
}),
|
||||
)
|
||||
const [count] = yield* Subscribable.useAll([state.count])
|
||||
|
||||
return <p>Count: {count}</p>
|
||||
})
|
||||
```
|
||||
|
||||
For simple UI state that is not shared and does not need Effect integration,
|
||||
prefer regular React state. A local "show details" toggle is usually better as
|
||||
`React.useState(false)` than as a Lens.
|
||||
|
||||
## Subscribable.useAll
|
||||
|
||||
A `Subscribable<A>` is reactive state with a current value and a stream of
|
||||
changes. Use `Subscribable.useAll` whenever a component needs to bind
|
||||
subscribable values into render output.
|
||||
|
||||
`Lens` is a `Subscribable`, so this is also the default way to read Lens values
|
||||
from a component.
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, Subscribable } from "effect-fc"
|
||||
|
||||
class CounterState extends Effect.Service<CounterState>()("CounterState", {
|
||||
effect: Effect.gen(function* () {
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
const doubled = Subscribable.map(count, (n) => n * 2)
|
||||
|
||||
return { count, doubled } as const
|
||||
}),
|
||||
}) {}
|
||||
|
||||
const CounterReadOnlyView = Component.make("CounterReadOnly")(
|
||||
function* () {
|
||||
const state = yield* CounterState
|
||||
const [count, doubled] = yield* Subscribable.useAll([
|
||||
state.count,
|
||||
state.doubled,
|
||||
])
|
||||
|
||||
return <p>Count: {count}, doubled: {doubled}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
`Subscribable.useAll` reads the current values during render and uses scoped subscriptions to update React state when changes arrive.
|
||||
|
||||
When you need to modify state, write to the Lens with `Lens.set` or `Lens.update`:
|
||||
|
||||
```tsx
|
||||
const CounterControlsView = Component.make("CounterControls")(
|
||||
function* () {
|
||||
const state = yield* CounterState
|
||||
const [count] = yield* Subscribable.useAll([state.count])
|
||||
|
||||
const increment = yield* Component.useCallbackSync(
|
||||
() => Lens.update(state.count, (n) => n + 1),
|
||||
[],
|
||||
)
|
||||
const reset = yield* Component.useCallbackSync(
|
||||
() => Lens.set(state.count, 0),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p>Count: {count}</p>
|
||||
<button onClick={increment}>Increment</button>
|
||||
<button onClick={reset}>Reset</button>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## Lens.useState
|
||||
|
||||
`Lens.useState` is useful when React needs the familiar `[value, setValue]`
|
||||
tuple, backed by a Lens. Reach for it when the JSX API expects a synchronous
|
||||
setter, especially controlled inputs such as text fields, checkboxes, selects,
|
||||
or third-party components with `value` / `onChange` props.
|
||||
|
||||
If a component only needs to display the value, prefer `Subscribable.useAll`.
|
||||
`Lens.useState` is for places where reading and writing need to be wired
|
||||
together in React's local-state shape.
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens } from "effect-fc"
|
||||
|
||||
class FormState extends Effect.Service<FormState>()("FormState", {
|
||||
effect: Effect.gen(function* () {
|
||||
const name = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(""))
|
||||
|
||||
return { name } as const
|
||||
}),
|
||||
}) {}
|
||||
|
||||
const NameInputView = Component.make("NameInput")(function* () {
|
||||
const state = yield* FormState
|
||||
const [name, setName] = yield* Lens.useState(state.name)
|
||||
|
||||
return (
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
`Lens.useState` returns the current value and a React-compatible setter. Calling
|
||||
the setter writes through the Lens, so every other component subscribed to the
|
||||
same Lens sees the update.
|
||||
|
||||
## Focused Lenses
|
||||
|
||||
Use focused Lenses when a component should work with one part of a larger state
|
||||
object. A focused Lens is still a Lens, so it can be read with
|
||||
`Subscribable.useAll` or used with `Lens.useState` when React needs a
|
||||
read/write tuple.
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, Subscribable } from "effect-fc"
|
||||
|
||||
interface UserProfile {
|
||||
readonly name: string
|
||||
readonly email: string
|
||||
readonly role: string
|
||||
}
|
||||
|
||||
class ProfileState extends Effect.Service<ProfileState>()("ProfileState", {
|
||||
effect: Effect.gen(function* () {
|
||||
const profile = Lens.fromSubscriptionRef(
|
||||
yield* SubscriptionRef.make<UserProfile>({
|
||||
name: "",
|
||||
email: "",
|
||||
role: "reader",
|
||||
}),
|
||||
)
|
||||
const name = Lens.focusObjectOn(profile, "name")
|
||||
const role = Lens.focusObjectOn(profile, "role")
|
||||
|
||||
return { profile, name, role } as const
|
||||
}),
|
||||
}) {}
|
||||
|
||||
const ProfileNameView = Component.make("ProfileName")(function* () {
|
||||
const state = yield* ProfileState
|
||||
const [name, setName] = yield* Lens.useState(state.name)
|
||||
const [role] = yield* Subscribable.useAll([state.role])
|
||||
|
||||
return (
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
/>
|
||||
<span>Role: {role}</span>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Updating the focused `name` Lens through `Lens.useState` updates the parent
|
||||
`profile` Lens. The focused `role` Lens is only read, so it stays on the simpler
|
||||
`Subscribable.useAll` path.
|
||||
|
||||
For focusing into nested state, deriving lenses, custom write behavior, and the
|
||||
complete API, refer to the
|
||||
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens).
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"docsSidebar": [
|
||||
"getting-started",
|
||||
"state-management",
|
||||
"forms"
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
[
|
||||
"effect-fc-v0"
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.tanstack
|
||||
@@ -1,51 +0,0 @@
|
||||
# Effect FC Example
|
||||
|
||||
Example application for the legacy `effect-fc` package, built with React,
|
||||
TypeScript, and Vite.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import react from 'eslint-plugin-react'
|
||||
|
||||
export default tseslint.config({
|
||||
// Set the react version
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
// Add the react plugin
|
||||
react,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended rules
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"files": {
|
||||
"includes": ["./src/**", "!src/routeTree.gen.ts"]
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Effect FC Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"name": "@effect-fc/example",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"lint:tsc": "tsc -b --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
"preview": "vite preview",
|
||||
"clean:cache": "rm -rf .turbo node_modules/.tmp node_modules/.vite* .tanstack",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-router": "^1.170.10",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/router-plugin": "^1.168.13",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"globals": "^17.6.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"type-fest": "^5.7.0",
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform": "^0.96.1",
|
||||
"@effect/platform-browser": "^0.76.0",
|
||||
"@radix-ui/themes": "^3.3.0",
|
||||
"@typed/id": "^0.17.2",
|
||||
"effect": "^3.21.2",
|
||||
"effect-fc": "workspace:*",
|
||||
"react-icons": "^5.6.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "^19.2.15",
|
||||
"effect": "^3.21.2",
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
|
||||
const router = createRouter({ routeTree })
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: React entrypoint
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ReactRuntime.Provider runtime={runtime}>
|
||||
<RouterProvider router={router} />
|
||||
</ReactRuntime.Provider>
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -1,210 +0,0 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as ResultRouteImport } from './routes/result'
|
||||
import { Route as QueryRouteImport } from './routes/query'
|
||||
import { Route as FormRouteImport } from './routes/form'
|
||||
import { Route as BlankRouteImport } from './routes/blank'
|
||||
import { Route as AsyncRouteImport } from './routes/async'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as DevMemoRouteImport } from './routes/dev/memo'
|
||||
import { Route as DevContextRouteImport } from './routes/dev/context'
|
||||
|
||||
const ResultRoute = ResultRouteImport.update({
|
||||
id: '/result',
|
||||
path: '/result',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const QueryRoute = QueryRouteImport.update({
|
||||
id: '/query',
|
||||
path: '/query',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const FormRoute = FormRouteImport.update({
|
||||
id: '/form',
|
||||
path: '/form',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const BlankRoute = BlankRouteImport.update({
|
||||
id: '/blank',
|
||||
path: '/blank',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AsyncRoute = AsyncRouteImport.update({
|
||||
id: '/async',
|
||||
path: '/async',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DevMemoRoute = DevMemoRouteImport.update({
|
||||
id: '/dev/memo',
|
||||
path: '/dev/memo',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DevContextRoute = DevContextRouteImport.update({
|
||||
id: '/dev/context',
|
||||
path: '/dev/context',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/query': typeof QueryRoute
|
||||
'/result': typeof ResultRoute
|
||||
'/dev/context': typeof DevContextRoute
|
||||
'/dev/memo': typeof DevMemoRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/query': typeof QueryRoute
|
||||
'/result': typeof ResultRoute
|
||||
'/dev/context': typeof DevContextRoute
|
||||
'/dev/memo': typeof DevMemoRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/query': typeof QueryRoute
|
||||
'/result': typeof ResultRoute
|
||||
'/dev/context': typeof DevContextRoute
|
||||
'/dev/memo': typeof DevMemoRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/async'
|
||||
| '/blank'
|
||||
| '/form'
|
||||
| '/query'
|
||||
| '/result'
|
||||
| '/dev/context'
|
||||
| '/dev/memo'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/async'
|
||||
| '/blank'
|
||||
| '/form'
|
||||
| '/query'
|
||||
| '/result'
|
||||
| '/dev/context'
|
||||
| '/dev/memo'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/async'
|
||||
| '/blank'
|
||||
| '/form'
|
||||
| '/query'
|
||||
| '/result'
|
||||
| '/dev/context'
|
||||
| '/dev/memo'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AsyncRoute: typeof AsyncRoute
|
||||
BlankRoute: typeof BlankRoute
|
||||
FormRoute: typeof FormRoute
|
||||
QueryRoute: typeof QueryRoute
|
||||
ResultRoute: typeof ResultRoute
|
||||
DevContextRoute: typeof DevContextRoute
|
||||
DevMemoRoute: typeof DevMemoRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/result': {
|
||||
id: '/result'
|
||||
path: '/result'
|
||||
fullPath: '/result'
|
||||
preLoaderRoute: typeof ResultRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/query': {
|
||||
id: '/query'
|
||||
path: '/query'
|
||||
fullPath: '/query'
|
||||
preLoaderRoute: typeof QueryRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/form': {
|
||||
id: '/form'
|
||||
path: '/form'
|
||||
fullPath: '/form'
|
||||
preLoaderRoute: typeof FormRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/blank': {
|
||||
id: '/blank'
|
||||
path: '/blank'
|
||||
fullPath: '/blank'
|
||||
preLoaderRoute: typeof BlankRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/async': {
|
||||
id: '/async'
|
||||
path: '/async'
|
||||
fullPath: '/async'
|
||||
preLoaderRoute: typeof AsyncRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/dev/memo': {
|
||||
id: '/dev/memo'
|
||||
path: '/dev/memo'
|
||||
fullPath: '/dev/memo'
|
||||
preLoaderRoute: typeof DevMemoRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/dev/context': {
|
||||
id: '/dev/context'
|
||||
path: '/dev/context'
|
||||
fullPath: '/dev/context'
|
||||
preLoaderRoute: typeof DevContextRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AsyncRoute: AsyncRoute,
|
||||
BlankRoute: BlankRoute,
|
||||
FormRoute: FormRoute,
|
||||
QueryRoute: QueryRoute,
|
||||
ResultRoute: ResultRoute,
|
||||
DevContextRoute: DevContextRoute,
|
||||
DevMemoRoute: DevMemoRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Container, Flex, Theme } from "@radix-ui/themes"
|
||||
import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||
|
||||
import "@radix-ui/themes/styles.css"
|
||||
import "../index.css"
|
||||
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Root
|
||||
})
|
||||
|
||||
function Root() {
|
||||
return (
|
||||
<Theme>
|
||||
<Container mb="4">
|
||||
<Flex direction="row" justify="center" align="center" gap="2">
|
||||
<Link to="/">Index</Link>
|
||||
<Link to="/blank">Blank</Link>
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
<Outlet />
|
||||
|
||||
<TanStackRouterDevtools />
|
||||
</Theme>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
|
||||
export const Route = createFileRoute("/blank")({
|
||||
component: RouteComponent
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/blank"!</div>
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { Button, Container, Flex, Text } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
|
||||
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
|
||||
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
||||
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
|
||||
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
const email = Schema.pattern<typeof Schema.String>(
|
||||
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i,
|
||||
|
||||
{
|
||||
identifier: "email",
|
||||
title: "email",
|
||||
message: () => "Not an email address",
|
||||
},
|
||||
)
|
||||
|
||||
const RegisterFormSchema = Schema.Struct({
|
||||
email: Schema.String.pipe(email),
|
||||
password: Schema.String.pipe(Schema.minLength(3)),
|
||||
birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
|
||||
})
|
||||
|
||||
const RegisterFormSubmitSchema = Schema.Struct({
|
||||
email: Schema.transformOrFail(
|
||||
Schema.String,
|
||||
Schema.String,
|
||||
{
|
||||
decode: (input, _options, ast) => input !== "admin@admin.com"
|
||||
? ParseResult.succeed(input)
|
||||
: ParseResult.fail(new ParseResult.Type(ast, input, "This email is already in use.")),
|
||||
encode: ParseResult.succeed,
|
||||
},
|
||||
),
|
||||
password: Schema.String,
|
||||
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
|
||||
})
|
||||
|
||||
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
|
||||
scoped: Effect.gen(function*() {
|
||||
const form = yield* SubmittableForm.service({
|
||||
schema: RegisterFormSchema.pipe(
|
||||
Schema.compose(
|
||||
Schema.transformOrFail(
|
||||
Schema.typeSchema(RegisterFormSchema),
|
||||
Schema.typeSchema(RegisterFormSchema),
|
||||
{
|
||||
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
|
||||
encode: ParseResult.succeed,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
initialEncodedValue: { email: "", password: "", birth: Option.none() },
|
||||
f: Effect.fnUntraced(function*([value]) {
|
||||
yield* Effect.sleep("500 millis")
|
||||
return yield* Schema.decode(RegisterFormSubmitSchema)(value)
|
||||
}),
|
||||
})
|
||||
|
||||
return {
|
||||
form,
|
||||
emailField: Form.focusObjectOn(form, "email"),
|
||||
passwordField: Form.focusObjectOn(form, "password"),
|
||||
birthField: Form.focusObjectOn(form, "birth"),
|
||||
} as const
|
||||
})
|
||||
}) {}
|
||||
|
||||
class RegisterFormView extends Component.make("RegisterFormView")(function*() {
|
||||
const form = yield* RegisterFormService
|
||||
const [canCommit, submitResult] = yield* Subscribable.useAll([
|
||||
form.form.canCommit,
|
||||
form.form.mutation.result,
|
||||
])
|
||||
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
const TextFieldFormInput = yield* TextFieldFormInputView.use
|
||||
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
|
||||
|
||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
|
||||
yield* Console.log("RegisterFormView mounted")
|
||||
}))
|
||||
|
||||
|
||||
return (
|
||||
<Container width="300">
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault()
|
||||
void runPromise(form.form.submit)
|
||||
}}>
|
||||
<Flex direction="column" gap="2">
|
||||
<TextFieldFormInput
|
||||
form={form.emailField}
|
||||
debounce="250 millis"
|
||||
/>
|
||||
|
||||
<TextFieldFormInput
|
||||
form={form.passwordField}
|
||||
debounce="250 millis"
|
||||
/>
|
||||
|
||||
<TextFieldOptionalFormInput
|
||||
type="datetime-local"
|
||||
form={form.birthField}
|
||||
defaultValue=""
|
||||
/>
|
||||
|
||||
<Button disabled={!canCommit}>Submit</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
|
||||
{Match.value(submitResult).pipe(
|
||||
Match.tag("Initial", () => <></>),
|
||||
Match.tag("Running", () => <Text>Submitting...</Text>),
|
||||
Match.tag("Success", () => <Text>Submitted successfully!</Text>),
|
||||
Match.tag("Failure", e => <Text>Error: {e.cause.toString()}</Text>),
|
||||
Match.exhaustive,
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}) {}
|
||||
|
||||
const RegisterPage = Component.make("RegisterPageView")(function*() {
|
||||
const RegisterForm = yield* Effect.provide(
|
||||
RegisterFormView.use,
|
||||
yield* Component.useLayer(RegisterFormService.Default),
|
||||
)
|
||||
|
||||
return <RegisterForm />
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
|
||||
export const Route = createFileRoute("/form")({
|
||||
component: RegisterPage
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
import { runtime } from "@/runtime"
|
||||
import { TodosState } from "@/todo/TodosState"
|
||||
import { TodosView } from "@/todo/TodosView"
|
||||
|
||||
|
||||
const TodosStateLive = TodosState.Default("todos")
|
||||
|
||||
const Index = Component.make("IndexView")(function*() {
|
||||
const Todos = yield* Effect.provide(
|
||||
TodosView.use,
|
||||
yield* Component.useLayer(TodosStateLive),
|
||||
)
|
||||
|
||||
return <Todos />
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Index
|
||||
})
|
||||
@@ -1,117 +0,0 @@
|
||||
import { HttpClient, type HttpClientError } from "@effect/platform"
|
||||
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect"
|
||||
import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
const Post = Schema.Struct({
|
||||
userId: Schema.Int,
|
||||
id: Schema.Int,
|
||||
title: Schema.String,
|
||||
body: Schema.String,
|
||||
})
|
||||
|
||||
const ResultView = Component.make("ResultView")(function*() {
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
|
||||
const key = Stream.map(idLens.changes, id => [id] as const)
|
||||
|
||||
const query = yield* Query.service({
|
||||
key,
|
||||
f: ([id]) => HttpClient.HttpClient.pipe(
|
||||
Effect.tap(Effect.sleep("500 millis")),
|
||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
||||
Effect.andThen(response => response.json),
|
||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
||||
),
|
||||
staleTime: "10 seconds",
|
||||
})
|
||||
|
||||
const mutation = yield* Mutation.make({
|
||||
f: ([id]: readonly [id: number]) => HttpClient.HttpClient.pipe(
|
||||
Effect.tap(Effect.sleep("500 millis")),
|
||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
||||
Effect.andThen(response => response.json),
|
||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
||||
),
|
||||
})
|
||||
|
||||
return [idLens, query, mutation] as const
|
||||
}))
|
||||
|
||||
const [id, setId] = yield* Lens.useState(idLens)
|
||||
const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result])
|
||||
|
||||
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
||||
Effect.andThen(observer => observer.subscribe),
|
||||
Effect.andThen(Stream.fromQueue),
|
||||
Stream.unwrapScoped,
|
||||
Stream.runForEach(flow(
|
||||
Cause.failures,
|
||||
Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"),
|
||||
Option.match({
|
||||
onSome: e => Console.log("ResultView HttpClient error", e),
|
||||
onNone: () => Effect.void,
|
||||
}),
|
||||
)),
|
||||
Effect.forkScoped,
|
||||
))
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Flex direction="column" align="center" gap="2">
|
||||
<Slider
|
||||
value={[id]}
|
||||
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{Match.value(queryResult).pipe(
|
||||
Match.tag("Running", () => <Text>Loading...</Text>),
|
||||
Match.tag("Success", result => <>
|
||||
<Heading>{result.value.title}</Heading>
|
||||
<Text>{result.value.body}</Text>
|
||||
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
|
||||
</>),
|
||||
Match.tag("Failure", result =>
|
||||
<Text>An error has occured: {result.cause.toString()}</Text>
|
||||
),
|
||||
Match.orElse(() => <></>),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Flex direction="row" justify="center" align="center" gap="1">
|
||||
<Button onClick={() => runPromise(query.refresh)}>Refresh</Button>
|
||||
<Button onClick={() => runPromise(query.invalidateCache)}>Invalidate cache</Button>
|
||||
</Flex>
|
||||
|
||||
<div>
|
||||
{Match.value(mutationResult).pipe(
|
||||
Match.tag("Running", () => <Text>Loading...</Text>),
|
||||
Match.tag("Success", result => <>
|
||||
<Heading>{result.value.title}</Heading>
|
||||
<Text>{result.value.body}</Text>
|
||||
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
|
||||
</>),
|
||||
Match.tag("Failure", result =>
|
||||
<Text>An error has occured: {result.cause.toString()}</Text>
|
||||
),
|
||||
Match.orElse(() => <></>),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Flex direction="row" justify="center" align="center" gap="1">
|
||||
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/query")({
|
||||
component: Component.withRuntime(ResultView, runtime.context)
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
|
||||
import { DateTime, Layer } from "effect"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
|
||||
|
||||
export const AppLive = Layer.empty.pipe(
|
||||
Layer.provideMerge(DateTime.layerCurrentZoneLocal),
|
||||
Layer.provideMerge(Clipboard.layer),
|
||||
Layer.provideMerge(Geolocation.layer),
|
||||
Layer.provideMerge(Permissions.layer),
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
@@ -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>
|
||||
)
|
||||
}) {}
|
||||
@@ -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 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
"plugins": [
|
||||
{ "name": "@effect/language-service" }
|
||||
]
|
||||
},
|
||||
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import path from "node:path"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tanstackRouter({
|
||||
target: "react",
|
||||
autoCodeSplitting: true,
|
||||
}),
|
||||
react(),
|
||||
],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome.
|
||||
|
||||
Documentation is currently being written. In the meantime, you can take a look at the `packages/effect-fc-example` directory.
|
||||
Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
|
||||
|
||||
## Peer dependencies
|
||||
- `effect` 3.19+
|
||||
@@ -42,7 +42,7 @@ export class TodosView extends Component.make("TodosView")(function*() {
|
||||
}) {}
|
||||
|
||||
const Index = Component.make("IndexView")(function*() {
|
||||
const context = yield* Component.useLayer(TodosState.Default)
|
||||
const context = yield* Component.useContextFromLayer(TodosState.Default)
|
||||
const Todos = yield* Effect.provide(TodosView.use, context)
|
||||
|
||||
return <Todos />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "effect-fc",
|
||||
"description": "Write React function components with Effect",
|
||||
"version": "0.3.1",
|
||||
"version": "0.2.4",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"./README.md",
|
||||
@@ -9,7 +9,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "git+https://github.com/Thiladev/effect-view.git"
|
||||
"url": "git+https://github.com/Thiladev/effect-fc.git"
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
@@ -29,20 +29,16 @@
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"lint:tsc": "tsc -b --noEmit",
|
||||
"build": "tsc",
|
||||
"lint:tsc": "tsc --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
"test": "vitest run",
|
||||
"build": "tsc -b tsconfig.build.json",
|
||||
"pack": "npm pack",
|
||||
"clean:cache": "rm -rf .turbo *.tsbuildinfo",
|
||||
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-browser": "^0.76.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
"@effect/platform-browser": "^0.76.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
@@ -50,6 +46,6 @@
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect-lens": "^0.2.3"
|
||||
"effect-lens": "^0.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ export type AsyncProps = Omit<React.SuspenseProps, "children">
|
||||
export const AsyncPrototype: AsyncPrototype = Object.freeze({
|
||||
[AsyncTypeId]: AsyncTypeId,
|
||||
|
||||
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Component.Signature>(
|
||||
this: Component.Component<P, A, E, R, F> & Async,
|
||||
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
|
||||
this: Component.Component<P, A, E, R> & Async,
|
||||
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
|
||||
) {
|
||||
const Inner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
|
||||
@@ -106,7 +106,7 @@ export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, Asyn
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export const async = <T extends Component.Component.Any>(
|
||||
export const async = <T extends Component.Component<any, any, any, any>>(
|
||||
self: T & (
|
||||
"promise" extends keyof Component.Component.Props<T>
|
||||
? "The 'promise' prop name is restricted for Async components. Please rename the 'promise' prop to something else."
|
||||
@@ -118,8 +118,7 @@ export const async = <T extends Component.Component.Any>(
|
||||
Component.Component.Props<T> & AsyncProps,
|
||||
Component.Component.Success<T>,
|
||||
Component.Component.Error<T>,
|
||||
Component.Component.Context<T>,
|
||||
Component.Component.DefaultSignature<Component.Component.Props<T> & AsyncProps, Component.Component.Success<T>>
|
||||
Component.Component.Context<T>
|
||||
>
|
||||
& Async
|
||||
) => Object.setPrototypeOf(
|
||||
@@ -155,14 +154,14 @@ export const async = <T extends Component.Component.Any>(
|
||||
* ```
|
||||
*/
|
||||
export const withOptions: {
|
||||
<T extends Component.Component.Any & Async>(
|
||||
<T extends Component.Component<any, any, any, any> & Async>(
|
||||
options: Partial<AsyncOptions>
|
||||
): (self: T) => T
|
||||
<T extends Component.Component.Any & Async>(
|
||||
<T extends Component.Component<any, any, any, any> & Async>(
|
||||
self: T,
|
||||
options: Partial<AsyncOptions>,
|
||||
): T
|
||||
} = Function.dual(2, <T extends Component.Component.Any & Async>(
|
||||
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Async>(
|
||||
self: T,
|
||||
options: Partial<AsyncOptions>,
|
||||
): T => Object.setPrototypeOf(
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import * as React from "react"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import * as Component from "./Component.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
|
||||
|
||||
class ValueService extends Context.Tag("ValueService")<ValueService, { readonly value: string }>() {}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("Component", () => {
|
||||
it("runs useOnMount only once across rerenders", async () => {
|
||||
const onMount = vi.fn(() => Effect.succeed("mounted"))
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
const Probe = Component.makeUntraced("UseOnMountProbe")(function*() {
|
||||
const value = yield* Component.useOnMount(onMount)
|
||||
return <div>{value}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("mounted")
|
||||
expect(onMount).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
expect(await screen.findByText("mounted")).toBeTruthy()
|
||||
expect(onMount).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.unmount()
|
||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||
})
|
||||
|
||||
it("recomputes useOnChange only when dependencies change", async () => {
|
||||
const onChange = vi.fn((value: number) => Effect.succeed(`value:${value}`))
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
const Probe = Component.makeUntraced("UseOnChangeProbe")(function*(props: { readonly value: number }) {
|
||||
const result = yield* Component.useOnChange(() => onChange(props.value), [props.value], {
|
||||
finalizerExecutionDebounce: 0,
|
||||
})
|
||||
|
||||
return <div>{result}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={1} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("value:1")
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={1} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
expect(await screen.findByText("value:1")).toBeTruthy()
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={2} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("value:2")
|
||||
expect(onChange).toHaveBeenCalledTimes(2)
|
||||
|
||||
view.unmount()
|
||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||
})
|
||||
|
||||
it("closes the previous scope on dependency changes and unmount", async () => {
|
||||
const cleanup = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
const Probe = Component.makeUntraced("ScopeCleanupProbe")(function*(props: { readonly value: string }) {
|
||||
const result = yield* Component.useOnChange(
|
||||
() => Effect.gen(function*() {
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
|
||||
return props.value
|
||||
}),
|
||||
[props.value],
|
||||
{ finalizerExecutionDebounce: 0 },
|
||||
)
|
||||
|
||||
return <div>{result}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
expect(cleanup).not.toHaveBeenCalled()
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("second")
|
||||
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"))
|
||||
expect(cleanup).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.unmount()
|
||||
|
||||
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"))
|
||||
expect(cleanup).toHaveBeenCalledTimes(2)
|
||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||
})
|
||||
|
||||
it("runs useReactEffect setup and cleanup when dependencies change", async () => {
|
||||
const lifecycle = vi.fn<(message: string) => void>()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
const Probe = Component.makeUntraced("UseReactEffectProbe")(function*(props: { readonly value: string }) {
|
||||
yield* Component.useReactEffect(() =>
|
||||
Effect.gen(function*() {
|
||||
yield* Effect.sync(() => lifecycle(`mount:${props.value}`))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => lifecycle(`cleanup:${props.value}`)))
|
||||
}),
|
||||
[props.value])
|
||||
|
||||
return <div>{props.value}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:first"))
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("second")
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:first"))
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:second"))
|
||||
|
||||
view.unmount()
|
||||
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:second"))
|
||||
expect(lifecycle.mock.calls.map(([message]) => message)).toEqual([
|
||||
"mount:first",
|
||||
"cleanup:first",
|
||||
"mount:second",
|
||||
"cleanup:second",
|
||||
])
|
||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||
})
|
||||
|
||||
it("keeps useCallbackSync stable until dependencies change", async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
const seenCallbacks: Array<(value: number) => string> = []
|
||||
|
||||
const Probe = Component.makeUntraced("UseCallbackSyncProbe")(function*(props: { readonly prefix: string }) {
|
||||
const callback = yield* Component.useCallbackSync(
|
||||
(value: number) => Effect.succeed(`${props.prefix}:${value}`),
|
||||
[props.prefix],
|
||||
)
|
||||
|
||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||
seenCallbacks.push(callback)
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
seenCallbacks.push(callback)
|
||||
}, [callback])
|
||||
|
||||
return <div>{callback(1)}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe prefix="a" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("a:1")
|
||||
expect(seenCallbacks).toHaveLength(2)
|
||||
expect(seenCallbacks[0]).toBe(seenCallbacks[1])
|
||||
expect(seenCallbacks[0]?.(2)).toBe("a:2")
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe prefix="a" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("a:1")
|
||||
expect(seenCallbacks).toHaveLength(2)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe prefix="b" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("b:1")
|
||||
await waitFor(() => expect(seenCallbacks).toHaveLength(3))
|
||||
expect(seenCallbacks[2]).not.toBe(seenCallbacks[1])
|
||||
expect(seenCallbacks[2]?.(2)).toBe("b:2")
|
||||
|
||||
view.unmount()
|
||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||
})
|
||||
|
||||
it("delays cleanup according to finalizerExecutionDebounce", async () => {
|
||||
const cleanup = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
const Probe = Component.makeUntraced("DebouncedCleanupProbe")(function*(props: { readonly value: string }) {
|
||||
const result = yield* Component.useOnChange(
|
||||
() => Effect.gen(function*() {
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
|
||||
return props.value
|
||||
}),
|
||||
[props.value],
|
||||
{ finalizerExecutionDebounce: "20 millis" },
|
||||
)
|
||||
|
||||
return <div>{result}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
vi.useFakeTimers()
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
expect(screen.getByText("second")).toBeTruthy()
|
||||
expect(cleanup).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(19)
|
||||
expect(cleanup).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(cleanup).toHaveBeenCalledWith("first")
|
||||
|
||||
view.unmount()
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(cleanup).toHaveBeenCalledWith("second")
|
||||
vi.useRealTimers()
|
||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||
})
|
||||
|
||||
it("does not remount a component when only nonReactiveTags change", async () => {
|
||||
const mounts = vi.fn()
|
||||
const unmounts = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
const SubComponent = Component.makeUntraced("NonReactiveSubComponent")(function*() {
|
||||
const service = yield* ValueService
|
||||
|
||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
yield* Effect.sync(() => mounts())
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
|
||||
}))
|
||||
|
||||
return <div>{service.value}</div>
|
||||
}).pipe(
|
||||
Component.withOptions({ nonReactiveTags: [ValueService] })
|
||||
)
|
||||
|
||||
const Parent = Component.makeUntraced("NonReactiveParent")(function*(props: { readonly value: string }) {
|
||||
const serviceLayer = React.useMemo(
|
||||
() => Layer.succeed(ValueService, { value: props.value }),
|
||||
[props.value],
|
||||
)
|
||||
const context = yield* Component.useLayer(serviceLayer, {
|
||||
finalizerExecutionDebounce: 0,
|
||||
})
|
||||
const Child = yield* Effect.provide(SubComponent.use, context)
|
||||
|
||||
return <Child />
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Parent value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
expect(mounts).toHaveBeenCalledTimes(1)
|
||||
expect(unmounts).not.toHaveBeenCalled()
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Parent value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("second")
|
||||
expect(mounts).toHaveBeenCalledTimes(1)
|
||||
expect(unmounts).not.toHaveBeenCalled()
|
||||
|
||||
view.unmount()
|
||||
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
|
||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||
})
|
||||
})
|
||||
@@ -10,52 +10,49 @@ export type ComponentTypeId = typeof ComponentTypeId
|
||||
/**
|
||||
* Represents an Effect-based React Component that integrates the Effect system with React.
|
||||
*/
|
||||
export interface Component<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>
|
||||
extends ComponentPrototype<R, F>, ComponentOptions {
|
||||
export interface Component<P extends {}, A extends React.ReactNode, E, R>
|
||||
extends ComponentPrototype<P, A, R>, ComponentOptions {
|
||||
new(_: never): Record<string, never>
|
||||
readonly [ComponentTypeId]: ComponentTypeId
|
||||
readonly "~Props": P
|
||||
readonly "~Success": A
|
||||
readonly "~Error": E
|
||||
readonly "~Context": R
|
||||
readonly "~Function": F
|
||||
|
||||
readonly body: (props: P) => Effect.Effect<A, E, R>
|
||||
}
|
||||
|
||||
export declare namespace Component {
|
||||
export type Default<P extends {}, A extends React.ReactNode, E, R> = Component<P, A, E, R, DefaultSignature<P, A>>
|
||||
export type Any = Component<any, any, any, any, any>
|
||||
export type Props<T extends Component<any, any, any, any>> = [T] extends [Component<infer P, infer _A, infer _E, infer _R>] ? P : never
|
||||
export type Success<T extends Component<any, any, any, any>> = [T] extends [Component<infer _P, infer A, infer _E, infer _R>] ? A : never
|
||||
export type Error<T extends Component<any, any, any, any>> = [T] extends [Component<infer _P, infer _A, infer E, infer _R>] ? E : never
|
||||
export type Context<T extends Component<any, any, any, any>> = [T] extends [Component<infer _P, infer _A, infer _E, infer R>] ? R : never
|
||||
|
||||
export type Signature = (props: any) => React.ReactNode
|
||||
export type DefaultSignature<P extends {}, A extends React.ReactNode> = (props: P) => A
|
||||
|
||||
export type Props<T extends Any> = [T] extends [Component<infer P, infer _A, infer _E, infer _R, infer _F>] ? P : never
|
||||
export type Success<T extends Any> = [T] extends [Component<infer _P, infer A, infer _E, infer _R, infer _F>] ? A : never
|
||||
export type Error<T extends Any> = [T] extends [Component<infer _P, infer _A, infer E, infer _R, infer _F>] ? E : never
|
||||
export type Context<T extends Any> = [T] extends [Component<infer _P, infer _A, infer _E, infer R, infer _F>] ? R : never
|
||||
export type Function<T extends Any> = [T] extends [Component<infer _P, infer _A, infer _E, infer _R, infer F>] ? F : never
|
||||
|
||||
export type AsComponent<T extends Any> = Component<Props<T>, Success<T>, Error<T>, Context<T>, Function<T>>
|
||||
export type AsComponent<T extends Component<any, any, any, any>> = Component<Props<T>, Success<T>, Error<T>, Context<T>>
|
||||
}
|
||||
|
||||
|
||||
export interface ComponentImpl<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>
|
||||
extends Component<P, A, E, R, F>, ComponentImplPrototype<R, F> {}
|
||||
export interface ComponentPrototype<P extends {}, A extends React.ReactNode, R>
|
||||
extends Pipeable.Pipeable {
|
||||
readonly [ComponentTypeId]: ComponentTypeId
|
||||
readonly use: Effect.Effect<(props: P) => A, never, Exclude<R, Scope.Scope>>
|
||||
|
||||
export interface ComponentImplPrototype<R, F extends Component.Signature> {
|
||||
readonly use: Effect.Effect<F, never, Exclude<R, Scope.Scope>>
|
||||
asFunctionComponent(
|
||||
runtimeRef: React.Ref<Runtime.Runtime<Exclude<R, Scope.Scope>>>
|
||||
): (props: P) => A
|
||||
|
||||
asFunctionComponent(runtimeRef: React.Ref<Runtime.Runtime<Exclude<R, Scope.Scope>>>): F
|
||||
setFunctionComponentName(f: F): void
|
||||
transformFunctionComponent(f: F): F
|
||||
setFunctionComponentName(f: React.FC<P>): void
|
||||
transformFunctionComponent(f: React.FC<P>): React.FC<P>
|
||||
}
|
||||
|
||||
export const ComponentImplPrototype: ComponentImplPrototype<any, any> = Object.freeze({
|
||||
export const ComponentPrototype: ComponentPrototype<any, any, any> = Object.freeze({
|
||||
[ComponentTypeId]: ComponentTypeId,
|
||||
...Pipeable.Prototype,
|
||||
|
||||
get use() { return use(this) },
|
||||
|
||||
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
||||
this: ComponentImpl<P, A, E, R, F>,
|
||||
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
|
||||
this: Component<P, A, E, R>,
|
||||
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
|
||||
) {
|
||||
return (props: P) => Runtime.runSync(runtimeRef.current)(
|
||||
@@ -66,8 +63,8 @@ export const ComponentImplPrototype: ComponentImplPrototype<any, any> = Object.f
|
||||
)
|
||||
},
|
||||
|
||||
setFunctionComponentName<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
||||
this: ComponentImpl<P, A, E, R, F>,
|
||||
setFunctionComponentName<P extends {}, A extends React.ReactNode, E, R>(
|
||||
this: Component<P, A, E, R>,
|
||||
f: React.FC<P>,
|
||||
) {
|
||||
f.displayName = this.displayName ?? "Anonymous"
|
||||
@@ -76,8 +73,8 @@ export const ComponentImplPrototype: ComponentImplPrototype<any, any> = Object.f
|
||||
transformFunctionComponent: identity,
|
||||
} as const)
|
||||
|
||||
const use = Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
||||
self: ComponentImpl<P, A, E, R, F>
|
||||
const use = Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode, E, R>(
|
||||
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!)
|
||||
@@ -85,7 +82,7 @@ const use = Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode
|
||||
|
||||
return yield* React.useState(() => Runtime.runSync(runtimeRef.current)(Effect.cachedFunction(
|
||||
(_services: readonly any[]) => Effect.sync(() => {
|
||||
const f = self.asFunctionComponent(runtimeRef)
|
||||
const f: React.FC<P> = self.asFunctionComponent(runtimeRef)
|
||||
self.setFunctionComponentName(f)
|
||||
return self.transformFunctionComponent(f)
|
||||
}),
|
||||
@@ -96,23 +93,6 @@ const use = Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode
|
||||
})
|
||||
|
||||
|
||||
export interface ComponentPrototype<R, F extends Component.Signature>
|
||||
extends Pipeable.Pipeable {
|
||||
readonly [ComponentTypeId]: ComponentTypeId
|
||||
readonly use: Effect.Effect<F, never, Exclude<R, Scope.Scope>>
|
||||
}
|
||||
|
||||
export const ComponentPrototype: ComponentPrototype<any, any> = Object.freeze(
|
||||
Object.defineProperties(
|
||||
{
|
||||
[ComponentTypeId]: ComponentTypeId,
|
||||
...Pipeable.Prototype,
|
||||
},
|
||||
Object.getOwnPropertyDescriptors(ComponentImplPrototype),
|
||||
) as ComponentPrototype<any, any>
|
||||
)
|
||||
|
||||
|
||||
export interface ComponentOptions {
|
||||
/**
|
||||
* Custom display name for the component in React DevTools and debugging utilities.
|
||||
@@ -151,13 +131,13 @@ export const defaultOptions: ComponentOptions = {
|
||||
}
|
||||
|
||||
|
||||
export const isComponent = (u: unknown): u is Component.Default<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, ComponentTypeId)
|
||||
export const isComponent = (u: unknown): u is Component<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, ComponentTypeId)
|
||||
|
||||
export declare namespace make {
|
||||
export type Gen = {
|
||||
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A extends React.ReactNode, P extends {} = {}>(
|
||||
body: (props: P) => Generator<Eff, A, never>
|
||||
): Component.Default<
|
||||
): Component<
|
||||
P, A,
|
||||
[Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap<Effect.Effect<infer _A, infer E, infer _R>>] ? E : never,
|
||||
[Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap<Effect.Effect<infer _A, infer _E, infer R>>] ? R : never
|
||||
@@ -172,7 +152,7 @@ export declare namespace make {
|
||||
>,
|
||||
props: NoInfer<P>,
|
||||
) => B,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<B>>, Effect.Effect.Error<B>, Effect.Effect.Context<B>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<B>>, Effect.Effect.Error<B>, Effect.Effect.Context<B>>
|
||||
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
|
||||
body: (props: P) => Generator<Eff, A, never>,
|
||||
a: (
|
||||
@@ -184,7 +164,7 @@ export declare namespace make {
|
||||
props: NoInfer<P>,
|
||||
) => B,
|
||||
b: (_: B, props: NoInfer<P>) => C,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<C>>, Effect.Effect.Error<C>, Effect.Effect.Context<C>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<C>>, Effect.Effect.Error<C>, Effect.Effect.Context<C>>
|
||||
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
|
||||
body: (props: P) => Generator<Eff, A, never>,
|
||||
a: (
|
||||
@@ -197,7 +177,7 @@ export declare namespace make {
|
||||
) => B,
|
||||
b: (_: B, props: NoInfer<P>) => C,
|
||||
c: (_: C, props: NoInfer<P>) => D,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<D>>, Effect.Effect.Error<D>, Effect.Effect.Context<D>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<D>>, Effect.Effect.Error<D>, Effect.Effect.Context<D>>
|
||||
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
|
||||
body: (props: P) => Generator<Eff, A, never>,
|
||||
a: (
|
||||
@@ -211,7 +191,7 @@ export declare namespace make {
|
||||
b: (_: B, props: NoInfer<P>) => C,
|
||||
c: (_: C, props: NoInfer<P>) => D,
|
||||
d: (_: D, props: NoInfer<P>) => E,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<E>>, Effect.Effect.Error<E>, Effect.Effect.Context<E>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<E>>, Effect.Effect.Error<E>, Effect.Effect.Context<E>>
|
||||
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
|
||||
body: (props: P) => Generator<Eff, A, never>,
|
||||
a: (
|
||||
@@ -226,7 +206,7 @@ export declare namespace make {
|
||||
c: (_: C, props: NoInfer<P>) => D,
|
||||
d: (_: D, props: NoInfer<P>) => E,
|
||||
e: (_: E, props: NoInfer<P>) => F,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<F>>, Effect.Effect.Error<F>, Effect.Effect.Context<F>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<F>>, Effect.Effect.Error<F>, Effect.Effect.Context<F>>
|
||||
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
|
||||
body: (props: P) => Generator<Eff, A, never>,
|
||||
a: (
|
||||
@@ -242,7 +222,7 @@ export declare namespace make {
|
||||
d: (_: D, props: NoInfer<P>) => E,
|
||||
e: (_: E, props: NoInfer<P>) => F,
|
||||
f: (_: F, props: NoInfer<P>) => G,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<G>>, Effect.Effect.Error<G>, Effect.Effect.Context<G>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<G>>, Effect.Effect.Error<G>, Effect.Effect.Context<G>>
|
||||
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G, H extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
|
||||
body: (props: P) => Generator<Eff, A, never>,
|
||||
a: (
|
||||
@@ -259,7 +239,7 @@ export declare namespace make {
|
||||
e: (_: E, props: NoInfer<P>) => F,
|
||||
f: (_: F, props: NoInfer<P>) => G,
|
||||
g: (_: G, props: NoInfer<P>) => H,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<H>>, Effect.Effect.Error<H>, Effect.Effect.Context<H>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<H>>, Effect.Effect.Error<H>, Effect.Effect.Context<H>>
|
||||
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G, H, I extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
|
||||
body: (props: P) => Generator<Eff, A, never>,
|
||||
a: (
|
||||
@@ -277,7 +257,7 @@ export declare namespace make {
|
||||
f: (_: F, props: NoInfer<P>) => G,
|
||||
g: (_: G, props: NoInfer<P>) => H,
|
||||
h: (_: H, props: NoInfer<P>) => I,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<I>>, Effect.Effect.Error<I>, Effect.Effect.Context<I>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<I>>, Effect.Effect.Error<I>, Effect.Effect.Context<I>>
|
||||
<Eff extends Utils.YieldWrap<Effect.Effect<any, any, any>>, A, B, C, D, E, F, G, H, I, J extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
|
||||
body: (props: P) => Generator<Eff, A, never>,
|
||||
a: (
|
||||
@@ -296,35 +276,35 @@ export declare namespace make {
|
||||
g: (_: G, props: NoInfer<P>) => H,
|
||||
h: (_: H, props: NoInfer<P>) => I,
|
||||
i: (_: I, props: NoInfer<P>) => J,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<J>>, Effect.Effect.Error<J>, Effect.Effect.Context<J>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<J>>, Effect.Effect.Error<J>, Effect.Effect.Context<J>>
|
||||
}
|
||||
|
||||
export type NonGen = {
|
||||
<Eff extends Effect.Effect<React.ReactNode, any, any>, P extends {} = {}>(
|
||||
body: (props: P) => Eff
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, P extends {} = {}>(
|
||||
body: (props: P) => A,
|
||||
a: (_: A, props: NoInfer<P>) => Eff,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, P extends {} = {}>(
|
||||
body: (props: P) => A,
|
||||
a: (_: A, props: NoInfer<P>) => B,
|
||||
b: (_: B, props: NoInfer<P>) => Eff,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, P extends {} = {}>(
|
||||
body: (props: P) => A,
|
||||
a: (_: A, props: NoInfer<P>) => B,
|
||||
b: (_: B, props: NoInfer<P>) => C,
|
||||
c: (_: C, props: NoInfer<P>) => Eff,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, P extends {} = {}>(
|
||||
body: (props: P) => A,
|
||||
a: (_: A, props: NoInfer<P>) => B,
|
||||
b: (_: B, props: NoInfer<P>) => C,
|
||||
c: (_: C, props: NoInfer<P>) => D,
|
||||
d: (_: D, props: NoInfer<P>) => Eff,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, P extends {} = {}>(
|
||||
body: (props: P) => A,
|
||||
a: (_: A, props: NoInfer<P>) => B,
|
||||
@@ -332,7 +312,7 @@ export declare namespace make {
|
||||
c: (_: C, props: NoInfer<P>) => D,
|
||||
d: (_: D, props: NoInfer<P>) => E,
|
||||
e: (_: E, props: NoInfer<P>) => Eff,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, P extends {} = {}>(
|
||||
body: (props: P) => A,
|
||||
a: (_: A, props: NoInfer<P>) => B,
|
||||
@@ -341,7 +321,7 @@ export declare namespace make {
|
||||
d: (_: D, props: NoInfer<P>) => E,
|
||||
e: (_: E, props: NoInfer<P>) => F,
|
||||
f: (_: F, props: NoInfer<P>) => Eff,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, G, P extends {} = {}>(
|
||||
body: (props: P) => A,
|
||||
a: (_: A, props: NoInfer<P>) => B,
|
||||
@@ -351,7 +331,7 @@ export declare namespace make {
|
||||
e: (_: E, props: NoInfer<P>) => F,
|
||||
f: (_: F, props: NoInfer<P>) => G,
|
||||
g: (_: G, props: NoInfer<P>) => Eff,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, G, H, P extends {} = {}>(
|
||||
body: (props: P) => A,
|
||||
a: (_: A, props: NoInfer<P>) => B,
|
||||
@@ -362,7 +342,7 @@ export declare namespace make {
|
||||
f: (_: F, props: NoInfer<P>) => G,
|
||||
g: (_: G, props: NoInfer<P>) => H,
|
||||
h: (_: H, props: NoInfer<P>) => Eff,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
<Eff extends Effect.Effect<React.ReactNode, any, any>, A, B, C, D, E, F, G, H, I, P extends {} = {}>(
|
||||
body: (props: P) => A,
|
||||
a: (_: A, props: NoInfer<P>) => B,
|
||||
@@ -374,7 +354,7 @@ export declare namespace make {
|
||||
g: (_: G, props: NoInfer<P>) => H,
|
||||
h: (_: H, props: NoInfer<P>) => I,
|
||||
i: (_: I, props: NoInfer<P>) => Eff,
|
||||
): Component.Default<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
): Component<P, Effect.Effect.Success<Effect.Effect.AsEffect<Eff>>, Effect.Effect.Error<Eff>, Effect.Effect.Context<Eff>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,24 +500,6 @@ export const makeUntraced: (
|
||||
)
|
||||
)
|
||||
|
||||
export declare namespace withSignature {
|
||||
export type Result<T extends Component.Any, F extends Component.Signature> = (
|
||||
& Omit<T, keyof Component.AsComponent<T>>
|
||||
& Component<Component.Props<T>, Component.Success<T>, Component.Error<T>, Component.Context<T>, F>
|
||||
)
|
||||
}
|
||||
|
||||
export const withSignature: {
|
||||
<F extends Component.Signature>(): <T extends Component.Any>(
|
||||
self: T
|
||||
) => withSignature.Result<T, F>
|
||||
<F extends Component.Signature, T extends Component.Any>(
|
||||
self: T
|
||||
): withSignature.Result<T, F>
|
||||
} = (self?: Component.Any): any => self === undefined
|
||||
? identity
|
||||
: self
|
||||
|
||||
/**
|
||||
* Creates a new component with modified configuration options while preserving all original behavior.
|
||||
*
|
||||
@@ -555,14 +517,14 @@ export const withSignature: {
|
||||
* ```
|
||||
*/
|
||||
export const withOptions: {
|
||||
<T extends Component.Any>(
|
||||
<T extends Component<any, any, any, any>>(
|
||||
options: Partial<ComponentOptions>
|
||||
): (self: T) => T
|
||||
<T extends Component.Any>(
|
||||
<T extends Component<any, any, any, any>>(
|
||||
self: T,
|
||||
options: Partial<ComponentOptions>,
|
||||
): T
|
||||
} = Function.dual(2, <T extends Component.Any>(
|
||||
} = Function.dual(2, <T extends Component<any, any, any, any>>(
|
||||
self: T,
|
||||
options: Partial<ComponentOptions>,
|
||||
): T => Object.setPrototypeOf(
|
||||
@@ -608,19 +570,19 @@ export const withOptions: {
|
||||
*
|
||||
*/
|
||||
export const withRuntime: {
|
||||
<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
||||
<P extends {}, A extends React.ReactNode, E, R>(
|
||||
context: React.Context<Runtime.Runtime<R>>,
|
||||
): (self: Component<P, A, E, Scope.Scope | NoInfer<R>, F>) => F
|
||||
<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
||||
self: Component<P, A, E, Scope.Scope | NoInfer<R>, F>,
|
||||
): (self: Component<P, A, E, Scope.Scope | NoInfer<R>>) => (props: P) => A
|
||||
<P extends {}, A extends React.ReactNode, E, R>(
|
||||
self: Component<P, A, E, Scope.Scope | NoInfer<R>>,
|
||||
context: React.Context<Runtime.Runtime<R>>,
|
||||
): F
|
||||
} = Function.dual(2, <P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
||||
self: Component<P, A, E, R, F>,
|
||||
): (props: P) => A
|
||||
} = Function.dual(2, <P extends {}, A extends React.ReactNode, E, R>(
|
||||
self: Component<P, A, E, R>,
|
||||
context: React.Context<Runtime.Runtime<R>>,
|
||||
) => function WithRuntime(props: P) {
|
||||
return React.createElement(
|
||||
Runtime.runSync(React.useContext(context))(self.use) as React.FC<P>,
|
||||
Runtime.runSync(React.useContext(context))(self.use),
|
||||
props,
|
||||
)
|
||||
})
|
||||
@@ -1075,8 +1037,10 @@ export declare namespace useContext {
|
||||
* ```tsx
|
||||
* const MyLayer = Layer.succeed(MyService, new MyServiceImpl())
|
||||
* const MyComponent = Component.make(function*() {
|
||||
* const context = yield* Component.useLayer(MyLayer)
|
||||
* const Sub = yield* Effect.provide(SubComponent.use, context)
|
||||
* const context = yield* Component.useContextFromLayer(MyLayer)
|
||||
* const Sub = yield* SubComponent.use.pipe(
|
||||
* Effect.provide(context)
|
||||
* )
|
||||
*
|
||||
* return <Sub />
|
||||
* })
|
||||
@@ -1085,10 +1049,12 @@ export declare namespace useContext {
|
||||
* @example With memoized layer
|
||||
* ```tsx
|
||||
* const MyComponent = Component.make(function*(props: { id: string })) {
|
||||
* const context = yield* Component.useLayer(
|
||||
* const context = yield* Component.useContextFromLayer(
|
||||
* React.useMemo(() => Layer.succeed(MyService, new MyServiceImpl(props.id)), [props.id])
|
||||
* )
|
||||
* const Sub = yield* Effect.provide(SubComponent.use, context)
|
||||
* const Sub = yield* SubComponent.use.pipe(
|
||||
* Effect.provide(context)
|
||||
* )
|
||||
*
|
||||
* return <Sub />
|
||||
* })
|
||||
@@ -1098,15 +1064,17 @@ export declare namespace useContext {
|
||||
* ```tsx
|
||||
* const MyAsyncLayer = Layer.effect(MyService, someAsyncEffect)
|
||||
* const MyComponent = Component.make(function*() {
|
||||
* const context = yield* Component.useLayer(MyAsyncLayer)
|
||||
* const Sub = yield* Effect.provide(SubComponent.use, context)
|
||||
* 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 useLayer = <ROut, E, RIn>(
|
||||
export const useContextFromLayer = <ROut, E, RIn>(
|
||||
layer: Layer.Layer<ROut, E, RIn>,
|
||||
options?: useContext.Options,
|
||||
): Effect.Effect<Context.Context<ROut>, E, RIn | Scope.Scope> => useOnChange(() => Effect.context<RIn>().pipe(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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, Function, identity, Option, ParseResult, Pipeable, Predicate, Schema, type Scope, Stream } from "effect"
|
||||
import type * as React from "react"
|
||||
import * as Component from "./Component.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"
|
||||
import * as SubscriptionRef from "./SubscriptionRef.js"
|
||||
|
||||
|
||||
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
|
||||
@@ -17,8 +20,8 @@ extends Pipeable.Pipeable {
|
||||
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>
|
||||
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>
|
||||
readonly isValidating: Subscribable.Subscribable<boolean, ER, never>
|
||||
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
|
||||
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
|
||||
readonly canSubmit: Subscribable.Subscribable<boolean, never, never>
|
||||
readonly isSubmitting: Subscribable.Subscribable<boolean, never, never>
|
||||
}
|
||||
|
||||
export class FormImpl<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
|
||||
@@ -31,15 +34,209 @@ extends Pipeable.Class() implements Form<P, A, I, ER, EW> {
|
||||
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>,
|
||||
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>,
|
||||
readonly isValidating: Subscribable.Subscribable<boolean, never, never>,
|
||||
readonly canCommit: Subscribable.Subscribable<boolean, never, never>,
|
||||
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>,
|
||||
readonly canSubmit: Subscribable.Subscribable<boolean, never, never>,
|
||||
readonly isSubmitting: Subscribable.Subscribable<boolean, never, never>,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const RootFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/RootForm")
|
||||
export type RootFormTypeId = typeof RootFormTypeId
|
||||
|
||||
export interface RootForm<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<readonly [], A, I, never, never> {
|
||||
readonly schema: Schema.Schema<A, I, R>
|
||||
readonly context: Context.Context<Scope.Scope | R>
|
||||
readonly mutation: Mutation.Mutation<
|
||||
readonly [value: A, form: RootForm<A, I, R, unknown, unknown, unknown>],
|
||||
MA, ME, MR, MP
|
||||
>
|
||||
readonly autosubmit: boolean
|
||||
|
||||
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 RootFormImpl<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 RootForm<A, I, R, MA, ME, MR, MP> {
|
||||
readonly [FormTypeId]: FormTypeId = FormTypeId
|
||||
readonly [RootFormTypeId]: RootFormTypeId = RootFormTypeId
|
||||
|
||||
readonly path = [] as const
|
||||
|
||||
constructor(
|
||||
readonly schema: Schema.Schema<A, I, R>,
|
||||
readonly context: Context.Context<Scope.Scope | R>,
|
||||
readonly mutation: Mutation.Mutation<
|
||||
readonly [value: A, form: RootForm<A, I, R, unknown, unknown, unknown>],
|
||||
MA, ME, MR, MP
|
||||
>,
|
||||
readonly autosubmit: boolean,
|
||||
|
||||
readonly value: Lens.Lens<Option.Option<A>, never, never, never, never>,
|
||||
readonly encodedValue: 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 isValidating: Subscribable.Subscribable<boolean, never, never>,
|
||||
|
||||
readonly canSubmit: Subscribable.Subscribable<boolean, never, never>,
|
||||
readonly isSubmitting: Subscribable.Subscribable<boolean, never, never>,
|
||||
|
||||
readonly runSemaphore: Effect.Semaphore,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
get run(): Effect.Effect<void> {
|
||||
return this.runSemaphore.withPermits(1)(Stream.runForEach(
|
||||
this.encodedValue.changes,
|
||||
|
||||
encodedValue => Lens.get(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(
|
||||
Lens.set(this.value, Option.some(v)),
|
||||
Lens.set(this.issues, Array.empty()),
|
||||
),
|
||||
onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), {
|
||||
onSome: e => Effect.flatMap(
|
||||
ParseResult.ArrayFormatter.formatError(e),
|
||||
v => Lens.set(this.issues, v),
|
||||
),
|
||||
onNone: () => Effect.void,
|
||||
}),
|
||||
}),
|
||||
Lens.set(this.validationFiber, Option.none()),
|
||||
),
|
||||
)).pipe(
|
||||
Effect.tap(fiber => Lens.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 Lens.get(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 => Effect.flatMap(
|
||||
ParseResult.ArrayFormatter.formatError(e),
|
||||
v => Lens.set(this.issues, v),
|
||||
),
|
||||
onNone: () => Effect.void,
|
||||
},
|
||||
)
|
||||
: Effect.void
|
||||
),
|
||||
this.canSubmit.get,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
|
||||
export const isRootForm = (u: unknown): u is RootForm<readonly PropertyKey[], unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, RootFormTypeId)
|
||||
|
||||
|
||||
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: RootForm<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
|
||||
}
|
||||
}
|
||||
|
||||
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<
|
||||
RootForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
|
||||
never,
|
||||
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
|
||||
> {
|
||||
const mutation = yield* Mutation.make(options)
|
||||
const valueLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<A>()))
|
||||
const issuesLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly ParseResult.ArrayFormatterIssue[]>(Array.empty()))
|
||||
const validationFiberLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>()))
|
||||
|
||||
return new RootFormImpl(
|
||||
options.schema,
|
||||
yield* Effect.context<Scope.Scope | R>(),
|
||||
mutation,
|
||||
options.autosubmit ?? false,
|
||||
|
||||
valueLens,
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)),
|
||||
issuesLens,
|
||||
validationFiberLens,
|
||||
Subscribable.map(validationFiberLens, Option.isSome),
|
||||
|
||||
Subscribable.map(
|
||||
Subscribable.zipLatestAll(valueLens, issuesLens, validationFiberLens, mutation.result),
|
||||
([value, issues, validationFiber, result]) => (
|
||||
Option.isSome(value) &&
|
||||
Array.isEmptyReadonlyArray(issues) &&
|
||||
Option.isNone(validationFiber) &&
|
||||
!(Result.isRunning(result) || Result.hasRefreshingFlag(result))
|
||||
),
|
||||
),
|
||||
Subscribable.map(mutation.result, result => Result.isRunning(result) || Result.hasRefreshingFlag(result)),
|
||||
|
||||
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<
|
||||
RootForm<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),
|
||||
)
|
||||
|
||||
|
||||
const filterIssuesByPath = (
|
||||
@@ -49,7 +246,7 @@ const filterIssuesByPath = (
|
||||
issue.path.length >= path.length && Array.every(path, (p, i) => p === issue.path[i])
|
||||
)
|
||||
|
||||
export const focusObjectOn: {
|
||||
export const focusObjectField: {
|
||||
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
||||
self: Form<P, A, I, ER, EW>,
|
||||
key: K,
|
||||
@@ -67,11 +264,11 @@ export const focusObjectOn: {
|
||||
return new FormImpl(
|
||||
path,
|
||||
Subscribable.mapOption(form.value, a => a[key]),
|
||||
Lens.focusObjectOn(form.encodedValue, key),
|
||||
Lens.focusObjectField(form.encodedValue, key),
|
||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||
form.isValidating,
|
||||
form.canCommit,
|
||||
form.isCommitting,
|
||||
form.canSubmit,
|
||||
form.isSubmitting,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -96,8 +293,8 @@ export const focusArrayAt: {
|
||||
Lens.focusArrayAt(form.encodedValue, index),
|
||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||
form.isValidating,
|
||||
form.canCommit,
|
||||
form.isCommitting,
|
||||
form.canSubmit,
|
||||
form.isSubmitting,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -122,8 +319,8 @@ export const focusTupleAt: {
|
||||
Lens.focusTupleAt(form.encodedValue, index),
|
||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||
form.isValidating,
|
||||
form.canCommit,
|
||||
form.isCommitting,
|
||||
form.canSubmit,
|
||||
form.isSubmitting,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -148,8 +345,8 @@ export const focusChunkAt: {
|
||||
Lens.focusChunkAt(form.encodedValue, index),
|
||||
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||
form.isValidating,
|
||||
form.canCommit,
|
||||
form.isCommitting,
|
||||
form.canSubmit,
|
||||
form.isSubmitting,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -192,7 +389,7 @@ export const useInput = Effect.fnUntraced(function* <P extends readonly Property
|
||||
),
|
||||
internalValue => Lens.set(form.encodedValue, internalValue),
|
||||
),
|
||||
], { concurrency: "unbounded", discard: true }))
|
||||
], { concurrency: "unbounded" }))
|
||||
|
||||
return internalValueLens
|
||||
}), [form, options?.debounce])
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { Effect, Layer, SubscriptionRef } from "effect"
|
||||
import * as React from "react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Component from "./Component.js"
|
||||
import * as Lens from "./Lens.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
|
||||
|
||||
const makeRuntime = async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
return {
|
||||
runtime,
|
||||
effectRuntime,
|
||||
dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
|
||||
}
|
||||
}
|
||||
|
||||
const expectDefined = <A,>(value: A | undefined): A => {
|
||||
if (value === undefined)
|
||||
throw new Error("Expected value to be defined")
|
||||
return value
|
||||
}
|
||||
|
||||
describe("Lens", () => {
|
||||
it("useState stays in sync with lens updates in both directions", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
const ref = await Effect.runPromise(SubscriptionRef.make(0))
|
||||
const lens = Lens.fromSubscriptionRef(ref)
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseStateProbe")(function*() {
|
||||
const [value, setValue] = yield* Lens.useState(lens)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>{value}</div>
|
||||
<button type="button" onClick={() => setValue(previous => previous + 1)}>increment</button>
|
||||
</>
|
||||
)
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("0")
|
||||
|
||||
await Effect.runPromise(Lens.set(lens, 5))
|
||||
await screen.findByText("5")
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "increment" }))
|
||||
await screen.findByText("6")
|
||||
expect(await Effect.runPromise(Lens.get(lens))).toBe(6)
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it("useState respects the provided equivalence when subscribing to lens changes", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
const ref = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
|
||||
const lens = Lens.fromSubscriptionRef(ref)
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseStateEquivalenceProbe")(function*() {
|
||||
const [value] = yield* Lens.useState(lens, {
|
||||
equivalence: (self, that) => self.id === that.id,
|
||||
})
|
||||
|
||||
return <div>{value.label}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
|
||||
await Effect.runPromise(Lens.set(lens, { id: 1, label: "ignored" }))
|
||||
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||
expect(screen.queryByText("ignored")).toBeNull()
|
||||
|
||||
await Effect.runPromise(Lens.set(lens, { id: 2, label: "updated" }))
|
||||
await screen.findByText("updated")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it("useFromReactState writes React state changes into the returned lens", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
let lens: Lens.Lens<string, never, never, never, never> | undefined
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseFromReactStateProbe")(function*() {
|
||||
const [value, setValue] = React.useState("hello")
|
||||
const reactLens = yield* Lens.useFromReactState([value, setValue])
|
||||
|
||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||
lens = reactLens
|
||||
}))
|
||||
|
||||
return <button type="button" onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("hello")
|
||||
await waitFor(() => expect(lens).toBeDefined())
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "hello" }))
|
||||
await screen.findByText("hello!")
|
||||
await waitFor(async () => expect(await Effect.runPromise(Lens.get(expectDefined(lens)))).toBe("hello!"))
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it("useFromReactState respects equivalence when lens updates flow back into React state", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
let lens: Lens.Lens<{ readonly id: number; readonly label: string }, never, never, never, never> | undefined
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseFromReactStateEquivalenceProbe")(function*() {
|
||||
const [value, setValue] = React.useState({ id: 1, label: "first" })
|
||||
const reactLens = yield* Lens.useFromReactState([value, setValue], {
|
||||
equivalence: (self, that) => self.id === that.id,
|
||||
})
|
||||
|
||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||
lens = reactLens
|
||||
}))
|
||||
|
||||
return <div>{value.label}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
await waitFor(() => expect(lens).toBeDefined())
|
||||
|
||||
await Effect.runPromise(Lens.set(expectDefined(lens), { id: 1, label: "ignored" }))
|
||||
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||
expect(screen.queryByText("ignored")).toBeNull()
|
||||
|
||||
await Effect.runPromise(Lens.set(expectDefined(lens), { id: 2, label: "updated" }))
|
||||
await screen.findByText("updated")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Effect, Equivalence, Stream, SubscriptionRef } from "effect"
|
||||
import { Effect, Equivalence, Stream } from "effect"
|
||||
import { Lens } from "effect-lens"
|
||||
import * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
import * as SetStateAction from "./SetStateAction.js"
|
||||
import * as SubscriptionRef from "./SubscriptionRef.js"
|
||||
|
||||
|
||||
export * from "effect-lens/Lens"
|
||||
@@ -37,15 +38,15 @@ export const useState = Effect.fnUntraced(function* <A, ER, EW, RR, RW>(
|
||||
return [reactStateValue, setValue]
|
||||
})
|
||||
|
||||
export declare namespace useFromReactState {
|
||||
export declare namespace useFromState {
|
||||
export interface Options<A> {
|
||||
readonly equivalence?: Equivalence.Equivalence<A>
|
||||
}
|
||||
}
|
||||
|
||||
export const useFromReactState = Effect.fnUntraced(function* <A>(
|
||||
export const useFromState = Effect.fnUntraced(function* <A>(
|
||||
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
|
||||
options?: useFromReactState.Options<NoInfer<A>>,
|
||||
options?: useFromState.Options<NoInfer<A>>,
|
||||
): Effect.fn.Return<Lens.Lens<A, never, never, never, never>> {
|
||||
const lens = yield* Component.useOnMount(() => Effect.map(
|
||||
SubscriptionRef.make(value),
|
||||
|
||||
@@ -61,7 +61,7 @@ export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasP
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export const memoized = <T extends Component.Component.Any>(
|
||||
export const memoized = <T extends Component.Component<any, any, any, any>>(
|
||||
self: T
|
||||
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self),
|
||||
@@ -96,14 +96,14 @@ export const memoized = <T extends Component.Component.Any>(
|
||||
* ```
|
||||
*/
|
||||
export const withOptions: {
|
||||
<T extends Component.Component.Any & Memoized<any>>(
|
||||
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>
|
||||
): (self: T) => T
|
||||
<T extends Component.Component.Any & Memoized<any>>(
|
||||
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||
self: T,
|
||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
|
||||
): T
|
||||
} = Function.dual(2, <T extends Component.Component.Any & Memoized<any>>(
|
||||
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||
self: T,
|
||||
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
|
||||
): T => Object.setPrototypeOf(
|
||||
|
||||
@@ -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 declare namespace make {
|
||||
export interface Options<K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never> {
|
||||
readonly f: (key: K) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Option } from "effect"
|
||||
import * as PropertyPath from "./PropertyPath.js"
|
||||
|
||||
|
||||
describe("immutableSet with arrays", () => {
|
||||
test("sets a top-level array element", () => {
|
||||
const arr = [1, 2, 3]
|
||||
const result = PropertyPath.immutableSet(arr, [1], 99)
|
||||
expect(result).toEqual(Option.some([1, 99, 3]))
|
||||
})
|
||||
|
||||
test("does not mutate the original array", () => {
|
||||
const arr = [1, 2, 3]
|
||||
PropertyPath.immutableSet(arr, [0], 42)
|
||||
expect(arr).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("sets the first element of an array", () => {
|
||||
const arr = ["a", "b", "c"]
|
||||
const result = PropertyPath.immutableSet(arr, [0], "z")
|
||||
expect(result).toEqual(Option.some(["z", "b", "c"]))
|
||||
})
|
||||
|
||||
test("sets the last element of an array", () => {
|
||||
const arr = [10, 20, 30]
|
||||
const result = PropertyPath.immutableSet(arr, [2], 99)
|
||||
expect(result).toEqual(Option.some([10, 20, 99]))
|
||||
})
|
||||
|
||||
test("sets a nested array element inside an object", () => {
|
||||
const obj = { tags: ["foo", "bar", "baz"] }
|
||||
const result = PropertyPath.immutableSet(obj, ["tags", 1], "qux")
|
||||
expect(result).toEqual(Option.some({ tags: ["foo", "qux", "baz"] }))
|
||||
})
|
||||
|
||||
test("sets a deeply nested value inside an array of objects", () => {
|
||||
const obj = { items: [{ name: "alice" }, { name: "bob" }] }
|
||||
const result = PropertyPath.immutableSet(obj, ["items", 0, "name"], "charlie")
|
||||
expect(result).toEqual(Option.some({ items: [{ name: "charlie" }, { name: "bob" }] }))
|
||||
})
|
||||
|
||||
test("sets a value in a nested array", () => {
|
||||
const matrix = [[1, 2], [3, 4]]
|
||||
const result = PropertyPath.immutableSet(matrix, [1, 0], 99)
|
||||
expect(result).toEqual(Option.some([[1, 2], [99, 4]]))
|
||||
})
|
||||
|
||||
test("returns Option.none() for an out-of-bounds index", () => {
|
||||
const arr = [1, 2, 3]
|
||||
const result = PropertyPath.immutableSet(arr, [5], 99)
|
||||
expect(result).toEqual(Option.none())
|
||||
})
|
||||
|
||||
test("returns Option.none() for a non-numeric key on an array", () => {
|
||||
const arr = [1, 2, 3]
|
||||
// @ts-expect-error intentionally wrong key type
|
||||
const result = PropertyPath.immutableSet(arr, ["length"], 0)
|
||||
expect(result).toEqual(Option.none())
|
||||
})
|
||||
|
||||
test("empty path returns Option.some of the value itself", () => {
|
||||
const arr = [1, 2, 3]
|
||||
const result = PropertyPath.immutableSet(arr, [], [9, 9, 9] as any)
|
||||
expect(result).toEqual(Option.some([9, 9, 9]))
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
|
||||
export const useFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
|
||||
export const usePubSubFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
|
||||
values: A
|
||||
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
|
||||
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Effect, Option, type Scope, Stream } from "effect"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Query from "./Query.js"
|
||||
import * as QueryClient from "./QueryClient.js"
|
||||
import * as Result from "./Result.js"
|
||||
|
||||
|
||||
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
|
||||
Effect.runPromise(Effect.scoped(effect.pipe(
|
||||
Effect.provide(QueryClient.QueryClient.Default),
|
||||
)))
|
||||
|
||||
const expectSuccessValue = <A, E, P>(
|
||||
result: Result.Result<A, E, P>,
|
||||
): A => {
|
||||
expect(Result.isSuccess(result)).toBe(true)
|
||||
|
||||
if (!Result.isSuccess(result))
|
||||
throw new Error(`Expected Success result, received ${result._tag}`)
|
||||
|
||||
return result.value
|
||||
}
|
||||
|
||||
const expectSomeValue = <A>(option: Option.Option<A>): A => {
|
||||
expect(Option.isSome(option)).toBe(true)
|
||||
|
||||
if (!Option.isSome(option))
|
||||
throw new Error("Expected Some option, received None")
|
||||
|
||||
return option.value
|
||||
}
|
||||
|
||||
describe("Query", () => {
|
||||
it("fetch caches successful results until they are invalidated or stale", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
const second = yield* query.fetch([1])
|
||||
|
||||
return [first, second] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(result[0]._tag).toBe("Success")
|
||||
expect(result[1]._tag).toBe("Success")
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:1")
|
||||
})
|
||||
|
||||
it("refresh reruns the latest query key", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "0 millis",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
yield* Effect.sleep("1 millis")
|
||||
const refreshed = yield* query.refresh
|
||||
|
||||
return [first, refreshed] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||
})
|
||||
|
||||
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
yield* query.invalidateCacheEntry([1])
|
||||
const second = yield* query.fetch([1])
|
||||
|
||||
return [first, second] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||
})
|
||||
|
||||
it("invalidateCache clears cached entries for the query function", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
yield* query.invalidateCache
|
||||
const second = yield* query.fetch([1])
|
||||
|
||||
return [first, second] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||
})
|
||||
|
||||
it("service starts the key stream automatically and updates latest state", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.make([1] as const) as Stream.Stream<readonly [number]>
|
||||
|
||||
const effect = Effect.gen(function*() {
|
||||
const query = yield* Query.service({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
return {
|
||||
final: yield* query.result.get,
|
||||
latestKey: yield* query.latestKey.get,
|
||||
latestFinalResult: yield* query.latestFinalResult.get,
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runQueryTest(effect)
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(expectSuccessValue(result.final)).toBe("value:1:1")
|
||||
expect(expectSomeValue(result.latestKey)).toEqual([1])
|
||||
expect(expectSuccessValue(expectSomeValue(result.latestFinalResult))).toBe("value:1:1")
|
||||
})
|
||||
})
|
||||
@@ -266,10 +266,8 @@ extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown> => Predicate.hasProperty(u, QueryTypeId)
|
||||
|
||||
|
||||
export declare namespace make {
|
||||
export interface Options<K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never> {
|
||||
readonly key: Stream.Stream<K, KE, KR>
|
||||
|
||||
@@ -1,4 +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, pipe, Pipeable, Predicate, PubSub, Ref, type Scope, Stream, type Subscribable, SynchronizedRef } from "effect"
|
||||
import { Lens } from "effect-lens"
|
||||
|
||||
|
||||
@@ -212,24 +212,24 @@ export const unsafeForkEffect = Effect.fnUntraced(function* <A, E, R, P = never>
|
||||
never,
|
||||
Scope.Scope | unsafeForkEffect.OutputContext<R, P>
|
||||
> {
|
||||
const ref = (yield* SynchronizedRef.make(
|
||||
options?.initial ?? initial<A, E, P>()
|
||||
)) as Lens.SynchronizedRefLensImpl.SynchronizedRefWithInternals<Result<A, E, P>>
|
||||
const ref = yield* SynchronizedRef.make<Result<A, E, P>>(options?.initial ?? initial<A, E, P>())
|
||||
const pubsub = yield* PubSub.unbounded<Result<A, E, P>>()
|
||||
|
||||
const state = Lens.make({
|
||||
get: Ref.get(ref.ref),
|
||||
const state = Lens.make<Result<A, E, P>, never, never, never, never>({
|
||||
get get() { return Ref.get(ref) },
|
||||
get changes() {
|
||||
return Stream.unwrapScoped(Effect.map(
|
||||
Effect.all([Ref.get(ref.ref), Stream.fromPubSub(pubsub, { scoped: true })]),
|
||||
Effect.all([Ref.get(ref), Stream.fromPubSub(pubsub, { scoped: true })]),
|
||||
([latest, stream]) => Stream.concat(Stream.make(latest), stream),
|
||||
))
|
||||
},
|
||||
commit: value => Effect.zipLeft(
|
||||
Ref.set(ref.ref, value),
|
||||
PubSub.publish(pubsub, value),
|
||||
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))
|
||||
)),
|
||||
),
|
||||
lock: Effect.succeed(ref.withLock),
|
||||
})
|
||||
|
||||
const fiber = yield* Effect.gen(function*() {
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
|
||||
|
||||
export const use: {
|
||||
export const useStream: {
|
||||
<A, E, R>(
|
||||
stream: Stream.Stream<A, E, R>
|
||||
): Effect.Effect<Option.Option<A>, never, R>
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import { Array, Cause, Chunk, type Context, Effect, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Schema, type Scope, SubscriptionRef } from "effect"
|
||||
import * as Form from "./Form.js"
|
||||
import * as Lens from "./Lens.js"
|
||||
import * as Mutation from "./Mutation.js"
|
||||
import * as Result from "./Result.js"
|
||||
import * as Subscribable from "./Subscribable.js"
|
||||
|
||||
|
||||
export const SubmittableFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SubmittableForm")
|
||||
export type SubmittableFormTypeId = typeof SubmittableFormTypeId
|
||||
|
||||
export interface SubmittableForm<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
||||
extends Form.Form<readonly [], A, I, never, never> {
|
||||
readonly [SubmittableFormTypeId]: SubmittableFormTypeId
|
||||
|
||||
readonly schema: Schema.Schema<A, I, R>
|
||||
readonly context: Context.Context<Scope.Scope | R>
|
||||
readonly mutation: Mutation.Mutation<
|
||||
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
|
||||
MA, ME, MR, MP
|
||||
>
|
||||
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never>
|
||||
|
||||
readonly run: Effect.Effect<void>
|
||||
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
|
||||
}
|
||||
|
||||
export class SubmittableFormImpl<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
||||
extends Pipeable.Class() implements SubmittableForm<A, I, R, MA, ME, MR, MP> {
|
||||
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
|
||||
readonly [SubmittableFormTypeId]: SubmittableFormTypeId = SubmittableFormTypeId
|
||||
|
||||
readonly path = [] as const
|
||||
|
||||
readonly encodedValue: Lens.Lens<I, never, never, never, never>
|
||||
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
|
||||
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
|
||||
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
|
||||
|
||||
constructor(
|
||||
readonly schema: Schema.Schema<A, I, R>,
|
||||
readonly context: Context.Context<Scope.Scope | R>,
|
||||
readonly mutation: Mutation.Mutation<
|
||||
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
|
||||
MA, ME, MR, MP
|
||||
>,
|
||||
readonly value: Lens.Lens<Option.Option<A>, never, never, never, never>,
|
||||
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
|
||||
readonly issues: Lens.Lens<readonly ParseResult.ArrayFormatterIssue[], never, never, never, never>,
|
||||
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never, never, never>,
|
||||
|
||||
readonly runSemaphore: Effect.Semaphore,
|
||||
) {
|
||||
super()
|
||||
|
||||
this.encodedValue = Effect.all([
|
||||
Effect.succeed(this),
|
||||
Effect.succeed(Lens.asLensImpl(this.internalEncodedValue)),
|
||||
]).pipe(
|
||||
Effect.map(([self, parent]) => Lens.make({
|
||||
get: parent.get,
|
||||
get changes() { return parent.changes },
|
||||
commit: a => Effect.andThen(
|
||||
Effect.flatMap(
|
||||
parent.resolve,
|
||||
resolved => resolved.commit(Effect.succeed(a)),
|
||||
),
|
||||
self.synchronizeEncodedValue(a),
|
||||
),
|
||||
lock: parent.lock,
|
||||
})),
|
||||
Lens.unwrap,
|
||||
)
|
||||
this.isValidating = Effect.succeed(this).pipe(
|
||||
Effect.map(self => Subscribable.map(self.validationFiber, Option.isSome)),
|
||||
Subscribable.unwrap,
|
||||
)
|
||||
this.canCommit = Effect.succeed(this).pipe(
|
||||
Effect.map(self => Subscribable.map(
|
||||
Subscribable.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.result),
|
||||
([value, issues, validationFiber, result]) => (
|
||||
Option.isSome(value) &&
|
||||
Array.isEmptyReadonlyArray(issues) &&
|
||||
Option.isNone(validationFiber) &&
|
||||
!(Result.isRunning(result) || Result.hasRefreshingFlag(result))
|
||||
),
|
||||
)),
|
||||
Subscribable.unwrap,
|
||||
)
|
||||
this.isCommitting = Effect.succeed(this).pipe(
|
||||
Effect.map(self => Subscribable.map(
|
||||
self.mutation.result,
|
||||
result => Result.isRunning(result) || Result.hasRefreshingFlag(result),
|
||||
)),
|
||||
Subscribable.unwrap,
|
||||
)
|
||||
}
|
||||
|
||||
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, never, never> {
|
||||
return Lens.get(this.validationFiber).pipe(
|
||||
Effect.andThen(Option.match({
|
||||
onSome: Fiber.interrupt,
|
||||
onNone: () => Effect.void,
|
||||
})),
|
||||
Effect.andThen(Effect.forkScoped(
|
||||
Effect.ensuring(
|
||||
Schema.decode(this.schema, { errors: "all" })(encodedValue),
|
||||
Lens.set(this.validationFiber, Option.none()),
|
||||
)
|
||||
)),
|
||||
Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
|
||||
Effect.flatMap(Fiber.join),
|
||||
|
||||
Effect.tap(() => Lens.set(this.issues, Array.empty())),
|
||||
Effect.flatMap(value => Lens.set(this.value, Option.some(value))),
|
||||
Effect.catchIf(
|
||||
ParseResult.isParseError,
|
||||
flow(
|
||||
ParseResult.ArrayFormatter.formatError,
|
||||
Effect.flatMap(v => Lens.set(this.issues, v)),
|
||||
),
|
||||
),
|
||||
|
||||
Effect.provide(this.context),
|
||||
)
|
||||
}
|
||||
|
||||
get run(): Effect.Effect<void, never, never> {
|
||||
return Lens.get(this.encodedValue).pipe(
|
||||
Effect.flatMap(v => Schema.decode(this.schema)(v)),
|
||||
Effect.option,
|
||||
Effect.flatMap(v => Lens.set(this.value, v)),
|
||||
Effect.provide(this.context),
|
||||
this.runSemaphore.withPermits(1),
|
||||
)
|
||||
}
|
||||
|
||||
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException, never> {
|
||||
return Lens.get(this.value).pipe(
|
||||
Effect.flatMap(identity),
|
||||
Effect.flatMap(value => this.submitValue(value)),
|
||||
)
|
||||
}
|
||||
|
||||
submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, never, never> {
|
||||
return Effect.whenEffect(
|
||||
Effect.tap(
|
||||
this.mutation.mutate([value, this as any]),
|
||||
result => Result.isFailure(result)
|
||||
? Option.match(
|
||||
Chunk.findFirst(
|
||||
Cause.failures(result.cause as Cause.Cause<ParseResult.ParseError>),
|
||||
e => e._tag === "ParseError",
|
||||
),
|
||||
{
|
||||
onSome: e => Effect.flatMap(
|
||||
ParseResult.ArrayFormatter.formatError(e),
|
||||
v => Lens.set(this.issues, v),
|
||||
),
|
||||
onNone: () => Effect.void,
|
||||
},
|
||||
)
|
||||
: Effect.void
|
||||
),
|
||||
this.canCommit.get,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const isSubmittableForm = (u: unknown): u is SubmittableForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SubmittableFormTypeId)
|
||||
|
||||
|
||||
export declare namespace make {
|
||||
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
||||
extends Mutation.make.Options<
|
||||
readonly [value: NoInfer<A>, form: SubmittableForm<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>],
|
||||
MA, ME, MR, MP
|
||||
> {
|
||||
readonly schema: Schema.Schema<A, I, R>
|
||||
readonly initialEncodedValue: NoInfer<I>
|
||||
}
|
||||
}
|
||||
|
||||
export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
|
||||
options: make.Options<A, I, R, MA, ME, MR, MP>
|
||||
): Effect.fn.Return<
|
||||
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
|
||||
never,
|
||||
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
|
||||
> {
|
||||
return new SubmittableFormImpl(
|
||||
options.schema,
|
||||
yield* Effect.context<Scope.Scope | R>(),
|
||||
yield* Mutation.make(options),
|
||||
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<A>())),
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)),
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly ParseResult.ArrayFormatterIssue[]>(Array.empty())),
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>())),
|
||||
|
||||
yield* Effect.makeSemaphore(1),
|
||||
)
|
||||
})
|
||||
|
||||
export declare namespace service {
|
||||
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
|
||||
extends make.Options<A, I, R, MA, ME, MR, MP> {}
|
||||
}
|
||||
|
||||
export const service = <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
|
||||
options: service.Options<A, I, R, MA, ME, MR, MP>
|
||||
): Effect.Effect<
|
||||
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
|
||||
never,
|
||||
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
|
||||
> => Effect.tap(
|
||||
make(options),
|
||||
form => Effect.forkScoped(form.run),
|
||||
)
|
||||
@@ -1,129 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { Effect, Fiber, Layer, Stream, SubscriptionRef } from "effect"
|
||||
import { Lens } from "effect-lens"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Component from "./Component.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
import * as Subscribable from "./Subscribable.js"
|
||||
|
||||
|
||||
const makeRuntime = async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
return {
|
||||
runtime,
|
||||
effectRuntime,
|
||||
dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
|
||||
}
|
||||
}
|
||||
|
||||
describe("Subscribable", () => {
|
||||
it("zipLatestAll reads current values from all inputs", async () => {
|
||||
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
|
||||
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
|
||||
const left = Lens.fromSubscriptionRef(leftRef)
|
||||
const right = Lens.fromSubscriptionRef(rightRef)
|
||||
|
||||
const zipped = Subscribable.zipLatestAll(left, right)
|
||||
|
||||
expect(await Effect.runPromise(zipped.get)).toEqual([1, "a"])
|
||||
})
|
||||
|
||||
it("zipLatestAll emits updates when any input changes", async () => {
|
||||
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
|
||||
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
|
||||
const left = Lens.fromSubscriptionRef(leftRef)
|
||||
const right = Lens.fromSubscriptionRef(rightRef)
|
||||
|
||||
const zipped = Subscribable.zipLatestAll(left, right)
|
||||
const values: Array<readonly [number, string]> = []
|
||||
|
||||
const collector = Effect.runFork(Effect.scoped(zipped.changes.pipe(
|
||||
Stream.runForEach(value => Effect.sync(() => {
|
||||
values.push(value as readonly [number, string])
|
||||
})),
|
||||
)))
|
||||
|
||||
await Effect.runPromise(Lens.set(left, 2))
|
||||
await waitFor(() => expect(values).toContainEqual([2, "a"]))
|
||||
|
||||
await Effect.runPromise(Lens.set(right, "b"))
|
||||
await waitFor(() => expect(values).toContainEqual([2, "b"]))
|
||||
|
||||
Fiber.interruptFork(collector)
|
||||
})
|
||||
|
||||
it("useAll returns the latest values and rerenders when any input changes", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
const countRef = await Effect.runPromise(SubscriptionRef.make(1))
|
||||
const labelRef = await Effect.runPromise(SubscriptionRef.make("a"))
|
||||
const count = Lens.fromSubscriptionRef(countRef)
|
||||
const label = Lens.fromSubscriptionRef(labelRef)
|
||||
|
||||
const Probe = Component.makeUntraced("SubscribableUseAllProbe")(function*() {
|
||||
const [currentCount, currentLabel] = yield* Subscribable.useAll([count, label])
|
||||
|
||||
return <div>{`${currentCount}:${currentLabel}`}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("1:a")
|
||||
|
||||
await Effect.runPromise(Lens.set(count, 2))
|
||||
await screen.findByText("2:a")
|
||||
|
||||
await Effect.runPromise(Lens.set(label, "b"))
|
||||
await screen.findByText("2:b")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it("useAll respects the provided equivalence when processing updates", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
const itemRef = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
|
||||
const flagRef = await Effect.runPromise(SubscriptionRef.make(true))
|
||||
const item = Lens.fromSubscriptionRef(itemRef)
|
||||
const flag = Lens.fromSubscriptionRef(flagRef)
|
||||
|
||||
const Probe = Component.makeUntraced("SubscribableUseAllEquivalenceProbe")(function*() {
|
||||
const [currentItem, currentFlag] = yield* Subscribable.useAll([item, flag], {
|
||||
equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) =>
|
||||
selfItem.id === thatItem.id && selfFlag === thatFlag,
|
||||
})
|
||||
|
||||
return <div>{`${currentItem.label}:${currentFlag ? "on" : "off"}`}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first:on")
|
||||
|
||||
await Effect.runPromise(Lens.set(item, { id: 1, label: "ignored" }))
|
||||
await waitFor(() => expect(screen.getByText("first:on")).toBeTruthy())
|
||||
expect(screen.queryByText("ignored:on")).toBeNull()
|
||||
|
||||
await Effect.runPromise(Lens.set(flag, false))
|
||||
await screen.findByText("ignored:off")
|
||||
|
||||
await Effect.runPromise(Lens.set(item, { id: 2, label: "updated" }))
|
||||
await screen.findByText("updated:off")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||