Compare commits

...
Author SHA1 Message Date
renovate-bot 6422db13fd Update docker/setup-buildx-action action to v4
Test build / test-build (pull_request) Successful in 1m11s
Lint / lint (push) Successful in 23s
2026-08-21 15:02:17 +00:00
ThilawynandJulien Valverdé a3119a4479 Add Component.provide and improve mutation state handling (#73)
Publish / publish (push) Successful in 1m56s
Lint / lint (push) Successful in 25s
## Summary

Add mount-lifetime layer provisioning through `Component.provide` and improve mutation state handling.

## Changes

- Add dual-form `Component.provide` support for direct and pipeline usage.
- Build provided layers once per mounted component and release them on unmount.
- Refactor mutation state to retain mutation keys and finalized results.
- Add non-blocking `mutateView` behavior and failure-state handling.
- Update `MutationForm` to use the new mutation state shape.
- Add Component and Mutation tests.
- Update documentation and the lens form example.
- Bump `effect-view` to `0.1.4`.

## Testing

- `bun run --cwd packages/effect-view test`
- 7 test files passed, 32 tests passed.

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #73
2026-08-21 16:54:22 +02:00
ThilawynandJulien Valverdé 44bea41e39 Upgrade effect-view to Effect v4 RC and tsgo (#72)
Publish / publish (push) Successful in 2m0s
Lint / lint (push) Successful in 1m8s
## Summary

- Upgrade Effect and effect-lens from beta to RC versions.
- Replace `@effect/language-service` with `@effect/tsgo`.
- Update TypeScript, VS Code, and package configuration for tsgo.
- Adapt `MutationForm`, `PubSub`, and fiber interruption code to RC APIs.
- Bump `effect-view` to `0.1.3` and update its published file set.
- Update documentation and examples from Effect v4 beta to RC.

## Testing

- `bun run lint:tsc`
- `bun run test` — 59 tests passed

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #72
2026-08-15 01:45:34 +02:00
ThilawynandJulien Valverdé 689af07190 Add debounced form status API and upgrade Effect beta dependencies (#71)
Publish / publish (push) Successful in 3m27s
Lint / lint (push) Successful in 47s
# Add debounced form status API and upgrade Effect beta dependencies

## Summary

- Add `Form.useStatus` for debounced form validation and commit status.
- Expose `isValidating`, `isCommitting`, and `canCommit` as a presentation-friendly status object.
- Support configurable status debounce intervals, defaulting to 250 ms.
- Update form documentation and examples to use `Form.useStatus`.
- Bump `effect-view` to `0.1.2`.
- Upgrade Effect, `@effect/platform-browser`, and `effect-lens` to beta.103.
- Align the example workspace with Effect beta.103.
- Regenerate `bun.lock`.

## Validation

- `bun run lint:tsc`
- `bun run lint:biome`
- `bun run --cwd packages/effect-view test`

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #71
2026-08-04 21:20:51 +02:00
Julien Valverdé 3fef9b1a47 Bump version
Lint / lint (push) Successful in 47s
Publish / publish (push) Successful in 3m26s
2026-07-30 15:45:43 +02:00
ThilawynandJulien Valverdé 2b1c6aeda9 Add explicit lifecycle pipelines and scheduled query refreshes (#68)
Lint / lint (push) Successful in 48s
Publish / publish (push) Has been cancelled
## Summary

- Replace auto-running `service` constructors with explicit `make(...).pipe(thenRun)` lifecycle pipelines.
- Add scoped, schedule-driven Query refreshes with `Query.withScheduledRefresh`.
- Upgrade the Effect v4 stack to beta 102.
- Add `@effect-view/vite-plugin` to the publish workflow.
- Update examples and documentation for the new APIs.

## Breaking changes

The following constructors have been replaced:

- `Query.service(options)` → `Query.make(options).pipe(Query.thenRun)`
- `QueryClient.service(options)` → `QueryClient.make(options).pipe(QueryClient.thenRun)`
- `MutationForm.service(options)` → `MutationForm.make(options).pipe(MutationForm.thenRun)`
- `LensForm.service(options)` → `LensForm.make(options).pipe(LensForm.thenRun)`

This separates object construction from starting its scoped background behavior.

## Scheduled Query refreshes

Queries can now be refreshed with any Effect `Schedule`:

```ts
const query = yield* Query.make(options).pipe(
  Query.thenRun,
  Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
)

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #68
2026-07-30 15:36:49 +02:00
31 changed files with 796 additions and 245 deletions
+52
View File
@@ -0,0 +1,52 @@
# Version control and local agent/editor metadata
.git
.gitea
.github
.agents
.codex
.vscode
# Dependencies and package-manager caches
node_modules
.npm
.yarn
.pnpm-store
.bun
# Build output and task-runner caches
dist
build
out
.turbo
*.tsbuildinfo
.cache
.parcel-cache
.docusaurus
.vite
*.vite*
# Test, coverage, and diagnostic output
coverage
.nyc_output
*.lcov
reports
report.*.json
# Logs and temporary files
*.log
*.pid
*.seed
tmp
temp
*.tmp
*.swp
*.swo
# Local environment and secrets
.env
.env.*
!.env.example
# Package artifacts
*.tgz
*.tar
+3 -1
View File
@@ -8,7 +8,9 @@ jobs:
steps:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Clone repo
with:
bun-version: "1.4"
- name: Checkout
uses: actions/checkout@v6
- name: Install dependencies
run: bun install --frozen-lockfile
+11 -7
View File
@@ -11,8 +11,10 @@ jobs:
steps:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.4"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Container Registry
uses: docker/login-action@v3
with:
@@ -20,7 +22,7 @@ jobs:
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Clone repo
- name: Checkout
uses: actions/checkout@v6
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -40,6 +42,13 @@ jobs:
access: public
token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org
- name: Publish @effect-view/vite-plugin
uses: JS-DevTools/npm-publish@v4
with:
package: packages/vite-plugin
access: public
token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org
- name: Publish effect-fc
uses: JS-DevTools/npm-publish@v4
with:
@@ -48,11 +57,6 @@ jobs:
token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org
- name: Clean before Docker build
run: |
bun clean:cache
bun clean:dist
bun clean:modules
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@v5
+5 -8
View File
@@ -9,14 +9,16 @@ jobs:
steps:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.4"
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "22"
node-version: "24"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Clone repo
- name: Checkout
uses: actions/checkout@v6
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -32,11 +34,6 @@ jobs:
- name: Pack
run: bun pack
- name: Clean before Docker build
run: |
bun clean:cache
bun clean:dist
bun clean:modules
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@v5
+4 -1
View File
@@ -1,5 +1,8 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"js/ts.tsdk.path": "./node_modules/typescript/lib",
"js/ts.tsdk.additionalLocations": ["./node_modules/typescript/bin"],
"js/ts.tsdk.promptToUseWorkspaceVersion": true,
"js/ts.experimental.useTsgo": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit"
}
+3 -3
View File
@@ -1,6 +1,6 @@
FROM oven/bun:1.3.12-debian@sha256:1b709c9dd883fc1af38c210f7ea5222c552a8d470ea73efbd4b8fcfee798a64b AS bun
FROM oven/bun:1.4.0-debian@sha256:5bb0f9be3a1a36a03e27c9a9dd894a3b1ad26657155c7df4dda771e17bf872ef AS bun
FROM node:22.21.1-trixie-slim@sha256:98e1429d1a0b99378b4de43fa385f0746fd6276faf4feeb6104d91f6bad290f9
FROM node:24.19.0-trixie-slim@sha256:0711b541c1c33a8a530ac4f0d391baa9a15b3d804695b1b24a47daa5fb60e74d
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bunx /usr/local/bin/
COPY . /app
WORKDIR /app
@@ -9,4 +9,4 @@ RUN bun install --frozen-lockfile && \
bun run build && \
bun clean:cache && \
bun clean:modules && \
bun install --production --frozen-lockfile
bun install --production --frozen-lockfile --ignore-scripts
+34 -28
View File
@@ -6,8 +6,8 @@
"name": "@effect-view/monorepo",
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@effect/language-service": "^0.87.1",
"@types/bun": "^1.3.14",
"@effect/tsgo": "0.36.4",
"@types/bun": "^1.4.0",
"npm-check-updates": "^23.0.0",
"npm-sort": "^0.0.4",
"turbo": "^2.10.7",
@@ -82,21 +82,21 @@
},
"packages/effect-view": {
"name": "effect-view",
"version": "0.1.0",
"version": "0.1.3",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"effect-lens": "^2.0.1-beta.101",
"effect-lens": "2.0.1-rc.109",
},
"devDependencies": {
"@effect/platform-browser": "4.0.0-beta.101",
"@effect/platform-browser": "4.0.0-rc.109",
"@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.101",
"effect": "4.0.0-rc.109",
"jsdom": "^26.1.0",
"vitest": "^3.2.4",
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "4.0.0-beta.101",
"effect": "4.0.0-rc.109",
"react": "^19.2.0",
},
},
@@ -104,9 +104,9 @@
"name": "@effect-view/example",
"version": "0.0.0",
"dependencies": {
"@effect/platform-browser": "4.0.0-beta.101",
"@effect/platform-browser": "4.0.0-rc.109",
"@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.101",
"effect": "4.0.0-rc.109",
"effect-view": "workspace:*",
"react-icons": "^5.6.0",
},
@@ -578,12 +578,26 @@
"@effect-view/vite-plugin": ["@effect-view/vite-plugin@workspace:packages/vite-plugin"],
"@effect/language-service": ["@effect/language-service@0.87.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-kcljlJmEgqg5mFAM6UShJYJjMqJb3TbHHxrK8Qoubvwugc0aVWpRkbdgQvK5b17puOt3BKXXKOmcV+oQt0oQqQ=="],
"@effect/platform": ["@effect/platform@0.96.3", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.10", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.21.5" } }, "sha512-LzvIj4HYE++TcTv/cVTCI/GRvQTV0ymwRmgjZMzNB5dU4OS05pTN36RKN0npiOm5orLJgw4yjIv1dNZoYGh/vg=="],
"@effect/platform-browser": ["@effect/platform-browser@0.76.0", "", { "dependencies": { "multipasta": "^0.2.7" }, "peerDependencies": { "@effect/platform": "^0.96.0", "effect": "^3.21.0" } }, "sha512-cUyBpcLstrP/HiNsIePMBAI6R1+u6aRFlAUZb4wf08y1d1Vqf/Dmxsq14ZjBfnSYiqBPrCeYf1ZI+qMGQQL0RA=="],
"@effect/tsgo": ["@effect/tsgo@0.36.4", "", { "optionalDependencies": { "@effect/tsgo-darwin-arm64": "0.36.4", "@effect/tsgo-darwin-x64": "0.36.4", "@effect/tsgo-linux-arm": "0.36.4", "@effect/tsgo-linux-arm64": "0.36.4", "@effect/tsgo-linux-x64": "0.36.4", "@effect/tsgo-win32-arm64": "0.36.4", "@effect/tsgo-win32-x64": "0.36.4" }, "bin": { "effect-tsgo": "dist/effect-tsgo.cjs" } }, "sha512-fNmdUV6FgXnvIcG18AVS1SRXt7z9jsyBh5CKuJYmTsE+DgbLaWF0CevKHx7hQPcidDWRVmdJYfU66Jvc/ZEt6w=="],
"@effect/tsgo-darwin-arm64": ["@effect/tsgo-darwin-arm64@0.36.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oOcWdKQucNch6G4CvidHEzmfgeKEPMvFcz+DXuKGonBLwVHX+i1VYGbZwccEOoiitOg7eWVl4e8S0qKMXg3/gg=="],
"@effect/tsgo-darwin-x64": ["@effect/tsgo-darwin-x64@0.36.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-4QZh0n/nUoTI7sp4o7gDEviJ/jXH+IFTVqIyQ2w2fqqPoOvaGl9bVoR/2f9j/67hvY4DHcXKaccpmyoRYhaYsw=="],
"@effect/tsgo-linux-arm": ["@effect/tsgo-linux-arm@0.36.4", "", { "os": "linux", "cpu": "arm" }, "sha512-10oTUnOK/RCfmTMFvMndNIfk61uviyYBRpbxhDd/SiU6DYCmWlnZ/xhGmgyj5uiysOiFNsp3NKxT0d9y4uAz8g=="],
"@effect/tsgo-linux-arm64": ["@effect/tsgo-linux-arm64@0.36.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-aOv+wVbZWgBt9PGXPA874qgw151GrzkWqaswe+4TXjhdw/M4i35PCUH5T+uaQj3FnQ1NWiPqHIGfNBRjf3xG6w=="],
"@effect/tsgo-linux-x64": ["@effect/tsgo-linux-x64@0.36.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+BuwiG5d8dLNrrN1PB5GA3gRkBwvlUYmMkm/XkttaLnppp9jePFkB8CgAiNV8j290q7BVO2nQjao5aPkVwmluw=="],
"@effect/tsgo-win32-arm64": ["@effect/tsgo-win32-arm64@0.36.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-HsW90wbMWOeorUeNMdTqTcMZIDcCXdkeFGXTUkx9K1R21fXSw+QEdPtTmEyKsRMXEH4vqek/HQa0yMkUT0CJzA=="],
"@effect/tsgo-win32-x64": ["@effect/tsgo-win32-x64@0.36.4", "", { "os": "win32", "cpu": "x64" }, "sha512-q4j1q0ESYxxookjA0ECz/84DEH2MsP51K0aZb3uXKfxagHDASPoWDcVu4BJTcwrK5kdZzvw9QrCPjAXe9WZqng=="],
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
@@ -1152,7 +1166,7 @@
"@types/bonjour": ["@types/bonjour@3.5.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@@ -1426,7 +1440,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -1942,7 +1956,7 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
"ini": ["ini@2.0.0", "", {}, "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
@@ -2046,8 +2060,6 @@
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
"latest-version": ["latest-version@7.0.0", "", { "dependencies": { "package-json": "^8.1.0" } }, "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg=="],
"launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="],
@@ -2856,8 +2868,6 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
@@ -2940,7 +2950,7 @@
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"value-equal": ["value-equal@1.0.1", "", {}, "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="],
@@ -3052,9 +3062,9 @@
"@docusaurus/utils/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"@effect-view/example/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.101", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-05//60oEzMyQyNjk8Ll1mML6gU2RvT9TTaomq5cGmNQu6Gzb6jlWfzn2/ZGzFtehhB9iOpqtjy0QkT5tDh9ElA=="],
"@effect-view/example/@effect/platform-browser": ["@effect/platform-browser@4.0.0-rc.109", "", { "peerDependencies": { "effect": "^4.0.0-rc.109" } }, "sha512-63/hM2dCh0HQb7sRkAhAoZjO4swjRD2vT/eUrs8nXA0LzJMz52ChPsxdnL1D0yg+ZeXtMHQM0MFJxITJXjv+EA=="],
"@effect-view/example/effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="],
"@effect-view/example/effect": ["effect@4.0.0-rc.109", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.4" } }, "sha512-6ubcOCtfdbmFO5+vgcT2HsTw5s+n3aMUj4eAIbVpUxP7+VYCwXxxcBHgiWgizOrGO1eGmuOBFek3mM0dFcwaWA=="],
"@effect-view/vite-plugin/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
@@ -3122,11 +3132,11 @@
"dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
"effect-view/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.101", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-05//60oEzMyQyNjk8Ll1mML6gU2RvT9TTaomq5cGmNQu6Gzb6jlWfzn2/ZGzFtehhB9iOpqtjy0QkT5tDh9ElA=="],
"effect-view/@effect/platform-browser": ["@effect/platform-browser@4.0.0-rc.109", "", { "peerDependencies": { "effect": "^4.0.0-rc.109" } }, "sha512-63/hM2dCh0HQb7sRkAhAoZjO4swjRD2vT/eUrs8nXA0LzJMz52ChPsxdnL1D0yg+ZeXtMHQM0MFJxITJXjv+EA=="],
"effect-view/effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="],
"effect-view/effect": ["effect@4.0.0-rc.109", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.4" } }, "sha512-6ubcOCtfdbmFO5+vgcT2HsTw5s+n3aMUj4eAIbVpUxP7+VYCwXxxcBHgiWgizOrGO1eGmuOBFek3mM0dFcwaWA=="],
"effect-view/effect-lens": ["effect-lens@2.0.1-beta.101", "", { "peerDependencies": { "effect": "4.0.0-beta.101" } }, "sha512-ME5JruBkF3AG34rhb9MlHb1uwEbW70hr6lFITf8tcHBTgVF5QXFCe8nWATraYZpxBHGqM2bOnRvoadmwh2yyvQ=="],
"effect-view/effect-lens": ["effect-lens@2.0.1-rc.109", "", { "peerDependencies": { "effect": "4.0.0-rc.109" } }, "sha512-rMluFV/QGvLyXWoLu7YGaEUus/E40LeUXB0bAnzR3yvF1euch0g/gVISYuGlu+mvUahzBEH//+QNi45/0IwfTQ=="],
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
@@ -3142,8 +3152,6 @@
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"global-dirs/ini": ["ini@2.0.0", "", {}, "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA=="],
"got/@sindresorhus/is": ["@sindresorhus/is@5.6.0", "", {}, "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g=="],
"hpack.js/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
@@ -3338,8 +3346,6 @@
"sitemap/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
"sockjs/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+4 -3
View File
@@ -1,11 +1,12 @@
{
"name": "@effect-view/monorepo",
"packageManager": "bun@1.3.14",
"packageManager": "bun@1.4.0",
"private": true,
"workspaces": [
"./packages/*"
],
"scripts": {
"prepare": "effect-tsgo patch --typescript --no-oxlint",
"lint:tsc": "turbo lint:tsc",
"lint:biome": "turbo lint:biome",
"test": "turbo test",
@@ -17,8 +18,8 @@
},
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@effect/language-service": "^0.87.1",
"@types/bun": "^1.3.14",
"@effect/tsgo": "0.36.4",
"@types/bun": "^1.4.0",
"npm-check-updates": "^23.0.0",
"npm-sort": "^0.0.4",
"turbo": "^2.10.7",
+28 -25
View File
@@ -54,11 +54,9 @@ valid decoded value should go:
## Create forms once
`MutationForm.service` and `LensForm.service` are Effect constructors, not
hooks. Create each root form once and keep it stable. For a component-owned
form, call the constructor inside `Component.useOnMount`; for a form shared by
multiple components, create it in an Effect service. Update the existing form's
Lenses rather than reconstructing the form during render.
`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
@@ -73,7 +71,7 @@ import { Component, MutationForm, View } from "effect-view"
const CreateProfileView = Component.make("CreateProfile")(function* () {
const form = yield* Component.useOnMount(() =>
MutationForm.service({
MutationForm.make({
schema: ProfileSchema,
initialEncodedValue: {
displayName: "",
@@ -84,7 +82,7 @@ const CreateProfileView = Component.make("CreateProfile")(function* () {
Effect.log(
`Creating ${profile.displayName}, age ${profile.age}`,
),
}),
}).pipe(MutationForm.thenRun),
)
const [canCommit, isCommitting] = yield* View.useAll([
@@ -110,9 +108,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
invalid draft from being submitted.
`MutationForm.service` also starts initial validation in the current scope. In
the example above, `Component.useOnMount` keeps that validation and the form's
mutation work tied to the component that owns them.
`MutationForm.thenRun` starts initial validation. In the example above,
`Component.useOnMount` keeps that validation and the form's mutation work tied
to the component that owns them.
## LensForm: validate, then synchronize
@@ -136,10 +134,10 @@ const EditProfileView = Component.make("EditProfile")(function* () {
}),
)
const form = yield* LensForm.service({
const form = yield* LensForm.make({
schema: ProfileSchema,
target: profile,
})
}).pipe(LensForm.thenRun)
return [form, profile] as const
}),
@@ -168,8 +166,8 @@ reaches the target. If another part of the application updates the target,
`LensForm` encodes that value back into the draft.
Pass `initialEncodedValue` only when the first draft should differ from the
encoded target. Otherwise `LensForm.service` obtains the initial draft by
encoding the target through the schema.
encoded target. Otherwise `LensForm.make` obtains the initial draft by encoding
the target through the schema.
## Focus into subforms
@@ -256,6 +254,13 @@ Use `Form.useOptionalInput` when the encoded field is an Effect `Option`. It
returns `value` and `setValue` together with `enabled` and `setEnabled`, making
it suitable for an optional input that the user can toggle on and off.
Use `Form.useStatus` for displayed form status. It debounces short-lived state
changes, avoiding flicker in pending indicators.
```tsx
const { isValidating, isCommitting, canCommit } = yield* Form.useStatus(form)
```
`useInput` and `useOptionalInput` can be used directly, but they are primarily
building blocks for your own reusable input components. A component library
can wrap them to consistently handle labels, validation issues, validating
@@ -301,11 +306,8 @@ export const TextFieldFormInputView = Component.make(
>,
) {
const input = yield* Form.useInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* View.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
])
const [issues] = yield* View.useAll([props.form.issues])
const { isValidating, isCommitting } = yield* Form.useStatus(props.form)
return (
<Flex direction="column" gap="1">
@@ -353,8 +355,9 @@ They expose the schema pipeline as reactive Lenses and Views:
| `isCommitting` | Whether a mutation or target write is in progress. |
The form model itself is Effect code. Effect View adds the React-facing hooks,
including `Form.useInput` and `Form.useOptionalInput`, while your own component
library remains responsible for rendering controls and layout.
including `Form.useInput`, `Form.useOptionalInput`, and `Form.useStatus`, while
your own component library remains responsible for rendering controls and
layout.
## Example: local date input, UTC domain value
@@ -417,7 +420,7 @@ the mutation receives UTC:
```tsx
import { DateTime, Effect, Schema } from "effect"
import { Component, Form, MutationForm, View } from "effect-view"
import { Component, Form, MutationForm } from "effect-view"
import { DateTimeUtcFromZonedInput } from "./DateTimeUtcFromZonedInput"
const AppointmentSchema = Schema.Struct({
@@ -427,21 +430,21 @@ const AppointmentSchema = Schema.Struct({
const AppointmentView = Component.make("Appointment")(function* () {
const [form, startsAtField] = yield* Component.useOnMount(() =>
Effect.gen(function* () {
const form = yield* MutationForm.service({
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 { canCommit } = yield* Form.useStatus(form)
const runPromise = yield* Component.useRunPromise()
return (
+46 -11
View File
@@ -469,24 +469,59 @@ Prefer regular React state for simple, component-local UI concerns. Use
`Lens`/`View` when state needs Effect integration, subscriptions, focusing, or
sharing. The [State Management guide](./state-management) covers that model.
## Provide services to a subtree
## Provide services to a component
Use `Component.useLayer` when only one Effect View subtree needs extra
services. It builds the layer in a scope and returns the resulting Effect
context:
Use `Component.provide` to give a component a static layer:
```tsx title="src/GreetingPageView.tsx"
import { Effect } from "effect"
```tsx title="src/GreetingViewLive.tsx"
import { Component } from "effect-view"
import { GreetingView } from "./GreetingView"
import { GreetingService } from "./GreetingService"
export const GreetingPageView = Component.make("GreetingPage")(
function* () {
const context = yield* Component.useLayer(GreetingService.layer)
const Greeting = yield* GreetingView.use.pipe(
Effect.provide(context),
export const GreetingViewLive = Component.provide(
GreetingView,
GreetingService.layer,
)
```
The pipeline form is equivalent:
```tsx
export const GreetingViewLive = GreetingView.pipe(
Component.provide(GreetingService.layer),
)
```
`Component.provide` builds the layer for each mounted component instance and
releases its resources when that instance unmounts.
The layer is constructed during render and must acquire synchronously unless
the component is enhanced with [`Async.async`](./async).
## Provide services to a subtree
Use `Component.useLayer` to provide a layer from a component to its Effect View
subcomponents. This is also the right choice when the layer depends on props,
state, or context read inside React: memoize the layer and provide its context
manually.
```tsx title="src/GreetingPageView.tsx"
import { Effect, Layer } from "effect"
import { Component } from "effect-view"
import * as React from "react"
import { GreetingView } from "./GreetingView"
import { GreetingService } from "./GreetingService"
export const GreetingPageView = Component.make("GreetingPage")(
function* (props: { readonly greeting: string }) {
const layer = React.useMemo(
() => Layer.succeed(GreetingService, {
greet: (name) => `${props.greeting}, ${name}`,
}),
[props.greeting],
)
const context = yield* Component.useLayer(layer)
const Greeting = yield* Effect.provide(GreetingView.use, context)
return <Greeting name="Effect" />
},
+37 -8
View File
@@ -57,11 +57,11 @@ client defaults. Window-focus refresh also requires the optional
## Create a reactive query
A query is driven by a `View` rather than by a value read during one React
render. Whenever that key changes, `Query.service` checks the cache and starts
render. Whenever that key changes, a running Query checks the cache and starts
the query effect when necessary.
`Query.service` is an Effect constructor, not a hook. Create each query instance
once and keep it stable. In a component, the usual place is
`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.
@@ -84,7 +84,7 @@ const PostView = Component.make("Post")(function* () {
yield* SubscriptionRef.make(["post", 1 as number] as const),
)
const query = yield* Query.service({
const query = yield* Query.make({
key,
staleTime: "1 minute",
f: ([, id]) =>
@@ -95,7 +95,7 @@ const PostView = Component.make("Post")(function* () {
Effect.andThen((response) => response.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)),
),
})
}).pipe(Query.thenRun)
return [Lens.focusTupleAt(key, 1), query] as const
}),
@@ -124,7 +124,7 @@ Effect equality by default, so structurally equal Effect data types work well
as query keys. Supply `keyEquivalence` when the key needs different equality
semantics.
`Query.service` starts watching its key in the current scope. The
`Query.thenRun` starts watching its key in the current scope. The
`Component.useOnMount` call above keeps both the query instance and its query
function identity stable for the component's lifetime.
@@ -248,6 +248,35 @@ workflows that need to wait for the final success or failure state.
Invalidating does not itself refetch. Follow it with `refreshView`, change the
key, or allow a later fetch to repopulate the cache.
### Refresh on an interval
Refresh every five minutes, starting after five minutes:
```ts
import { Schedule } from "effect"
const query = yield* Query.make(options).pipe(
Query.thenRun,
Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
)
```
Limit the number of refreshes:
```ts
const query = yield* Query.make(options).pipe(
Query.thenRun,
Query.withScheduledRefresh(
Schedule.spaced("5 minutes").pipe(
Schedule.upTo({ times: 3 }),
),
),
)
```
The refresh fiber stops with the surrounding scope. The Effect returns the
original query. Cache and `staleTime` rules still apply.
## Staleness and cache lifetime
`staleTime` controls how long a successful result can satisfy a fetch without
@@ -262,12 +291,12 @@ By default, the client enables refresh on browser window focus. Set it globally
or override it for one query:
```tsx
const query = yield* Query.service({
const query = yield* Query.make({
key,
f: loadPost,
staleTime: "10 seconds",
refreshOnWindowFocus: false,
})
}).pipe(Query.thenRun)
```
Window-focus refresh depends on the optional `@effect/platform-browser`
+1 -1
View File
@@ -49,6 +49,6 @@
]
},
"engines": {
"node": ">=20.0"
"node": ">=24.0"
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ describe("Subscribable", () => {
await Effect.runPromise(Lens.set(right, "b"))
await waitFor(() => expect(values).toContainEqual([2, "b"]))
Fiber.interruptFork(collector)
await Effect.runPromise(Fiber.interrupt(collector))
})
it("useAll returns the latest values and rerenders when any input changes", async () => {
+3 -3
View File
@@ -25,12 +25,12 @@ data modeling into React 19 without replacing React's component model. Yield
Effects and services from a component body, let scopes follow the React
lifecycle, and return ordinary JSX.
> **Effect v4 beta:** Effect View is built for the Effect v4 beta release. If
> **Effect v4 RC:** Effect View is built for the Effect v4 release candidate. If
> your application uses Effect v3, use the legacy
> [`effect-fc` package](https://www.npmjs.com/package/effect-fc) instead.
```bash
npm install effect-view effect@beta react
npm install effect-view effect@rc react
```
Effect View does not depend on `react-dom`. Install the renderer used by your
@@ -127,7 +127,7 @@ The complete documentation is available at **[thila.dev/effect-view](https://thi
## Requirements
- React 19.2 or newer
- **Effect v4 beta** (`effect@beta`)
- **Effect v4 RC** (`effect@rc`)
- TypeScript and `@types/react` for TypeScript projects
Effect View is renderer-independent and does not require `react-dom`.
+9 -6
View File
@@ -1,11 +1,14 @@
{
"name": "effect-view",
"description": "Write React function components with Effect",
"version": "0.1.0",
"version": "0.1.4",
"type": "module",
"files": [
"./README.md",
"./dist"
"./src/**/*.ts",
"./dist/**/*.js",
"./dist/**/*.js.map",
"./dist/**/*.d.ts"
],
"license": "MIT",
"repository": {
@@ -97,19 +100,19 @@
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect/platform-browser": "4.0.0-beta.101",
"@effect/platform-browser": "4.0.0-rc.109",
"@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.101",
"effect": "4.0.0-rc.109",
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "4.0.0-beta.101",
"effect": "4.0.0-rc.109",
"react": "^19.2.0"
},
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"effect-lens": "^2.0.1-beta.101"
"effect-lens": "2.0.1-rc.109"
}
}
@@ -10,11 +10,56 @@ import * as ScopeRegistry from "./ScopeRegistry.js"
class ValueService extends Context.Service<ValueService, { readonly value: string }>()("ValueService") {}
class ParentService extends Context.Service<ParentService, { readonly prefix: string }>()("ParentService") {}
afterEach(() => {
vi.useRealTimers()
})
describe("Component", () => {
it("provides a layer once per mounted component instance", async () => {
const setup = vi.fn()
const cleanup = vi.fn()
const serviceLayer = Layer.effect(ValueService, Effect.gen(function*() {
const parent = yield* ParentService
yield* Effect.sync(setup)
yield* Effect.addFinalizer(() => Effect.sync(cleanup))
return { value: `${parent.prefix} value` }
}))
const runtime = ReactRuntime.make(Layer.succeed(ParentService, { prefix: "provided" }))
const effectRuntime = await runtime.runtime.context()
const Probe = Component.makeUntraced("ProvidedServiceProbe")(function*() {
const service = yield* ValueService
return <div>{service.value}</div>
}).pipe(
Component.provide(serviceLayer),
Component.withContext(runtime.context),
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
expect(await screen.findByText("provided value")).toBeTruthy()
expect(setup).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
expect(await screen.findByText("provided value")).toBeTruthy()
expect(setup).toHaveBeenCalledTimes(1)
view.unmount()
await waitFor(() => expect(cleanup).toHaveBeenCalledTimes(1))
await runtime.runtime.dispose()
})
it("does not rerun useOnMount across rerenders after Strict Mode initialization", async () => {
const onMount = vi.fn(() => Effect.succeed("mounted"))
const runtime = ReactRuntime.make(Layer.empty)
+53 -2
View File
@@ -607,6 +607,57 @@ export const withOptions: {
Object.getPrototypeOf(self),
))
export declare namespace provide {
export type Result<T extends Component.Any, ROut, E, RIn> = (
& Omit<T, keyof Component.AsComponent<T>>
& Component<
Component.Props<T>,
Component.Success<T>,
Component.Error<T> | E,
Exclude<Component.Context<T>, ROut> | RIn,
Component.Function<T>
>
)
}
/**
* Provides a layer to an Effect View component.
*
* The layer is built once for each mounted instance of the returned component and
* is released when that instance unmounts. Any services still required by the
* layer remain requirements of the returned component.
*
* @example
* ```tsx
* const TodosViewLive = Component.provide(TodosView, TodosService.Default)
*
* // Equivalent pipeline form
* const TodosViewLive = TodosView.pipe(
* Component.provide(TodosService.Default),
* )
* ```
*/
export const provide: {
<ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>,
): <T extends Component.Any>(self: T) => provide.Result<T, ROut, E, RIn>
<T extends Component.Any, ROut, E, RIn>(
self: T,
layer: Layer.Layer<ROut, E, RIn>,
): provide.Result<T, ROut, E, RIn>
} = Function.dual(2, <T extends Component.Any, ROut, E, RIn>(
self: T,
layer: Layer.Layer<ROut, E, RIn>,
): provide.Result<T, ROut, E, RIn> => Object.setPrototypeOf(
Object.assign(function() {}, self, {
body: (props: Component.Props<T>) => Effect.flatMap(
useLayer(layer),
context => Effect.provide(self.body(props), context),
),
}),
Object.getPrototypeOf(self),
))
/**
* Wraps an Effect View Component and converts it into a standard React function component,
* serving as an **entrypoint** into an Effect View component hierarchy.
@@ -1047,7 +1098,7 @@ export const useCallbackPromise = Effect.fnUntraced(function* <Args extends unkn
return React.useCallback((...args: Args) => Effect.runPromiseWith(contextRef.current)(f(...args)), deps)
})
export declare namespace useContext {
export declare namespace useLayer {
export interface Options extends useOnChange.Options {}
}
@@ -1112,7 +1163,7 @@ export declare namespace useContext {
*/
export const useLayer = <ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>,
options?: useContext.Options,
options?: useLayer.Options,
): Effect.Effect<Context.Context<ROut>, E, RIn | Scope.Scope> => useOnChange(() => Effect.flatMap(
Effect.context<RIn>(),
context => Layer.build(Layer.provide(layer, Layer.succeedContext(context))),
+42 -1
View File
@@ -1,6 +1,6 @@
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
import type * as React from "react"
import * as React from "react"
import * as Component from "./Component.js"
import * as Lens from "./Lens.js"
import * as View from "./View.js"
@@ -156,6 +156,47 @@ export const focusChunkAt: {
})
export declare namespace useStatus {
export interface Options {
/**
* The debounce interval applied to changes in the returned status.
*
* Defaults to 250 milliseconds.
*/
readonly debounce?: Duration.Input
}
export interface Success {
readonly isValidating: boolean
readonly isCommitting: boolean
readonly canCommit: boolean
}
}
/**
* Subscribes to form status flags for presentation.
*
* Status changes are debounced to avoid transient UI feedback. The underlying
* form state is not changed.
*/
export const useStatus = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
form: Form<P, A, I, ER, EW>,
options?: useStatus.Options,
): Effect.fn.Return<useStatus.Success, ER, Scope.Scope> {
const views = React.useMemo(() => {
const debounce = options?.debounce ?? "250 millis"
return [
View.mapStream(form.isValidating, Stream.debounce(debounce)),
View.mapStream(form.isCommitting, Stream.debounce(debounce)),
View.mapStream(form.canCommit, Stream.debounce(debounce)),
] as const
}, [form, options?.debounce])
const [isValidating, isCommitting, canCommit] = yield* View.useAll(views)
return { isValidating, isCommitting, canCommit }
})
export namespace useInput {
export interface Options {
readonly debounce?: Duration.Input
+5 -10
View File
@@ -189,18 +189,13 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
)
})
export declare namespace service {
export interface Options<in out A, out I = A, out RD = never, out RE = never, out TER = never, out TEW = never, out TRR = never, out TRW = never>
extends make.Options<A, I, RD, RE, TER, TEW, TRR, TRW> {}
}
export const service = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: service.Options<A, I, RD, RE, TER, TEW, TRR, TRW>
export const thenRun = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never, E = never, R = never>(
self: Effect.Effect<LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>, E, R>,
): Effect.Effect<
LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>,
Schema.SchemaError | TER,
Scope.Scope | RD | RE | TRR | TRW
E,
Scope.Scope | R
> => Effect.tap(
make(options),
self,
form => Effect.forkScoped(form.run),
)
+108
View File
@@ -0,0 +1,108 @@
import { Cause, Deferred, Effect, Option, type Scope } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import { describe, expect, it } from "vitest"
import * as Mutation from "./Mutation.js"
import * as View from "./View.js"
const runMutationTest = <A, E>(effect: Effect.Effect<A, E, Scope.Scope>) =>
Effect.runPromise(Effect.scoped(effect))
const expectSuccessValue = <A, E>(state: { readonly result: AsyncResult.AsyncResult<A, E> }): A => {
expect(AsyncResult.isSuccess(state.result)).toBe(true)
if (!AsyncResult.isSuccess(state.result))
throw new Error(`Expected Success result, received ${state.result._tag}`)
return state.result.value
}
describe("Mutation", () => {
it("runs a mutation and exposes its latest completed state", async () => {
const result = await runMutationTest(Effect.gen(function*() {
const mutation = yield* Mutation.make({
f: (key: number) => Effect.succeed(`value:${key}`),
})
const final = yield* mutation.mutate(1)
return {
isMutation: Mutation.isMutation(mutation),
final,
latestKey: yield* View.get(mutation.latestKey),
state: yield* View.get(mutation.state),
latestFinalState: yield* View.get(mutation.latestFinalState),
fiber: yield* View.get(mutation.fiber),
}
}))
expect(result.isMutation).toBe(true)
expect(result.final.key.value).toBe(1)
expect(expectSuccessValue(result.final)).toBe("value:1")
expect(result.latestKey).toEqual(Option.some(1))
expect(result.state.key).toEqual(Option.some(1))
expect(expectSuccessValue(result.state)).toBe("value:1")
expect(result.latestFinalState).toEqual(Option.some(result.final))
expect(result.fiber).toEqual(Option.none())
})
it("records failures while retaining the previous successful value", async () => {
const result = await runMutationTest(Effect.gen(function*() {
let calls = 0
const mutation = yield* Mutation.make({
f: (_key: "save") => Effect.sync(() => {
calls += 1
return calls
}).pipe(
Effect.flatMap(call => call === 1
? Effect.succeed("saved")
: Effect.fail("could not save")),
),
})
yield* mutation.mutate("save")
return yield* mutation.mutate("save")
}))
expect(result.key.value).toBe("save")
expect(AsyncResult.isFailure(result.result)).toBe(true)
if (!AsyncResult.isFailure(result.result))
throw new Error(`Expected Failure result, received ${result.result._tag}`)
expect(result.result.cause).toEqual(Cause.fail("could not save"))
expect(Option.isSome(result.result.previousSuccess)).toBe(true)
if (Option.isSome(result.result.previousSuccess))
expect(result.result.previousSuccess.value.value).toBe("saved")
})
it("mutateView returns a waiting state without waiting for completion", async () => {
const result = await runMutationTest(Effect.gen(function*() {
const deferred = yield* Deferred.make<string>()
const mutation = yield* Mutation.make({
f: (_key: string) => Deferred.await(deferred),
})
const state = yield* mutation.mutateView("save")
yield* Effect.yieldNow
const pending = yield* View.get(state)
const hasRunningFiber = Option.isSome(yield* View.get(mutation.fiber))
yield* Deferred.succeed(deferred, "saved")
yield* Effect.yieldNow
const final = yield* View.get(mutation.latestFinalState).pipe(Effect.flatMap(Effect.fromOption))
return { pending, hasRunningFiber, final }
}))
expect(result.pending.key.value).toBe("save")
expect(AsyncResult.isInitial(result.pending.result)).toBe(true)
expect(result.pending.result.waiting).toBe(true)
expect(result.hasRunningFiber).toBe(true)
expect(result.final.key.value).toBe("save")
expect(expectSuccessValue(result.final)).toBe("saved")
})
})
+132 -46
View File
@@ -1,4 +1,4 @@
import { type Context, Effect, Equal, Exit, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
import { Cause, type Context, Effect, Exit, type Fiber, Option, Pipeable, Predicate, PubSub, Ref, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js"
import * as View from "./View.js"
@@ -16,11 +16,26 @@ extends Pipeable.Pipeable {
readonly latestKey: View.View<Option.Option<K>>
readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
readonly state: View.View<AsyncResult.AsyncResult<A, E>>
readonly latestFinalResult: View.View<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>
readonly state: View.View<LatestMutationState<K, A, E>>
readonly latestFinalState: View.View<Option.Option<FinalMutationState<K, A, E>>>
mutate(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>>
mutate(key: K): Effect.Effect<FinalMutationState<K, A, E>>
mutateView(key: K): Effect.Effect<View.View<MutationState<K, A, E>>>
}
export interface LatestMutationState<out K, out A, out E = never> {
readonly key: Option.Option<K>
readonly result: AsyncResult.AsyncResult<A, E>
}
export interface MutationState<out K, out A, out E = never> {
readonly key: Option.Some<K>
readonly result: AsyncResult.AsyncResult<A, E>
}
export interface FinalMutationState<out K, out A, out E = never> {
readonly key: Option.Some<K>
readonly result: AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>
}
export const isMutation = (u: unknown): u is Mutation<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
@@ -36,20 +51,20 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
readonly latestKey: Lens.Lens<Option.Option<K>>,
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
readonly state: Lens.Lens<AsyncResult.AsyncResult<A, E>>,
readonly latestFinalResult: Lens.Lens<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>,
readonly state: Lens.Lens<LatestMutationState<K, A, E>>,
readonly latestFinalState: Lens.Lens<Option.Option<FinalMutationState<K, A, E>>>,
) {
super()
}
mutate(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
mutate(key: K): Effect.Effect<FinalMutationState<K, A, E>> {
return Lens.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(this.start(key)),
Effect.flatMap(state => this.watch(state)),
Effect.provide(this.context),
)
}
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>> {
mutateView(key: K): Effect.Effect<View.View<MutationState<K, A, E>>> {
return Lens.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(this.start(key)),
Effect.tap(state => Effect.forkScoped(this.watch(state))),
@@ -58,54 +73,81 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
}
start(key: K): Effect.Effect<
View.View<AsyncResult.AsyncResult<A, E>>,
View.View<MutationState<K, A, E>>,
never,
Scope.Scope | R
> {
return Effect.gen({ self: this }, function*() {
const previous = yield* Lens.get(this.latestFinalResult)
const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make<AsyncResult.AsyncResult<A, E>>(
Option.getOrElse(previous, () => AsyncResult.initial(false))
))
const previous: MutationState<K, A, E> = Option.getOrElse(yield* Lens.get(this.latestFinalState), () => ({
key: Option.some(key) as Option.Some<K>,
result: AsyncResult.initial(),
}))
const state = yield* makeMutationStateLens(previous)
const fiber = yield* Effect.forkScoped(Effect.andThen(
Lens.update(state, AsyncResult.match({
onInitial: () => AsyncResult.initial(true),
onSuccess: v => AsyncResult.success(v.value, {
Lens.update<MutationState<K, A, E>, never, never, never, never>(
state,
previous => AsyncResult.match(previous.result, {
onInitial: () => ({
key: previous.key,
result: AsyncResult.initial(true),
}),
onSuccess: result => ({
key: previous.key,
result: AsyncResult.success(result.value, {
waiting: true,
}),
onFailure: v => AsyncResult.failure(v.cause, {
}),
onFailure: result => ({
key: previous.key,
result: AsyncResult.failure(result.cause, {
waiting: true,
previousSuccess: v.previousSuccess,
})
})),
previousSuccess: result.previousSuccess,
}),
}),
}
)),
Effect.onExit(this.f(key), exit => Lens.update(
Effect.onExit(this.f(previous.key.value), exit => Effect.gen({ self: this }, function*() {
const fiberId = yield* Effect.fiberId
const fiber = yield* Lens.get(this.fiber)
if (Option.isSome(fiber) && fiberId === fiber.value.id)
yield* Lens.set(this.fiber, Option.none())
const finalState = (yield* Lens.updateAndGet<MutationState<K, A, E>, never, never, never, never>(
state,
previous => Exit.match(exit, {
onSuccess: v => AsyncResult.success(v),
onFailure: c => AsyncResult.match(previous, {
onInitial: () => AsyncResult.failure(c),
onSuccess: v => AsyncResult.failure(c, {
onSuccess: v => ({
key: previous.key,
result: AsyncResult.success(v),
}),
onFailure: c => Cause.hasInterruptsOnly(c)
? previous
: AsyncResult.match(previous.result, {
onInitial: () => ({
key: previous.key,
result: AsyncResult.failure(c),
}),
onSuccess: v => ({
key: previous.key,
result: AsyncResult.failure(c, {
previousSuccess: Option.some(v),
}),
onFailure: v => AsyncResult.failure(c, {
}),
onFailure: v => ({
key: previous.key,
result: AsyncResult.failure(c, {
previousSuccess: v.previousSuccess,
})
}),
}),
).pipe(
Effect.andThen(Effect.all([
Effect.fiberId,
Lens.get(this.fiber),
])),
Effect.flatMap(([fiberId, fiber]) => Option.match(fiber, {
onSome: v => Equal.equals(fiberId, v.id)
? Lens.set(this.fiber, Option.none())
: Effect.void,
onNone: () => Effect.void,
})),
)),
}),
}),
)) as FinalMutationState<K, A, E>
yield* Lens.set(this.latestFinalState, Option.some(finalState))
yield* PubSub.shutdown(state.pubsub)
}))
))
yield* Lens.set(this.fiber, Option.some(fiber))
@@ -114,15 +156,15 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
}
watch(
state: View.View<AsyncResult.AsyncResult<A, E>>
): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
state: View.View<MutationState<K, A, E>>
): Effect.Effect<FinalMutationState<K, A, E>> {
return View.get(state).pipe(
Effect.andThen(initial => Stream.runFoldEffect(
View.changes(state),
() => initial,
(_, result) => Effect.as(Lens.set(this.state, result), result),
) as Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>),
Effect.tap(result => Lens.set(this.latestFinalResult, Option.some(result))),
) as Effect.Effect<FinalMutationState<K, A, E>>),
Effect.tap(result => Lens.set(this.latestFinalState, Option.some(result))),
)
}
}
@@ -147,7 +189,51 @@ export const make = Effect.fnUntraced(function* <K = never, A = void, E = never,
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<K>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<AsyncResult.AsyncResult<A, E>>(AsyncResult.initial())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<LatestMutationState<K, A, E>>({
key: Option.none(),
result: AsyncResult.initial(),
})),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<FinalMutationState<K, A, E>>())),
)
})
export class MutationStateLens<in out K, in out A, in out E = never>
extends Lens.LensImpl<MutationState<K, A, E>, never, never, never, never> {
constructor(
readonly ref: Ref.Ref<MutationState<K, A, E>>,
readonly pubsub: PubSub.PubSub<MutationState<K, A, E>>,
readonly semaphore: Semaphore.Semaphore,
) {
super()
}
get resolve(): Effect.Effect<Lens.LensImpl.Resolved<MutationState<K, A, E>>, never, never> {
return Effect.map(
Ref.get(this.ref),
value => ({
value,
commit: next => Effect.flatMap(
next,
value => Effect.andThen(
Ref.set(this.ref, value),
PubSub.publish(this.pubsub, value),
),
),
}),
)
}
get changes() { return Stream.fromPubSub(this.pubsub) }
get lock() { return Effect.succeed(this.semaphore.withPermit) }
}
export const makeMutationStateLens = <K, A, E = never>(
initial: MutationState<K, A, E>,
) => Effect.all([
Ref.make(initial),
PubSub.unbounded<MutationState<K, A, E>>({ replay: 1 }),
Semaphore.make(1),
]).pipe(
Effect.tap(([, pubsub]) => PubSub.publish(pubsub, initial)),
Effect.map(([ref, pubsub, semaphore]) => new MutationStateLens(ref, pubsub, semaphore)),
)
+40 -21
View File
@@ -1,5 +1,5 @@
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { Array, Cause, type Context, Effect, Fiber, Option, Pipeable, Predicate, Schema, SchemaError, SchemaIssue, type Scope, Semaphore, SubscriptionRef } from "effect"
import { Array, Cause, type Context, Effect, Fiber, Option, Pipeable, Predicate, Schema, SchemaIssue, type Scope, Semaphore, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import * as Form from "./Form.js"
import * as Lens from "./Lens.js"
@@ -23,7 +23,14 @@ extends Form.Form<readonly [], A, I, never, never> {
readonly validationFiber: View.View<Option.Option<Fiber.Fiber<A, Schema.SchemaError>>, never, never>
readonly run: Effect.Effect<void>
readonly submit: Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, Cause.NoSuchElementError>
readonly submit: Effect.Effect<
Option.Option<Mutation.FinalMutationState<
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
MA, ME
>>,
Cause.NoSuchElementError,
never
>
}
export class MutationFormImpl<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, in out MR = never>
@@ -79,17 +86,20 @@ extends Pipeable.Class implements MutationForm<A, I, RD, RE, MA, ME, MR> {
this.canCommit = Effect.succeed(this).pipe(
Effect.map(self => View.map(
View.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.state),
([value, issues, validationFiber, result]) => (
([value, issues, validationFiber, state]) => (
Option.isSome(value) &&
Array.isReadonlyArrayEmpty(issues) &&
Option.isNone(validationFiber) &&
!AsyncResult.isWaiting(result)
!AsyncResult.isWaiting(state.result)
),
)),
View.unwrap,
)
this.isCommitting = Effect.succeed(this).pipe(
Effect.map(self => View.map(self.mutation.state, AsyncResult.isWaiting)),
Effect.map(self => View.map(
self.mutation.state,
state => AsyncResult.isWaiting(state.result),
)),
View.unwrap,
)
}
@@ -112,7 +122,7 @@ extends Pipeable.Class implements MutationForm<A, I, RD, RE, MA, ME, MR> {
Effect.tap(() => Lens.set(this.issues, Array.empty())),
Effect.flatMap(value => Lens.set(this.value, Option.some(value))),
Effect.catchIf(
SchemaError.isSchemaError,
Schema.isSchemaError,
error => Lens.set(this.issues, SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues),
),
@@ -130,22 +140,36 @@ extends Pipeable.Class implements MutationForm<A, I, RD, RE, MA, ME, MR> {
)
}
get submit(): Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, Cause.NoSuchElementError, never> {
get submit(): Effect.Effect<
Option.Option<Mutation.FinalMutationState<
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
MA, ME
>>,
Cause.NoSuchElementError,
never
> {
return Lens.get(this.value).pipe(
Effect.flatMap(Effect.fromOption),
Effect.flatMap(value => this.submitValue(value)),
)
}
submitValue(value: A): Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, never, never> {
submitValue(value: A): Effect.Effect<
Option.Option<Mutation.FinalMutationState<
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
MA, ME
>>,
never,
never
> {
return Effect.when(
Effect.tap(
this.mutation.mutate([value, this as any]),
result => AsyncResult.isFailure(result)
state => AsyncResult.isFailure(state.result)
? Option.match(
Array.findFirst(
result.cause.reasons,
reason => Cause.isFailReason(reason) && SchemaError.isSchemaError(reason.error)
state.result.cause.reasons,
reason => Cause.isFailReason(reason) && Schema.isSchemaError(reason.error)
? Option.some(reason.error)
: Option.none(),
),
@@ -196,18 +220,13 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
)
})
export declare namespace service {
export interface Options<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, out MR = never>
extends make.Options<A, I, RD, RE, MA, ME, MR> {}
}
export const service = <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never>(
options: service.Options<A, I, RD, RE, MA, ME, MR>
export const thenRun = <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never, E = never, R = never>(
self: Effect.Effect<MutationForm<A, I, RD, RE, MA, ME, MR>, E, R>,
): Effect.Effect<
MutationForm<A, I, RD, RE, MA, ME, MR>,
never,
Scope.Scope | RD | RE | MR
E,
Scope.Scope | R
> => Effect.tap(
make(options),
self,
form => Effect.forkScoped(form.run),
)
+1 -1
View File
@@ -11,7 +11,7 @@ export const useFromReactiveValues = Effect.fnUntraced(function* <const A extend
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
yield* Component.useReactEffect(() => Effect.flatMap(
PubSub.isShutdown(pubsub),
shutdown => shutdown ? Effect.succeed(undefined) : Effect.asVoid(PubSub.publish(pubsub, values)),
shutdown => shutdown ? Effect.void : Effect.asVoid(PubSub.publish(pubsub, values)),
), values)
return pubsub
})
+47 -3
View File
@@ -1,4 +1,5 @@
import { Effect, type Scope, Stream } from "effect"
import { Effect, Schedule, type Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { AsyncResult } from "effect/unstable/reactivity"
import { describe, expect, it } from "vitest"
import * as Query from "./Query.js"
@@ -79,6 +80,49 @@ describe("Query", () => {
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 () => {
let calls = 0
const key = staticKey<readonly [number]>([1])
@@ -136,14 +180,14 @@ describe("Query", () => {
const key = staticKey<readonly [number]>([1])
const effect = Effect.gen(function*() {
const query = yield* Query.service({
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
}).pipe(Query.thenRun)
const latestFinalState = yield* Effect.sleep("1 millis").pipe(
Effect.andThen(View.get(query.latestFinalState)),
+44 -9
View File
@@ -1,4 +1,4 @@
import { Cause, type Context, Duration, Effect, Equal, type Equivalence, Exit, Fiber, Option, Pipeable, Predicate, PubSub, Ref, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
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 { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js"
import * as QueryClient from "./QueryClient.js"
@@ -410,17 +410,52 @@ export const make = Effect.fnUntraced(function* <K, A, E = never, R = never>(
)
})
export const service = <K, A, E = never, R = never>(
options: make.Options<K, A, E, R>
): Effect.Effect<
Query<K, A, E, R>,
Cause.NoSuchElementError,
Scope.Scope | QueryClient.QueryClient | R
> => Effect.tap(
make(options),
export const thenRun = <K, A, E = never, R = never, E2 = never, R2 = never>(
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | R2> => Effect.tap(
self,
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>
extends Lens.LensImpl<QueryState<K, A, E>, never, never, never, never> {
+5 -9
View File
@@ -115,18 +115,14 @@ export const make = Effect.fnUntraced(function* (
)
})
export declare namespace service {
export interface Options extends make.Options {}
}
export const service = (
options?: service.Options
): Effect.Effect<QueryClientService, Cause.NoSuchElementError, Scope.Scope> => Effect.tap(
make(options),
export const thenRun = <E = never, R = never>(
self: Effect.Effect<QueryClientService, E, R>,
): Effect.Effect<QueryClientService, E, Scope.Scope | R> => Effect.tap(
self,
client => Effect.forkScoped(client.run),
)
export const layer = (options?: service.Options) => Layer.effect(QueryClient, service(options))
export const layer = (options?: make.Options) => Layer.effect(QueryClient, thenRun(make(options)))
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey")
+2 -1
View File
@@ -1,4 +1,5 @@
{
"$schema": "../../node_modules/@effect/tsgo/schema.json",
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
@@ -35,5 +36,5 @@
]
},
"include": ["./src"],
"include": ["./src"]
}
+3 -3
View File
@@ -27,15 +27,15 @@
"vite": "^8.0.16"
},
"dependencies": {
"@effect/platform-browser": "4.0.0-beta.101",
"@effect/platform-browser": "4.0.0-rc.109",
"@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.101",
"effect": "4.0.0-rc.109",
"effect-view": "workspace:*",
"react-icons": "^5.6.0"
},
"overrides": {
"@types/react": "^19.2.15",
"effect": "4.0.0-beta.101",
"effect": "4.0.0-rc.109",
"react": "^19.2.6"
}
}
+4 -2
View File
@@ -26,11 +26,13 @@ const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() {
}))
const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() {
const form = yield* MutationForm.service({
const form = yield* MutationForm.make({
schema: RegisterSchema,
initialEncodedValue: { email: "", password: "" },
f: ([value]) => Effect.log(`Registered ${value.email}`),
})
}).pipe(
MutationForm.thenRun,
)
const emailField = Form.focusObjectOn(form, "email")
const passwordField = Form.focusObjectOn(form, "password")
+8 -17
View File
@@ -41,20 +41,7 @@ class AppState extends Context.Service<AppState, {
}
const LensFormPageView = Component.make("LensFormPageView")(function*() {
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("LensForm route unmounted"))
yield* Console.log("LensForm route mounted")
}))
const context = yield* Component.useLayer(AppState.layer)
const UserProfileEditor = yield* Effect.provide(UserProfileEditorView.use, context)
return <UserProfileEditor />
})
const UserProfileEditorView = Component.make("UserProfileEditorView")(function*() {
const LensFormPageView = Component.make("LensFormPage")(function*() {
const appState = yield* AppState
const [
@@ -65,14 +52,16 @@ const UserProfileEditorView = Component.make("UserProfileEditorView")(function*(
Stream.runForEach(Lens.changes(appState.lens), Console.log)
)
const form = yield* LensForm.service({
const form = yield* LensForm.make({
schema: UserProfileSchema,
target: Lens.focusObjectOn(appState.lens, "currentUser"),
initialEncodedValue: {
email: "",
password: "",
},
})
}).pipe(
LensForm.thenRun,
)
const emailField = Form.focusObjectOn(form, "email")
const passwordField = Form.focusObjectOn(form, "password")
@@ -104,7 +93,9 @@ const UserProfileEditorView = Component.make("UserProfileEditorView")(function*(
</form>
</Container>
)
})
}).pipe(
Component.provide(AppState.layer),
)
export const Route = createFileRoute("/lensform")({
+9 -7
View File
@@ -36,16 +36,18 @@ const QueryRouteComponent = Component.make("QueryRouteView")(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({
const query = yield* Query.make({
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)),
Effect.tap(Effect.sleep("500 millis")),
Effect.flatMap(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.flatMap(response => response.json),
Effect.flatMap(Schema.decodeUnknownEffect(Post)),
),
staleTime: "10 seconds",
})
}).pipe(
Query.thenRun,
)
const mutation = yield* Mutation.make({
f: ([id]: [id: number]) => HttpClient.HttpClient.pipe(
@@ -85,7 +87,7 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
</Button>
</Flex>
<PostResultView result={mutationState} />
<PostResultView result={mutationState.result} />
<Button onClick={() => runSync(mutation.mutateView([id]))}>
Mutate