Compare commits
112 Commits
next
..
7d6bab81be
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d6bab81be | |||
| faac021527 | |||
| 3a25b1a34e | |||
| 296f65ee1b | |||
| d8b9c0394c | |||
| 71f66cd5bc | |||
| d09c17e1c2 | |||
| cce0a47ea5 | |||
| 7e816d041e | |||
| c6eeb7b8bc | |||
| ccb33973db | |||
| 3516ba05f8 | |||
| cb62639829 | |||
| 8fed17789a | |||
| c151b4608f | |||
| cce1d6e485 | |||
| a5867ef36a | |||
| fa058ecf53 | |||
| ffed4bda40 | |||
| c48ca0acd3 | |||
| 6e7172a24e | |||
| b91729888c | |||
| 5f3e560969 | |||
| 8f8a66f7a9 | |||
| 9789969ecf | |||
| 4be5b12121 | |||
| 547aeec2fb | |||
| 1651e96696 | |||
| 22933b2a09 | |||
| 36c47e1b4b | |||
| c4b954d21c | |||
| f669dbee3a | |||
| e610fa1773 | |||
| 75f51cdf1c | |||
| d7f0aa1a38 | |||
| 9a93d72e48 | |||
| 19d5aba009 | |||
| 5017ccf5ba | |||
| 10784322b1 | |||
| 1fa2440d71 | |||
| fb4fd633dc | |||
| c804262e5f | |||
| a47ae73618 | |||
| efdf490bc0 | |||
| 8bbeed9b8d | |||
| 4ba7943bef | |||
| 89c8ac59bc | |||
| d21ff53c26 | |||
| 6551969bb1 | |||
| cc01e46997 | |||
| 1dc8368750 | |||
| f22743eefe | |||
| 31d87ffcab | |||
| 885cff74b3 | |||
| cbb050673b | |||
| 9b25ef4b57 | |||
| be1038fc8d | |||
| 956c77d45b | |||
| 985556b430 | |||
| feced4732f | |||
| cc64ae785d | |||
| 3fc3904c6c | |||
| 23cdfb3894 | |||
| 7a7783e7de | |||
| e07ce1cd68 | |||
| c59e2ee035 | |||
| e5ac120cea | |||
| 015aa0c816 | |||
| 5945953555 | |||
| 65fcfb395e | |||
| a86cb7e024 | |||
| 24ce543092 | |||
| 6b5c501a98 | |||
| c8df30480a | |||
| a38be0b172 | |||
| 091e102b23 | |||
| b7ea35006d | |||
| 257d505fdf | |||
| 241d489b0c | |||
| cf2cb8bf09 | |||
| 7ae5d08555 | |||
| 9fb56da120 | |||
| 1cead4bb7b | |||
| ad1ef4a73b | |||
| 81fb1dcf42 | |||
| c90c5f9532 | |||
| 436dc275b3 | |||
| 5791c08b51 | |||
| a74ec6e398 | |||
| fe65cd96aa | |||
| c492408969 | |||
| 86bdb5374e | |||
| 054f8773f0 | |||
| eb81ff16d2 | |||
| 3000ff2d87 | |||
| 91670779fe | |||
| d3525aaad7 | |||
| 0a59fdf2b4 | |||
| 88b3a1c98a | |||
| 74a8e3a102 | |||
| 8303c1f70e | |||
| c941e5970a | |||
| a8d4520fed | |||
| d40ac326ec | |||
| 05a8ae9ae4 | |||
| 25fac0ca32 | |||
| 845aa193ba | |||
| 1292b9885a | |||
| 658e6bb8ea | |||
| 7638324f2f | |||
| fcb29c0d76 | |||
| ea0108650b |
@@ -16,5 +16,3 @@ jobs:
|
|||||||
run: bun lint:tsc
|
run: bun lint:tsc
|
||||||
- name: Lint Biome
|
- name: Lint Biome
|
||||||
run: bun lint:biome
|
run: bun lint:biome
|
||||||
- name: Test
|
|
||||||
run: bun run test
|
|
||||||
|
|||||||
@@ -11,42 +11,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Login to Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: docker.valverde.cloud
|
|
||||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Clone repo
|
- name: Clone repo
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Lint TypeScript
|
- name: Lint TypeScript
|
||||||
run: bun lint:tsc
|
run: bun lint:tsc
|
||||||
- name: Lint Biome
|
- name: Lint Biome
|
||||||
run: bun lint:biome
|
run: bun lint:biome
|
||||||
- name: Test
|
|
||||||
run: bun run test
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build
|
||||||
- name: Publish effect-view
|
|
||||||
uses: JS-DevTools/npm-publish@v4
|
|
||||||
with:
|
|
||||||
package: packages/effect-view
|
|
||||||
access: public
|
|
||||||
token: ${{ secrets.NPM_TOKEN }}
|
|
||||||
registry: https://registry.npmjs.org
|
|
||||||
- name: Publish @effect-view/vite-plugin
|
|
||||||
uses: JS-DevTools/npm-publish@v4
|
|
||||||
with:
|
|
||||||
package: packages/vite-plugin
|
|
||||||
access: public
|
|
||||||
token: ${{ secrets.NPM_TOKEN }}
|
|
||||||
registry: https://registry.npmjs.org
|
|
||||||
- name: Publish effect-fc
|
- name: Publish effect-fc
|
||||||
uses: JS-DevTools/npm-publish@v4
|
uses: JS-DevTools/npm-publish@v4
|
||||||
with:
|
with:
|
||||||
@@ -54,30 +28,3 @@ jobs:
|
|||||||
access: public
|
access: public
|
||||||
token: ${{ secrets.NPM_TOKEN }}
|
token: ${{ secrets.NPM_TOKEN }}
|
||||||
registry: https://registry.npmjs.org
|
registry: https://registry.npmjs.org
|
||||||
|
|
||||||
- name: Clean before Docker build
|
|
||||||
run: |
|
|
||||||
bun clean:cache
|
|
||||||
bun clean:dist
|
|
||||||
bun clean:modules
|
|
||||||
- name: Generate Docker metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
docker.valverde.cloud/thilawyn/effect-view
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=tag
|
|
||||||
type=ref,event=pr
|
|
||||||
type=sha
|
|
||||||
flavor: |
|
|
||||||
latest=true
|
|
||||||
- name: Build Docker image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
|
|||||||
@@ -12,49 +12,16 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "24"
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Clone repo
|
- name: Clone repo
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Lint TypeScript
|
- name: Lint TypeScript
|
||||||
run: bun lint:tsc
|
run: bun lint:tsc
|
||||||
- name: Lint Biome
|
- name: Lint Biome
|
||||||
run: bun lint:biome
|
run: bun lint:biome
|
||||||
- name: Test
|
|
||||||
run: bun run test
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build
|
||||||
- name: Pack
|
- name: Pack
|
||||||
run: bun pack
|
run: bun pack
|
||||||
|
|
||||||
- name: Clean before Docker build
|
|
||||||
run: |
|
|
||||||
bun clean:cache
|
|
||||||
bun clean:dist
|
|
||||||
bun clean:modules
|
|
||||||
- name: Generate Docker metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
docker.valverde.cloud/thilawyn/effect-view
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=tag
|
|
||||||
type=ref,event=pr
|
|
||||||
type=sha
|
|
||||||
flavor: |
|
|
||||||
latest=true
|
|
||||||
- name: Build Docker image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
push: false
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
|
|||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
FROM oven/bun:1.3.12-debian@sha256:1b709c9dd883fc1af38c210f7ea5222c552a8d470ea73efbd4b8fcfee798a64b AS bun
|
|
||||||
|
|
||||||
FROM node:22.21.1-trixie-slim@sha256:98e1429d1a0b99378b4de43fa385f0746fd6276faf4feeb6104d91f6bad290f9
|
|
||||||
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bunx /usr/local/bin/
|
|
||||||
COPY . /app
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN bun install --frozen-lockfile && \
|
|
||||||
bun run build && \
|
|
||||||
bun clean:cache && \
|
|
||||||
bun clean:modules && \
|
|
||||||
bun install --production --frozen-lockfile
|
|
||||||
+4
-4
@@ -1,10 +1,10 @@
|
|||||||
# Effect View error-handling review
|
# Effect FC Next error-handling review
|
||||||
|
|
||||||
## Verdict
|
## Verdict
|
||||||
|
|
||||||
`ErrorObserver` is a reasonable *opt-in failure notification bus*, but it is
|
`ErrorObserver` is a reasonable *opt-in failure notification bus*, but it is
|
||||||
not an application error handler. It neither recovers from errors nor, in
|
not an application error handler. It neither recovers from errors nor, in
|
||||||
`effect-view`, observes the application's effects by default. Keeping it
|
`effect-fc-next`, observes the application's effects by default. Keeping it
|
||||||
as the central error-handling mechanism would make error delivery implicit,
|
as the central error-handling mechanism would make error delivery implicit,
|
||||||
incomplete, and easy to duplicate.
|
incomplete, and easy to duplicate.
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ should not replace that state.
|
|||||||
|
|
||||||
## What exists today
|
## What exists today
|
||||||
|
|
||||||
`packages/effect-view/src/ErrorObserver.ts` provides a context service
|
`packages/effect-fc-next/src/ErrorObserver.ts` provides a context service
|
||||||
backed by an unbounded `PubSub<Cause.Cause<E>>`:
|
backed by an unbounded `PubSub<Cause.Cause<E>>`:
|
||||||
|
|
||||||
- `handle(effect)` uses `Effect.tapCause` to publish every non-successful
|
- `handle(effect)` uses `Effect.tapCause` to publish every non-successful
|
||||||
@@ -33,7 +33,7 @@ This is narrower than the similarly named service in the legacy
|
|||||||
Effect supervisor that attempts to publish every failed fiber. The Next
|
Effect supervisor that attempts to publish every failed fiber. The Next
|
||||||
runtime's `preludeLayer` contains only `Component.ScopeMap`; it does not
|
runtime's `preludeLayer` contains only `Component.ScopeMap`; it does not
|
||||||
install `ErrorObserver.layer`. There are also no `ErrorObserver.handle(...)`
|
install `ErrorObserver.layer`. There are also no `ErrorObserver.handle(...)`
|
||||||
call sites in `effect-view`.
|
call sites in `effect-fc-next`.
|
||||||
|
|
||||||
Therefore, as checked at this revision, a consumer can create and subscribe to
|
Therefore, as checked at this revision, a consumer can create and subscribe to
|
||||||
the service manually, but no failure is automatically delivered to it. It is
|
the service manually, but no failure is automatically delivered to it. It is
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
This report describes the lifecycle problem in `effect-view`, why abandoned React renders leak Effect scopes, what the experimental implementations attempted, why those implementations are not satisfactory, and how a robust system should be designed.
|
This report describes the lifecycle problem in `effect-fc-next`, why abandoned React renders leak Effect scopes, what the experimental implementations attempted, why those implementations are not satisfactory, and how a robust system should be designed.
|
||||||
|
|
||||||
The experimental implementation discussed here has been removed from the source tree. This document is intended as design material for a clean reimplementation.
|
The experimental implementation discussed here has been removed from the source tree. This document is intended as design material for a clean reimplementation.
|
||||||
|
|
||||||
## Executive summary
|
## Executive summary
|
||||||
|
|
||||||
The fundamental problem is not scope disposal by itself. It is that `effect-view` performs Effect work and creates resource scopes while React is rendering.
|
The fundamental problem is not scope disposal by itself. It is that `effect-fc-next` performs Effect work and creates resource scopes while React is rendering.
|
||||||
|
|
||||||
React distinguishes between two broad phases:
|
React distinguishes between two broad phases:
|
||||||
|
|
||||||
@@ -467,7 +467,7 @@ interface ComponentScope {
|
|||||||
|
|
||||||
Create it at commit and close it at effect cleanup. No registry, snapshot, subscription, speculative timer, or abandonment recovery is necessary unless runtime-wide disposal must independently own it.
|
Create it at commit and close it at effect cleanup. No registry, snapshot, subscription, speculative timer, or abandonment recovery is necessary unless runtime-wide disposal must independently own it.
|
||||||
|
|
||||||
## Final recommendation for `effect-view`
|
## Final recommendation for `effect-fc-next`
|
||||||
|
|
||||||
Do not attempt to hide both committed component lifecycles and render-time Suspense resources behind one implicit `Scope` mechanism.
|
Do not attempt to hide both committed component lifecycles and render-time Suspense resources behind one implicit `Scope` mechanism.
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ The current API has two identities:
|
|||||||
|
|
||||||
Vite's React Refresh transform sees the descriptor definition, while React reconciles the synthesized function. In addition, `.use` deliberately caches that function. A newly evaluated module therefore creates a new descriptor, but the mounted function continues to close over the old descriptor.
|
Vite's React Refresh transform sees the descriptor definition, while React reconciles the synthesized function. In addition, `.use` deliberately caches that function. A newly evaluated module therefore creates a new descriptor, but the mounted function continues to close over the old descriptor.
|
||||||
|
|
||||||
The recommended implementation is a bundler-neutral development refresh protocol in Effect View plus a Vite compiler adapter. The plugin should assign stable source IDs and hook signatures to Effect View definitions. The main library's protocol should retain the current descriptor, notify mounted instances after an update, preserve state when the signature is compatible, and remount when it is not. The Vite adapter should retain those cells in `import.meta.hot.data` and manage accept/invalidate behavior.
|
The recommended implementation is a small development-only runtime bridge plus a Vite compiler plugin. The plugin should assign stable source IDs and hook signatures to Effect View definitions. The runtime should retain a hot cell for each definition, notify mounted instances after an update, preserve state when the signature is compatible, and remount when it is not.
|
||||||
|
|
||||||
My feasibility assessment is:
|
My feasibility assessment is:
|
||||||
|
|
||||||
@@ -26,12 +26,16 @@ A production-quality Vite implementation is likely **10–18 engineer-days**, in
|
|||||||
|
|
||||||
## Scope examined
|
## Scope examined
|
||||||
|
|
||||||
The implementation target is `effect-view` (Effect 4 beta). The legacy
|
The findings apply to both implementations in this repository:
|
||||||
`effect-fc` package is not supported by the Vite plugin.
|
|
||||||
|
- `effect-fc` (Effect 3)
|
||||||
|
- `effect-fc-next` (Effect 4 beta)
|
||||||
|
|
||||||
|
The two packages differ in Effect runtime details, but their React component identity model is the same. The examples also use the same Vite 8 and `@vitejs/plugin-react` 6 setup.
|
||||||
|
|
||||||
## How Effect View rendering works today
|
## How Effect View rendering works today
|
||||||
|
|
||||||
`Component.make` creates a function-shaped descriptor with a `body` property and `ComponentPrototype`; it does not create the React component that will be mounted. See [`packages/effect-view/src/Component.ts`](packages/effect-view/src/Component.ts), around `make`, `makeUntraced`, and `ComponentImplPrototype`.
|
`Component.make` creates a function-shaped descriptor with a `body` property and `ComponentPrototype`; it does not create the React component that will be mounted. See [`packages/effect-fc/src/Component.ts`](packages/effect-fc/src/Component.ts), around `make`, `makeUntraced`, and `ComponentImplPrototype`.
|
||||||
|
|
||||||
The relevant path is:
|
The relevant path is:
|
||||||
|
|
||||||
@@ -44,10 +48,14 @@ source definition
|
|||||||
-> mounted React fiber
|
-> mounted React fiber
|
||||||
```
|
```
|
||||||
|
|
||||||
In `effect-view`, `asFunctionComponent` creates a closure over the descriptor
|
In `effect-fc`:
|
||||||
and Effect context. `.use` retains that synthesized function in `componentRef`
|
|
||||||
while the relevant Effect service identities remain unchanged, and
|
- `asFunctionComponent` creates a closure over `this` and `runtimeRef`.
|
||||||
`withContext` renders the function returned by `.use`.
|
- The closure invokes `this.body(props)`.
|
||||||
|
- `.use` stores a cached function in `React.useState` and keys its internal cache by reactive Effect services.
|
||||||
|
- `withRuntime` renders the function returned by `.use`.
|
||||||
|
|
||||||
|
In `effect-fc-next`, `.use` uses `componentRef` and `previousServicesRef` instead of `useState` plus `Effect.cachedFunction`, but it still retains the previously synthesized function when service identities are unchanged.
|
||||||
|
|
||||||
This caching is useful during ordinary rendering: without it, React would see a new component type and remount on every parent render. It is also why a newly evaluated module cannot replace the mounted implementation by itself.
|
This caching is useful during ordinary rendering: without it, React would see a new component type and remount on every parent render. It is also why a newly evaluated module cannot replace the mounted implementation by itself.
|
||||||
|
|
||||||
@@ -85,9 +93,7 @@ Consequently, edits currently propagate through ordinary Vite HMR invalidation.
|
|||||||
|
|
||||||
## Runtime experiment
|
## Runtime experiment
|
||||||
|
|
||||||
The initial feasibility study used the legacy implementation for a focused
|
I ran a focused jsdom/React experiment against `effect-fc`:
|
||||||
jsdom/React identity experiment. Its descriptor-caching result also applies to
|
|
||||||
`effect-view`, but the legacy package is not an implementation target:
|
|
||||||
|
|
||||||
1. Render an Effect View containing `React.useState(0)`.
|
1. Render an Effect View containing `React.useState(0)`.
|
||||||
2. Increment it to `old:1`.
|
2. Increment it to `old:1`.
|
||||||
@@ -129,11 +135,11 @@ A complete implementation should provide the same safety contract developers exp
|
|||||||
|
|
||||||
### 1. Add an Effect View refresh transform
|
### 1. Add an Effect View refresh transform
|
||||||
|
|
||||||
Ship a Vite plugin, `@effect-view/vite-plugin`, placed before `react()`:
|
Ship a Vite plugin, for example `@effect-fc/vite`, placed before `react()`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
plugins: [
|
plugins: [
|
||||||
effectView(),
|
effectViewRefresh(),
|
||||||
react(),
|
react(),
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -144,7 +150,7 @@ It should recognize:
|
|||||||
- `Component.makeUntraced(...)`
|
- `Component.makeUntraced(...)`
|
||||||
- class declarations extending either factory result
|
- class declarations extending either factory result
|
||||||
- definitions followed by Effect `pipe` transformations
|
- definitions followed by Effect `pipe` transformations
|
||||||
- aliased imports from `effect-view`
|
- aliased imports from both `effect-fc` and `effect-fc-next`
|
||||||
|
|
||||||
For each definition it should inject development-only metadata containing:
|
For each definition it should inject development-only metadata containing:
|
||||||
|
|
||||||
@@ -157,9 +163,9 @@ Detection must be binding-aware rather than matching text. Otherwise unrelated `
|
|||||||
|
|
||||||
The plugin must instrument local Views as well as exports. React Refresh works on component families, not only refresh-boundary exports, and most nested Views in this repository are consumed through `.use`.
|
The plugin must instrument local Views as well as exports. React Refresh works on component families, not only refresh-boundary exports, and most nested Views in this repository are consumed through `.use`.
|
||||||
|
|
||||||
### 2. Add a development-only hot cell protocol to Effect View
|
### 2. Add a development-only hot cell
|
||||||
|
|
||||||
The main library should expose the bundler-neutral cell contract from its `Refreshable` module and the component shell should consume it directly:
|
The component metadata should connect to a cell retained in `import.meta.hot.data`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface HotViewCell {
|
interface HotViewCell {
|
||||||
@@ -175,8 +181,6 @@ On module reevaluation, the new descriptor updates `cell.current`. If the signat
|
|||||||
|
|
||||||
Keeping the cell rather than only mutating the old descriptor is important for class inheritance and traits. Class-style Views inherit `body` and options through the generated base class, while memoized and async Views alter prototype behavior. A cell can point to the complete new descriptor without attempting to copy an unknown prototype graph onto the old one.
|
Keeping the cell rather than only mutating the old descriptor is important for class inheritance and traits. Class-style Views inherit `body` and options through the generated base class, while memoized and async Views alter prototype behavior. A cell can point to the complete new descriptor without attempting to copy an unknown prototype graph onto the old one.
|
||||||
|
|
||||||
Bundler integrations should depend on this public protocol rather than define a structurally duplicated symbol and cell. The Vite adapter owns only Vite-specific storage in `import.meta.hot.data`, registration IDs, and accept/invalidate decisions.
|
|
||||||
|
|
||||||
### 3. Split the development component into a stable shell and keyed implementation
|
### 3. Split the development component into a stable shell and keyed implementation
|
||||||
|
|
||||||
In development, the function materialized by `.use` should become a stable shell:
|
In development, the function materialized by `.use` should become a stable shell:
|
||||||
@@ -254,7 +258,7 @@ Exit criterion: editing render text updates without a page reload, and incompati
|
|||||||
### Phase 1: Vite MVP (4–7 additional days)
|
### Phase 1: Vite MVP (4–7 additional days)
|
||||||
|
|
||||||
- Implement binding-aware AST detection for all public construction styles.
|
- Implement binding-aware AST detection for all public construction styles.
|
||||||
- Add stable shell/keyed implementation support to the supported `effect-view` Component implementation.
|
- Add stable shell/keyed implementation support to both Component implementations, or share the dev bridge in a small internal module.
|
||||||
- Cover memoized and async traits.
|
- Cover memoized and async traits.
|
||||||
- Add safe self-accept/fallback behavior.
|
- Add safe self-accept/fallback behavior.
|
||||||
- Add browser integration tests against the example app.
|
- Add browser integration tests against the example app.
|
||||||
@@ -273,7 +277,7 @@ Exit criterion: the behavior matches React Fast Refresh closely enough that stat
|
|||||||
|
|
||||||
### Later: other bundlers
|
### Later: other bundlers
|
||||||
|
|
||||||
Because the main Effect View library owns the runtime metadata protocol, adapters for webpack/Rspack, Rolldown, or other environments can emit the same metadata without depending on Vite. Building those in parallel with the first Vite version would enlarge the test matrix before the semantics have settled.
|
Once the runtime metadata protocol is stable, adapters for webpack/Rspack, Rolldown, or other environments can emit the same metadata. Building those in parallel with the first Vite version would enlarge the test matrix before the semantics have settled.
|
||||||
|
|
||||||
## Test matrix
|
## Test matrix
|
||||||
|
|
||||||
@@ -294,11 +298,18 @@ Minimum cases:
|
|||||||
- Async View and Suspense/error recovery.
|
- Async View and Suspense/error recovery.
|
||||||
- Two materializations of the same View under different Effect contexts.
|
- Two materializations of the same View under different Effect contexts.
|
||||||
- Reactive and `nonReactiveTags` service changes.
|
- Reactive and `nonReactiveTags` service changes.
|
||||||
- TanStack Router code splitting used by the `example` application.
|
- TanStack Router code splitting used by both example applications.
|
||||||
- Successive edits before the previous refresh finishes.
|
- Successive edits before the previous refresh finishes.
|
||||||
- Syntax error followed by recovery.
|
- Syntax error followed by recovery.
|
||||||
- Production build contains no HMR registry/subscription code.
|
- Production build contains no HMR registry/subscription code.
|
||||||
|
|
||||||
|
## Repository test status during this study
|
||||||
|
|
||||||
|
- `packages/effect-fc`: `bun run test` passed all 20 tests.
|
||||||
|
- `packages/effect-fc-next`: the existing suite was not green before any implementation work: 4 Component tests failed because mount/cached values were observed twice, and `Query.test.ts` could not resolve the absent `src/Result.js`. The remaining 9 tests passed. These failures are unrelated to this report, but a clean baseline or documented exclusions will be needed before implementing refresh support in the beta package.
|
||||||
|
|
||||||
|
The Vite development server also regenerated `packages/example/src/routeTree.gen.ts` with ordering-only changes while inspecting transforms; that generated file was restored, so this report is the only intended worktree change.
|
||||||
|
|
||||||
## Risks and open questions
|
## Risks and open questions
|
||||||
|
|
||||||
### Hook signature ownership
|
### Hook signature ownership
|
||||||
@@ -315,7 +326,7 @@ A stable shell plus keyed implementation adds one fiber in development. This is
|
|||||||
|
|
||||||
### React/Vite version coupling
|
### React/Vite version coupling
|
||||||
|
|
||||||
The current plugin stack uses Vite's OXC refresh transform and a bundled/simplified React Refresh runtime. Depending on private runtime functions or generated transform text would make the feature sensitive to Vite upgrades. The main Effect View package should therefore own the standalone metadata protocol, while the Vite plugin depends on and adapts it.
|
The current plugin stack uses Vite's OXC refresh transform and a bundled/simplified React Refresh runtime. Depending on private runtime functions or generated transform text would make the feature sensitive to Vite upgrades. A standalone Effect View metadata protocol minimizes that coupling.
|
||||||
|
|
||||||
### Multiple materializations
|
### Multiple materializations
|
||||||
|
|
||||||
@@ -1,61 +1,7 @@
|
|||||||
<p align="center">
|
# Effect FC Monorepo
|
||||||
<a href="https://thila.dev/effect-view">
|
|
||||||
<img src="https://github.com/Thiladev/effect-view/raw/master/packages/docs/static/img/logo.svg" width="104" height="104" alt="Effect View logo" />
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h1 align="center">Effect View Monorepo</h1>
|
[Effect-TS](https://effect.website/) integration for React 19.2+ that allows you to write function components using Effect generators.
|
||||||
|
|
||||||
<p align="center">
|
This monorepo contains:
|
||||||
Write React components as typed Effect programs.
|
- [The `effect-fc` library](packages/effect-fc)
|
||||||
</p>
|
- [An example project](packages/example)
|
||||||
|
|
||||||
This repository contains Effect View, its documentation, development examples,
|
|
||||||
and supporting tooling. Effect View brings Effect's typed services, resource
|
|
||||||
safety, concurrency, and data modeling to React while keeping ordinary React
|
|
||||||
components, hooks, and JSX.
|
|
||||||
|
|
||||||
## Packages
|
|
||||||
|
|
||||||
| Package | Description |
|
|
||||||
| --- | --- |
|
|
||||||
| [`effect-view`](packages/effect-view) | The Effect View library for Effect v4 and React 19. |
|
|
||||||
| [`@effect-view/vite-plugin`](packages/vite-plugin) | Vite Fast Refresh support for Effect View components. |
|
|
||||||
| [`docs`](packages/docs) | The [Effect View documentation](https://thila.dev/effect-view), built with Docusaurus. |
|
|
||||||
| [`example`](packages/example) | Example application using Effect View and Effect v4. |
|
|
||||||
| [`effect-fc`](packages/effect-fc) | Legacy Effect v3 package. |
|
|
||||||
| [`effect-fc-example`](packages/effect-fc-example) | Example application for the legacy package. |
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
The monorepo uses [Bun](https://bun.sh/) workspaces and
|
|
||||||
[Turborepo](https://turbo.build/).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
Run checks and builds from the repository root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run lint:tsc
|
|
||||||
bun run lint:biome
|
|
||||||
bun run test
|
|
||||||
bun run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Start the Effect View example or documentation site:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run --cwd packages/example dev
|
|
||||||
bun run --cwd packages/docs start
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Read the documentation at
|
|
||||||
[thila.dev/effect-view](https://thila.dev/effect-view).
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](LICENSE)
|
|
||||||
|
|||||||
+7
-8
@@ -1,27 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "@effect-view/monorepo",
|
"name": "@effect-fc/monorepo",
|
||||||
"packageManager": "bun@1.3.14",
|
"packageManager": "bun@1.3.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./packages/*"
|
"./packages/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "turbo build",
|
||||||
"lint:tsc": "turbo lint:tsc",
|
"lint:tsc": "turbo lint:tsc",
|
||||||
"lint:biome": "turbo lint:biome",
|
"lint:biome": "turbo lint:biome",
|
||||||
"test": "turbo test",
|
|
||||||
"build": "turbo build",
|
|
||||||
"pack": "turbo pack",
|
"pack": "turbo pack",
|
||||||
"clean:cache": "turbo clean:cache",
|
"clean:cache": "turbo clean:cache",
|
||||||
"clean:dist": "turbo clean:dist",
|
"clean:dist": "turbo clean:dist",
|
||||||
"clean:modules": "turbo clean:modules && rm -rf node_modules"
|
"clean:modules": "turbo clean:modules && rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.5.5",
|
"@biomejs/biome": "^2.4.16",
|
||||||
"@effect/language-service": "^0.87.1",
|
"@effect/language-service": "^0.86.2",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"npm-check-updates": "^23.0.0",
|
"npm-check-updates": "^22.2.1",
|
||||||
"npm-sort": "^0.0.4",
|
"npm-sort": "^0.0.4",
|
||||||
"turbo": "^2.10.7",
|
"turbo": "^2.9.16",
|
||||||
"typescript": "^7.0.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,5 @@
|
|||||||
"extends": "//",
|
"extends": "//",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": ["./src/**"]
|
"includes": ["./src/**"]
|
||||||
},
|
|
||||||
"linter": {
|
|
||||||
"rules": {
|
|
||||||
"complexity": {
|
|
||||||
"noImportantStyles": "off"
|
|
||||||
},
|
|
||||||
"style": {
|
|
||||||
"noDescendingSpecificity": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
---
|
|
||||||
sidebar_position: 2
|
|
||||||
title: Async
|
|
||||||
---
|
|
||||||
|
|
||||||
# Async
|
|
||||||
|
|
||||||
Effect View components run synchronously by default. Apply `Async.async` when a
|
|
||||||
component must wait for an asynchronous Effect before it can return JSX. The
|
|
||||||
component suspends while the Effect runs and accepts React Suspense props such
|
|
||||||
as `fallback`.
|
|
||||||
|
|
||||||
```tsx title="src/UserCard.tsx"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { Async, Component } from "effect-view"
|
|
||||||
import { loadUser } from "./api"
|
|
||||||
|
|
||||||
export const UserCard = Component.make("UserCard")(
|
|
||||||
function* ({ userId }: { readonly userId: string }) {
|
|
||||||
const user = yield* Component.useOnChange(
|
|
||||||
() => loadUser(userId),
|
|
||||||
[userId],
|
|
||||||
)
|
|
||||||
|
|
||||||
return <article>{user.name}</article>
|
|
||||||
},
|
|
||||||
).pipe(Async.async)
|
|
||||||
```
|
|
||||||
|
|
||||||
Pass a fallback when rendering the component:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const User = yield* UserCard.use
|
|
||||||
|
|
||||||
return <User userId="123" fallback={<p>Loading user...</p>} />
|
|
||||||
```
|
|
||||||
|
|
||||||
Or configure a default fallback once:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
export const UserCard = Component.make("UserCard")(
|
|
||||||
function* ({ userId }: { readonly userId: string }) {
|
|
||||||
const user = yield* Component.useOnChange(
|
|
||||||
() => loadUser(userId),
|
|
||||||
[userId],
|
|
||||||
)
|
|
||||||
|
|
||||||
return <article>{user.name}</article>
|
|
||||||
},
|
|
||||||
).pipe(
|
|
||||||
Async.async,
|
|
||||||
Async.withOptions({ defaultFallback: <p>Loading user...</p> }),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hook ordering
|
|
||||||
|
|
||||||
**Important:** Do not use React hooks or Effect View hook helpers after an
|
|
||||||
asynchronous computation in the component body. After the computation suspends,
|
|
||||||
the generator continuation runs outside React's synchronous render phase.
|
|
||||||
|
|
||||||
Place every hook before the first operation that may suspend:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useState } from "react"
|
|
||||||
|
|
||||||
const UserCard = Component.make("UserCard")(
|
|
||||||
function* ({ userId }: { readonly userId: string }) {
|
|
||||||
// Hooks and hook helpers go before asynchronous work.
|
|
||||||
const [showDetails, setShowDetails] = useState(false)
|
|
||||||
|
|
||||||
// This may suspend, so no hooks may be used after it.
|
|
||||||
const user = yield* Component.useOnChange(
|
|
||||||
() => loadUser(userId),
|
|
||||||
[userId],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article>
|
|
||||||
<button onClick={() => setShowDetails((value) => !value)}>
|
|
||||||
{user.name}
|
|
||||||
</button>
|
|
||||||
{showDetails && <p>{user.bio}</p>}
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
).pipe(Async.async)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Memoization
|
|
||||||
|
|
||||||
An async computation starts whenever its component renders. Apply
|
|
||||||
`Memoized.memoized` after `Async.async` to prevent an unrelated parent render
|
|
||||||
from restarting an async child whose props have not changed:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Async, Component, Memoized } from "effect-view"
|
|
||||||
|
|
||||||
export const UserCard = Component.make("UserCard")(
|
|
||||||
function* ({ userId }: { readonly userId: string }) {
|
|
||||||
const user = yield* Component.useOnChange(
|
|
||||||
() => loadUser(userId),
|
|
||||||
[userId],
|
|
||||||
)
|
|
||||||
|
|
||||||
return <article>{user.name}</article>
|
|
||||||
},
|
|
||||||
).pipe(
|
|
||||||
Async.async,
|
|
||||||
Memoized.memoized,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Async components compare props with `Object.is` by default and ignore the
|
|
||||||
`fallback` prop during that comparison. A changed `userId` renders the child
|
|
||||||
again, closes the previous dependency scope, and runs `loadUser` for the new
|
|
||||||
value.
|
|
||||||
|
|
||||||
If props contain immutable objects or arrays that may be recreated with the same
|
|
||||||
content, use `Equal.asEquivalence()` for full structural equality:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Equal } from "effect"
|
|
||||||
import { Async, Component, Memoized } from "effect-view"
|
|
||||||
|
|
||||||
export const UserCard = Component.make("UserCard")(
|
|
||||||
function* (props: { readonly user: { readonly id: string } }) {
|
|
||||||
const details = yield* Component.useOnChange(
|
|
||||||
() => loadUser(props.user.id),
|
|
||||||
[props.user.id],
|
|
||||||
)
|
|
||||||
|
|
||||||
return <article>{details.name}</article>
|
|
||||||
},
|
|
||||||
).pipe(
|
|
||||||
Async.async,
|
|
||||||
Memoized.memoized,
|
|
||||||
Memoized.withOptions({
|
|
||||||
propsEquivalence: Equal.asEquivalence(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Supplying `propsEquivalence` replaces the async component's default comparison,
|
|
||||||
so `fallback` is also included in this structural comparison.
|
|
||||||
+127
-143
@@ -52,12 +52,6 @@ valid decoded value should go:
|
|||||||
| `MutationForm` | Yes | It is passed to a mutation when `submit` runs. |
|
| `MutationForm` | Yes | It is passed to a mutation when `submit` runs. |
|
||||||
| `LensForm` | Yes | It is written automatically to a target `Lens`. |
|
| `LensForm` | Yes | It is written automatically to a target `Lens`. |
|
||||||
|
|
||||||
## Create forms once
|
|
||||||
|
|
||||||
`MutationForm.make` and `LensForm.make` construct forms. Pipe them through the
|
|
||||||
module's `run` operator to start validation or synchronization in the current
|
|
||||||
scope. Create each root form once and keep it stable.
|
|
||||||
|
|
||||||
## MutationForm: validate, then submit
|
## MutationForm: validate, then submit
|
||||||
|
|
||||||
Use `MutationForm` for registration, checkout, search, and other workflows with
|
Use `MutationForm` for registration, checkout, search, and other workflows with
|
||||||
@@ -71,7 +65,7 @@ import { Component, MutationForm, View } from "effect-view"
|
|||||||
|
|
||||||
const CreateProfileView = Component.make("CreateProfile")(function* () {
|
const CreateProfileView = Component.make("CreateProfile")(function* () {
|
||||||
const form = yield* Component.useOnMount(() =>
|
const form = yield* Component.useOnMount(() =>
|
||||||
MutationForm.make({
|
MutationForm.service({
|
||||||
schema: ProfileSchema,
|
schema: ProfileSchema,
|
||||||
initialEncodedValue: {
|
initialEncodedValue: {
|
||||||
displayName: "",
|
displayName: "",
|
||||||
@@ -82,7 +76,7 @@ const CreateProfileView = Component.make("CreateProfile")(function* () {
|
|||||||
Effect.log(
|
Effect.log(
|
||||||
`Creating ${profile.displayName}, age ${profile.age}`,
|
`Creating ${profile.displayName}, age ${profile.age}`,
|
||||||
),
|
),
|
||||||
}).pipe(MutationForm.thenRun),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const [canCommit, isCommitting] = yield* View.useAll([
|
const [canCommit, isCommitting] = yield* View.useAll([
|
||||||
@@ -108,9 +102,9 @@ mutation receives the schema's decoded profile, so `profile.age` is a number.
|
|||||||
Schema transformations happen before the mutation and schema issues prevent an
|
Schema transformations happen before the mutation and schema issues prevent an
|
||||||
invalid draft from being submitted.
|
invalid draft from being submitted.
|
||||||
|
|
||||||
`MutationForm.thenRun` starts initial validation. In the example above,
|
`MutationForm.service` also starts initial validation in the current scope. Use
|
||||||
`Component.useOnMount` keeps that validation and the form's mutation work tied
|
it inside `Component.useOnMount`, a scoped service, or another Effect scope so
|
||||||
to the component that owns them.
|
its validation and mutation work is cleaned up with its owner.
|
||||||
|
|
||||||
## LensForm: validate, then synchronize
|
## LensForm: validate, then synchronize
|
||||||
|
|
||||||
@@ -134,10 +128,10 @@ const EditProfileView = Component.make("EditProfile")(function* () {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const form = yield* LensForm.make({
|
const form = yield* LensForm.service({
|
||||||
schema: ProfileSchema,
|
schema: ProfileSchema,
|
||||||
target: profile,
|
target: profile,
|
||||||
}).pipe(LensForm.thenRun)
|
})
|
||||||
|
|
||||||
return [form, profile] as const
|
return [form, profile] as const
|
||||||
}),
|
}),
|
||||||
@@ -166,8 +160,126 @@ reaches the target. If another part of the application updates the target,
|
|||||||
`LensForm` encodes that value back into the draft.
|
`LensForm` encodes that value back into the draft.
|
||||||
|
|
||||||
Pass `initialEncodedValue` only when the first draft should differ from the
|
Pass `initialEncodedValue` only when the first draft should differ from the
|
||||||
encoded target. Otherwise `LensForm.make` obtains the initial draft by encoding
|
encoded target. Otherwise `LensForm.service` obtains the initial draft by
|
||||||
the target through the schema.
|
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
|
## Focus into subforms
|
||||||
|
|
||||||
@@ -188,16 +300,6 @@ const contactForm = Form.focusObjectOn(form, "contact")
|
|||||||
const emailField = Form.focusObjectOn(contactForm, "email")
|
const emailField = Form.focusObjectOn(contactForm, "email")
|
||||||
```
|
```
|
||||||
|
|
||||||
Form focus helpers also have a dual API. Pass only the key or index to get a curried function. Curried helpers can be chained
|
|
||||||
with `pipe` to focus through a deeper path:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const emailField = form.pipe(
|
|
||||||
Form.focusObjectOn("contact"),
|
|
||||||
Form.focusObjectOn("email"),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`displayNameField`, `ageField`, and `emailField` can each be passed to the input
|
`displayNameField`, `ageField`, and `emailField` can each be passed to the input
|
||||||
component responsible for that field. Focusing can also be repeated:
|
component responsible for that field. Focusing can also be repeated:
|
||||||
`contactForm` represents the complete contact section, and `emailField`
|
`contactForm` represents the complete contact section, and `emailField`
|
||||||
@@ -353,121 +455,3 @@ They expose the schema pipeline as reactive Lenses and Views:
|
|||||||
The form model itself is Effect code. Effect View adds the React-facing hooks,
|
The form model itself is Effect code. Effect View adds the React-facing hooks,
|
||||||
including `Form.useInput` and `Form.useOptionalInput`, while your own component
|
including `Form.useInput` and `Form.useOptionalInput`, while your own component
|
||||||
library remains responsible for rendering controls and layout.
|
library remains responsible for rendering controls and layout.
|
||||||
|
|
||||||
## Example: local date input, UTC domain value
|
|
||||||
|
|
||||||
The encoded/decoded distinction is especially useful for dates. An HTML
|
|
||||||
`datetime-local` input produces a wall-clock string such as
|
|
||||||
`"2026-07-22T14:30"`. It contains no time-zone or UTC offset, while application
|
|
||||||
state and APIs usually need an unambiguous UTC instant.
|
|
||||||
|
|
||||||
A schema can own that entire conversion. The following schema interprets the
|
|
||||||
input in the current time zone and decodes it to `DateTime.Utc`:
|
|
||||||
|
|
||||||
```tsx title="src/DateTimeUtcFromZonedInput.ts"
|
|
||||||
import { DateTime, Effect, Option, ParseResult, Schema } from "effect"
|
|
||||||
|
|
||||||
class DateTimeUtcFromZoned extends Schema.transformOrFail(
|
|
||||||
Schema.DateTimeZonedFromSelf,
|
|
||||||
Schema.DateTimeUtcFromSelf,
|
|
||||||
{
|
|
||||||
strict: true,
|
|
||||||
decode: (input) => ParseResult.succeed(DateTime.toUtc(input)),
|
|
||||||
encode: DateTime.setZoneCurrent,
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class DateTimeUtcFromZonedInput extends Schema.transformOrFail(
|
|
||||||
Schema.String,
|
|
||||||
DateTimeUtcFromZoned,
|
|
||||||
{
|
|
||||||
strict: true,
|
|
||||||
decode: (input, _options, ast) =>
|
|
||||||
Effect.flatMap(DateTime.CurrentTimeZone, (timeZone) =>
|
|
||||||
Option.match(
|
|
||||||
DateTime.makeZoned(input, {
|
|
||||||
timeZone,
|
|
||||||
adjustForTimeZone: true,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
onSome: ParseResult.succeed,
|
|
||||||
onNone: () =>
|
|
||||||
ParseResult.fail(
|
|
||||||
new ParseResult.Type(
|
|
||||||
ast,
|
|
||||||
input,
|
|
||||||
"Enter a valid date and time",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
encode: (value) =>
|
|
||||||
ParseResult.succeed(
|
|
||||||
DateTime.formatIsoZoned(value).slice(0, 16),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use it like any other field schema. The form and input work with a string, but
|
|
||||||
the mutation receives UTC:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { DateTime, Effect, Schema } from "effect"
|
|
||||||
import { Component, Form, MutationForm, View } from "effect-view"
|
|
||||||
import { DateTimeUtcFromZonedInput } from "./DateTimeUtcFromZonedInput"
|
|
||||||
|
|
||||||
const AppointmentSchema = Schema.Struct({
|
|
||||||
startsAt: DateTimeUtcFromZonedInput,
|
|
||||||
})
|
|
||||||
|
|
||||||
const AppointmentView = Component.make("Appointment")(function* () {
|
|
||||||
const [form, startsAtField] = yield* Component.useOnMount(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const form = yield* MutationForm.make({
|
|
||||||
schema: AppointmentSchema,
|
|
||||||
initialEncodedValue: { startsAt: "" },
|
|
||||||
f: ([appointment]) =>
|
|
||||||
Effect.log(
|
|
||||||
`Saving ${DateTime.formatIso(appointment.startsAt)}`,
|
|
||||||
),
|
|
||||||
}).pipe(MutationForm.thenRun)
|
|
||||||
|
|
||||||
return [form, Form.focusObjectOn(form, "startsAt")] as const
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const startsAt = yield* Form.useInput(startsAtField)
|
|
||||||
const [canCommit] = yield* View.useAll([form.canCommit])
|
|
||||||
const runPromise = yield* Component.useRunPromise()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
void runPromise(form.submit)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={startsAt.value}
|
|
||||||
onChange={(event) =>
|
|
||||||
startsAt.setValue(event.currentTarget.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<button disabled={!canCommit}>Save appointment</button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
The schema requires `DateTime.CurrentTimeZone`; provide
|
|
||||||
`DateTime.layerCurrentZoneLocal` in the application runtime. If the current
|
|
||||||
zone is `Europe/Paris`, for example, entering `2026-07-22T14:30` decodes to the
|
|
||||||
UTC instant `2026-07-22T12:30:00.000Z`.
|
|
||||||
|
|
||||||
Encoding works in the other direction. A `LensForm` whose target contains that
|
|
||||||
UTC instant presents `2026-07-22T14:30` to the local input. The component never
|
|
||||||
manually parses dates, applies offsets, or maintains a second representation;
|
|
||||||
the schema defines both directions and the form keeps them synchronized.
|
|
||||||
|
|||||||
@@ -35,33 +35,6 @@ 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
|
install that renderer instead of `react-dom` and keep the rest of the setup the
|
||||||
same.
|
same.
|
||||||
|
|
||||||
## Set up hot reloading with Vite
|
|
||||||
|
|
||||||
Install the Effect View Vite plugin to preserve component state while editing
|
|
||||||
Effect View components:
|
|
||||||
|
|
||||||
```bash npm2yarn
|
|
||||||
npm install --save-dev @effect-view/vite-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `effectView()` before the React plugin in your Vite configuration:
|
|
||||||
|
|
||||||
```ts title="vite.config.ts"
|
|
||||||
import { effectView } from "@effect-view/vite-plugin"
|
|
||||||
import react from "@vitejs/plugin-react"
|
|
||||||
import { defineConfig } from "vite"
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
effectView(),
|
|
||||||
react(),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Vite is the only bundler supported for Effect View hot reloading at the moment.
|
|
||||||
Support for other bundlers will follow.
|
|
||||||
|
|
||||||
## Create the runtime
|
## Create the runtime
|
||||||
|
|
||||||
`ReactRuntime` owns a managed Effect runtime. Define it at module scope from the
|
`ReactRuntime` owns a managed Effect runtime. Define it at module scope from the
|
||||||
@@ -199,25 +172,51 @@ to provide local services to a subtree.
|
|||||||
|
|
||||||
## Synchronous and asynchronous components
|
## Synchronous and asynchronous components
|
||||||
|
|
||||||
A regular Effect View component runs its body during React render. Effects
|
A regular Effect View component runs its body during React render. That Effect
|
||||||
executed directly by that body—including setup passed to `useOnMount`,
|
must complete synchronously: yielding a service, reading synchronous state, or
|
||||||
`useOnChange`, or `useLayer`—must complete synchronously every time they run.
|
creating a scoped object is fine; sleeping, fetching, or awaiting a promise is
|
||||||
Yielding services and reading synchronous state is fine; sleeping, fetching, or
|
not.
|
||||||
awaiting a promise is not.
|
|
||||||
|
|
||||||
Choose the integration that matches the asynchronous work:
|
Use `Async.async` when render genuinely depends on an asynchronous Effect. The
|
||||||
|
component then suspends and accepts React Suspense props such as `fallback`:
|
||||||
|
|
||||||
- Make the component asynchronous with [`Async.async`](./async) when it must
|
```tsx title="src/UserView.tsx"
|
||||||
wait for a one-off asynchronous Effect before producing JSX. The component
|
import { Async, Component, Memoized } from "effect-view"
|
||||||
suspends while it waits.
|
import { loadUser } from "./api"
|
||||||
- Use [Query](./query) for server reads that need caching, sharing, refresh, or
|
|
||||||
invalidation.
|
export const UserView = Component.make("UserView")(
|
||||||
- Use [Mutation](./mutation) for user-triggered writes with observable pending
|
function* (props: { readonly userId: string }) {
|
||||||
and error state.
|
const user = yield* Component.useOnChange(
|
||||||
- Use `useRunPromise` or `useCallbackPromise` for asynchronous event work that
|
() => loadUser(props.userId),
|
||||||
does not need Mutation state.
|
[props.userId],
|
||||||
- Use a post-commit hook with a scoped fiber for subscriptions or background
|
)
|
||||||
work tied to the component lifecycle.
|
|
||||||
|
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
|
## Use Effect services
|
||||||
|
|
||||||
@@ -295,25 +294,24 @@ const ResourceView = Component.make("Resource")(function* () {
|
|||||||
with the context already available in the component body, so the resource above
|
with the context already available in the component body, so the resource above
|
||||||
is owned by the component's root scope.
|
is owned by the component's root scope.
|
||||||
|
|
||||||
`useOnChange`, `useReactEffect`, and `useReactLayoutEffect` each manage their
|
`useOnChange`, `useReactEffect`, and `useReactLayoutEffect` each create a scope
|
||||||
own resources. When their dependencies change, those resources are cleaned up
|
that can be replaced without closing the component root scope.
|
||||||
and recreated while the rest of the component remains active.
|
|
||||||
|
|
||||||
The main lifecycle choices are:
|
The main lifecycle choices are:
|
||||||
|
|
||||||
| Hook | What it does | Scope seen by setup | Scope closes when |
|
| 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 |
|
| Component body | Produces the component's rendered value | Component root scope | The component unmounts |
|
||||||
| `useOnMount` | Computes and caches a value for the component instance | Component root scope | The component unmounts |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `useReactLayoutEffect` | Runs a layout effect before the browser paints | A new layout-effect scope | Dependencies change or the component unmounts |
|
||||||
| `useLayer` | Builds and provides a layer context | A new scope tied to the current layer reference | The layer reference changes or the component unmounts |
|
| `useLayer` | Builds and provides a layer context | A dependency scope created through `useOnChange` | The layer reference changes or the component unmounts |
|
||||||
|
|
||||||
`useRunSync` and `useRunPromise` do not create a new scope. The runner they
|
`useRunSync`, `useRunPromise`, `useCallbackSync`, and `useCallbackPromise` do
|
||||||
return includes the component root scope in its context by default.
|
not create lifecycle scopes either. They capture the component context, so an
|
||||||
`useCallbackSync` and `useCallbackPromise` similarly capture the component
|
Effect invoked through them sees the component root scope unless it explicitly
|
||||||
context required by their Effect.
|
provides another one.
|
||||||
|
|
||||||
### useOnMount
|
### useOnMount
|
||||||
|
|
||||||
@@ -411,21 +409,6 @@ Use `Component.useRunSync` only for Effects known to complete synchronously.
|
|||||||
Use `Component.useRunPromise` for Effects that may suspend, sleep, fetch, or
|
Use `Component.useRunPromise` for Effects that may suspend, sleep, fetch, or
|
||||||
otherwise continue asynchronously.
|
otherwise continue asynchronously.
|
||||||
|
|
||||||
Both runners provide `Scope.Scope` by default, so they can run scoped Effects
|
|
||||||
without a type argument. When an Effect also requires application services,
|
|
||||||
declare the complete runner context explicitly:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Scope } from "effect"
|
|
||||||
|
|
||||||
const runPromise = yield* Component.useRunPromise<
|
|
||||||
Scope.Scope | UserRepository
|
|
||||||
>()
|
|
||||||
```
|
|
||||||
|
|
||||||
The resulting runner accepts Effects that require the component scope,
|
|
||||||
`UserRepository`, or both.
|
|
||||||
|
|
||||||
When a callback is passed to a memoized child or used as a dependency, use the
|
When a callback is passed to a memoized child or used as a dependency, use the
|
||||||
callback variants to preserve its identity:
|
callback variants to preserve its identity:
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ Create a mutation in a component scope with `Mutation.make`. Its function can
|
|||||||
be any Effect, including one that requires services from the application
|
be any Effect, including one that requires services from the application
|
||||||
runtime:
|
runtime:
|
||||||
|
|
||||||
`Mutation.make` is an Effect constructor, not a hook. Create each mutation
|
|
||||||
instance once and keep it stable instead of calling `Mutation.make` again on
|
|
||||||
every render. Use `Component.useOnMount` for a component-owned mutation or
|
|
||||||
create it in an Effect service when it should be shared.
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { AsyncResult } from "effect/unstable/reactivity"
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
|
|||||||
+13
-57
@@ -35,6 +35,7 @@ application runtime once, alongside the services used by your query effects:
|
|||||||
|
|
||||||
```tsx title="src/runtime.ts"
|
```tsx title="src/runtime.ts"
|
||||||
import { Layer } from "effect"
|
import { Layer } from "effect"
|
||||||
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { QueryClient, ReactRuntime } from "effect-view"
|
import { QueryClient, ReactRuntime } from "effect-view"
|
||||||
|
|
||||||
const AppLive = Layer.empty.pipe(
|
const AppLive = Layer.empty.pipe(
|
||||||
@@ -43,6 +44,7 @@ const AppLive = Layer.empty.pipe(
|
|||||||
defaultRefreshOnWindowFocus: true,
|
defaultRefreshOnWindowFocus: true,
|
||||||
cacheGcTime: "5 minutes",
|
cacheGcTime: "5 minutes",
|
||||||
})),
|
})),
|
||||||
|
Layer.provideMerge(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const runtime = ReactRuntime.make(AppLive)
|
export const runtime = ReactRuntime.make(AppLive)
|
||||||
@@ -50,22 +52,14 @@ export const runtime = ReactRuntime.make(AppLive)
|
|||||||
|
|
||||||
The client owns the cache and its cleanup lifecycle. Individual queries can
|
The client owns the cache and its cleanup lifecycle. Individual queries can
|
||||||
override `staleTime` and `refreshOnWindowFocus`; otherwise they inherit the
|
override `staleTime` and `refreshOnWindowFocus`; otherwise they inherit the
|
||||||
client defaults. Window-focus refresh also requires the optional
|
client defaults.
|
||||||
`@effect/platform-browser` package, as described under
|
|
||||||
[Staleness and cache lifetime](#staleness-and-cache-lifetime).
|
|
||||||
|
|
||||||
## Create a reactive query
|
## Create a reactive query
|
||||||
|
|
||||||
A query is driven by a `View` rather than by a value read during one React
|
A query is driven by a `View` rather than by a value read during one React
|
||||||
render. Whenever that key changes, a running Query checks the cache and starts
|
render. Whenever that key changes, `Query.service` checks the cache and starts
|
||||||
the query effect when necessary.
|
the query effect when necessary.
|
||||||
|
|
||||||
`Query.make` constructs the Query and `Query.thenRun` starts it in the current
|
|
||||||
scope. Create each query instance once and keep it stable. In a component, the usual place is
|
|
||||||
`Component.useOnMount`; a query shared by multiple components can instead be
|
|
||||||
owned by an Effect service. Change the existing query's reactive key rather
|
|
||||||
than reconstructing the query during render.
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Effect, Schema, SubscriptionRef } from "effect"
|
import { Effect, Schema, SubscriptionRef } from "effect"
|
||||||
import { HttpClient } from "effect/unstable/http"
|
import { HttpClient } from "effect/unstable/http"
|
||||||
@@ -84,7 +78,7 @@ const PostView = Component.make("Post")(function* () {
|
|||||||
yield* SubscriptionRef.make(["post", 1 as number] as const),
|
yield* SubscriptionRef.make(["post", 1 as number] as const),
|
||||||
)
|
)
|
||||||
|
|
||||||
const query = yield* Query.make({
|
const query = yield* Query.service({
|
||||||
key,
|
key,
|
||||||
staleTime: "1 minute",
|
staleTime: "1 minute",
|
||||||
f: ([, id]) =>
|
f: ([, id]) =>
|
||||||
@@ -95,7 +89,7 @@ const PostView = Component.make("Post")(function* () {
|
|||||||
Effect.andThen((response) => response.json),
|
Effect.andThen((response) => response.json),
|
||||||
Effect.andThen(Schema.decodeUnknownEffect(Post)),
|
Effect.andThen(Schema.decodeUnknownEffect(Post)),
|
||||||
),
|
),
|
||||||
}).pipe(Query.thenRun)
|
})
|
||||||
|
|
||||||
return [Lens.focusTupleAt(key, 1), query] as const
|
return [Lens.focusTupleAt(key, 1), query] as const
|
||||||
}),
|
}),
|
||||||
@@ -124,9 +118,9 @@ Effect equality by default, so structurally equal Effect data types work well
|
|||||||
as query keys. Supply `keyEquivalence` when the key needs different equality
|
as query keys. Supply `keyEquivalence` when the key needs different equality
|
||||||
semantics.
|
semantics.
|
||||||
|
|
||||||
`Query.thenRun` starts watching its key in the current scope. The
|
`Query.service` creates the query and starts watching its key in the current
|
||||||
`Component.useOnMount` call above keeps both the query instance and its query
|
scope. Creating it in `Component.useOnMount` keeps one query instance—and one
|
||||||
function identity stable for the component's lifetime.
|
stable query function identity—for the component's lifetime.
|
||||||
|
|
||||||
## Render AsyncResult
|
## Render AsyncResult
|
||||||
|
|
||||||
@@ -248,35 +242,6 @@ workflows that need to wait for the final success or failure state.
|
|||||||
Invalidating does not itself refetch. Follow it with `refreshView`, change the
|
Invalidating does not itself refetch. Follow it with `refreshView`, change the
|
||||||
key, or allow a later fetch to repopulate the cache.
|
key, or allow a later fetch to repopulate the cache.
|
||||||
|
|
||||||
### Refresh on an interval
|
|
||||||
|
|
||||||
Refresh every five minutes, starting after five minutes:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { Schedule } from "effect"
|
|
||||||
|
|
||||||
const query = yield* Query.make(options).pipe(
|
|
||||||
Query.thenRun,
|
|
||||||
Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Limit the number of refreshes:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const query = yield* Query.make(options).pipe(
|
|
||||||
Query.thenRun,
|
|
||||||
Query.withScheduledRefresh(
|
|
||||||
Schedule.spaced("5 minutes").pipe(
|
|
||||||
Schedule.upTo({ times: 3 }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
The refresh fiber stops with the surrounding scope. The Effect returns the
|
|
||||||
original query. Cache and `staleTime` rules still apply.
|
|
||||||
|
|
||||||
## Staleness and cache lifetime
|
## Staleness and cache lifetime
|
||||||
|
|
||||||
`staleTime` controls how long a successful result can satisfy a fetch without
|
`staleTime` controls how long a successful result can satisfy a fetch without
|
||||||
@@ -291,25 +256,16 @@ By default, the client enables refresh on browser window focus. Set it globally
|
|||||||
or override it for one query:
|
or override it for one query:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const query = yield* Query.make({
|
const query = yield* Query.service({
|
||||||
key,
|
key,
|
||||||
f: loadPost,
|
f: loadPost,
|
||||||
staleTime: "10 seconds",
|
staleTime: "10 seconds",
|
||||||
refreshOnWindowFocus: false,
|
refreshOnWindowFocus: false,
|
||||||
}).pipe(Query.thenRun)
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
Window-focus refresh depends on the optional `@effect/platform-browser`
|
Window focus resolves the current key again, subject to the same freshness
|
||||||
integration. Install it in browser applications that use this behavior:
|
check. The browser integration is ignored in non-browser environments.
|
||||||
|
|
||||||
```bash npm2yarn
|
|
||||||
npm install @effect/platform-browser@beta
|
|
||||||
```
|
|
||||||
|
|
||||||
When the package is available, focusing the window resolves the current key
|
|
||||||
again, subject to the same freshness check. Without it,
|
|
||||||
`refreshOnWindowFocus` has no effect; the rest of the Query API continues to
|
|
||||||
work normally. The integration is also ignored in non-browser environments.
|
|
||||||
|
|
||||||
## The Effect touch
|
## The Effect touch
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ 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.
|
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-view` re-exports the `Lens` and `View` modules from
|
||||||
[`effect-lens`](https://www.npmjs.com/package/effect-lens/v/beta) for convenience. The
|
[`effect-lens`](https://github.com/Thiladev/effect-lens) for convenience. The
|
||||||
core data model and transformation APIs belong to `effect-lens`, so check the
|
core data model and transformation APIs belong to `effect-lens`, so check the
|
||||||
[`effect-lens` documentation](https://www.npmjs.com/package/effect-lens/v/beta) for the full Lens/View API.
|
[`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
|
The usual pattern is to create state with Effect primitives such as
|
||||||
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor
|
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor
|
||||||
@@ -219,16 +219,15 @@ interface UserProfile {
|
|||||||
readonly name: string
|
readonly name: string
|
||||||
readonly email: string
|
readonly email: string
|
||||||
readonly role: string
|
readonly role: string
|
||||||
readonly contact: {
|
|
||||||
readonly address: {
|
|
||||||
readonly city: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProfileState extends Context.Service<
|
class ProfileState extends Context.Service<
|
||||||
ProfileState,
|
ProfileState,
|
||||||
{ readonly profile: Lens.Lens<UserProfile> }
|
{
|
||||||
|
readonly profile: Lens.Lens<UserProfile>
|
||||||
|
readonly name: Lens.Lens<string>
|
||||||
|
readonly role: Lens.Lens<string>
|
||||||
|
}
|
||||||
>()("ProfileState") {
|
>()("ProfileState") {
|
||||||
static readonly layer = Layer.effect(
|
static readonly layer = Layer.effect(
|
||||||
ProfileState,
|
ProfileState,
|
||||||
@@ -238,35 +237,20 @@ class ProfileState extends Context.Service<
|
|||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
role: "reader",
|
role: "reader",
|
||||||
contact: {
|
|
||||||
address: {
|
|
||||||
city: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
const name = Lens.focusObjectOn(profile, "name")
|
||||||
|
const role = Lens.focusObjectOn(profile, "role")
|
||||||
|
|
||||||
return { profile } as const
|
return { profile, name, role } as const
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProfileNameView = Component.make("ProfileName")(function*() {
|
const ProfileNameView = Component.make("ProfileName")(function*() {
|
||||||
const state = yield* ProfileState
|
const state = yield* ProfileState
|
||||||
const [nameLens, roleLens, cityLens] = yield* Component.useOnMount(() =>
|
const [name, setName] = yield* Lens.useState(state.name)
|
||||||
Effect.succeed([
|
const [role] = yield* View.useAll([state.role])
|
||||||
Lens.focusObjectOn(state.profile, "name"),
|
|
||||||
Lens.focusObjectOn(state.profile, "role"),
|
|
||||||
state.profile.pipe(
|
|
||||||
Lens.focusObjectOn("contact"),
|
|
||||||
Lens.focusObjectOn("address"),
|
|
||||||
Lens.focusObjectOn("city"),
|
|
||||||
),
|
|
||||||
] as const),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [name, setName] = yield* Lens.useState(nameLens)
|
|
||||||
const [role, city] = yield* View.useAll([roleLens, cityLens])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label>
|
<label>
|
||||||
@@ -276,22 +260,15 @@ const ProfileNameView = Component.make("ProfileName")(function*() {
|
|||||||
onChange={(event) => setName(event.currentTarget.value)}
|
onChange={(event) => setName(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<span>Role: {role}</span>
|
<span>Role: {role}</span>
|
||||||
<span>City: {city}</span>
|
|
||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
The service owns only the root `profile` Lens. The component chooses the fields
|
Updating the focused `name` Lens through `Lens.useState` updates the parent
|
||||||
it needs, creates those focused Lenses once in `Component.useOnMount`, and
|
`profile` Lens. The focused `role` Lens is only read, so it stays on the simpler
|
||||||
subscribes only to them. Updating `nameLens` through `Lens.useState` still
|
`View.useAll` path.
|
||||||
updates the parent `profile` Lens.
|
|
||||||
|
|
||||||
Lens focus helpers have a dual API. They can be called in data-first form, such
|
|
||||||
as `Lens.focusObjectOn(profile, "name")`, or in curried form with only the key
|
|
||||||
or index. The `cityLens` above uses the curried form with `pipe` to focus through
|
|
||||||
a deeper path without storing intermediate Lenses.
|
|
||||||
|
|
||||||
For focusing into nested state, deriving lenses, custom write behavior, and the
|
For focusing into nested state, deriving lenses, custom write behavior, and the
|
||||||
complete API, refer to the
|
complete API, refer to the
|
||||||
[`effect-lens` documentation](https://www.npmjs.com/package/effect-lens/v/beta).
|
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens).
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ const config: Config = {
|
|||||||
lastVersion: "current",
|
lastVersion: "current",
|
||||||
versions: {
|
versions: {
|
||||||
current: {
|
current: {
|
||||||
label: "effect-view v0",
|
label: "effect-view v1",
|
||||||
},
|
},
|
||||||
"effect-fc-v0": {
|
"effect-fc-1.0": {
|
||||||
label: "effect-fc v0",
|
label: "effect-fc 1.0",
|
||||||
path: "effect-fc/v0",
|
path: "effect-fc/1.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -84,11 +84,6 @@ const config: Config = {
|
|||||||
type: "docsVersionDropdown",
|
type: "docsVersionDropdown",
|
||||||
position: "right",
|
position: "right",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: "https://www.npmjs.com/package/effect-view",
|
|
||||||
label: "npm",
|
|
||||||
position: "right",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: "https://github.com/Thiladev/effect-view",
|
href: "https://github.com/Thiladev/effect-view",
|
||||||
label: "GitHub",
|
label: "GitHub",
|
||||||
|
|||||||
@@ -3,25 +3,21 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint:tsc": "tsc -b --noEmit",
|
|
||||||
"lint:biome": "biome lint",
|
|
||||||
"docusaurus": "docusaurus",
|
"docusaurus": "docusaurus",
|
||||||
"start": "docusaurus start",
|
"start": "docusaurus start",
|
||||||
"build": "docusaurus build --out-dir dist",
|
"build": "docusaurus build",
|
||||||
"swizzle": "docusaurus swizzle",
|
"swizzle": "docusaurus swizzle",
|
||||||
"deploy": "docusaurus deploy",
|
"deploy": "docusaurus deploy",
|
||||||
"clear": "docusaurus clear",
|
"clear": "docusaurus clear",
|
||||||
"serve": "docusaurus serve --dir dist",
|
"serve": "docusaurus serve",
|
||||||
"write-translations": "docusaurus write-translations",
|
"write-translations": "docusaurus write-translations",
|
||||||
"write-heading-ids": "docusaurus write-heading-ids",
|
"write-heading-ids": "docusaurus write-heading-ids",
|
||||||
"clean:cache": "rm -rf .turbo *.tsbuildinfo && docusaurus clear",
|
"typecheck": "tsc"
|
||||||
"clean:dist": "rm -rf dist",
|
|
||||||
"clean:modules": "rm -rf node_modules"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docusaurus/core": "^3.10.2",
|
"@docusaurus/core": "3.10.1",
|
||||||
"@docusaurus/faster": "^3.10.2",
|
"@docusaurus/faster": "^3.10.1",
|
||||||
"@docusaurus/preset-classic": "^3.10.2",
|
"@docusaurus/preset-classic": "3.10.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
@@ -29,9 +25,9 @@
|
|||||||
"react-dom": "^19.2.5"
|
"react-dom": "^19.2.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@docusaurus/module-type-aliases": "^3.10.2",
|
"@docusaurus/module-type-aliases": "3.10.1",
|
||||||
"@docusaurus/tsconfig": "^3.10.2",
|
"@docusaurus/tsconfig": "3.10.1",
|
||||||
"@docusaurus/types": "^3.10.2",
|
"@docusaurus/types": "3.10.1",
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ const sidebars: SidebarsConfig = {
|
|||||||
docsSidebar: [
|
docsSidebar: [
|
||||||
"getting-started",
|
"getting-started",
|
||||||
"state-management",
|
"state-management",
|
||||||
"async",
|
|
||||||
"query",
|
"query",
|
||||||
"mutation",
|
"mutation",
|
||||||
"forms",
|
"forms",
|
||||||
|
|||||||
@@ -72,15 +72,9 @@
|
|||||||
letter-spacing: -0.075em;
|
letter-spacing: -0.075em;
|
||||||
line-height: 0.92;
|
line-height: 0.92;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow-wrap: normal;
|
|
||||||
word-break: normal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1 .unbreakable {
|
.hero h1 span {
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero h1 .heroAccent {
|
|
||||||
background: linear-gradient(105deg, var(--home-teal) 5%, #1a9793 48%, var(--home-amber));
|
background: linear-gradient(105deg, var(--home-teal) 5%, #1a9793 48%, var(--home-amber));
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
@@ -163,26 +157,6 @@
|
|||||||
padding: 0;
|
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 {
|
.codeStage {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
perspective: 1200px;
|
perspective: 1200px;
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ export default function Home(): ReactNode {
|
|||||||
Effect View for React 19
|
Effect View for React 19
|
||||||
</div>
|
</div>
|
||||||
<h1>
|
<h1>
|
||||||
React <span className={styles.unbreakable}>components,</span>
|
React components,
|
||||||
<span className={styles.heroAccent}> powered by Effect.</span>
|
<span> powered by Effect.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className={styles.lede}>
|
<p className={styles.lede}>
|
||||||
Bring typed services, scoped resources, reactive state, server
|
Bring typed services, scoped resources, reactive state, server
|
||||||
@@ -126,13 +126,6 @@ export default function Home(): ReactNode {
|
|||||||
<span aria-hidden="true">$</span>
|
<span aria-hidden="true">$</span>
|
||||||
<code>npm install effect-view effect@beta</code>
|
<code>npm install effect-view effect@beta</code>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className={styles.codeStage}>
|
<div className={styles.codeStage}>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
[
|
[
|
||||||
"effect-fc-v0"
|
"effect-fc-1.0"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
# Effect FC Example
|
|
||||||
|
|
||||||
Example application for the legacy `effect-fc` package, built with React,
|
|
||||||
TypeScript, and Vite.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
|
||||||
|
|
||||||
- Configure the top-level `parserOptions` property like this:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default tseslint.config({
|
|
||||||
languageOptions: {
|
|
||||||
// other options...
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
|
||||||
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
|
||||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import react from 'eslint-plugin-react'
|
|
||||||
|
|
||||||
export default tseslint.config({
|
|
||||||
// Set the react version
|
|
||||||
settings: { react: { version: '18.3' } },
|
|
||||||
plugins: {
|
|
||||||
// Add the react plugin
|
|
||||||
react,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
// other rules...
|
|
||||||
// Enable its recommended rules
|
|
||||||
...react.configs.recommended.rules,
|
|
||||||
...react.configs['jsx-runtime'].rules,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import { Button, Container, Flex, Text } from "@radix-ui/themes"
|
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
|
||||||
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
|
|
||||||
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
|
|
||||||
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
|
||||||
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
|
|
||||||
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
|
||||||
import { runtime } from "@/runtime"
|
|
||||||
|
|
||||||
|
|
||||||
const email = Schema.pattern<typeof Schema.String>(
|
|
||||||
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i,
|
|
||||||
|
|
||||||
{
|
|
||||||
identifier: "email",
|
|
||||||
title: "email",
|
|
||||||
message: () => "Not an email address",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const RegisterFormSchema = Schema.Struct({
|
|
||||||
email: Schema.String.pipe(email),
|
|
||||||
password: Schema.String.pipe(Schema.minLength(3)),
|
|
||||||
birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
|
|
||||||
})
|
|
||||||
|
|
||||||
const RegisterFormSubmitSchema = Schema.Struct({
|
|
||||||
email: Schema.transformOrFail(
|
|
||||||
Schema.String,
|
|
||||||
Schema.String,
|
|
||||||
{
|
|
||||||
decode: (input, _options, ast) => input !== "admin@admin.com"
|
|
||||||
? ParseResult.succeed(input)
|
|
||||||
: ParseResult.fail(new ParseResult.Type(ast, input, "This email is already in use.")),
|
|
||||||
encode: ParseResult.succeed,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
password: Schema.String,
|
|
||||||
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
|
|
||||||
})
|
|
||||||
|
|
||||||
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
|
|
||||||
scoped: Effect.gen(function*() {
|
|
||||||
const form = yield* SubmittableForm.service({
|
|
||||||
schema: RegisterFormSchema.pipe(
|
|
||||||
Schema.compose(
|
|
||||||
Schema.transformOrFail(
|
|
||||||
Schema.typeSchema(RegisterFormSchema),
|
|
||||||
Schema.typeSchema(RegisterFormSchema),
|
|
||||||
{
|
|
||||||
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
|
|
||||||
encode: ParseResult.succeed,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
initialEncodedValue: { email: "", password: "", birth: Option.none() },
|
|
||||||
f: Effect.fnUntraced(function*([value]) {
|
|
||||||
yield* Effect.sleep("500 millis")
|
|
||||||
return yield* Schema.decode(RegisterFormSubmitSchema)(value)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
form,
|
|
||||||
emailField: Form.focusObjectOn(form, "email"),
|
|
||||||
passwordField: Form.focusObjectOn(form, "password"),
|
|
||||||
birthField: Form.focusObjectOn(form, "birth"),
|
|
||||||
} as const
|
|
||||||
})
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class RegisterFormView extends Component.make("RegisterFormView")(function*() {
|
|
||||||
const form = yield* RegisterFormService
|
|
||||||
const [canCommit, submitResult] = yield* Subscribable.useAll([
|
|
||||||
form.form.canCommit,
|
|
||||||
form.form.mutation.result,
|
|
||||||
])
|
|
||||||
|
|
||||||
const runPromise = yield* Component.useRunPromise()
|
|
||||||
const TextFieldFormInput = yield* TextFieldFormInputView.use
|
|
||||||
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
|
|
||||||
|
|
||||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
|
||||||
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
|
|
||||||
yield* Console.log("RegisterFormView mounted")
|
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container width="300">
|
|
||||||
<form onSubmit={e => {
|
|
||||||
e.preventDefault()
|
|
||||||
void runPromise(form.form.submit)
|
|
||||||
}}>
|
|
||||||
<Flex direction="column" gap="2">
|
|
||||||
<TextFieldFormInput
|
|
||||||
form={form.emailField}
|
|
||||||
debounce="250 millis"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextFieldFormInput
|
|
||||||
form={form.passwordField}
|
|
||||||
debounce="250 millis"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextFieldOptionalFormInput
|
|
||||||
type="datetime-local"
|
|
||||||
form={form.birthField}
|
|
||||||
defaultValue=""
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button disabled={!canCommit}>Submit</Button>
|
|
||||||
</Flex>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{Match.value(submitResult).pipe(
|
|
||||||
Match.tag("Initial", () => <></>),
|
|
||||||
Match.tag("Running", () => <Text>Submitting...</Text>),
|
|
||||||
Match.tag("Success", () => <Text>Submitted successfully!</Text>),
|
|
||||||
Match.tag("Failure", e => <Text>Error: {e.cause.toString()}</Text>),
|
|
||||||
Match.exhaustive,
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const RegisterPage = Component.make("RegisterPageView")(function*() {
|
|
||||||
const RegisterForm = yield* Effect.provide(
|
|
||||||
RegisterFormView.use,
|
|
||||||
yield* Component.useLayer(RegisterFormService.Default),
|
|
||||||
)
|
|
||||||
|
|
||||||
return <RegisterForm />
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/form")({
|
|
||||||
component: RegisterPage
|
|
||||||
})
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { Component } from "effect-fc"
|
|
||||||
import { runtime } from "@/runtime"
|
|
||||||
import { TodosState } from "@/todo/TodosState"
|
|
||||||
import { TodosView } from "@/todo/TodosView"
|
|
||||||
|
|
||||||
|
|
||||||
const TodosStateLive = TodosState.Default("todos")
|
|
||||||
|
|
||||||
const Index = Component.make("IndexView")(function*() {
|
|
||||||
const Todos = yield* Effect.provide(
|
|
||||||
TodosView.use,
|
|
||||||
yield* Component.useLayer(TodosStateLive),
|
|
||||||
)
|
|
||||||
|
|
||||||
return <Todos />
|
|
||||||
}).pipe(
|
|
||||||
Component.withRuntime(runtime.context)
|
|
||||||
)
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
|
||||||
component: Index
|
|
||||||
})
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { HttpClient, type HttpClientError } from "@effect/platform"
|
|
||||||
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
|
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
|
||||||
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect"
|
|
||||||
import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc"
|
|
||||||
import { runtime } from "@/runtime"
|
|
||||||
|
|
||||||
|
|
||||||
const Post = Schema.Struct({
|
|
||||||
userId: Schema.Int,
|
|
||||||
id: Schema.Int,
|
|
||||||
title: Schema.String,
|
|
||||||
body: Schema.String,
|
|
||||||
})
|
|
||||||
|
|
||||||
const ResultView = Component.make("ResultView")(function*() {
|
|
||||||
const runPromise = yield* Component.useRunPromise()
|
|
||||||
|
|
||||||
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
|
||||||
const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
|
|
||||||
const key = Stream.map(idLens.changes, id => [id] as const)
|
|
||||||
|
|
||||||
const query = yield* Query.service({
|
|
||||||
key,
|
|
||||||
f: ([id]) => HttpClient.HttpClient.pipe(
|
|
||||||
Effect.tap(Effect.sleep("500 millis")),
|
|
||||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
|
||||||
Effect.andThen(response => response.json),
|
|
||||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
|
||||||
),
|
|
||||||
staleTime: "10 seconds",
|
|
||||||
})
|
|
||||||
|
|
||||||
const mutation = yield* Mutation.make({
|
|
||||||
f: ([id]: readonly [id: number]) => HttpClient.HttpClient.pipe(
|
|
||||||
Effect.tap(Effect.sleep("500 millis")),
|
|
||||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
|
||||||
Effect.andThen(response => response.json),
|
|
||||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
return [idLens, query, mutation] as const
|
|
||||||
}))
|
|
||||||
|
|
||||||
const [id, setId] = yield* Lens.useState(idLens)
|
|
||||||
const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result])
|
|
||||||
|
|
||||||
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
|
||||||
Effect.andThen(observer => observer.subscribe),
|
|
||||||
Effect.andThen(Stream.fromQueue),
|
|
||||||
Stream.unwrapScoped,
|
|
||||||
Stream.runForEach(flow(
|
|
||||||
Cause.failures,
|
|
||||||
Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"),
|
|
||||||
Option.match({
|
|
||||||
onSome: e => Console.log("ResultView HttpClient error", e),
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
}),
|
|
||||||
)),
|
|
||||||
Effect.forkScoped,
|
|
||||||
))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Flex direction="column" align="center" gap="2">
|
|
||||||
<Slider
|
|
||||||
value={[id]}
|
|
||||||
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{Match.value(queryResult).pipe(
|
|
||||||
Match.tag("Running", () => <Text>Loading...</Text>),
|
|
||||||
Match.tag("Success", result => <>
|
|
||||||
<Heading>{result.value.title}</Heading>
|
|
||||||
<Text>{result.value.body}</Text>
|
|
||||||
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
|
|
||||||
</>),
|
|
||||||
Match.tag("Failure", result =>
|
|
||||||
<Text>An error has occured: {result.cause.toString()}</Text>
|
|
||||||
),
|
|
||||||
Match.orElse(() => <></>),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Flex direction="row" justify="center" align="center" gap="1">
|
|
||||||
<Button onClick={() => runPromise(query.refresh)}>Refresh</Button>
|
|
||||||
<Button onClick={() => runPromise(query.invalidateCache)}>Invalidate cache</Button>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{Match.value(mutationResult).pipe(
|
|
||||||
Match.tag("Running", () => <Text>Loading...</Text>),
|
|
||||||
Match.tag("Success", result => <>
|
|
||||||
<Heading>{result.value.title}</Heading>
|
|
||||||
<Text>{result.value.body}</Text>
|
|
||||||
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
|
|
||||||
</>),
|
|
||||||
Match.tag("Failure", result =>
|
|
||||||
<Text>An error has occured: {result.cause.toString()}</Text>
|
|
||||||
),
|
|
||||||
Match.orElse(() => <></>),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Flex direction="row" justify="center" align="center" gap="1">
|
|
||||||
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/query")({
|
|
||||||
component: Component.withRuntime(ResultView, runtime.context)
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://thila.dev/effect-view">
|
<a href="https://thila.dev/effect-view">
|
||||||
<img src="https://github.com/Thiladev/effect-view/raw/master/packages/docs/static/img/logo.svg" width="104" height="104" alt="Effect View logo" />
|
<img src="../docs/static/img/logo.svg" width="104" height="104" alt="Effect View logo" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -136,6 +136,9 @@ Effect View is renderer-independent and does not require `react-dom`.
|
|||||||
|
|
||||||
Effect View is currently beta software. The main APIs are available, but
|
Effect View is currently beta software. The main APIs are available, but
|
||||||
breaking changes and rough edges are still possible before a stable release.
|
breaking changes and rough edges are still possible before a stable release.
|
||||||
|
React Fast Refresh does not yet recognize Effect View component definitions, so
|
||||||
|
changes to them currently require a page reload; ordinary React components are
|
||||||
|
unaffected (currently being worked on).
|
||||||
|
|
||||||
Issues, ideas, and contributions are welcome on
|
Issues, ideas, and contributions are welcome on
|
||||||
[GitHub](https://github.com/Thiladev/effect-view).
|
[GitHub](https://github.com/Thiladev/effect-view).
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "effect-fc-next",
|
||||||
|
"description": "Write React function components with Effect",
|
||||||
|
"version": "0.1.0-beta.0",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"./README.md",
|
||||||
|
"./dist"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"url": "git+https://github.com/Thiladev/effect-fc.git"
|
||||||
|
},
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./*": [
|
||||||
|
{
|
||||||
|
"types": "./dist/*/index.d.ts",
|
||||||
|
"default": "./dist/*/index.js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"types": "./dist/*.d.ts",
|
||||||
|
"default": "./dist/*.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"lint:tsc": "tsc --noEmit",
|
||||||
|
"lint:biome": "biome lint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"pack": "npm pack",
|
||||||
|
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
|
||||||
|
"clean:dist": "rm -rf dist",
|
||||||
|
"clean:modules": "rm -rf node_modules"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@effect/platform-browser": "4.0.0-beta.98",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"effect": "4.0.0-beta.98",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"effect": "4.0.0-beta.98",
|
||||||
|
"react": "^19.2.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"effect-lens": "^2.0.0-beta.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
|||||||
import * as Component from "./Component.js"
|
import * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
export const AsyncTypeId: unique symbol = Symbol.for("@effect-view/Async/Async")
|
export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
|
||||||
export type AsyncTypeId = typeof AsyncTypeId
|
export type AsyncTypeId = typeof AsyncTypeId
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export type AsyncProps = Omit<React.SuspenseProps, "children">
|
|||||||
export const AsyncPrototype: AsyncPrototype = Object.freeze({
|
export const AsyncPrototype: AsyncPrototype = Object.freeze({
|
||||||
[AsyncTypeId]: AsyncTypeId,
|
[AsyncTypeId]: AsyncTypeId,
|
||||||
|
|
||||||
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Component.Signature>(
|
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Component.Signature>(
|
||||||
this: Component.Component<P, A, E, R, F> & Async,
|
this: Component.Component<P, A, E, R, F> & Async,
|
||||||
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>,
|
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>,
|
||||||
) {
|
) {
|
||||||
@@ -5,7 +5,7 @@ import * as React from "react"
|
|||||||
import * as ScopeRegistry from "./ScopeRegistry.js"
|
import * as ScopeRegistry from "./ScopeRegistry.js"
|
||||||
|
|
||||||
|
|
||||||
export const ComponentTypeId: unique symbol = Symbol.for("@effect-view/Component/Component")
|
export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component")
|
||||||
export type ComponentTypeId = typeof ComponentTypeId
|
export type ComponentTypeId = typeof ComponentTypeId
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,15 +44,10 @@ export declare namespace Component {
|
|||||||
export interface ComponentImpl<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>
|
export interface ComponentImpl<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>
|
||||||
extends Component<P, A, E, R, F>, ComponentImplPrototype<R, F> {}
|
extends Component<P, A, E, R, F>, ComponentImplPrototype<R, F> {}
|
||||||
|
|
||||||
export declare namespace ComponentImpl {
|
|
||||||
export type Any = ComponentImpl<any, any, any, any, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComponentImplPrototype<R, F extends Component.Signature> {
|
export interface ComponentImplPrototype<R, F extends Component.Signature> {
|
||||||
readonly use: Effect.Effect<F, never, Exclude<R, Scope.Scope>>
|
readonly use: Effect.Effect<F, never, Exclude<R, Scope.Scope>>
|
||||||
|
|
||||||
asFunctionComponent(contextRef: React.Ref<Context.Context<Exclude<R, Scope.Scope>>>): F
|
asFunctionComponent(contextRef: React.Ref<Context.Context<Exclude<R, Scope.Scope>>>): F
|
||||||
makeFunctionComponent(contextRef: React.Ref<Context.Context<Exclude<R, Scope.Scope>>>): F
|
|
||||||
setFunctionComponentName(f: F): void
|
setFunctionComponentName(f: F): void
|
||||||
transformFunctionComponent(f: F): F
|
transformFunctionComponent(f: F): F
|
||||||
}
|
}
|
||||||
@@ -60,7 +55,7 @@ export interface ComponentImplPrototype<R, F extends Component.Signature> {
|
|||||||
export const ComponentImplPrototype: ComponentImplPrototype<any, any> = Object.freeze({
|
export const ComponentImplPrototype: ComponentImplPrototype<any, any> = Object.freeze({
|
||||||
get use() { return use(this) },
|
get use() { return use(this) },
|
||||||
|
|
||||||
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
||||||
this: ComponentImpl<P, A, E, R, F>,
|
this: ComponentImpl<P, A, E, R, F>,
|
||||||
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>,
|
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>,
|
||||||
) {
|
) {
|
||||||
@@ -72,13 +67,6 @@ export const ComponentImplPrototype: ComponentImplPrototype<any, any> = Object.f
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
|
||||||
this: ComponentImpl<P, A, E, R, F>,
|
|
||||||
contextRef: React.RefObject<Context.Context<Exclude<R, Scope.Scope>>>,
|
|
||||||
) {
|
|
||||||
return this.makeFunctionComponent(contextRef)
|
|
||||||
},
|
|
||||||
|
|
||||||
setFunctionComponentName<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
setFunctionComponentName<P extends {}, A extends React.ReactNode, E, R, F extends Component.Signature>(
|
||||||
this: ComponentImpl<P, A, E, R, F>,
|
this: ComponentImpl<P, A, E, R, F>,
|
||||||
f: React.FC<P>,
|
f: React.FC<P>,
|
||||||
@@ -416,9 +404,9 @@ export declare namespace make {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an Effect View Component using the same overloads and pipeline composition style as `Effect.fn`.
|
* Creates an Effect-FC Component using the same overloads and pipeline composition style as `Effect.fn`.
|
||||||
*
|
*
|
||||||
* This is the **recommended** approach for defining Effect View components. It provides comprehensive
|
* This is the **recommended** approach for defining Effect-FC components. It provides comprehensive
|
||||||
* support for multiple component definition patterns:
|
* support for multiple component definition patterns:
|
||||||
*
|
*
|
||||||
* - **Generator syntax** (yield* style): Most ergonomic and readable approach for sequential operations
|
* - **Generator syntax** (yield* style): Most ergonomic and readable approach for sequential operations
|
||||||
@@ -491,7 +479,7 @@ export const make: (
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an Effect View Component without automatic distributed tracing.
|
* Creates an Effect-FC Component without automatic distributed tracing.
|
||||||
*
|
*
|
||||||
* This function provides the same API surface as `make`, but does not create automatic tracing spans.
|
* This function provides the same API surface as `make`, but does not create automatic tracing spans.
|
||||||
* It follows the exact same overload structure as `Effect.fnUntraced`.
|
* It follows the exact same overload structure as `Effect.fnUntraced`.
|
||||||
@@ -608,19 +596,19 @@ export const withOptions: {
|
|||||||
))
|
))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps an Effect View Component and converts it into a standard React function component,
|
* Wraps an Effect-FC Component and converts it into a standard React function component,
|
||||||
* serving as an **entrypoint** into an Effect View component hierarchy.
|
* serving as an **entrypoint** into an Effect-FC component hierarchy.
|
||||||
*
|
*
|
||||||
* This is how Effect View components are integrated with the broader React ecosystem,
|
* This is how Effect-FC components are integrated with the broader React ecosystem,
|
||||||
* particularly when:
|
* particularly when:
|
||||||
* - Using client-side routers (TanStack Router, React Router, etc.)
|
* - Using client-side routers (TanStack Router, React Router, etc.)
|
||||||
* - Implementing lazy-loaded or code-split routes
|
* - Implementing lazy-loaded or code-split routes
|
||||||
* - Connecting to third-party libraries expecting standard React components
|
* - Connecting to third-party libraries expecting standard React components
|
||||||
* - Creating component boundaries between Effect View and non-Effect View code
|
* - Creating component boundaries between Effect-FC and non-Effect-FC code
|
||||||
*
|
*
|
||||||
* The Effect runtime is obtained from the provided React Context.
|
* The Effect runtime is obtained from the provided React Context.
|
||||||
*
|
*
|
||||||
* @param self - The Effect View Component to be rendered as a standard React component
|
* @param self - The Effect-FC Component to be rendered as a standard React component
|
||||||
* @param context - React Context providing the Effect Runtime for this component tree.
|
* @param context - React Context providing the Effect Runtime for this component tree.
|
||||||
* Create this using the `ReactRuntime` module.
|
* Create this using the `ReactRuntime` module.
|
||||||
*
|
*
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { type Cause, Context, Effect, Layer, Option, Pipeable, Predicate, PubSub, type Scope } from "effect"
|
||||||
|
|
||||||
|
|
||||||
|
export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
|
||||||
|
export type ErrorObserverTypeId = typeof ErrorObserverTypeId
|
||||||
|
|
||||||
|
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
|
||||||
|
readonly [ErrorObserverTypeId]: ErrorObserverTypeId
|
||||||
|
handle<A, E1, R>(effect: Effect.Effect<A, E1, R>): Effect.Effect<A, E1, R>
|
||||||
|
readonly subscribe: Effect.Effect<PubSub.Subscription<Cause.Cause<E>>, never, Scope.Scope>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorObserver = <E = never>() => Context.Service<ErrorObserver<E>>(
|
||||||
|
"@effect-fc/ErrorObserver/ErrorObserver",
|
||||||
|
)
|
||||||
|
|
||||||
|
export class ErrorObserverImpl<in out E = never> extends Pipeable.Class implements ErrorObserver<E> {
|
||||||
|
readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId
|
||||||
|
|
||||||
|
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<E>>) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
get subscribe(): Effect.Effect<PubSub.Subscription<Cause.Cause<E>>, never, Scope.Scope> {
|
||||||
|
return PubSub.subscribe(this.pubsub)
|
||||||
|
}
|
||||||
|
|
||||||
|
handle<A, E1, R>(effect: Effect.Effect<A, E1, R>): Effect.Effect<A, E1, R> {
|
||||||
|
return Effect.tapCause(effect, cause => Effect.asVoid(
|
||||||
|
PubSub.publish(this.pubsub, cause as unknown as Cause.Cause<E>),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, ErrorObserverTypeId)
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<ErrorObserver> = Layer.effect(ErrorObserver())(
|
||||||
|
Effect.map(PubSub.unbounded<Cause.Cause<never>>(), pubsub => new ErrorObserverImpl(pubsub)),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const handle = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => Effect.flatMap(
|
||||||
|
Effect.serviceOption(ErrorObserver()),
|
||||||
|
Option.match({
|
||||||
|
onSome: observer => observer.handle(effect),
|
||||||
|
onNone: () => effect,
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -6,7 +6,7 @@ import * as Lens from "./Lens.js"
|
|||||||
import * as View from "./View.js"
|
import * as View from "./View.js"
|
||||||
|
|
||||||
|
|
||||||
export const FormTypeId: unique symbol = Symbol.for("@effect-view/Form/Form")
|
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
|
||||||
export type FormTypeId = typeof FormTypeId
|
export type FormTypeId = typeof FormTypeId
|
||||||
|
|
||||||
export interface Form<out P extends readonly PropertyKey[], out A, in out I = A, out ER = never, out EW = never>
|
export interface Form<out P extends readonly PropertyKey[], out A, in out I = A, out ER = never, out EW = never>
|
||||||
@@ -5,7 +5,7 @@ import * as Lens from "./Lens.js"
|
|||||||
import * as View from "./View.js"
|
import * as View from "./View.js"
|
||||||
|
|
||||||
|
|
||||||
export const LensFormTypeId: unique symbol = Symbol.for("@effect-view/Form/LensForm")
|
export const LensFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/LensForm")
|
||||||
export type LensFormTypeId = typeof LensFormTypeId
|
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>
|
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>
|
||||||
@@ -189,13 +189,18 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const thenRun = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never, E = never, R = never>(
|
export declare namespace service {
|
||||||
self: Effect.Effect<LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>, E, R>,
|
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<
|
): Effect.Effect<
|
||||||
LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>,
|
LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>,
|
||||||
E,
|
Schema.SchemaError | TER,
|
||||||
Scope.Scope | R
|
Scope.Scope | RD | RE | TRR | TRW
|
||||||
> => Effect.tap(
|
> => Effect.tap(
|
||||||
self,
|
make(options),
|
||||||
form => Effect.forkScoped(form.run),
|
form => Effect.forkScoped(form.run),
|
||||||
)
|
)
|
||||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
|||||||
import type * as Component from "./Component.js"
|
import type * as Component from "./Component.js"
|
||||||
|
|
||||||
|
|
||||||
export const MemoizedTypeId: unique symbol = Symbol.for("@effect-view/Memoized/Memoized")
|
export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
|
||||||
export type MemoizedTypeId = typeof MemoizedTypeId
|
export type MemoizedTypeId = typeof MemoizedTypeId
|
||||||
|
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ import * as Lens from "./Lens.js"
|
|||||||
import * as View from "./View.js"
|
import * as View from "./View.js"
|
||||||
|
|
||||||
|
|
||||||
export const MutationTypeId: unique symbol = Symbol.for("@effect-view/Mutation/Mutation")
|
export const MutationTypeId: unique symbol = Symbol.for("@effect-fc/Mutation/Mutation")
|
||||||
export type MutationTypeId = typeof MutationTypeId
|
export type MutationTypeId = typeof MutationTypeId
|
||||||
|
|
||||||
export interface Mutation<in out K, out A, out E = never, in out R = never>
|
export interface Mutation<in out K, out A, out E = never, in out R = never>
|
||||||
+11
-6
@@ -7,7 +7,7 @@ import * as Mutation from "./Mutation.js"
|
|||||||
import * as View from "./View.js"
|
import * as View from "./View.js"
|
||||||
|
|
||||||
|
|
||||||
export const MutationFormTypeId: unique symbol = Symbol.for("@effect-view/Form/MutationForm")
|
export const MutationFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/MutationForm")
|
||||||
export type MutationFormTypeId = typeof MutationFormTypeId
|
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>
|
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>
|
||||||
@@ -196,13 +196,18 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const thenRun = <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never, E = never, R = never>(
|
export declare namespace service {
|
||||||
self: Effect.Effect<MutationForm<A, I, RD, RE, MA, ME, MR>, E, R>,
|
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<
|
): Effect.Effect<
|
||||||
MutationForm<A, I, RD, RE, MA, ME, MR>,
|
MutationForm<A, I, RD, RE, MA, ME, MR>,
|
||||||
E,
|
never,
|
||||||
Scope.Scope | R
|
Scope.Scope | RD | RE | MR
|
||||||
> => Effect.tap(
|
> => Effect.tap(
|
||||||
self,
|
make(options),
|
||||||
form => Effect.forkScoped(form.run),
|
form => Effect.forkScoped(form.run),
|
||||||
)
|
)
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Cause, type Context, Duration, Effect, Equal, type Equivalence, Exit, Fiber, Function, Option, Pipeable, Predicate, PubSub, Ref, type Schedule, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
|
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 { AsyncResult } from "effect/unstable/reactivity"
|
||||||
import * as Lens from "./Lens.js"
|
import * as Lens from "./Lens.js"
|
||||||
import * as QueryClient from "./QueryClient.js"
|
import * as QueryClient from "./QueryClient.js"
|
||||||
import * as View from "./View.js"
|
import * as View from "./View.js"
|
||||||
|
|
||||||
|
|
||||||
export const QueryTypeId: unique symbol = Symbol.for("@effect-view/Query/Query")
|
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
|
||||||
export type QueryTypeId = typeof QueryTypeId
|
export type QueryTypeId = typeof QueryTypeId
|
||||||
|
|
||||||
export interface Query<in out K, out A, out E = never, in out R = never>
|
export interface Query<in out K, out A, out E = never, in out R = never>
|
||||||
@@ -410,52 +410,17 @@ export const make = Effect.fnUntraced(function* <K, A, E = never, R = never>(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const thenRun = <K, A, E = never, R = never, E2 = never, R2 = never>(
|
export const service = <K, A, E = never, R = never>(
|
||||||
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
|
options: make.Options<K, A, E, R>
|
||||||
): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | R2> => Effect.tap(
|
): Effect.Effect<
|
||||||
self,
|
Query<K, A, E, R>,
|
||||||
|
Cause.NoSuchElementError,
|
||||||
|
Scope.Scope | QueryClient.QueryClient | R
|
||||||
|
> => Effect.tap(
|
||||||
|
make(options),
|
||||||
query => Effect.forkScoped(query.run),
|
query => Effect.forkScoped(query.run),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Refreshes the Query on a schedule and returns it.
|
|
||||||
*
|
|
||||||
* @example Refresh every five minutes, starting after five minutes
|
|
||||||
* ```ts
|
|
||||||
* yield* Query.make(options).pipe(
|
|
||||||
* Query.thenRun,
|
|
||||||
* Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
|
|
||||||
* )
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Refresh at most three times
|
|
||||||
* ```ts
|
|
||||||
* yield* Query.make(options).pipe(
|
|
||||||
* Query.thenRun,
|
|
||||||
* Query.withScheduledRefresh(
|
|
||||||
* Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 })),
|
|
||||||
* ),
|
|
||||||
* )
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const withScheduledRefresh: {
|
|
||||||
<Output, Error, Env>(
|
|
||||||
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
|
|
||||||
): <K, A, E, R, E2, R2>(
|
|
||||||
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
|
|
||||||
) => Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | Env | R2>
|
|
||||||
<K, A, E, R, E2, R2, Output, Error, Env>(
|
|
||||||
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
|
|
||||||
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
|
|
||||||
): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | Env | R2>
|
|
||||||
} = Function.dual(2, <K, A, E, R, E2, R2, Output, Error, Env>(
|
|
||||||
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
|
|
||||||
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
|
|
||||||
) => Effect.tap(
|
|
||||||
self,
|
|
||||||
query => Effect.forkScoped(Effect.schedule(query.refresh, schedule)),
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
export class QueryStateLens<in out K, in out A, in out E = never>
|
export class QueryStateLens<in out K, in out A, in out E = never>
|
||||||
extends Lens.LensImpl<QueryState<K, A, E>, never, never, never, never> {
|
extends Lens.LensImpl<QueryState<K, A, E>, never, never, never, never> {
|
||||||
+13
-9
@@ -4,7 +4,7 @@ import * as Lens from "./Lens.js"
|
|||||||
import type * as View from "./View.js"
|
import type * as View from "./View.js"
|
||||||
|
|
||||||
|
|
||||||
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientService")
|
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService")
|
||||||
export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
|
export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
|
||||||
|
|
||||||
export interface QueryClientService extends Pipeable.Pipeable {
|
export interface QueryClientService extends Pipeable.Pipeable {
|
||||||
@@ -27,7 +27,7 @@ export interface QueryClientService extends Pipeable.Pipeable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class QueryClient extends Context.Service<QueryClient, QueryClientService>()(
|
export class QueryClient extends Context.Service<QueryClient, QueryClientService>()(
|
||||||
"@effect-view/QueryClient/QueryClient"
|
"@effect-fc/QueryClient/QueryClient"
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
export class QueryClientServiceImpl
|
export class QueryClientServiceImpl
|
||||||
@@ -115,17 +115,21 @@ export const make = Effect.fnUntraced(function* (
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const thenRun = <E = never, R = never>(
|
export declare namespace service {
|
||||||
self: Effect.Effect<QueryClientService, E, R>,
|
export interface Options extends make.Options {}
|
||||||
): Effect.Effect<QueryClientService, E, Scope.Scope | R> => Effect.tap(
|
}
|
||||||
self,
|
|
||||||
|
export const service = (
|
||||||
|
options?: service.Options
|
||||||
|
): Effect.Effect<QueryClientService, Cause.NoSuchElementError, Scope.Scope> => Effect.tap(
|
||||||
|
make(options),
|
||||||
client => Effect.forkScoped(client.run),
|
client => Effect.forkScoped(client.run),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const layer = (options?: make.Options) => Layer.effect(QueryClient, thenRun(make(options)))
|
export const layer = (options?: service.Options) => Layer.effect(QueryClient, service(options))
|
||||||
|
|
||||||
|
|
||||||
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey")
|
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheKey")
|
||||||
export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId
|
export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId
|
||||||
|
|
||||||
export class QueryClientCacheKey
|
export class QueryClientCacheKey
|
||||||
@@ -151,7 +155,7 @@ implements Pipeable.Pipeable, Equal.Equal {
|
|||||||
export const isQueryClientCacheKey = (u: unknown): u is QueryClientCacheKey => Predicate.hasProperty(u, QueryClientCacheKeyTypeId)
|
export const isQueryClientCacheKey = (u: unknown): u is QueryClientCacheKey => Predicate.hasProperty(u, QueryClientCacheKeyTypeId)
|
||||||
|
|
||||||
|
|
||||||
export const QueryClientCacheEntryTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheEntry")
|
export const QueryClientCacheEntryTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheEntry")
|
||||||
export type QueryClientCacheEntryTypeId = typeof QueryClientCacheEntryTypeId
|
export type QueryClientCacheEntryTypeId = typeof QueryClientCacheEntryTypeId
|
||||||
|
|
||||||
export class QueryClientCacheEntry
|
export class QueryClientCacheEntry
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import * as Component from "./Component.js"
|
|||||||
import * as ScopeRegistry from "./ScopeRegistry.js"
|
import * as ScopeRegistry from "./ScopeRegistry.js"
|
||||||
|
|
||||||
|
|
||||||
export const ReactRuntimeTypeId: unique symbol = Symbol.for("@effect-view/ReactRuntime/ReactRuntime")
|
export const ReactRuntimeTypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime")
|
||||||
export type ReactRuntimeTypeId = typeof ReactRuntimeTypeId
|
export type ReactRuntimeTypeId = typeof ReactRuntimeTypeId
|
||||||
|
|
||||||
export interface ReactRuntime<R, ER> {
|
export interface ReactRuntime<R, ER> {
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Async from "./Async.js"
|
export * as Async from "./Async.js"
|
||||||
export * as Component from "./Component.js"
|
export * as Component from "./Component.js"
|
||||||
|
export * as ErrorObserver from "./ErrorObserver.js"
|
||||||
export * as Form from "./Form.js"
|
export * as Form from "./Form.js"
|
||||||
export * as Lens from "./Lens.js"
|
export * as Lens from "./Lens.js"
|
||||||
export * as LensForm from "./LensForm.js"
|
export * as LensForm from "./LensForm.js"
|
||||||
@@ -10,7 +11,6 @@ export * as PubSub from "./PubSub.js"
|
|||||||
export * as Query from "./Query.js"
|
export * as Query from "./Query.js"
|
||||||
export * as QueryClient from "./QueryClient.js"
|
export * as QueryClient from "./QueryClient.js"
|
||||||
export * as ReactRuntime from "./ReactRuntime.js"
|
export * as ReactRuntime from "./ReactRuntime.js"
|
||||||
export * as Refreshable from "./Refreshable.js"
|
|
||||||
export * as ScopeRegistry from "./ScopeRegistry.js"
|
export * as ScopeRegistry from "./ScopeRegistry.js"
|
||||||
export * as SetStateAction from "./SetStateAction.js"
|
export * as SetStateAction from "./SetStateAction.js"
|
||||||
export * as Stream from "./Stream.js"
|
export * as Stream from "./Stream.js"
|
||||||
+4
-4
@@ -2,10 +2,10 @@ import { act, fireEvent, render, screen } from "@testing-library/react"
|
|||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { describe, expect, it, vi } from "vitest"
|
import { describe, expect, it, vi } from "vitest"
|
||||||
import * as Async from "./Async.js"
|
import * as Async from "../src/Async.js"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "../src/Component.js"
|
||||||
import * as Memoized from "./Memoized.js"
|
import * as Memoized from "../src/Memoized.js"
|
||||||
import * as ReactRuntime from "./ReactRuntime.js"
|
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||||
|
|
||||||
describe("Async", () => {
|
describe("Async", () => {
|
||||||
it("does not rerun for an unrelated parent state update", async () => {
|
it("does not rerun for an unrelated parent state update", async () => {
|
||||||
+8
-51
@@ -2,10 +2,9 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
|||||||
import { Context, Effect, HashMap, Layer, SubscriptionRef } from "effect"
|
import { Context, Effect, HashMap, Layer, SubscriptionRef } from "effect"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "../src/Component.js"
|
||||||
import * as ReactRuntime from "./ReactRuntime.js"
|
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||||
import * as Refreshable from "./Refreshable.js"
|
import * as ScopeRegistry from "../src/ScopeRegistry.js"
|
||||||
import * as ScopeRegistry from "./ScopeRegistry.js"
|
|
||||||
|
|
||||||
|
|
||||||
class ValueService extends Context.Service<ValueService, { readonly value: string }>()("ValueService") {}
|
class ValueService extends Context.Service<ValueService, { readonly value: string }>()("ValueService") {}
|
||||||
@@ -277,7 +276,6 @@ describe("Component", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await screen.findByText("first")
|
await screen.findByText("first")
|
||||||
vi.useFakeTimers()
|
|
||||||
|
|
||||||
view.rerender(
|
view.rerender(
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
@@ -285,19 +283,16 @@ describe("Component", () => {
|
|||||||
</runtime.context.Provider>
|
</runtime.context.Provider>
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByText("second")).toBeTruthy()
|
await screen.findByText("second")
|
||||||
expect(cleanup).not.toHaveBeenCalled()
|
expect(cleanup).not.toHaveBeenCalled()
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(19)
|
await new Promise(resolve => setTimeout(resolve, 5))
|
||||||
expect(cleanup).not.toHaveBeenCalled()
|
expect(cleanup).not.toHaveBeenCalled()
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1)
|
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"), { timeout: 100 })
|
||||||
expect(cleanup).toHaveBeenCalledWith("first")
|
|
||||||
|
|
||||||
view.unmount()
|
view.unmount()
|
||||||
await vi.advanceTimersByTimeAsync(20)
|
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"), { timeout: 100 })
|
||||||
expect(cleanup).toHaveBeenCalledWith("second")
|
|
||||||
vi.useRealTimers()
|
|
||||||
await runtime.runtime.dispose()
|
await runtime.runtime.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -316,7 +311,7 @@ describe("Component", () => {
|
|||||||
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
|
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return <button type="button" onClick={() => setCount(value => value + 1)}>{`${service.value}:${count}`}</button>
|
return <button onClick={() => setCount(value => value + 1)}>{`${service.value}:${count}`}</button>
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Component.withOptions({
|
Component.withOptions({
|
||||||
nonReactiveTags: [...Component.defaultOptions.nonReactiveTags, ValueService],
|
nonReactiveTags: [...Component.defaultOptions.nonReactiveTags, ValueService],
|
||||||
@@ -534,42 +529,4 @@ describe("Component", () => {
|
|||||||
await runtime.runtime.dispose()
|
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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
+8
-14
@@ -2,9 +2,9 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
|||||||
import { Effect, Layer, SubscriptionRef } from "effect"
|
import { Effect, Layer, SubscriptionRef } from "effect"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "../src/Component.js"
|
||||||
import * as Lens from "./Lens.js"
|
import * as Lens from "../src/Lens.js"
|
||||||
import * as ReactRuntime from "./ReactRuntime.js"
|
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||||
|
|
||||||
|
|
||||||
const makeRuntime = async () => {
|
const makeRuntime = async () => {
|
||||||
@@ -18,12 +18,6 @@ const makeRuntime = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectDefined = <A,>(value: A | undefined): A => {
|
|
||||||
if (value === undefined)
|
|
||||||
throw new Error("Expected value to be defined")
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Lens", () => {
|
describe("Lens", () => {
|
||||||
it("useState stays in sync with lens updates in both directions", async () => {
|
it("useState stays in sync with lens updates in both directions", async () => {
|
||||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||||
@@ -36,7 +30,7 @@ describe("Lens", () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>{value}</div>
|
<div>{value}</div>
|
||||||
<button type="button" onClick={() => setValue(previous => previous + 1)}>increment</button>
|
<button onClick={() => setValue(previous => previous + 1)}>increment</button>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
@@ -108,7 +102,7 @@ describe("Lens", () => {
|
|||||||
lens = reactLens
|
lens = reactLens
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return <button type="button" onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
|
return <button onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Component.withContext(runtime.context)
|
Component.withContext(runtime.context)
|
||||||
)
|
)
|
||||||
@@ -124,7 +118,7 @@ describe("Lens", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "hello" }))
|
fireEvent.click(screen.getByRole("button", { name: "hello" }))
|
||||||
await screen.findByText("hello!")
|
await screen.findByText("hello!")
|
||||||
await waitFor(async () => expect(await Effect.runPromise(Lens.get(expectDefined(lens)))).toBe("hello!"))
|
await waitFor(async () => expect(await Effect.runPromise(Lens.get(lens!))).toBe("hello!"))
|
||||||
|
|
||||||
view.unmount()
|
view.unmount()
|
||||||
await dispose()
|
await dispose()
|
||||||
@@ -158,11 +152,11 @@ describe("Lens", () => {
|
|||||||
await screen.findByText("first")
|
await screen.findByText("first")
|
||||||
await waitFor(() => expect(lens).toBeDefined())
|
await waitFor(() => expect(lens).toBeDefined())
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(expectDefined(lens), { id: 1, label: "ignored" }))
|
await Effect.runPromise(Lens.set(lens!, { id: 1, label: "ignored" }))
|
||||||
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||||
expect(screen.queryByText("ignored")).toBeNull()
|
expect(screen.queryByText("ignored")).toBeNull()
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(expectDefined(lens), { id: 2, label: "updated" }))
|
await Effect.runPromise(Lens.set(lens!, { id: 2, label: "updated" }))
|
||||||
await screen.findByText("updated")
|
await screen.findByText("updated")
|
||||||
|
|
||||||
view.unmount()
|
view.unmount()
|
||||||
+6
-50
@@ -1,10 +1,9 @@
|
|||||||
import { Effect, Schedule, type Scope, Stream } from "effect"
|
import { Effect, type Scope, Stream } from "effect"
|
||||||
import { TestClock } from "effect/testing"
|
|
||||||
import { AsyncResult } from "effect/unstable/reactivity"
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import * as Query from "./Query.js"
|
import * as Query from "../src/Query.js"
|
||||||
import * as QueryClient from "./QueryClient.js"
|
import * as QueryClient from "../src/QueryClient.js"
|
||||||
import * as View from "./View.js"
|
import * as View from "../src/View.js"
|
||||||
|
|
||||||
|
|
||||||
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
|
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
|
||||||
@@ -80,49 +79,6 @@ describe("Query", () => {
|
|||||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("withScheduledRefresh lets the Schedule control the first refresh", async () => {
|
|
||||||
let calls = 0
|
|
||||||
const key = staticKey<readonly [number]>([1])
|
|
||||||
|
|
||||||
const result = await runQueryTest(Effect.gen(function*() {
|
|
||||||
const query = yield* Query.make({
|
|
||||||
key,
|
|
||||||
f: () => Effect.sync(() => {
|
|
||||||
calls += 1
|
|
||||||
return calls
|
|
||||||
}),
|
|
||||||
staleTime: "0 millis",
|
|
||||||
}).pipe(
|
|
||||||
Query.thenRun,
|
|
||||||
Query.withScheduledRefresh(
|
|
||||||
Schedule.spaced("1 second").pipe(
|
|
||||||
Schedule.upTo({ times: 1 }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* TestClock.adjust("999 millis")
|
|
||||||
const beforeInterval = calls
|
|
||||||
|
|
||||||
yield* TestClock.adjust("1 millis")
|
|
||||||
const afterFirstInterval = calls
|
|
||||||
|
|
||||||
return {
|
|
||||||
isQuery: Query.isQuery(query),
|
|
||||||
beforeInterval,
|
|
||||||
afterFirstInterval,
|
|
||||||
}
|
|
||||||
}).pipe(
|
|
||||||
Effect.provide(TestClock.layer()),
|
|
||||||
))
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
isQuery: true,
|
|
||||||
beforeInterval: 1,
|
|
||||||
afterFirstInterval: 2,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
|
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
|
||||||
let calls = 0
|
let calls = 0
|
||||||
const key = staticKey<readonly [number]>([1])
|
const key = staticKey<readonly [number]>([1])
|
||||||
@@ -180,14 +136,14 @@ describe("Query", () => {
|
|||||||
const key = staticKey<readonly [number]>([1])
|
const key = staticKey<readonly [number]>([1])
|
||||||
|
|
||||||
const effect = Effect.gen(function*() {
|
const effect = Effect.gen(function*() {
|
||||||
const query = yield* Query.make({
|
const query = yield* Query.service({
|
||||||
key,
|
key,
|
||||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||||
calls += 1
|
calls += 1
|
||||||
return `value:${id}:${calls}`
|
return `value:${id}:${calls}`
|
||||||
}),
|
}),
|
||||||
staleTime: "1 minute",
|
staleTime: "1 minute",
|
||||||
}).pipe(Query.thenRun)
|
})
|
||||||
|
|
||||||
const latestFinalState = yield* Effect.sleep("1 millis").pipe(
|
const latestFinalState = yield* Effect.sleep("1 millis").pipe(
|
||||||
Effect.andThen(View.get(query.latestFinalState)),
|
Effect.andThen(View.get(query.latestFinalState)),
|
||||||
@@ -2,9 +2,9 @@ import { render, screen, waitFor } from "@testing-library/react"
|
|||||||
import { Effect, Layer, SubscriptionRef } from "effect"
|
import { Effect, Layer, SubscriptionRef } from "effect"
|
||||||
import { Lens } from "effect-lens"
|
import { Lens } from "effect-lens"
|
||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "../src/Component.js"
|
||||||
import * as ReactRuntime from "./ReactRuntime.js"
|
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||||
import * as View from "./View.js"
|
import * as View from "../src/View.js"
|
||||||
|
|
||||||
|
|
||||||
const makeRuntime = async () => {
|
const makeRuntime = async () => {
|
||||||
@@ -36,4 +36,5 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"include": ["./src"],
|
"include": ["./src"],
|
||||||
|
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import { defineConfig } from "vitest/config"
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
include: ["./src/**/*.test.ts?(x)"],
|
include: ["test/**/*.test.ts?(x)"],
|
||||||
setupFiles: ["./src/setup-tests.ts"],
|
setupFiles: ["test/setup.ts"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome.
|
This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome.
|
||||||
|
|
||||||
Documentation is currently being written. In the meantime, you can take a look at the `packages/effect-fc-example` directory.
|
Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
|
||||||
|
|
||||||
## Peer dependencies
|
## Peer dependencies
|
||||||
- `effect` 3.19+
|
- `effect` 3.19+
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "effect-fc",
|
"name": "effect-fc",
|
||||||
"description": "Write React function components with Effect",
|
"description": "Write React function components with Effect",
|
||||||
"version": "0.3.1",
|
"version": "0.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"./README.md",
|
"./README.md",
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"url": "git+https://github.com/Thiladev/effect-view.git"
|
"url": "git+https://github.com/Thiladev/effect-fc.git"
|
||||||
},
|
},
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -29,12 +29,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint:tsc": "tsc -b --noEmit",
|
"build": "tsc",
|
||||||
|
"lint:tsc": "tsc --noEmit",
|
||||||
"lint:biome": "biome lint",
|
"lint:biome": "biome lint",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"build": "tsc -b tsconfig.build.json",
|
|
||||||
"pack": "npm pack",
|
"pack": "npm pack",
|
||||||
"clean:cache": "rm -rf .turbo *.tsbuildinfo",
|
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
|
||||||
"clean:dist": "rm -rf dist",
|
"clean:dist": "rm -rf dist",
|
||||||
"clean:modules": "rm -rf node_modules"
|
"clean:modules": "rm -rf node_modules"
|
||||||
},
|
},
|
||||||
@@ -50,6 +50,6 @@
|
|||||||
"react": "^19.2.0"
|
"react": "^19.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect-lens": "^0.2.3"
|
"effect-lens": "^0.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-10
@@ -2,8 +2,8 @@ import { render, screen, waitFor } from "@testing-library/react"
|
|||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "../src/Component.js"
|
||||||
import * as ReactRuntime from "./ReactRuntime.js"
|
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||||
|
|
||||||
|
|
||||||
class ValueService extends Context.Tag("ValueService")<ValueService, { readonly value: string }>() {}
|
class ValueService extends Context.Tag("ValueService")<ValueService, { readonly value: string }>() {}
|
||||||
@@ -273,7 +273,6 @@ describe("Component", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await screen.findByText("first")
|
await screen.findByText("first")
|
||||||
vi.useFakeTimers()
|
|
||||||
|
|
||||||
view.rerender(
|
view.rerender(
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
<runtime.context.Provider value={effectRuntime}>
|
||||||
@@ -281,19 +280,16 @@ describe("Component", () => {
|
|||||||
</runtime.context.Provider>
|
</runtime.context.Provider>
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByText("second")).toBeTruthy()
|
await screen.findByText("second")
|
||||||
expect(cleanup).not.toHaveBeenCalled()
|
expect(cleanup).not.toHaveBeenCalled()
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(19)
|
await new Promise(resolve => setTimeout(resolve, 5))
|
||||||
expect(cleanup).not.toHaveBeenCalled()
|
expect(cleanup).not.toHaveBeenCalled()
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1)
|
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"), { timeout: 100 })
|
||||||
expect(cleanup).toHaveBeenCalledWith("first")
|
|
||||||
|
|
||||||
view.unmount()
|
view.unmount()
|
||||||
await vi.advanceTimersByTimeAsync(20)
|
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"), { timeout: 100 })
|
||||||
expect(cleanup).toHaveBeenCalledWith("second")
|
|
||||||
vi.useRealTimers()
|
|
||||||
await Effect.runPromise(runtime.runtime.disposeEffect)
|
await Effect.runPromise(runtime.runtime.disposeEffect)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2,9 +2,9 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
|||||||
import { Effect, Layer, SubscriptionRef } from "effect"
|
import { Effect, Layer, SubscriptionRef } from "effect"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "../src/Component.js"
|
||||||
import * as Lens from "./Lens.js"
|
import * as Lens from "../src/Lens.js"
|
||||||
import * as ReactRuntime from "./ReactRuntime.js"
|
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||||
|
|
||||||
|
|
||||||
const makeRuntime = async () => {
|
const makeRuntime = async () => {
|
||||||
@@ -18,12 +18,6 @@ const makeRuntime = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectDefined = <A,>(value: A | undefined): A => {
|
|
||||||
if (value === undefined)
|
|
||||||
throw new Error("Expected value to be defined")
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Lens", () => {
|
describe("Lens", () => {
|
||||||
it("useState stays in sync with lens updates in both directions", async () => {
|
it("useState stays in sync with lens updates in both directions", async () => {
|
||||||
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
const { runtime, effectRuntime, dispose } = await makeRuntime()
|
||||||
@@ -36,7 +30,7 @@ describe("Lens", () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>{value}</div>
|
<div>{value}</div>
|
||||||
<button type="button" onClick={() => setValue(previous => previous + 1)}>increment</button>
|
<button onClick={() => setValue(previous => previous + 1)}>increment</button>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
@@ -108,7 +102,7 @@ describe("Lens", () => {
|
|||||||
lens = reactLens
|
lens = reactLens
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return <button type="button" onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
|
return <button onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Component.withRuntime(runtime.context)
|
Component.withRuntime(runtime.context)
|
||||||
)
|
)
|
||||||
@@ -124,7 +118,7 @@ describe("Lens", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "hello" }))
|
fireEvent.click(screen.getByRole("button", { name: "hello" }))
|
||||||
await screen.findByText("hello!")
|
await screen.findByText("hello!")
|
||||||
await waitFor(async () => expect(await Effect.runPromise(Lens.get(expectDefined(lens)))).toBe("hello!"))
|
await waitFor(async () => expect(await Effect.runPromise(Lens.get(lens!))).toBe("hello!"))
|
||||||
|
|
||||||
view.unmount()
|
view.unmount()
|
||||||
await dispose()
|
await dispose()
|
||||||
@@ -158,11 +152,11 @@ describe("Lens", () => {
|
|||||||
await screen.findByText("first")
|
await screen.findByText("first")
|
||||||
await waitFor(() => expect(lens).toBeDefined())
|
await waitFor(() => expect(lens).toBeDefined())
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(expectDefined(lens), { id: 1, label: "ignored" }))
|
await Effect.runPromise(Lens.set(lens!, { id: 1, label: "ignored" }))
|
||||||
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
|
||||||
expect(screen.queryByText("ignored")).toBeNull()
|
expect(screen.queryByText("ignored")).toBeNull()
|
||||||
|
|
||||||
await Effect.runPromise(Lens.set(expectDefined(lens), { id: 2, label: "updated" }))
|
await Effect.runPromise(Lens.set(lens!, { id: 2, label: "updated" }))
|
||||||
await screen.findByText("updated")
|
await screen.findByText("updated")
|
||||||
|
|
||||||
view.unmount()
|
view.unmount()
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Effect, Option, type Scope, Stream } from "effect"
|
import { Effect, Option, type Scope, Stream } from "effect"
|
||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import * as Query from "./Query.js"
|
import * as Query from "../src/Query.js"
|
||||||
import * as QueryClient from "./QueryClient.js"
|
import * as QueryClient from "../src/QueryClient.js"
|
||||||
import * as Result from "./Result.js"
|
import * as Result from "../src/Result.js"
|
||||||
|
|
||||||
|
|
||||||
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
|
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
|
||||||
+3
-3
@@ -2,9 +2,9 @@ import { render, screen, waitFor } from "@testing-library/react"
|
|||||||
import { Effect, Fiber, Layer, Stream, SubscriptionRef } from "effect"
|
import { Effect, Fiber, Layer, Stream, SubscriptionRef } from "effect"
|
||||||
import { Lens } from "effect-lens"
|
import { Lens } from "effect-lens"
|
||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "../src/Component.js"
|
||||||
import * as ReactRuntime from "./ReactRuntime.js"
|
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||||
import * as Subscribable from "./Subscribable.js"
|
import * as Subscribable from "../src/Subscribable.js"
|
||||||
|
|
||||||
|
|
||||||
const makeRuntime = async () => {
|
const makeRuntime = async () => {
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"exclude": ["**/setup-tests.ts", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"]
|
|
||||||
}
|
|
||||||
@@ -36,4 +36,5 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"include": ["./src"],
|
"include": ["./src"],
|
||||||
|
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ import { defineConfig } from "vitest/config"
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
include: ["./src/**/*.test.ts?(x)"],
|
include: ["test/**/*.test.ts?(x)"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "effect-view",
|
|
||||||
"description": "Write React function components with Effect",
|
|
||||||
"version": "0.1.1",
|
|
||||||
"type": "module",
|
|
||||||
"files": [
|
|
||||||
"./README.md",
|
|
||||||
"./dist"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"repository": {
|
|
||||||
"url": "git+https://github.com/Thiladev/effect-view.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.102",
|
|
||||||
"@testing-library/react": "^16.3.0",
|
|
||||||
"effect": "4.0.0-beta.102",
|
|
||||||
"jsdom": "^26.1.0",
|
|
||||||
"vitest": "^3.2.4"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "^19.2.0",
|
|
||||||
"effect": "4.0.0-beta.102",
|
|
||||||
"react": "^19.2.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@standard-schema/spec": "^1.1.0",
|
|
||||||
"effect-lens": "^2.0.1-beta.102"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"exclude": ["**/setup-tests.ts", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"]
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Effect FC Example</title>
|
<title>Vite + React + TS</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@effect-fc/example",
|
"name": "@effect-fc/example-next",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"lint:tsc": "tsc -b --noEmit",
|
"lint:tsc": "tsc -b --noEmit",
|
||||||
"lint:biome": "biome lint",
|
"lint:biome": "biome lint",
|
||||||
@@ -22,21 +23,18 @@
|
|||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"type-fest": "^5.7.0",
|
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform": "^0.96.1",
|
"@effect/platform-browser": "4.0.0-beta.98",
|
||||||
"@effect/platform-browser": "^0.76.0",
|
|
||||||
"@radix-ui/themes": "^3.3.0",
|
"@radix-ui/themes": "^3.3.0",
|
||||||
"@typed/id": "^0.17.2",
|
"effect": "4.0.0-beta.98",
|
||||||
"effect": "^3.21.2",
|
"effect-fc-next": "workspace:*",
|
||||||
"effect-fc": "workspace:*",
|
|
||||||
"react-icons": "^5.6.0"
|
"react-icons": "^5.6.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"effect": "^3.21.2",
|
"effect": "4.0.0-beta.98",
|
||||||
"react": "^19.2.6"
|
"react": "^19.2.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
|
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
|
||||||
import { Array, Option, Struct } from "effect"
|
import { Array, Option, Struct } from "effect"
|
||||||
import { Component, Form, Subscribable } from "effect-fc"
|
import { Component, Form, View } from "effect-fc-next"
|
||||||
import type * as React from "react"
|
import type * as React from "react"
|
||||||
|
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
|
|||||||
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
|
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
|
||||||
) {
|
) {
|
||||||
const input = yield* Form.useInput(props.form, props)
|
const input = yield* Form.useInput(props.form, props)
|
||||||
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
|
const [issues, isValidating, isCommitting] = yield* View.useAll([
|
||||||
props.form.issues,
|
props.form.issues,
|
||||||
props.form.isValidating,
|
props.form.isValidating,
|
||||||
props.form.isCommitting,
|
props.form.isCommitting,
|
||||||
@@ -29,7 +29,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
|
|||||||
value={input.value}
|
value={input.value}
|
||||||
onChange={e => input.setValue(e.target.value)}
|
onChange={e => input.setValue(e.target.value)}
|
||||||
disabled={isCommitting}
|
disabled={isCommitting}
|
||||||
{...Struct.omit(props, "form")}
|
{...Struct.omit(props, ["form"])}
|
||||||
>
|
>
|
||||||
{isValidating &&
|
{isValidating &&
|
||||||
<TextField.Slot side="right">
|
<TextField.Slot side="right">
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
|
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
|
||||||
import { Array, Option, Struct } from "effect"
|
import { Array, Option, Struct } from "effect"
|
||||||
import { Component, Form, Subscribable } from "effect-fc"
|
import { Component, Form, View } from "effect-fc-next"
|
||||||
import type * as React from "react"
|
import type * as React from "react"
|
||||||
|
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
|
|||||||
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
|
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
|
||||||
) {
|
) {
|
||||||
const input = yield* Form.useOptionalInput(props.form, props)
|
const input = yield* Form.useOptionalInput(props.form, props)
|
||||||
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
|
const [issues, isValidating, isCommitting] = yield* View.useAll([
|
||||||
props.form.issues,
|
props.form.issues,
|
||||||
props.form.isValidating,
|
props.form.isValidating,
|
||||||
props.form.isCommitting,
|
props.form.isCommitting,
|
||||||
@@ -29,7 +29,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
|
|||||||
value={input.value}
|
value={input.value}
|
||||||
onChange={e => input.setValue(e.target.value)}
|
onChange={e => input.setValue(e.target.value)}
|
||||||
disabled={!input.enabled || isCommitting}
|
disabled={!input.enabled || isCommitting}
|
||||||
{...Struct.omit(props, "form", "defaultValue")}
|
{...Struct.omit(props, ["form", "defaultValue"])}
|
||||||
>
|
>
|
||||||
<TextField.Slot side="left">
|
<TextField.Slot side="left">
|
||||||
<Switch
|
<Switch
|
||||||
@@ -1,24 +1,21 @@
|
|||||||
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||||
import { ReactRuntime } from "effect-fc"
|
import { ReactRuntime } from "effect-fc-next"
|
||||||
import { StrictMode } from "react"
|
import { StrictMode } from "react"
|
||||||
import { createRoot } from "react-dom/client"
|
import { createRoot } from "react-dom/client"
|
||||||
import { routeTree } from "./routeTree.gen"
|
import { routeTree } from "./routeTree.gen"
|
||||||
import { runtime } from "./runtime"
|
import { runtime } from "./runtime"
|
||||||
|
|
||||||
|
|
||||||
const router = createRouter({ routeTree })
|
const router = createRouter({ routeTree })
|
||||||
|
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register { router: typeof router }
|
||||||
router: typeof router
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: React entrypoint
|
// biome-ignore lint/style/noNonNullAssertion: the Vite template provides this element
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ReactRuntime.Provider runtime={runtime}>
|
<ReactRuntime.Provider runtime={runtime}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</ReactRuntime.Provider>
|
</ReactRuntime.Provider>
|
||||||
</StrictMode>
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
+57
-118
@@ -9,33 +9,16 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// 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 rootRouteImport } from './routes/__root'
|
||||||
import { Route as ResultRouteImport } from './routes/result'
|
|
||||||
import { Route as QueryRouteImport } from './routes/query'
|
|
||||||
import { Route as FormRouteImport } from './routes/form'
|
|
||||||
import { Route as BlankRouteImport } from './routes/blank'
|
|
||||||
import { Route as AsyncRouteImport } from './routes/async'
|
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as DevMemoRouteImport } from './routes/dev/memo'
|
import { Route as AsyncRouteImport } from './routes/async'
|
||||||
import { Route as DevContextRouteImport } from './routes/dev/context'
|
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 ResultRoute = ResultRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/result',
|
id: '/',
|
||||||
path: '/result',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const QueryRoute = QueryRouteImport.update({
|
|
||||||
id: '/query',
|
|
||||||
path: '/query',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const FormRoute = FormRouteImport.update({
|
|
||||||
id: '/form',
|
|
||||||
path: '/form',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const BlankRoute = BlankRouteImport.update({
|
|
||||||
id: '/blank',
|
|
||||||
path: '/blank',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const AsyncRoute = AsyncRouteImport.update({
|
const AsyncRoute = AsyncRouteImport.update({
|
||||||
@@ -43,19 +26,24 @@ const AsyncRoute = AsyncRouteImport.update({
|
|||||||
path: '/async',
|
path: '/async',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const BlankRoute = BlankRouteImport.update({
|
||||||
id: '/',
|
id: '/blank',
|
||||||
path: '/',
|
path: '/blank',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const DevMemoRoute = DevMemoRouteImport.update({
|
const FormRoute = FormRouteImport.update({
|
||||||
id: '/dev/memo',
|
id: '/form',
|
||||||
path: '/dev/memo',
|
path: '/form',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const DevContextRoute = DevContextRouteImport.update({
|
const LensformRoute = LensformRouteImport.update({
|
||||||
id: '/dev/context',
|
id: '/lensform',
|
||||||
path: '/dev/context',
|
path: '/lensform',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const QueryRoute = QueryRouteImport.update({
|
||||||
|
id: '/query',
|
||||||
|
path: '/query',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
@@ -64,20 +52,16 @@ export interface FileRoutesByFullPath {
|
|||||||
'/async': typeof AsyncRoute
|
'/async': typeof AsyncRoute
|
||||||
'/blank': typeof BlankRoute
|
'/blank': typeof BlankRoute
|
||||||
'/form': typeof FormRoute
|
'/form': typeof FormRoute
|
||||||
|
'/lensform': typeof LensformRoute
|
||||||
'/query': typeof QueryRoute
|
'/query': typeof QueryRoute
|
||||||
'/result': typeof ResultRoute
|
|
||||||
'/dev/context': typeof DevContextRoute
|
|
||||||
'/dev/memo': typeof DevMemoRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/async': typeof AsyncRoute
|
'/async': typeof AsyncRoute
|
||||||
'/blank': typeof BlankRoute
|
'/blank': typeof BlankRoute
|
||||||
'/form': typeof FormRoute
|
'/form': typeof FormRoute
|
||||||
|
'/lensform': typeof LensformRoute
|
||||||
'/query': typeof QueryRoute
|
'/query': typeof QueryRoute
|
||||||
'/result': typeof ResultRoute
|
|
||||||
'/dev/context': typeof DevContextRoute
|
|
||||||
'/dev/memo': typeof DevMemoRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -85,42 +69,15 @@ export interface FileRoutesById {
|
|||||||
'/async': typeof AsyncRoute
|
'/async': typeof AsyncRoute
|
||||||
'/blank': typeof BlankRoute
|
'/blank': typeof BlankRoute
|
||||||
'/form': typeof FormRoute
|
'/form': typeof FormRoute
|
||||||
|
'/lensform': typeof LensformRoute
|
||||||
'/query': typeof QueryRoute
|
'/query': typeof QueryRoute
|
||||||
'/result': typeof ResultRoute
|
|
||||||
'/dev/context': typeof DevContextRoute
|
|
||||||
'/dev/memo': typeof DevMemoRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
|
||||||
| '/'
|
|
||||||
| '/async'
|
|
||||||
| '/blank'
|
|
||||||
| '/form'
|
|
||||||
| '/query'
|
|
||||||
| '/result'
|
|
||||||
| '/dev/context'
|
|
||||||
| '/dev/memo'
|
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
|
||||||
| '/'
|
id: '__root__' | '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
|
||||||
| '/async'
|
|
||||||
| '/blank'
|
|
||||||
| '/form'
|
|
||||||
| '/query'
|
|
||||||
| '/result'
|
|
||||||
| '/dev/context'
|
|
||||||
| '/dev/memo'
|
|
||||||
id:
|
|
||||||
| '__root__'
|
|
||||||
| '/'
|
|
||||||
| '/async'
|
|
||||||
| '/blank'
|
|
||||||
| '/form'
|
|
||||||
| '/query'
|
|
||||||
| '/result'
|
|
||||||
| '/dev/context'
|
|
||||||
| '/dev/memo'
|
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -128,40 +85,17 @@ export interface RootRouteChildren {
|
|||||||
AsyncRoute: typeof AsyncRoute
|
AsyncRoute: typeof AsyncRoute
|
||||||
BlankRoute: typeof BlankRoute
|
BlankRoute: typeof BlankRoute
|
||||||
FormRoute: typeof FormRoute
|
FormRoute: typeof FormRoute
|
||||||
|
LensformRoute: typeof LensformRoute
|
||||||
QueryRoute: typeof QueryRoute
|
QueryRoute: typeof QueryRoute
|
||||||
ResultRoute: typeof ResultRoute
|
|
||||||
DevContextRoute: typeof DevContextRoute
|
|
||||||
DevMemoRoute: typeof DevMemoRoute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/result': {
|
'/': {
|
||||||
id: '/result'
|
id: '/'
|
||||||
path: '/result'
|
path: '/'
|
||||||
fullPath: '/result'
|
fullPath: '/'
|
||||||
preLoaderRoute: typeof ResultRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/query': {
|
|
||||||
id: '/query'
|
|
||||||
path: '/query'
|
|
||||||
fullPath: '/query'
|
|
||||||
preLoaderRoute: typeof QueryRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/form': {
|
|
||||||
id: '/form'
|
|
||||||
path: '/form'
|
|
||||||
fullPath: '/form'
|
|
||||||
preLoaderRoute: typeof FormRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/blank': {
|
|
||||||
id: '/blank'
|
|
||||||
path: '/blank'
|
|
||||||
fullPath: '/blank'
|
|
||||||
preLoaderRoute: typeof BlankRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/async': {
|
'/async': {
|
||||||
@@ -171,25 +105,32 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AsyncRouteImport
|
preLoaderRoute: typeof AsyncRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/': {
|
'/blank': {
|
||||||
id: '/'
|
id: '/blank'
|
||||||
path: '/'
|
path: '/blank'
|
||||||
fullPath: '/'
|
fullPath: '/blank'
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof BlankRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/dev/memo': {
|
'/form': {
|
||||||
id: '/dev/memo'
|
id: '/form'
|
||||||
path: '/dev/memo'
|
path: '/form'
|
||||||
fullPath: '/dev/memo'
|
fullPath: '/form'
|
||||||
preLoaderRoute: typeof DevMemoRouteImport
|
preLoaderRoute: typeof FormRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/dev/context': {
|
'/lensform': {
|
||||||
id: '/dev/context'
|
id: '/lensform'
|
||||||
path: '/dev/context'
|
path: '/lensform'
|
||||||
fullPath: '/dev/context'
|
fullPath: '/lensform'
|
||||||
preLoaderRoute: typeof DevContextRouteImport
|
preLoaderRoute: typeof LensformRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/query': {
|
||||||
|
id: '/query'
|
||||||
|
path: '/query'
|
||||||
|
fullPath: '/query'
|
||||||
|
preLoaderRoute: typeof QueryRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,10 +141,8 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
AsyncRoute: AsyncRoute,
|
AsyncRoute: AsyncRoute,
|
||||||
BlankRoute: BlankRoute,
|
BlankRoute: BlankRoute,
|
||||||
FormRoute: FormRoute,
|
FormRoute: FormRoute,
|
||||||
|
LensformRoute: LensformRoute,
|
||||||
QueryRoute: QueryRoute,
|
QueryRoute: QueryRoute,
|
||||||
ResultRoute: ResultRoute,
|
|
||||||
DevContextRoute: DevContextRoute,
|
|
||||||
DevMemoRoute: DevMemoRoute,
|
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
+5
-1
@@ -7,7 +7,7 @@ import "../index.css"
|
|||||||
|
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: Root
|
component: Root,
|
||||||
})
|
})
|
||||||
|
|
||||||
function Root() {
|
function Root() {
|
||||||
@@ -17,6 +17,10 @@ function Root() {
|
|||||||
<Flex direction="row" justify="center" align="center" gap="2">
|
<Flex direction="row" justify="center" align="center" gap="2">
|
||||||
<Link to="/">Index</Link>
|
<Link to="/">Index</Link>
|
||||||
<Link to="/blank">Blank</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>
|
</Flex>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
+29
-23
@@ -1,30 +1,35 @@
|
|||||||
import { HttpClient } from "@effect/platform"
|
|
||||||
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
|
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { Array, Effect, flow, Option, Schema } from "effect"
|
import { Console, Effect } from "effect"
|
||||||
import { Async, Component, Memoized } from "effect-fc"
|
import { Async, Component, Memoized } from "effect-fc-next"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { runtime } from "@/runtime"
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
|
|
||||||
const Post = Schema.Struct({
|
const fetchPost = (id: number) => Effect.sleep("500 millis").pipe(
|
||||||
userId: Schema.Int,
|
Effect.as({
|
||||||
id: Schema.Int,
|
title: `Post ${id}`,
|
||||||
title: Schema.String,
|
body: `This is the content of post ${id}.`,
|
||||||
body: Schema.String,
|
}),
|
||||||
})
|
)
|
||||||
|
|
||||||
|
|
||||||
interface AsyncFetchPostViewProps {
|
interface AsyncFetchPostViewProps {
|
||||||
readonly id: number
|
readonly id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) {
|
const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*(
|
||||||
const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe(
|
props: AsyncFetchPostViewProps,
|
||||||
Effect.tap(Effect.sleep("500 millis")),
|
) {
|
||||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)),
|
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||||
Effect.andThen(response => response.json),
|
yield* Effect.addFinalizer(() => Console.log("AsyncFetchPostView unmounted"))
|
||||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
yield* Console.log("AsyncFetchPostView mounted")
|
||||||
), [props.id])
|
}))
|
||||||
|
|
||||||
|
const post = yield* Component.useOnChange(
|
||||||
|
() => fetchPost(props.id),
|
||||||
|
[props.id],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -34,10 +39,9 @@ class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(
|
|||||||
)
|
)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Async.async,
|
Async.async,
|
||||||
Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }),
|
Async.withOptions({ defaultFallback: <Text>Loading post...</Text> }),
|
||||||
Memoized.memoized,
|
Memoized.memoized,
|
||||||
) {}
|
)
|
||||||
|
|
||||||
|
|
||||||
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
|
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
|
||||||
const [text, setText] = React.useState("Typing here should not trigger a refetch of the post")
|
const [text, setText] = React.useState("Typing here should not trigger a refetch of the post")
|
||||||
@@ -50,20 +54,22 @@ const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
|
|||||||
<Flex direction="column" align="stretch" gap="2">
|
<Flex direction="column" align="stretch" gap="2">
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
value={text}
|
value={text}
|
||||||
onChange={e => setText(e.currentTarget.value)}
|
onChange={event => setText(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Slider
|
<Slider
|
||||||
value={[id]}
|
value={[id]}
|
||||||
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
|
min={1}
|
||||||
|
max={10}
|
||||||
|
onValueChange={([value]) => setId(value ?? 1)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
|
<AsyncFetchPost id={id} />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Component.withRuntime(runtime.context)
|
Component.withContext(runtime.context),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const Route = createFileRoute("/async")({
|
export const Route = createFileRoute("/async")({
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Button, Container, Flex, Text } from "@radix-ui/themes"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { Console, Effect, Schema } from "effect"
|
||||||
|
import { Component, Form, MutationForm, View } from "effect-fc-next"
|
||||||
|
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
||||||
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
|
|
||||||
|
const RegisterSchema = Schema.Struct({
|
||||||
|
email: Schema.String.check(
|
||||||
|
Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {
|
||||||
|
message: "Enter a valid email address",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
password: Schema.String.check(
|
||||||
|
Schema.isMinLength(5, {
|
||||||
|
message: "Password must be at least 5 characters long",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() {
|
||||||
|
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||||
|
yield* Effect.addFinalizer(() => Console.log("Form route unmounted"))
|
||||||
|
yield* Console.log("Form route mounted")
|
||||||
|
}))
|
||||||
|
|
||||||
|
const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||||
|
const form = yield* MutationForm.service({
|
||||||
|
schema: RegisterSchema,
|
||||||
|
initialEncodedValue: { email: "", password: "" },
|
||||||
|
f: ([value]) => Effect.log(`Registered ${value.email}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
const emailField = Form.focusObjectOn(form, "email")
|
||||||
|
const passwordField = Form.focusObjectOn(form, "password")
|
||||||
|
|
||||||
|
return [form, emailField, passwordField] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
const [canCommit, isCommitting] = yield* View.useAll([
|
||||||
|
form.canCommit,
|
||||||
|
form.isCommitting,
|
||||||
|
])
|
||||||
|
|
||||||
|
const TextFieldFormInput = yield* TextFieldFormInputView.use
|
||||||
|
const runPromise = yield* Component.useRunPromise()
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container width="300">
|
||||||
|
<form onSubmit={event => {
|
||||||
|
event.preventDefault()
|
||||||
|
void runPromise(form.submit)
|
||||||
|
}}>
|
||||||
|
<Flex direction="column" gap="2">
|
||||||
|
<TextFieldFormInput
|
||||||
|
form={emailField}
|
||||||
|
placeholder="Email"
|
||||||
|
debounce="250 millis"
|
||||||
|
/>
|
||||||
|
<TextFieldFormInput
|
||||||
|
form={passwordField}
|
||||||
|
placeholder="Password"
|
||||||
|
type="password"
|
||||||
|
debounce="250 millis"
|
||||||
|
/>
|
||||||
|
<Button disabled={!canCommit || isCommitting}>
|
||||||
|
{isCommitting ? "Submitting…" : "Submit"}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</form>
|
||||||
|
<Text size="2">A MutationForm validates local input, then submits it.</Text>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withContext(runtime.context),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/form")({
|
||||||
|
component: RegisterRouteComponent,
|
||||||
|
})
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Button, Container, Flex, Text, TextField } from "@radix-ui/themes"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { Effect, SubscriptionRef } from "effect"
|
||||||
|
import { Component, Lens, View } from "effect-fc-next"
|
||||||
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
|
|
||||||
|
const TodoRouteComponent = Component.make("TodoRouteView")(function*() {
|
||||||
|
const todosLens = yield* Component.useOnMount(() => Effect.map(
|
||||||
|
SubscriptionRef.make<readonly string[]>([]),
|
||||||
|
Lens.fromSubscriptionRef,
|
||||||
|
))
|
||||||
|
const draftLens = yield* Component.useOnMount(() => Effect.map(
|
||||||
|
SubscriptionRef.make(""),
|
||||||
|
Lens.fromSubscriptionRef,
|
||||||
|
))
|
||||||
|
|
||||||
|
const [todos] = yield* View.useAll([todosLens])
|
||||||
|
const [draft, setDraft] = yield* Lens.useState(draftLens)
|
||||||
|
const runPromise = yield* Component.useRunPromise()
|
||||||
|
|
||||||
|
const addTodo = Lens.update(todosLens, todos =>
|
||||||
|
draft.trim() === ""
|
||||||
|
? todos
|
||||||
|
: [...todos, draft.trim()],
|
||||||
|
).pipe(
|
||||||
|
Effect.andThen(Lens.set(draftLens, "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container width="480">
|
||||||
|
<Flex direction="column" gap="3">
|
||||||
|
<Text size="2">A small Effect v4 todo state example backed by a Lens.</Text>
|
||||||
|
|
||||||
|
<Flex gap="2">
|
||||||
|
<TextField.Root
|
||||||
|
value={draft}
|
||||||
|
onChange={event => setDraft(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={() => void runPromise(addTodo)}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{todos.map(todo => <Text key={todo}>• {todo}</Text>)}
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withContext(runtime.context),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/")({
|
||||||
|
component: TodoRouteComponent,
|
||||||
|
})
|
||||||
+3
-5
@@ -1,7 +1,7 @@
|
|||||||
import { Container, Flex } from "@radix-ui/themes"
|
import { Container, Flex } from "@radix-ui/themes"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { Console, Context, Effect, Layer, Schema, Stream, SubscriptionRef } from "effect"
|
import { Console, Context, Effect, Layer, Schema, Stream, SubscriptionRef } from "effect"
|
||||||
import { Component, Form, Lens, LensForm } from "effect-view"
|
import { Component, Form, Lens, LensForm } from "effect-fc-next"
|
||||||
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
||||||
import { runtime } from "@/runtime"
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
@@ -65,16 +65,14 @@ const UserProfileEditorView = Component.make("UserProfileEditorView")(function*(
|
|||||||
Stream.runForEach(Lens.changes(appState.lens), Console.log)
|
Stream.runForEach(Lens.changes(appState.lens), Console.log)
|
||||||
)
|
)
|
||||||
|
|
||||||
const form = yield* LensForm.make({
|
const form = yield* LensForm.service({
|
||||||
schema: UserProfileSchema,
|
schema: UserProfileSchema,
|
||||||
target: Lens.focusObjectOn(appState.lens, "currentUser"),
|
target: Lens.focusObjectOn(appState.lens, "currentUser"),
|
||||||
initialEncodedValue: {
|
initialEncodedValue: {
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
},
|
},
|
||||||
}).pipe(
|
})
|
||||||
LensForm.thenRun,
|
|
||||||
)
|
|
||||||
|
|
||||||
const emailField = Form.focusObjectOn(form, "email")
|
const emailField = Form.focusObjectOn(form, "email")
|
||||||
const passwordField = Form.focusObjectOn(form, "password")
|
const passwordField = Form.focusObjectOn(form, "password")
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { Effect, Schema, SubscriptionRef } from "effect"
|
||||||
|
import { HttpClient } from "effect/unstable/http"
|
||||||
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
|
import { Component, Lens, Mutation, Query, View } from "effect-fc-next"
|
||||||
|
import { runtime } from "@/runtime"
|
||||||
|
|
||||||
|
|
||||||
|
const Post = Schema.Struct({
|
||||||
|
userId: Schema.Int,
|
||||||
|
id: Schema.Int,
|
||||||
|
title: Schema.String,
|
||||||
|
body: Schema.String,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
interface PostResultViewProps {
|
||||||
|
readonly result: AsyncResult.AsyncResult<typeof Post.Type, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
const PostResultView = (props: PostResultViewProps) => AsyncResult.match(props.result, {
|
||||||
|
onInitial: result => result.waiting
|
||||||
|
? <Text>Loading...</Text>
|
||||||
|
: <Text>No data.</Text>,
|
||||||
|
onFailure: result => <Text>Request failed: { result.cause.toString() }</Text>,
|
||||||
|
onSuccess: result => <>
|
||||||
|
{result.waiting && <Text>Refreshing...</Text>}
|
||||||
|
<Heading>{result.value.title}</Heading>
|
||||||
|
<Text>{result.value.body}</Text>
|
||||||
|
</>,
|
||||||
|
})
|
||||||
|
|
||||||
|
const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
|
||||||
|
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||||
|
const keyLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const))
|
||||||
|
const idLens = Lens.focusTupleAt(keyLens, 1)
|
||||||
|
|
||||||
|
const query = yield* Query.service({
|
||||||
|
key: keyLens,
|
||||||
|
f: ([, id]) => HttpClient.HttpClient.pipe(
|
||||||
|
Effect.tap(Effect.sleep("1 second")),
|
||||||
|
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
||||||
|
Effect.andThen(response => response.json),
|
||||||
|
Effect.andThen(Schema.decodeUnknownEffect(Post)),
|
||||||
|
),
|
||||||
|
staleTime: "10 seconds",
|
||||||
|
})
|
||||||
|
|
||||||
|
const mutation = yield* Mutation.make({
|
||||||
|
f: ([id]: [id: number]) => HttpClient.HttpClient.pipe(
|
||||||
|
Effect.tap(Effect.sleep("1 second")),
|
||||||
|
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
||||||
|
Effect.andThen(response => response.json),
|
||||||
|
Effect.andThen(Schema.decodeUnknownEffect(Post)),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
return [idLens, query, mutation] as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
const [id, setId] = yield* Lens.useState(idLens)
|
||||||
|
const [queryState, mutationState] = yield* View.useAll([query.state, mutation.state])
|
||||||
|
|
||||||
|
const runSync = yield* Component.useRunSync()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Flex direction="column" align="center" gap="2">
|
||||||
|
<Slider
|
||||||
|
value={[id]}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
onValueChange={([value]) => setId(value ?? 1)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PostResultView result={queryState.result} />
|
||||||
|
|
||||||
|
<Flex direction="row" justify="center" align="center" gap="1">
|
||||||
|
<Button onClick={() => runSync(query.refreshView)}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => runSync(query.invalidateCache)}>
|
||||||
|
Invalidate cache
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<PostResultView result={mutationState} />
|
||||||
|
|
||||||
|
<Button onClick={() => runSync(mutation.mutateView([id]))}>
|
||||||
|
Mutate
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withContext(runtime.context),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/query")({
|
||||||
|
component: QueryRouteComponent,
|
||||||
|
})
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { FetchHttpClient } from "@effect/platform"
|
|
||||||
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
|
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
|
||||||
import { DateTime, Layer } from "effect"
|
import { DateTime, Layer } from "effect"
|
||||||
import { ReactRuntime } from "effect-fc"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
import { QueryClient, ReactRuntime } from "effect-fc-next"
|
||||||
|
|
||||||
|
|
||||||
export const AppLive = Layer.empty.pipe(
|
export const layer = Layer.empty.pipe(
|
||||||
|
Layer.provideMerge(QueryClient.layer()),
|
||||||
Layer.provideMerge(DateTime.layerCurrentZoneLocal),
|
Layer.provideMerge(DateTime.layerCurrentZoneLocal),
|
||||||
Layer.provideMerge(Clipboard.layer),
|
Layer.provideMerge(Clipboard.layer),
|
||||||
Layer.provideMerge(Geolocation.layer),
|
Layer.provideMerge(Geolocation.layer),
|
||||||
@@ -12,4 +13,4 @@ export const AppLive = Layer.empty.pipe(
|
|||||||
Layer.provideMerge(FetchHttpClient.layer),
|
Layer.provideMerge(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const runtime = ReactRuntime.make(AppLive)
|
export const runtime = ReactRuntime.make(layer)
|
||||||
@@ -1,10 +1,50 @@
|
|||||||
# Effect View Example
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
Minimal React example for `effect-view`, Effect V4, and `effect-lens@2`.
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
```bash
|
Currently, two official plugins are available:
|
||||||
bun run dev
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||||
|
|
||||||
|
- Configure the top-level `parserOptions` property like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default tseslint.config({
|
||||||
|
languageOptions: {
|
||||||
|
// other options...
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
The counter demonstrates a V4 `SubscriptionRef` exposed as an Effect Lens and
|
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||||
rendered through an Effect View component.
|
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||||
|
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import react from 'eslint-plugin-react'
|
||||||
|
|
||||||
|
export default tseslint.config({
|
||||||
|
// Set the react version
|
||||||
|
settings: { react: { version: '18.3' } },
|
||||||
|
plugins: {
|
||||||
|
// Add the react plugin
|
||||||
|
react,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// other rules...
|
||||||
|
// Enable its recommended rules
|
||||||
|
...react.configs.recommended.rules,
|
||||||
|
...react.configs['jsx-runtime'].rules,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Effect View Example</title>
|
<title>Vite + React + TS</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@effect-view/example",
|
"name": "@effect-fc/example",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"lint:tsc": "tsc -b --noEmit",
|
"lint:tsc": "tsc -b --noEmit",
|
||||||
"lint:biome": "biome lint",
|
"lint:biome": "biome lint",
|
||||||
@@ -14,7 +13,6 @@
|
|||||||
"clean:modules": "rm -rf node_modules"
|
"clean:modules": "rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@effect-view/vite-plugin": "workspace:*",
|
|
||||||
"@tanstack/react-router": "^1.170.10",
|
"@tanstack/react-router": "^1.170.10",
|
||||||
"@tanstack/react-router-devtools": "^1.167.0",
|
"@tanstack/react-router-devtools": "^1.167.0",
|
||||||
"@tanstack/router-plugin": "^1.168.13",
|
"@tanstack/router-plugin": "^1.168.13",
|
||||||
@@ -24,18 +22,21 @@
|
|||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
"type-fest": "^5.7.0",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-browser": "4.0.0-beta.102",
|
"@effect/platform": "^0.96.1",
|
||||||
|
"@effect/platform-browser": "^0.76.0",
|
||||||
"@radix-ui/themes": "^3.3.0",
|
"@radix-ui/themes": "^3.3.0",
|
||||||
"effect": "4.0.0-beta.102",
|
"@typed/id": "^0.17.2",
|
||||||
"effect-view": "workspace:*",
|
"effect": "^3.21.2",
|
||||||
|
"effect-fc": "workspace:*",
|
||||||
"react-icons": "^5.6.0"
|
"react-icons": "^5.6.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"effect": "4.0.0-beta.102",
|
"effect": "^3.21.2",
|
||||||
"react": "^19.2.6"
|
"react": "^19.2.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user