Add Effect v4 support, Fast Refresh tooling, and revamped docs (#56)
## Summary - Add `effect-fc-next`, a React 19 integration targeting Effect v4 beta. - Introduce component lifecycles, scoped resources, queries, mutations, forms, lenses, views, and refreshable components. - Add `@effect-view/vite-plugin` for Vite Fast Refresh support. - Add an Effect v4 example application covering the new APIs. - Expand test coverage for both the existing and next-generation packages. - Replace the starter Docusaurus content with complete Effect View documentation while preserving the Effect v3 docs as a versioned snapshot. - Update the landing page, navigation, package scripts, and build output. - Extend CI with linting, tests, package builds, Docker builds, and container publishing. ## Validation - `bun lint:tsc` - `bun lint:biome` - `bun test` - `bun run build` - `bun pack` - Docker image build --------- Co-authored-by: Julien Valverdé <julien.valverde@mailo.com> Reviewed-on: Thilawyn/effect-fc#56
@@ -1,41 +1,17 @@
|
||||
# Website
|
||||
# effect-fc Docs
|
||||
|
||||
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
yarn
|
||||
```
|
||||
The documentation site is built with Docusaurus.
|
||||
|
||||
## Local Development
|
||||
|
||||
```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
|
||||
|
||||
```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.
|
||||
|
||||
## 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.
|
||||
The static site is written to `packages/docs/build`.
|
||||
|
||||
@@ -4,5 +4,15 @@
|
||||
"extends": "//",
|
||||
"files": {
|
||||
"includes": ["./src/**"]
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
"complexity": {
|
||||
"noImportantStyles": "off"
|
||||
},
|
||||
"style": {
|
||||
"noDescendingSpecificity": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,457 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
title: Forms
|
||||
---
|
||||
|
||||
# Forms
|
||||
|
||||
Effect View forms are built from an Effect `Schema`. The schema is the source of
|
||||
truth for the shape of the form, its validation rules, and the value your
|
||||
application receives. The form layer supplies reactive state and lifecycle; it
|
||||
does not replace your UI components.
|
||||
|
||||
A schema distinguishes the **encoded value** edited by the UI from the
|
||||
**decoded value** used by the application. For example, an `<input>` edits an
|
||||
age as a string, while the submit handler or application state receives a
|
||||
number:
|
||||
|
||||
```tsx
|
||||
import { Schema } from "effect"
|
||||
|
||||
const ProfileSchema = Schema.Struct({
|
||||
displayName: Schema.String.check(
|
||||
Schema.isMinLength(1, { message: "Enter a display name" }),
|
||||
),
|
||||
age: Schema.NumberFromString,
|
||||
contact: Schema.Struct({
|
||||
email: Schema.String.check(
|
||||
Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {
|
||||
message: "Enter a valid email address",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
That schema gives the form several useful properties:
|
||||
|
||||
- Validation stays next to the data definition instead of being duplicated in
|
||||
components and submit handlers.
|
||||
- Inputs can keep UI-friendly encoded values while business logic receives
|
||||
decoded, typed values.
|
||||
- Nested schema issue paths can be routed automatically to the matching
|
||||
subform or field.
|
||||
- Synchronous and effectful schema validation use the same form model.
|
||||
- The same schema can validate a local draft or protect writes to shared state.
|
||||
|
||||
There are two concrete root form implementations. Choose one based on where a
|
||||
valid decoded value should go:
|
||||
|
||||
| Implementation | Owns a local draft | What happens to a valid value |
|
||||
| --- | --- | --- |
|
||||
| `MutationForm` | Yes | It is passed to a mutation when `submit` runs. |
|
||||
| `LensForm` | Yes | It is written automatically to a target `Lens`. |
|
||||
|
||||
## MutationForm: validate, then submit
|
||||
|
||||
Use `MutationForm` for registration, checkout, search, and other workflows with
|
||||
an explicit submit action. It owns the encoded draft, continuously decodes it
|
||||
with the schema, and exposes the last valid decoded value. Calling `submit`
|
||||
runs the mutation only when the form can commit.
|
||||
|
||||
```tsx
|
||||
import { Effect } from "effect"
|
||||
import { Component, MutationForm, View } from "effect-view"
|
||||
|
||||
const CreateProfileView = Component.make("CreateProfile")(function* () {
|
||||
const form = yield* Component.useOnMount(() =>
|
||||
MutationForm.service({
|
||||
schema: ProfileSchema,
|
||||
initialEncodedValue: {
|
||||
displayName: "",
|
||||
age: "",
|
||||
contact: { email: "" },
|
||||
},
|
||||
f: ([profile]) =>
|
||||
Effect.log(
|
||||
`Creating ${profile.displayName}, age ${profile.age}`,
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
const [canCommit, isCommitting] = yield* View.useAll([
|
||||
form.canCommit,
|
||||
form.isCommitting,
|
||||
])
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
// Focused inputs are added in "Focus into subforms" below.
|
||||
return (
|
||||
<button
|
||||
disabled={!canCommit || isCommitting}
|
||||
onClick={() => void runPromise(form.submit)}
|
||||
>
|
||||
{isCommitting ? "Creating..." : "Create profile"}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Notice that `age` starts as a string because it is an encoded input value. The
|
||||
mutation receives the schema's decoded profile, so `profile.age` is a number.
|
||||
Schema transformations happen before the mutation and schema issues prevent an
|
||||
invalid draft from being submitted.
|
||||
|
||||
`MutationForm.service` also starts initial validation in the current scope. Use
|
||||
it inside `Component.useOnMount`, a scoped service, or another Effect scope so
|
||||
its validation and mutation work is cleaned up with its owner.
|
||||
|
||||
## LensForm: validate, then synchronize
|
||||
|
||||
Use `LensForm` for settings panels, inspectors, and edit screens where valid
|
||||
changes should update existing state without a final submit. Its target holds
|
||||
decoded application data; the form derives an encoded draft from that target
|
||||
and keeps the two synchronized in both directions.
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, LensForm, View } from "effect-view"
|
||||
|
||||
const EditProfileView = Component.make("EditProfile")(function* () {
|
||||
const [form, profile] = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
const profile = Lens.fromSubscriptionRef(
|
||||
yield* SubscriptionRef.make({
|
||||
displayName: "Ada",
|
||||
age: 37,
|
||||
contact: { email: "ada@example.com" },
|
||||
}),
|
||||
)
|
||||
|
||||
const form = yield* LensForm.service({
|
||||
schema: ProfileSchema,
|
||||
target: profile,
|
||||
})
|
||||
|
||||
return [form, profile] as const
|
||||
}),
|
||||
)
|
||||
|
||||
const [savedProfile, isCommitting] = yield* View.useAll([
|
||||
profile,
|
||||
form.isCommitting,
|
||||
])
|
||||
|
||||
// Focused inputs are added in "Focus into subforms" below.
|
||||
return (
|
||||
<output>
|
||||
{isCommitting
|
||||
? "Saving..."
|
||||
: `${savedProfile.displayName} is ${savedProfile.age}`}
|
||||
</output>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Here the target contains `age: 37`, while the form exposes the encoded value
|
||||
`age: "37"` to an input. A valid edit is decoded and written to `profile`.
|
||||
Invalid input remains in the form so the user can correct it, but it never
|
||||
reaches the target. If another part of the application updates the target,
|
||||
`LensForm` encodes that value back into the draft.
|
||||
|
||||
Pass `initialEncodedValue` only when the first draft should differ from the
|
||||
encoded target. Otherwise `LensForm.service` obtains the initial draft by
|
||||
encoding the target through the schema.
|
||||
|
||||
## Example: local date input, UTC domain value
|
||||
|
||||
The encoded/decoded distinction is especially useful for dates. An HTML
|
||||
`datetime-local` input produces a wall-clock string such as
|
||||
`"2026-07-22T14:30"`. It contains no time-zone or UTC offset, while application
|
||||
state and APIs usually need an unambiguous UTC instant.
|
||||
|
||||
A schema can own that entire conversion. The following schema interprets the
|
||||
input in the current time zone and decodes it to `DateTime.Utc`:
|
||||
|
||||
```tsx title="src/DateTimeUtcFromZonedInput.ts"
|
||||
import { DateTime, Effect, Option, ParseResult, Schema } from "effect"
|
||||
|
||||
class DateTimeUtcFromZoned extends Schema.transformOrFail(
|
||||
Schema.DateTimeZonedFromSelf,
|
||||
Schema.DateTimeUtcFromSelf,
|
||||
{
|
||||
strict: true,
|
||||
decode: (input) => ParseResult.succeed(DateTime.toUtc(input)),
|
||||
encode: DateTime.setZoneCurrent,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class DateTimeUtcFromZonedInput extends Schema.transformOrFail(
|
||||
Schema.String,
|
||||
DateTimeUtcFromZoned,
|
||||
{
|
||||
strict: true,
|
||||
decode: (input, _options, ast) =>
|
||||
Effect.flatMap(DateTime.CurrentTimeZone, (timeZone) =>
|
||||
Option.match(
|
||||
DateTime.makeZoned(input, {
|
||||
timeZone,
|
||||
adjustForTimeZone: true,
|
||||
}),
|
||||
{
|
||||
onSome: ParseResult.succeed,
|
||||
onNone: () =>
|
||||
ParseResult.fail(
|
||||
new ParseResult.Type(
|
||||
ast,
|
||||
input,
|
||||
"Enter a valid date and time",
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
encode: (value) =>
|
||||
ParseResult.succeed(
|
||||
DateTime.formatIsoZoned(value).slice(0, 16),
|
||||
),
|
||||
},
|
||||
) {}
|
||||
```
|
||||
|
||||
Use it like any other field schema. The form and input work with a string, but
|
||||
the mutation receives UTC:
|
||||
|
||||
```tsx
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Component, Form, MutationForm, View } from "effect-view"
|
||||
import { DateTimeUtcFromZonedInput } from "./DateTimeUtcFromZonedInput"
|
||||
|
||||
const AppointmentSchema = Schema.Struct({
|
||||
startsAt: DateTimeUtcFromZonedInput,
|
||||
})
|
||||
|
||||
const AppointmentView = Component.make("Appointment")(function* () {
|
||||
const [form, startsAtField] = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
const form = yield* MutationForm.service({
|
||||
schema: AppointmentSchema,
|
||||
initialEncodedValue: { startsAt: "" },
|
||||
f: ([appointment]) =>
|
||||
Effect.log(
|
||||
`Saving ${DateTime.formatIso(appointment.startsAt)}`,
|
||||
),
|
||||
})
|
||||
|
||||
return [form, Form.focusObjectOn(form, "startsAt")] as const
|
||||
}),
|
||||
)
|
||||
|
||||
const startsAt = yield* Form.useInput(startsAtField)
|
||||
const [canCommit] = yield* View.useAll([form.canCommit])
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void runPromise(form.submit)
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startsAt.value}
|
||||
onChange={(event) =>
|
||||
startsAt.setValue(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<button disabled={!canCommit}>Save appointment</button>
|
||||
</form>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
The schema requires `DateTime.CurrentTimeZone`; provide
|
||||
`DateTime.layerCurrentZoneLocal` in the application runtime. If the current
|
||||
zone is `Europe/Paris`, for example, entering `2026-07-22T14:30` decodes to the
|
||||
UTC instant `2026-07-22T12:30:00.000Z`.
|
||||
|
||||
Encoding works in the other direction. A `LensForm` whose target contains that
|
||||
UTC instant presents `2026-07-22T14:30` to the local input. The component never
|
||||
manually parses dates, applies offsets, or maintains a second representation;
|
||||
the schema defines both directions and the form keeps them synchronized.
|
||||
|
||||
## Focus into subforms
|
||||
|
||||
The root `MutationForm` or `LensForm` represents the complete schema. UI
|
||||
components usually need a smaller form representing one field, so they do not
|
||||
have to receive or understand the entire root form. **Focusing** creates that
|
||||
smaller form.
|
||||
|
||||
Every root form and every focused subform implements the same `Form.Form`
|
||||
interface. There is no separate field abstraction: an individual field is
|
||||
simply a `Form` focused on that part of its parent.
|
||||
|
||||
```tsx
|
||||
const displayNameField = Form.focusObjectOn(form, "displayName")
|
||||
const ageField = Form.focusObjectOn(form, "age")
|
||||
|
||||
const contactForm = Form.focusObjectOn(form, "contact")
|
||||
const emailField = Form.focusObjectOn(contactForm, "email")
|
||||
```
|
||||
|
||||
`displayNameField`, `ageField`, and `emailField` can each be passed to the input
|
||||
component responsible for that field. Focusing can also be repeated:
|
||||
`contactForm` represents the complete contact section, and `emailField`
|
||||
represents its email field.
|
||||
|
||||
A focused form exposes only the relevant:
|
||||
|
||||
- `encodedValue`: the UI-facing value that can be edited.
|
||||
- `value`: the decoded value as an `Option`.
|
||||
- `issues`: the schema issues for that subform.
|
||||
|
||||
Validation and commit state remain connected to the root form. An input can
|
||||
show its own issues while still observing the root form's `isValidating`,
|
||||
`canCommit`, and `isCommitting` state.
|
||||
|
||||
Use the focusing helper that matches the value being represented:
|
||||
|
||||
```tsx
|
||||
const property = Form.focusObjectOn(form, "contact")
|
||||
const item = Form.focusArrayAt(itemsForm, 0)
|
||||
const tupleMember = Form.focusTupleAt(tupleForm, 1)
|
||||
const chunkItem = Form.focusChunkAt(chunkForm, 0)
|
||||
```
|
||||
|
||||
Schema issue paths are focused at the same time. A component receiving
|
||||
`emailField` gets its email issues directly instead of searching through every
|
||||
issue on the root form.
|
||||
|
||||
## Bind a subform to an input
|
||||
|
||||
`Form.useInput` is the liaison between an Effect form and a React controlled
|
||||
component. It reads the subform's encoded value and returns the familiar React
|
||||
`{ value, setValue }` interface. Calling `setValue` updates the form, which in
|
||||
turn runs the schema pipeline and updates validation state.
|
||||
|
||||
```tsx
|
||||
const input = yield* Form.useInput(emailField, {
|
||||
debounce: "250 millis",
|
||||
})
|
||||
|
||||
return (
|
||||
<input
|
||||
value={input.value}
|
||||
onChange={(event) => input.setValue(event.currentTarget.value)}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
The debounce is useful for text inputs, which can otherwise update the form and
|
||||
run schema validation on every keystroke. The displayed value still updates
|
||||
immediately; propagation to the form waits until the user pauses typing.
|
||||
|
||||
Use `Form.useOptionalInput` when the encoded field is an Effect `Option`. It
|
||||
returns `value` and `setValue` together with `enabled` and `setEnabled`, making
|
||||
it suitable for an optional input that the user can toggle on and off.
|
||||
|
||||
`useInput` and `useOptionalInput` can be used directly, but they are primarily
|
||||
building blocks for your own reusable input components. A component library
|
||||
can wrap them to consistently handle labels, validation issues, validating
|
||||
indicators, disabled state, accessibility, styling, and a standard debounce.
|
||||
Because both hooks accept a `Form.Form`, the same input components work with
|
||||
subforms from both `MutationForm` and `LensForm`.
|
||||
|
||||
```tsx title="TextFieldFormInputView.tsx"
|
||||
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
|
||||
import { Array, Option, Struct } from "effect"
|
||||
import { Component, Form, View } from "effect-view"
|
||||
import type * as React from "react"
|
||||
|
||||
export declare namespace TextFieldFormInputView {
|
||||
export interface Props<
|
||||
out P extends readonly PropertyKey[],
|
||||
A,
|
||||
ER,
|
||||
EW,
|
||||
> extends Omit<TextField.RootProps, "form">,
|
||||
Form.useInput.Options {
|
||||
readonly form: Form.Form<P, A, string, ER, EW>
|
||||
}
|
||||
|
||||
export type Signature = <
|
||||
P extends readonly PropertyKey[],
|
||||
A,
|
||||
ER,
|
||||
EW,
|
||||
>(
|
||||
props: Props<P, A, ER, EW>,
|
||||
) => React.ReactNode
|
||||
}
|
||||
|
||||
export const TextFieldFormInputView = Component.make(
|
||||
"TextFieldFormInputView",
|
||||
)(function* (
|
||||
props: TextFieldFormInputView.Props<
|
||||
readonly PropertyKey[],
|
||||
any,
|
||||
any,
|
||||
any
|
||||
>,
|
||||
) {
|
||||
const input = yield* Form.useInput(props.form, props)
|
||||
const [issues, isValidating, isCommitting] = yield* View.useAll([
|
||||
props.form.issues,
|
||||
props.form.isValidating,
|
||||
props.form.isCommitting,
|
||||
])
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="1">
|
||||
<TextField.Root
|
||||
value={input.value}
|
||||
onChange={(event) => input.setValue(event.target.value)}
|
||||
disabled={isCommitting}
|
||||
{...Struct.omit(props, ["form", "debounce"])}
|
||||
>
|
||||
{isValidating && (
|
||||
<TextField.Slot side="right">
|
||||
<Spinner />
|
||||
</TextField.Slot>
|
||||
)}
|
||||
{props.children}
|
||||
</TextField.Root>
|
||||
|
||||
{Option.match(Array.head(issues), {
|
||||
onSome: (issue) => (
|
||||
<Callout.Root>
|
||||
<Callout.Text>{issue.message}</Callout.Text>
|
||||
</Callout.Root>
|
||||
),
|
||||
onNone: () => null,
|
||||
})}
|
||||
</Flex>
|
||||
)
|
||||
}).pipe(
|
||||
Component.withSignature<TextFieldFormInputView.Signature>(),
|
||||
)
|
||||
```
|
||||
|
||||
## The common Form model
|
||||
|
||||
Both root implementations and every focused subform implement `Form.Form`.
|
||||
They expose the schema pipeline as reactive Lenses and Views:
|
||||
|
||||
| Member | Meaning |
|
||||
| --- | --- |
|
||||
| `encodedValue` | Writable input-shaped state. |
|
||||
| `value` | The decoded value as an `Option`; `None` until decoding succeeds. |
|
||||
| `issues` | Standard Schema issues scoped to this form's path. |
|
||||
| `isValidating` | Whether schema decoding is currently running. |
|
||||
| `canCommit` | Whether the root has a valid value and is ready to commit. |
|
||||
| `isCommitting` | Whether a mutation or target write is in progress. |
|
||||
|
||||
The form model itself is Effect code. Effect View adds the React-facing hooks,
|
||||
including `Form.useInput` and `Form.useOptionalInput`, while your own component
|
||||
library remains responsible for rendering controls and layout.
|
||||
@@ -0,0 +1,541 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Getting Started
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
|
||||
`effect-view` lets a React function component be described as an Effect
|
||||
program. Inside a component you can yield services, create scoped resources,
|
||||
subscribe to Effect-powered state, and turn Effects into React callbacks. At a
|
||||
React boundary, that description becomes a normal function component.
|
||||
|
||||
The core model has four pieces:
|
||||
|
||||
1. `ReactRuntime` builds the Effect services available to the UI.
|
||||
2. `Component.make` defines an Effect View component.
|
||||
3. `Component.withContext` converts an Effect View component into a normal
|
||||
React component at an application or router boundary.
|
||||
4. Effect View children are composed through their `.use` Effect.
|
||||
|
||||
## Install
|
||||
|
||||
For a web application, install Effect View with Effect 4 and React 19.2 or
|
||||
newer:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install effect-view effect@beta react react-dom
|
||||
```
|
||||
|
||||
```bash npm2yarn
|
||||
npm install --save-dev @types/react @types/react-dom
|
||||
```
|
||||
|
||||
Effect View is not tied to React DOM. For React Native or another renderer,
|
||||
install that renderer instead of `react-dom` and keep the rest of the setup the
|
||||
same.
|
||||
|
||||
## Set up hot reloading with Vite
|
||||
|
||||
Install the Effect View Vite plugin to preserve component state while editing
|
||||
Effect View components:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install --save-dev @effect-view/vite-plugin
|
||||
```
|
||||
|
||||
Add `effectViewPlugin()` before the React plugin in your Vite configuration:
|
||||
|
||||
```ts title="vite.config.ts"
|
||||
import { effectViewPlugin } from "@effect-view/vite-plugin"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
effectViewPlugin(),
|
||||
react(),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
Vite is the only bundler supported for Effect View hot reloading at the moment.
|
||||
Support for other bundlers will follow.
|
||||
|
||||
## Create the runtime
|
||||
|
||||
`ReactRuntime` owns a managed Effect runtime. Define it at module scope from the
|
||||
layers needed by the UI:
|
||||
|
||||
```tsx title="src/runtime.ts"
|
||||
import { Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { ReactRuntime } from "effect-view"
|
||||
|
||||
const AppLive = Layer.empty.pipe(
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
```
|
||||
|
||||
Keep the runtime stable. Creating it during a React render would create new
|
||||
managed resources and a new React context on every render.
|
||||
|
||||
## Provide the runtime
|
||||
|
||||
Place `ReactRuntime.Provider` above every Effect View entrypoint. The provider
|
||||
builds the runtime layer, makes its Effect context available through React, and
|
||||
disposes the managed runtime when the provider unmounts.
|
||||
|
||||
Runtime construction can suspend, so give the provider a fallback:
|
||||
|
||||
```tsx title="src/main.tsx"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { ReactRuntime } from "effect-view"
|
||||
import { App } from "./App"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ReactRuntime.Provider
|
||||
runtime={runtime}
|
||||
fallback={<p>Starting application...</p>}
|
||||
>
|
||||
<App />
|
||||
</ReactRuntime.Provider>
|
||||
</StrictMode>,
|
||||
)
|
||||
```
|
||||
|
||||
With a router, keep the provider above the router provider:
|
||||
|
||||
```tsx
|
||||
<ReactRuntime.Provider runtime={runtime} fallback={<p>Starting...</p>}>
|
||||
<RouterProvider router={router} />
|
||||
</ReactRuntime.Provider>
|
||||
```
|
||||
|
||||
If runtime layer construction can fail, place an appropriate React error
|
||||
boundary above `ReactRuntime.Provider` as well.
|
||||
|
||||
## Write your first component
|
||||
|
||||
`Component.make` defines a component body using the same generator style as
|
||||
`Effect.gen` and `Effect.fn`. Providing a name creates a tracing span and also
|
||||
sets the React DevTools display name:
|
||||
|
||||
```tsx title="src/HelloView.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-view"
|
||||
|
||||
export const HelloView = Component.make("HelloView")(
|
||||
function* (props: { readonly name: string }) {
|
||||
const message = yield* Effect.succeed(`Hello, ${props.name}`)
|
||||
|
||||
return <h1>{message}</h1>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Use `Component.makeUntraced("HelloView")` when you want the display name but do
|
||||
not want an automatic tracing span.
|
||||
|
||||
`HelloView` is an Effect View component description, not yet a normal React
|
||||
component. Convert it at the point where plain React, a router, or a third-party
|
||||
library needs a function component:
|
||||
|
||||
```tsx title="src/Hello.tsx"
|
||||
import { Component } from "effect-view"
|
||||
import { HelloView } from "./HelloView"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
export const Hello = HelloView.pipe(
|
||||
Component.withContext(runtime.context),
|
||||
)
|
||||
```
|
||||
|
||||
It can now be rendered by ordinary React:
|
||||
|
||||
```tsx title="src/App.tsx"
|
||||
import { Hello } from "./Hello"
|
||||
|
||||
export function App() {
|
||||
return <Hello name="Effect" />
|
||||
}
|
||||
```
|
||||
|
||||
`Component.withContext` reads the context populated by the matching
|
||||
`ReactRuntime.Provider`. It does not build or provide the runtime by itself.
|
||||
|
||||
## Compose Effect View components
|
||||
|
||||
Inside another Effect View component, yield a child's `.use` Effect. This binds
|
||||
the child to the current Effect context and returns a component that can be used
|
||||
in JSX:
|
||||
|
||||
```tsx title="src/GreetingCardView.tsx"
|
||||
import { Component } from "effect-view"
|
||||
import { HelloView } from "./HelloView"
|
||||
|
||||
export const GreetingCardView = Component.make("GreetingCard")(
|
||||
function* () {
|
||||
const Hello = yield* HelloView.use
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Hello name="Effect" />
|
||||
<p>Both components use the same Effect context.</p>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Only apply `Component.withContext` when crossing from Effect View into plain
|
||||
React. Applying it to every nested component is unnecessary and makes it harder
|
||||
to provide local services to a subtree.
|
||||
|
||||
## Synchronous and asynchronous components
|
||||
|
||||
A regular Effect View component runs its body during React render. That Effect
|
||||
must complete synchronously: yielding a service, reading synchronous state, or
|
||||
creating a scoped object is fine; sleeping, fetching, or awaiting a promise is
|
||||
not.
|
||||
|
||||
Use `Async.async` when render genuinely depends on an asynchronous Effect. The
|
||||
component then suspends and accepts React Suspense props such as `fallback`:
|
||||
|
||||
```tsx title="src/UserView.tsx"
|
||||
import { Async, Component, Memoized } from "effect-view"
|
||||
import { loadUser } from "./api"
|
||||
|
||||
export const UserView = Component.make("UserView")(
|
||||
function* (props: { readonly userId: string }) {
|
||||
const user = yield* Component.useOnChange(
|
||||
() => loadUser(props.userId),
|
||||
[props.userId],
|
||||
)
|
||||
|
||||
return <h2>{user.name}</h2>
|
||||
},
|
||||
).pipe(
|
||||
Async.async,
|
||||
Async.withOptions({ defaultFallback: <p>Loading user...</p> }),
|
||||
Memoized.memoized,
|
||||
)
|
||||
```
|
||||
|
||||
Use it from an Effect View parent in the usual way:
|
||||
|
||||
```tsx
|
||||
const User = yield* UserView.use
|
||||
|
||||
return <User userId={selectedUserId} />
|
||||
```
|
||||
|
||||
`Memoized.memoized` prevents an unrelated parent re-render from restarting an
|
||||
async child whose props have not changed. A changed `userId` creates a new
|
||||
dependency scope, cleans up the previous one, and runs `loadUser` again.
|
||||
|
||||
An async component's rejected Effect is handled by the nearest React error
|
||||
boundary. Suspense handles waiting, not failures.
|
||||
|
||||
For server data that should be cached, refreshed, and shared, prefer the
|
||||
[Query module](./query) over a raw async component.
|
||||
|
||||
## Use Effect services
|
||||
|
||||
Components can yield Effect service tags directly. Their required service type
|
||||
becomes part of the component type, so the final runtime or a local layer must
|
||||
provide it:
|
||||
|
||||
```tsx title="src/GreetingService.ts"
|
||||
import { Context, Layer } from "effect"
|
||||
|
||||
export class GreetingService extends Context.Service<
|
||||
GreetingService,
|
||||
{ readonly greet: (name: string) => string }
|
||||
>()("GreetingService") {
|
||||
static readonly layer = Layer.succeed(GreetingService, {
|
||||
greet: (name) => `Hello, ${name}`,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```tsx title="src/GreetingView.tsx"
|
||||
import { Component } from "effect-view"
|
||||
import { GreetingService } from "./GreetingService"
|
||||
|
||||
export const GreetingView = Component.make("Greeting")(
|
||||
function* (props: { readonly name: string }) {
|
||||
const greeting = yield* GreetingService
|
||||
|
||||
return <p>{greeting.greet(props.name)}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Add `GreetingService.layer` to the application runtime, or provide it only to a
|
||||
subtree as shown later on this page.
|
||||
|
||||
Effect service instances are reactive at component boundaries. If the supplied
|
||||
context changes to contain a different service instance, dependent Effect View
|
||||
components are recreated so they read the new environment and restart their
|
||||
scoped lifecycle.
|
||||
|
||||
## Understand lifecycle hooks
|
||||
|
||||
Effect View hooks are still React hooks internally. Call them unconditionally
|
||||
at the top level of the component body, in a consistent order, and never inside
|
||||
branches, loops, event handlers, or nested callbacks.
|
||||
|
||||
### The component root scope
|
||||
|
||||
Every rendered Effect View component gets a root `Scope.Scope`. Effect View
|
||||
creates it when the component instance is rendered, provides it to the entire
|
||||
component body, and closes it when that React component unmounts.
|
||||
|
||||
Effects yielded directly from the body therefore see the root scope. This also
|
||||
means that `Effect.addFinalizer`, `Effect.acquireRelease`, and
|
||||
`Effect.forkScoped` can be used naturally inside component setup:
|
||||
|
||||
```tsx
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-view"
|
||||
|
||||
const ResourceView = Component.make("Resource")(function* () {
|
||||
const resource = yield* Component.useOnMount(() =>
|
||||
Effect.acquireRelease(
|
||||
openResource,
|
||||
(resource) => closeResource(resource),
|
||||
),
|
||||
)
|
||||
|
||||
return <p>{resource.name}</p>
|
||||
})
|
||||
```
|
||||
|
||||
`useOnMount` does **not** create or provide another scope. It runs its Effect
|
||||
with the context already available in the component body, so the resource above
|
||||
is owned by the component's root scope.
|
||||
|
||||
`useOnChange`, `useReactEffect`, and `useReactLayoutEffect` each create a scope
|
||||
that can be replaced without closing the component root scope.
|
||||
|
||||
The main lifecycle choices are:
|
||||
|
||||
| Hook | What it does | Scope seen by setup | Scope closes when |
|
||||
| --- | --- | --- | --- |
|
||||
| Component body | Produces the component's rendered value | Component root scope | The component unmounts |
|
||||
| `useOnMount` | Computes and caches a value for the component instance | The same component root scope | The component unmounts |
|
||||
| `useOnChange` | Recomputes a cached value when dependencies change | A new dependency scope | Dependencies change or the component unmounts |
|
||||
| `useReactEffect` | Runs a post-commit side effect | A new effect scope | Dependencies change or the component unmounts |
|
||||
| `useReactLayoutEffect` | Runs a layout effect before the browser paints | A new layout-effect scope | Dependencies change or the component unmounts |
|
||||
| `useLayer` | Builds and provides a layer context | A dependency scope created through `useOnChange` | The layer reference changes or the component unmounts |
|
||||
|
||||
`useRunSync`, `useRunPromise`, `useCallbackSync`, and `useCallbackPromise` do
|
||||
not create lifecycle scopes either. They capture the component context, so an
|
||||
Effect invoked through them sees the component root scope unless it explicitly
|
||||
provides another one.
|
||||
|
||||
### useOnMount
|
||||
|
||||
`Component.useOnMount` computes a value during the initial render and caches it
|
||||
for later renders. It uses the component root scope, so its resources live
|
||||
until the component unmounts.
|
||||
|
||||
```tsx title="src/LocalStateView.tsx"
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-view"
|
||||
|
||||
export const LocalStateView = Component.make("LocalState")(
|
||||
function* () {
|
||||
const count = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.log("LocalState disposed"),
|
||||
)
|
||||
|
||||
return Lens.fromSubscriptionRef(
|
||||
yield* SubscriptionRef.make(0),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const [value] = yield* View.useAll([count])
|
||||
return <p>Count: {value}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
In a regular component, the setup Effect must be synchronous because it runs
|
||||
during render. In a component enhanced with `Async.async`, it may be
|
||||
asynchronous and will suspend the component.
|
||||
|
||||
### useOnChange
|
||||
|
||||
`Component.useOnChange` computes and caches a value, then recomputes it when its
|
||||
dependencies change. Each dependency set gets its own scope, which closes when
|
||||
the dependencies change or the component unmounts.
|
||||
|
||||
```tsx
|
||||
const label = yield* Component.useOnChange(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.log(`Stopped viewing ${props.userId}`),
|
||||
)
|
||||
|
||||
return `Viewing user ${props.userId}`
|
||||
}),
|
||||
[props.userId],
|
||||
)
|
||||
```
|
||||
|
||||
Dependency arrays follow normal React semantics. Include every reactive value
|
||||
read by the setup Effect.
|
||||
|
||||
### useReactEffect and useReactLayoutEffect
|
||||
|
||||
`Component.useReactEffect` runs side effects after React commits, while
|
||||
`Component.useReactLayoutEffect` runs before the browser paints. Each hook owns
|
||||
a scope that closes when its dependencies change or the component unmounts.
|
||||
|
||||
Setup must complete synchronously, but it can fork asynchronous work into the
|
||||
hook scope:
|
||||
|
||||
```tsx
|
||||
yield* Component.useReactEffect(
|
||||
() => Effect.forkScoped(listenForNotifications(props.userId)),
|
||||
[props.userId],
|
||||
)
|
||||
```
|
||||
|
||||
React Strict Mode may intentionally repeat development-only render and effect
|
||||
setup. Initializers and resource acquisition should therefore be safe to run
|
||||
more than once; production retains the normal component lifetime semantics.
|
||||
|
||||
## Run Effects from event handlers
|
||||
|
||||
React event handlers are plain functions. Use Effect View runners to execute an
|
||||
Effect with the component's current context and scope:
|
||||
|
||||
```tsx
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
return (
|
||||
<button onClick={() => void runPromise(saveUser(user))}>
|
||||
Save
|
||||
</button>
|
||||
)
|
||||
```
|
||||
|
||||
Use `Component.useRunSync` only for Effects known to complete synchronously.
|
||||
Use `Component.useRunPromise` for Effects that may suspend, sleep, fetch, or
|
||||
otherwise continue asynchronously.
|
||||
|
||||
When a callback is passed to a memoized child or used as a dependency, use the
|
||||
callback variants to preserve its identity:
|
||||
|
||||
```tsx
|
||||
const save = yield* Component.useCallbackPromise(
|
||||
(nextUser: User) => saveUser(nextUser),
|
||||
[saveUser],
|
||||
)
|
||||
|
||||
return <SaveButton onSave={() => void save(user)} />
|
||||
```
|
||||
|
||||
`useCallbackSync` and `useCallbackPromise` follow the same dependency rules as
|
||||
`React.useCallback`, while also supplying the Effect context when invoked.
|
||||
|
||||
## Use React normally
|
||||
|
||||
Effect View components can use regular React hooks, refs, context, event
|
||||
handlers, and JSX composition:
|
||||
|
||||
```tsx
|
||||
import { Component } from "effect-view"
|
||||
import * as React from "react"
|
||||
|
||||
const CounterView = Component.make("Counter")(function* () {
|
||||
const [count, setCount] = React.useState(0)
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count: {count}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Prefer regular React state for simple, component-local UI concerns. Use
|
||||
`Lens`/`View` when state needs Effect integration, subscriptions, focusing, or
|
||||
sharing. The [State Management guide](./state-management) covers that model.
|
||||
|
||||
## Provide services to a subtree
|
||||
|
||||
Use `Component.useLayer` when only one Effect View subtree needs extra
|
||||
services. It builds the layer in a scope and returns the resulting Effect
|
||||
context:
|
||||
|
||||
```tsx title="src/GreetingPageView.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-view"
|
||||
import { GreetingView } from "./GreetingView"
|
||||
import { GreetingService } from "./GreetingService"
|
||||
|
||||
export const GreetingPageView = Component.make("GreetingPage")(
|
||||
function* () {
|
||||
const context = yield* Component.useLayer(GreetingService.layer)
|
||||
const Greeting = yield* GreetingView.use.pipe(
|
||||
Effect.provide(context),
|
||||
)
|
||||
|
||||
return <Greeting name="Effect" />
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Keep the layer reference stable. Define static layers outside the component or
|
||||
memoize a layer that depends on props with `React.useMemo`; a new layer object
|
||||
causes reconstruction and scoped cleanup.
|
||||
|
||||
Layer construction runs during render through `useOnChange`. A layer with
|
||||
asynchronous acquisition requires the component to be enhanced with
|
||||
`Async.async`. Resources built by the layer are released when the owning
|
||||
component unmounts or the layer reference changes.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- Effect View hooks obey React's Rules of Hooks even though they are called
|
||||
with `yield*`.
|
||||
- Do not yield an asynchronous Effect from a regular component body. Use
|
||||
`Async.async`, Query, a Mutation callback, or a post-commit scoped fiber.
|
||||
- `Component.withContext` needs a matching `ReactRuntime.Provider` above it.
|
||||
- Apply `withContext` at React boundaries, not between Effect View components.
|
||||
- Keep runtimes and layers stable instead of creating them on every render.
|
||||
- Use `useRunPromise` rather than `useRunSync` for asynchronous event work.
|
||||
- Suspense fallbacks handle waiting; React error boundaries handle failed
|
||||
component Effects.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [State Management](./state-management) explains `Lens`, `View`, local state,
|
||||
and focused state.
|
||||
- [Query](./query) adds reactive keys, caching, refresh, and invalidation to
|
||||
Effect-based server reads.
|
||||
- [Mutation](./mutation) models user-triggered Effect operations and their
|
||||
`AsyncResult` state.
|
||||
- [Forms](./forms) builds schema-driven editable state with `MutationForm` and
|
||||
`LensForm`.
|
||||
|
||||
The repeatable pattern is small: provide one runtime, define components as
|
||||
Effect programs, cross into plain React with `Component.withContext`, and use
|
||||
`.use` everywhere inside the Effect View tree.
|
||||
@@ -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,270 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: Mutation
|
||||
---
|
||||
|
||||
# Mutation
|
||||
|
||||
The Mutation module is Effect View's counterpart to TanStack Query mutations.
|
||||
It models user-triggered asynchronous work such as saving a profile, deleting a
|
||||
record, uploading a file, or sending a command to an API.
|
||||
|
||||
The familiar concepts are present, but the operation itself is an Effect:
|
||||
|
||||
| TanStack Mutation concept | Effect View equivalent |
|
||||
| --- | --- |
|
||||
| Mutation variables | The input key `K` |
|
||||
| `mutationFn` | `f: (key: K) => Effect<A, E, R>` |
|
||||
| Mutation result | `mutation.state`, a `View<AsyncResult<A, E>>` |
|
||||
| `isPending` | `result.waiting` |
|
||||
| `mutateAsync` | `mutation.mutate(key)` |
|
||||
| Start without awaiting | `mutation.mutateView(key)` |
|
||||
|
||||
Unlike a Query, a Mutation has no cache, reactive key, or automatic execution.
|
||||
It runs only when `mutate` or `mutateView` is called. This makes it suitable for
|
||||
commands, while Query remains the model for cached server state.
|
||||
|
||||
## Create a mutation
|
||||
|
||||
Create a mutation in a component scope with `Mutation.make`. Its function can
|
||||
be any Effect, including one that requires services from the application
|
||||
runtime:
|
||||
|
||||
```tsx
|
||||
import { Effect } from "effect"
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
import { Component, Mutation, View } from "effect-view"
|
||||
import { sendInvite } from "./api"
|
||||
|
||||
interface InviteInput {
|
||||
readonly email: string
|
||||
readonly role: "member" | "admin"
|
||||
}
|
||||
|
||||
const InviteButtonView = Component.make("InviteButton")(
|
||||
function* (props: { readonly email: string }) {
|
||||
const mutation = yield* Component.useOnMount(() =>
|
||||
Mutation.make({
|
||||
f: (input: InviteInput) => sendInvite(input),
|
||||
}),
|
||||
)
|
||||
|
||||
const [result] = yield* View.useAll([mutation.state])
|
||||
const runSync = yield* Component.useRunSync()
|
||||
|
||||
return (
|
||||
<section>
|
||||
<button
|
||||
disabled={result.waiting}
|
||||
onClick={() =>
|
||||
runSync(mutation.mutateView({
|
||||
email: props.email,
|
||||
role: "member",
|
||||
}))
|
||||
}
|
||||
>
|
||||
{result.waiting ? "Sending..." : "Send invite"}
|
||||
</button>
|
||||
|
||||
{AsyncResult.match(result, {
|
||||
onInitial: () => null,
|
||||
onFailure: ({ cause }) => (
|
||||
<p>Could not send invite: {cause.toString()}</p>
|
||||
),
|
||||
onSuccess: ({ value }) => (
|
||||
<p>Invite sent to {value.email}</p>
|
||||
),
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
`Mutation.make` captures the current Effect context. If `sendInvite` requires
|
||||
an HTTP client, authentication service, tracer, or another service, provide it
|
||||
to the runtime or component layer before creating the mutation. The click
|
||||
handler does not need to reconstruct those dependencies.
|
||||
|
||||
The mutation also belongs to the creation scope. Any mutation fibers still
|
||||
running when that scope closes are interrupted automatically.
|
||||
|
||||
## Understand AsyncResult
|
||||
|
||||
`mutation.state` starts as `Initial` with `waiting: false`. Starting a mutation
|
||||
changes it to a waiting state, then publishes either `Success` or `Failure`:
|
||||
|
||||
```tsx
|
||||
const [result] = yield* View.useAll([mutation.state])
|
||||
|
||||
return AsyncResult.match(result, {
|
||||
onInitial: ({ waiting }) =>
|
||||
waiting ? <p>Starting...</p> : <p>Ready.</p>,
|
||||
onFailure: ({ cause, previousSuccess, waiting }) => (
|
||||
<div>
|
||||
<p>{cause.toString()}</p>
|
||||
{previousSuccess._tag === "Some" && (
|
||||
<p>Last saved value: {previousSuccess.value.value.name}</p>
|
||||
)}
|
||||
{waiting && <p>Trying again...</p>}
|
||||
</div>
|
||||
),
|
||||
onSuccess: ({ value, waiting }) => (
|
||||
<div>
|
||||
<p>Saved {value.name}</p>
|
||||
{waiting && <p>Saving a newer value...</p>}
|
||||
</div>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
The `waiting` flag is independent from the result tag. After one successful
|
||||
call, starting another keeps the successful value and sets `waiting: true`.
|
||||
If that next call fails, the failure can retain the earlier success in
|
||||
`previousSuccess`. Components can therefore keep useful feedback visible
|
||||
during retries or repeated submissions.
|
||||
|
||||
A failure contains an Effect `Cause<E>`, not only `E`. Typed failures, defects,
|
||||
and interruption information remain available for logging or presentation.
|
||||
|
||||
## Choose mutate or mutateView
|
||||
|
||||
Both methods start the same mutation effect. They differ in what the caller
|
||||
waits for:
|
||||
|
||||
| Method | Return value | Best suited to |
|
||||
| --- | --- | --- |
|
||||
| `mutate(key)` | The final `Success` or `Failure` | Effect workflows that need the outcome. |
|
||||
| `mutateView(key)` | A live `View<AsyncResult<A, E>>` | UI callbacks that should return after starting the work. |
|
||||
|
||||
Use `mutate` with an asynchronous component runner when later logic depends on
|
||||
the final result:
|
||||
|
||||
```tsx
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
const save = () =>
|
||||
void runPromise(
|
||||
Effect.gen(function* () {
|
||||
const result = yield* mutation.mutate(input)
|
||||
|
||||
if (AsyncResult.isSuccess(result)) {
|
||||
yield* Effect.log(`Saved record ${result.value.id}`)
|
||||
}
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
The mutation Effect does not fail with `E`. It captures the operation's `Exit`
|
||||
and returns a final `AsyncResult.Success` or `AsyncResult.Failure`. Inspect or
|
||||
match that value when control flow depends on the outcome.
|
||||
|
||||
Use `mutateView` when the UI only needs to start the operation and react to its
|
||||
state:
|
||||
|
||||
```tsx
|
||||
const runSync = yield* Component.useRunSync()
|
||||
|
||||
const startSave = () => {
|
||||
const state = runSync(mutation.mutateView(input))
|
||||
// `state` is a View for this specific call.
|
||||
}
|
||||
```
|
||||
|
||||
`mutateView` starts the scoped work and returns immediately with a per-call
|
||||
View. The shared `mutation.state` is also updated as that call progresses.
|
||||
|
||||
## Track the latest call
|
||||
|
||||
In addition to `state`, a mutation exposes reactive metadata:
|
||||
|
||||
| Member | Meaning |
|
||||
| --- | --- |
|
||||
| `state` | The latest mutation state published to the shared View. |
|
||||
| `latestKey` | The most recently supplied input as an `Option<K>`. |
|
||||
| `latestFinalResult` | The latest completed success or failure as an `Option`. |
|
||||
| `fiber` | The most recently started mutation fiber as an `Option`. |
|
||||
|
||||
Subscribe to any of them with `View.useAll`:
|
||||
|
||||
```tsx
|
||||
const [result, latestInput, latestFinal] = yield* View.useAll([
|
||||
mutation.state,
|
||||
mutation.latestKey,
|
||||
mutation.latestFinalResult,
|
||||
])
|
||||
```
|
||||
|
||||
The input is called a `key` in the generic API, but it is equivalent to
|
||||
mutation variables in TanStack Query. It can be a primitive, tuple, struct, or
|
||||
any other value accepted by the mutation function.
|
||||
|
||||
## Concurrent mutations
|
||||
|
||||
Starting a mutation does not automatically interrupt an earlier mutation.
|
||||
Calls may overlap, and each call has its own state View. This is useful for
|
||||
independent operations such as uploading several files.
|
||||
|
||||
When calls overlap, `mutation.state` reflects updates published by all calls;
|
||||
the last update to arrive wins. Use the View returned by `mutateView` when each
|
||||
concurrent operation needs its own progress indicator:
|
||||
|
||||
```tsx
|
||||
const upload = (file: File) =>
|
||||
Effect.gen(function* () {
|
||||
const uploadState = yield* mutation.mutateView(file)
|
||||
// Store or pass `uploadState` to the row rendering this file.
|
||||
return uploadState
|
||||
})
|
||||
```
|
||||
|
||||
For a single submit button, disabling it while `mutation.state.waiting` is
|
||||
usually enough to prevent accidental overlap. For “latest request wins”
|
||||
behavior, model that policy explicitly or use Query when the operation is
|
||||
actually a reactive read.
|
||||
|
||||
## Update queries after a mutation
|
||||
|
||||
Mutations do not invalidate Query caches automatically. Compose invalidation
|
||||
with the mutation result so the relationship remains explicit and typed:
|
||||
|
||||
```tsx
|
||||
const saveAndRefresh = (input: UpdatePostInput) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* updatePost.mutate(input)
|
||||
|
||||
if (AsyncResult.isSuccess(result)) {
|
||||
yield* posts.invalidateCacheEntry(["post", result.value.id] as const)
|
||||
yield* posts.refreshView
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
```
|
||||
|
||||
This is the Effect equivalent of an `onSuccess` callback that invalidates a
|
||||
TanStack Query. Because it is ordinary Effect composition, the workflow can
|
||||
also include tracing, transactions, retries, notifications, or parallel cache
|
||||
updates without introducing a separate callback API.
|
||||
|
||||
Remember that Query invalidation removes cached data but does not refetch by
|
||||
itself. Follow it with `refreshView` when the current screen should update
|
||||
immediately.
|
||||
|
||||
## The Effect touch
|
||||
|
||||
Mutation follows the ergonomics of TanStack mutations while retaining Effect's
|
||||
execution model:
|
||||
|
||||
- The mutation function has the full `Effect<A, E, R>` type.
|
||||
- Required services are captured from the creation context.
|
||||
- Failures are represented as `Cause<E>`, including defects and interruption.
|
||||
- Fibers are scoped, so component unmounting cleans up in-flight operations.
|
||||
- `mutate` composes directly inside larger Effect workflows.
|
||||
- `mutateView` exposes call-specific progress as a View for React or Effect
|
||||
consumers.
|
||||
- Retry schedules, timeouts, tracing, logging, metrics, schema validation, and
|
||||
concurrency controls can be applied with normal Effect operators.
|
||||
|
||||
Mutation is therefore a small bridge: TanStack-style mutation state on one
|
||||
side, and an ordinary, typed Effect program on the other.
|
||||
@@ -0,0 +1,288 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: Query
|
||||
---
|
||||
|
||||
# Query
|
||||
|
||||
The Query module is Effect View's take on TanStack Query. It provides the same
|
||||
kind of server-state workflow—reactive query keys, cached results, stale times,
|
||||
background refreshes, window-focus refetching, and cache invalidation—but the
|
||||
query function is an `Effect` and the observable state is a `View`.
|
||||
|
||||
If you already know TanStack Query, the main concepts translate directly:
|
||||
|
||||
| TanStack Query concept | Effect View equivalent |
|
||||
| --- | --- |
|
||||
| `QueryClient` | The `QueryClient` Effect service |
|
||||
| `queryKey` | A reactive key supplied as a `View<K>` |
|
||||
| `queryFn` | `f: (key: K) => Effect<A, E, R>` |
|
||||
| `useQuery` result | `query.state`, a `View<QueryState<K, A, E>>` |
|
||||
| `isFetching` | `result.waiting` |
|
||||
| `refetch` | `query.refresh` or `query.refreshView` |
|
||||
| `invalidateQueries` | `query.invalidateCache` or `invalidateCacheEntry` |
|
||||
|
||||
The goal is familiar query behavior without leaving Effect's model. Query
|
||||
functions keep their typed success, error, and service channels. They can use
|
||||
services from the runtime, be composed with schema decoding and retry policies,
|
||||
and are interrupted automatically when their scope ends or a new key supersedes
|
||||
the current request.
|
||||
|
||||
## Provide a QueryClient
|
||||
|
||||
Queries share successful results through a `QueryClient`. Add its layer to the
|
||||
application runtime once, alongside the services used by your query effects:
|
||||
|
||||
```tsx title="src/runtime.ts"
|
||||
import { Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { QueryClient, ReactRuntime } from "effect-view"
|
||||
|
||||
const AppLive = Layer.empty.pipe(
|
||||
Layer.provideMerge(QueryClient.layer({
|
||||
defaultStaleTime: "30 seconds",
|
||||
defaultRefreshOnWindowFocus: true,
|
||||
cacheGcTime: "5 minutes",
|
||||
})),
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
```
|
||||
|
||||
The client owns the cache and its cleanup lifecycle. Individual queries can
|
||||
override `staleTime` and `refreshOnWindowFocus`; otherwise they inherit the
|
||||
client defaults.
|
||||
|
||||
## Create a reactive query
|
||||
|
||||
A query is driven by a `View` rather than by a value read during one React
|
||||
render. Whenever that key changes, `Query.service` checks the cache and starts
|
||||
the query effect when necessary.
|
||||
|
||||
```tsx
|
||||
import { Effect, Schema, SubscriptionRef } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Component, Lens, Query, View } from "effect-view"
|
||||
|
||||
const Post = Schema.Struct({
|
||||
id: Schema.Int,
|
||||
title: Schema.String,
|
||||
body: Schema.String,
|
||||
})
|
||||
|
||||
const PostView = Component.make("Post")(function* () {
|
||||
const [postId, query] = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
const key = Lens.fromSubscriptionRef(
|
||||
yield* SubscriptionRef.make(["post", 1 as number] as const),
|
||||
)
|
||||
|
||||
const query = yield* Query.service({
|
||||
key,
|
||||
staleTime: "1 minute",
|
||||
f: ([, id]) =>
|
||||
HttpClient.HttpClient.pipe(
|
||||
Effect.andThen((client) =>
|
||||
client.get(`https://example.com/posts/${id}`),
|
||||
),
|
||||
Effect.andThen((response) => response.json),
|
||||
Effect.andThen(Schema.decodeUnknownEffect(Post)),
|
||||
),
|
||||
})
|
||||
|
||||
return [Lens.focusTupleAt(key, 1), query] as const
|
||||
}),
|
||||
)
|
||||
|
||||
const [state] = yield* View.useAll([query.state])
|
||||
const [id, setId] = yield* Lens.useState(postId)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<select
|
||||
value={id}
|
||||
onChange={(event) => setId(Number(event.currentTarget.value))}
|
||||
>
|
||||
<option value={1}>Post 1</option>
|
||||
<option value={2}>Post 2</option>
|
||||
</select>
|
||||
<pre>{state.result._tag}</pre>
|
||||
</section>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
The tuple key plays the same role as `['post', id]` in TanStack Query. Keys use
|
||||
Effect equality by default, so structurally equal Effect data types work well
|
||||
as query keys. Supply `keyEquivalence` when the key needs different equality
|
||||
semantics.
|
||||
|
||||
`Query.service` creates the query and starts watching its key in the current
|
||||
scope. Creating it in `Component.useOnMount` keeps one query instance—and one
|
||||
stable query function identity—for the component's lifetime.
|
||||
|
||||
## Render AsyncResult
|
||||
|
||||
`query.state` contains both the current key and an `AsyncResult`:
|
||||
|
||||
```ts
|
||||
interface QueryState<K, A, E> {
|
||||
readonly key: K
|
||||
readonly result: AsyncResult.AsyncResult<A, E>
|
||||
}
|
||||
```
|
||||
|
||||
Subscribe with `View.useAll`, then match the result explicitly:
|
||||
|
||||
```tsx
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
|
||||
const [state] = yield* View.useAll([query.state])
|
||||
|
||||
return AsyncResult.match(state.result, {
|
||||
onInitial: ({ waiting }) =>
|
||||
waiting ? <p>Loading...</p> : <p>Not loaded.</p>,
|
||||
onFailure: ({ cause, previousSuccess, waiting }) => (
|
||||
<section>
|
||||
<p>Request failed: {cause.toString()}</p>
|
||||
{previousSuccess._tag === "Some" && (
|
||||
<p>Last post: {previousSuccess.value.value.title}</p>
|
||||
)}
|
||||
{waiting && <p>Trying again...</p>}
|
||||
</section>
|
||||
),
|
||||
onSuccess: ({ value, waiting }) => (
|
||||
<article>
|
||||
{waiting && <small>Refreshing...</small>}
|
||||
<h2>{value.title}</h2>
|
||||
<p>{value.body}</p>
|
||||
</article>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
The `waiting` flag separates “do I have a result?” from “is work currently in
|
||||
flight?”. During a background refresh, a successful result remains successful
|
||||
and keeps its value while `waiting` becomes `true`. A failed refresh can retain
|
||||
its `previousSuccess`. This avoids replacing useful content with an empty
|
||||
loading screen every time data is refreshed.
|
||||
|
||||
Failures contain an Effect `Cause<E>`, preserving typed failures, defects, and
|
||||
interruption information instead of flattening everything into an untyped
|
||||
exception.
|
||||
|
||||
## Change the query key
|
||||
|
||||
The key is reactive state, so changing it is enough to fetch the corresponding
|
||||
resource. There is no separate dependency array to keep synchronized:
|
||||
|
||||
```tsx
|
||||
const [id, setId] = yield* Lens.useState(postId)
|
||||
|
||||
return (
|
||||
<label>
|
||||
Post
|
||||
<select
|
||||
value={id}
|
||||
onChange={(event) => setId(Number(event.currentTarget.value))}
|
||||
>
|
||||
<option value={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
<option value={3}>3</option>
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
```
|
||||
|
||||
When the key changes, the previous in-flight request is interrupted. The query
|
||||
then reuses a fresh cached success or starts the effect for the new key.
|
||||
|
||||
## Refresh and invalidate
|
||||
|
||||
Refresh resolves the latest key again. A fresh cached result can satisfy it
|
||||
immediately; a stale or missing result runs the query effect while preserving
|
||||
previous data as background state. Invalidation removes cached data, so the
|
||||
next fetch for that key must run the effect again.
|
||||
|
||||
```tsx
|
||||
const runSync = yield* Component.useRunSync()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => runSync(query.refreshView)}>
|
||||
Refresh current post
|
||||
</button>
|
||||
<button onClick={() => runSync(query.invalidateCacheEntry(["post", id] as const))}>
|
||||
Invalidate current post
|
||||
</button>
|
||||
<button onClick={() => runSync(query.invalidateCache)}>
|
||||
Invalidate all posts from this query
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
```
|
||||
|
||||
The methods come in two styles:
|
||||
|
||||
| Method | Behavior |
|
||||
| --- | --- |
|
||||
| `fetch(key)` | Fetch a specific key and wait for its final state. |
|
||||
| `fetchView(key)` | Start fetching and immediately return a live state `View`. |
|
||||
| `refresh` | Resolve the current key again and wait for its final state. |
|
||||
| `refreshView` | Resolve the current key again and immediately return a live state `View`. |
|
||||
| `invalidateCacheEntry(key)` | Remove the cached success for one key. |
|
||||
| `invalidateCache` | Remove every cached success associated with this query function. |
|
||||
|
||||
The `*View` variants are convenient in synchronous UI callbacks: they return
|
||||
after the scoped request has started, while the returned View and `query.state`
|
||||
continue to publish progress. The non-View variants are useful in Effect
|
||||
workflows that need to wait for the final success or failure state.
|
||||
|
||||
Invalidating does not itself refetch. Follow it with `refreshView`, change the
|
||||
key, or allow a later fetch to repopulate the cache.
|
||||
|
||||
## Staleness and cache lifetime
|
||||
|
||||
`staleTime` controls how long a successful result can satisfy a fetch without
|
||||
running the effect again. A stale entry remains available as previous data
|
||||
while the query refreshes it.
|
||||
|
||||
`cacheGcTime` is configured on `QueryClient`. Entries that have not been
|
||||
accessed are eventually removed after their stale period plus the configured
|
||||
garbage-collection time.
|
||||
|
||||
By default, the client enables refresh on browser window focus. Set it globally
|
||||
or override it for one query:
|
||||
|
||||
```tsx
|
||||
const query = yield* Query.service({
|
||||
key,
|
||||
f: loadPost,
|
||||
staleTime: "10 seconds",
|
||||
refreshOnWindowFocus: false,
|
||||
})
|
||||
```
|
||||
|
||||
Window focus resolves the current key again, subject to the same freshness
|
||||
check. The browser integration is ignored in non-browser environments.
|
||||
|
||||
## The Effect touch
|
||||
|
||||
The API emulates TanStack Query's server-state ergonomics, but query execution
|
||||
remains ordinary Effect code:
|
||||
|
||||
- `f` has the full `Effect<A, E, R>` type, including required services.
|
||||
- The query captures its Effect context when it is created, so callbacks do not
|
||||
need to manually reconstruct dependencies.
|
||||
- Effects can use `Schema`, retry schedules, tracing, logging, metrics,
|
||||
cancellation, and any other Effect operator before becoming query state.
|
||||
- Request fibers belong to the creation scope and are interrupted on unmount,
|
||||
key replacement, or scope closure.
|
||||
- Results are `View`s, so the same state can drive React through `View.useAll`
|
||||
or participate in Effect streams and application logic outside React.
|
||||
- Failures retain `Cause<E>` rather than losing Effect's error model.
|
||||
|
||||
`Query` is therefore best understood as a TanStack Query-shaped coordinator
|
||||
for Effect programs: it handles when and whether to run them, while Effect
|
||||
continues to describe what the work requires and how it behaves.
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: State Management
|
||||
---
|
||||
|
||||
# State Management
|
||||
|
||||
`Lens` is the main type used for state management in `effect-view`.
|
||||
|
||||
A Lens is an effectful handle to a piece of state. It can read the current value,
|
||||
subscribe to changes, and write updates back to the underlying source. A Lens
|
||||
can point at a whole state object or focus on one nested field inside it.
|
||||
|
||||
`effect-view` re-exports the `Lens` and `View` modules from
|
||||
[`effect-lens`](https://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/View API.
|
||||
|
||||
The usual pattern is to create state with Effect primitives such as
|
||||
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor
|
||||
such as `Lens.fromSubscriptionRef`, and bind Lens values into components with
|
||||
`View.useAll`.
|
||||
|
||||
`View` is the read-only side of this model. Every Lens is also a View.
|
||||
|
||||
## Where To Store State
|
||||
|
||||
State can live pretty much anywhere as a `Lens` or `View`: in a
|
||||
service, in a layer, in a component scope, or alongside plain React state. Pick
|
||||
the owner based on who needs the state. Once you have a Lens/View
|
||||
handle, pass it around however you like, including through React props.
|
||||
|
||||
If state is shared by multiple components or belongs to application logic, store
|
||||
it in an Effect service:
|
||||
|
||||
```tsx
|
||||
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-view"
|
||||
|
||||
class CounterState extends Context.Service<
|
||||
CounterState,
|
||||
{ readonly count: Lens.Lens<number> }
|
||||
>()("CounterState") {
|
||||
static readonly layer = Layer.effect(
|
||||
CounterState,
|
||||
Effect.gen(function*() {
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
|
||||
return { count } as const
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const CounterValueView = Component.make("CounterValue")(function*() {
|
||||
const state = yield* CounterState
|
||||
const [count] = yield* View.useAll([state.count])
|
||||
|
||||
return <p>Count: {count}</p>
|
||||
})
|
||||
```
|
||||
|
||||
If state belongs to a single Effect View component instance, or to a shallow
|
||||
hierarchy of subcomponents that receive it through props, create it with
|
||||
`Component.useOnMount`:
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-view"
|
||||
|
||||
const LocalCounterView = Component.make("LocalCounter")(function*() {
|
||||
const state = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function*() {
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
|
||||
return { count } as const
|
||||
}),
|
||||
)
|
||||
const [count] = yield* View.useAll([state.count])
|
||||
|
||||
return <p>Count: {count}</p>
|
||||
})
|
||||
```
|
||||
|
||||
For simple UI state that is not shared and does not need Effect integration,
|
||||
prefer regular React state. A local "show details" toggle is usually better as
|
||||
`React.useState(false)` than as a Lens.
|
||||
|
||||
## View.useAll
|
||||
|
||||
A `View<A>` is reactive state with a current value and a stream of changes. Use
|
||||
`View.useAll` whenever a component needs to bind View values into render output.
|
||||
|
||||
`Lens` is a `View`, so this is also the default way to read Lens values
|
||||
from a component.
|
||||
|
||||
```tsx
|
||||
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-view"
|
||||
|
||||
class CounterState extends Context.Service<
|
||||
CounterState,
|
||||
{
|
||||
readonly count: Lens.Lens<number>
|
||||
readonly doubled: View.View<number>
|
||||
}
|
||||
>()("CounterState") {
|
||||
static readonly layer = Layer.effect(
|
||||
CounterState,
|
||||
Effect.gen(function*() {
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
const doubled = View.map(count, (n) => n * 2)
|
||||
|
||||
return { count, doubled } as const
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const CounterReadOnlyView = Component.make("CounterReadOnly")(
|
||||
function* () {
|
||||
const state = yield* CounterState
|
||||
const [count, doubled] = yield* View.useAll([
|
||||
state.count,
|
||||
state.doubled,
|
||||
])
|
||||
|
||||
return <p>Count: {count}, doubled: {doubled}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
`View.useAll` reads the current values during render and uses scoped subscriptions to update React state when changes arrive.
|
||||
|
||||
When you need to modify state, write to the Lens with `Lens.set` or `Lens.update`:
|
||||
|
||||
```tsx
|
||||
const CounterControlsView = Component.make("CounterControls")(
|
||||
function* () {
|
||||
const state = yield* CounterState
|
||||
const [count] = yield* View.useAll([state.count])
|
||||
|
||||
const increment = yield* Component.useCallbackSync(
|
||||
() => Lens.update(state.count, (n) => n + 1),
|
||||
[],
|
||||
)
|
||||
const reset = yield* Component.useCallbackSync(
|
||||
() => Lens.set(state.count, 0),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p>Count: {count}</p>
|
||||
<button onClick={increment}>Increment</button>
|
||||
<button onClick={reset}>Reset</button>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## Lens.useState
|
||||
|
||||
`Lens.useState` is useful when React needs the familiar `[value, setValue]`
|
||||
tuple, backed by a Lens. Reach for it when the JSX API expects a synchronous
|
||||
setter, especially controlled inputs such as text fields, checkboxes, selects,
|
||||
or third-party components with `value` / `onChange` props.
|
||||
|
||||
If a component only needs to display the value, prefer `View.useAll`.
|
||||
`Lens.useState` is for places where reading and writing need to be wired
|
||||
together in React's local-state shape.
|
||||
|
||||
```tsx
|
||||
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||
import { Component, Lens } from "effect-view"
|
||||
|
||||
class FormState extends Context.Service<
|
||||
FormState,
|
||||
{ readonly name: Lens.Lens<string> }
|
||||
>()("FormState") {
|
||||
static readonly layer = Layer.effect(
|
||||
FormState,
|
||||
Effect.gen(function*() {
|
||||
const name = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(""))
|
||||
|
||||
return { name } as const
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const NameInputView = Component.make("NameInput")(function* () {
|
||||
const state = yield* FormState
|
||||
const [name, setName] = yield* Lens.useState(state.name)
|
||||
|
||||
return (
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
`Lens.useState` returns the current value and a React-compatible setter. Calling
|
||||
the setter writes through the Lens, so every other component subscribed to the
|
||||
same Lens sees the update.
|
||||
|
||||
## Focused Lenses
|
||||
|
||||
Use focused Lenses when a component should work with one part of a larger state
|
||||
object. A focused Lens is still a Lens, so it can be read with
|
||||
`View.useAll` or used with `Lens.useState` when React needs a
|
||||
read/write tuple.
|
||||
|
||||
```tsx
|
||||
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-view"
|
||||
|
||||
interface UserProfile {
|
||||
readonly name: string
|
||||
readonly email: string
|
||||
readonly role: string
|
||||
}
|
||||
|
||||
class ProfileState extends Context.Service<
|
||||
ProfileState,
|
||||
{
|
||||
readonly profile: Lens.Lens<UserProfile>
|
||||
readonly name: Lens.Lens<string>
|
||||
readonly role: Lens.Lens<string>
|
||||
}
|
||||
>()("ProfileState") {
|
||||
static readonly layer = Layer.effect(
|
||||
ProfileState,
|
||||
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* View.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
|
||||
`View.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 {Config} from '@docusaurus/types';
|
||||
import {themes as prismThemes} from 'prism-react-renderer';
|
||||
import type * as Preset from "@docusaurus/preset-classic"
|
||||
import type { Config } from "@docusaurus/types"
|
||||
import { themes as prismThemes } from "prism-react-renderer"
|
||||
|
||||
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||
|
||||
const config: Config = {
|
||||
title: 'My Site',
|
||||
tagline: 'Dinosaurs are cool',
|
||||
favicon: 'img/favicon.ico',
|
||||
title: "Effect View",
|
||||
tagline: "Write React function components with Effect",
|
||||
favicon: "img/favicon.ico",
|
||||
|
||||
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
|
||||
future: {
|
||||
@@ -15,54 +15,48 @@ const config: Config = {
|
||||
},
|
||||
|
||||
// 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
|
||||
// For GitHub pages deployment, it is often '/<projectName>/'
|
||||
baseUrl: '/',
|
||||
baseUrl: "/effect-view/",
|
||||
|
||||
// GitHub pages deployment config.
|
||||
// If you aren't using GitHub pages, you don't need these.
|
||||
organizationName: 'facebook', // Usually your GitHub org/user name.
|
||||
projectName: 'docusaurus', // Usually your repo name.
|
||||
organizationName: "Thiladev", // Usually your GitHub org/user name.
|
||||
projectName: "effect-view", // Usually your repo name.
|
||||
|
||||
onBrokenLinks: 'throw',
|
||||
onBrokenLinks: "throw",
|
||||
|
||||
// Even if you don't use internationalization, you can use this field to set
|
||||
// useful metadata like html lang. For example, if your site is Chinese, you
|
||||
// may want to replace "en" with "zh-Hans".
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en'],
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
"classic",
|
||||
{
|
||||
docs: {
|
||||
sidebarPath: './sidebars.ts',
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
sidebarPath: "./sidebars.ts",
|
||||
editUrl:
|
||||
'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
|
||||
},
|
||||
blog: {
|
||||
showReadingTime: true,
|
||||
feedOptions: {
|
||||
type: ['rss', 'atom'],
|
||||
xslt: true,
|
||||
"https://github.com/Thiladev/effect-view/tree/main/packages/docs/",
|
||||
lastVersion: "current",
|
||||
versions: {
|
||||
current: {
|
||||
label: "effect-view v0",
|
||||
},
|
||||
"effect-fc-v0": {
|
||||
label: "effect-fc v0",
|
||||
path: "effect-fc/v0",
|
||||
},
|
||||
},
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl:
|
||||
'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
|
||||
// Useful options to enforce blogging best practices
|
||||
onInlineTags: 'warn',
|
||||
onInlineAuthors: 'warn',
|
||||
onUntruncatedBlogPosts: 'warn',
|
||||
},
|
||||
blog: false,
|
||||
theme: {
|
||||
customCss: './src/css/custom.css',
|
||||
customCss: "./src/css/custom.css",
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
@@ -70,81 +64,84 @@ const config: Config = {
|
||||
|
||||
themeConfig: {
|
||||
// Replace with your project's social card
|
||||
image: 'img/docusaurus-social-card.jpg',
|
||||
colorMode: {
|
||||
respectPrefersColorScheme: true,
|
||||
},
|
||||
navbar: {
|
||||
title: 'My Site',
|
||||
title: "Effect View",
|
||||
logo: {
|
||||
alt: 'My Site Logo',
|
||||
src: 'img/logo.svg',
|
||||
alt: "Effect View logo",
|
||||
src: "img/logo.svg",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'docSidebar',
|
||||
sidebarId: 'tutorialSidebar',
|
||||
position: 'left',
|
||||
label: 'Tutorial',
|
||||
type: "docSidebar",
|
||||
sidebarId: "docsSidebar",
|
||||
position: "left",
|
||||
label: "Docs",
|
||||
},
|
||||
{to: '/blog', label: 'Blog', position: 'left'},
|
||||
{
|
||||
href: 'https://github.com/facebook/docusaurus',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
type: "docsVersionDropdown",
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
href: "https://www.npmjs.com/package/effect-view",
|
||||
label: "npm",
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
href: "https://github.com/Thiladev/effect-view",
|
||||
label: "GitHub",
|
||||
position: "right",
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: 'dark',
|
||||
style: "dark",
|
||||
links: [
|
||||
{
|
||||
title: 'Docs',
|
||||
title: "Docs",
|
||||
items: [
|
||||
{
|
||||
label: 'Tutorial',
|
||||
to: '/docs/intro',
|
||||
label: "Getting Started",
|
||||
to: "/docs/getting-started",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Community',
|
||||
title: "Project",
|
||||
items: [
|
||||
{
|
||||
label: 'Stack Overflow',
|
||||
href: 'https://stackoverflow.com/questions/tagged/docusaurus',
|
||||
label: "GitHub",
|
||||
href: "https://github.com/Thiladev/effect-view",
|
||||
},
|
||||
{
|
||||
label: 'Discord',
|
||||
href: 'https://discordapp.com/invite/docusaurus',
|
||||
},
|
||||
{
|
||||
label: 'X',
|
||||
href: 'https://x.com/docusaurus',
|
||||
label: "Example App",
|
||||
href: "https://github.com/Thiladev/effect-view/tree/main/packages/example",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'More',
|
||||
title: "Ecosystem",
|
||||
items: [
|
||||
{
|
||||
label: 'Blog',
|
||||
to: '/blog',
|
||||
label: "Effect",
|
||||
href: "https://effect.website/",
|
||||
},
|
||||
{
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/facebook/docusaurus',
|
||||
label: "React",
|
||||
href: "https://react.dev/",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
copyright: `Copyright © ${new Date().getFullYear()} My Project, Inc. Built with Docusaurus.`,
|
||||
copyright: `Copyright © ${new Date().getFullYear()} Effect View contributors. Built with Docusaurus.`,
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.github,
|
||||
darkTheme: prismThemes.dracula,
|
||||
},
|
||||
} satisfies Preset.ThemeConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export default config;
|
||||
export default config
|
||||
|
||||
@@ -3,21 +3,25 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint:tsc": "tsc -b --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"build": "docusaurus build --out-dir dist",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"serve": "docusaurus serve --dir dist",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
"clean:cache": "rm -rf .turbo *.tsbuildinfo && docusaurus clear",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.10.1",
|
||||
"@docusaurus/faster": "^3.10.1",
|
||||
"@docusaurus/preset-classic": "3.10.1",
|
||||
"@docusaurus/core": "^3.10.2",
|
||||
"@docusaurus/faster": "^3.10.2",
|
||||
"@docusaurus/preset-classic": "^3.10.2",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
@@ -25,9 +29,11 @@
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.10.1",
|
||||
"@docusaurus/tsconfig": "3.10.1",
|
||||
"@docusaurus/types": "3.10.1",
|
||||
"@docusaurus/module-type-aliases": "^3.10.2",
|
||||
"@docusaurus/tsconfig": "^3.10.2",
|
||||
"@docusaurus/types": "^3.10.2",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -1,33 +1,15 @@
|
||||
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...)
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
|
||||
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
tutorialSidebar: [
|
||||
'intro',
|
||||
'hello',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Tutorial',
|
||||
items: ['tutorial-basics/create-a-document'],
|
||||
},
|
||||
docsSidebar: [
|
||||
"getting-started",
|
||||
"state-management",
|
||||
"query",
|
||||
"mutation",
|
||||
"forms",
|
||||
],
|
||||
*/
|
||||
};
|
||||
}
|
||||
|
||||
export default sidebars;
|
||||
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 {
|
||||
--ifm-color-primary: #2e8555;
|
||||
--ifm-color-primary-dark: #29784c;
|
||||
--ifm-color-primary-darker: #277148;
|
||||
--ifm-color-primary-darkest: #205d3b;
|
||||
--ifm-color-primary-light: #33925d;
|
||||
--ifm-color-primary-lighter: #359962;
|
||||
--ifm-color-primary-lightest: #3cad6e;
|
||||
--ifm-color-primary: #0b6f74;
|
||||
--ifm-color-primary-dark: #096469;
|
||||
--ifm-color-primary-darker: #085e63;
|
||||
--ifm-color-primary-darkest: #074d51;
|
||||
--ifm-color-primary-light: #0d7a7f;
|
||||
--ifm-color-primary-lighter: #0e8085;
|
||||
--ifm-color-primary-lightest: #109096;
|
||||
--ifm-background-color: #fffaf1;
|
||||
--ifm-code-font-size: 95%;
|
||||
--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. */
|
||||
[data-theme='dark'] {
|
||||
--ifm-color-primary: #25c2a0;
|
||||
--ifm-color-primary-dark: #21af90;
|
||||
--ifm-color-primary-darker: #1fa588;
|
||||
--ifm-color-primary-darkest: #1a8870;
|
||||
--ifm-color-primary-light: #29d5b0;
|
||||
--ifm-color-primary-lighter: #32d8b4;
|
||||
--ifm-color-primary-lightest: #4fddbf;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
.button--primary {
|
||||
--ifm-button-background-color: #0b6f74;
|
||||
--ifm-button-border-color: #0b6f74;
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
--ifm-button-background-color: #f4a636;
|
||||
--ifm-button-border-color: #f4a636;
|
||||
--ifm-button-color: #1f2526;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--ifm-color-primary: #7ad6d4;
|
||||
--ifm-color-primary-dark: #5cccca;
|
||||
--ifm-color-primary-darker: #4cc6c4;
|
||||
--ifm-color-primary-darkest: #34aaa8;
|
||||
--ifm-color-primary-light: #98e0de;
|
||||
--ifm-color-primary-lighter: #a8e6e4;
|
||||
--ifm-color-primary-lightest: #d5f4f3;
|
||||
--ifm-background-color: #111c1f;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(122, 214, 212, 0.18);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,672 @@
|
||||
/**
|
||||
* CSS files with the .module.css suffix will be treated as CSS modules
|
||||
* and scoped locally.
|
||||
*/
|
||||
|
||||
.heroBanner {
|
||||
padding: 4rem 0;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
.page {
|
||||
--home-ink: #102f31;
|
||||
--home-muted: #506a6c;
|
||||
--home-teal: #08777b;
|
||||
--home-teal-bright: #16a3a1;
|
||||
--home-amber: #e99526;
|
||||
--home-paper: #fffdf8;
|
||||
--home-card: rgba(255, 255, 255, 0.76);
|
||||
--home-line: rgba(16, 47, 49, 0.12);
|
||||
min-height: calc(100vh - var(--ifm-navbar-height));
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background:
|
||||
radial-gradient(circle at 8% 5%, rgba(14, 145, 148, 0.2), transparent 28rem),
|
||||
radial-gradient(circle at 92% 14%, rgba(240, 159, 48, 0.2), transparent 27rem),
|
||||
linear-gradient(145deg, #f7f2e7 0%, #edf8f6 48%, #fff8ed 100%);
|
||||
color: var(--home-ink);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 996px) {
|
||||
.heroBanner {
|
||||
padding: 2rem;
|
||||
.gridBackdrop {
|
||||
background-image:
|
||||
linear-gradient(rgba(16, 47, 49, 0.045) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(16, 47, 49, 0.045) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
inset: 0;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 62%);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.hero {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: clamp(3rem, 6vw, 6rem);
|
||||
grid-template-columns: minmax(0, 0.88fr) minmax(560px, 1.12fr);
|
||||
min-height: min(820px, calc(100vh - var(--ifm-navbar-height)));
|
||||
padding-bottom: 6rem;
|
||||
padding-top: 6rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
max-width: 650px;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.sectionKicker {
|
||||
align-items: center;
|
||||
color: var(--home-teal);
|
||||
display: flex;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 850;
|
||||
gap: 0.65rem;
|
||||
letter-spacing: 0.18em;
|
||||
margin: 0 0 1.35rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
background: var(--home-teal-bright);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 5px rgba(22, 163, 161, 0.14);
|
||||
height: 0.55rem;
|
||||
width: 0.55rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
color: var(--home-ink);
|
||||
font-size: clamp(3.4rem, 6.4vw, 6.4rem);
|
||||
font-weight: 780;
|
||||
letter-spacing: -0.075em;
|
||||
line-height: 0.92;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero h1 span {
|
||||
background: linear-gradient(105deg, var(--home-teal) 5%, #1a9793 48%, var(--home-amber));
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
display: inline;
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--home-muted);
|
||||
font-size: clamp(1.08rem, 1.55vw, 1.32rem);
|
||||
line-height: 1.65;
|
||||
margin: 2rem 0 0;
|
||||
max-width: 630px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
margin-top: 2.3rem;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction,
|
||||
.ctaAction {
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
font-weight: 750;
|
||||
gap: 0.8rem;
|
||||
justify-content: center;
|
||||
padding: 0.88rem 1.35rem;
|
||||
text-decoration: none !important;
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
background: var(--home-ink);
|
||||
box-shadow: 0 12px 28px rgba(16, 47, 49, 0.2);
|
||||
color: #f6fffd;
|
||||
}
|
||||
|
||||
.primaryAction:hover {
|
||||
box-shadow: 0 16px 36px rgba(16, 47, 49, 0.28);
|
||||
color: #fff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
border: 1px solid var(--home-line);
|
||||
color: var(--home-ink);
|
||||
}
|
||||
|
||||
.secondaryAction:hover {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--home-teal);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.install {
|
||||
align-items: center;
|
||||
color: var(--home-muted);
|
||||
display: flex;
|
||||
font-size: 0.84rem;
|
||||
gap: 0.7rem;
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.install span {
|
||||
color: var(--home-amber);
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.install code {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hotReload {
|
||||
align-items: center;
|
||||
color: var(--home-teal);
|
||||
display: inline-flex;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 750;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.hotReload span {
|
||||
color: var(--home-amber);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hotReload:hover {
|
||||
color: var(--home-teal-bright);
|
||||
}
|
||||
|
||||
.codeStage {
|
||||
min-width: 0;
|
||||
perspective: 1200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.codeGlow {
|
||||
background: linear-gradient(115deg, rgba(11, 133, 136, 0.48), rgba(238, 156, 40, 0.38));
|
||||
filter: blur(55px);
|
||||
inset: 8% 5%;
|
||||
opacity: 0.55;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.codeWindow {
|
||||
background: #101b1e;
|
||||
border: 1px solid rgba(166, 225, 220, 0.2);
|
||||
border-radius: 1.25rem;
|
||||
box-shadow:
|
||||
0 34px 90px rgba(18, 49, 52, 0.3),
|
||||
0 2px 0 rgba(255, 255, 255, 0.06) inset;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transform: rotateY(-2deg) rotateX(1deg);
|
||||
}
|
||||
|
||||
.windowBar {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #b6c9c9;
|
||||
display: grid;
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.73rem;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
padding: 0.78rem 1rem;
|
||||
}
|
||||
|
||||
.windowDots {
|
||||
display: flex;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.windowDots span {
|
||||
background: #4f6d6e;
|
||||
border-radius: 50%;
|
||||
height: 0.55rem;
|
||||
width: 0.55rem;
|
||||
}
|
||||
|
||||
.windowDots span:first-child {
|
||||
background: #ef8d5b;
|
||||
}
|
||||
|
||||
.windowDots span:nth-child(2) {
|
||||
background: #e9bc50;
|
||||
}
|
||||
|
||||
.windowDots span:last-child {
|
||||
background: #65bd92;
|
||||
}
|
||||
|
||||
.windowStatus {
|
||||
color: #74d2cd;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.codeWindow :global(.theme-code-block) {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.codeWindow :global(.theme-code-block pre) {
|
||||
background: #101b1e !important;
|
||||
color: #d7e5e4 !important;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.62;
|
||||
max-height: none;
|
||||
padding: 1.45rem 1.6rem !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token-line),
|
||||
.codeWindow :global(.token.plain),
|
||||
.codeWindow :global(.token.imports),
|
||||
.codeWindow :global(.token.maybe-class-name),
|
||||
.codeWindow :global(.token.punctuation),
|
||||
.codeWindow :global(.token.operator),
|
||||
.codeWindow :global(.token.plain-text),
|
||||
.codeWindow :global(.token.property-access) {
|
||||
color: #d7e5e4 !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.keyword) {
|
||||
color: #c792ea !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.string) {
|
||||
color: #c3e88d !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.function),
|
||||
.codeWindow :global(.token.method) {
|
||||
color: #82aaff !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.tag) {
|
||||
color: #f07178 !important;
|
||||
}
|
||||
|
||||
.codeWindow :global(.token.builtin) {
|
||||
color: #ffcb6b !important;
|
||||
}
|
||||
|
||||
.codeLegend {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #a9bcbc;
|
||||
display: grid;
|
||||
font-size: 0.7rem;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.codeLegend span {
|
||||
padding: 0.8rem 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.codeLegend span + span {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.codeLegend b {
|
||||
color: #e8a94b;
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.featuresSection {
|
||||
padding-bottom: 7rem;
|
||||
padding-top: 4rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
display: grid;
|
||||
gap: 0 3rem;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
|
||||
margin-bottom: 2.8rem;
|
||||
}
|
||||
|
||||
.sectionHeading .sectionKicker {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.sectionHeading h2,
|
||||
.cta h2 {
|
||||
color: var(--home-ink);
|
||||
font-size: clamp(2.25rem, 4vw, 4rem);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sectionHeading > p:last-child {
|
||||
align-self: end;
|
||||
color: var(--home-muted);
|
||||
font-size: 1.03rem;
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.featureGrid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.featureCard {
|
||||
background: var(--home-card);
|
||||
border: 1px solid var(--home-line);
|
||||
border-radius: 1.35rem;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 310px;
|
||||
overflow: hidden;
|
||||
padding: 1.45rem;
|
||||
position: relative;
|
||||
text-decoration: none !important;
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.featureCard::before {
|
||||
background: linear-gradient(90deg, var(--home-teal-bright), var(--home-amber));
|
||||
content: "";
|
||||
height: 3px;
|
||||
left: 1.45rem;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 1.45rem;
|
||||
top: 0;
|
||||
transform: scaleX(0.5);
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.featureCard:hover {
|
||||
border-color: rgba(8, 119, 123, 0.35);
|
||||
box-shadow: 0 24px 60px rgba(28, 65, 66, 0.12);
|
||||
color: inherit;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.featureCard:hover::before {
|
||||
opacity: 1;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.cardTopline {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.featureIcon {
|
||||
align-items: center;
|
||||
background: rgba(8, 119, 123, 0.1);
|
||||
border: 1px solid rgba(8, 119, 123, 0.12);
|
||||
border-radius: 0.8rem;
|
||||
color: var(--home-teal);
|
||||
display: flex;
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 1rem;
|
||||
font-weight: 850;
|
||||
height: 2.7rem;
|
||||
justify-content: center;
|
||||
width: 2.7rem;
|
||||
}
|
||||
|
||||
.cardIndex {
|
||||
color: rgba(16, 47, 49, 0.32);
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.featureCard h3 {
|
||||
color: var(--home-ink);
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 2rem 0 0.7rem;
|
||||
}
|
||||
|
||||
.featureCard > p {
|
||||
color: var(--home-muted);
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.cardFooter code {
|
||||
background: rgba(8, 119, 123, 0.08);
|
||||
border: 0;
|
||||
color: var(--home-teal);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.cardFooter > span {
|
||||
color: var(--home-amber);
|
||||
font-size: 1.1rem;
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.featureCard:hover .cardFooter > span {
|
||||
transform: translate(3px, -3px);
|
||||
}
|
||||
|
||||
.ctaWrap {
|
||||
padding-bottom: 7rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cta {
|
||||
align-items: center;
|
||||
background:
|
||||
radial-gradient(circle at 90% 20%, rgba(233, 149, 38, 0.22), transparent 20rem),
|
||||
linear-gradient(125deg, #12383a, #10292c);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1.75rem;
|
||||
box-shadow: 0 28px 80px rgba(16, 47, 49, 0.2);
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
padding: clamp(2rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
.cta .sectionKicker {
|
||||
color: #79d5d1;
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
color: #f7fffd;
|
||||
}
|
||||
|
||||
.cta p:last-child {
|
||||
color: #b9cccc;
|
||||
line-height: 1.65;
|
||||
margin: 1rem 0 0;
|
||||
max-width: 650px;
|
||||
}
|
||||
|
||||
.ctaAction {
|
||||
background: #f0a13b;
|
||||
color: #162d2e;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ctaAction:hover {
|
||||
box-shadow: 0 14px 35px rgba(0, 0, 0, 0.24);
|
||||
color: #102526;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .page {
|
||||
--home-ink: #e9f7f5;
|
||||
--home-muted: #a9bfbd;
|
||||
--home-teal: #79d5d1;
|
||||
--home-teal-bright: #5bc8c5;
|
||||
--home-amber: #f1ad4e;
|
||||
--home-card: rgba(20, 42, 45, 0.76);
|
||||
--home-line: rgba(174, 221, 218, 0.14);
|
||||
background:
|
||||
radial-gradient(circle at 8% 5%, rgba(33, 154, 154, 0.17), transparent 28rem),
|
||||
radial-gradient(circle at 92% 14%, rgba(209, 130, 31, 0.13), transparent 27rem),
|
||||
linear-gradient(145deg, #0f1b1e 0%, #102426 48%, #161e1e 100%);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .gridBackdrop {
|
||||
background-image:
|
||||
linear-gradient(rgba(174, 221, 218, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(174, 221, 218, 0.04) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .secondaryAction {
|
||||
background: rgba(19, 43, 46, 0.75);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .secondaryAction:hover {
|
||||
background: rgba(27, 57, 60, 0.95);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .primaryAction {
|
||||
background: #dff8f5;
|
||||
color: #123638;
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .primaryAction:hover {
|
||||
color: #0b282a;
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .cardIndex {
|
||||
color: rgba(220, 242, 239, 0.35);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1180px) {
|
||||
.hero {
|
||||
gap: 3rem;
|
||||
grid-template-columns: minmax(0, 0.85fr) minmax(500px, 1.15fr);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(3.2rem, 6vw, 5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@media screen and (max-width: 996px) {
|
||||
.hero {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding-bottom: 4rem;
|
||||
padding-top: 4.5rem;
|
||||
}
|
||||
|
||||
.heroCopy,
|
||||
.codeStage,
|
||||
.codeWindow,
|
||||
.codeWindow :global(.theme-code-block) {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.codeStage {
|
||||
margin: 0 auto;
|
||||
max-width: 760px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.codeWindow {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.featureGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
.hero {
|
||||
box-sizing: border-box;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
max-width: 100vw;
|
||||
padding-left: 1.15rem;
|
||||
padding-right: 1.15rem;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.heroCopy,
|
||||
.codeStage {
|
||||
max-width: calc(100vw - 2.3rem);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(3rem, 15vw, 4.3rem);
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sectionHeading > p:last-child {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.featureGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.featureCard {
|
||||
min-height: 275px;
|
||||
}
|
||||
|
||||
.codeLegend {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.codeLegend span + span {
|
||||
border-left: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.windowStatus {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.windowBar {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.codeWindow :global(.theme-code-block pre) {
|
||||
font-size: 0.69rem;
|
||||
overflow-x: auto;
|
||||
padding: 1.1rem !important;
|
||||
}
|
||||
|
||||
.cta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.featureCard,
|
||||
.featureCard::before,
|
||||
.cardFooter > span,
|
||||
.primaryAction,
|
||||
.secondaryAction,
|
||||
.ctaAction {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,212 @@
|
||||
import Link from '@docusaurus/Link';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import HomepageFeatures from '@site/src/components/HomepageFeatures';
|
||||
import Heading from '@theme/Heading';
|
||||
import Layout from '@theme/Layout';
|
||||
import clsx from 'clsx';
|
||||
import type {ReactNode} from 'react';
|
||||
import Link from "@docusaurus/Link"
|
||||
import CodeBlock from "@theme/CodeBlock"
|
||||
import Layout from "@theme/Layout"
|
||||
import clsx from "clsx"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import styles from './index.module.css';
|
||||
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>
|
||||
);
|
||||
}
|
||||
const componentExample = `import { Effect } from "effect"
|
||||
import { Async, Component } from "effect-view"
|
||||
import { Users } from "./services"
|
||||
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { userId: string }) {
|
||||
const users = yield* Users
|
||||
|
||||
const user = yield* Component.useOnChange(
|
||||
() => users.find(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
yield* Component.useReactEffect(
|
||||
() => Effect.forkScoped(users.watch(userId)),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return (
|
||||
<article>
|
||||
<h2>{user.name}</h2>
|
||||
<p>{user.email}</p>
|
||||
</article>
|
||||
)
|
||||
},
|
||||
).pipe(Async.async)`
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: "fx",
|
||||
title: "Components are Effect programs",
|
||||
description:
|
||||
"Yield Effects and services directly from a component body, then return ordinary JSX. Types track every dependency all the way to the runtime boundary.",
|
||||
tag: "Component.make",
|
||||
to: "/docs/getting-started#write-your-first-component",
|
||||
},
|
||||
{
|
||||
icon: "R",
|
||||
title: "One typed runtime",
|
||||
description:
|
||||
"Build your application Layer once. Components access HTTP clients, repositories, configuration, tracing, and your own services without React context plumbing.",
|
||||
tag: "ReactRuntime",
|
||||
to: "/docs/getting-started#create-the-runtime",
|
||||
},
|
||||
{
|
||||
icon: "S",
|
||||
title: "Scopes follow React",
|
||||
description:
|
||||
"Every component owns an Effect Scope. Resources, subscriptions, and fibers are finalized when their component or dependency lifecycle ends.",
|
||||
tag: "Scope.Scope",
|
||||
to: "/docs/getting-started#the-component-root-scope",
|
||||
},
|
||||
{
|
||||
icon: "↔",
|
||||
title: "Reactive state without a new universe",
|
||||
description:
|
||||
"Lens and View connect Effect state to React. Focus large models into small writable values and subscribe only where rendering needs them.",
|
||||
tag: "Lens + View",
|
||||
to: "/docs/state-management",
|
||||
},
|
||||
{
|
||||
icon: "Q",
|
||||
title: "TanStack-shaped server state",
|
||||
description:
|
||||
"Reactive keys, caching, stale times, background refresh, mutations, and invalidation—with typed Effect services, Causes, and scoped fibers underneath.",
|
||||
tag: "Query + Mutation",
|
||||
to: "/docs/query",
|
||||
},
|
||||
{
|
||||
icon: "Σ",
|
||||
title: "Forms driven by Schema",
|
||||
description:
|
||||
"Keep input-friendly encoded values while application code receives validated, decoded types. Focus one root into reusable field-sized subforms.",
|
||||
tag: "MutationForm + LensForm",
|
||||
to: "/docs/forms",
|
||||
},
|
||||
] as const
|
||||
|
||||
export default function Home(): ReactNode {
|
||||
const {siteConfig} = useDocusaurusContext();
|
||||
return (
|
||||
<Layout
|
||||
title={`Hello from ${siteConfig.title}`}
|
||||
description="Description will go into a meta tag in <head />">
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
title="Effect View"
|
||||
description="Write React function components as typed Effect programs"
|
||||
>
|
||||
<main className={styles.page}>
|
||||
<div className={styles.gridBackdrop} aria-hidden="true" />
|
||||
|
||||
<section className={clsx("container", styles.hero)}>
|
||||
<div className={styles.heroCopy}>
|
||||
<div className={styles.eyebrow}>
|
||||
<span className={styles.pulse} />
|
||||
Effect View for React 19
|
||||
</div>
|
||||
<h1>
|
||||
React components,
|
||||
<span> powered by Effect.</span>
|
||||
</h1>
|
||||
<p className={styles.lede}>
|
||||
Bring typed services, scoped resources, reactive state, server
|
||||
queries, and schema-driven forms into React without hiding either
|
||||
framework.
|
||||
</p>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link className={styles.primaryAction} to="/docs/getting-started">
|
||||
Start building
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
<Link
|
||||
className={styles.secondaryAction}
|
||||
to="https://github.com/Thiladev/effect-view"
|
||||
>
|
||||
View on GitHub
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.install}>
|
||||
<span aria-hidden="true">$</span>
|
||||
<code>npm install effect-view effect@beta</code>
|
||||
</div>
|
||||
<Link
|
||||
className={styles.hotReload}
|
||||
to="/docs/getting-started#set-up-hot-reloading-with-vite"
|
||||
>
|
||||
<span aria-hidden="true">↻</span>
|
||||
Hot reload with Vite Fast Refresh
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.codeStage}>
|
||||
<div className={styles.codeGlow} aria-hidden="true" />
|
||||
<div className={styles.codeWindow}>
|
||||
<div className={styles.windowBar}>
|
||||
<div className={styles.windowDots} aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<span>UserCard.tsx</span>
|
||||
<span className={styles.windowStatus}>Effect + JSX</span>
|
||||
</div>
|
||||
<CodeBlock language="tsx">{componentExample}</CodeBlock>
|
||||
<div className={styles.codeLegend}>
|
||||
<span><b>01</b> Yield services</span>
|
||||
<span><b>02</b> Own the lifecycle</span>
|
||||
<span><b>03</b> Return JSX</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={clsx("container", styles.featuresSection)}>
|
||||
<div className={styles.sectionHeading}>
|
||||
<p className={styles.sectionKicker}>Why Effect View</p>
|
||||
<h2>One model from render to resources.</h2>
|
||||
<p>
|
||||
Effect View adds the pieces React deliberately leaves open while
|
||||
preserving the React component model you already know.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.featureGrid}>
|
||||
{features.map((feature, index) => (
|
||||
<Link
|
||||
className={styles.featureCard}
|
||||
to={feature.to}
|
||||
key={feature.title}
|
||||
>
|
||||
<div className={styles.cardTopline}>
|
||||
<span className={styles.featureIcon}>{feature.icon}</span>
|
||||
<span className={styles.cardIndex}>
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<h3>{feature.title}</h3>
|
||||
<p>{feature.description}</p>
|
||||
<div className={styles.cardFooter}>
|
||||
<code>{feature.tag}</code>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={clsx("container", styles.ctaWrap)}>
|
||||
<div className={styles.cta}>
|
||||
<div>
|
||||
<p className={styles.sectionKicker}>Ready when React is</p>
|
||||
<h2>Start with one Effect component.</h2>
|
||||
<p>
|
||||
Add a runtime, cross the React boundary once, and grow into the
|
||||
rest of the toolkit only when you need it.
|
||||
</p>
|
||||
</div>
|
||||
<Link className={styles.ctaAction} to="/docs/getting-started">
|
||||
Read the guide <span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: 3.8 KiB |
@@ -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.
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"exclude": [".docusaurus", "build"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: Forms
|
||||
---
|
||||
|
||||
# Forms
|
||||
|
||||
The form modules provide an Effect-first model for editable, schema-validated
|
||||
state. They are not a visual component library. Instead, they give you
|
||||
Lenses/Subscribables for form values, validation issues, and commit state, so
|
||||
you can build the UI with whatever components your app already uses.
|
||||
|
||||
The base `Form` type represents a field. A field tracks both the raw encoded
|
||||
value used by inputs and the decoded value produced by an Effect `Schema`:
|
||||
|
||||
- `encodedValue`: a `Lens` containing the raw input value.
|
||||
- `value`: a `Subscribable` containing the decoded value as an `Option`.
|
||||
- `issues`: a `Subscribable` containing schema validation issues for that field.
|
||||
- `canCommit`, `isValidating`, and `isCommitting`: `Subscribable` flags for UI
|
||||
state.
|
||||
|
||||
There are two root form flavors:
|
||||
|
||||
- `SubmittableForm`: owns local encoded form state, validates it, and submits a
|
||||
decoded value through a mutation.
|
||||
- `SynchronizedForm`: wraps an existing target `Lens` and writes valid changes
|
||||
back to that target.
|
||||
|
||||
Most apps create one of those root forms, then focus it into fields with
|
||||
`Form.focusObjectOn`, `Form.focusArrayAt`, or the other focusing helpers.
|
||||
|
||||
The core form model is Effect code and can technically be used outside
|
||||
Effect-FC. Effect-FC adds the React-facing hooks, such as `Form.useInput`, that
|
||||
make those forms convenient to bind to components.
|
||||
|
||||
## Field Inputs
|
||||
|
||||
Use `Form.useInput` to connect a `Form` field to a controlled input. It returns
|
||||
a React-style `{ value, setValue }` pair and writes changes back to the field's
|
||||
`encodedValue` Lens.
|
||||
|
||||
```tsx
|
||||
import { Component, Form, Subscribable } from "effect-fc"
|
||||
|
||||
const TextInputView = Component.make("TextInput")(
|
||||
function* (props: {
|
||||
readonly form: Form.Form<readonly PropertyKey[], string, string>
|
||||
readonly label: string
|
||||
}) {
|
||||
const input = yield* Form.useInput(props.form, {
|
||||
debounce: "250 millis",
|
||||
})
|
||||
const [issues] = yield* Subscribable.useAll([props.form.issues])
|
||||
|
||||
return (
|
||||
<label>
|
||||
{props.label}
|
||||
<input
|
||||
value={input.value}
|
||||
onChange={(event) => input.setValue(event.currentTarget.value)}
|
||||
/>
|
||||
{issues.map((issue) => (
|
||||
<small key={issue.path.join(".")}>
|
||||
{issue.message}
|
||||
</small>
|
||||
))}
|
||||
</label>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Use `Form.useOptionalInput` for `Option` fields. It returns the same value/setter
|
||||
pair plus `enabled` and `setEnabled`, which is useful for optional inputs that
|
||||
can be toggled on and off.
|
||||
|
||||
## Submittable Forms
|
||||
|
||||
Use `SubmittableForm` when the user edits local encoded form state, validates it
|
||||
with a schema, and then submits a decoded value.
|
||||
|
||||
```tsx
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
|
||||
import { TextInputView } from "./TextInputView"
|
||||
|
||||
const RegisterSchema = Schema.Struct({
|
||||
email: Schema.String,
|
||||
password: Schema.String,
|
||||
})
|
||||
|
||||
class RegisterFormState extends Effect.Service<RegisterFormState>()(
|
||||
"RegisterFormState",
|
||||
{
|
||||
scoped: Effect.gen(function* () {
|
||||
const form = yield* SubmittableForm.service({
|
||||
schema: RegisterSchema,
|
||||
initialEncodedValue: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
f: ([value]) =>
|
||||
Effect.log(`Registering ${value.email}`),
|
||||
})
|
||||
|
||||
return {
|
||||
form,
|
||||
email: Form.focusObjectOn(form, "email"),
|
||||
password: Form.focusObjectOn(form, "password"),
|
||||
} as const
|
||||
}),
|
||||
},
|
||||
) {}
|
||||
|
||||
const RegisterFormView = Component.make("RegisterForm")(
|
||||
function* () {
|
||||
const state = yield* RegisterFormState
|
||||
const [canCommit, isCommitting] = yield* Subscribable.useAll([
|
||||
state.form.canCommit,
|
||||
state.form.isCommitting,
|
||||
])
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
const TextInput = yield* TextInputView.use
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void runPromise(state.form.submit)
|
||||
}}
|
||||
>
|
||||
<TextInput label="Email" form={state.email} />
|
||||
<TextInput label="Password" form={state.password} />
|
||||
<button disabled={!canCommit}>
|
||||
{isCommitting ? "Submitting..." : "Submit"}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
`SubmittableForm.service` starts validation in the form's scope. The form keeps
|
||||
its encoded input state, decoded value, validation issues, submit mutation, and
|
||||
commit flags available as Lenses/Subscribables.
|
||||
|
||||
## Synchronized Forms
|
||||
|
||||
Use `SynchronizedForm` when a form should edit an existing target `Lens`.
|
||||
Instead of submitting a final value, valid encoded changes are synchronized back
|
||||
to the target Lens.
|
||||
|
||||
This is useful for edit screens where the form is a validated view over existing
|
||||
state. Use `SubmittableForm` for "submit this draft" flows, and
|
||||
`SynchronizedForm` for "keep this target state synchronized when valid" flows.
|
||||
|
||||
## Focusing Fields
|
||||
|
||||
Forms can be focused just like Lenses:
|
||||
|
||||
```tsx
|
||||
const emailField = Form.focusObjectOn(form, "email")
|
||||
const firstItemField = Form.focusArrayAt(form, 0)
|
||||
```
|
||||
|
||||
Focused fields keep the parent form's validation and commit state, but narrow
|
||||
`encodedValue`, `value`, and `issues` to the field path. This lets each input
|
||||
component receive only the field it needs.
|
||||
@@ -0,0 +1,415 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Getting Started
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
|
||||
`effect-fc` lets React components be written as Effect programs. Inside a
|
||||
component body you can yield services, run Effects, subscribe to Effect-powered
|
||||
state, and still export a normal React function component at the edge of your
|
||||
app.
|
||||
|
||||
## Install
|
||||
|
||||
Install `effect-fc` alongside `effect` and React 19.2 or newer:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install effect-fc effect react
|
||||
```
|
||||
```bash npm2yarn
|
||||
npm install --save-dev @types/react
|
||||
```
|
||||
|
||||
`effect-fc` is not opinionated about the React platform. Use it with web,
|
||||
native, custom renderers, or any environment where React components can run.
|
||||
Then install the platform-specific React packages for your target.
|
||||
|
||||
For web apps, install React DOM:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install react-dom
|
||||
```
|
||||
```bash npm2yarn
|
||||
npm install --save-dev @types/react-dom
|
||||
```
|
||||
|
||||
## Create A Runtime
|
||||
|
||||
An Effect-FC app needs an Effect runtime. Build one from the services your UI
|
||||
needs, then share it with React through `ReactRuntime.Provider`.
|
||||
|
||||
For an empty app, `Layer.empty` is enough:
|
||||
|
||||
```tsx title="src/runtime.ts"
|
||||
import { Layer } from "effect"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
|
||||
export const runtime = ReactRuntime.make(Layer.empty)
|
||||
```
|
||||
|
||||
As your app grows, add services to the layer:
|
||||
|
||||
```tsx title="src/runtime.ts"
|
||||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { Layer } from "effect"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
|
||||
const AppLive = Layer.empty.pipe(
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
```
|
||||
|
||||
## Provide The Runtime
|
||||
|
||||
At the React root, wrap your app with `ReactRuntime.Provider`:
|
||||
|
||||
```tsx title="src/main.tsx"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
import { App } from "./App"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ReactRuntime.Provider runtime={runtime}>
|
||||
<App />
|
||||
</ReactRuntime.Provider>
|
||||
</StrictMode>,
|
||||
)
|
||||
```
|
||||
|
||||
`ReactRuntime.Provider` also works with routers. Keep it above your router
|
||||
provider so route components can be converted with the same runtime context.
|
||||
|
||||
## Write Your First Component
|
||||
|
||||
Use `Component.make` when you want automatic tracing spans, or
|
||||
`Component.makeUntraced` when you only want the component behavior. This creates
|
||||
an Effect-FC component, not a plain React component yet.
|
||||
|
||||
The Effect run during render must be synchronous. Effect-FC follows React's
|
||||
render model, so component bodies should produce JSX without waiting on async
|
||||
work. Use lifecycle hooks, callbacks, queries, or other Effect-FC helpers for
|
||||
async work that happens outside render.
|
||||
|
||||
```tsx title="src/HelloView.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
|
||||
export const HelloView = Component.make("HelloView")(function* (props: {
|
||||
readonly name: string
|
||||
}) {
|
||||
const message = yield* Effect.succeed(`Hello, ${props.name}`)
|
||||
|
||||
return <h1>{message}</h1>
|
||||
})
|
||||
```
|
||||
|
||||
At this point `HelloView` can yield Effects, services, and scoped lifecycle
|
||||
work, but React cannot render it directly.
|
||||
|
||||
## Apply A Runtime
|
||||
|
||||
Use `Component.withRuntime` at the boundary where an Effect-FC component needs
|
||||
to become a regular React function component.
|
||||
|
||||
```tsx title="src/Hello.tsx"
|
||||
import { Component } from "effect-fc"
|
||||
import { HelloView } from "./HelloView"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
export const Hello = HelloView.pipe(
|
||||
Component.withRuntime(runtime.context),
|
||||
)
|
||||
```
|
||||
|
||||
`Hello` is now a normal React component:
|
||||
|
||||
```tsx title="src/App.tsx"
|
||||
import { Hello } from "./Hello"
|
||||
|
||||
export function App() {
|
||||
return <Hello name="Effect" />
|
||||
}
|
||||
```
|
||||
|
||||
## Use Effect-FC Components Together
|
||||
|
||||
Inside an Effect-FC component, other Effect-FC components are available through
|
||||
their `.use` effect. Yield `.use` to get a React component for the current
|
||||
runtime context, then render it like JSX.
|
||||
|
||||
```tsx title="src/GreetingCardView.tsx"
|
||||
import { Component } from "effect-fc"
|
||||
import { HelloView } from "./HelloView"
|
||||
|
||||
export const GreetingCardView = Component.make("GreetingCard")(
|
||||
function* () {
|
||||
const Hello = yield* HelloView.use
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Hello name="Effect" />
|
||||
<p>This component is still running inside Effect-FC.</p>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Use `Component.withRuntime` only when you leave the Effect-FC tree and need a
|
||||
normal React component again:
|
||||
|
||||
```tsx title="src/GreetingCard.tsx"
|
||||
import { Component } from "effect-fc"
|
||||
import { GreetingCardView } from "./GreetingCardView"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
export const GreetingCard = GreetingCardView.pipe(
|
||||
Component.withRuntime(runtime.context),
|
||||
)
|
||||
```
|
||||
|
||||
## Component Lifecycle
|
||||
|
||||
Every Effect-FC component instance exposes an Effect `Scope`. Effects that need
|
||||
`Scope.Scope` can use that component scope to register finalizers, fork scoped
|
||||
work, or acquire scoped resources.
|
||||
|
||||
The component scope is created when React mounts the component and closes when
|
||||
React unmounts it. When the scope closes, Effect runs the finalizers registered
|
||||
inside that scope.
|
||||
|
||||
Finalizers are forked when the scope closes, so cleanup logic can run
|
||||
asynchronous Effects even though the component body itself must stay
|
||||
synchronous.
|
||||
|
||||
```tsx title="src/MountedMessageView.tsx"
|
||||
import { Console, Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
|
||||
export const MountedMessageView = Component.make("MountedMessage")(
|
||||
function* () {
|
||||
const message = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Console.log("MountedMessage mounted")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Console.log("MountedMessage unmounted"),
|
||||
)
|
||||
|
||||
return "This value was loaded on mount."
|
||||
}),
|
||||
)
|
||||
|
||||
return <p>{message}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Use `Component.useOnMount` when the component needs a value produced by an
|
||||
Effect during its first render. The value is then cached for the component
|
||||
instance. You can also use it to set up scoped component logic, subscriptions,
|
||||
or resources that should live for the lifetime of that component instance.
|
||||
|
||||
Use `Component.useOnChange` when render needs the value computed by
|
||||
scoped work that depends on changing inputs. When a dependency changes, React
|
||||
re-renders the component and the hook computes the next value during that
|
||||
render.
|
||||
|
||||
```tsx
|
||||
import { Console, Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
|
||||
const UserPanelView = Component.make("UserPanel")(
|
||||
function* (props: { readonly userId: string }) {
|
||||
const label = yield* Component.useOnChange(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Console.log(`Preparing view for ${props.userId}`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Console.log(`Cleaning up ${props.userId}`),
|
||||
)
|
||||
|
||||
return `Viewing user ${props.userId}`
|
||||
}),
|
||||
[props.userId],
|
||||
)
|
||||
|
||||
return <p>{label}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
In this example, each `userId` gets its own scope. When `userId` changes,
|
||||
Effect-FC closes the previous scope, runs its finalizers, and creates a new
|
||||
scope for the next load. Unlike `useOnMount`, `useOnChange` does not expose the
|
||||
component's root scope directly. It creates and provides its own scope for that
|
||||
dependency window. Some other Effect-FC hooks follow the same pattern when they
|
||||
need a lifecycle that is narrower than the whole component instance.
|
||||
|
||||
## Useful Effect-FC Hooks
|
||||
|
||||
Unlike plain React hooks, Effect-FC hooks return Effects. Use `yield*` to run
|
||||
them inside the component body.
|
||||
|
||||
The most common Effect-FC hooks are:
|
||||
|
||||
- `Component.useOnMount`: run an Effect once during the component's first render
|
||||
after mount, and return its value. The Effect must be synchronous.
|
||||
|
||||
```tsx
|
||||
const initialData = yield* Component.useOnMount(() => loadInitialData)
|
||||
```
|
||||
|
||||
- `Component.useOnChange`: when a dependency changes, React re-renders the
|
||||
component and the Effect is run again inside the component body. It returns
|
||||
the latest value, so the Effect must be synchronous too.
|
||||
|
||||
```tsx
|
||||
const label = yield* Component.useOnChange(() => formatUserLabel(id), [id])
|
||||
```
|
||||
|
||||
- `Component.useReactEffect`: Effect-powered `React.useEffect` for side effects
|
||||
that do not need to compute render output or trigger a re-render. The Effect
|
||||
must be synchronous and can register scoped finalizers.
|
||||
|
||||
```tsx
|
||||
yield* Component.useReactEffect(
|
||||
() => Effect.forkScoped(subscribeToUser(id)),
|
||||
[id],
|
||||
)
|
||||
```
|
||||
|
||||
- `Component.useReactLayoutEffect`: Effect-powered `React.useLayoutEffect` with scoped
|
||||
finalizers.
|
||||
|
||||
```tsx
|
||||
yield* Component.useReactLayoutEffect(() => measure(ref), [])
|
||||
```
|
||||
|
||||
- `Component.useRunSync` and `Component.useRunPromise`: run Effects from React
|
||||
event handlers.
|
||||
|
||||
```tsx
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
return (
|
||||
<button onClick={() => void runPromise(saveUser)}>
|
||||
Save
|
||||
</button>
|
||||
)
|
||||
```
|
||||
|
||||
- `Component.useCallbackSync` and `Component.useCallbackPromise`: create stable
|
||||
React callbacks.
|
||||
|
||||
```tsx
|
||||
const save = yield* Component.useCallbackPromise(
|
||||
(user: User) => saveUser(user),
|
||||
[],
|
||||
)
|
||||
|
||||
return <button onClick={() => void save(user)}>Save</button>
|
||||
```
|
||||
|
||||
## Use React Normally
|
||||
|
||||
An Effect-FC component is still a React function component. Anything you can do
|
||||
in a regular React component can also be done in an Effect-FC component,
|
||||
including React hooks, refs, event handlers, context, and JSX composition.
|
||||
|
||||
```tsx
|
||||
import { Component } from "effect-fc"
|
||||
import * as React from "react"
|
||||
|
||||
const CounterView = Component.make("Counter")(function* () {
|
||||
const [count, setCount] = React.useState(0)
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<button ref={buttonRef} onClick={() => setCount(count + 1)}>
|
||||
Count: {count}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Use regular React hooks for local UI concerns, and reach for Effect-FC helpers
|
||||
when the component needs Effect services, scopes, resources, or Effect-powered
|
||||
callbacks.
|
||||
|
||||
## Use Tags
|
||||
|
||||
Components can yield Effect tags directly in the component body. There is no
|
||||
need to memoize the service yourself; just yield the tag wherever the component
|
||||
needs it.
|
||||
|
||||
```tsx title="src/Greeting.tsx"
|
||||
const GreetingView = Component.make("Greeting")(function* (props: {
|
||||
readonly name: string
|
||||
}) {
|
||||
const greeting = yield* GreetingService
|
||||
|
||||
return <p>{greeting.greet(props.name)}</p>
|
||||
})
|
||||
```
|
||||
|
||||
Service values in the runtime context are reactive by default. If a provided
|
||||
service instance changes, Effect-FC unmounts and mounts again the components
|
||||
that depend on that context, so they read the new service and restart their
|
||||
scoped lifecycle with the new environment. For services that should not trigger
|
||||
that behavior, pass their tags with `Component.withOptions({ nonReactiveTags:
|
||||
[...] })`.
|
||||
|
||||
## Provide Services To A Subtree
|
||||
|
||||
Use `Component.useContextFromLayer` when an Effect-FC component should provide
|
||||
extra services to another Effect-FC component. It turns a `Layer` into a runtime
|
||||
context that can be provided to `.use`.
|
||||
|
||||
```tsx title="src/GreetingPageView.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
import { GreetingView } from "./Greeting"
|
||||
import { GreetingService } from "./services"
|
||||
|
||||
const GreetingLive = GreetingService.Default({
|
||||
greet: (name: string) => `Welcome, ${name}`,
|
||||
})
|
||||
|
||||
export const GreetingPageView = Component.make("GreetingPage")(function* () {
|
||||
const context = yield* Component.useContextFromLayer(GreetingLive)
|
||||
const Greeting = yield* Effect.provide(GreetingView.use, context)
|
||||
|
||||
return <Greeting name="Effect" />
|
||||
})
|
||||
```
|
||||
|
||||
The layer passed to `useContextFromLayer` should be stable. If a new layer value
|
||||
is created on every render, Effect-FC has to build a new context, which can
|
||||
cause unnecessary re-renders and scoped lifecycle restarts. Define static layers
|
||||
outside the component, or memoize layers that depend on React props or state.
|
||||
|
||||
Layers built with `useContextFromLayer` are scoped to the component that builds
|
||||
them. That means service construction can register scoped logic, acquire
|
||||
resources, fork scoped fibers, and add finalizers that are tied to that
|
||||
component's lifecycle.
|
||||
|
||||
## Where To Go Next
|
||||
|
||||
Once the runtime and component boundary are in place, the rest of the library
|
||||
builds on the same idea:
|
||||
|
||||
- `Subscribable.useAll` reads Effect subscribables and rerenders when they
|
||||
change.
|
||||
- `Lens` connects React state and Effect `SubscriptionRef` values.
|
||||
- `Query` and `Mutation` model async data and user-triggered operations.
|
||||
- `Form`, `SubmittableForm`, and `SynchronizedForm` help build Effect-backed
|
||||
forms.
|
||||
|
||||
The important pattern is small and repeatable: write Effect-FC components inside
|
||||
the runtime, then use `Component.withRuntime` at React boundaries.
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: State Management
|
||||
---
|
||||
|
||||
# State Management
|
||||
|
||||
`Lens` is the main type used for state management in `effect-fc`.
|
||||
|
||||
A Lens is an effectful handle to a piece of state. It can read the current value,
|
||||
subscribe to changes, and write updates back to the underlying source. A Lens
|
||||
can point at a whole state object or focus on one nested field inside it.
|
||||
|
||||
The usual pattern is to create state with Effect primitives such as
|
||||
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor
|
||||
such as `Lens.fromSubscriptionRef`, and bind Lens values into components with
|
||||
`Subscribable.useAll`.
|
||||
|
||||
`Subscribable` is the read-only side of this model. Every Lens is also a
|
||||
Subscribable.
|
||||
|
||||
`effect-fc` re-exports the `Lens` and `Subscribable` modules from
|
||||
[`effect-lens`](https://github.com/Thiladev/effect-lens) for convenience. The
|
||||
core data model and transformation APIs belong to `effect-lens`, so check the
|
||||
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens) for the full Lens/Subscribable API.
|
||||
|
||||
## Where To Store State
|
||||
|
||||
State can live pretty much anywhere as a `Lens` or `Subscribable`: in a
|
||||
service, in a layer, in a component scope, or alongside plain React state. Pick
|
||||
the owner based on who needs the state. Once you have a Lens/Subscribable
|
||||
handle, pass it around however you like, including through React props.
|
||||
|
||||
If state is shared by multiple components or belongs to application logic, store
|
||||
it in an Effect service:
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, Subscribable } from "effect-fc"
|
||||
|
||||
class CounterState extends Effect.Service<CounterState>()("CounterState", {
|
||||
effect: Effect.gen(function* () {
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
|
||||
return { count } as const
|
||||
}),
|
||||
}) {}
|
||||
|
||||
const CounterValueView = Component.make("CounterValue")(function* () {
|
||||
const state = yield* CounterState
|
||||
const [count] = yield* Subscribable.useAll([state.count])
|
||||
|
||||
return <p>Count: {count}</p>
|
||||
})
|
||||
```
|
||||
|
||||
If state belongs to a single Effect-FC component instance, or to a shallow
|
||||
hierarchy of subcomponents that receive it through props, create it with
|
||||
`Component.useOnMount`:
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, Subscribable } from "effect-fc"
|
||||
|
||||
const LocalCounterView = Component.make("LocalCounter")(function* () {
|
||||
const state = yield* Component.useOnMount(() =>
|
||||
Effect.gen(function* () {
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
|
||||
return { count } as const
|
||||
}),
|
||||
)
|
||||
const [count] = yield* Subscribable.useAll([state.count])
|
||||
|
||||
return <p>Count: {count}</p>
|
||||
})
|
||||
```
|
||||
|
||||
For simple UI state that is not shared and does not need Effect integration,
|
||||
prefer regular React state. A local "show details" toggle is usually better as
|
||||
`React.useState(false)` than as a Lens.
|
||||
|
||||
## Subscribable.useAll
|
||||
|
||||
A `Subscribable<A>` is reactive state with a current value and a stream of
|
||||
changes. Use `Subscribable.useAll` whenever a component needs to bind
|
||||
subscribable values into render output.
|
||||
|
||||
`Lens` is a `Subscribable`, so this is also the default way to read Lens values
|
||||
from a component.
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, Subscribable } from "effect-fc"
|
||||
|
||||
class CounterState extends Effect.Service<CounterState>()("CounterState", {
|
||||
effect: Effect.gen(function* () {
|
||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||
const doubled = Subscribable.map(count, (n) => n * 2)
|
||||
|
||||
return { count, doubled } as const
|
||||
}),
|
||||
}) {}
|
||||
|
||||
const CounterReadOnlyView = Component.make("CounterReadOnly")(
|
||||
function* () {
|
||||
const state = yield* CounterState
|
||||
const [count, doubled] = yield* Subscribable.useAll([
|
||||
state.count,
|
||||
state.doubled,
|
||||
])
|
||||
|
||||
return <p>Count: {count}, doubled: {doubled}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
`Subscribable.useAll` reads the current values during render and uses scoped subscriptions to update React state when changes arrive.
|
||||
|
||||
When you need to modify state, write to the Lens with `Lens.set` or `Lens.update`:
|
||||
|
||||
```tsx
|
||||
const CounterControlsView = Component.make("CounterControls")(
|
||||
function* () {
|
||||
const state = yield* CounterState
|
||||
const [count] = yield* Subscribable.useAll([state.count])
|
||||
|
||||
const increment = yield* Component.useCallbackSync(
|
||||
() => Lens.update(state.count, (n) => n + 1),
|
||||
[],
|
||||
)
|
||||
const reset = yield* Component.useCallbackSync(
|
||||
() => Lens.set(state.count, 0),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p>Count: {count}</p>
|
||||
<button onClick={increment}>Increment</button>
|
||||
<button onClick={reset}>Reset</button>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## Lens.useState
|
||||
|
||||
`Lens.useState` is useful when React needs the familiar `[value, setValue]`
|
||||
tuple, backed by a Lens. Reach for it when the JSX API expects a synchronous
|
||||
setter, especially controlled inputs such as text fields, checkboxes, selects,
|
||||
or third-party components with `value` / `onChange` props.
|
||||
|
||||
If a component only needs to display the value, prefer `Subscribable.useAll`.
|
||||
`Lens.useState` is for places where reading and writing need to be wired
|
||||
together in React's local-state shape.
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens } from "effect-fc"
|
||||
|
||||
class FormState extends Effect.Service<FormState>()("FormState", {
|
||||
effect: Effect.gen(function* () {
|
||||
const name = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(""))
|
||||
|
||||
return { name } as const
|
||||
}),
|
||||
}) {}
|
||||
|
||||
const NameInputView = Component.make("NameInput")(function* () {
|
||||
const state = yield* FormState
|
||||
const [name, setName] = yield* Lens.useState(state.name)
|
||||
|
||||
return (
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
`Lens.useState` returns the current value and a React-compatible setter. Calling
|
||||
the setter writes through the Lens, so every other component subscribed to the
|
||||
same Lens sees the update.
|
||||
|
||||
## Focused Lenses
|
||||
|
||||
Use focused Lenses when a component should work with one part of a larger state
|
||||
object. A focused Lens is still a Lens, so it can be read with
|
||||
`Subscribable.useAll` or used with `Lens.useState` when React needs a
|
||||
read/write tuple.
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, Subscribable } from "effect-fc"
|
||||
|
||||
interface UserProfile {
|
||||
readonly name: string
|
||||
readonly email: string
|
||||
readonly role: string
|
||||
}
|
||||
|
||||
class ProfileState extends Effect.Service<ProfileState>()("ProfileState", {
|
||||
effect: Effect.gen(function* () {
|
||||
const profile = Lens.fromSubscriptionRef(
|
||||
yield* SubscriptionRef.make<UserProfile>({
|
||||
name: "",
|
||||
email: "",
|
||||
role: "reader",
|
||||
}),
|
||||
)
|
||||
const name = Lens.focusObjectOn(profile, "name")
|
||||
const role = Lens.focusObjectOn(profile, "role")
|
||||
|
||||
return { profile, name, role } as const
|
||||
}),
|
||||
}) {}
|
||||
|
||||
const ProfileNameView = Component.make("ProfileName")(function* () {
|
||||
const state = yield* ProfileState
|
||||
const [name, setName] = yield* Lens.useState(state.name)
|
||||
const [role] = yield* Subscribable.useAll([state.role])
|
||||
|
||||
return (
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
/>
|
||||
<span>Role: {role}</span>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Updating the focused `name` Lens through `Lens.useState` updates the parent
|
||||
`profile` Lens. The focused `role` Lens is only read, so it stays on the simpler
|
||||
`Subscribable.useAll` path.
|
||||
|
||||
For focusing into nested state, deriving lenses, custom write behavior, and the
|
||||
complete API, refer to the
|
||||
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens).
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"docsSidebar": [
|
||||
"getting-started",
|
||||
"state-management",
|
||||
"forms"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"effect-fc-v0"
|
||||
]
|
||||
@@ -0,0 +1,145 @@
|
||||
<p align="center">
|
||||
<a href="https://thila.dev/effect-view">
|
||||
<img src="../docs/static/img/logo.svg" width="104" height="104" alt="Effect View logo" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Effect View</h1>
|
||||
|
||||
<p align="center">
|
||||
Write React components as typed Effect programs.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/effect-view"><img src="https://img.shields.io/npm/v/effect-view?color=08777b" alt="npm version" /></a>
|
||||
<a href="https://thila.dev/effect-view"><img src="https://img.shields.io/badge/docs-Effect_View-16a3a1" alt="Effect View documentation" /></a>
|
||||
<a href="https://github.com/Thiladev/effect-view/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/effect-view?color=e99526" alt="MIT license" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong><a href="https://thila.dev/effect-view">Read the documentation →</a></strong>
|
||||
</p>
|
||||
|
||||
Effect View brings Effect's typed services, resource safety, concurrency, and
|
||||
data modeling into React 19 without replacing React's component model. Yield
|
||||
Effects and services from a component body, let scopes follow the React
|
||||
lifecycle, and return ordinary JSX.
|
||||
|
||||
> **Effect v4 beta:** Effect View is built for the Effect v4 beta release. If
|
||||
> your application uses Effect v3, use the legacy
|
||||
> [`effect-fc` package](https://www.npmjs.com/package/effect-fc) instead.
|
||||
|
||||
```bash
|
||||
npm install effect-view effect@beta react
|
||||
```
|
||||
|
||||
Effect View does not depend on `react-dom`. Install the renderer used by your
|
||||
application—for example, `react-dom` for the web:
|
||||
|
||||
```bash
|
||||
npm install react-dom
|
||||
```
|
||||
|
||||
You can use Effect View with React Native or any other React renderer instead.
|
||||
|
||||
## What it looks like
|
||||
|
||||
An Effect View component is an Effect program that produces JSX. It can create
|
||||
scoped state, access services, and use React-facing hooks without leaving the
|
||||
generator model:
|
||||
|
||||
```tsx
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens } from "effect-view"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
const CounterValueView = Component.make("CounterValue")(
|
||||
function* ({ count }: { readonly count: Lens.Lens<number> }) {
|
||||
yield* Component.useOnMount(() =>
|
||||
Effect.addFinalizer(() =>
|
||||
Effect.log("CounterValue unmounted"),
|
||||
),
|
||||
)
|
||||
|
||||
const [value, setValue] = yield* Lens.useState(count)
|
||||
|
||||
return (
|
||||
<button onClick={() => setValue((n) => n + 1)}>
|
||||
Count: {value}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const CounterView = Component.make("Counter")(function* () {
|
||||
const count = yield* Component.useOnMount(() =>
|
||||
Effect.map(
|
||||
SubscriptionRef.make(0),
|
||||
Lens.fromSubscriptionRef,
|
||||
),
|
||||
)
|
||||
|
||||
const CounterValue = yield* CounterValueView.use
|
||||
|
||||
return <CounterValue count={count} />
|
||||
})
|
||||
|
||||
export const Counter = CounterView.pipe(
|
||||
Component.withContext(runtime.context),
|
||||
)
|
||||
```
|
||||
|
||||
`CounterView` composes another Effect View component by yielding its `.use`
|
||||
Effect, then renders the resulting component as ordinary JSX. The child can
|
||||
access the current Effect context and receives its own component scope.
|
||||
|
||||
`Effect.addFinalizer` uses the scope already provided to the component body, so
|
||||
the finalizer above runs when `CounterValue` unmounts.
|
||||
|
||||
At the outer boundary, `Counter` is still a normal React component and its Effect requirements remain
|
||||
typed until they reach the runtime.
|
||||
|
||||
[Build the runtime and render your first component →](https://thila.dev/effect-view/docs/getting-started)
|
||||
|
||||
## Why Effect View
|
||||
|
||||
| | Capability | What it gives you |
|
||||
| --- | --- | --- |
|
||||
| **Effect components** | `Component.make` | Yield Effects and typed services directly, then return JSX. |
|
||||
| **Managed lifecycles** | `Scope.Scope` | Finalizers, fibers, subscriptions, and resources close with their component or dependency lifecycle. |
|
||||
| **One application runtime** | `ReactRuntime` | Build your Layer once and make its services available throughout the UI. |
|
||||
| **Reactive state** | `Lens` and `View` | Focus large state models into small writable values and subscribe only where React needs them. |
|
||||
| **Server state** | `Query` and `Mutation` | TanStack Query-style caching, invalidation, staleness, and mutations with typed Effects underneath. |
|
||||
| **Schema-driven forms** | `MutationForm` and `LensForm` | Keep input-friendly encoded values while application code receives validated, decoded types. |
|
||||
| **Async rendering** | `Async` and `Memoized` | Integrate asynchronous Effects with Suspense while retaining Effect errors and cancellation. |
|
||||
|
||||
## Designed for both ecosystems
|
||||
|
||||
Effect View does not hide Effect behind callbacks or recreate React around a
|
||||
new rendering model. React still owns rendering, JSX, hooks, Suspense, and
|
||||
events. Effect owns services, errors, resource safety, concurrency, streams,
|
||||
and schemas. Effect View provides the lifecycle-aware boundary between them.
|
||||
|
||||
## Documentation
|
||||
|
||||
The complete documentation is available at **[thila.dev/effect-view](https://thila.dev/effect-view)**.
|
||||
|
||||
## Requirements
|
||||
|
||||
- React 19.2 or newer
|
||||
- **Effect v4 beta** (`effect@beta`)
|
||||
- TypeScript and `@types/react` for TypeScript projects
|
||||
|
||||
Effect View is renderer-independent and does not require `react-dom`.
|
||||
|
||||
## Project status
|
||||
|
||||
Effect View is currently beta software. The main APIs are available, but
|
||||
breaking changes and rough edges are still possible before a stable release.
|
||||
|
||||
Issues, ideas, and contributions are welcome on
|
||||
[GitHub](https://github.com/Thiladev/effect-view).
|
||||
|
||||
## License
|
||||
|
||||
[MIT](https://github.com/Thiladev/effect-view/blob/main/LICENSE)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"files": {
|
||||
"includes": ["./src/**"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"name": "effect-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"
|
||||
},
|
||||
"./Async": {
|
||||
"types": "./dist/Async.d.ts",
|
||||
"default": "./dist/Async.js"
|
||||
},
|
||||
"./Component": {
|
||||
"types": "./dist/Component.d.ts",
|
||||
"default": "./dist/Component.js"
|
||||
},
|
||||
"./Form": {
|
||||
"types": "./dist/Form.d.ts",
|
||||
"default": "./dist/Form.js"
|
||||
},
|
||||
"./Lens": {
|
||||
"types": "./dist/Lens.d.ts",
|
||||
"default": "./dist/Lens.js"
|
||||
},
|
||||
"./LensForm": {
|
||||
"types": "./dist/LensForm.d.ts",
|
||||
"default": "./dist/LensForm.js"
|
||||
},
|
||||
"./Memoized": {
|
||||
"types": "./dist/Memoized.d.ts",
|
||||
"default": "./dist/Memoized.js"
|
||||
},
|
||||
"./Mutation": {
|
||||
"types": "./dist/Mutation.d.ts",
|
||||
"default": "./dist/Mutation.js"
|
||||
},
|
||||
"./MutationForm": {
|
||||
"types": "./dist/MutationForm.d.ts",
|
||||
"default": "./dist/MutationForm.js"
|
||||
},
|
||||
"./PubSub": {
|
||||
"types": "./dist/PubSub.d.ts",
|
||||
"default": "./dist/PubSub.js"
|
||||
},
|
||||
"./Query": {
|
||||
"types": "./dist/Query.d.ts",
|
||||
"default": "./dist/Query.js"
|
||||
},
|
||||
"./QueryClient": {
|
||||
"types": "./dist/QueryClient.d.ts",
|
||||
"default": "./dist/QueryClient.js"
|
||||
},
|
||||
"./ReactRuntime": {
|
||||
"types": "./dist/ReactRuntime.d.ts",
|
||||
"default": "./dist/ReactRuntime.js"
|
||||
},
|
||||
"./Refreshable": {
|
||||
"types": "./dist/Refreshable.d.ts",
|
||||
"default": "./dist/Refreshable.js"
|
||||
},
|
||||
"./ScopeRegistry": {
|
||||
"types": "./dist/ScopeRegistry.d.ts",
|
||||
"default": "./dist/ScopeRegistry.js"
|
||||
},
|
||||
"./SetStateAction": {
|
||||
"types": "./dist/SetStateAction.d.ts",
|
||||
"default": "./dist/SetStateAction.js"
|
||||
},
|
||||
"./Stream": {
|
||||
"types": "./dist/Stream.d.ts",
|
||||
"default": "./dist/Stream.js"
|
||||
},
|
||||
"./View": {
|
||||
"types": "./dist/View.d.ts",
|
||||
"default": "./dist/View.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint:tsc": "tsc -b --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
"test": "vitest run",
|
||||
"build": "tsc -b tsconfig.build.json",
|
||||
"pack": "npm pack",
|
||||
"clean:cache": "rm -rf .turbo *.tsbuildinfo",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-browser": "4.0.0-beta.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,63 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import * as Async from "./Async.js"
|
||||
import * as Component from "./Component.js"
|
||||
import * as Memoized from "./Memoized.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
|
||||
describe("Async", () => {
|
||||
it("does not rerun for an unrelated parent state update", async () => {
|
||||
const load = vi.fn((_id: number) => Effect.never)
|
||||
const renderPost = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const context = await runtime.runtime.context()
|
||||
|
||||
const Post = Component.make("Post")(function*(props: { readonly id: number }) {
|
||||
renderPost()
|
||||
const value = yield* Component.useOnChange(() => load(props.id), [props.id])
|
||||
return <div>{value}</div>
|
||||
}).pipe(
|
||||
Async.async,
|
||||
Memoized.memoized,
|
||||
)
|
||||
|
||||
const Parent = Component.make("Parent")(function*() {
|
||||
const [text, setText] = React.useState("")
|
||||
const AsyncPost = yield* Post.use
|
||||
|
||||
return <>
|
||||
<input
|
||||
aria-label="text"
|
||||
value={text}
|
||||
onChange={event => setText(event.currentTarget.value)}
|
||||
/>
|
||||
<AsyncPost id={1} fallback={<div>loading</div>} />
|
||||
</>
|
||||
}).pipe(Component.withContext(runtime.context))
|
||||
|
||||
let view!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
view = render(
|
||||
<runtime.context.Provider value={context}>
|
||||
<Parent />
|
||||
</runtime.context.Provider>,
|
||||
)
|
||||
})
|
||||
|
||||
expect(screen.getByText("loading")).toBeTruthy()
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
const callsAfterLoad = load.mock.calls.length
|
||||
const rendersAfterLoad = renderPost.mock.calls.length
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByLabelText("text"), { target: { value: "a" } })
|
||||
})
|
||||
expect(load).toHaveBeenCalledTimes(callsAfterLoad)
|
||||
expect(renderPost).toHaveBeenCalledTimes(rendersAfterLoad)
|
||||
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
makeFunctionComponent<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,571 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { Context, Effect, HashMap, Layer, SubscriptionRef } from "effect"
|
||||
import * as React from "react"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import * as Component from "./Component.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
import * as Refreshable from "./Refreshable.js"
|
||||
import * as ScopeRegistry from "./ScopeRegistry.js"
|
||||
|
||||
|
||||
class ValueService extends Context.Service<ValueService, { readonly value: string }>()("ValueService") {}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("Component", () => {
|
||||
it("does not rerun useOnMount across rerenders after Strict Mode initialization", 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.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("mounted")
|
||||
expect(onMount).toHaveBeenCalledTimes(2)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
expect(await screen.findByText("mounted")).toBeTruthy()
|
||||
expect(onMount).toHaveBeenCalledTimes(2)
|
||||
|
||||
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.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={1} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("value:1")
|
||||
expect(onChange).toHaveBeenCalledTimes(2)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={1} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
expect(await screen.findByText("value:1")).toBeTruthy()
|
||||
expect(onChange).toHaveBeenCalledTimes(2)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={2} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("value:2")
|
||||
expect(onChange).toHaveBeenCalledTimes(4)
|
||||
|
||||
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.withContext(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.withContext(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: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.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe prefix="a" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("a:1")
|
||||
const initialLength = seenCallbacks.length
|
||||
const initialCallback = seenCallbacks.at(-1)
|
||||
expect(initialCallback?.(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(initialLength)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe prefix="b" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("b:1")
|
||||
await waitFor(() => expect(seenCallbacks.length).toBeGreaterThan(initialLength))
|
||||
expect(seenCallbacks.at(-1)).not.toBe(initialCallback)
|
||||
expect(seenCallbacks.at(-1)?.(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.withContext(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
|
||||
const [count, setCount] = React.useState(0)
|
||||
|
||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
yield* Effect.sync(() => mounts())
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
|
||||
}))
|
||||
|
||||
return <button type="button" onClick={() => setCount(value => value + 1)}>{`${service.value}:${count}`}</button>
|
||||
}).pipe(
|
||||
Component.withOptions({
|
||||
nonReactiveTags: [...Component.defaultOptions.nonReactiveTags, ValueService],
|
||||
})
|
||||
)
|
||||
|
||||
const Parent = Component.makeUntraced("NonReactiveParent")(function*(props: { readonly value: string }) {
|
||||
const serviceLayer = React.useMemo(
|
||||
() => Layer.succeed(ValueService, { value: props.value }),
|
||||
[props.value],
|
||||
)
|
||||
const context = yield* Component.useLayer(serviceLayer, {
|
||||
finalizerExecutionDebounce: 0,
|
||||
})
|
||||
const Child = yield* Effect.provide(SubComponent.use, context)
|
||||
|
||||
return <Child />
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Parent value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first:0")
|
||||
const initialMounts = mounts.mock.calls.length
|
||||
expect(initialMounts).toBeGreaterThan(0)
|
||||
expect(unmounts).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "first:0" }))
|
||||
await screen.findByText("first:1")
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Parent value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("second:1")
|
||||
expect(mounts).toHaveBeenCalledTimes(initialMounts)
|
||||
expect(unmounts).not.toHaveBeenCalled()
|
||||
|
||||
view.unmount()
|
||||
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("does not commit effects or retain registered scopes for a discarded Suspense render", async () => {
|
||||
const setup = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
const scopeRegistry = Context.get(effectRuntime, ScopeRegistry.ScopeRegistry)
|
||||
const pending = new Promise<void>(() => {})
|
||||
|
||||
const Probe = Component.makeUntraced("DiscardedSuspenseProbe")(function*() {
|
||||
yield* Component.useReactEffect(() => Effect.sync(setup), [])
|
||||
React.use(pending)
|
||||
return <div>committed</div>
|
||||
}).pipe(
|
||||
Component.withOptions({ scopeCommitTimeout: "10 millis" }),
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
let view!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
view = render(
|
||||
<React.Suspense fallback={<div>fallback</div>}>
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
</React.Suspense>
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
await screen.findByText("fallback")
|
||||
expect(setup).not.toHaveBeenCalled()
|
||||
await waitFor(() => expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(0))
|
||||
}
|
||||
finally {
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps committed scopes alive without heartbeats", async () => {
|
||||
const acquisitions = vi.fn()
|
||||
const releases = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
const scopeRegistry = Context.get(effectRuntime, ScopeRegistry.ScopeRegistry)
|
||||
|
||||
const Probe = Component.makeUntraced("CommittedScopeProbe")(function*() {
|
||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
yield* Effect.sync(acquisitions)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(releases))
|
||||
}))
|
||||
|
||||
return <div>committed</div>
|
||||
}).pipe(
|
||||
Component.withOptions({
|
||||
finalizerExecutionDebounce: "10 millis",
|
||||
scopeCommitTimeout: "10 millis",
|
||||
}),
|
||||
Component.withContext(runtime.context),
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("committed")
|
||||
await waitFor(() => expect(releases).toHaveBeenCalledTimes(acquisitions.mock.calls.length - 1))
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
|
||||
expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(1)
|
||||
expect(releases).toHaveBeenCalledTimes(acquisitions.mock.calls.length - 1)
|
||||
|
||||
view.unmount()
|
||||
await waitFor(() => expect(releases).toHaveBeenCalledTimes(acquisitions.mock.calls.length))
|
||||
await waitFor(() => expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(0))
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("does not expire a scope while a descendant is suspended past the finalizer debounce", async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
let resolve!: () => void
|
||||
const pending = new Promise<void>(complete => {
|
||||
resolve = complete
|
||||
})
|
||||
|
||||
const Suspended = () => {
|
||||
React.use(pending)
|
||||
return <div>committed</div>
|
||||
}
|
||||
|
||||
let view!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
view = render(
|
||||
<ReactRuntime.Provider runtime={runtime} fallback={<div>fallback</div>}>
|
||||
<Suspended />
|
||||
</ReactRuntime.Provider>
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
await screen.findByText("fallback")
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
await act(async () => {
|
||||
resolve()
|
||||
await pending
|
||||
})
|
||||
await screen.findByText("committed")
|
||||
}
|
||||
finally {
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it("clears ScopeRegistry after a suspended render retries, commits, and unmounts", async () => {
|
||||
const lifecycle = vi.fn<(message: string) => void>()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
const scopeRegistry = Context.get(effectRuntime, ScopeRegistry.ScopeRegistry)
|
||||
let resolve!: () => void
|
||||
const pending = new Promise<void>(complete => {
|
||||
resolve = complete
|
||||
})
|
||||
|
||||
const Probe = Component.makeUntraced("RetriedSuspenseProbe")(function*() {
|
||||
React.use(pending)
|
||||
yield* Component.useReactEffect(() => Effect.gen(function*() {
|
||||
yield* Effect.sync(() => lifecycle("mount"))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => lifecycle("cleanup")))
|
||||
}), [])
|
||||
return <div>committed</div>
|
||||
}).pipe(
|
||||
Component.withOptions({ scopeCommitTimeout: "10 millis" }),
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
let view!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
view = render(
|
||||
<React.Suspense fallback={<div>fallback</div>}>
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
</React.Suspense>
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
await screen.findByText("fallback")
|
||||
await act(async () => {
|
||||
resolve()
|
||||
await pending
|
||||
})
|
||||
await screen.findByText("committed")
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount"))
|
||||
|
||||
view.unmount()
|
||||
await waitFor(() => expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(0))
|
||||
expect(lifecycle.mock.calls.filter(([message]) => message === "mount")).toHaveLength(2)
|
||||
expect(lifecycle.mock.calls.filter(([message]) => message === "cleanup")).toHaveLength(2)
|
||||
}
|
||||
finally {
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it("refreshes a registered body while preserving or resetting local state", async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
const makeProbe = (label: string) => Component.makeUntraced("RefreshProbe")(function*() {
|
||||
const [count, setCount] = React.useState(0)
|
||||
return <button type="button" onClick={() => setCount(value => value + 1)}>{label}:{count}</button>
|
||||
})
|
||||
const original = makeProbe("old")
|
||||
const cell = Refreshable.makeCell(original, "hooks", false)
|
||||
Refreshable.attach(original, cell)
|
||||
|
||||
const Probe = Component.withContext(original, runtime.context)
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
fireEvent.click(await screen.findByRole("button"))
|
||||
expect(screen.getByRole("button").textContent).toBe("old:1")
|
||||
|
||||
await act(async () => {
|
||||
cell.update(makeProbe("compatible"), "hooks", false)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole("button").textContent).toBe("compatible:1")
|
||||
|
||||
await act(async () => {
|
||||
cell.update(makeProbe("reset"), "changed-hooks", false)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole("button").textContent).toBe("reset:0")
|
||||
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
})
|
||||
@@ -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,171 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { Effect, Layer, SubscriptionRef } from "effect"
|
||||
import * as React from "react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Component from "./Component.js"
|
||||
import * as Lens from "./Lens.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
|
||||
|
||||
const makeRuntime = async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
return {
|
||||
runtime,
|
||||
effectRuntime,
|
||||
dispose: () => runtime.runtime.dispose(),
|
||||
}
|
||||
}
|
||||
|
||||
const expectDefined = <A,>(value: A | undefined): A => {
|
||||
if (value === undefined)
|
||||
throw new Error("Expected value to be defined")
|
||||
return value
|
||||
}
|
||||
|
||||
describe("Lens", () => {
|
||||
it("useState stays in sync with lens updates in both directions", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
const ref = await Effect.runPromise(SubscriptionRef.make(0))
|
||||
const lens = Lens.fromSubscriptionRef(ref)
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseStateProbe")(function*() {
|
||||
const [value, setValue] = yield* Lens.useState(lens)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>{value}</div>
|
||||
<button type="button" onClick={() => setValue(previous => previous + 1)}>increment</button>
|
||||
</>
|
||||
)
|
||||
}).pipe(
|
||||
Component.withContext(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.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
|
||||
await Effect.runPromise(Lens.set(lens, { id: 1, label: "ignored" }))
|
||||
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||
expect(screen.queryByText("ignored")).toBeNull()
|
||||
|
||||
await Effect.runPromise(Lens.set(lens, { id: 2, label: "updated" }))
|
||||
await screen.findByText("updated")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it("useFromReactState writes React state changes into the returned lens", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
let lens: Lens.Lens<string, never, never, never, never> | undefined
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseFromReactStateProbe")(function*() {
|
||||
const [value, setValue] = React.useState("hello")
|
||||
const reactLens = yield* Lens.useFromReactState([value, setValue])
|
||||
|
||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||
lens = reactLens
|
||||
}))
|
||||
|
||||
return <button type="button" onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("hello")
|
||||
await waitFor(() => expect(lens).toBeDefined())
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "hello" }))
|
||||
await screen.findByText("hello!")
|
||||
await waitFor(async () => expect(await Effect.runPromise(Lens.get(expectDefined(lens)))).toBe("hello!"))
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it("useFromReactState respects equivalence when lens updates flow back into React state", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
let lens: Lens.Lens<{ readonly id: number; readonly label: string }, never, never, never, never> | undefined
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseFromReactStateEquivalenceProbe")(function*() {
|
||||
const [value, setValue] = React.useState({ id: 1, label: "first" })
|
||||
const reactLens = yield* Lens.useFromReactState([value, setValue], {
|
||||
equivalence: (self, that) => self.id === that.id,
|
||||
})
|
||||
|
||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||
lens = reactLens
|
||||
}))
|
||||
|
||||
return <div>{value.label}</div>
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
await waitFor(() => expect(lens).toBeDefined())
|
||||
|
||||
await Effect.runPromise(Lens.set(expectDefined(lens), { id: 1, label: "ignored" }))
|
||||
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||
expect(screen.queryByText("ignored")).toBeNull()
|
||||
|
||||
await Effect.runPromise(Lens.set(expectDefined(lens), { id: 2, label: "updated" }))
|
||||
await screen.findByText("updated")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Effect, Equivalence, Stream, SubscriptionRef } from "effect"
|
||||
import { Lens } from "effect-lens"
|
||||
import * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
import * as SetStateAction from "./SetStateAction.js"
|
||||
|
||||
|
||||
export * from "effect-lens/Lens"
|
||||
|
||||
export declare namespace useState {
|
||||
export interface Options<A> {
|
||||
readonly equivalence?: Equivalence.Equivalence<A>
|
||||
}
|
||||
}
|
||||
|
||||
export const useState = Effect.fnUntraced(function* <A, ER, EW, RR, RW>(
|
||||
lens: Lens.Lens<A, ER, EW, RR, RW>,
|
||||
options?: useState.Options<NoInfer<A>>,
|
||||
): Effect.fn.Return<readonly [A, React.Dispatch<React.SetStateAction<A>>], ER | 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,167 @@
|
||||
import { Effect, type Scope, Stream } from "effect"
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Query from "./Query.js"
|
||||
import * as QueryClient from "./QueryClient.js"
|
||||
import * as View from "./View.js"
|
||||
|
||||
|
||||
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
|
||||
Effect.runPromise(Effect.scoped(effect.pipe(
|
||||
Effect.provide(QueryClient.layer()),
|
||||
)))
|
||||
|
||||
const staticKey = <K>(key: K): View.View<K> => View.make({
|
||||
get: Effect.succeed(key),
|
||||
changes: Stream.make(key),
|
||||
})
|
||||
|
||||
const expectSuccessValue = <A, E>(
|
||||
state: Query.FinalQueryState<unknown, A, E>,
|
||||
): A => {
|
||||
expect(AsyncResult.isSuccess(state.result)).toBe(true)
|
||||
|
||||
if (!AsyncResult.isSuccess(state.result))
|
||||
throw new Error(`Expected Success result, received ${state.result._tag}`)
|
||||
|
||||
return state.result.value
|
||||
}
|
||||
|
||||
describe("Query", () => {
|
||||
it("fetch caches successful results until they are invalidated or stale", async () => {
|
||||
let calls = 0
|
||||
const key = staticKey<readonly [number]>([1])
|
||||
|
||||
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(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 = staticKey<readonly [number]>([1])
|
||||
|
||||
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 = staticKey<readonly [number]>([1])
|
||||
|
||||
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 = staticKey<readonly [number]>([1])
|
||||
|
||||
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 view automatically and records its latest final state", async () => {
|
||||
let calls = 0
|
||||
const key = staticKey<readonly [number]>([1])
|
||||
|
||||
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",
|
||||
})
|
||||
|
||||
const latestFinalState = yield* Effect.sleep("1 millis").pipe(
|
||||
Effect.andThen(View.get(query.latestFinalState)),
|
||||
Effect.flatMap(Effect.fromOption),
|
||||
Effect.eventually,
|
||||
Effect.timeout("1 second"),
|
||||
)
|
||||
|
||||
return {
|
||||
state: yield* View.get(query.state),
|
||||
latestFinalState,
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runQueryTest(effect)
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(result.state.key).toEqual([1])
|
||||
expect(expectSuccessValue(result.latestFinalState)).toBe("value:1:1")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,463 @@
|
||||
import { Cause, type Context, Duration, Effect, Equal, type Equivalence, Exit, Fiber, Option, Pipeable, Predicate, PubSub, Ref, 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 = yield* makeQueryStateLens(previous)
|
||||
|
||||
const fiber = yield* Effect.forkScoped(Effect.andThen(
|
||||
Lens.update<QueryState<K, A, E>, never, never, never, never>(
|
||||
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 => Effect.gen({ self: this }, function*() {
|
||||
const fiberId = yield* Effect.fiberId
|
||||
const fiber = yield* Lens.get(this.fiber)
|
||||
|
||||
if (Option.isSome(fiber) && fiberId === fiber.value.id)
|
||||
yield* Lens.set(this.fiber, Option.none())
|
||||
|
||||
const finalState = (yield* Lens.updateAndGet<QueryState<K, A, E>, never, never, never, never>(
|
||||
state,
|
||||
previous => Exit.match(exit, {
|
||||
onSuccess: v => ({
|
||||
key: previous.key,
|
||||
result: AsyncResult.success(v),
|
||||
}),
|
||||
onFailure: c => Cause.hasInterruptsOnly(c)
|
||||
? previous
|
||||
: 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,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)) as FinalQueryState<K, A, E>
|
||||
|
||||
yield* Lens.set(this.latestFinalState, Option.some(finalState))
|
||||
yield* PubSub.shutdown(state.pubsub)
|
||||
}))
|
||||
))
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
export class QueryStateLens<in out K, in out A, in out E = never>
|
||||
extends Lens.LensImpl<QueryState<K, A, E>, never, never, never, never> {
|
||||
constructor(
|
||||
readonly ref: Ref.Ref<QueryState<K, A, E>>,
|
||||
readonly pubsub: PubSub.PubSub<QueryState<K, A, E>>,
|
||||
readonly semaphore: Semaphore.Semaphore,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
get resolve(): Effect.Effect<Lens.LensImpl.Resolved<QueryState<K, A, E>>, never, never> {
|
||||
return Effect.map(
|
||||
Ref.get(this.ref),
|
||||
value => ({
|
||||
value,
|
||||
commit: next => Effect.flatMap(
|
||||
next,
|
||||
value => Effect.andThen(
|
||||
Ref.set(this.ref, value),
|
||||
PubSub.publish(this.pubsub, value),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
get changes() { return Stream.fromPubSub(this.pubsub) }
|
||||
get lock() { return Effect.succeed(this.semaphore.withPermit) }
|
||||
}
|
||||
|
||||
export const makeQueryStateLens = <K, A, E = never>(
|
||||
initial: QueryState<K, A, E>,
|
||||
) => Effect.all([
|
||||
Ref.make(initial),
|
||||
PubSub.unbounded<QueryState<K, A, E>>({ replay: 1 }),
|
||||
Semaphore.make(1),
|
||||
]).pipe(
|
||||
Effect.tap(([, pubsub]) => PubSub.publish(pubsub, initial)),
|
||||
Effect.map(([ref, pubsub, semaphore]) => new QueryStateLens(ref, pubsub, semaphore)),
|
||||
)
|
||||
@@ -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,73 @@
|
||||
/** 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"
|
||||
import * as ScopeRegistry from "./ScopeRegistry.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<ScopeRegistry.ScopeRegistry> = ScopeRegistry.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.merge(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 => {
|
||||
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 => {
|
||||
const context = React.use(promise)
|
||||
Effect.runSyncWith(context)(Component.useOnChange(
|
||||
() => Effect.addFinalizer(() => runtime.runtime.disposeEffect),
|
||||
[runtime],
|
||||
))
|
||||
|
||||
return React.createElement(runtime.context, { value: context }, children)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import type * as Component from "./Component.js"
|
||||
import * as Refreshable from "./Refreshable.js"
|
||||
|
||||
|
||||
describe("Refreshable", () => {
|
||||
it("attaches a cell and notifies subscribers after a compatible update", async () => {
|
||||
const first = {} as Component.Component.Any
|
||||
const cell = Refreshable.makeCell(first, "hooks", false)
|
||||
const listener = vi.fn()
|
||||
|
||||
expect(Refreshable.isRefreshable(first)).toBe(false)
|
||||
const attached = Refreshable.attach(first, cell)
|
||||
expect(attached).toBe(first)
|
||||
expect(Refreshable.isRefreshable(first)).toBe(true)
|
||||
expect(attached[Refreshable.RefreshableTypeId]).toBe(cell)
|
||||
expect(Object.getPrototypeOf(attached).asFunctionComponent)
|
||||
.toBe(Refreshable.RefreshablePrototype.asFunctionComponent)
|
||||
|
||||
cell.subscribe(listener)
|
||||
const second = {} as Component.Component.Any
|
||||
cell.update(second, "hooks", false)
|
||||
|
||||
expect(cell.current).toBe(second)
|
||||
expect(cell.snapshot).toEqual({
|
||||
revision: 1,
|
||||
resetRevision: 0,
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("requests a remount when a signature changes or reset is forced", () => {
|
||||
const component = {} as Component.Component.Any
|
||||
const cell = Refreshable.makeCell(component, "one", false)
|
||||
|
||||
cell.update({} as Component.Component.Any, "two", false)
|
||||
expect(cell.snapshot.resetRevision).toBe(1)
|
||||
|
||||
cell.update({} as Component.Component.Any, "two", true)
|
||||
expect(cell.snapshot.resetRevision).toBe(2)
|
||||
})
|
||||
|
||||
it("builds the refresh shell from the current descriptor implementation", () => {
|
||||
const implementation = () => null
|
||||
const makeFunctionComponent = vi.fn(() => implementation)
|
||||
const component = {
|
||||
makeFunctionComponent,
|
||||
} as unknown as Component.ComponentImpl.Any
|
||||
const contextRef = {
|
||||
current: {},
|
||||
} as never
|
||||
const cell = Refreshable.makeCell(component, "hooks", false)
|
||||
const attached = Refreshable.attach(component, cell)
|
||||
|
||||
expect(attached.asFunctionComponent(contextRef)).not.toBe(implementation)
|
||||
expect(makeFunctionComponent).toHaveBeenCalledWith(contextRef)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Context, Scope } from "effect"
|
||||
import * as React from "react"
|
||||
import type * as Component from "./Component.js"
|
||||
|
||||
|
||||
/**
|
||||
* A stable identifier used to associate an Effect View descriptor with its
|
||||
* development refresh cell.
|
||||
*
|
||||
* This low-level API is intended for development-server integrations such as
|
||||
* `@effect-view/vite-plugin`.
|
||||
*/
|
||||
export const RefreshableTypeId: unique symbol = Symbol.for("@effect-view/Refreshable/Refreshable")
|
||||
export type RefreshableTypeId = typeof RefreshableTypeId
|
||||
|
||||
/**
|
||||
* The version observed by a mounted Effect View refresh shell.
|
||||
*
|
||||
* `revision` changes for every update. `resetRevision` changes only when the
|
||||
* adapter determines that preserving React state is unsafe.
|
||||
*/
|
||||
export interface Snapshot {
|
||||
readonly revision: number
|
||||
readonly resetRevision: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A mutable development cell holding the latest version of an Effect View
|
||||
* descriptor.
|
||||
*
|
||||
* This low-level API is intended for development-server integrations.
|
||||
*/
|
||||
export interface Cell {
|
||||
current: Component.ComponentImpl.Any
|
||||
signature: string
|
||||
forceReset: boolean
|
||||
snapshot: Snapshot
|
||||
readonly subscribe: (listener: () => void) => () => void
|
||||
readonly getSnapshot: () => Snapshot
|
||||
readonly update: (
|
||||
component: Component.Component.Any,
|
||||
signature: string,
|
||||
forceReset: boolean,
|
||||
) => void
|
||||
}
|
||||
|
||||
export const RefreshablePrototype = Object.freeze({
|
||||
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Component.Signature>(
|
||||
this: Component.ComponentImpl<P, A, E, R, F> & Refreshable,
|
||||
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>,
|
||||
) {
|
||||
const cell = this[RefreshableTypeId]
|
||||
let current = cell.current
|
||||
let functionComponent = current.makeFunctionComponent(contextRef)
|
||||
|
||||
// Calling the current renderer inside this stable component deliberately
|
||||
// keeps its hooks on the same fiber until resetRevision changes.
|
||||
const Implementation = (props: P) => {
|
||||
if (current !== cell.current) {
|
||||
current = cell.current
|
||||
functionComponent = current.makeFunctionComponent(contextRef)
|
||||
}
|
||||
return functionComponent(props)
|
||||
}
|
||||
|
||||
const RefreshableComponent = (props: P) => {
|
||||
const snapshot = React.useSyncExternalStore(
|
||||
cell.subscribe,
|
||||
cell.getSnapshot,
|
||||
cell.getSnapshot,
|
||||
)
|
||||
return React.createElement(Implementation, {
|
||||
...props,
|
||||
key: snapshot.resetRevision,
|
||||
})
|
||||
}
|
||||
|
||||
return RefreshableComponent as F
|
||||
},
|
||||
} as const)
|
||||
|
||||
export type RefreshablePrototype = typeof RefreshablePrototype
|
||||
|
||||
/**
|
||||
* A descriptor that can be connected to a development refresh cell.
|
||||
*/
|
||||
export interface Refreshable extends RefreshablePrototype {
|
||||
readonly [RefreshableTypeId]: Cell
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a descriptor has been connected to a development refresh
|
||||
* cell.
|
||||
*/
|
||||
export const isRefreshable = <A extends object>(
|
||||
value: A,
|
||||
): value is A & Refreshable => Object.hasOwn(value, RefreshableTypeId)
|
||||
|
||||
/**
|
||||
* Creates a refresh cell for an Effect View descriptor.
|
||||
*
|
||||
* This low-level API is intended for development-server integrations.
|
||||
*/
|
||||
export const makeCell = (
|
||||
component: Component.Component.Any,
|
||||
signature: string,
|
||||
forceReset: boolean,
|
||||
): Cell => {
|
||||
const listeners = new Set<() => void>()
|
||||
let notificationPending = false
|
||||
|
||||
const cell: Cell = {
|
||||
current: component as Component.ComponentImpl.Any,
|
||||
signature,
|
||||
forceReset,
|
||||
snapshot: {
|
||||
revision: 0,
|
||||
resetRevision: 0,
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
},
|
||||
getSnapshot() {
|
||||
return cell.snapshot
|
||||
},
|
||||
update(nextComponent, nextSignature, nextForceReset) {
|
||||
const shouldReset = cell.forceReset
|
||||
|| nextForceReset
|
||||
|| cell.signature !== nextSignature
|
||||
|
||||
cell.current = nextComponent as Component.ComponentImpl.Any
|
||||
cell.signature = nextSignature
|
||||
cell.forceReset = nextForceReset
|
||||
cell.snapshot = {
|
||||
revision: cell.snapshot.revision + 1,
|
||||
resetRevision: cell.snapshot.resetRevision + (shouldReset ? 1 : 0),
|
||||
}
|
||||
|
||||
if (!notificationPending) {
|
||||
notificationPending = true
|
||||
queueMicrotask(() => {
|
||||
notificationPending = false
|
||||
for (const listener of listeners)
|
||||
listener()
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates a descriptor with a refresh cell and returns the descriptor.
|
||||
*
|
||||
* This low-level API is intended for development-server integrations.
|
||||
*/
|
||||
export const attach = <A extends Component.Component.Any>(
|
||||
component: A,
|
||||
cell: Cell,
|
||||
): A & Refreshable => {
|
||||
if (!isRefreshable(component)) {
|
||||
Object.setPrototypeOf(
|
||||
component,
|
||||
Object.freeze(Object.setPrototypeOf(
|
||||
Object.assign({}, RefreshablePrototype),
|
||||
Object.getPrototypeOf(component),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
Object.defineProperty(component, RefreshableTypeId, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: cell,
|
||||
})
|
||||
return component as A & Refreshable
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { type Cause, Chunk, Context, DateTime, type Duration, Effect, Equal, Exit, HashMap, Layer, Option, Order, Predicate, Scope, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||
|
||||
|
||||
export const ScopeRegistryServiceTypeId: unique symbol = Symbol.for("@effect-view/ScopeRegistryService/ScopeRegistryService")
|
||||
export type ScopeRegistryServiceTypeId = typeof ScopeRegistryServiceTypeId
|
||||
|
||||
export interface ScopeRegistryService {
|
||||
readonly [ScopeRegistryServiceTypeId]: ScopeRegistryServiceTypeId
|
||||
readonly ref: SubscriptionRef.SubscriptionRef<HashMap.HashMap<ScopeRegistryService.Key, ScopeRegistryService.Entry>>
|
||||
|
||||
register(
|
||||
key: ScopeRegistryService.Key,
|
||||
options: ScopeRegistryService.RegisterOptions,
|
||||
): Effect.Effect<ScopeRegistryService.Entry>
|
||||
commit(key: ScopeRegistryService.Key): Effect.Effect<ScopeRegistryService.Entry, Cause.NoSuchElementError>
|
||||
release(key: ScopeRegistryService.Key): Effect.Effect<ScopeRegistryService.Entry, Cause.NoSuchElementError>
|
||||
|
||||
readonly run: Effect.Effect<void, never, Scope.Scope>
|
||||
}
|
||||
|
||||
export declare namespace ScopeRegistryService {
|
||||
export type Key = object
|
||||
|
||||
export interface RegisterOptions {
|
||||
readonly finalizerExecutionStrategy: "sequential" | "parallel"
|
||||
readonly finalizerExecutionDebounce: Duration.Input
|
||||
readonly scopeCommitTimeout: Duration.Input
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
readonly scope: Scope.Closeable
|
||||
readonly expiresAt: Option.Option<DateTime.Utc>
|
||||
readonly finalizerExecutionDebounce: Duration.Input
|
||||
}
|
||||
}
|
||||
|
||||
export const isScopeRegistryService = (u: unknown): u is ScopeRegistryService => Predicate.hasProperty(u, ScopeRegistryServiceTypeId)
|
||||
export const makeKey = (): ScopeRegistryService.Key => Equal.byReference({})
|
||||
|
||||
|
||||
export class ScopeRegistryServiceImpl implements ScopeRegistryService {
|
||||
readonly [ScopeRegistryServiceTypeId]: ScopeRegistryServiceTypeId = ScopeRegistryServiceTypeId
|
||||
|
||||
constructor(
|
||||
readonly ref: SubscriptionRef.SubscriptionRef<HashMap.HashMap<ScopeRegistryService.Key, ScopeRegistryService.Entry>>,
|
||||
readonly runSemaphore: Semaphore.Semaphore,
|
||||
) {}
|
||||
|
||||
register(
|
||||
key: ScopeRegistryService.Key,
|
||||
options: ScopeRegistryService.RegisterOptions,
|
||||
): Effect.Effect<ScopeRegistryService.Entry> {
|
||||
return Effect.gen({ self: this }, function*() {
|
||||
const entry = Equal.byReference({
|
||||
scope: yield* Scope.make(options.finalizerExecutionStrategy),
|
||||
expiresAt: Option.some(DateTime.addDuration(yield* DateTime.now, options.scopeCommitTimeout)),
|
||||
finalizerExecutionDebounce: options.finalizerExecutionDebounce,
|
||||
})
|
||||
|
||||
yield* SubscriptionRef.update(this.ref, HashMap.set(key, entry))
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
||||
commit(key: ScopeRegistryService.Key): Effect.Effect<ScopeRegistryService.Entry, Cause.NoSuchElementError> {
|
||||
return SubscriptionRef.get(this.ref).pipe(
|
||||
Effect.map(HashMap.get(key)),
|
||||
Effect.flatMap(Effect.fromOption),
|
||||
Effect.map(entry => Equal.byReference<ScopeRegistryService.Entry>({
|
||||
...entry,
|
||||
expiresAt: Option.none(),
|
||||
})),
|
||||
Effect.tap(entry => SubscriptionRef.update(this.ref, HashMap.set(key, entry))),
|
||||
)
|
||||
}
|
||||
|
||||
release(key: ScopeRegistryService.Key): Effect.Effect<ScopeRegistryService.Entry, Cause.NoSuchElementError> {
|
||||
return SubscriptionRef.get(this.ref).pipe(
|
||||
Effect.map(HashMap.get(key)),
|
||||
Effect.flatMap(option => Effect.all([
|
||||
DateTime.now,
|
||||
Effect.fromOption(option),
|
||||
])),
|
||||
Effect.map(([now, entry]) => Equal.byReference<ScopeRegistryService.Entry>({
|
||||
...entry,
|
||||
expiresAt: Option.some(DateTime.addDuration(now, entry.finalizerExecutionDebounce)),
|
||||
})),
|
||||
Effect.tap(entry => SubscriptionRef.update(this.ref, HashMap.set(key, entry))),
|
||||
)
|
||||
}
|
||||
|
||||
get run(): Effect.Effect<void, never, Scope.Scope> {
|
||||
return Effect.addFinalizer(() => this.dispose).pipe(
|
||||
Effect.andThen(SubscriptionRef.changes(this.ref).pipe(
|
||||
Stream.switchMap(entries => Option.match(this.getNextExpiration(entries), {
|
||||
onNone: () => Stream.never,
|
||||
onSome: expiresAt => Stream.fromEffect(DateTime.now.pipe(
|
||||
Effect.flatMap(now => DateTime.isLessThan(now, expiresAt)
|
||||
? Effect.sleep(DateTime.distance(now, expiresAt))
|
||||
: Effect.void),
|
||||
Effect.andThen(Effect.uninterruptible(this.closeExpired)),
|
||||
)),
|
||||
})),
|
||||
Stream.runDrain,
|
||||
)),
|
||||
this.runSemaphore.withPermit,
|
||||
)
|
||||
}
|
||||
|
||||
get dispose(): Effect.Effect<void> {
|
||||
return SubscriptionRef.getAndSet(
|
||||
this.ref,
|
||||
HashMap.empty<ScopeRegistryService.Key, ScopeRegistryService.Entry>(),
|
||||
).pipe(
|
||||
Effect.flatMap(entries => Effect.forEach(
|
||||
HashMap.values(entries),
|
||||
entry => Scope.close(entry.scope, Exit.void),
|
||||
)),
|
||||
Effect.asVoid,
|
||||
)
|
||||
}
|
||||
|
||||
get closeExpired(): Effect.Effect<void> {
|
||||
return Effect.flatMap(DateTime.now, now => SubscriptionRef.modify(
|
||||
this.ref,
|
||||
HashMap.reduce(
|
||||
[
|
||||
Chunk.empty<ScopeRegistryService.Entry>(),
|
||||
HashMap.empty<ScopeRegistryService.Key, ScopeRegistryService.Entry>(),
|
||||
] as const,
|
||||
|
||||
([expired, remaining], entry, key) => Option.exists(
|
||||
entry.expiresAt,
|
||||
expiresAt => DateTime.isLessThanOrEqualTo(expiresAt, now),
|
||||
)
|
||||
? [Chunk.append(expired, entry), remaining] as const
|
||||
: [expired, HashMap.set(remaining, key, entry)] as const,
|
||||
),
|
||||
)).pipe(
|
||||
Effect.flatMap(entries => Effect.forEach(
|
||||
entries,
|
||||
entry => Scope.close(entry.scope, Exit.void),
|
||||
)),
|
||||
Effect.asVoid,
|
||||
)
|
||||
}
|
||||
|
||||
getNextExpiration(
|
||||
entries: HashMap.HashMap<ScopeRegistryService.Key, ScopeRegistryService.Entry>,
|
||||
): Option.Option<DateTime.Utc> {
|
||||
return HashMap.reduce(
|
||||
entries,
|
||||
Option.none<DateTime.Utc>(),
|
||||
(earliest, entry) => Option.match(entry.expiresAt, {
|
||||
onNone: () => earliest,
|
||||
onSome: expiresAt => Option.some(Option.match(earliest, {
|
||||
onNone: () => expiresAt,
|
||||
onSome: Order.min<DateTime.Utc>(DateTime.Order)(expiresAt),
|
||||
})),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const make: Effect.Effect<ScopeRegistryService> = Effect.gen(function*() {
|
||||
return new ScopeRegistryServiceImpl(
|
||||
yield* SubscriptionRef.make(HashMap.empty<ScopeRegistryService.Key, ScopeRegistryService.Entry>()),
|
||||
yield* Semaphore.make(1),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* Internal Effect service that maintains a registry of scopes associated with React component instances.
|
||||
*
|
||||
* This service is used internally by the `Component.useScope` hook to manage the lifecycle of component scopes,
|
||||
* including tracking active scopes and coordinating their cleanup when components unmount or dependencies change.
|
||||
*/
|
||||
export class ScopeRegistry extends Context.Service<ScopeRegistry, ScopeRegistryService>()(
|
||||
"@effect-view/ScopeRegistry/ScopeRegistry"
|
||||
) {}
|
||||
|
||||
export const layer = Layer.effect(ScopeRegistry, Effect.tap(
|
||||
make,
|
||||
registry => Effect.forkScoped(registry.run),
|
||||
))
|
||||
@@ -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,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 "./Component.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
import * as View from "./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.withContext(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.withContext(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,42 @@
|
||||
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(() => Stream.make(reactStateValue).pipe(
|
||||
Stream.concat(View.changes(View.zipLatestAll(...elements))),
|
||||
Stream.changesWith((options?.equivalence as Equivalence.Equivalence<any[]> | undefined) ?? Equivalence.Array(Equivalence.strictEqual())),
|
||||
Stream.drop(1),
|
||||
Stream.runForEach(v =>
|
||||
Effect.sync(() => setReactStateValue(v))
|
||||
),
|
||||
Effect.forkScoped,
|
||||
), elements)
|
||||
|
||||
return reactStateValue as any
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
export * as Async from "./Async.js"
|
||||
export * as Component from "./Component.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 Refreshable from "./Refreshable.js"
|
||||
export * as ScopeRegistry from "./ScopeRegistry.js"
|
||||
export * as SetStateAction from "./SetStateAction.js"
|
||||
export * as Stream from "./Stream.js"
|
||||
export * as View from "./View.js"
|
||||
@@ -0,0 +1,4 @@
|
||||
import { configure } from "@testing-library/react"
|
||||
|
||||
|
||||
configure({ reactStrictMode: true })
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["**/setup-tests.ts", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"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"],
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["./src/**/*.test.ts?(x)"],
|
||||
setupFiles: ["./src/setup-tests.ts"],
|
||||
},
|
||||
})
|
||||
@@ -42,7 +42,7 @@ export class TodosView extends Component.make("TodosView")(function*() {
|
||||
}) {}
|
||||
|
||||
const Index = Component.make("IndexView")(function*() {
|
||||
const context = yield* Component.useContextFromLayer(TodosState.Default)
|
||||
const context = yield* Component.useLayer(TodosState.Default)
|
||||
const Todos = yield* Effect.provide(TodosView.use, context)
|
||||
|
||||
return <Todos />
|
||||
|
||||
@@ -29,16 +29,20 @@
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint:tsc": "tsc --noEmit",
|
||||
"lint:tsc": "tsc -b --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
"test": "vitest run",
|
||||
"build": "tsc -b tsconfig.build.json",
|
||||
"pack": "npm pack",
|
||||
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
|
||||
"clean:cache": "rm -rf .turbo *.tsbuildinfo",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-browser": "^0.76.0"
|
||||
"@effect/platform-browser": "^0.76.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
|
||||
@@ -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 "./Component.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
|
||||
|
||||
class ValueService extends Context.Tag("ValueService")<ValueService, { readonly value: string }>() {}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("Component", () => {
|
||||
it("runs useOnMount only once across rerenders", async () => {
|
||||
const onMount = vi.fn(() => Effect.succeed("mounted"))
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
const Probe = Component.makeUntraced("UseOnMountProbe")(function*() {
|
||||
const value = yield* Component.useOnMount(onMount)
|
||||
return <div>{value}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("mounted")
|
||||
expect(onMount).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
expect(await screen.findByText("mounted")).toBeTruthy()
|
||||
expect(onMount).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.unmount()
|
||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||
})
|
||||
|
||||
it("recomputes useOnChange only when dependencies change", async () => {
|
||||
const onChange = vi.fn((value: number) => Effect.succeed(`value:${value}`))
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
const Probe = Component.makeUntraced("UseOnChangeProbe")(function*(props: { readonly value: number }) {
|
||||
const result = yield* Component.useOnChange(() => onChange(props.value), [props.value], {
|
||||
finalizerExecutionDebounce: 0,
|
||||
})
|
||||
|
||||
return <div>{result}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={1} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("value:1")
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={1} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
expect(await screen.findByText("value:1")).toBeTruthy()
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={2} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("value:2")
|
||||
expect(onChange).toHaveBeenCalledTimes(2)
|
||||
|
||||
view.unmount()
|
||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||
})
|
||||
|
||||
it("closes the previous scope on dependency changes and unmount", async () => {
|
||||
const cleanup = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
const Probe = Component.makeUntraced("ScopeCleanupProbe")(function*(props: { readonly value: string }) {
|
||||
const result = yield* Component.useOnChange(
|
||||
() => Effect.gen(function*() {
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
|
||||
return props.value
|
||||
}),
|
||||
[props.value],
|
||||
{ finalizerExecutionDebounce: 0 },
|
||||
)
|
||||
|
||||
return <div>{result}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
expect(cleanup).not.toHaveBeenCalled()
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("second")
|
||||
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"))
|
||||
expect(cleanup).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.unmount()
|
||||
|
||||
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"))
|
||||
expect(cleanup).toHaveBeenCalledTimes(2)
|
||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||
})
|
||||
|
||||
it("runs useReactEffect setup and cleanup when dependencies change", async () => {
|
||||
const lifecycle = vi.fn<(message: string) => void>()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
const Probe = Component.makeUntraced("UseReactEffectProbe")(function*(props: { readonly value: string }) {
|
||||
yield* Component.useReactEffect(() =>
|
||||
Effect.gen(function*() {
|
||||
yield* Effect.sync(() => lifecycle(`mount:${props.value}`))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => lifecycle(`cleanup:${props.value}`)))
|
||||
}),
|
||||
[props.value])
|
||||
|
||||
return <div>{props.value}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:first"))
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("second")
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:first"))
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:second"))
|
||||
|
||||
view.unmount()
|
||||
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:second"))
|
||||
expect(lifecycle.mock.calls.map(([message]) => message)).toEqual([
|
||||
"mount:first",
|
||||
"cleanup:first",
|
||||
"mount:second",
|
||||
"cleanup:second",
|
||||
])
|
||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||
})
|
||||
|
||||
it("keeps useCallbackSync stable until dependencies change", async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
const seenCallbacks: Array<(value: number) => string> = []
|
||||
|
||||
const Probe = Component.makeUntraced("UseCallbackSyncProbe")(function*(props: { readonly prefix: string }) {
|
||||
const callback = yield* Component.useCallbackSync(
|
||||
(value: number) => Effect.succeed(`${props.prefix}:${value}`),
|
||||
[props.prefix],
|
||||
)
|
||||
|
||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||
seenCallbacks.push(callback)
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
seenCallbacks.push(callback)
|
||||
}, [callback])
|
||||
|
||||
return <div>{callback(1)}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe prefix="a" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("a:1")
|
||||
expect(seenCallbacks).toHaveLength(2)
|
||||
expect(seenCallbacks[0]).toBe(seenCallbacks[1])
|
||||
expect(seenCallbacks[0]?.(2)).toBe("a:2")
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe prefix="a" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("a:1")
|
||||
expect(seenCallbacks).toHaveLength(2)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe prefix="b" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("b:1")
|
||||
await waitFor(() => expect(seenCallbacks).toHaveLength(3))
|
||||
expect(seenCallbacks[2]).not.toBe(seenCallbacks[1])
|
||||
expect(seenCallbacks[2]?.(2)).toBe("b:2")
|
||||
|
||||
view.unmount()
|
||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||
})
|
||||
|
||||
it("delays cleanup according to finalizerExecutionDebounce", async () => {
|
||||
const cleanup = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
const Probe = Component.makeUntraced("DebouncedCleanupProbe")(function*(props: { readonly value: string }) {
|
||||
const result = yield* Component.useOnChange(
|
||||
() => Effect.gen(function*() {
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
|
||||
return props.value
|
||||
}),
|
||||
[props.value],
|
||||
{ finalizerExecutionDebounce: "20 millis" },
|
||||
)
|
||||
|
||||
return <div>{result}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
|
||||
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.useLayer(serviceLayer, {
|
||||
finalizerExecutionDebounce: 0,
|
||||
})
|
||||
const Child = yield* Effect.provide(SubComponent.use, context)
|
||||
|
||||
return <Child />
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Parent value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
expect(mounts).toHaveBeenCalledTimes(1)
|
||||
expect(unmounts).not.toHaveBeenCalled()
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Parent value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("second")
|
||||
expect(mounts).toHaveBeenCalledTimes(1)
|
||||
expect(unmounts).not.toHaveBeenCalled()
|
||||
|
||||
view.unmount()
|
||||
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
|
||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||
})
|
||||
})
|
||||
@@ -1075,10 +1075,8 @@ export declare namespace useContext {
|
||||
* ```tsx
|
||||
* const MyLayer = Layer.succeed(MyService, new MyServiceImpl())
|
||||
* const MyComponent = Component.make(function*() {
|
||||
* const context = yield* Component.useContextFromLayer(MyLayer)
|
||||
* const Sub = yield* SubComponent.use.pipe(
|
||||
* Effect.provide(context)
|
||||
* )
|
||||
* const context = yield* Component.useLayer(MyLayer)
|
||||
* const Sub = yield* Effect.provide(SubComponent.use, context)
|
||||
*
|
||||
* return <Sub />
|
||||
* })
|
||||
@@ -1087,12 +1085,10 @@ export declare namespace useContext {
|
||||
* @example With memoized layer
|
||||
* ```tsx
|
||||
* const MyComponent = Component.make(function*(props: { id: string })) {
|
||||
* const context = yield* Component.useContextFromLayer(
|
||||
* const context = yield* Component.useLayer(
|
||||
* React.useMemo(() => Layer.succeed(MyService, new MyServiceImpl(props.id)), [props.id])
|
||||
* )
|
||||
* const Sub = yield* SubComponent.use.pipe(
|
||||
* Effect.provide(context)
|
||||
* )
|
||||
* const Sub = yield* Effect.provide(SubComponent.use, context)
|
||||
*
|
||||
* return <Sub />
|
||||
* })
|
||||
@@ -1102,17 +1098,15 @@ export declare namespace useContext {
|
||||
* ```tsx
|
||||
* const MyAsyncLayer = Layer.effect(MyService, someAsyncEffect)
|
||||
* const MyComponent = Component.make(function*() {
|
||||
* const context = yield* Component.useContextFromLayer(MyAsyncLayer)
|
||||
* const Sub = yield* SubComponent.use.pipe(
|
||||
* Effect.provide(context)
|
||||
* )
|
||||
* const context = yield* Component.useLayer(MyAsyncLayer)
|
||||
* const Sub = yield* Effect.provide(SubComponent.use, context)
|
||||
*
|
||||
* return <Sub />
|
||||
* }).pipe(
|
||||
* Async.async // Required to handle async layer effects
|
||||
* )
|
||||
*/
|
||||
export const useContextFromLayer = <ROut, E, RIn>(
|
||||
export const useLayer = <ROut, E, RIn>(
|
||||
layer: Layer.Layer<ROut, E, RIn>,
|
||||
options?: useContext.Options,
|
||||
): Effect.Effect<Context.Context<ROut>, E, RIn | Scope.Scope> => useOnChange(() => Effect.context<RIn>().pipe(
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { Effect, Layer, SubscriptionRef } from "effect"
|
||||
import * as React from "react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Component from "./Component.js"
|
||||
import * as Lens from "./Lens.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
|
||||
|
||||
const makeRuntime = async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
return {
|
||||
runtime,
|
||||
effectRuntime,
|
||||
dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
|
||||
}
|
||||
}
|
||||
|
||||
const expectDefined = <A,>(value: A | undefined): A => {
|
||||
if (value === undefined)
|
||||
throw new Error("Expected value to be defined")
|
||||
return value
|
||||
}
|
||||
|
||||
describe("Lens", () => {
|
||||
it("useState stays in sync with lens updates in both directions", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
const ref = await Effect.runPromise(SubscriptionRef.make(0))
|
||||
const lens = Lens.fromSubscriptionRef(ref)
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseStateProbe")(function*() {
|
||||
const [value, setValue] = yield* Lens.useState(lens)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>{value}</div>
|
||||
<button type="button" onClick={() => setValue(previous => previous + 1)}>increment</button>
|
||||
</>
|
||||
)
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("0")
|
||||
|
||||
await Effect.runPromise(Lens.set(lens, 5))
|
||||
await screen.findByText("5")
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "increment" }))
|
||||
await screen.findByText("6")
|
||||
expect(await Effect.runPromise(Lens.get(lens))).toBe(6)
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it("useState respects the provided equivalence when subscribing to lens changes", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
const ref = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
|
||||
const lens = Lens.fromSubscriptionRef(ref)
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseStateEquivalenceProbe")(function*() {
|
||||
const [value] = yield* Lens.useState(lens, {
|
||||
equivalence: (self, that) => self.id === that.id,
|
||||
})
|
||||
|
||||
return <div>{value.label}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
|
||||
await Effect.runPromise(Lens.set(lens, { id: 1, label: "ignored" }))
|
||||
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||
expect(screen.queryByText("ignored")).toBeNull()
|
||||
|
||||
await Effect.runPromise(Lens.set(lens, { id: 2, label: "updated" }))
|
||||
await screen.findByText("updated")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it("useFromReactState writes React state changes into the returned lens", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
let lens: Lens.Lens<string, never, never, never, never> | undefined
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseFromReactStateProbe")(function*() {
|
||||
const [value, setValue] = React.useState("hello")
|
||||
const reactLens = yield* Lens.useFromReactState([value, setValue])
|
||||
|
||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||
lens = reactLens
|
||||
}))
|
||||
|
||||
return <button type="button" onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("hello")
|
||||
await waitFor(() => expect(lens).toBeDefined())
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "hello" }))
|
||||
await screen.findByText("hello!")
|
||||
await waitFor(async () => expect(await Effect.runPromise(Lens.get(expectDefined(lens)))).toBe("hello!"))
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it("useFromReactState respects equivalence when lens updates flow back into React state", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
let lens: Lens.Lens<{ readonly id: number; readonly label: string }, never, never, never, never> | undefined
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseFromReactStateEquivalenceProbe")(function*() {
|
||||
const [value, setValue] = React.useState({ id: 1, label: "first" })
|
||||
const reactLens = yield* Lens.useFromReactState([value, setValue], {
|
||||
equivalence: (self, that) => self.id === that.id,
|
||||
})
|
||||
|
||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||
lens = reactLens
|
||||
}))
|
||||
|
||||
return <div>{value.label}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
await waitFor(() => expect(lens).toBeDefined())
|
||||
|
||||
await Effect.runPromise(Lens.set(expectDefined(lens), { id: 1, label: "ignored" }))
|
||||
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||
expect(screen.queryByText("ignored")).toBeNull()
|
||||
|
||||
await Effect.runPromise(Lens.set(expectDefined(lens), { id: 2, label: "updated" }))
|
||||
await screen.findByText("updated")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Effect, Option, type Scope, Stream } from "effect"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Query from "./Query.js"
|
||||
import * as QueryClient from "./QueryClient.js"
|
||||
import * as Result from "./Result.js"
|
||||
|
||||
|
||||
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
|
||||
Effect.runPromise(Effect.scoped(effect.pipe(
|
||||
Effect.provide(QueryClient.QueryClient.Default),
|
||||
)))
|
||||
|
||||
const expectSuccessValue = <A, E, P>(
|
||||
result: Result.Result<A, E, P>,
|
||||
): A => {
|
||||
expect(Result.isSuccess(result)).toBe(true)
|
||||
|
||||
if (!Result.isSuccess(result))
|
||||
throw new Error(`Expected Success result, received ${result._tag}`)
|
||||
|
||||
return result.value
|
||||
}
|
||||
|
||||
const expectSomeValue = <A>(option: Option.Option<A>): A => {
|
||||
expect(Option.isSome(option)).toBe(true)
|
||||
|
||||
if (!Option.isSome(option))
|
||||
throw new Error("Expected Some option, received None")
|
||||
|
||||
return option.value
|
||||
}
|
||||
|
||||
describe("Query", () => {
|
||||
it("fetch caches successful results until they are invalidated or stale", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
const second = yield* query.fetch([1])
|
||||
|
||||
return [first, second] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(result[0]._tag).toBe("Success")
|
||||
expect(result[1]._tag).toBe("Success")
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:1")
|
||||
})
|
||||
|
||||
it("refresh reruns the latest query key", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "0 millis",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
yield* Effect.sleep("1 millis")
|
||||
const refreshed = yield* query.refresh
|
||||
|
||||
return [first, refreshed] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||
})
|
||||
|
||||
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
yield* query.invalidateCacheEntry([1])
|
||||
const second = yield* query.fetch([1])
|
||||
|
||||
return [first, second] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||
})
|
||||
|
||||
it("invalidateCache clears cached entries for the query function", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
yield* query.invalidateCache
|
||||
const second = yield* query.fetch([1])
|
||||
|
||||
return [first, second] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||
})
|
||||
|
||||
it("service starts the key stream automatically and updates latest state", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.make([1] as const) as Stream.Stream<readonly [number]>
|
||||
|
||||
const effect = Effect.gen(function*() {
|
||||
const query = yield* Query.service({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
return {
|
||||
final: yield* query.result.get,
|
||||
latestKey: yield* query.latestKey.get,
|
||||
latestFinalResult: yield* query.latestFinalResult.get,
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runQueryTest(effect)
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(expectSuccessValue(result.final)).toBe("value:1:1")
|
||||
expect(expectSomeValue(result.latestKey)).toEqual([1])
|
||||
expect(expectSuccessValue(expectSomeValue(result.latestFinalResult))).toBe("value:1:1")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { Effect, Fiber, Layer, Stream, SubscriptionRef } from "effect"
|
||||
import { Lens } from "effect-lens"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Component from "./Component.js"
|
||||
import * as ReactRuntime from "./ReactRuntime.js"
|
||||
import * as Subscribable from "./Subscribable.js"
|
||||
|
||||
|
||||
const makeRuntime = async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
|
||||
|
||||
return {
|
||||
runtime,
|
||||
effectRuntime,
|
||||
dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
|
||||
}
|
||||
}
|
||||
|
||||
describe("Subscribable", () => {
|
||||
it("zipLatestAll reads current values from all inputs", async () => {
|
||||
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
|
||||
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
|
||||
const left = Lens.fromSubscriptionRef(leftRef)
|
||||
const right = Lens.fromSubscriptionRef(rightRef)
|
||||
|
||||
const zipped = Subscribable.zipLatestAll(left, right)
|
||||
|
||||
expect(await Effect.runPromise(zipped.get)).toEqual([1, "a"])
|
||||
})
|
||||
|
||||
it("zipLatestAll emits updates when any input changes", async () => {
|
||||
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
|
||||
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
|
||||
const left = Lens.fromSubscriptionRef(leftRef)
|
||||
const right = Lens.fromSubscriptionRef(rightRef)
|
||||
|
||||
const zipped = Subscribable.zipLatestAll(left, right)
|
||||
const values: Array<readonly [number, string]> = []
|
||||
|
||||
const collector = Effect.runFork(Effect.scoped(zipped.changes.pipe(
|
||||
Stream.runForEach(value => Effect.sync(() => {
|
||||
values.push(value as readonly [number, string])
|
||||
})),
|
||||
)))
|
||||
|
||||
await Effect.runPromise(Lens.set(left, 2))
|
||||
await waitFor(() => expect(values).toContainEqual([2, "a"]))
|
||||
|
||||
await Effect.runPromise(Lens.set(right, "b"))
|
||||
await waitFor(() => expect(values).toContainEqual([2, "b"]))
|
||||
|
||||
Fiber.interruptFork(collector)
|
||||
})
|
||||
|
||||
it("useAll returns the latest values and rerenders when any input changes", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
const countRef = await Effect.runPromise(SubscriptionRef.make(1))
|
||||
const labelRef = await Effect.runPromise(SubscriptionRef.make("a"))
|
||||
const count = Lens.fromSubscriptionRef(countRef)
|
||||
const label = Lens.fromSubscriptionRef(labelRef)
|
||||
|
||||
const Probe = Component.makeUntraced("SubscribableUseAllProbe")(function*() {
|
||||
const [currentCount, currentLabel] = yield* Subscribable.useAll([count, label])
|
||||
|
||||
return <div>{`${currentCount}:${currentLabel}`}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("1:a")
|
||||
|
||||
await Effect.runPromise(Lens.set(count, 2))
|
||||
await screen.findByText("2:a")
|
||||
|
||||
await Effect.runPromise(Lens.set(label, "b"))
|
||||
await screen.findByText("2:b")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it("useAll respects the provided equivalence when processing updates", async () => {
|
||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||
const itemRef = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
|
||||
const flagRef = await Effect.runPromise(SubscriptionRef.make(true))
|
||||
const item = Lens.fromSubscriptionRef(itemRef)
|
||||
const flag = Lens.fromSubscriptionRef(flagRef)
|
||||
|
||||
const Probe = Component.makeUntraced("SubscribableUseAllEquivalenceProbe")(function*() {
|
||||
const [currentItem, currentFlag] = yield* Subscribable.useAll([item, flag], {
|
||||
equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) =>
|
||||
selfItem.id === thatItem.id && selfFlag === thatFlag,
|
||||
})
|
||||
|
||||
return <div>{`${currentItem.label}:${currentFlag ? "on" : "off"}`}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first:on")
|
||||
|
||||
await Effect.runPromise(Lens.set(item, { id: 1, label: "ignored" }))
|
||||
await waitFor(() => expect(screen.getByText("first:on")).toBeTruthy())
|
||||
expect(screen.queryByText("ignored:on")).toBeNull()
|
||||
|
||||
await Effect.runPromise(Lens.set(flag, false))
|
||||
await screen.findByText("ignored:off")
|
||||
|
||||
await Effect.runPromise(Lens.set(item, { id: 2, label: "updated" }))
|
||||
await screen.findByText("updated:off")
|
||||
|
||||
view.unmount()
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["**/setup-tests.ts", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"]
|
||||
}
|
||||
@@ -36,5 +36,4 @@
|
||||
},
|
||||
|
||||
"include": ["./src"],
|
||||
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["./src/**/*.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,41 @@
|
||||
{
|
||||
"name": "@effect-fc/example-next",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc -b && vite build",
|
||||
"dev": "vite",
|
||||
"lint:tsc": "tsc -b --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
"preview": "vite preview",
|
||||
"clean:cache": "rm -rf .turbo node_modules/.tmp node_modules/.vite* .tanstack",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect-view/vite-plugin": "workspace:*",
|
||||
"@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,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 LensformRouteImport } from './routes/lensform'
|
||||
import { Route as QueryRouteImport } from './routes/query'
|
||||
|
||||
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 LensformRoute = LensformRouteImport.update({
|
||||
id: '/lensform',
|
||||
path: '/lensform',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const QueryRoute = QueryRouteImport.update({
|
||||
id: '/query',
|
||||
path: '/query',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/lensform': typeof LensformRoute
|
||||
'/query': typeof QueryRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/lensform': typeof LensformRoute
|
||||
'/query': typeof QueryRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/lensform': typeof LensformRoute
|
||||
'/query': typeof QueryRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
|
||||
id: '__root__' | '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AsyncRoute: typeof AsyncRoute
|
||||
BlankRoute: typeof BlankRoute
|
||||
FormRoute: typeof FormRoute
|
||||
LensformRoute: typeof LensformRoute
|
||||
QueryRoute: typeof QueryRoute
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
'/lensform': {
|
||||
id: '/lensform'
|
||||
path: '/lensform'
|
||||
fullPath: '/lensform'
|
||||
preLoaderRoute: typeof LensformRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/query': {
|
||||
id: '/query'
|
||||
path: '/query'
|
||||
fullPath: '/query'
|
||||
preLoaderRoute: typeof QueryRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AsyncRoute: AsyncRoute,
|
||||
BlankRoute: BlankRoute,
|
||||
FormRoute: FormRoute,
|
||||
LensformRoute: LensformRoute,
|
||||
QueryRoute: QueryRoute,
|
||||
}
|
||||
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="/form">Form</Link>
|
||||
<Link to="/lensform">LensForm</Link>
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
<Outlet />
|
||||
|
||||
<TanStackRouterDevtools />
|
||||
</Theme>
|
||||
)
|
||||
}
|
||||