Update dependency typescript to v7 - autoclosed #54
@@ -21,6 +21,6 @@
|
|||||||
"npm-check-updates": "^22.2.1",
|
"npm-check-updates": "^22.2.1",
|
||||||
"npm-sort": "^0.0.4",
|
"npm-sort": "^0.0.4",
|
||||||
"turbo": "^2.9.16",
|
"turbo": "^2.9.16",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^7.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,17 @@
|
|||||||
# Website
|
# effect-fc Docs
|
||||||
|
|
||||||
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
|
The documentation site is built with Docusaurus.
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn
|
|
||||||
```
|
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn start
|
bun run --cwd packages/docs 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
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn build
|
bun run --cwd packages/docs build
|
||||||
```
|
```
|
||||||
|
|
||||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
The static site is written to `packages/docs/build`.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
---
|
|
||||||
slug: first-blog-post
|
|
||||||
title: First Blog Post
|
|
||||||
authors: [slorber, yangshun]
|
|
||||||
tags: [hola, docusaurus]
|
|
||||||
---
|
|
||||||
|
|
||||||
Lorem ipsum dolor sit amet...
|
|
||||||
|
|
||||||
...consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
|
Before Width: | Height: | Size: 94 KiB |
@@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 3
|
||||||
|
title: Forms
|
||||||
|
---
|
||||||
|
|
||||||
|
# Forms
|
||||||
|
|
||||||
|
The form modules provide an Effect-first model for editable, schema-validated
|
||||||
|
state. They are not a visual component library. Instead, they give you
|
||||||
|
Lenses/Subscribables for form values, validation issues, and commit state, so
|
||||||
|
you can build the UI with whatever components your app already uses.
|
||||||
|
|
||||||
|
The base `Form` type represents a field. A field tracks both the raw encoded
|
||||||
|
value used by inputs and the decoded value produced by an Effect `Schema`:
|
||||||
|
|
||||||
|
- `encodedValue`: a `Lens` containing the raw input value.
|
||||||
|
- `value`: a `Subscribable` containing the decoded value as an `Option`.
|
||||||
|
- `issues`: a `Subscribable` containing schema validation issues for that field.
|
||||||
|
- `canCommit`, `isValidating`, and `isCommitting`: `Subscribable` flags for UI
|
||||||
|
state.
|
||||||
|
|
||||||
|
There are two root form flavors:
|
||||||
|
|
||||||
|
- `SubmittableForm`: owns local encoded form state, validates it, and submits a
|
||||||
|
decoded value through a mutation.
|
||||||
|
- `SynchronizedForm`: wraps an existing target `Lens` and writes valid changes
|
||||||
|
back to that target.
|
||||||
|
|
||||||
|
Most apps create one of those root forms, then focus it into fields with
|
||||||
|
`Form.focusObjectOn`, `Form.focusArrayAt`, or the other focusing helpers.
|
||||||
|
|
||||||
|
The core form model is Effect code and can technically be used outside
|
||||||
|
Effect-FC. Effect-FC adds the React-facing hooks, such as `Form.useInput`, that
|
||||||
|
make those forms convenient to bind to components.
|
||||||
|
|
||||||
|
## Field Inputs
|
||||||
|
|
||||||
|
Use `Form.useInput` to connect a `Form` field to a controlled input. It returns
|
||||||
|
a React-style `{ value, setValue }` pair and writes changes back to the field's
|
||||||
|
`encodedValue` Lens.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Component, Form, Subscribable } from "effect-fc"
|
||||||
|
|
||||||
|
const TextInputView = Component.make("TextInput")(
|
||||||
|
function* (props: {
|
||||||
|
readonly form: Form.Form<readonly PropertyKey[], string, string>
|
||||||
|
readonly label: string
|
||||||
|
}) {
|
||||||
|
const input = yield* Form.useInput(props.form, {
|
||||||
|
debounce: "250 millis",
|
||||||
|
})
|
||||||
|
const [issues] = yield* Subscribable.useAll([props.form.issues])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
{props.label}
|
||||||
|
<input
|
||||||
|
value={input.value}
|
||||||
|
onChange={(event) => input.setValue(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<small key={issue.path.join(".")}>
|
||||||
|
{issue.message}
|
||||||
|
</small>
|
||||||
|
))}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `Form.useOptionalInput` for `Option` fields. It returns the same value/setter
|
||||||
|
pair plus `enabled` and `setEnabled`, which is useful for optional inputs that
|
||||||
|
can be toggled on and off.
|
||||||
|
|
||||||
|
## Submittable Forms
|
||||||
|
|
||||||
|
Use `SubmittableForm` when the user edits local encoded form state, validates it
|
||||||
|
with a schema, and then submits a decoded value.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
|
||||||
|
import { TextInputView } from "./TextInputView"
|
||||||
|
|
||||||
|
const RegisterSchema = Schema.Struct({
|
||||||
|
email: Schema.String,
|
||||||
|
password: Schema.String,
|
||||||
|
})
|
||||||
|
|
||||||
|
class RegisterFormState extends Effect.Service<RegisterFormState>()(
|
||||||
|
"RegisterFormState",
|
||||||
|
{
|
||||||
|
scoped: Effect.gen(function* () {
|
||||||
|
const form = yield* SubmittableForm.service({
|
||||||
|
schema: RegisterSchema,
|
||||||
|
initialEncodedValue: {
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
f: ([value]) =>
|
||||||
|
Effect.log(`Registering ${value.email}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
email: Form.focusObjectOn(form, "email"),
|
||||||
|
password: Form.focusObjectOn(form, "password"),
|
||||||
|
} as const
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
const RegisterFormView = Component.make("RegisterForm")(
|
||||||
|
function* () {
|
||||||
|
const state = yield* RegisterFormState
|
||||||
|
const [canCommit, isCommitting] = yield* Subscribable.useAll([
|
||||||
|
state.form.canCommit,
|
||||||
|
state.form.isCommitting,
|
||||||
|
])
|
||||||
|
const runPromise = yield* Component.useRunPromise()
|
||||||
|
const TextInput = yield* TextInputView.use
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
void runPromise(state.form.submit)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput label="Email" form={state.email} />
|
||||||
|
<TextInput label="Password" form={state.password} />
|
||||||
|
<button disabled={!canCommit}>
|
||||||
|
{isCommitting ? "Submitting..." : "Submit"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`SubmittableForm.service` starts validation in the form's scope. The form keeps
|
||||||
|
its encoded input state, decoded value, validation issues, submit mutation, and
|
||||||
|
commit flags available as Lenses/Subscribables.
|
||||||
|
|
||||||
|
## Synchronized Forms
|
||||||
|
|
||||||
|
Use `SynchronizedForm` when a form should edit an existing target `Lens`.
|
||||||
|
Instead of submitting a final value, valid encoded changes are synchronized back
|
||||||
|
to the target Lens.
|
||||||
|
|
||||||
|
This is useful for edit screens where the form is a validated view over existing
|
||||||
|
state. Use `SubmittableForm` for "submit this draft" flows, and
|
||||||
|
`SynchronizedForm` for "keep this target state synchronized when valid" flows.
|
||||||
|
|
||||||
|
## Focusing Fields
|
||||||
|
|
||||||
|
Forms can be focused just like Lenses:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const emailField = Form.focusObjectOn(form, "email")
|
||||||
|
const firstItemField = Form.focusArrayAt(form, 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Focused fields keep the parent form's validation and commit state, but narrow
|
||||||
|
`encodedValue`, `value`, and `issues` to the field path. This lets each input
|
||||||
|
component receive only the field it needs.
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 1
|
||||||
|
title: Getting Started
|
||||||
|
---
|
||||||
|
|
||||||
|
# Getting Started
|
||||||
|
|
||||||
|
`effect-fc` lets React components be written as Effect programs. Inside a
|
||||||
|
component body you can yield services, run Effects, subscribe to Effect-powered
|
||||||
|
state, and still export a normal React function component at the edge of your
|
||||||
|
app.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Install `effect-fc` alongside `effect` and React 19.2 or newer:
|
||||||
|
|
||||||
|
```bash npm2yarn
|
||||||
|
npm install effect-fc effect react
|
||||||
|
```
|
||||||
|
```bash npm2yarn
|
||||||
|
npm install --save-dev @types/react
|
||||||
|
```
|
||||||
|
|
||||||
|
`effect-fc` is not opinionated about the React platform. Use it with web,
|
||||||
|
native, custom renderers, or any environment where React components can run.
|
||||||
|
Then install the platform-specific React packages for your target.
|
||||||
|
|
||||||
|
For web apps, install React DOM:
|
||||||
|
|
||||||
|
```bash npm2yarn
|
||||||
|
npm install react-dom
|
||||||
|
```
|
||||||
|
```bash npm2yarn
|
||||||
|
npm install --save-dev @types/react-dom
|
||||||
|
```
|
||||||
|
|
||||||
|
## Create A Runtime
|
||||||
|
|
||||||
|
An Effect-FC app needs an Effect runtime. Build one from the services your UI
|
||||||
|
needs, then share it with React through `ReactRuntime.Provider`.
|
||||||
|
|
||||||
|
For an empty app, `Layer.empty` is enough:
|
||||||
|
|
||||||
|
```tsx title="src/runtime.ts"
|
||||||
|
import { Layer } from "effect"
|
||||||
|
import { ReactRuntime } from "effect-fc"
|
||||||
|
|
||||||
|
export const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
```
|
||||||
|
|
||||||
|
As your app grows, add services to the layer:
|
||||||
|
|
||||||
|
```tsx title="src/runtime.ts"
|
||||||
|
import { FetchHttpClient } from "@effect/platform"
|
||||||
|
import { Layer } from "effect"
|
||||||
|
import { ReactRuntime } from "effect-fc"
|
||||||
|
|
||||||
|
const AppLive = Layer.empty.pipe(
|
||||||
|
Layer.provideMerge(FetchHttpClient.layer),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const runtime = ReactRuntime.make(AppLive)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Provide The Runtime
|
||||||
|
|
||||||
|
At the React root, wrap your app with `ReactRuntime.Provider`:
|
||||||
|
|
||||||
|
```tsx title="src/main.tsx"
|
||||||
|
import { StrictMode } from "react"
|
||||||
|
import { createRoot } from "react-dom/client"
|
||||||
|
import { ReactRuntime } from "effect-fc"
|
||||||
|
import { App } from "./App"
|
||||||
|
import { runtime } from "./runtime"
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ReactRuntime.Provider runtime={runtime}>
|
||||||
|
<App />
|
||||||
|
</ReactRuntime.Provider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ReactRuntime.Provider` also works with routers. Keep it above your router
|
||||||
|
provider so route components can be converted with the same runtime context.
|
||||||
|
|
||||||
|
## Write Your First Component
|
||||||
|
|
||||||
|
Use `Component.make` when you want automatic tracing spans, or
|
||||||
|
`Component.makeUntraced` when you only want the component behavior. This creates
|
||||||
|
an Effect-FC component, not a plain React component yet.
|
||||||
|
|
||||||
|
The Effect run during render must be synchronous. Effect-FC follows React's
|
||||||
|
render model, so component bodies should produce JSX without waiting on async
|
||||||
|
work. Use lifecycle hooks, callbacks, queries, or other Effect-FC helpers for
|
||||||
|
async work that happens outside render.
|
||||||
|
|
||||||
|
```tsx title="src/HelloView.tsx"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Component } from "effect-fc"
|
||||||
|
|
||||||
|
export const HelloView = Component.make("HelloView")(function* (props: {
|
||||||
|
readonly name: string
|
||||||
|
}) {
|
||||||
|
const message = yield* Effect.succeed(`Hello, ${props.name}`)
|
||||||
|
|
||||||
|
return <h1>{message}</h1>
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
At this point `HelloView` can yield Effects, services, and scoped lifecycle
|
||||||
|
work, but React cannot render it directly.
|
||||||
|
|
||||||
|
## Apply A Runtime
|
||||||
|
|
||||||
|
Use `Component.withRuntime` at the boundary where an Effect-FC component needs
|
||||||
|
to become a regular React function component.
|
||||||
|
|
||||||
|
```tsx title="src/Hello.tsx"
|
||||||
|
import { Component } from "effect-fc"
|
||||||
|
import { HelloView } from "./HelloView"
|
||||||
|
import { runtime } from "./runtime"
|
||||||
|
|
||||||
|
export const Hello = HelloView.pipe(
|
||||||
|
Component.withRuntime(runtime.context),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Hello` is now a normal React component:
|
||||||
|
|
||||||
|
```tsx title="src/App.tsx"
|
||||||
|
import { Hello } from "./Hello"
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return <Hello name="Effect" />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Effect-FC Components Together
|
||||||
|
|
||||||
|
Inside an Effect-FC component, other Effect-FC components are available through
|
||||||
|
their `.use` effect. Yield `.use` to get a React component for the current
|
||||||
|
runtime context, then render it like JSX.
|
||||||
|
|
||||||
|
```tsx title="src/GreetingCardView.tsx"
|
||||||
|
import { Component } from "effect-fc"
|
||||||
|
import { HelloView } from "./HelloView"
|
||||||
|
|
||||||
|
export const GreetingCardView = Component.make("GreetingCard")(
|
||||||
|
function* () {
|
||||||
|
const Hello = yield* HelloView.use
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<Hello name="Effect" />
|
||||||
|
<p>This component is still running inside Effect-FC.</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `Component.withRuntime` only when you leave the Effect-FC tree and need a
|
||||||
|
normal React component again:
|
||||||
|
|
||||||
|
```tsx title="src/GreetingCard.tsx"
|
||||||
|
import { Component } from "effect-fc"
|
||||||
|
import { GreetingCardView } from "./GreetingCardView"
|
||||||
|
import { runtime } from "./runtime"
|
||||||
|
|
||||||
|
export const GreetingCard = GreetingCardView.pipe(
|
||||||
|
Component.withRuntime(runtime.context),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Lifecycle
|
||||||
|
|
||||||
|
Every Effect-FC component instance exposes an Effect `Scope`. Effects that need
|
||||||
|
`Scope.Scope` can use that component scope to register finalizers, fork scoped
|
||||||
|
work, or acquire scoped resources.
|
||||||
|
|
||||||
|
The component scope is created when React mounts the component and closes when
|
||||||
|
React unmounts it. When the scope closes, Effect runs the finalizers registered
|
||||||
|
inside that scope.
|
||||||
|
|
||||||
|
Finalizers are forked when the scope closes, so cleanup logic can run
|
||||||
|
asynchronous Effects even though the component body itself must stay
|
||||||
|
synchronous.
|
||||||
|
|
||||||
|
```tsx title="src/MountedMessageView.tsx"
|
||||||
|
import { Console, Effect } from "effect"
|
||||||
|
import { Component } from "effect-fc"
|
||||||
|
|
||||||
|
export const MountedMessageView = Component.make("MountedMessage")(
|
||||||
|
function* () {
|
||||||
|
const message = yield* Component.useOnMount(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Console.log("MountedMessage mounted")
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Console.log("MountedMessage unmounted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return "This value was loaded on mount."
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return <p>{message}</p>
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `Component.useOnMount` when the component needs a value produced by an
|
||||||
|
Effect during its first render. The value is then cached for the component
|
||||||
|
instance. You can also use it to set up scoped component logic, subscriptions,
|
||||||
|
or resources that should live for the lifetime of that component instance.
|
||||||
|
|
||||||
|
Use `Component.useOnChange` when render needs the value computed by
|
||||||
|
scoped work that depends on changing inputs. When a dependency changes, React
|
||||||
|
re-renders the component and the hook computes the next value during that
|
||||||
|
render.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Console, Effect } from "effect"
|
||||||
|
import { Component } from "effect-fc"
|
||||||
|
|
||||||
|
const UserPanelView = Component.make("UserPanel")(
|
||||||
|
function* (props: { readonly userId: string }) {
|
||||||
|
const label = yield* Component.useOnChange(
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Console.log(`Preparing view for ${props.userId}`)
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Console.log(`Cleaning up ${props.userId}`),
|
||||||
|
)
|
||||||
|
|
||||||
|
return `Viewing user ${props.userId}`
|
||||||
|
}),
|
||||||
|
[props.userId],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <p>{label}</p>
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example, each `userId` gets its own scope. When `userId` changes,
|
||||||
|
Effect-FC closes the previous scope, runs its finalizers, and creates a new
|
||||||
|
scope for the next load. Unlike `useOnMount`, `useOnChange` does not expose the
|
||||||
|
component's root scope directly. It creates and provides its own scope for that
|
||||||
|
dependency window. Some other Effect-FC hooks follow the same pattern when they
|
||||||
|
need a lifecycle that is narrower than the whole component instance.
|
||||||
|
|
||||||
|
## Useful Effect-FC Hooks
|
||||||
|
|
||||||
|
Unlike plain React hooks, Effect-FC hooks return Effects. Use `yield*` to run
|
||||||
|
them inside the component body.
|
||||||
|
|
||||||
|
The most common Effect-FC hooks are:
|
||||||
|
|
||||||
|
- `Component.useOnMount`: run an Effect once during the component's first render
|
||||||
|
after mount, and return its value. The Effect must be synchronous.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const initialData = yield* Component.useOnMount(() => loadInitialData)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Component.useOnChange`: when a dependency changes, React re-renders the
|
||||||
|
component and the Effect is run again inside the component body. It returns
|
||||||
|
the latest value, so the Effect must be synchronous too.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const label = yield* Component.useOnChange(() => formatUserLabel(id), [id])
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Component.useReactEffect`: Effect-powered `React.useEffect` for side effects
|
||||||
|
that do not need to compute render output or trigger a re-render. The Effect
|
||||||
|
must be synchronous and can register scoped finalizers.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
yield* Component.useReactEffect(
|
||||||
|
() => Effect.forkScoped(subscribeToUser(id)),
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Component.useReactLayoutEffect`: Effect-powered `React.useLayoutEffect` with scoped
|
||||||
|
finalizers.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
yield* Component.useReactLayoutEffect(() => measure(ref), [])
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Component.useRunSync` and `Component.useRunPromise`: run Effects from React
|
||||||
|
event handlers.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const runPromise = yield* Component.useRunPromise()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={() => void runPromise(saveUser)}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Component.useCallbackSync` and `Component.useCallbackPromise`: create stable
|
||||||
|
React callbacks.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const save = yield* Component.useCallbackPromise(
|
||||||
|
(user: User) => saveUser(user),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <button onClick={() => void save(user)}>Save</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use React Normally
|
||||||
|
|
||||||
|
An Effect-FC component is still a React function component. Anything you can do
|
||||||
|
in a regular React component can also be done in an Effect-FC component,
|
||||||
|
including React hooks, refs, event handlers, context, and JSX composition.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Component } from "effect-fc"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const CounterView = Component.make("Counter")(function* () {
|
||||||
|
const [count, setCount] = React.useState(0)
|
||||||
|
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button ref={buttonRef} onClick={() => setCount(count + 1)}>
|
||||||
|
Count: {count}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Use regular React hooks for local UI concerns, and reach for Effect-FC helpers
|
||||||
|
when the component needs Effect services, scopes, resources, or Effect-powered
|
||||||
|
callbacks.
|
||||||
|
|
||||||
|
## Use Tags
|
||||||
|
|
||||||
|
Components can yield Effect tags directly in the component body. There is no
|
||||||
|
need to memoize the service yourself; just yield the tag wherever the component
|
||||||
|
needs it.
|
||||||
|
|
||||||
|
```tsx title="src/Greeting.tsx"
|
||||||
|
const GreetingView = Component.make("Greeting")(function* (props: {
|
||||||
|
readonly name: string
|
||||||
|
}) {
|
||||||
|
const greeting = yield* GreetingService
|
||||||
|
|
||||||
|
return <p>{greeting.greet(props.name)}</p>
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Service values in the runtime context are reactive by default. If a provided
|
||||||
|
service instance changes, Effect-FC unmounts and mounts again the components
|
||||||
|
that depend on that context, so they read the new service and restart their
|
||||||
|
scoped lifecycle with the new environment. For services that should not trigger
|
||||||
|
that behavior, pass their tags with `Component.withOptions({ nonReactiveTags:
|
||||||
|
[...] })`.
|
||||||
|
|
||||||
|
## Provide Services To A Subtree
|
||||||
|
|
||||||
|
Use `Component.useContextFromLayer` when an Effect-FC component should provide
|
||||||
|
extra services to another Effect-FC component. It turns a `Layer` into a runtime
|
||||||
|
context that can be provided to `.use`.
|
||||||
|
|
||||||
|
```tsx title="src/GreetingPageView.tsx"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Component } from "effect-fc"
|
||||||
|
import { GreetingView } from "./Greeting"
|
||||||
|
import { GreetingService } from "./services"
|
||||||
|
|
||||||
|
const GreetingLive = GreetingService.Default({
|
||||||
|
greet: (name: string) => `Welcome, ${name}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GreetingPageView = Component.make("GreetingPage")(function* () {
|
||||||
|
const context = yield* Component.useContextFromLayer(GreetingLive)
|
||||||
|
const Greeting = yield* Effect.provide(GreetingView.use, context)
|
||||||
|
|
||||||
|
return <Greeting name="Effect" />
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The layer passed to `useContextFromLayer` should be stable. If a new layer value
|
||||||
|
is created on every render, Effect-FC has to build a new context, which can
|
||||||
|
cause unnecessary re-renders and scoped lifecycle restarts. Define static layers
|
||||||
|
outside the component, or memoize layers that depend on React props or state.
|
||||||
|
|
||||||
|
Layers built with `useContextFromLayer` are scoped to the component that builds
|
||||||
|
them. That means service construction can register scoped logic, acquire
|
||||||
|
resources, fork scoped fibers, and add finalizers that are tied to that
|
||||||
|
component's lifecycle.
|
||||||
|
|
||||||
|
## Where To Go Next
|
||||||
|
|
||||||
|
Once the runtime and component boundary are in place, the rest of the library
|
||||||
|
builds on the same idea:
|
||||||
|
|
||||||
|
- `Subscribable.useAll` reads Effect subscribables and rerenders when they
|
||||||
|
change.
|
||||||
|
- `Lens` connects React state and Effect `SubscriptionRef` values.
|
||||||
|
- `Query` and `Mutation` model async data and user-triggered operations.
|
||||||
|
- `Form`, `SubmittableForm`, and `SynchronizedForm` help build Effect-backed
|
||||||
|
forms.
|
||||||
|
|
||||||
|
The important pattern is small and repeatable: write Effect-FC components inside
|
||||||
|
the runtime, then use `Component.withRuntime` at React boundaries.
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 2
|
||||||
|
title: State Management
|
||||||
|
---
|
||||||
|
|
||||||
|
# State Management
|
||||||
|
|
||||||
|
`Lens` is the main type used for state management in `effect-fc`.
|
||||||
|
|
||||||
|
A Lens is an effectful handle to a piece of state. It can read the current value,
|
||||||
|
subscribe to changes, and write updates back to the underlying source. A Lens
|
||||||
|
can point at a whole state object or focus on one nested field inside it.
|
||||||
|
|
||||||
|
The usual pattern is to create state with Effect primitives such as
|
||||||
|
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor
|
||||||
|
such as `Lens.fromSubscriptionRef`, and bind Lens values into components with
|
||||||
|
`Subscribable.useAll`.
|
||||||
|
|
||||||
|
`Subscribable` is the read-only side of this model. Every Lens is also a
|
||||||
|
Subscribable.
|
||||||
|
|
||||||
|
`effect-fc` re-exports the `Lens` and `Subscribable` modules from
|
||||||
|
[`effect-lens`](https://github.com/Thiladev/effect-lens) for convenience. The
|
||||||
|
core data model and transformation APIs belong to `effect-lens`, so check the
|
||||||
|
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens) for the full Lens/Subscribable API.
|
||||||
|
|
||||||
|
## Where To Store State
|
||||||
|
|
||||||
|
State can live pretty much anywhere as a `Lens` or `Subscribable`: in a
|
||||||
|
service, in a layer, in a component scope, or alongside plain React state. Pick
|
||||||
|
the owner based on who needs the state. Once you have a Lens/Subscribable
|
||||||
|
handle, pass it around however you like, including through React props.
|
||||||
|
|
||||||
|
If state is shared by multiple components or belongs to application logic, store
|
||||||
|
it in an Effect service:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Effect, SubscriptionRef } from "effect"
|
||||||
|
import { Component, Lens, Subscribable } from "effect-fc"
|
||||||
|
|
||||||
|
class CounterState extends Effect.Service<CounterState>()("CounterState", {
|
||||||
|
effect: Effect.gen(function* () {
|
||||||
|
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||||
|
|
||||||
|
return { count } as const
|
||||||
|
}),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const CounterValueView = Component.make("CounterValue")(function* () {
|
||||||
|
const state = yield* CounterState
|
||||||
|
const [count] = yield* Subscribable.useAll([state.count])
|
||||||
|
|
||||||
|
return <p>Count: {count}</p>
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
If state belongs to a single Effect-FC component instance, or to a shallow
|
||||||
|
hierarchy of subcomponents that receive it through props, create it with
|
||||||
|
`Component.useOnMount`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Effect, SubscriptionRef } from "effect"
|
||||||
|
import { Component, Lens, Subscribable } from "effect-fc"
|
||||||
|
|
||||||
|
const LocalCounterView = Component.make("LocalCounter")(function* () {
|
||||||
|
const state = yield* Component.useOnMount(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||||
|
|
||||||
|
return { count } as const
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const [count] = yield* Subscribable.useAll([state.count])
|
||||||
|
|
||||||
|
return <p>Count: {count}</p>
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
For simple UI state that is not shared and does not need Effect integration,
|
||||||
|
prefer regular React state. A local "show details" toggle is usually better as
|
||||||
|
`React.useState(false)` than as a Lens.
|
||||||
|
|
||||||
|
## Subscribable.useAll
|
||||||
|
|
||||||
|
A `Subscribable<A>` is reactive state with a current value and a stream of
|
||||||
|
changes. Use `Subscribable.useAll` whenever a component needs to bind
|
||||||
|
subscribable values into render output.
|
||||||
|
|
||||||
|
`Lens` is a `Subscribable`, so this is also the default way to read Lens values
|
||||||
|
from a component.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Effect, SubscriptionRef } from "effect"
|
||||||
|
import { Component, Lens, Subscribable } from "effect-fc"
|
||||||
|
|
||||||
|
class CounterState extends Effect.Service<CounterState>()("CounterState", {
|
||||||
|
effect: Effect.gen(function* () {
|
||||||
|
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||||
|
const doubled = Subscribable.map(count, (n) => n * 2)
|
||||||
|
|
||||||
|
return { count, doubled } as const
|
||||||
|
}),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const CounterReadOnlyView = Component.make("CounterReadOnly")(
|
||||||
|
function* () {
|
||||||
|
const state = yield* CounterState
|
||||||
|
const [count, doubled] = yield* Subscribable.useAll([
|
||||||
|
state.count,
|
||||||
|
state.doubled,
|
||||||
|
])
|
||||||
|
|
||||||
|
return <p>Count: {count}, doubled: {doubled}</p>
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Subscribable.useAll` reads the current values during render and uses scoped subscriptions to update React state when changes arrive.
|
||||||
|
|
||||||
|
When you need to modify state, write to the Lens with `Lens.set` or `Lens.update`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const CounterControlsView = Component.make("CounterControls")(
|
||||||
|
function* () {
|
||||||
|
const state = yield* CounterState
|
||||||
|
const [count] = yield* Subscribable.useAll([state.count])
|
||||||
|
|
||||||
|
const increment = yield* Component.useCallbackSync(
|
||||||
|
() => Lens.update(state.count, (n) => n + 1),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const reset = yield* Component.useCallbackSync(
|
||||||
|
() => Lens.set(state.count, 0),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<p>Count: {count}</p>
|
||||||
|
<button onClick={increment}>Increment</button>
|
||||||
|
<button onClick={reset}>Reset</button>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lens.useState
|
||||||
|
|
||||||
|
`Lens.useState` is useful when React needs the familiar `[value, setValue]`
|
||||||
|
tuple, backed by a Lens. Reach for it when the JSX API expects a synchronous
|
||||||
|
setter, especially controlled inputs such as text fields, checkboxes, selects,
|
||||||
|
or third-party components with `value` / `onChange` props.
|
||||||
|
|
||||||
|
If a component only needs to display the value, prefer `Subscribable.useAll`.
|
||||||
|
`Lens.useState` is for places where reading and writing need to be wired
|
||||||
|
together in React's local-state shape.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Effect, SubscriptionRef } from "effect"
|
||||||
|
import { Component, Lens } from "effect-fc"
|
||||||
|
|
||||||
|
class FormState extends Effect.Service<FormState>()("FormState", {
|
||||||
|
effect: Effect.gen(function* () {
|
||||||
|
const name = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(""))
|
||||||
|
|
||||||
|
return { name } as const
|
||||||
|
}),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const NameInputView = Component.make("NameInput")(function* () {
|
||||||
|
const state = yield* FormState
|
||||||
|
const [name, setName] = yield* Lens.useState(state.name)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`Lens.useState` returns the current value and a React-compatible setter. Calling
|
||||||
|
the setter writes through the Lens, so every other component subscribed to the
|
||||||
|
same Lens sees the update.
|
||||||
|
|
||||||
|
## Focused Lenses
|
||||||
|
|
||||||
|
Use focused Lenses when a component should work with one part of a larger state
|
||||||
|
object. A focused Lens is still a Lens, so it can be read with
|
||||||
|
`Subscribable.useAll` or used with `Lens.useState` when React needs a
|
||||||
|
read/write tuple.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Effect, SubscriptionRef } from "effect"
|
||||||
|
import { Component, Lens, Subscribable } from "effect-fc"
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
readonly name: string
|
||||||
|
readonly email: string
|
||||||
|
readonly role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProfileState extends Effect.Service<ProfileState>()("ProfileState", {
|
||||||
|
effect: Effect.gen(function* () {
|
||||||
|
const profile = Lens.fromSubscriptionRef(
|
||||||
|
yield* SubscriptionRef.make<UserProfile>({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
role: "reader",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const name = Lens.focusObjectOn(profile, "name")
|
||||||
|
const role = Lens.focusObjectOn(profile, "role")
|
||||||
|
|
||||||
|
return { profile, name, role } as const
|
||||||
|
}),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const ProfileNameView = Component.make("ProfileName")(function* () {
|
||||||
|
const state = yield* ProfileState
|
||||||
|
const [name, setName] = yield* Lens.useState(state.name)
|
||||||
|
const [role] = yield* Subscribable.useAll([state.role])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<span>Role: {role}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Updating the focused `name` Lens through `Lens.useState` updates the parent
|
||||||
|
`profile` Lens. The focused `role` Lens is only read, so it stays on the simpler
|
||||||
|
`Subscribable.useAll` path.
|
||||||
|
|
||||||
|
For focusing into nested state, deriving lenses, custom write behavior, and the
|
||||||
|
complete API, refer to the
|
||||||
|
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens).
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"label": "Tutorial - Basics",
|
|
||||||
"position": 2,
|
|
||||||
"link": {
|
|
||||||
"type": "generated-index",
|
|
||||||
"description": "5 minutes to learn the most important Docusaurus concepts."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
---
|
|
||||||
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)
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
---
|
|
||||||
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).
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
---
|
|
||||||
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'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
```
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
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).
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
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)**).
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
---
|
|
||||||
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> !
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"label": "Tutorial - Extras",
|
|
||||||
"position": 3,
|
|
||||||
"link": {
|
|
||||||
"type": "generated-index"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 27 KiB |
@@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
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`
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
---
|
|
||||||
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 * as Preset from "@docusaurus/preset-classic"
|
||||||
import type {Config} from '@docusaurus/types';
|
import type { Config } from "@docusaurus/types"
|
||||||
import {themes as prismThemes} from 'prism-react-renderer';
|
import { themes as prismThemes } from "prism-react-renderer"
|
||||||
|
|
||||||
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
title: 'My Site',
|
title: "effect-fc",
|
||||||
tagline: 'Dinosaurs are cool',
|
tagline: "Write React function components with Effect",
|
||||||
favicon: 'img/favicon.ico',
|
favicon: "img/favicon.ico",
|
||||||
|
|
||||||
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
|
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
|
||||||
future: {
|
future: {
|
||||||
@@ -15,54 +15,38 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Set the production url of your site here
|
// Set the production url of your site here
|
||||||
url: 'https://your-docusaurus-site.example.com',
|
url: "https://thiladev.github.io",
|
||||||
// Set the /<baseUrl>/ pathname under which your site is served
|
// Set the /<baseUrl>/ pathname under which your site is served
|
||||||
// For GitHub pages deployment, it is often '/<projectName>/'
|
// For GitHub pages deployment, it is often '/<projectName>/'
|
||||||
baseUrl: '/',
|
baseUrl: "/effect-fc/",
|
||||||
|
|
||||||
// GitHub pages deployment config.
|
// GitHub pages deployment config.
|
||||||
// If you aren't using GitHub pages, you don't need these.
|
// If you aren't using GitHub pages, you don't need these.
|
||||||
organizationName: 'facebook', // Usually your GitHub org/user name.
|
organizationName: "Thiladev", // Usually your GitHub org/user name.
|
||||||
projectName: 'docusaurus', // Usually your repo name.
|
projectName: "effect-fc", // Usually your repo name.
|
||||||
|
|
||||||
onBrokenLinks: 'throw',
|
onBrokenLinks: "throw",
|
||||||
|
|
||||||
// Even if you don't use internationalization, you can use this field to set
|
// 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
|
// useful metadata like html lang. For example, if your site is Chinese, you
|
||||||
// may want to replace "en" with "zh-Hans".
|
// may want to replace "en" with "zh-Hans".
|
||||||
i18n: {
|
i18n: {
|
||||||
defaultLocale: 'en',
|
defaultLocale: "en",
|
||||||
locales: ['en'],
|
locales: ["en"],
|
||||||
},
|
},
|
||||||
|
|
||||||
presets: [
|
presets: [
|
||||||
[
|
[
|
||||||
'classic',
|
"classic",
|
||||||
{
|
{
|
||||||
docs: {
|
docs: {
|
||||||
sidebarPath: './sidebars.ts',
|
sidebarPath: "./sidebars.ts",
|
||||||
// Please change this to your repo.
|
|
||||||
// Remove this to remove the "edit this page" links.
|
|
||||||
editUrl:
|
editUrl:
|
||||||
'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
|
"https://github.com/Thiladev/effect-fc/tree/main/packages/docs/",
|
||||||
},
|
|
||||||
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: {
|
theme: {
|
||||||
customCss: './src/css/custom.css',
|
customCss: "./src/css/custom.css",
|
||||||
},
|
},
|
||||||
} satisfies Preset.Options,
|
} satisfies Preset.Options,
|
||||||
],
|
],
|
||||||
@@ -70,81 +54,75 @@ const config: Config = {
|
|||||||
|
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
// Replace with your project's social card
|
// Replace with your project's social card
|
||||||
image: 'img/docusaurus-social-card.jpg',
|
|
||||||
colorMode: {
|
colorMode: {
|
||||||
respectPrefersColorScheme: true,
|
respectPrefersColorScheme: true,
|
||||||
},
|
},
|
||||||
navbar: {
|
navbar: {
|
||||||
title: 'My Site',
|
title: "effect-fc",
|
||||||
logo: {
|
logo: {
|
||||||
alt: 'My Site Logo',
|
alt: "effect-fc logo",
|
||||||
src: 'img/logo.svg',
|
src: "img/logo.svg",
|
||||||
},
|
},
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
type: 'docSidebar',
|
type: "docSidebar",
|
||||||
sidebarId: 'tutorialSidebar',
|
sidebarId: "docsSidebar",
|
||||||
position: 'left',
|
position: "left",
|
||||||
label: 'Tutorial',
|
label: "Docs",
|
||||||
},
|
},
|
||||||
{to: '/blog', label: 'Blog', position: 'left'},
|
|
||||||
{
|
{
|
||||||
href: 'https://github.com/facebook/docusaurus',
|
href: "https://github.com/Thiladev/effect-fc",
|
||||||
label: 'GitHub',
|
label: "GitHub",
|
||||||
position: 'right',
|
position: "right",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
style: 'dark',
|
style: "dark",
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
title: 'Docs',
|
title: "Docs",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Tutorial',
|
label: "Getting Started",
|
||||||
to: '/docs/intro',
|
to: "/docs/getting-started",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Community',
|
title: "Project",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Stack Overflow',
|
label: "GitHub",
|
||||||
href: 'https://stackoverflow.com/questions/tagged/docusaurus',
|
href: "https://github.com/Thiladev/effect-fc",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Discord',
|
label: "Example App",
|
||||||
href: 'https://discordapp.com/invite/docusaurus',
|
href: "https://github.com/Thiladev/effect-fc/tree/main/packages/example",
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'X',
|
|
||||||
href: 'https://x.com/docusaurus',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'More',
|
title: "Ecosystem",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Blog',
|
label: "Effect",
|
||||||
to: '/blog',
|
href: "https://effect.website/",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'GitHub',
|
label: "React",
|
||||||
href: 'https://github.com/facebook/docusaurus',
|
href: "https://react.dev/",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
copyright: `Copyright © ${new Date().getFullYear()} My Project, Inc. Built with Docusaurus.`,
|
copyright: `Copyright © ${new Date().getFullYear()} effect-fc contributors. Built with Docusaurus.`,
|
||||||
},
|
},
|
||||||
prism: {
|
prism: {
|
||||||
theme: prismThemes.github,
|
theme: prismThemes.github,
|
||||||
darkTheme: prismThemes.dracula,
|
darkTheme: prismThemes.dracula,
|
||||||
},
|
},
|
||||||
} satisfies Preset.ThemeConfig,
|
} satisfies Preset.ThemeConfig,
|
||||||
};
|
}
|
||||||
|
|
||||||
export default config;
|
export default config
|
||||||
|
|||||||
@@ -28,7 +28,9 @@
|
|||||||
"@docusaurus/module-type-aliases": "3.10.1",
|
"@docusaurus/module-type-aliases": "3.10.1",
|
||||||
"@docusaurus/tsconfig": "3.10.1",
|
"@docusaurus/tsconfig": "3.10.1",
|
||||||
"@docusaurus/types": "3.10.1",
|
"@docusaurus/types": "3.10.1",
|
||||||
"typescript": "~6.0.3"
|
"@types/react": "^19.2.15",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"typescript": "~7.0.0"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|||||||
@@ -1,33 +1,9 @@
|
|||||||
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...)
|
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
|
|
||||||
The sidebars can be generated from the filesystem, or explicitly defined here.
|
|
||||||
|
|
||||||
Create as many sidebars as you want.
|
|
||||||
*/
|
|
||||||
const sidebars: SidebarsConfig = {
|
const sidebars: SidebarsConfig = {
|
||||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
docsSidebar: ["getting-started", "state-management", "forms"],
|
||||||
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
|
}
|
||||||
|
|
||||||
// But you can create a sidebar manually
|
export default sidebars
|
||||||
/*
|
|
||||||
tutorialSidebar: [
|
|
||||||
'intro',
|
|
||||||
'hello',
|
|
||||||
{
|
|
||||||
type: 'category',
|
|
||||||
label: 'Tutorial',
|
|
||||||
items: ['tutorial-basics/create-a-document'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
*/
|
|
||||||
};
|
|
||||||
|
|
||||||
export default sidebars;
|
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
.features {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2rem 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featureSvg {
|
|
||||||
height: 200px;
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,37 @@
|
|||||||
/**
|
|
||||||
* 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 {
|
:root {
|
||||||
--ifm-color-primary: #2e8555;
|
--ifm-color-primary: #0b6f74;
|
||||||
--ifm-color-primary-dark: #29784c;
|
--ifm-color-primary-dark: #096469;
|
||||||
--ifm-color-primary-darker: #277148;
|
--ifm-color-primary-darker: #085e63;
|
||||||
--ifm-color-primary-darkest: #205d3b;
|
--ifm-color-primary-darkest: #074d51;
|
||||||
--ifm-color-primary-light: #33925d;
|
--ifm-color-primary-light: #0d7a7f;
|
||||||
--ifm-color-primary-lighter: #359962;
|
--ifm-color-primary-lighter: #0e8085;
|
||||||
--ifm-color-primary-lightest: #3cad6e;
|
--ifm-color-primary-lightest: #109096;
|
||||||
|
--ifm-background-color: #fffaf1;
|
||||||
--ifm-code-font-size: 95%;
|
--ifm-code-font-size: 95%;
|
||||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
--ifm-font-family-base:
|
||||||
|
"Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||||
|
--docusaurus-highlighted-code-line-bg: rgba(11, 111, 116, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
.button--primary {
|
||||||
[data-theme='dark'] {
|
--ifm-button-background-color: #0b6f74;
|
||||||
--ifm-color-primary: #25c2a0;
|
--ifm-button-border-color: #0b6f74;
|
||||||
--ifm-color-primary-dark: #21af90;
|
}
|
||||||
--ifm-color-primary-darker: #1fa588;
|
|
||||||
--ifm-color-primary-darkest: #1a8870;
|
.button--secondary {
|
||||||
--ifm-color-primary-light: #29d5b0;
|
--ifm-button-background-color: #f4a636;
|
||||||
--ifm-color-primary-lighter: #32d8b4;
|
--ifm-button-border-color: #f4a636;
|
||||||
--ifm-color-primary-lightest: #4fddbf;
|
--ifm-button-color: #1f2526;
|
||||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--ifm-color-primary: #7ad6d4;
|
||||||
|
--ifm-color-primary-dark: #5cccca;
|
||||||
|
--ifm-color-primary-darker: #4cc6c4;
|
||||||
|
--ifm-color-primary-darkest: #34aaa8;
|
||||||
|
--ifm-color-primary-light: #98e0de;
|
||||||
|
--ifm-color-primary-lighter: #a8e6e4;
|
||||||
|
--ifm-color-primary-lightest: #d5f4f3;
|
||||||
|
--ifm-background-color: #111c1f;
|
||||||
|
--docusaurus-highlighted-code-line-bg: rgba(122, 214, 212, 0.18);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,80 @@
|
|||||||
/**
|
.page {
|
||||||
* CSS files with the .module.css suffix will be treated as CSS modules
|
min-height: calc(100vh - var(--ifm-navbar-height));
|
||||||
* and scoped locally.
|
background:
|
||||||
*/
|
radial-gradient(circle at top left, rgba(11, 111, 116, 0.22), transparent 32rem),
|
||||||
|
radial-gradient(circle at 85% 20%, rgba(244, 166, 54, 0.18), transparent 28rem),
|
||||||
|
linear-gradient(135deg, #f8f4ea 0%, #eef8f7 48%, #fffaf1 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.heroBanner {
|
.hero {
|
||||||
padding: 4rem 0;
|
padding: 8rem 1rem 5rem;
|
||||||
text-align: center;
|
}
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
.eyebrow {
|
||||||
|
color: #0b6f74;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
color: #132c2f;
|
||||||
|
font-size: clamp(3rem, 9vw, 6.75rem);
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
line-height: 0.9;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 880px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
color: #385256;
|
||||||
|
font-size: clamp(1.15rem, 2vw, 1.45rem);
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 2rem 0 0;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
padding: 0 1rem 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards article {
|
||||||
|
background: rgba(255, 255, 255, 0.74);
|
||||||
|
border: 1px solid rgba(19, 44, 47, 0.12);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
box-shadow: 0 24px 80px rgba(19, 44, 47, 0.08);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards h2 {
|
||||||
|
color: #132c2f;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards p {
|
||||||
|
color: #486267;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 996px) {
|
@media screen and (max-width: 996px) {
|
||||||
.heroBanner {
|
.hero {
|
||||||
padding: 2rem;
|
padding-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttons {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,44 +1,62 @@
|
|||||||
import Link from '@docusaurus/Link';
|
import Link from "@docusaurus/Link"
|
||||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
import Layout from "@theme/Layout"
|
||||||
import HomepageFeatures from '@site/src/components/HomepageFeatures';
|
import clsx from "clsx"
|
||||||
import Heading from '@theme/Heading';
|
import type { ReactNode } from "react"
|
||||||
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"
|
||||||
|
|
||||||
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 {
|
export default function Home(): ReactNode {
|
||||||
const {siteConfig} = useDocusaurusContext();
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
title={`Hello from ${siteConfig.title}`}
|
title="effect-fc"
|
||||||
description="Description will go into a meta tag in <head />">
|
description="Write React function components with Effect"
|
||||||
<HomepageHeader />
|
>
|
||||||
<main>
|
<main className={styles.page}>
|
||||||
<HomepageFeatures />
|
<section className={clsx("container", styles.hero)}>
|
||||||
|
<p className={styles.eyebrow}>Effect for React function components</p>
|
||||||
|
<h1>Write components as Effect programs.</h1>
|
||||||
|
<p className={styles.lede}>
|
||||||
|
effect-fc gives React 19 components access to Effect services,
|
||||||
|
scopes, subscriptions, and async workflows without giving up normal
|
||||||
|
React boundaries.
|
||||||
|
</p>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Link className="button button--primary button--lg" to="/docs/getting-started">
|
||||||
|
Get Started
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="button button--secondary button--lg"
|
||||||
|
to="https://github.com/Thiladev/effect-fc"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={clsx("container", styles.cards)}>
|
||||||
|
<article>
|
||||||
|
<h2>Generator components</h2>
|
||||||
|
<p>
|
||||||
|
Use <code>Component.make</code> to yield Effects and return JSX
|
||||||
|
from the same component body.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h2>Runtime at the edge</h2>
|
||||||
|
<p>
|
||||||
|
Provide your app layer once with <code>ReactRuntime.Provider</code>
|
||||||
|
and convert Effect-FC components at React boundaries.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h2>Scoped lifecycles</h2>
|
||||||
|
<p>
|
||||||
|
Tie subscriptions and resources to component scopes so finalizers
|
||||||
|
run when React unmounts.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
title: Markdown page example
|
|
||||||
---
|
|
||||||
|
|
||||||
# Markdown page example
|
|
||||||
|
|
||||||
You don't need React to write simple standalone pages.
|
|
||||||
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 424 B |
@@ -1,171 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 31 KiB |
@@ -1,170 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,40 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -2,7 +2,8 @@
|
|||||||
// This file is not used in compilation. It is here just for a nice editor experience.
|
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||||
"extends": "@docusaurus/tsconfig",
|
"extends": "@docusaurus/tsconfig",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "."
|
"baseUrl": ".",
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
},
|
},
|
||||||
"exclude": [".docusaurus", "build"]
|
"exclude": [".docusaurus", "build"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Effect FC Next
|
||||||
|
|
||||||
|
[Effect-TS](https://effect.website/) integration for React 19.2+ that allows you to write function components using Effect generators.
|
||||||
|
|
||||||
|
This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome.
|
||||||
|
|
||||||
|
Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
|
||||||
|
|
||||||
|
## Peer dependencies
|
||||||
|
- `effect` 3.19+
|
||||||
|
- `react` & `@types/react` 19.2+
|
||||||
|
|
||||||
|
## Known issues
|
||||||
|
- React Refresh doesn't work for Effect FC's yet. Page reload is required to view changes. Regular React components are unaffected.
|
||||||
|
|
||||||
|
## What writing components looks like
|
||||||
|
```typescript
|
||||||
|
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
|
||||||
|
})
|
||||||
|
```
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||||
|
"root": false,
|
||||||
|
"extends": "//",
|
||||||
|
"files": {
|
||||||
|
"includes": ["./src/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "effect-fc-next",
|
||||||
|
"description": "Write React function components with Effect",
|
||||||
|
"version": "0.1.0-beta.0",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"./README.md",
|
||||||
|
"./dist"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"url": "git+https://github.com/Thiladev/effect-fc.git"
|
||||||
|
},
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./*": [
|
||||||
|
{
|
||||||
|
"types": "./dist/*/index.d.ts",
|
||||||
|
"default": "./dist/*/index.js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"types": "./dist/*.d.ts",
|
||||||
|
"default": "./dist/*.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"lint:tsc": "tsc --noEmit",
|
||||||
|
"lint:biome": "biome lint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"pack": "npm pack",
|
||||||
|
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
|
||||||
|
"clean:dist": "rm -rf dist",
|
||||||
|
"clean:modules": "rm -rf node_modules"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@effect/platform-browser": "4.0.0-beta.98",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"effect": "4.0.0-beta.98",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"effect": "4.0.0-beta.98",
|
||||||
|
"react": "^19.2.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"effect-lens": "^2.0.0-beta.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||||
|
import { type Context, Effect, type Equivalence, Function, Predicate, Scope } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
|
||||||
|
export type AsyncTypeId = typeof AsyncTypeId
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A trait for `Component`'s that allows them running asynchronous effects.
|
||||||
|
*/
|
||||||
|
export interface Async extends AsyncPrototype, AsyncOptions {}
|
||||||
|
|
||||||
|
export interface AsyncPrototype {
|
||||||
|
readonly [AsyncTypeId]: AsyncTypeId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for `Async` components.
|
||||||
|
*/
|
||||||
|
export interface AsyncOptions {
|
||||||
|
/**
|
||||||
|
* The default fallback React node to display while the async operation is pending.
|
||||||
|
* Used if no fallback is provided to the component when rendering.
|
||||||
|
*/
|
||||||
|
readonly defaultFallback?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for `Async` components.
|
||||||
|
*/
|
||||||
|
export type AsyncProps = Omit<React.SuspenseProps, "children">
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>,
|
||||||
|
) {
|
||||||
|
const Inner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
|
||||||
|
|
||||||
|
return ({ fallback, name, ...props }: AsyncProps) => {
|
||||||
|
const promise = Effect.runPromiseWith(contextRef.current)(
|
||||||
|
Effect.flatMap(
|
||||||
|
Component.useScope([], this),
|
||||||
|
scope => Effect.provideService(this.body(props as P), Scope.Scope, scope),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
React.Suspense,
|
||||||
|
{ fallback: fallback ?? this.defaultFallback, name },
|
||||||
|
React.createElement(Inner, { promise }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as const)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An equivalence function for comparing `AsyncProps` that ignores the `fallback` property.
|
||||||
|
* Used by default by async components with `Memoized.memoized` applied.
|
||||||
|
*/
|
||||||
|
export const defaultPropsEquivalence: Equivalence.Equivalence<AsyncProps> = (
|
||||||
|
self: Record<string, unknown>,
|
||||||
|
that: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
if (self === that)
|
||||||
|
return true
|
||||||
|
|
||||||
|
for (const key in self) {
|
||||||
|
if (key === "fallback")
|
||||||
|
continue
|
||||||
|
if (!(key in that) || !Object.is(self[key], that[key]))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key in that) {
|
||||||
|
if (key === "fallback")
|
||||||
|
continue
|
||||||
|
if (!(key in self))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, AsyncTypeId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Component into an `Async` component that supports running asynchronous effects.
|
||||||
|
*
|
||||||
|
* Note: The component cannot have a prop named "promise" as it's reserved for internal use.
|
||||||
|
*
|
||||||
|
* @param self - The component to convert to an Async component
|
||||||
|
* @returns A new `Async` component with the same body, error, and context types as the input
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const MyAsyncComponent = MyComponent.pipe(
|
||||||
|
* Async.async,
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const async = <T extends Component.Component.Any>(
|
||||||
|
self: T & (
|
||||||
|
"promise" extends keyof Component.Component.Props<T>
|
||||||
|
? "The 'promise' prop name is restricted for Async components. Please rename the 'promise' prop to something else."
|
||||||
|
: T
|
||||||
|
)
|
||||||
|
): (
|
||||||
|
& Omit<T, keyof Component.Component.AsComponent<T>>
|
||||||
|
& Component.Component<
|
||||||
|
Component.Component.Props<T> & 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>>
|
||||||
|
>
|
||||||
|
& Async
|
||||||
|
) => Object.setPrototypeOf(
|
||||||
|
Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }),
|
||||||
|
Object.freeze(Object.setPrototypeOf(
|
||||||
|
Object.assign({}, AsyncPrototype),
|
||||||
|
Object.getPrototypeOf(self),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies options to an Async component, returning a new Async component with the updated configuration.
|
||||||
|
*
|
||||||
|
* Supports both curried and uncurried application styles.
|
||||||
|
*
|
||||||
|
* @param self - The Async component to apply options to (in uncurried form)
|
||||||
|
* @param options - The options to apply to the component
|
||||||
|
* @returns An Async component with the applied options
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Curried
|
||||||
|
* const MyAsyncComponent = MyComponent.pipe(
|
||||||
|
* Async.async,
|
||||||
|
* Async.withOptions({ defaultFallback: <p>Loading...</p> }),
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* // Uncurried
|
||||||
|
* const MyAsyncComponent = Async.withOptions(
|
||||||
|
* Async.async(MyComponent),
|
||||||
|
* { defaultFallback: <p>Loading...</p> },
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const withOptions: {
|
||||||
|
<T extends Component.Component.Any & Async>(
|
||||||
|
options: Partial<AsyncOptions>
|
||||||
|
): (self: T) => T
|
||||||
|
<T extends Component.Component.Any & Async>(
|
||||||
|
self: T,
|
||||||
|
options: Partial<AsyncOptions>,
|
||||||
|
): T
|
||||||
|
} = Function.dual(2, <T extends Component.Component.Any & Async>(
|
||||||
|
self: T,
|
||||||
|
options: Partial<AsyncOptions>,
|
||||||
|
): T => Object.setPrototypeOf(
|
||||||
|
Object.assign(function() {}, self, options),
|
||||||
|
Object.getPrototypeOf(self),
|
||||||
|
))
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { type Cause, Context, Effect, Layer, Option, Pipeable, Predicate, PubSub, type Scope } from "effect"
|
||||||
|
|
||||||
|
|
||||||
|
export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
|
||||||
|
export type ErrorObserverTypeId = typeof ErrorObserverTypeId
|
||||||
|
|
||||||
|
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
|
||||||
|
readonly [ErrorObserverTypeId]: ErrorObserverTypeId
|
||||||
|
handle<A, E1, R>(effect: Effect.Effect<A, E1, R>): Effect.Effect<A, E1, R>
|
||||||
|
readonly subscribe: Effect.Effect<PubSub.Subscription<Cause.Cause<E>>, never, Scope.Scope>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorObserver = <E = never>() => Context.Service<ErrorObserver<E>>(
|
||||||
|
"@effect-fc/ErrorObserver/ErrorObserver",
|
||||||
|
)
|
||||||
|
|
||||||
|
export class ErrorObserverImpl<in out E = never> extends Pipeable.Class implements ErrorObserver<E> {
|
||||||
|
readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId
|
||||||
|
|
||||||
|
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<E>>) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
get subscribe(): Effect.Effect<PubSub.Subscription<Cause.Cause<E>>, never, Scope.Scope> {
|
||||||
|
return PubSub.subscribe(this.pubsub)
|
||||||
|
}
|
||||||
|
|
||||||
|
handle<A, E1, R>(effect: Effect.Effect<A, E1, R>): Effect.Effect<A, E1, R> {
|
||||||
|
return Effect.tapCause(effect, cause => Effect.asVoid(
|
||||||
|
PubSub.publish(this.pubsub, cause as unknown as Cause.Cause<E>),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, ErrorObserverTypeId)
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<ErrorObserver> = Layer.effect(ErrorObserver())(
|
||||||
|
Effect.map(PubSub.unbounded<Cause.Cause<never>>(), pubsub => new ErrorObserverImpl(pubsub)),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const handle = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => Effect.flatMap(
|
||||||
|
Effect.serviceOption(ErrorObserver()),
|
||||||
|
Option.match({
|
||||||
|
onSome: observer => observer.handle(effect),
|
||||||
|
onNone: () => effect,
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
||||||
|
import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
|
||||||
|
import type * as React from "react"
|
||||||
|
import * as Component from "./Component.js"
|
||||||
|
import * as Lens from "./Lens.js"
|
||||||
|
import * as View from "./View.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
|
||||||
|
export type FormTypeId = typeof FormTypeId
|
||||||
|
|
||||||
|
export interface Form<out P extends readonly PropertyKey[], out A, in out I = A, out ER = never, out EW = never>
|
||||||
|
extends Pipeable.Pipeable {
|
||||||
|
readonly [FormTypeId]: FormTypeId
|
||||||
|
|
||||||
|
readonly path: P
|
||||||
|
readonly value: View.View<Option.Option<A>, ER, never>
|
||||||
|
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>
|
||||||
|
readonly issues: View.View<readonly StandardSchemaV1.Issue[], never, never>
|
||||||
|
readonly isValidating: View.View<boolean, ER, never>
|
||||||
|
readonly canCommit: View.View<boolean, never, never>
|
||||||
|
readonly isCommitting: View.View<boolean, never, never>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FormImpl<out P extends readonly PropertyKey[], out A, in out I = A, out ER = never, out EW = never>
|
||||||
|
extends Pipeable.Class implements Form<P, A, I, ER, EW> {
|
||||||
|
readonly [FormTypeId]: FormTypeId = FormTypeId
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly path: P,
|
||||||
|
readonly value: View.View<Option.Option<A>, ER, never>,
|
||||||
|
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>,
|
||||||
|
readonly issues: View.View<readonly StandardSchemaV1.Issue[], never, never>,
|
||||||
|
readonly isValidating: View.View<boolean, never, never>,
|
||||||
|
readonly canCommit: View.View<boolean, never, never>,
|
||||||
|
readonly isCommitting: View.View<boolean, never, never>,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
|
||||||
|
|
||||||
|
|
||||||
|
const filterIssuesByPath = (
|
||||||
|
issues: readonly StandardSchemaV1.Issue[],
|
||||||
|
path: readonly PropertyKey[],
|
||||||
|
): readonly StandardSchemaV1.Issue[] => Array.filter(issues, issue => {
|
||||||
|
const issuePath = issue.path
|
||||||
|
if (!issuePath) return false
|
||||||
|
return issuePath.length >= path.length && Array.every(path, (p, i) => p === issuePath[i])
|
||||||
|
})
|
||||||
|
|
||||||
|
export const focusObjectOn: {
|
||||||
|
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
||||||
|
self: Form<P, A, I, ER, EW>,
|
||||||
|
key: K,
|
||||||
|
): Form<readonly [...P, K], A[K], I[K], ER, EW>
|
||||||
|
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
||||||
|
key: K,
|
||||||
|
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
|
||||||
|
} = Function.dual(2, <P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
|
||||||
|
self: Form<P, A, I, ER, EW>,
|
||||||
|
key: K,
|
||||||
|
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
|
||||||
|
const form = self as FormImpl<P, A, I, ER, EW>
|
||||||
|
const path = [...form.path, key] as const
|
||||||
|
|
||||||
|
return new FormImpl(
|
||||||
|
path,
|
||||||
|
View.mapOption(form.value, a => a[key]),
|
||||||
|
Lens.focusObjectOn(form.encodedValue, key),
|
||||||
|
View.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||||
|
form.isValidating,
|
||||||
|
form.canCommit,
|
||||||
|
form.isCommitting,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const focusArrayAt: {
|
||||||
|
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
|
||||||
|
self: Form<P, A, I, ER, EW>,
|
||||||
|
index: number,
|
||||||
|
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementError, EW | Cause.NoSuchElementError>
|
||||||
|
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
|
||||||
|
index: number,
|
||||||
|
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementError, EW | Cause.NoSuchElementError>
|
||||||
|
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
|
||||||
|
self: Form<P, A, I, ER, EW>,
|
||||||
|
index: number,
|
||||||
|
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementError, EW | Cause.NoSuchElementError> => {
|
||||||
|
const form = self as FormImpl<P, A, I, ER, EW>
|
||||||
|
const path = [...form.path, index] as const
|
||||||
|
|
||||||
|
return new FormImpl(
|
||||||
|
path,
|
||||||
|
View.mapOptionEffect(form.value, value => Effect.fromOption(Array.get(value, index))),
|
||||||
|
Lens.focusArrayAt(form.encodedValue, index),
|
||||||
|
View.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||||
|
form.isValidating,
|
||||||
|
form.canCommit,
|
||||||
|
form.isCommitting,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const focusTupleAt: {
|
||||||
|
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
|
||||||
|
self: Form<P, A, I, ER, EW>,
|
||||||
|
index: K,
|
||||||
|
): Form<readonly [...P, K], A[K], I[K], ER, EW>
|
||||||
|
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
|
||||||
|
index: K,
|
||||||
|
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
|
||||||
|
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
|
||||||
|
self: Form<P, A, I, ER, EW>,
|
||||||
|
index: K,
|
||||||
|
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
|
||||||
|
const form = self as FormImpl<P, A, I, ER, EW>
|
||||||
|
const path = [...form.path, index] as const
|
||||||
|
|
||||||
|
return new FormImpl(
|
||||||
|
path,
|
||||||
|
View.mapOption(form.value, Array.getUnsafe(index)),
|
||||||
|
Lens.focusTupleAt(form.encodedValue, index),
|
||||||
|
View.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||||
|
form.isValidating,
|
||||||
|
form.canCommit,
|
||||||
|
form.isCommitting,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const focusChunkAt: {
|
||||||
|
<P extends readonly PropertyKey[], A, I, ER, EW>(
|
||||||
|
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
|
||||||
|
index: number,
|
||||||
|
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementError, EW>
|
||||||
|
<P extends readonly PropertyKey[], A, I, ER, EW>(
|
||||||
|
index: number,
|
||||||
|
): (self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>) => Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementError, EW>
|
||||||
|
} = Function.dual(2, <P extends readonly PropertyKey[], A, I, ER, EW>(
|
||||||
|
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
|
||||||
|
index: number,
|
||||||
|
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementError, EW> => {
|
||||||
|
const form = self as FormImpl<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>
|
||||||
|
const path = [...form.path, index] as const
|
||||||
|
|
||||||
|
return new FormImpl(
|
||||||
|
path,
|
||||||
|
View.mapOptionEffect(form.value, value => Effect.fromOption(Chunk.get(value, index))),
|
||||||
|
Lens.focusChunkAt(form.encodedValue, index),
|
||||||
|
View.map(form.issues, issues => filterIssuesByPath(issues, path)),
|
||||||
|
form.isValidating,
|
||||||
|
form.canCommit,
|
||||||
|
form.isCommitting,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
export namespace useInput {
|
||||||
|
export interface Options {
|
||||||
|
readonly debounce?: Duration.Input
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Success<T> {
|
||||||
|
readonly value: T
|
||||||
|
readonly setValue: React.Dispatch<React.SetStateAction<T>>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
|
||||||
|
form: Form<P, A, I, ER, EW>,
|
||||||
|
options?: useInput.Options,
|
||||||
|
): Effect.fn.Return<useInput.Success<I>, ER, Scope.Scope> {
|
||||||
|
const internalValueLens = yield* Component.useOnChange(() => Effect.gen(function*() {
|
||||||
|
const internalValueLens = yield* Lens.get(form.encodedValue).pipe(
|
||||||
|
Effect.flatMap(SubscriptionRef.make),
|
||||||
|
Effect.map(Lens.fromSubscriptionRef),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Effect.forkScoped(Effect.all([
|
||||||
|
Stream.runForEach(
|
||||||
|
Stream.drop(Lens.changes(form.encodedValue), 1),
|
||||||
|
upstreamEncodedValue => Effect.when(
|
||||||
|
Lens.set(internalValueLens, upstreamEncodedValue),
|
||||||
|
Effect.map(Lens.get(internalValueLens), internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Stream.runForEach(
|
||||||
|
Lens.changes(internalValueLens).pipe(
|
||||||
|
Stream.drop(1),
|
||||||
|
Stream.changesWith(Equal.asEquivalence()),
|
||||||
|
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
||||||
|
),
|
||||||
|
internalValue => Lens.set(form.encodedValue, internalValue),
|
||||||
|
),
|
||||||
|
], { concurrency: "unbounded", discard: true }))
|
||||||
|
|
||||||
|
return internalValueLens
|
||||||
|
}), [form, options?.debounce])
|
||||||
|
|
||||||
|
const [value, setValue] = yield* Lens.useState(internalValueLens)
|
||||||
|
return { value, setValue }
|
||||||
|
})
|
||||||
|
|
||||||
|
export namespace useOptionalInput {
|
||||||
|
export interface Options<T> extends useInput.Options {
|
||||||
|
readonly defaultValue: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Success<T> extends useInput.Success<T> {
|
||||||
|
readonly enabled: boolean
|
||||||
|
readonly setEnabled: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOptionalInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
|
||||||
|
field: Form<P, A, Option.Option<I>, ER, EW>,
|
||||||
|
options: useOptionalInput.Options<I>,
|
||||||
|
): Effect.fn.Return<useOptionalInput.Success<I>, ER, Scope.Scope> {
|
||||||
|
const [enabledLens, internalValueLens] = yield* Component.useOnChange(() => Effect.gen(function*() {
|
||||||
|
const [enabledLens, internalValueLens] = yield* Effect.flatMap(
|
||||||
|
Lens.get(field.encodedValue),
|
||||||
|
Option.match({
|
||||||
|
onSome: v => Effect.all([
|
||||||
|
Effect.map(SubscriptionRef.make(true), Lens.fromSubscriptionRef),
|
||||||
|
Effect.map(SubscriptionRef.make(v), Lens.fromSubscriptionRef),
|
||||||
|
]),
|
||||||
|
onNone: () => Effect.all([
|
||||||
|
Effect.map(SubscriptionRef.make(false), Lens.fromSubscriptionRef),
|
||||||
|
Effect.map(SubscriptionRef.make(options.defaultValue), Lens.fromSubscriptionRef),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Effect.forkScoped(Effect.all([
|
||||||
|
Stream.runForEach(
|
||||||
|
Stream.drop(Lens.changes(field.encodedValue), 1),
|
||||||
|
|
||||||
|
upstreamEncodedValue => Effect.when(
|
||||||
|
Option.match(upstreamEncodedValue, {
|
||||||
|
onSome: v => Effect.andThen(
|
||||||
|
Lens.set(enabledLens, true),
|
||||||
|
Lens.set(internalValueLens, v),
|
||||||
|
),
|
||||||
|
onNone: () => Effect.andThen(
|
||||||
|
Lens.set(enabledLens, false),
|
||||||
|
Lens.set(internalValueLens, options.defaultValue),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
|
||||||
|
Effect.map(
|
||||||
|
Effect.all([Lens.get(enabledLens), Lens.get(internalValueLens)]),
|
||||||
|
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Stream.runForEach(
|
||||||
|
Lens.changes(enabledLens).pipe(
|
||||||
|
Stream.zipLatest(internalValueLens.changes),
|
||||||
|
Stream.drop(1),
|
||||||
|
Stream.changesWith(Equal.asEquivalence()),
|
||||||
|
options?.debounce ? Stream.debounce(options.debounce) : identity,
|
||||||
|
),
|
||||||
|
([enabled, internalValue]) => Lens.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
|
||||||
|
),
|
||||||
|
], { concurrency: "unbounded" }))
|
||||||
|
|
||||||
|
return [enabledLens, internalValueLens] as const
|
||||||
|
}), [field, options.debounce])
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = yield* Lens.useState(enabledLens)
|
||||||
|
const [value, setValue] = yield* Lens.useState(internalValueLens)
|
||||||
|
return { enabled, setEnabled, value, setValue }
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Effect, Equivalence, Stream, SubscriptionRef } from "effect"
|
||||||
|
import { Lens } from "effect-lens"
|
||||||
|
import * as React from "react"
|
||||||
|
import * as Component from "./Component.js"
|
||||||
|
import * as SetStateAction from "./SetStateAction.js"
|
||||||
|
|
||||||
|
|
||||||
|
export * from "effect-lens/Lens"
|
||||||
|
|
||||||
|
export declare namespace useState {
|
||||||
|
export interface Options<A> {
|
||||||
|
readonly equivalence?: Equivalence.Equivalence<A>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useState = Effect.fnUntraced(function* <A, ER, EW, RR, RW>(
|
||||||
|
lens: Lens.Lens<A, ER, EW, RR, RW>,
|
||||||
|
options?: useState.Options<NoInfer<A>>,
|
||||||
|
): Effect.fn.Return<readonly [A, React.Dispatch<React.SetStateAction<A>>], ER | EW, RR | RW> {
|
||||||
|
const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => Lens.get(lens)))
|
||||||
|
|
||||||
|
yield* Component.useReactEffect(() => Effect.forkScoped(
|
||||||
|
Stream.runForEach(
|
||||||
|
Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strictEqual()),
|
||||||
|
v => Effect.sync(() => setReactStateValue(v)),
|
||||||
|
)
|
||||||
|
), [lens])
|
||||||
|
|
||||||
|
const setValue = yield* Component.useCallbackSync(
|
||||||
|
(setStateAction: React.SetStateAction<A>) => Effect.tap(
|
||||||
|
Lens.updateAndGet(lens, prevState => SetStateAction.value(setStateAction, prevState)),
|
||||||
|
v => Effect.sync(() => setReactStateValue(v)),
|
||||||
|
),
|
||||||
|
[lens],
|
||||||
|
)
|
||||||
|
|
||||||
|
return [reactStateValue, setValue]
|
||||||
|
})
|
||||||
|
|
||||||
|
export declare namespace useFromReactState {
|
||||||
|
export interface Options<A> {
|
||||||
|
readonly equivalence?: Equivalence.Equivalence<A>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFromReactState = Effect.fnUntraced(function* <A>(
|
||||||
|
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
|
||||||
|
options?: useFromReactState.Options<NoInfer<A>>,
|
||||||
|
): Effect.fn.Return<Lens.Lens<A, never, never, never, never>> {
|
||||||
|
const lens = yield* Component.useOnMount(() => Effect.map(
|
||||||
|
SubscriptionRef.make(value),
|
||||||
|
Lens.fromSubscriptionRef,
|
||||||
|
))
|
||||||
|
|
||||||
|
yield* Component.useReactEffect(() => Effect.forkScoped(Stream.runForEach(
|
||||||
|
Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strictEqual()),
|
||||||
|
v => Effect.sync(() => setValue(v)),
|
||||||
|
)), [setValue])
|
||||||
|
yield* Component.useReactEffect(() => Lens.set(lens, value), [value])
|
||||||
|
|
||||||
|
return lens
|
||||||
|
})
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
||||||
|
import { Array, type Context, Effect, Equal, Fiber, Option, Pipeable, Predicate, Schema, SchemaIssue, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||||
|
import * as Form from "./Form.js"
|
||||||
|
import * as Lens from "./Lens.js"
|
||||||
|
import * as View from "./View.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const LensFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/LensForm")
|
||||||
|
export type LensFormTypeId = typeof LensFormTypeId
|
||||||
|
|
||||||
|
export interface LensForm<in out A, in out I = A, in out RD = never, in out RE = never, out TER = never, out TEW = never, in out TRR = never, in out TRW = never>
|
||||||
|
extends Form.Form<readonly [], A, I, TER, TER | TEW> {
|
||||||
|
readonly [LensFormTypeId]: LensFormTypeId
|
||||||
|
|
||||||
|
readonly schema: Schema.ConstraintCodec<A, I, RD, RE>
|
||||||
|
readonly context: Context.Context<Scope.Scope | RD | RE | TRR | TRW>
|
||||||
|
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
|
||||||
|
readonly validationFiber: View.View<Option.Option<Fiber.Fiber<A, Schema.SchemaError>>, never, never>
|
||||||
|
|
||||||
|
readonly run: Effect.Effect<void, TER>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LensFormImpl<in out A, in out I = A, in out RD = never, in out RE = never, out TER = never, out TEW = never, in out TRR = never, in out TRW = never>
|
||||||
|
extends Pipeable.Class implements LensForm<A, I, RD, RE, TER, TEW, TRR, TRW> {
|
||||||
|
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
|
||||||
|
readonly [LensFormTypeId]: LensFormTypeId = LensFormTypeId
|
||||||
|
|
||||||
|
readonly path = [] as const
|
||||||
|
|
||||||
|
readonly value: View.View<Option.Option<A>, never, never>
|
||||||
|
readonly encodedValue: Lens.Lens<I, TER, TER | TEW, never, never>
|
||||||
|
readonly isValidating: View.View<boolean, never, never>
|
||||||
|
readonly canCommit: View.View<boolean, never, never>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly schema: Schema.ConstraintCodec<A, I, RD, RE>,
|
||||||
|
readonly context: Context.Context<Scope.Scope | RD | RE | TRR | TRW>,
|
||||||
|
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>,
|
||||||
|
|
||||||
|
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
|
||||||
|
readonly issues: Lens.Lens<readonly StandardSchemaV1.Issue[], never, never, never, never>,
|
||||||
|
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, Schema.SchemaError>>, never, never, never, never>,
|
||||||
|
readonly isCommitting: Lens.Lens<boolean, never, never>,
|
||||||
|
|
||||||
|
readonly runSemaphore: Semaphore.Semaphore,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.value = Effect.succeed(this).pipe(
|
||||||
|
Effect.map(self => View.make({
|
||||||
|
get: Effect.provide(Effect.option(self.target.get), self.context),
|
||||||
|
get changes() {
|
||||||
|
return Stream.provideContext(
|
||||||
|
self.target.changes.pipe(
|
||||||
|
Stream.map(Option.some),
|
||||||
|
Stream.catch(() => Stream.make(Option.none())),
|
||||||
|
),
|
||||||
|
self.context,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
View.unwrap,
|
||||||
|
)
|
||||||
|
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 => View.map(self.validationFiber, Option.isSome)),
|
||||||
|
View.unwrap,
|
||||||
|
)
|
||||||
|
this.canCommit = Effect.succeed(this).pipe(
|
||||||
|
Effect.map(self => View.map(
|
||||||
|
View.zipLatestAll(self.issues, self.validationFiber, self.isCommitting),
|
||||||
|
([issues, validationFiber, isCommitting]) => (
|
||||||
|
Array.isReadonlyArrayEmpty(issues) &&
|
||||||
|
Option.isNone(validationFiber) &&
|
||||||
|
!isCommitting
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
View.unwrap,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, TER | TEW, never> {
|
||||||
|
return Lens.get(this.validationFiber).pipe(
|
||||||
|
Effect.andThen(Option.match({
|
||||||
|
onSome: Fiber.interrupt,
|
||||||
|
onNone: () => Effect.void,
|
||||||
|
})),
|
||||||
|
Effect.andThen(Effect.forkScoped(
|
||||||
|
Effect.ensuring(
|
||||||
|
Schema.decodeEffect(this.schema, { errors: "all" })(encodedValue),
|
||||||
|
Lens.set(this.validationFiber, Option.none()),
|
||||||
|
)
|
||||||
|
)),
|
||||||
|
Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
|
||||||
|
Effect.flatMap(Fiber.join),
|
||||||
|
|
||||||
|
Effect.flatMap(value => Effect.ensuring(
|
||||||
|
Lens.set(this.isCommitting, true).pipe(
|
||||||
|
Effect.andThen(Lens.set(this.issues, Array.empty())),
|
||||||
|
Effect.andThen(Lens.set(this.target, value)),
|
||||||
|
),
|
||||||
|
Lens.set(this.isCommitting, false),
|
||||||
|
)),
|
||||||
|
Effect.catchIf(
|
||||||
|
Schema.isSchemaError,
|
||||||
|
error => Lens.set(this.issues, SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues),
|
||||||
|
),
|
||||||
|
|
||||||
|
Effect.provide(this.context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get run(): Effect.Effect<void, TER, never> {
|
||||||
|
return this.runSemaphore.withPermits(1)(Effect.provide(
|
||||||
|
Stream.runForEach(
|
||||||
|
Stream.drop(Lens.changes(this.target), 1),
|
||||||
|
targetValue => Schema.encodeEffect(this.schema, { errors: "all" })(targetValue).pipe(
|
||||||
|
Effect.flatMap(encodedValue => Effect.when(
|
||||||
|
Effect.andThen(
|
||||||
|
Lens.set(this.issues, Array.empty()),
|
||||||
|
Lens.set(this.internalEncodedValue, encodedValue),
|
||||||
|
),
|
||||||
|
Effect.map(
|
||||||
|
Lens.get(this.internalEncodedValue),
|
||||||
|
currentEncodedValue => !Equal.equals(encodedValue, currentEncodedValue),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
Effect.ignore,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
this.context,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isLensForm = (u: unknown): u is LensForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, LensFormTypeId)
|
||||||
|
|
||||||
|
|
||||||
|
export declare namespace make {
|
||||||
|
export interface Options<in out A, out I = A, out RD = never, out RE = never, out TER = never, out TEW = never, out TRR = never, out TRW = never> {
|
||||||
|
readonly schema: Schema.ConstraintCodec<A, I, RD, RE>
|
||||||
|
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
|
||||||
|
readonly initialEncodedValue?: NoInfer<I>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never>(
|
||||||
|
options: make.Options<A, I, RD, RE, TER, TEW, TRR, TRW>
|
||||||
|
): Effect.fn.Return<
|
||||||
|
LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>,
|
||||||
|
Schema.SchemaError | TER,
|
||||||
|
Scope.Scope | RD | RE | TRR | TRW
|
||||||
|
> {
|
||||||
|
const initialEncodedValue = options.initialEncodedValue !== undefined
|
||||||
|
? options.initialEncodedValue
|
||||||
|
: yield* Effect.flatMap(
|
||||||
|
Lens.get(options.target),
|
||||||
|
Schema.encodeEffect(options.schema),
|
||||||
|
)
|
||||||
|
|
||||||
|
return new LensFormImpl(
|
||||||
|
options.schema,
|
||||||
|
yield* Effect.context<Scope.Scope | RD | RE | TRR | TRW>(),
|
||||||
|
options.target,
|
||||||
|
|
||||||
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(initialEncodedValue)),
|
||||||
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly StandardSchemaV1.Issue[]>(Array.empty())),
|
||||||
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, Schema.SchemaError>>())),
|
||||||
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(false)),
|
||||||
|
|
||||||
|
yield* Semaphore.make(1),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export declare namespace service {
|
||||||
|
export interface Options<in out A, out I = A, out RD = never, out RE = never, out TER = never, out TEW = never, out TRR = never, out TRW = never>
|
||||||
|
extends make.Options<A, I, RD, RE, TER, TEW, TRR, TRW> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const service = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never>(
|
||||||
|
options: service.Options<A, I, RD, RE, TER, TEW, TRR, TRW>
|
||||||
|
): Effect.Effect<
|
||||||
|
LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>,
|
||||||
|
Schema.SchemaError | TER,
|
||||||
|
Scope.Scope | RD | RE | TRR | TRW
|
||||||
|
> => Effect.tap(
|
||||||
|
make(options),
|
||||||
|
form => Effect.forkScoped(form.run),
|
||||||
|
)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||||
|
import { type Equivalence, Function, Predicate } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import type * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
|
||||||
|
export type MemoizedTypeId = typeof MemoizedTypeId
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A trait for `Component`'s that uses `React.memo` to optimize re-renders based on prop equality.
|
||||||
|
*
|
||||||
|
* @template P The props type of the component
|
||||||
|
*/
|
||||||
|
export interface Memoized<P> extends MemoizedPrototype, MemoizedOptions<P> {}
|
||||||
|
|
||||||
|
export interface MemoizedPrototype {
|
||||||
|
readonly [MemoizedTypeId]: MemoizedTypeId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for Memoized components.
|
||||||
|
*
|
||||||
|
* @template P The props type of the component
|
||||||
|
*/
|
||||||
|
export interface MemoizedOptions<P> {
|
||||||
|
/**
|
||||||
|
* An optional equivalence function for comparing component props.
|
||||||
|
* If provided, this function is used by React.memo to determine if props have changed.
|
||||||
|
* Returns `true` if props are equivalent (no re-render), `false` if they differ (re-render).
|
||||||
|
*/
|
||||||
|
readonly propsEquivalence?: Equivalence.Equivalence<P>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const MemoizedPrototype: MemoizedPrototype = Object.freeze({
|
||||||
|
[MemoizedTypeId]: MemoizedTypeId,
|
||||||
|
|
||||||
|
transformFunctionComponent<P extends {}>(
|
||||||
|
this: Memoized<P>,
|
||||||
|
f: React.FC<P>,
|
||||||
|
) {
|
||||||
|
return React.memo(f, this.propsEquivalence)
|
||||||
|
},
|
||||||
|
} as const)
|
||||||
|
|
||||||
|
|
||||||
|
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, MemoizedTypeId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Component into a `Memoized` component that optimizes re-renders using `React.memo`.
|
||||||
|
*
|
||||||
|
* @param self - The component to convert to a Memoized component
|
||||||
|
* @returns A new `Memoized` component with the same body, error, and context types as the input
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const MyMemoizedComponent = MyComponent.pipe(
|
||||||
|
* Memoized.memoized,
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const memoized = <T extends Component.Component.Any>(
|
||||||
|
self: T
|
||||||
|
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
|
||||||
|
Object.assign(function() {}, self),
|
||||||
|
Object.freeze(Object.setPrototypeOf(
|
||||||
|
Object.assign({}, MemoizedPrototype),
|
||||||
|
Object.getPrototypeOf(self),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies options to a Memoized component, returning a new Memoized component with the updated configuration.
|
||||||
|
*
|
||||||
|
* Supports both curried and uncurried application styles.
|
||||||
|
*
|
||||||
|
* @param self - The Memoized component to apply options to (in uncurried form)
|
||||||
|
* @param options - The options to apply to the component
|
||||||
|
* @returns A Memoized component with the applied options
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Curried
|
||||||
|
* const MyMemoizedComponent = MyComponent.pipe(
|
||||||
|
* Memoized.memoized,
|
||||||
|
* Memoized.withOptions({ propsEquivalence: (a, b) => a.id === b.id }),
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* // Uncurried
|
||||||
|
* const MyMemoizedComponent = Memoized.withOptions(
|
||||||
|
* Memoized.memoized(MyComponent),
|
||||||
|
* { propsEquivalence: (a, b) => a.id === b.id },
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const withOptions: {
|
||||||
|
<T extends Component.Component.Any & Memoized<any>>(
|
||||||
|
options: Partial<MemoizedOptions<Component.Component.Props<T>>>
|
||||||
|
): (self: T) => T
|
||||||
|
<T extends Component.Component.Any & Memoized<any>>(
|
||||||
|
self: T,
|
||||||
|
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
|
||||||
|
): T
|
||||||
|
} = Function.dual(2, <T extends Component.Component.Any & Memoized<any>>(
|
||||||
|
self: T,
|
||||||
|
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
|
||||||
|
): T => Object.setPrototypeOf(
|
||||||
|
Object.assign(function() {}, self, options),
|
||||||
|
Object.getPrototypeOf(self),
|
||||||
|
))
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { type Context, Effect, Equal, Exit, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
|
||||||
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
|
import * as Lens from "./Lens.js"
|
||||||
|
import * as View from "./View.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const MutationTypeId: unique symbol = Symbol.for("@effect-fc/Mutation/Mutation")
|
||||||
|
export type MutationTypeId = typeof MutationTypeId
|
||||||
|
|
||||||
|
export interface Mutation<in out K, out A, out E = never, in out R = never>
|
||||||
|
extends Pipeable.Pipeable {
|
||||||
|
readonly [MutationTypeId]: MutationTypeId
|
||||||
|
|
||||||
|
readonly context: Context.Context<Scope.Scope | R>
|
||||||
|
readonly f: (key: K) => Effect.Effect<A, E, R>
|
||||||
|
|
||||||
|
readonly latestKey: View.View<Option.Option<K>>
|
||||||
|
readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
|
||||||
|
readonly state: View.View<AsyncResult.AsyncResult<A, E>>
|
||||||
|
readonly latestFinalResult: View.View<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>
|
||||||
|
|
||||||
|
mutate(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>
|
||||||
|
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isMutation = (u: unknown): u is Mutation<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
|
||||||
|
|
||||||
|
|
||||||
|
export class MutationImpl<in out K, in out A, in out E = never, in out R = never>
|
||||||
|
extends Pipeable.Class implements Mutation<K, A, E, R> {
|
||||||
|
readonly [MutationTypeId]: MutationTypeId = MutationTypeId
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly context: Context.Context<Scope.Scope | R>,
|
||||||
|
readonly f: (key: K) => Effect.Effect<A, E, R>,
|
||||||
|
|
||||||
|
readonly latestKey: Lens.Lens<Option.Option<K>>,
|
||||||
|
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
|
||||||
|
readonly state: Lens.Lens<AsyncResult.AsyncResult<A, E>>,
|
||||||
|
readonly latestFinalResult: Lens.Lens<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutate(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
|
||||||
|
return Lens.set(this.latestKey, Option.some(key)).pipe(
|
||||||
|
Effect.andThen(this.start(key)),
|
||||||
|
Effect.flatMap(state => this.watch(state)),
|
||||||
|
Effect.provide(this.context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>> {
|
||||||
|
return Lens.set(this.latestKey, Option.some(key)).pipe(
|
||||||
|
Effect.andThen(this.start(key)),
|
||||||
|
Effect.tap(state => Effect.forkScoped(this.watch(state))),
|
||||||
|
Effect.provide(this.context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
start(key: K): Effect.Effect<
|
||||||
|
View.View<AsyncResult.AsyncResult<A, E>>,
|
||||||
|
never,
|
||||||
|
Scope.Scope | R
|
||||||
|
> {
|
||||||
|
return Effect.gen({ self: this }, function*() {
|
||||||
|
const previous = yield* Lens.get(this.latestFinalResult)
|
||||||
|
const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make<AsyncResult.AsyncResult<A, E>>(
|
||||||
|
Option.getOrElse(previous, () => AsyncResult.initial(false))
|
||||||
|
))
|
||||||
|
|
||||||
|
const fiber = yield* Effect.forkScoped(Effect.andThen(
|
||||||
|
Lens.update(state, AsyncResult.match({
|
||||||
|
onInitial: () => AsyncResult.initial(true),
|
||||||
|
onSuccess: v => AsyncResult.success(v.value, {
|
||||||
|
waiting: true,
|
||||||
|
}),
|
||||||
|
onFailure: v => AsyncResult.failure(v.cause, {
|
||||||
|
waiting: true,
|
||||||
|
previousSuccess: v.previousSuccess,
|
||||||
|
})
|
||||||
|
})),
|
||||||
|
|
||||||
|
Effect.onExit(this.f(key), exit => Lens.update(
|
||||||
|
state,
|
||||||
|
previous => Exit.match(exit, {
|
||||||
|
onSuccess: v => AsyncResult.success(v),
|
||||||
|
onFailure: c => AsyncResult.match(previous, {
|
||||||
|
onInitial: () => AsyncResult.failure(c),
|
||||||
|
onSuccess: v => AsyncResult.failure(c, {
|
||||||
|
previousSuccess: Option.some(v),
|
||||||
|
}),
|
||||||
|
onFailure: v => AsyncResult.failure(c, {
|
||||||
|
previousSuccess: v.previousSuccess,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.andThen(Effect.all([
|
||||||
|
Effect.fiberId,
|
||||||
|
Lens.get(this.fiber),
|
||||||
|
])),
|
||||||
|
Effect.flatMap(([fiberId, fiber]) => Option.match(fiber, {
|
||||||
|
onSome: v => Equal.equals(fiberId, v.id)
|
||||||
|
? Lens.set(this.fiber, Option.none())
|
||||||
|
: Effect.void,
|
||||||
|
onNone: () => Effect.void,
|
||||||
|
})),
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
|
||||||
|
yield* Lens.set(this.fiber, Option.some(fiber))
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
state: View.View<AsyncResult.AsyncResult<A, E>>
|
||||||
|
): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
|
||||||
|
return View.get(state).pipe(
|
||||||
|
Effect.andThen(initial => Stream.runFoldEffect(
|
||||||
|
View.changes(state),
|
||||||
|
() => initial,
|
||||||
|
(_, result) => Effect.as(Lens.set(this.state, result), result),
|
||||||
|
) as Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>),
|
||||||
|
Effect.tap(result => Lens.set(this.latestFinalResult, Option.some(result))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export declare namespace make {
|
||||||
|
export interface Options<K = never, A = void, E = never, R = never> {
|
||||||
|
readonly f: (key: K) => Effect.Effect<A, E, R>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const make = Effect.fnUntraced(function* <K = never, A = void, E = never, R = never>(
|
||||||
|
options: make.Options<K, A, E, R>
|
||||||
|
): Effect.fn.Return<
|
||||||
|
Mutation<K, A, E, R>,
|
||||||
|
never,
|
||||||
|
Scope.Scope | R
|
||||||
|
> {
|
||||||
|
return new MutationImpl(
|
||||||
|
yield* Effect.context<Scope.Scope | R>(),
|
||||||
|
options.f,
|
||||||
|
|
||||||
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<K>())),
|
||||||
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>())),
|
||||||
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<AsyncResult.AsyncResult<A, E>>(AsyncResult.initial())),
|
||||||
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>())),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
||||||
|
import { Array, Cause, type Context, Effect, Fiber, Option, Pipeable, Predicate, Schema, SchemaError, SchemaIssue, type Scope, Semaphore, SubscriptionRef } from "effect"
|
||||||
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
|
import * as Form from "./Form.js"
|
||||||
|
import * as Lens from "./Lens.js"
|
||||||
|
import * as Mutation from "./Mutation.js"
|
||||||
|
import * as View from "./View.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const MutationFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/MutationForm")
|
||||||
|
export type MutationFormTypeId = typeof MutationFormTypeId
|
||||||
|
|
||||||
|
export interface MutationForm<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, in out MR = never>
|
||||||
|
extends Form.Form<readonly [], A, I, never, never> {
|
||||||
|
readonly [MutationFormTypeId]: MutationFormTypeId
|
||||||
|
|
||||||
|
readonly schema: Schema.ConstraintCodec<A, I, RD, RE>
|
||||||
|
readonly context: Context.Context<Scope.Scope | RD | RE>
|
||||||
|
readonly mutation: Mutation.Mutation<
|
||||||
|
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
|
||||||
|
MA, ME, MR
|
||||||
|
>
|
||||||
|
readonly validationFiber: View.View<Option.Option<Fiber.Fiber<A, Schema.SchemaError>>, never, never>
|
||||||
|
|
||||||
|
readonly run: Effect.Effect<void>
|
||||||
|
readonly submit: Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, Cause.NoSuchElementError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MutationFormImpl<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, in out MR = never>
|
||||||
|
extends Pipeable.Class implements MutationForm<A, I, RD, RE, MA, ME, MR> {
|
||||||
|
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
|
||||||
|
readonly [MutationFormTypeId]: MutationFormTypeId = MutationFormTypeId
|
||||||
|
|
||||||
|
readonly path = [] as const
|
||||||
|
|
||||||
|
readonly encodedValue: Lens.Lens<I, never, never, never, never>
|
||||||
|
readonly isValidating: View.View<boolean, never, never>
|
||||||
|
readonly canCommit: View.View<boolean, never, never>
|
||||||
|
readonly isCommitting: View.View<boolean, never, never>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly schema: Schema.ConstraintCodec<A, I, RD, RE>,
|
||||||
|
readonly context: Context.Context<Scope.Scope | RD | RE>,
|
||||||
|
readonly mutation: Mutation.Mutation<
|
||||||
|
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
|
||||||
|
MA, ME, MR
|
||||||
|
>,
|
||||||
|
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 StandardSchemaV1.Issue[], never, never, never, never>,
|
||||||
|
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, Schema.SchemaError>>, never, never, never, never>,
|
||||||
|
|
||||||
|
readonly runSemaphore: Semaphore.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 => View.map(self.validationFiber, Option.isSome)),
|
||||||
|
View.unwrap,
|
||||||
|
)
|
||||||
|
this.canCommit = Effect.succeed(this).pipe(
|
||||||
|
Effect.map(self => View.map(
|
||||||
|
View.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.state),
|
||||||
|
([value, issues, validationFiber, result]) => (
|
||||||
|
Option.isSome(value) &&
|
||||||
|
Array.isReadonlyArrayEmpty(issues) &&
|
||||||
|
Option.isNone(validationFiber) &&
|
||||||
|
!AsyncResult.isWaiting(result)
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
View.unwrap,
|
||||||
|
)
|
||||||
|
this.isCommitting = Effect.succeed(this).pipe(
|
||||||
|
Effect.map(self => View.map(self.mutation.state, AsyncResult.isWaiting)),
|
||||||
|
View.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.decodeEffect(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(
|
||||||
|
SchemaError.isSchemaError,
|
||||||
|
error => Lens.set(this.issues, SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues),
|
||||||
|
),
|
||||||
|
|
||||||
|
Effect.provide(this.context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get run(): Effect.Effect<void, never, never> {
|
||||||
|
return Lens.get(this.encodedValue).pipe(
|
||||||
|
Effect.flatMap(v => Schema.decodeEffect(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<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, Cause.NoSuchElementError, never> {
|
||||||
|
return Lens.get(this.value).pipe(
|
||||||
|
Effect.flatMap(Effect.fromOption),
|
||||||
|
Effect.flatMap(value => this.submitValue(value)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
submitValue(value: A): Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, never, never> {
|
||||||
|
return Effect.when(
|
||||||
|
Effect.tap(
|
||||||
|
this.mutation.mutate([value, this as any]),
|
||||||
|
result => AsyncResult.isFailure(result)
|
||||||
|
? Option.match(
|
||||||
|
Array.findFirst(
|
||||||
|
result.cause.reasons,
|
||||||
|
reason => Cause.isFailReason(reason) && SchemaError.isSchemaError(reason.error)
|
||||||
|
? Option.some(reason.error)
|
||||||
|
: Option.none(),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
onSome: error => Lens.set(this.issues, SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues),
|
||||||
|
onNone: () => Effect.void,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Effect.void,
|
||||||
|
),
|
||||||
|
View.get(this.canCommit),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isMutationForm = (u: unknown): u is MutationForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationFormTypeId)
|
||||||
|
|
||||||
|
|
||||||
|
export declare namespace make {
|
||||||
|
export interface Options<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, out MR = never>
|
||||||
|
extends Mutation.make.Options<
|
||||||
|
readonly [value: NoInfer<A>, form: MutationForm<NoInfer<A>, NoInfer<I>, NoInfer<RD>, NoInfer<RE>, unknown, unknown, unknown>],
|
||||||
|
MA, ME, MR
|
||||||
|
> {
|
||||||
|
readonly schema: Schema.ConstraintCodec<A, I, RD, RE>
|
||||||
|
readonly initialEncodedValue: NoInfer<I>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never>(
|
||||||
|
options: make.Options<A, I, RD, RE, MA, ME, MR>
|
||||||
|
): Effect.fn.Return<
|
||||||
|
MutationForm<A, I, RD, RE, MA, ME, MR>,
|
||||||
|
never,
|
||||||
|
Scope.Scope | RD | RE | MR
|
||||||
|
> {
|
||||||
|
return new MutationFormImpl(
|
||||||
|
options.schema,
|
||||||
|
yield* Effect.context<Scope.Scope | RD | RE | MR>(),
|
||||||
|
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 StandardSchemaV1.Issue[]>(Array.empty())),
|
||||||
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, Schema.SchemaError>>())),
|
||||||
|
|
||||||
|
yield* Semaphore.make(1),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export declare namespace service {
|
||||||
|
export interface Options<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, out MR = never>
|
||||||
|
extends make.Options<A, I, RD, RE, MA, ME, MR> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const service = <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never>(
|
||||||
|
options: service.Options<A, I, RD, RE, MA, ME, MR>
|
||||||
|
): Effect.Effect<
|
||||||
|
MutationForm<A, I, RD, RE, MA, ME, MR>,
|
||||||
|
never,
|
||||||
|
Scope.Scope | RD | RE | MR
|
||||||
|
> => Effect.tap(
|
||||||
|
make(options),
|
||||||
|
form => Effect.forkScoped(form.run),
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Effect, PubSub, type Scope } from "effect"
|
||||||
|
import type * as React from "react"
|
||||||
|
import * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
|
export * from "effect/PubSub"
|
||||||
|
|
||||||
|
export const useFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
|
||||||
|
values: A
|
||||||
|
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
|
||||||
|
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
|
||||||
|
yield* Component.useReactEffect(() => Effect.flatMap(
|
||||||
|
PubSub.isShutdown(pubsub),
|
||||||
|
shutdown => shutdown ? Effect.succeed(undefined) : Effect.asVoid(PubSub.publish(pubsub, values)),
|
||||||
|
), values)
|
||||||
|
return pubsub
|
||||||
|
})
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import { type Cause, type Context, Duration, Effect, Equal, type Equivalence, Exit, Fiber, Option, Pipeable, Predicate, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||||
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
|
import * as Lens from "./Lens.js"
|
||||||
|
import * as QueryClient from "./QueryClient.js"
|
||||||
|
import * as View from "./View.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
|
||||||
|
export type QueryTypeId = typeof QueryTypeId
|
||||||
|
|
||||||
|
export interface Query<in out K, out A, out E = never, in out R = never>
|
||||||
|
extends Pipeable.Pipeable {
|
||||||
|
readonly [QueryTypeId]: QueryTypeId
|
||||||
|
|
||||||
|
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>
|
||||||
|
readonly key: View.View<K>
|
||||||
|
readonly keyEquivalence: Equivalence.Equivalence<K>
|
||||||
|
readonly f: (key: K) => Effect.Effect<A, E, R>
|
||||||
|
|
||||||
|
readonly staleTime: Duration.Duration
|
||||||
|
readonly refreshOnWindowFocus: boolean
|
||||||
|
|
||||||
|
readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
|
||||||
|
readonly state: View.View<QueryState<K, A, E>>
|
||||||
|
readonly latestFinalState: View.View<Option.Option<FinalQueryState<K, A, E>>>
|
||||||
|
|
||||||
|
readonly run: Effect.Effect<void>
|
||||||
|
fetch(key: K): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError>
|
||||||
|
fetchView(key: K): Effect.Effect<View.View<QueryState<K, A, E>>, Cause.NoSuchElementError>
|
||||||
|
readonly refresh: Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError>
|
||||||
|
readonly refreshView: Effect.Effect<View.View<QueryState<K, A, E>>, Cause.NoSuchElementError>
|
||||||
|
|
||||||
|
readonly invalidateCache: Effect.Effect<void>
|
||||||
|
invalidateCacheEntry(key: K): Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryState<out K, out A, out E = never> {
|
||||||
|
readonly key: K
|
||||||
|
readonly result: AsyncResult.AsyncResult<A, E>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinalQueryState<out K, out A, out E = never> {
|
||||||
|
readonly key: K
|
||||||
|
readonly result: AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isQuery = (u: unknown): u is Query<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId)
|
||||||
|
|
||||||
|
|
||||||
|
export class QueryImpl<in out K, in out A, in out E = never, in out R = never>
|
||||||
|
extends Pipeable.Class implements Query<K, A, E, R> {
|
||||||
|
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>,
|
||||||
|
readonly key: View.View<K>,
|
||||||
|
readonly keyEquivalence: Equivalence.Equivalence<K>,
|
||||||
|
readonly f: (key: K) => Effect.Effect<A, E, R>,
|
||||||
|
|
||||||
|
readonly staleTime: Duration.Duration,
|
||||||
|
readonly refreshOnWindowFocus: boolean,
|
||||||
|
|
||||||
|
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
|
||||||
|
readonly state: Lens.Lens<QueryState<K, A, E>>,
|
||||||
|
readonly latestFinalState: Lens.Lens<Option.Option<FinalQueryState<K, A, E>>>,
|
||||||
|
|
||||||
|
readonly runSemaphore: Semaphore.Semaphore,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
get run(): Effect.Effect<void> {
|
||||||
|
return Effect.all([
|
||||||
|
Stream.runForEach(
|
||||||
|
this.key.changes,
|
||||||
|
key => Effect.gen({ self: this }, function*() {
|
||||||
|
yield* this.interrupt
|
||||||
|
const latestFinalState = yield* Lens.get(this.latestFinalState)
|
||||||
|
|
||||||
|
const state = yield* this.startCached(
|
||||||
|
Option.isSome(latestFinalState) && this.keyEquivalence(key, latestFinalState.value.key)
|
||||||
|
? latestFinalState.value
|
||||||
|
: {
|
||||||
|
key,
|
||||||
|
result: AsyncResult.initial(false),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Effect.forkScoped(this.watch(state))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
Effect.promise(() => import("@effect/platform-browser")).pipe(
|
||||||
|
Effect.flatMap(({ BrowserStream }) => this.refreshOnWindowFocus
|
||||||
|
? Stream.runForEach(
|
||||||
|
BrowserStream.fromEventListenerWindow("focus"),
|
||||||
|
() => this.refreshView,
|
||||||
|
)
|
||||||
|
: Effect.void
|
||||||
|
),
|
||||||
|
Effect.catchDefect(() => Effect.void),
|
||||||
|
),
|
||||||
|
], { concurrency: "unbounded" }).pipe(
|
||||||
|
Effect.ignore,
|
||||||
|
this.runSemaphore.withPermits(1),
|
||||||
|
Effect.provide(this.context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get interrupt(): Effect.Effect<void> {
|
||||||
|
return Effect.flatMap(Lens.get(this.fiber), Option.match({
|
||||||
|
onSome: Fiber.interrupt,
|
||||||
|
onNone: () => Effect.void,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(key: K): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> {
|
||||||
|
return Effect.gen({ self: this }, function*() {
|
||||||
|
yield* this.interrupt
|
||||||
|
const state = yield* this.startCached({
|
||||||
|
key,
|
||||||
|
result: AsyncResult.initial(false),
|
||||||
|
})
|
||||||
|
return yield* this.watch(state)
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(this.context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchView(key: K): Effect.Effect<
|
||||||
|
View.View<QueryState<K, A, E>>,
|
||||||
|
Cause.NoSuchElementError
|
||||||
|
> {
|
||||||
|
return Effect.gen({ self: this }, function*() {
|
||||||
|
yield* this.interrupt
|
||||||
|
const state = yield* this.startCached({
|
||||||
|
key,
|
||||||
|
result: AsyncResult.initial(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Effect.forkScoped(this.watch(state))
|
||||||
|
return state
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(this.context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get refresh(): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> {
|
||||||
|
return Effect.gen({ self: this }, function*() {
|
||||||
|
yield* this.interrupt
|
||||||
|
const latestState = yield* Lens.get(this.state)
|
||||||
|
const latestFinalState = yield* Lens.get(this.latestFinalState)
|
||||||
|
|
||||||
|
const state = yield* this.startCached(
|
||||||
|
Option.isSome(latestFinalState) && this.keyEquivalence(latestState.key, latestFinalState.value.key)
|
||||||
|
? latestFinalState.value
|
||||||
|
: {
|
||||||
|
key: latestState.key,
|
||||||
|
result: AsyncResult.initial(false),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return yield* this.watch(state)
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(this.context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get refreshView(): Effect.Effect<
|
||||||
|
View.View<QueryState<K, A, E>>,
|
||||||
|
Cause.NoSuchElementError
|
||||||
|
> {
|
||||||
|
return Effect.gen({ self: this }, function*() {
|
||||||
|
yield* this.interrupt
|
||||||
|
const latestState = yield* Lens.get(this.state)
|
||||||
|
const latestFinalState = yield* Lens.get(this.latestFinalState)
|
||||||
|
|
||||||
|
const state = yield* this.startCached(
|
||||||
|
Option.isSome(latestFinalState) && this.keyEquivalence(latestState.key, latestFinalState.value.key)
|
||||||
|
? latestFinalState.value
|
||||||
|
: {
|
||||||
|
key: latestState.key,
|
||||||
|
result: AsyncResult.initial(false),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Effect.forkScoped(this.watch(state))
|
||||||
|
return state
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(this.context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
startCached(
|
||||||
|
previous: QueryState<K, A, E>,
|
||||||
|
): Effect.Effect<
|
||||||
|
View.View<QueryState<K, A, E>>,
|
||||||
|
Cause.NoSuchElementError,
|
||||||
|
Scope.Scope | QueryClient.QueryClient | R
|
||||||
|
> {
|
||||||
|
return Effect.flatMap(this.getCacheEntry(previous.key), Option.match({
|
||||||
|
onSome: entry => Effect.flatMap(
|
||||||
|
QueryClient.isQueryClientCacheEntryStale(entry),
|
||||||
|
isStale => isStale
|
||||||
|
? this.start({
|
||||||
|
key: previous.key,
|
||||||
|
result: entry.result as AsyncResult.AsyncResult<A, E>,
|
||||||
|
})
|
||||||
|
: Effect.succeed(View.make({
|
||||||
|
get: Effect.succeed({
|
||||||
|
key: previous.key,
|
||||||
|
result: entry.result as AsyncResult.AsyncResult<A, E>,
|
||||||
|
}),
|
||||||
|
get changes() {
|
||||||
|
return Stream.make({
|
||||||
|
key: previous.key,
|
||||||
|
result: entry.result as AsyncResult.AsyncResult<A, E>,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
onNone: () => this.start(previous),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
start(
|
||||||
|
previous: QueryState<K, A, E>,
|
||||||
|
): Effect.Effect<
|
||||||
|
View.View<QueryState<K, A, E>>,
|
||||||
|
never,
|
||||||
|
Scope.Scope | R
|
||||||
|
> {
|
||||||
|
return Effect.gen({ self: this }, function*() {
|
||||||
|
const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(previous))
|
||||||
|
|
||||||
|
const fiber = yield* Effect.forkScoped(Effect.andThen(
|
||||||
|
Lens.update(state, previous => AsyncResult.match(previous.result, {
|
||||||
|
onInitial: () => ({
|
||||||
|
key: previous.key,
|
||||||
|
result: AsyncResult.initial(true),
|
||||||
|
}),
|
||||||
|
onSuccess: result => ({
|
||||||
|
key: previous.key,
|
||||||
|
result: AsyncResult.success(result.value, {
|
||||||
|
waiting: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
onFailure: result => ({
|
||||||
|
key: previous.key,
|
||||||
|
result: AsyncResult.failure(result.cause, {
|
||||||
|
waiting: true,
|
||||||
|
previousSuccess: result.previousSuccess,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
Effect.onExit(this.f(previous.key), exit => Lens.update(
|
||||||
|
state,
|
||||||
|
previous => Exit.match(exit, {
|
||||||
|
onSuccess: v => ({
|
||||||
|
key: previous.key,
|
||||||
|
result: AsyncResult.success(v),
|
||||||
|
}),
|
||||||
|
onFailure: c => AsyncResult.match(previous.result, {
|
||||||
|
onInitial: () => ({
|
||||||
|
key: previous.key,
|
||||||
|
result: AsyncResult.failure(c),
|
||||||
|
}),
|
||||||
|
onSuccess: v => ({
|
||||||
|
key: previous.key,
|
||||||
|
result: AsyncResult.failure(c, {
|
||||||
|
previousSuccess: Option.some(v),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
onFailure: v => ({
|
||||||
|
key: previous.key,
|
||||||
|
result: AsyncResult.failure(c, {
|
||||||
|
previousSuccess: v.previousSuccess,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.andThen(Effect.all([
|
||||||
|
Effect.fiberId,
|
||||||
|
Lens.get(this.fiber),
|
||||||
|
])),
|
||||||
|
Effect.flatMap(([fiberId, fiber]) => Option.match(fiber, {
|
||||||
|
onSome: v => fiberId === v.id
|
||||||
|
? Lens.set(this.fiber, Option.none())
|
||||||
|
: Effect.void,
|
||||||
|
onNone: () => Effect.void,
|
||||||
|
})),
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
|
||||||
|
yield* Lens.set(this.fiber, Option.some(fiber))
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
view: View.View<QueryState<K, A, E>>
|
||||||
|
): Effect.Effect<FinalQueryState<K, A, E>, never, QueryClient.QueryClient> {
|
||||||
|
return Effect.gen({ self: this }, function*() {
|
||||||
|
const initial = yield* View.get(view)
|
||||||
|
const final = yield* Stream.runFoldEffect(
|
||||||
|
View.changes(view),
|
||||||
|
() => initial,
|
||||||
|
(_, state) => Effect.as(Lens.set(this.state, state), state),
|
||||||
|
) as Effect.Effect<FinalQueryState<K, A, E>>
|
||||||
|
|
||||||
|
yield* Lens.set(this.latestFinalState, Option.some(final))
|
||||||
|
if (AsyncResult.isSuccess(final.result))
|
||||||
|
yield* this.setCacheEntry(final.key, final.result)
|
||||||
|
|
||||||
|
return final
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
makeCacheKey(key: K): QueryClient.QueryClientCacheKey {
|
||||||
|
return new QueryClient.QueryClientCacheKey(key, this.f as (key: unknown) => Effect.Effect<unknown, unknown, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
getCacheEntry(
|
||||||
|
key: K
|
||||||
|
): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> {
|
||||||
|
return Effect.andThen(
|
||||||
|
Effect.all([
|
||||||
|
Effect.succeed(this.makeCacheKey(key)),
|
||||||
|
QueryClient.QueryClient,
|
||||||
|
]),
|
||||||
|
([key, client]) => client.getCacheEntry(key),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setCacheEntry(
|
||||||
|
key: K,
|
||||||
|
result: AsyncResult.Success<A, E>,
|
||||||
|
): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> {
|
||||||
|
return Effect.flatMap(
|
||||||
|
Effect.all([
|
||||||
|
Effect.succeed(this.makeCacheKey(key)),
|
||||||
|
QueryClient.QueryClient,
|
||||||
|
]),
|
||||||
|
([key, client]) => client.setCacheEntry(key, result, this.staleTime),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get invalidateCache(): Effect.Effect<void> {
|
||||||
|
return QueryClient.QueryClient.pipe(
|
||||||
|
Effect.andThen(client => client.invalidateCacheEntries(this.f as (key: unknown) => Effect.Effect<unknown, unknown, unknown>)),
|
||||||
|
Effect.provide(this.context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateCacheEntry(key: K): Effect.Effect<void> {
|
||||||
|
return Effect.all([
|
||||||
|
Effect.succeed(this.makeCacheKey(key)),
|
||||||
|
QueryClient.QueryClient,
|
||||||
|
]).pipe(
|
||||||
|
Effect.andThen(([key, client]) => client.invalidateCacheEntry(key)),
|
||||||
|
Effect.provide(this.context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare namespace make {
|
||||||
|
export interface Options<K, A, E = never, R = never> {
|
||||||
|
readonly key: View.View<K>,
|
||||||
|
readonly keyEquivalence?: Equivalence.Equivalence<K>,
|
||||||
|
readonly f: (key: K) => Effect.Effect<A, E, R>
|
||||||
|
|
||||||
|
readonly staleTime?: Duration.Input
|
||||||
|
readonly refreshOnWindowFocus?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const make = Effect.fnUntraced(function* <K, A, E = never, R = never>(
|
||||||
|
options: make.Options<K, A, E, R>
|
||||||
|
): Effect.fn.Return<
|
||||||
|
Query<K, A, E, R>,
|
||||||
|
Cause.NoSuchElementError,
|
||||||
|
Scope.Scope | QueryClient.QueryClient | R
|
||||||
|
> {
|
||||||
|
const client = yield* QueryClient.QueryClient
|
||||||
|
|
||||||
|
return new QueryImpl(
|
||||||
|
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | R>(),
|
||||||
|
options.key,
|
||||||
|
options.keyEquivalence ?? Equal.asEquivalence(),
|
||||||
|
options.f,
|
||||||
|
|
||||||
|
options.staleTime ? yield* Effect.fromOption(Duration.fromInput(options.staleTime)) : client.defaultStaleTime,
|
||||||
|
options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus,
|
||||||
|
|
||||||
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>())),
|
||||||
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<QueryState<K, A, E>>({
|
||||||
|
key: yield* View.get(options.key),
|
||||||
|
result: AsyncResult.initial(false),
|
||||||
|
})),
|
||||||
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<FinalQueryState<K, A, E>>())),
|
||||||
|
|
||||||
|
yield* Semaphore.make(1),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const service = <K, A, E = never, R = never>(
|
||||||
|
options: make.Options<K, A, E, R>
|
||||||
|
): Effect.Effect<
|
||||||
|
Query<K, A, E, R>,
|
||||||
|
Cause.NoSuchElementError,
|
||||||
|
Scope.Scope | QueryClient.QueryClient | R
|
||||||
|
> => Effect.tap(
|
||||||
|
make(options),
|
||||||
|
query => Effect.forkScoped(query.run),
|
||||||
|
)
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { type Cause, Context, DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, Layer, type Option, Pipeable, Predicate, Schedule, type Scope, Semaphore, SubscriptionRef } from "effect"
|
||||||
|
import type { AsyncResult } from "effect/unstable/reactivity"
|
||||||
|
import * as Lens from "./Lens.js"
|
||||||
|
import type * as View from "./View.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService")
|
||||||
|
export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
|
||||||
|
|
||||||
|
export interface QueryClientService extends Pipeable.Pipeable {
|
||||||
|
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
|
||||||
|
|
||||||
|
readonly cache: View.View<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
|
||||||
|
readonly cacheGcTime: Duration.Duration
|
||||||
|
readonly defaultStaleTime: Duration.Duration
|
||||||
|
readonly defaultRefreshOnWindowFocus: boolean
|
||||||
|
|
||||||
|
readonly run: Effect.Effect<void, Cause.NoSuchElementError>
|
||||||
|
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>>
|
||||||
|
setCacheEntry(
|
||||||
|
key: QueryClientCacheKey,
|
||||||
|
result: AsyncResult.Success<unknown, unknown>,
|
||||||
|
staleTime: Duration.Duration,
|
||||||
|
): Effect.Effect<QueryClientCacheEntry>
|
||||||
|
invalidateCacheEntries(f: (key: unknown) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void>
|
||||||
|
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QueryClient extends Context.Service<QueryClient, QueryClientService>()(
|
||||||
|
"@effect-fc/QueryClient/QueryClient"
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export class QueryClientServiceImpl
|
||||||
|
extends Pipeable.Class
|
||||||
|
implements QueryClientService {
|
||||||
|
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly cache: Lens.Lens<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
|
||||||
|
readonly cacheGcTime: Duration.Duration,
|
||||||
|
readonly defaultStaleTime: Duration.Duration,
|
||||||
|
readonly defaultRefreshOnWindowFocus: boolean,
|
||||||
|
readonly runSemaphore: Semaphore.Semaphore,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
get run(): Effect.Effect<void, Cause.NoSuchElementError> {
|
||||||
|
return this.runSemaphore.withPermits(1)(Effect.repeat(
|
||||||
|
Effect.flatMap(
|
||||||
|
DateTime.now,
|
||||||
|
now => Lens.update(this.cache, HashMap.filter(entry =>
|
||||||
|
Duration.isLessThan(
|
||||||
|
DateTime.distance(entry.lastAccessedAt, now),
|
||||||
|
Duration.sum(entry.staleTime, this.cacheGcTime),
|
||||||
|
)
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
Schedule.spaced("30 second"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>> {
|
||||||
|
return Effect.all([
|
||||||
|
DateTime.now,
|
||||||
|
Effect.flatMap(
|
||||||
|
Effect.map(Lens.get(this.cache), HashMap.get(key)),
|
||||||
|
Effect.fromOption,
|
||||||
|
),
|
||||||
|
]).pipe(
|
||||||
|
Effect.map(([now, entry]) => new QueryClientCacheEntry(entry.result, entry.staleTime, entry.createdAt, now)),
|
||||||
|
Effect.tap(entry => Lens.update(this.cache, HashMap.set(key, entry))),
|
||||||
|
Effect.option,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setCacheEntry(
|
||||||
|
key: QueryClientCacheKey,
|
||||||
|
result: AsyncResult.Success<unknown, unknown>,
|
||||||
|
staleTime: Duration.Duration,
|
||||||
|
): Effect.Effect<QueryClientCacheEntry> {
|
||||||
|
return DateTime.now.pipe(
|
||||||
|
Effect.map(now => new QueryClientCacheEntry(result, staleTime, now, now)),
|
||||||
|
Effect.tap(entry => Lens.update(this.cache, HashMap.set(key, entry))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateCacheEntries(f: (key: unknown) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void> {
|
||||||
|
return Lens.update(this.cache, HashMap.filter((_, key) => !Equivalence.strictEqual()(key.f, f)))
|
||||||
|
}
|
||||||
|
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void> {
|
||||||
|
return Lens.update(this.cache, HashMap.remove(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId)
|
||||||
|
|
||||||
|
export declare namespace make {
|
||||||
|
export interface Options {
|
||||||
|
readonly cacheGcTime?: Duration.Input
|
||||||
|
readonly defaultStaleTime?: Duration.Input
|
||||||
|
readonly defaultRefreshOnWindowFocus?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const make = Effect.fnUntraced(function* (
|
||||||
|
options: make.Options = {}
|
||||||
|
): Effect.fn.Return<QueryClientService, Cause.NoSuchElementError, never> {
|
||||||
|
return new QueryClientServiceImpl(
|
||||||
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>())),
|
||||||
|
yield* Effect.fromOption(Duration.fromInput(options.cacheGcTime ?? "5 minutes")),
|
||||||
|
yield* Effect.fromOption(Duration.fromInput(options.defaultStaleTime ?? "0 minutes")),
|
||||||
|
options.defaultRefreshOnWindowFocus ?? true,
|
||||||
|
yield* Semaphore.make(1),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export declare namespace service {
|
||||||
|
export interface Options extends make.Options {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const service = (
|
||||||
|
options?: service.Options
|
||||||
|
): Effect.Effect<QueryClientService, Cause.NoSuchElementError, Scope.Scope> => Effect.tap(
|
||||||
|
make(options),
|
||||||
|
client => Effect.forkScoped(client.run),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const layer = (options?: service.Options) => Layer.effect(QueryClient, service(options))
|
||||||
|
|
||||||
|
|
||||||
|
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheKey")
|
||||||
|
export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId
|
||||||
|
|
||||||
|
export class QueryClientCacheKey
|
||||||
|
extends Pipeable.Class
|
||||||
|
implements Pipeable.Pipeable, Equal.Equal {
|
||||||
|
readonly [QueryClientCacheKeyTypeId]: QueryClientCacheKeyTypeId = QueryClientCacheKeyTypeId
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly key: unknown,
|
||||||
|
readonly f: (key: unknown) => Effect.Effect<unknown, unknown, unknown>,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
[Equal.symbol](that: Equal.Equal) {
|
||||||
|
return isQueryClientCacheKey(that) && Equal.equals(this.key, that.key) && Equivalence.strictEqual()(this.f, that.f)
|
||||||
|
}
|
||||||
|
[Hash.symbol]() {
|
||||||
|
return Hash.combine(Hash.hash(this.f))(Hash.hash(this.key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isQueryClientCacheKey = (u: unknown): u is QueryClientCacheKey => Predicate.hasProperty(u, QueryClientCacheKeyTypeId)
|
||||||
|
|
||||||
|
|
||||||
|
export const QueryClientCacheEntryTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheEntry")
|
||||||
|
export type QueryClientCacheEntryTypeId = typeof QueryClientCacheEntryTypeId
|
||||||
|
|
||||||
|
export class QueryClientCacheEntry
|
||||||
|
extends Pipeable.Class
|
||||||
|
implements Pipeable.Pipeable {
|
||||||
|
readonly [QueryClientCacheEntryTypeId]: QueryClientCacheEntryTypeId = QueryClientCacheEntryTypeId
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly result: AsyncResult.Success<unknown, unknown>,
|
||||||
|
readonly staleTime: Duration.Duration,
|
||||||
|
readonly createdAt: DateTime.DateTime,
|
||||||
|
readonly lastAccessedAt: DateTime.DateTime,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId)
|
||||||
|
|
||||||
|
export const isQueryClientCacheEntryStale = (
|
||||||
|
self: QueryClientCacheEntry
|
||||||
|
): Effect.Effect<boolean, Cause.NoSuchElementError> => Effect.map(
|
||||||
|
DateTime.now,
|
||||||
|
now => Duration.isGreaterThanOrEqualTo(DateTime.distance(self.createdAt, now), self.staleTime),
|
||||||
|
)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||||
|
import { type Context, Effect, Layer, ManagedRuntime, Predicate } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
|
export const ReactRuntimeTypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime")
|
||||||
|
export type ReactRuntimeTypeId = typeof ReactRuntimeTypeId
|
||||||
|
|
||||||
|
export interface ReactRuntime<R, ER> {
|
||||||
|
new(_: never): Record<string, never>
|
||||||
|
readonly [ReactRuntimeTypeId]: ReactRuntimeTypeId
|
||||||
|
readonly runtime: ManagedRuntime.ManagedRuntime<R, ER>
|
||||||
|
readonly context: React.Context<Context.Context<R>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReactRuntimePrototype = Object.freeze({ [ReactRuntimeTypeId]: ReactRuntimeTypeId } as const)
|
||||||
|
|
||||||
|
export const preludeLayer: Layer.Layer<Component.ScopeMap> = Component.ScopeMap.layer
|
||||||
|
|
||||||
|
export const isReactRuntime = (u: unknown): u is ReactRuntime<unknown, unknown> => Predicate.hasProperty(u, ReactRuntimeTypeId)
|
||||||
|
|
||||||
|
export const make = <R, ER>(
|
||||||
|
layer: Layer.Layer<R, ER>,
|
||||||
|
memoMap?: Layer.MemoMap,
|
||||||
|
): ReactRuntime<Layer.Success<typeof preludeLayer> | R, ER> => Object.setPrototypeOf(
|
||||||
|
Object.assign(function() {}, {
|
||||||
|
runtime: ManagedRuntime.make(
|
||||||
|
Layer.mergeAll(preludeLayer, layer),
|
||||||
|
{ memoMap },
|
||||||
|
),
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: context initialization
|
||||||
|
context: React.createContext<Context.Context<Layer.Success<typeof preludeLayer> | R>>(null!),
|
||||||
|
}),
|
||||||
|
ReactRuntimePrototype,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
export namespace Provider {
|
||||||
|
export interface Props<R, ER> extends React.SuspenseProps {
|
||||||
|
readonly runtime: ReactRuntime<R, ER>
|
||||||
|
readonly children?: React.ReactNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Provider = <R, ER>(
|
||||||
|
{ runtime, children, ...suspenseProps }: Provider.Props<R, ER>
|
||||||
|
): React.ReactNode => {
|
||||||
|
Effect.runSync(Component.useOnChange(
|
||||||
|
() => Effect.addFinalizer(() => runtime.runtime.disposeEffect),
|
||||||
|
[runtime],
|
||||||
|
))
|
||||||
|
|
||||||
|
const promise = React.useMemo(() => runtime.runtime.context(), [runtime])
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
React.Suspense,
|
||||||
|
suspenseProps,
|
||||||
|
React.createElement(ProviderInner<R, ER>, { runtime, promise, children }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProviderInner = <R, ER>(
|
||||||
|
{ runtime, promise, children }: {
|
||||||
|
readonly runtime: ReactRuntime<R, ER>
|
||||||
|
readonly promise: Promise<Context.Context<R>>
|
||||||
|
readonly children?: React.ReactNode
|
||||||
|
}
|
||||||
|
): React.ReactNode => React.createElement(runtime.context, { value: React.use(promise) }, children)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Function } from "effect"
|
||||||
|
import type * as React from "react"
|
||||||
|
|
||||||
|
|
||||||
|
export const value: {
|
||||||
|
<S>(self: React.SetStateAction<S>, prevState: S): S
|
||||||
|
<S>(prevState: S): (self: React.SetStateAction<S>) => S
|
||||||
|
} = Function.dual(2, <S>(self: React.SetStateAction<S>, prevState: S): S =>
|
||||||
|
typeof self === "function"
|
||||||
|
? (self as (prevState: S) => S)(prevState)
|
||||||
|
: self
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Effect, Equivalence, Option, Stream } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
|
export * from "effect/Stream"
|
||||||
|
|
||||||
|
export const use: {
|
||||||
|
<A, E, R>(
|
||||||
|
stream: Stream.Stream<A, E, R>
|
||||||
|
): Effect.Effect<Option.Option<A>, never, R>
|
||||||
|
<A extends NonNullable<unknown>, E, R>(
|
||||||
|
stream: Stream.Stream<A, E, R>,
|
||||||
|
initialValue: A,
|
||||||
|
): Effect.Effect<Option.Some<A>, never, R>
|
||||||
|
} = Effect.fnUntraced(function* <A extends NonNullable<unknown>, E, R>(
|
||||||
|
stream: Stream.Stream<A, E, R>,
|
||||||
|
initialValue?: A,
|
||||||
|
) {
|
||||||
|
const [reactStateValue, setReactStateValue] = React.useState(() => initialValue
|
||||||
|
? Option.some(initialValue)
|
||||||
|
: Option.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Component.useReactEffect(() => Effect.forkScoped(
|
||||||
|
Stream.runForEach(
|
||||||
|
Stream.changesWith(stream, Equivalence.strictEqual()),
|
||||||
|
v => Effect.sync(() => setReactStateValue(Option.some(v))),
|
||||||
|
)
|
||||||
|
), [stream])
|
||||||
|
|
||||||
|
return reactStateValue as Option.Some<A>
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Effect, Equivalence, Stream } from "effect"
|
||||||
|
import { View } from "effect-lens"
|
||||||
|
import * as React from "react"
|
||||||
|
import * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
|
export * from "effect-lens/View"
|
||||||
|
|
||||||
|
export declare namespace useAll {
|
||||||
|
export type Success<T extends readonly View.View<any, any, any>[]> = [T[number]] extends [never]
|
||||||
|
? never
|
||||||
|
: { [K in keyof T]: T[K] extends View.View<infer A, infer _E, infer _R> ? A : never }
|
||||||
|
|
||||||
|
export interface Options<A> {
|
||||||
|
readonly equivalence?: Equivalence.Equivalence<A>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAll = Effect.fnUntraced(function* <const T extends readonly View.View<any, any, any>[]>(
|
||||||
|
elements: T,
|
||||||
|
options?: useAll.Options<useAll.Success<NoInfer<T>>>,
|
||||||
|
): Effect.fn.Return<
|
||||||
|
useAll.Success<T>,
|
||||||
|
[T[number]] extends [never] ? never : T[number] extends View.View<infer _A, infer E, infer _R> ? E : never,
|
||||||
|
[T[number]] extends [never] ? never : T[number] extends View.View<infer _A, infer _E, infer R> ? R : never
|
||||||
|
> {
|
||||||
|
const [reactStateValue, setReactStateValue] = React.useState(
|
||||||
|
yield* Component.useOnMount(() => Effect.all(elements.map(View.get)))
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Component.useReactEffect(() => View.changes(View.zipLatestAll(...elements)).pipe(
|
||||||
|
Stream.changesWith((options?.equivalence as Equivalence.Equivalence<any[]> | undefined) ?? Equivalence.Array(Equivalence.strictEqual())),
|
||||||
|
Stream.runForEach(v =>
|
||||||
|
Effect.sync(() => setReactStateValue(v))
|
||||||
|
),
|
||||||
|
Effect.forkScoped,
|
||||||
|
), elements)
|
||||||
|
|
||||||
|
return reactStateValue as any
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export * as Async from "./Async.js"
|
||||||
|
export * as Component from "./Component.js"
|
||||||
|
export * as ErrorObserver from "./ErrorObserver.js"
|
||||||
|
export * as Form from "./Form.js"
|
||||||
|
export * as Lens from "./Lens.js"
|
||||||
|
export * as LensForm from "./LensForm.js"
|
||||||
|
export * as Memoized from "./Memoized.js"
|
||||||
|
export * as Mutation from "./Mutation.js"
|
||||||
|
export * as MutationForm from "./MutationForm.js"
|
||||||
|
export * as PubSub from "./PubSub.js"
|
||||||
|
export * as Query from "./Query.js"
|
||||||
|
export * as QueryClient from "./QueryClient.js"
|
||||||
|
export * as ReactRuntime from "./ReactRuntime.js"
|
||||||
|
export * as SetStateAction from "./SetStateAction.js"
|
||||||
|
export * as Stream from "./Stream.js"
|
||||||
|
export * as View from "./View.js"
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react"
|
||||||
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import * as Component from "../src/Component.js"
|
||||||
|
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||||
|
|
||||||
|
|
||||||
|
class ValueService extends Context.Service<ValueService, { readonly value: string }>()("ValueService") {}
|
||||||
|
|
||||||
|
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 runtime.runtime.context()
|
||||||
|
|
||||||
|
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 runtime.runtime.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
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 runtime.runtime.context()
|
||||||
|
|
||||||
|
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 runtime.runtime.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("closes the previous scope on dependency changes and unmount", async () => {
|
||||||
|
const cleanup = vi.fn()
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await runtime.runtime.context()
|
||||||
|
|
||||||
|
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 runtime.runtime.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
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 runtime.runtime.context()
|
||||||
|
|
||||||
|
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 runtime.runtime.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps useCallbackSync stable until dependencies change", async () => {
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await runtime.runtime.context()
|
||||||
|
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 runtime.runtime.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("delays cleanup according to finalizerExecutionDebounce", async () => {
|
||||||
|
const cleanup = vi.fn()
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await runtime.runtime.context()
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("DebouncedCleanupProbe")(function*(props: { readonly value: string }) {
|
||||||
|
const result = yield* Component.useOnChange(
|
||||||
|
() => Effect.gen(function*() {
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
|
||||||
|
return props.value
|
||||||
|
}),
|
||||||
|
[props.value],
|
||||||
|
{ finalizerExecutionDebounce: "20 millis" },
|
||||||
|
)
|
||||||
|
|
||||||
|
return <div>{result}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe value="first" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("first")
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe value="second" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("second")
|
||||||
|
expect(cleanup).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5))
|
||||||
|
expect(cleanup).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"), { timeout: 100 })
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"), { timeout: 100 })
|
||||||
|
await runtime.runtime.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
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 runtime.runtime.context()
|
||||||
|
|
||||||
|
const SubComponent = Component.makeUntraced("NonReactiveSubComponent")(function*() {
|
||||||
|
const service = yield* ValueService
|
||||||
|
|
||||||
|
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||||
|
yield* Effect.sync(() => mounts())
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
|
||||||
|
}))
|
||||||
|
|
||||||
|
return <div>{service.value}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withOptions({ nonReactiveTags: [ValueService] })
|
||||||
|
)
|
||||||
|
|
||||||
|
const Parent = Component.makeUntraced("NonReactiveParent")(function*(props: { readonly value: string }) {
|
||||||
|
const serviceLayer = React.useMemo(
|
||||||
|
() => Layer.succeed(ValueService, { value: props.value }),
|
||||||
|
[props.value],
|
||||||
|
)
|
||||||
|
const context = yield* Component.useContextFromLayer(serviceLayer, {
|
||||||
|
finalizerExecutionDebounce: 0,
|
||||||
|
})
|
||||||
|
const Child = yield* Effect.provide(SubComponent.use, context)
|
||||||
|
|
||||||
|
return <Child />
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Parent value="first" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("first")
|
||||||
|
expect(mounts).toHaveBeenCalledTimes(1)
|
||||||
|
expect(unmounts).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Parent value="second" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("second")
|
||||||
|
expect(mounts).toHaveBeenCalledTimes(1)
|
||||||
|
expect(unmounts).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
|
||||||
|
await runtime.runtime.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||||
|
import { Effect, Layer, SubscriptionRef } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import * as Component from "../src/Component.js"
|
||||||
|
import * as Lens from "../src/Lens.js"
|
||||||
|
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||||
|
|
||||||
|
|
||||||
|
const makeRuntime = async () => {
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await runtime.runtime.context()
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtime,
|
||||||
|
effectRuntime,
|
||||||
|
dispose: () => runtime.runtime.dispose(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Lens", () => {
|
||||||
|
it("useState stays in sync with lens updates in both directions", async () => {
|
||||||
|
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||||
|
const ref = await Effect.runPromise(SubscriptionRef.make(0))
|
||||||
|
const lens = Lens.fromSubscriptionRef(ref)
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("LensUseStateProbe")(function*() {
|
||||||
|
const [value, setValue] = yield* Lens.useState(lens)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>{value}</div>
|
||||||
|
<button onClick={() => setValue(previous => previous + 1)}>increment</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("0")
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(lens, 5))
|
||||||
|
await screen.findByText("5")
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "increment" }))
|
||||||
|
await screen.findByText("6")
|
||||||
|
expect(await Effect.runPromise(Lens.get(lens))).toBe(6)
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("useState respects the provided equivalence when subscribing to lens changes", async () => {
|
||||||
|
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||||
|
const ref = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
|
||||||
|
const lens = Lens.fromSubscriptionRef(ref)
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("LensUseStateEquivalenceProbe")(function*() {
|
||||||
|
const [value] = yield* Lens.useState(lens, {
|
||||||
|
equivalence: (self, that) => self.id === that.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <div>{value.label}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("first")
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(lens, { id: 1, label: "ignored" }))
|
||||||
|
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||||
|
expect(screen.queryByText("ignored")).toBeNull()
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(lens, { id: 2, label: "updated" }))
|
||||||
|
await screen.findByText("updated")
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("useFromReactState writes React state changes into the returned lens", async () => {
|
||||||
|
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||||
|
let lens: Lens.Lens<string, never, never, never, never> | undefined
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("LensUseFromReactStateProbe")(function*() {
|
||||||
|
const [value, setValue] = React.useState("hello")
|
||||||
|
const reactLens = yield* Lens.useFromReactState([value, setValue])
|
||||||
|
|
||||||
|
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||||
|
lens = reactLens
|
||||||
|
}))
|
||||||
|
|
||||||
|
return <button onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("hello")
|
||||||
|
await waitFor(() => expect(lens).toBeDefined())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "hello" }))
|
||||||
|
await screen.findByText("hello!")
|
||||||
|
await waitFor(async () => expect(await Effect.runPromise(Lens.get(lens!))).toBe("hello!"))
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("useFromReactState respects equivalence when lens updates flow back into React state", async () => {
|
||||||
|
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||||
|
let lens: Lens.Lens<{ readonly id: number; readonly label: string }, never, never, never, never> | undefined
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("LensUseFromReactStateEquivalenceProbe")(function*() {
|
||||||
|
const [value, setValue] = React.useState({ id: 1, label: "first" })
|
||||||
|
const reactLens = yield* Lens.useFromReactState([value, setValue], {
|
||||||
|
equivalence: (self, that) => self.id === that.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||||
|
lens = reactLens
|
||||||
|
}))
|
||||||
|
|
||||||
|
return <div>{value.label}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("first")
|
||||||
|
await waitFor(() => expect(lens).toBeDefined())
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(lens!, { id: 1, label: "ignored" }))
|
||||||
|
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||||
|
expect(screen.queryByText("ignored")).toBeNull()
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(lens!, { id: 2, label: "updated" }))
|
||||||
|
await screen.findByText("updated")
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { Effect, Option, type Scope, Stream } from "effect"
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import * as Query from "../src/Query.js"
|
||||||
|
import * as QueryClient from "../src/QueryClient.js"
|
||||||
|
import * as Result from "../src/Result.js"
|
||||||
|
|
||||||
|
|
||||||
|
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
|
||||||
|
Effect.runPromise(Effect.scoped(effect.pipe(
|
||||||
|
Effect.provide(QueryClient.QueryClient.Default),
|
||||||
|
)))
|
||||||
|
|
||||||
|
const expectSuccessValue = <A, E, P>(
|
||||||
|
result: Result.Result<A, E, P>,
|
||||||
|
): A => {
|
||||||
|
expect(Result.isSuccess(result)).toBe(true)
|
||||||
|
|
||||||
|
if (!Result.isSuccess(result))
|
||||||
|
throw new Error(`Expected Success result, received ${result._tag}`)
|
||||||
|
|
||||||
|
return result.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectSomeValue = <A>(option: Option.Option<A>): A => {
|
||||||
|
expect(Option.isSome(option)).toBe(true)
|
||||||
|
|
||||||
|
if (!Option.isSome(option))
|
||||||
|
throw new Error("Expected Some option, received None")
|
||||||
|
|
||||||
|
return option.value
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Query", () => {
|
||||||
|
it("fetch caches successful results until they are invalidated or stale", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||||
|
|
||||||
|
const result = await runQueryTest(Effect.gen(function*() {
|
||||||
|
const query = yield* Query.make({
|
||||||
|
key,
|
||||||
|
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||||
|
calls += 1
|
||||||
|
return `value:${id}:${calls}`
|
||||||
|
}),
|
||||||
|
staleTime: "1 minute",
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = yield* query.fetch([1])
|
||||||
|
const second = yield* query.fetch([1])
|
||||||
|
|
||||||
|
return [first, second] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(calls).toBe(1)
|
||||||
|
expect(result[0]._tag).toBe("Success")
|
||||||
|
expect(result[1]._tag).toBe("Success")
|
||||||
|
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||||
|
expect(expectSuccessValue(result[1])).toBe("value:1:1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("refresh reruns the latest query key", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||||
|
|
||||||
|
const result = await runQueryTest(Effect.gen(function*() {
|
||||||
|
const query = yield* Query.make({
|
||||||
|
key,
|
||||||
|
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||||
|
calls += 1
|
||||||
|
return `value:${id}:${calls}`
|
||||||
|
}),
|
||||||
|
staleTime: "0 millis",
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = yield* query.fetch([1])
|
||||||
|
yield* Effect.sleep("1 millis")
|
||||||
|
const refreshed = yield* query.refresh
|
||||||
|
|
||||||
|
return [first, refreshed] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(calls).toBe(2)
|
||||||
|
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||||
|
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||||
|
|
||||||
|
const result = await runQueryTest(Effect.gen(function*() {
|
||||||
|
const query = yield* Query.make({
|
||||||
|
key,
|
||||||
|
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||||
|
calls += 1
|
||||||
|
return `value:${id}:${calls}`
|
||||||
|
}),
|
||||||
|
staleTime: "1 minute",
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = yield* query.fetch([1])
|
||||||
|
yield* query.invalidateCacheEntry([1])
|
||||||
|
const second = yield* query.fetch([1])
|
||||||
|
|
||||||
|
return [first, second] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(calls).toBe(2)
|
||||||
|
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||||
|
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("invalidateCache clears cached entries for the query function", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||||
|
|
||||||
|
const result = await runQueryTest(Effect.gen(function*() {
|
||||||
|
const query = yield* Query.make({
|
||||||
|
key,
|
||||||
|
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||||
|
calls += 1
|
||||||
|
return `value:${id}:${calls}`
|
||||||
|
}),
|
||||||
|
staleTime: "1 minute",
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = yield* query.fetch([1])
|
||||||
|
yield* query.invalidateCache
|
||||||
|
const second = yield* query.fetch([1])
|
||||||
|
|
||||||
|
return [first, second] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(calls).toBe(2)
|
||||||
|
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||||
|
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("service starts the key stream automatically and updates latest state", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const key = Stream.make([1] as const) as Stream.Stream<readonly [number]>
|
||||||
|
|
||||||
|
const effect = Effect.gen(function*() {
|
||||||
|
const query = yield* Query.service({
|
||||||
|
key,
|
||||||
|
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||||
|
calls += 1
|
||||||
|
return `value:${id}:${calls}`
|
||||||
|
}),
|
||||||
|
staleTime: "1 minute",
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Effect.sleep("10 millis")
|
||||||
|
|
||||||
|
return {
|
||||||
|
final: yield* query.result.get,
|
||||||
|
latestKey: yield* query.latestKey.get,
|
||||||
|
latestFinalResult: yield* query.latestFinalResult.get,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await runQueryTest(effect)
|
||||||
|
|
||||||
|
expect(calls).toBe(1)
|
||||||
|
expect(expectSuccessValue(result.final)).toBe("value:1:1")
|
||||||
|
expect(expectSomeValue(result.latestKey)).toEqual([1])
|
||||||
|
expect(expectSuccessValue(expectSomeValue(result.latestFinalResult))).toBe("value:1:1")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react"
|
||||||
|
import { Effect, Layer, SubscriptionRef } from "effect"
|
||||||
|
import { Lens } from "effect-lens"
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import * as Component from "../src/Component.js"
|
||||||
|
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||||
|
import * as View from "../src/View.js"
|
||||||
|
|
||||||
|
|
||||||
|
const makeRuntime = async () => {
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await runtime.runtime.context()
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtime,
|
||||||
|
effectRuntime,
|
||||||
|
dispose: () => runtime.runtime.dispose(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("View", () => {
|
||||||
|
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("ViewUseAllProbe")(function*() {
|
||||||
|
const [currentCount, currentLabel] = yield* View.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("ViewUseAllEquivalenceProbe")(function*() {
|
||||||
|
const [currentItem, currentFlag] = yield* View.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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Enable latest features
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
// "allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
// "allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
// "noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
|
||||||
|
// Build
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
|
||||||
|
"plugins": [
|
||||||
|
{ "name": "@effect/language-service" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"include": ["./src"],
|
||||||
|
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vitest/config"
|
||||||
|
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
include: ["test/**/*.test.ts?(x)"],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -32,13 +32,17 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint:tsc": "tsc --noEmit",
|
"lint:tsc": "tsc --noEmit",
|
||||||
"lint:biome": "biome lint",
|
"lint:biome": "biome lint",
|
||||||
|
"test": "vitest run",
|
||||||
"pack": "npm pack",
|
"pack": "npm pack",
|
||||||
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
|
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
|
||||||
"clean:dist": "rm -rf dist",
|
"clean:dist": "rm -rf dist",
|
||||||
"clean:modules": "rm -rf node_modules"
|
"clean:modules": "rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@effect/platform-browser": "^0.76.0"
|
"@effect/platform-browser": "^0.76.0",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react"
|
||||||
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import * as Component from "../src/Component.js"
|
||||||
|
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||||
|
|
||||||
|
|
||||||
|
class ValueService extends Context.Tag("ValueService")<ValueService, { readonly value: string }>() {}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Component", () => {
|
||||||
|
it("runs useOnMount only once across rerenders", async () => {
|
||||||
|
const onMount = vi.fn(() => Effect.succeed("mounted"))
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("UseOnMountProbe")(function*() {
|
||||||
|
const value = yield* Component.useOnMount(onMount)
|
||||||
|
return <div>{value}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("mounted")
|
||||||
|
expect(onMount).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
expect(await screen.findByText("mounted")).toBeTruthy()
|
||||||
|
expect(onMount).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("recomputes useOnChange only when dependencies change", async () => {
|
||||||
|
const onChange = vi.fn((value: number) => Effect.succeed(`value:${value}`))
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("UseOnChangeProbe")(function*(props: { readonly value: number }) {
|
||||||
|
const result = yield* Component.useOnChange(() => onChange(props.value), [props.value], {
|
||||||
|
finalizerExecutionDebounce: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <div>{result}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe value={1} />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("value:1")
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe value={1} />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByText("value:1")).toBeTruthy()
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe value={2} />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("value:2")
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("closes the previous scope on dependency changes and unmount", async () => {
|
||||||
|
const cleanup = vi.fn()
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("ScopeCleanupProbe")(function*(props: { readonly value: string }) {
|
||||||
|
const result = yield* Component.useOnChange(
|
||||||
|
() => Effect.gen(function*() {
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
|
||||||
|
return props.value
|
||||||
|
}),
|
||||||
|
[props.value],
|
||||||
|
{ finalizerExecutionDebounce: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return <div>{result}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe value="first" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("first")
|
||||||
|
expect(cleanup).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe value="second" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("second")
|
||||||
|
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"))
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
|
||||||
|
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"))
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(2)
|
||||||
|
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("runs useReactEffect setup and cleanup when dependencies change", async () => {
|
||||||
|
const lifecycle = vi.fn<(message: string) => void>()
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("UseReactEffectProbe")(function*(props: { readonly value: string }) {
|
||||||
|
yield* Component.useReactEffect(() =>
|
||||||
|
Effect.gen(function*() {
|
||||||
|
yield* Effect.sync(() => lifecycle(`mount:${props.value}`))
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(() => lifecycle(`cleanup:${props.value}`)))
|
||||||
|
}),
|
||||||
|
[props.value])
|
||||||
|
|
||||||
|
return <div>{props.value}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe value="first" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("first")
|
||||||
|
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:first"))
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe value="second" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("second")
|
||||||
|
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:first"))
|
||||||
|
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:second"))
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
|
||||||
|
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:second"))
|
||||||
|
expect(lifecycle.mock.calls.map(([message]) => message)).toEqual([
|
||||||
|
"mount:first",
|
||||||
|
"cleanup:first",
|
||||||
|
"mount:second",
|
||||||
|
"cleanup:second",
|
||||||
|
])
|
||||||
|
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps useCallbackSync stable until dependencies change", async () => {
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||||
|
const seenCallbacks: Array<(value: number) => string> = []
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("UseCallbackSyncProbe")(function*(props: { readonly prefix: string }) {
|
||||||
|
const callback = yield* Component.useCallbackSync(
|
||||||
|
(value: number) => Effect.succeed(`${props.prefix}:${value}`),
|
||||||
|
[props.prefix],
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||||
|
seenCallbacks.push(callback)
|
||||||
|
}))
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
seenCallbacks.push(callback)
|
||||||
|
}, [callback])
|
||||||
|
|
||||||
|
return <div>{callback(1)}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe prefix="a" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("a:1")
|
||||||
|
expect(seenCallbacks).toHaveLength(2)
|
||||||
|
expect(seenCallbacks[0]).toBe(seenCallbacks[1])
|
||||||
|
expect(seenCallbacks[0]?.(2)).toBe("a:2")
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe prefix="a" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("a:1")
|
||||||
|
expect(seenCallbacks).toHaveLength(2)
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe prefix="b" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("b:1")
|
||||||
|
await waitFor(() => expect(seenCallbacks).toHaveLength(3))
|
||||||
|
expect(seenCallbacks[2]).not.toBe(seenCallbacks[1])
|
||||||
|
expect(seenCallbacks[2]?.(2)).toBe("b:2")
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("delays cleanup according to finalizerExecutionDebounce", async () => {
|
||||||
|
const cleanup = vi.fn()
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("DebouncedCleanupProbe")(function*(props: { readonly value: string }) {
|
||||||
|
const result = yield* Component.useOnChange(
|
||||||
|
() => Effect.gen(function*() {
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
|
||||||
|
return props.value
|
||||||
|
}),
|
||||||
|
[props.value],
|
||||||
|
{ finalizerExecutionDebounce: "20 millis" },
|
||||||
|
)
|
||||||
|
|
||||||
|
return <div>{result}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe value="first" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("first")
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe value="second" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("second")
|
||||||
|
expect(cleanup).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5))
|
||||||
|
expect(cleanup).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"), { timeout: 100 })
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"), { timeout: 100 })
|
||||||
|
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not remount a component when only nonReactiveTags change", async () => {
|
||||||
|
const mounts = vi.fn()
|
||||||
|
const unmounts = vi.fn()
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||||
|
|
||||||
|
const SubComponent = Component.makeUntraced("NonReactiveSubComponent")(function*() {
|
||||||
|
const service = yield* ValueService
|
||||||
|
|
||||||
|
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||||
|
yield* Effect.sync(() => mounts())
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
|
||||||
|
}))
|
||||||
|
|
||||||
|
return <div>{service.value}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withOptions({ nonReactiveTags: [ValueService] })
|
||||||
|
)
|
||||||
|
|
||||||
|
const Parent = Component.makeUntraced("NonReactiveParent")(function*(props: { readonly value: string }) {
|
||||||
|
const serviceLayer = React.useMemo(
|
||||||
|
() => Layer.succeed(ValueService, { value: props.value }),
|
||||||
|
[props.value],
|
||||||
|
)
|
||||||
|
const context = yield* Component.useContextFromLayer(serviceLayer, {
|
||||||
|
finalizerExecutionDebounce: 0,
|
||||||
|
})
|
||||||
|
const Child = yield* Effect.provide(SubComponent.use, context)
|
||||||
|
|
||||||
|
return <Child />
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Parent value="first" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("first")
|
||||||
|
expect(mounts).toHaveBeenCalledTimes(1)
|
||||||
|
expect(unmounts).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Parent value="second" />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("second")
|
||||||
|
expect(mounts).toHaveBeenCalledTimes(1)
|
||||||
|
expect(unmounts).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
|
||||||
|
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||||
|
import { Effect, Layer, SubscriptionRef } from "effect"
|
||||||
|
import * as React from "react"
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import * as Component from "../src/Component.js"
|
||||||
|
import * as Lens from "../src/Lens.js"
|
||||||
|
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||||
|
|
||||||
|
|
||||||
|
const makeRuntime = async () => {
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtime,
|
||||||
|
effectRuntime,
|
||||||
|
dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Lens", () => {
|
||||||
|
it("useState stays in sync with lens updates in both directions", async () => {
|
||||||
|
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||||
|
const ref = await Effect.runPromise(SubscriptionRef.make(0))
|
||||||
|
const lens = Lens.fromSubscriptionRef(ref)
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("LensUseStateProbe")(function*() {
|
||||||
|
const [value, setValue] = yield* Lens.useState(lens)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>{value}</div>
|
||||||
|
<button onClick={() => setValue(previous => previous + 1)}>increment</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("0")
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(lens, 5))
|
||||||
|
await screen.findByText("5")
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "increment" }))
|
||||||
|
await screen.findByText("6")
|
||||||
|
expect(await Effect.runPromise(Lens.get(lens))).toBe(6)
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("useState respects the provided equivalence when subscribing to lens changes", async () => {
|
||||||
|
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||||
|
const ref = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
|
||||||
|
const lens = Lens.fromSubscriptionRef(ref)
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("LensUseStateEquivalenceProbe")(function*() {
|
||||||
|
const [value] = yield* Lens.useState(lens, {
|
||||||
|
equivalence: (self, that) => self.id === that.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <div>{value.label}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("first")
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(lens, { id: 1, label: "ignored" }))
|
||||||
|
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||||
|
expect(screen.queryByText("ignored")).toBeNull()
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(lens, { id: 2, label: "updated" }))
|
||||||
|
await screen.findByText("updated")
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("useFromReactState writes React state changes into the returned lens", async () => {
|
||||||
|
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||||
|
let lens: Lens.Lens<string, never, never, never, never> | undefined
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("LensUseFromReactStateProbe")(function*() {
|
||||||
|
const [value, setValue] = React.useState("hello")
|
||||||
|
const reactLens = yield* Lens.useFromReactState([value, setValue])
|
||||||
|
|
||||||
|
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||||
|
lens = reactLens
|
||||||
|
}))
|
||||||
|
|
||||||
|
return <button onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("hello")
|
||||||
|
await waitFor(() => expect(lens).toBeDefined())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "hello" }))
|
||||||
|
await screen.findByText("hello!")
|
||||||
|
await waitFor(async () => expect(await Effect.runPromise(Lens.get(lens!))).toBe("hello!"))
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("useFromReactState respects equivalence when lens updates flow back into React state", async () => {
|
||||||
|
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||||
|
let lens: Lens.Lens<{ readonly id: number; readonly label: string }, never, never, never, never> | undefined
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("LensUseFromReactStateEquivalenceProbe")(function*() {
|
||||||
|
const [value, setValue] = React.useState({ id: 1, label: "first" })
|
||||||
|
const reactLens = yield* Lens.useFromReactState([value, setValue], {
|
||||||
|
equivalence: (self, that) => self.id === that.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||||
|
lens = reactLens
|
||||||
|
}))
|
||||||
|
|
||||||
|
return <div>{value.label}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("first")
|
||||||
|
await waitFor(() => expect(lens).toBeDefined())
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(lens!, { id: 1, label: "ignored" }))
|
||||||
|
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||||
|
expect(screen.queryByText("ignored")).toBeNull()
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(lens!, { id: 2, label: "updated" }))
|
||||||
|
await screen.findByText("updated")
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { Effect, Option, type Scope, Stream } from "effect"
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import * as Query from "../src/Query.js"
|
||||||
|
import * as QueryClient from "../src/QueryClient.js"
|
||||||
|
import * as Result from "../src/Result.js"
|
||||||
|
|
||||||
|
|
||||||
|
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
|
||||||
|
Effect.runPromise(Effect.scoped(effect.pipe(
|
||||||
|
Effect.provide(QueryClient.QueryClient.Default),
|
||||||
|
)))
|
||||||
|
|
||||||
|
const expectSuccessValue = <A, E, P>(
|
||||||
|
result: Result.Result<A, E, P>,
|
||||||
|
): A => {
|
||||||
|
expect(Result.isSuccess(result)).toBe(true)
|
||||||
|
|
||||||
|
if (!Result.isSuccess(result))
|
||||||
|
throw new Error(`Expected Success result, received ${result._tag}`)
|
||||||
|
|
||||||
|
return result.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectSomeValue = <A>(option: Option.Option<A>): A => {
|
||||||
|
expect(Option.isSome(option)).toBe(true)
|
||||||
|
|
||||||
|
if (!Option.isSome(option))
|
||||||
|
throw new Error("Expected Some option, received None")
|
||||||
|
|
||||||
|
return option.value
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Query", () => {
|
||||||
|
it("fetch caches successful results until they are invalidated or stale", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||||
|
|
||||||
|
const result = await runQueryTest(Effect.gen(function*() {
|
||||||
|
const query = yield* Query.make({
|
||||||
|
key,
|
||||||
|
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||||
|
calls += 1
|
||||||
|
return `value:${id}:${calls}`
|
||||||
|
}),
|
||||||
|
staleTime: "1 minute",
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = yield* query.fetch([1])
|
||||||
|
const second = yield* query.fetch([1])
|
||||||
|
|
||||||
|
return [first, second] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(calls).toBe(1)
|
||||||
|
expect(result[0]._tag).toBe("Success")
|
||||||
|
expect(result[1]._tag).toBe("Success")
|
||||||
|
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||||
|
expect(expectSuccessValue(result[1])).toBe("value:1:1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("refresh reruns the latest query key", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||||
|
|
||||||
|
const result = await runQueryTest(Effect.gen(function*() {
|
||||||
|
const query = yield* Query.make({
|
||||||
|
key,
|
||||||
|
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||||
|
calls += 1
|
||||||
|
return `value:${id}:${calls}`
|
||||||
|
}),
|
||||||
|
staleTime: "0 millis",
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = yield* query.fetch([1])
|
||||||
|
yield* Effect.sleep("1 millis")
|
||||||
|
const refreshed = yield* query.refresh
|
||||||
|
|
||||||
|
return [first, refreshed] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(calls).toBe(2)
|
||||||
|
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||||
|
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||||
|
|
||||||
|
const result = await runQueryTest(Effect.gen(function*() {
|
||||||
|
const query = yield* Query.make({
|
||||||
|
key,
|
||||||
|
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||||
|
calls += 1
|
||||||
|
return `value:${id}:${calls}`
|
||||||
|
}),
|
||||||
|
staleTime: "1 minute",
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = yield* query.fetch([1])
|
||||||
|
yield* query.invalidateCacheEntry([1])
|
||||||
|
const second = yield* query.fetch([1])
|
||||||
|
|
||||||
|
return [first, second] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(calls).toBe(2)
|
||||||
|
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||||
|
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("invalidateCache clears cached entries for the query function", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||||
|
|
||||||
|
const result = await runQueryTest(Effect.gen(function*() {
|
||||||
|
const query = yield* Query.make({
|
||||||
|
key,
|
||||||
|
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||||
|
calls += 1
|
||||||
|
return `value:${id}:${calls}`
|
||||||
|
}),
|
||||||
|
staleTime: "1 minute",
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = yield* query.fetch([1])
|
||||||
|
yield* query.invalidateCache
|
||||||
|
const second = yield* query.fetch([1])
|
||||||
|
|
||||||
|
return [first, second] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(calls).toBe(2)
|
||||||
|
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||||
|
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("service starts the key stream automatically and updates latest state", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const key = Stream.make([1] as const) as Stream.Stream<readonly [number]>
|
||||||
|
|
||||||
|
const effect = Effect.gen(function*() {
|
||||||
|
const query = yield* Query.service({
|
||||||
|
key,
|
||||||
|
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||||
|
calls += 1
|
||||||
|
return `value:${id}:${calls}`
|
||||||
|
}),
|
||||||
|
staleTime: "1 minute",
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Effect.sleep("10 millis")
|
||||||
|
|
||||||
|
return {
|
||||||
|
final: yield* query.result.get,
|
||||||
|
latestKey: yield* query.latestKey.get,
|
||||||
|
latestFinalResult: yield* query.latestFinalResult.get,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await runQueryTest(effect)
|
||||||
|
|
||||||
|
expect(calls).toBe(1)
|
||||||
|
expect(expectSuccessValue(result.final)).toBe("value:1:1")
|
||||||
|
expect(expectSomeValue(result.latestKey)).toEqual([1])
|
||||||
|
expect(expectSuccessValue(expectSomeValue(result.latestFinalResult))).toBe("value:1:1")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react"
|
||||||
|
import { Effect, Fiber, Layer, Stream, SubscriptionRef } from "effect"
|
||||||
|
import { Lens } from "effect-lens"
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import * as Component from "../src/Component.js"
|
||||||
|
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||||
|
import * as Subscribable from "../src/Subscribable.js"
|
||||||
|
|
||||||
|
|
||||||
|
const makeRuntime = async () => {
|
||||||
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtime,
|
||||||
|
effectRuntime,
|
||||||
|
dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Subscribable", () => {
|
||||||
|
it("zipLatestAll reads current values from all inputs", async () => {
|
||||||
|
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
|
||||||
|
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
|
||||||
|
const left = Lens.fromSubscriptionRef(leftRef)
|
||||||
|
const right = Lens.fromSubscriptionRef(rightRef)
|
||||||
|
|
||||||
|
const zipped = Subscribable.zipLatestAll(left, right)
|
||||||
|
|
||||||
|
expect(await Effect.runPromise(zipped.get)).toEqual([1, "a"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("zipLatestAll emits updates when any input changes", async () => {
|
||||||
|
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
|
||||||
|
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
|
||||||
|
const left = Lens.fromSubscriptionRef(leftRef)
|
||||||
|
const right = Lens.fromSubscriptionRef(rightRef)
|
||||||
|
|
||||||
|
const zipped = Subscribable.zipLatestAll(left, right)
|
||||||
|
const values: Array<readonly [number, string]> = []
|
||||||
|
|
||||||
|
const collector = Effect.runFork(Effect.scoped(zipped.changes.pipe(
|
||||||
|
Stream.runForEach(value => Effect.sync(() => {
|
||||||
|
values.push(value as readonly [number, string])
|
||||||
|
})),
|
||||||
|
)))
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(left, 2))
|
||||||
|
await waitFor(() => expect(values).toContainEqual([2, "a"]))
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(right, "b"))
|
||||||
|
await waitFor(() => expect(values).toContainEqual([2, "b"]))
|
||||||
|
|
||||||
|
Fiber.interruptFork(collector)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("useAll returns the latest values and rerenders when any input changes", async () => {
|
||||||
|
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||||
|
const countRef = await Effect.runPromise(SubscriptionRef.make(1))
|
||||||
|
const labelRef = await Effect.runPromise(SubscriptionRef.make("a"))
|
||||||
|
const count = Lens.fromSubscriptionRef(countRef)
|
||||||
|
const label = Lens.fromSubscriptionRef(labelRef)
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("SubscribableUseAllProbe")(function*() {
|
||||||
|
const [currentCount, currentLabel] = yield* Subscribable.useAll([count, label])
|
||||||
|
|
||||||
|
return <div>{`${currentCount}:${currentLabel}`}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("1:a")
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(count, 2))
|
||||||
|
await screen.findByText("2:a")
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(label, "b"))
|
||||||
|
await screen.findByText("2:b")
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("useAll respects the provided equivalence when processing updates", async () => {
|
||||||
|
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||||
|
const itemRef = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
|
||||||
|
const flagRef = await Effect.runPromise(SubscriptionRef.make(true))
|
||||||
|
const item = Lens.fromSubscriptionRef(itemRef)
|
||||||
|
const flag = Lens.fromSubscriptionRef(flagRef)
|
||||||
|
|
||||||
|
const Probe = Component.makeUntraced("SubscribableUseAllEquivalenceProbe")(function*() {
|
||||||
|
const [currentItem, currentFlag] = yield* Subscribable.useAll([item, flag], {
|
||||||
|
equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) =>
|
||||||
|
selfItem.id === thatItem.id && selfFlag === thatFlag,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <div>{`${currentItem.label}:${currentFlag ? "on" : "off"}`}</div>
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
|
<Probe />
|
||||||
|
</runtime.context.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText("first:on")
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(item, { id: 1, label: "ignored" }))
|
||||||
|
await waitFor(() => expect(screen.getByText("first:on")).toBeTruthy())
|
||||||
|
expect(screen.queryByText("ignored:on")).toBeNull()
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(flag, false))
|
||||||
|
await screen.findByText("ignored:off")
|
||||||
|
|
||||||
|
await Effect.runPromise(Lens.set(item, { id: 2, label: "updated" }))
|
||||||
|
await screen.findByText("updated:off")
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vitest/config"
|
||||||
|
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
include: ["test/**/*.test.ts?(x)"],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
.tanstack
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Effect FC Next Example
|
||||||
|
|
||||||
|
Minimal React example for `effect-fc-next`, Effect V4, and `effect-lens@2`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The counter demonstrates a V4 `SubscriptionRef` exposed as an Effect Lens and
|
||||||
|
rendered through an Effect-FC component.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||||
|
"root": false,
|
||||||
|
"extends": "//",
|
||||||
|
"files": {
|
||||||
|
"includes": ["./src/**", "!src/routeTree.gen.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + React + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "@effect-fc/example-next",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"dev": "vite",
|
||||||
|
"lint:tsc": "tsc --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",
|
||||||
|
"vite": "^8.0.16"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@effect/platform-browser": "4.0.0-beta.98",
|
||||||
|
"@radix-ui/themes": "^3.3.0",
|
||||||
|
"effect": "4.0.0-beta.98",
|
||||||
|
"effect-fc-next": "workspace:*",
|
||||||
|
"react-icons": "^5.6.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@types/react": "^19.2.15",
|
||||||
|
"effect": "4.0.0-beta.98",
|
||||||
|
"react": "^19.2.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
|
||||||
|
import { Array, Option, Struct } from "effect"
|
||||||
|
import { Component, Form, View } from "effect-fc-next"
|
||||||
|
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={e => input.setValue(e.target.value)}
|
||||||
|
disabled={isCommitting}
|
||||||
|
{...Struct.omit(props, ["form"])}
|
||||||
|
>
|
||||||
|
{isValidating &&
|
||||||
|
<TextField.Slot side="right">
|
||||||
|
<Spinner />
|
||||||
|
</TextField.Slot>
|
||||||
|
}
|
||||||
|
|
||||||
|
{props.children}
|
||||||
|
</TextField.Root>
|
||||||
|
|
||||||
|
{Option.match(Array.head(issues), {
|
||||||
|
onSome: issue => (
|
||||||
|
<Callout.Root>
|
||||||
|
<Callout.Text>{issue.message}</Callout.Text>
|
||||||
|
</Callout.Root>
|
||||||
|
),
|
||||||
|
|
||||||
|
onNone: () => <></>,
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withSignature<TextFieldFormInputView.Signature>()
|
||||||
|
)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
|
||||||
|
import { Array, Option, Struct } from "effect"
|
||||||
|
import { Component, Form, View } from "effect-fc-next"
|
||||||
|
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* View.useAll([
|
||||||
|
props.form.issues,
|
||||||
|
props.form.isValidating,
|
||||||
|
props.form.isCommitting,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
<TextField.Root
|
||||||
|
value={input.value}
|
||||||
|
onChange={e => input.setValue(e.target.value)}
|
||||||
|
disabled={!input.enabled || isCommitting}
|
||||||
|
{...Struct.omit(props, ["form", "defaultValue"])}
|
||||||
|
>
|
||||||
|
<TextField.Slot side="left">
|
||||||
|
<Switch
|
||||||
|
size="1"
|
||||||
|
checked={input.enabled}
|
||||||
|
onCheckedChange={input.setEnabled}
|
||||||
|
/>
|
||||||
|
</TextField.Slot>
|
||||||
|
|
||||||
|
{isValidating &&
|
||||||
|
<TextField.Slot side="right">
|
||||||
|
<Spinner />
|
||||||
|
</TextField.Slot>
|
||||||
|
}
|
||||||
|
|
||||||
|
{props.children}
|
||||||
|
</TextField.Root>
|
||||||
|
|
||||||
|
{Option.match(Array.head(issues), {
|
||||||
|
onSome: issue => (
|
||||||
|
<Callout.Root>
|
||||||
|
<Callout.Text>{issue.message}</Callout.Text>
|
||||||
|
</Callout.Root>
|
||||||
|
),
|
||||||
|
|
||||||
|
onNone: () => <></>,
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withSignature<TextFieldOptionalFormInputView.Signature>()
|
||||||
|
)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||||
|
import { ReactRuntime } from "effect-fc-next"
|
||||||
|
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: the Vite template provides this element
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ReactRuntime.Provider runtime={runtime}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ReactRuntime.Provider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
|
||||||
|
|
||||||
|
export interface Post {
|
||||||
|
readonly title: string
|
||||||
|
readonly body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchPost = (id: number) => Effect.tryPromise({
|
||||||
|
try: () =>
|
||||||
|
fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
|
||||||
|
.then(response => response.json() as Promise<Post>),
|
||||||
|
|
||||||
|
catch: () => new Error("Unable to fetch post"),
|
||||||
|
})
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/* 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 IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as AsyncRouteImport } from './routes/async'
|
||||||
|
import { Route as BlankRouteImport } from './routes/blank'
|
||||||
|
import { Route as FormRouteImport } from './routes/form'
|
||||||
|
import { Route as QueryRouteImport } from './routes/query'
|
||||||
|
import { Route as ResultRouteImport } from './routes/result'
|
||||||
|
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const AsyncRoute = AsyncRouteImport.update({
|
||||||
|
id: '/async',
|
||||||
|
path: '/async',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const BlankRoute = BlankRouteImport.update({
|
||||||
|
id: '/blank',
|
||||||
|
path: '/blank',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const FormRoute = FormRouteImport.update({
|
||||||
|
id: '/form',
|
||||||
|
path: '/form',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const QueryRoute = QueryRouteImport.update({
|
||||||
|
id: '/query',
|
||||||
|
path: '/query',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ResultRoute = ResultRouteImport.update({
|
||||||
|
id: '/result',
|
||||||
|
path: '/result',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/async': typeof AsyncRoute
|
||||||
|
'/blank': typeof BlankRoute
|
||||||
|
'/form': typeof FormRoute
|
||||||
|
'/query': typeof QueryRoute
|
||||||
|
'/result': typeof ResultRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/async': typeof AsyncRoute
|
||||||
|
'/blank': typeof BlankRoute
|
||||||
|
'/form': typeof FormRoute
|
||||||
|
'/query': typeof QueryRoute
|
||||||
|
'/result': typeof ResultRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/async': typeof AsyncRoute
|
||||||
|
'/blank': typeof BlankRoute
|
||||||
|
'/form': typeof FormRoute
|
||||||
|
'/query': typeof QueryRoute
|
||||||
|
'/result': typeof ResultRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths: '/' | '/async' | '/blank' | '/form' | '/query' | '/result'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to: '/' | '/async' | '/blank' | '/form' | '/query' | '/result'
|
||||||
|
id: '__root__' | '/' | '/async' | '/blank' | '/form' | '/query' | '/result'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
AsyncRoute: typeof AsyncRoute
|
||||||
|
BlankRoute: typeof BlankRoute
|
||||||
|
FormRoute: typeof FormRoute
|
||||||
|
QueryRoute: typeof QueryRoute
|
||||||
|
ResultRoute: typeof ResultRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/async': {
|
||||||
|
id: '/async'
|
||||||
|
path: '/async'
|
||||||
|
fullPath: '/async'
|
||||||
|
preLoaderRoute: typeof AsyncRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/blank': {
|
||||||
|
id: '/blank'
|
||||||
|
path: '/blank'
|
||||||
|
fullPath: '/blank'
|
||||||
|
preLoaderRoute: typeof BlankRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/form': {
|
||||||
|
id: '/form'
|
||||||
|
path: '/form'
|
||||||
|
fullPath: '/form'
|
||||||
|
preLoaderRoute: typeof FormRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/query': {
|
||||||
|
id: '/query'
|
||||||
|
path: '/query'
|
||||||
|
fullPath: '/query'
|
||||||
|
preLoaderRoute: typeof QueryRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/result': {
|
||||||
|
id: '/result'
|
||||||
|
path: '/result'
|
||||||
|
fullPath: '/result'
|
||||||
|
preLoaderRoute: typeof ResultRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
AsyncRoute: AsyncRoute,
|
||||||
|
BlankRoute: BlankRoute,
|
||||||
|
FormRoute: FormRoute,
|
||||||
|
QueryRoute: QueryRoute,
|
||||||
|
ResultRoute: ResultRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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>
|
||||||
|
<Link to="/async">Async</Link>
|
||||||
|
<Link to="/query">Query</Link>
|
||||||
|
<Link to="/result">Result</Link>
|
||||||
|
<Link to="/form">Form</Link>
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Outlet />
|
||||||
|
|
||||||
|
<TanStackRouterDevtools />
|
||||||
|
</Theme>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { Async, Component, Memoized } from "effect-fc-next"
|
||||||
|
import * as React from "react"
|
||||||
|
import { fetchPost } from "@/post"
|
||||||
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
|
|
||||||
|
interface AsyncFetchPostViewProps {
|
||||||
|
readonly id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*(
|
||||||
|
props: AsyncFetchPostViewProps,
|
||||||
|
) {
|
||||||
|
const post = yield* Component.useOnChange(
|
||||||
|
() => fetchPost(props.id),
|
||||||
|
[props.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Heading>{post.title}</Heading>
|
||||||
|
<Text>{post.body}</Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Async.async,
|
||||||
|
Async.withOptions({ defaultFallback: <Text>Loading post...</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={event => setText(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
value={[id]}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
onValueChange={([value]) => setId(value ?? 1)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/async")({
|
||||||
|
component: AsyncRouteComponent,
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/blank")({
|
||||||
|
component: RouteComponent
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <div>Hello "/blank"!</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Button, Container, Flex, Text, TextField } from "@radix-ui/themes"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Component, Form, MutationForm, View } from "effect-fc-next"
|
||||||
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
|
|
||||||
|
const RegisterSchema = Schema.Struct({
|
||||||
|
email: Schema.String,
|
||||||
|
password: Schema.String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() {
|
||||||
|
const form = yield* Component.useOnMount(() => MutationForm.service({
|
||||||
|
schema: RegisterSchema,
|
||||||
|
initialEncodedValue: { email: "", password: "" },
|
||||||
|
f: ([value]) => Effect.log(`Registered ${value.email}`),
|
||||||
|
}))
|
||||||
|
const emailField = yield* Form.useInput(
|
||||||
|
Form.focusObjectOn(form, "email"),
|
||||||
|
)
|
||||||
|
const passwordField = yield* Form.useInput(
|
||||||
|
Form.focusObjectOn(form, "password"),
|
||||||
|
)
|
||||||
|
const [canCommit, isCommitting] = yield* View.useAll([
|
||||||
|
form.canCommit,
|
||||||
|
form.isCommitting,
|
||||||
|
])
|
||||||
|
const runPromise = yield* Component.useRunPromise()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container width="300">
|
||||||
|
<form onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
void runPromise(form.submit)
|
||||||
|
}}>
|
||||||
|
<Flex direction="column" gap="2">
|
||||||
|
<TextField.Root
|
||||||
|
value={emailField.value}
|
||||||
|
onChange={event => emailField.setValue(event.currentTarget.value)}
|
||||||
|
placeholder="Email"
|
||||||
|
/>
|
||||||
|
<TextField.Root
|
||||||
|
value={passwordField.value}
|
||||||
|
onChange={event => passwordField.setValue(event.currentTarget.value)}
|
||||||
|
placeholder="Password"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<Button disabled={!canCommit || isCommitting}>
|
||||||
|
{isCommitting ? "Submitting…" : "Submit"}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</form>
|
||||||
|
<Text size="2">A MutationForm validates local input, then submits it.</Text>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/form")({
|
||||||
|
component: RegisterRouteComponent,
|
||||||
|
})
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Button, Container, Flex, Text, TextField } from "@radix-ui/themes"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { Effect, SubscriptionRef } from "effect"
|
||||||
|
import { Component, Lens, View } from "effect-fc-next"
|
||||||
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
|
|
||||||
|
const TodoRouteComponent = Component.make("TodoRouteView")(function*() {
|
||||||
|
const todosLens = yield* Component.useOnMount(() => Effect.map(
|
||||||
|
SubscriptionRef.make<readonly string[]>([]),
|
||||||
|
Lens.fromSubscriptionRef,
|
||||||
|
))
|
||||||
|
const draftLens = yield* Component.useOnMount(() => Effect.map(
|
||||||
|
SubscriptionRef.make(""),
|
||||||
|
Lens.fromSubscriptionRef,
|
||||||
|
))
|
||||||
|
|
||||||
|
const [todos] = yield* View.useAll([todosLens])
|
||||||
|
const [draft, setDraft] = yield* Lens.useState(draftLens)
|
||||||
|
const runPromise = yield* Component.useRunPromise()
|
||||||
|
|
||||||
|
const addTodo = Lens.update(todosLens, todos =>
|
||||||
|
draft.trim() === ""
|
||||||
|
? todos
|
||||||
|
: [...todos, draft.trim()],
|
||||||
|
).pipe(
|
||||||
|
Effect.andThen(Lens.set(draftLens, "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container width="480">
|
||||||
|
<Flex direction="column" gap="3">
|
||||||
|
<Text size="2">A small Effect v4 todo state example backed by a Lens.</Text>
|
||||||
|
|
||||||
|
<Flex gap="2">
|
||||||
|
<TextField.Root
|
||||||
|
value={draft}
|
||||||
|
onChange={event => setDraft(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={() => void runPromise(addTodo)}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{todos.map(todo => <Text key={todo}>• {todo}</Text>)}
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/")({
|
||||||
|
component: TodoRouteComponent,
|
||||||
|
})
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { Effect, SubscriptionRef } from "effect"
|
||||||
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
|
import { Component, Lens, Mutation, Query, View } from "effect-fc-next"
|
||||||
|
import { fetchPost, type Post } from "@/post"
|
||||||
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
|
|
||||||
|
interface PostResultViewProps {
|
||||||
|
readonly result: AsyncResult.AsyncResult<Post, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
const PostResultView = (props: PostResultViewProps) =>
|
||||||
|
AsyncResult.match(props.result, {
|
||||||
|
onInitial: () => <Text>Loading...</Text>,
|
||||||
|
onFailure: () => <Text>Request failed.</Text>,
|
||||||
|
onSuccess: result => (
|
||||||
|
<>
|
||||||
|
<Heading>{result.value.title}</Heading>
|
||||||
|
<Text>{result.value.body}</Text>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
|
||||||
|
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||||
|
const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
|
||||||
|
const query = yield* Query.service({
|
||||||
|
key: idLens,
|
||||||
|
f: fetchPost,
|
||||||
|
staleTime: "10 seconds",
|
||||||
|
})
|
||||||
|
const mutation = yield* Mutation.make({
|
||||||
|
f: fetchPost,
|
||||||
|
})
|
||||||
|
|
||||||
|
return [idLens, query, mutation] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
const [id] = yield* View.useAll([idLens])
|
||||||
|
const [queryState, mutationResult] = yield* View.useAll([
|
||||||
|
query.state,
|
||||||
|
mutation.state,
|
||||||
|
])
|
||||||
|
const runPromise = yield* Component.useRunPromise()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Flex direction="column" align="center" gap="2">
|
||||||
|
<Slider
|
||||||
|
value={[id]}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
onValueChange={([value]) =>
|
||||||
|
void runPromise(Lens.set(idLens, value ?? 1))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PostResultView result={queryState.result} />
|
||||||
|
|
||||||
|
<Flex direction="row" justify="center" align="center" gap="1">
|
||||||
|
<Button onClick={() => void runPromise(query.refresh)}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void runPromise(query.invalidateCache)}>
|
||||||
|
Invalidate cache
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<PostResultView result={mutationResult} />
|
||||||
|
|
||||||
|
<Button onClick={() => void runPromise(mutation.mutate(id))}>
|
||||||
|
Mutate
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/query")({
|
||||||
|
component: QueryRouteComponent,
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Button, Container, Flex, Heading, Text } from "@radix-ui/themes"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
|
import { Component, Mutation, View } from "effect-fc-next"
|
||||||
|
import { fetchPost, type Post } from "@/post"
|
||||||
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
|
|
||||||
|
const PostResultView = (props: { readonly result: AsyncResult.AsyncResult<Post, Error> }) =>
|
||||||
|
AsyncResult.match(props.result, {
|
||||||
|
onInitial: () => <Text>Ready to load.</Text>,
|
||||||
|
onFailure: () => <Text>Request failed.</Text>,
|
||||||
|
onSuccess: result => (
|
||||||
|
<>
|
||||||
|
<Heading>{result.value.title}</Heading>
|
||||||
|
<Text>{result.value.body}</Text>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ResultRouteComponent = Component.make("ResultRouteView")(function*() {
|
||||||
|
const mutation = yield* Component.useOnMount(() => Mutation.make({
|
||||||
|
f: (_: undefined) => fetchPost(1),
|
||||||
|
}))
|
||||||
|
const [result] = yield* View.useAll([mutation.state])
|
||||||
|
const runPromise = yield* Component.useRunPromise()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Flex direction="column" gap="2">
|
||||||
|
<Button onClick={() => void runPromise(mutation.mutate(undefined))}>
|
||||||
|
Load post
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<PostResultView result={result} />
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/result")({
|
||||||
|
component: ResultRouteComponent,
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { QueryClient, ReactRuntime } from "effect-fc-next"
|
||||||
|
|
||||||
|
|
||||||
|
export const runtime = ReactRuntime.make(QueryClient.layer())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"plugins": [
|
||||||
|
{ "name": "@effect/language-service" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { tanstackRouter } from "@tanstack/router-plugin/vite"
|
||||||
|
import react from "@vitejs/plugin-react"
|
||||||
|
import path from "node:path"
|
||||||
|
import { defineConfig } from "vite"
|
||||||
|
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
tanstackRouter({
|
||||||
|
target: "react",
|
||||||
|
autoCodeSplitting: true,
|
||||||
|
}),
|
||||||
|
react(),
|
||||||
|
],
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||