Compare commits

Author SHA1 Message Date
Julien Valverdé 2a76f6bb92 Merge branch 'next' into i18n
Lint / lint (push) Successful in 59s
2026-08-23 03:33:07 +02: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
Julien Valverdé 661064d940 Add base I18n implementation
Lint / lint (push) Successful in 27s
2026-08-16 17:55: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
36 changed files with 1207 additions and 243 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: steps:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
- name: Clone repo with:
bun-version: "1.4"
- name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
+10 -6
View File
@@ -11,6 +11,8 @@ jobs:
steps: steps:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
with:
bun-version: "1.4"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Login to Container Registry - name: Login to Container Registry
@@ -20,7 +22,7 @@ jobs:
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }} password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Clone repo - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
@@ -40,6 +42,13 @@ jobs:
access: public access: public
token: ${{ secrets.NPM_TOKEN }} token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org 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:
@@ -48,11 +57,6 @@ jobs:
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 - name: Generate Docker metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
+4 -7
View File
@@ -9,14 +9,16 @@ jobs:
steps: steps:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
with:
bun-version: "1.4"
- 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 - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Clone repo - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
@@ -32,11 +34,6 @@ jobs:
- 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 - name: Generate Docker metadata
id: meta id: meta
uses: docker/metadata-action@v5 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": { "editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit" "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 --from=bun /usr/local/bin/bun /usr/local/bin/bunx /usr/local/bin/
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app
@@ -9,4 +9,4 @@ RUN bun install --frozen-lockfile && \
bun run build && \ bun run build && \
bun clean:cache && \ bun clean:cache && \
bun clean:modules && \ 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", "name": "@effect-view/monorepo",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.5.5", "@biomejs/biome": "^2.5.5",
"@effect/language-service": "^0.87.1", "@effect/tsgo": "0.36.4",
"@types/bun": "^1.3.14", "@types/bun": "^1.4.0",
"npm-check-updates": "^23.0.0", "npm-check-updates": "^23.0.0",
"npm-sort": "^0.0.4", "npm-sort": "^0.0.4",
"turbo": "^2.10.7", "turbo": "^2.10.7",
@@ -82,21 +82,21 @@
}, },
"packages/effect-view": { "packages/effect-view": {
"name": "effect-view", "name": "effect-view",
"version": "0.1.0", "version": "0.1.3",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0", "@standard-schema/spec": "^1.1.0",
"effect-lens": "^2.0.1-beta.101", "effect-lens": "2.0.1-rc.109",
}, },
"devDependencies": { "devDependencies": {
"@effect/platform-browser": "4.0.0-beta.101", "@effect/platform-browser": "4.0.0-rc.109",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.101", "effect": "4.0.0-rc.109",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"vitest": "^3.2.4", "vitest": "^3.2.4",
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"effect": "4.0.0-beta.101", "effect": "4.0.0-rc.109",
"react": "^19.2.0", "react": "^19.2.0",
}, },
}, },
@@ -104,9 +104,9 @@
"name": "@effect-view/example", "name": "@effect-view/example",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@effect/platform-browser": "4.0.0-beta.101", "@effect/platform-browser": "4.0.0-rc.109",
"@radix-ui/themes": "^3.3.0", "@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.101", "effect": "4.0.0-rc.109",
"effect-view": "workspace:*", "effect-view": "workspace:*",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
}, },
@@ -578,12 +578,26 @@
"@effect-view/vite-plugin": ["@effect-view/vite-plugin@workspace:packages/vite-plugin"], "@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": ["@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/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/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=="], "@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/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=="], "@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=="], "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=="], "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=="], "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=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
@@ -2046,8 +2060,6 @@
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "@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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+4 -3
View File
@@ -1,11 +1,12 @@
{ {
"name": "@effect-view/monorepo", "name": "@effect-view/monorepo",
"packageManager": "bun@1.3.14", "packageManager": "bun@1.4.0",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"./packages/*" "./packages/*"
], ],
"scripts": { "scripts": {
"prepare": "effect-tsgo patch --typescript --no-oxlint",
"lint:tsc": "turbo lint:tsc", "lint:tsc": "turbo lint:tsc",
"lint:biome": "turbo lint:biome", "lint:biome": "turbo lint:biome",
"test": "turbo test", "test": "turbo test",
@@ -17,8 +18,8 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.5.5", "@biomejs/biome": "^2.5.5",
"@effect/language-service": "^0.87.1", "@effect/tsgo": "0.36.4",
"@types/bun": "^1.3.14", "@types/bun": "^1.4.0",
"npm-check-updates": "^23.0.0", "npm-check-updates": "^23.0.0",
"npm-sort": "^0.0.4", "npm-sort": "^0.0.4",
"turbo": "^2.10.7", "turbo": "^2.10.7",
+28 -25
View File
@@ -54,11 +54,9 @@ valid decoded value should go:
## Create forms once ## Create forms once
`MutationForm.service` and `LensForm.service` are Effect constructors, not `MutationForm.make` and `LensForm.make` construct forms. Pipe them through the
hooks. Create each root form once and keep it stable. For a component-owned module's `run` operator to start validation or synchronization in the current
form, call the constructor inside `Component.useOnMount`; for a form shared by scope. Create each root form once and keep it stable.
multiple components, create it in an Effect service. Update the existing form's
Lenses rather than reconstructing the form during render.
## MutationForm: validate, then submit ## MutationForm: validate, then submit
@@ -73,7 +71,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.service({ MutationForm.make({
schema: ProfileSchema, schema: ProfileSchema,
initialEncodedValue: { initialEncodedValue: {
displayName: "", displayName: "",
@@ -84,7 +82,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([
@@ -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 Schema transformations happen before the mutation and schema issues prevent an
invalid draft from being submitted. invalid draft from being submitted.
`MutationForm.service` also starts initial validation in the current scope. In `MutationForm.thenRun` starts initial validation. In the example above,
the example above, `Component.useOnMount` keeps that validation and the form's `Component.useOnMount` keeps that validation and the form's mutation work tied
mutation work tied to the component that owns them. to the component that owns them.
## LensForm: validate, then synchronize ## 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, schema: ProfileSchema,
target: profile, target: profile,
}) }).pipe(LensForm.thenRun)
return [form, profile] as const 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. `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.service` obtains the initial draft by encoded target. Otherwise `LensForm.make` obtains the initial draft by encoding
encoding the target through the schema. the target through the schema.
## Focus into subforms ## 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 returns `value` and `setValue` together with `enabled` and `setEnabled`, making
it suitable for an optional input that the user can toggle on and off. 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 `useInput` and `useOptionalInput` can be used directly, but they are primarily
building blocks for your own reusable input components. A component library building blocks for your own reusable input components. A component library
can wrap them to consistently handle labels, validation issues, validating 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 input = yield* Form.useInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* View.useAll([ const [issues] = yield* View.useAll([props.form.issues])
props.form.issues, const { isValidating, isCommitting } = yield* Form.useStatus(props.form)
props.form.isValidating,
props.form.isCommitting,
])
return ( return (
<Flex direction="column" gap="1"> <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. | | `isCommitting` | Whether a mutation or target write is in progress. |
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`, `Form.useOptionalInput`, and `Form.useStatus`, while
library remains responsible for rendering controls and layout. your own component library remains responsible for rendering controls and
layout.
## Example: local date input, UTC domain value ## Example: local date input, UTC domain value
@@ -417,7 +420,7 @@ the mutation receives UTC:
```tsx ```tsx
import { DateTime, Effect, Schema } from "effect" 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" import { DateTimeUtcFromZonedInput } from "./DateTimeUtcFromZonedInput"
const AppointmentSchema = Schema.Struct({ const AppointmentSchema = Schema.Struct({
@@ -427,21 +430,21 @@ const AppointmentSchema = Schema.Struct({
const AppointmentView = Component.make("Appointment")(function* () { const AppointmentView = Component.make("Appointment")(function* () {
const [form, startsAtField] = yield* Component.useOnMount(() => const [form, startsAtField] = yield* Component.useOnMount(() =>
Effect.gen(function* () { Effect.gen(function* () {
const form = yield* MutationForm.service({ const form = yield* MutationForm.make({
schema: AppointmentSchema, schema: AppointmentSchema,
initialEncodedValue: { startsAt: "" }, initialEncodedValue: { startsAt: "" },
f: ([appointment]) => f: ([appointment]) =>
Effect.log( Effect.log(
`Saving ${DateTime.formatIso(appointment.startsAt)}`, `Saving ${DateTime.formatIso(appointment.startsAt)}`,
), ),
}) }).pipe(MutationForm.thenRun)
return [form, Form.focusObjectOn(form, "startsAt")] as const return [form, Form.focusObjectOn(form, "startsAt")] as const
}), }),
) )
const startsAt = yield* Form.useInput(startsAtField) const startsAt = yield* Form.useInput(startsAtField)
const [canCommit] = yield* View.useAll([form.canCommit]) const { canCommit } = yield* Form.useStatus(form)
const runPromise = yield* Component.useRunPromise() const runPromise = yield* Component.useRunPromise()
return ( return (
+45 -10
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 `Lens`/`View` when state needs Effect integration, subscriptions, focusing, or
sharing. The [State Management guide](./state-management) covers that model. 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 Use `Component.provide` to give a component a static layer:
services. It builds the layer in a scope and returns the resulting Effect
context:
```tsx title="src/GreetingPageView.tsx" ```tsx title="src/GreetingViewLive.tsx"
import { Effect } from "effect"
import { Component } from "effect-view" import { Component } from "effect-view"
import { GreetingView } from "./GreetingView" import { GreetingView } from "./GreetingView"
import { GreetingService } from "./GreetingService" import { GreetingService } from "./GreetingService"
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")( export const GreetingPageView = Component.make("GreetingPage")(
function* () { function* (props: { readonly greeting: string }) {
const context = yield* Component.useLayer(GreetingService.layer) const layer = React.useMemo(
const Greeting = yield* GreetingView.use.pipe( () => Layer.succeed(GreetingService, {
Effect.provide(context), 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" /> 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 ## 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, `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. the query effect when necessary.
`Query.service` is an Effect constructor, not a hook. Create each query instance `Query.make` constructs the Query and `Query.thenRun` starts it in the current
once and keep it stable. In a component, the usual place is 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 `Component.useOnMount`; a query shared by multiple components can instead be
owned by an Effect service. Change the existing query's reactive key rather owned by an Effect service. Change the existing query's reactive key rather
than reconstructing the query during render. 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), yield* SubscriptionRef.make(["post", 1 as number] as const),
) )
const query = yield* Query.service({ const query = yield* Query.make({
key, key,
staleTime: "1 minute", staleTime: "1 minute",
f: ([, id]) => f: ([, id]) =>
@@ -95,7 +95,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,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 as query keys. Supply `keyEquivalence` when the key needs different equality
semantics. 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 `Component.useOnMount` call above keeps both the query instance and its query
function identity stable for the component's lifetime. 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 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
@@ -262,12 +291,12 @@ 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.service({ const query = yield* Query.make({
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 refresh depends on the optional `@effect/platform-browser`
+1 -1
View File
@@ -49,6 +49,6 @@
] ]
}, },
"engines": { "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 Effect.runPromise(Lens.set(right, "b"))
await waitFor(() => expect(values).toContainEqual([2, "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 () => { 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 Effects and services from a component body, let scopes follow the React
lifecycle, and return ordinary JSX. 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 > your application uses Effect v3, use the legacy
> [`effect-fc` package](https://www.npmjs.com/package/effect-fc) instead. > [`effect-fc` package](https://www.npmjs.com/package/effect-fc) instead.
```bash ```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 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 ## Requirements
- React 19.2 or newer - React 19.2 or newer
- **Effect v4 beta** (`effect@beta`) - **Effect v4 RC** (`effect@rc`)
- TypeScript and `@types/react` for TypeScript projects - TypeScript and `@types/react` for TypeScript projects
Effect View is renderer-independent and does not require `react-dom`. Effect View is renderer-independent and does not require `react-dom`.
+17 -6
View File
@@ -1,11 +1,14 @@
{ {
"name": "effect-view", "name": "effect-view",
"description": "Write React function components with Effect", "description": "Write React function components with Effect",
"version": "0.1.0", "version": "0.1.4",
"type": "module", "type": "module",
"files": [ "files": [
"./README.md", "./README.md",
"./dist" "./src/**/*.ts",
"./dist/**/*.js",
"./dist/**/*.js.map",
"./dist/**/*.d.ts"
], ],
"license": "MIT", "license": "MIT",
"repository": { "repository": {
@@ -29,6 +32,10 @@
"types": "./dist/Form.d.ts", "types": "./dist/Form.d.ts",
"default": "./dist/Form.js" "default": "./dist/Form.js"
}, },
"./I18n": {
"types": "./dist/I18n.d.ts",
"default": "./dist/I18n.js"
},
"./Lens": { "./Lens": {
"types": "./dist/Lens.d.ts", "types": "./dist/Lens.d.ts",
"default": "./dist/Lens.js" "default": "./dist/Lens.js"
@@ -37,6 +44,10 @@
"types": "./dist/LensForm.d.ts", "types": "./dist/LensForm.d.ts",
"default": "./dist/LensForm.js" "default": "./dist/LensForm.js"
}, },
"./Locale": {
"types": "./dist/Locale.d.ts",
"default": "./dist/Locale.js"
},
"./Memoized": { "./Memoized": {
"types": "./dist/Memoized.d.ts", "types": "./dist/Memoized.d.ts",
"default": "./dist/Memoized.js" "default": "./dist/Memoized.js"
@@ -97,19 +108,19 @@
"clean:modules": "rm -rf node_modules" "clean:modules": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@effect/platform-browser": "4.0.0-beta.101", "@effect/platform-browser": "4.0.0-rc.109",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.101", "effect": "4.0.0-rc.109",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"effect": "4.0.0-beta.101", "effect": "4.0.0-rc.109",
"react": "^19.2.0" "react": "^19.2.0"
}, },
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0", "@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 ValueService extends Context.Service<ValueService, { readonly value: string }>()("ValueService") {}
class ParentService extends Context.Service<ParentService, { readonly prefix: string }>()("ParentService") {}
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
}) })
describe("Component", () => { 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 () => { it("does not rerun useOnMount across rerenders after Strict Mode initialization", async () => {
const onMount = vi.fn(() => Effect.succeed("mounted")) const onMount = vi.fn(() => Effect.succeed("mounted"))
const runtime = ReactRuntime.make(Layer.empty) const runtime = ReactRuntime.make(Layer.empty)
+53 -2
View File
@@ -607,6 +607,57 @@ export const withOptions: {
Object.getPrototypeOf(self), 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, * Wraps an Effect View 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 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) 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 {} export interface Options extends useOnChange.Options {}
} }
@@ -1112,7 +1163,7 @@ export declare namespace useContext {
*/ */
export const useLayer = <ROut, E, RIn>( export const useLayer = <ROut, E, RIn>(
layer: Layer.Layer<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.Effect<Context.Context<ROut>, E, RIn | Scope.Scope> => useOnChange(() => Effect.flatMap(
Effect.context<RIn>(), Effect.context<RIn>(),
context => Layer.build(Layer.provide(layer, Layer.succeedContext(context))), 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 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 { 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 Component from "./Component.js"
import * as Lens from "./Lens.js" import * as Lens from "./Lens.js"
import * as View from "./View.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 namespace useInput {
export interface Options { export interface Options {
readonly debounce?: Duration.Input readonly debounce?: Duration.Input
+113
View File
@@ -0,0 +1,113 @@
import { Effect, Layer } from "effect"
import { describe, expect, it } from "vitest"
import * as I18n from "./I18n.js"
import * as Locale from "./Locale.js"
const Messages = I18n.contract({
title: I18n.text(),
welcome: I18n.message<{ readonly name: string }>(),
})
const English = I18n.catalog(Messages, {
title: "Welcome",
welcome: ({ name }) => `Hello, ${name}!`,
})
const French = I18n.catalog(Messages, {
title: "Bienvenue",
welcome: ({ name }) => `Bonjour, ${name}!`,
})
const AppI18n = I18n.make({
contract: Messages,
fallback: "en",
loaders: {
en: () => Effect.succeed(English),
fr: () => Effect.succeed(French),
},
})
const _assertTypeSafety = (service: I18n.I18nService<typeof Messages, "en" | "fr">) => Effect.gen(function*() {
yield* service.translate("title")
yield* service.translate("welcome", { name: "Ada" })
// @ts-expect-error Unknown message keys are rejected.
yield* service.translate("missing")
// @ts-expect-error Parameterized messages require their parameters.
yield* service.translate("welcome")
// @ts-expect-error Parameters must match the message contract.
yield* service.translate("welcome", { userId: 1 })
})
const run = <A, E>(
effect: Effect.Effect<A, E, I18n.I18nService<typeof Messages, "en" | "fr">>,
language: string,
) => effect.pipe(
Effect.provide(
AppI18n.layer.pipe(
Layer.provide(Layer.succeed(Locale.Locale, {
language,
languages: [language],
})),
),
),
Effect.runPromise,
)
describe("I18n", () => {
it("selects the exact preferred locale", async () => {
const result = await run(Effect.gen(function*() {
const i18n = yield* AppI18n.service
return [i18n.locale, yield* i18n.translate("title"), yield* i18n.translate("welcome", { name: "Ada" })]
}), "fr")
expect(result).toEqual(["fr", "Bienvenue", "Bonjour, Ada!"])
})
it("matches a base language before using the fallback", async () => {
const result = await run(Effect.gen(function*() {
const i18n = yield* AppI18n.service
return [i18n.locale, yield* i18n.translate("title")]
}), "fr-CA")
expect(result).toEqual(["fr", "Bienvenue"])
})
it("uses the configured fallback when no locale matches", async () => {
const result = await run(Effect.gen(function*() {
const i18n = yield* AppI18n.service
return [i18n.locale, yield* i18n.translate("title")]
}), "de")
expect(result).toEqual(["en", "Welcome"])
})
it("supports asynchronous code-split loaders", async () => {
const i18n = I18n.make({
contract: Messages,
fallback: "en",
loaders: {
en: () => Effect.promise(async () => English),
fr: () => Effect.promise(async () => French),
},
})
const result = await Effect.gen(function*() {
const service = yield* i18n.service
return yield* service.translate("welcome", { name: "Grace" })
}).pipe(
Effect.provide(
i18n.layer.pipe(
Layer.provide(Layer.succeed(Locale.Locale, {
language: "en",
languages: ["en"],
})),
),
),
Effect.runPromise,
)
expect(result).toBe("Hello, Grace!")
})
})
+173
View File
@@ -0,0 +1,173 @@
import { Context, Effect, Layer } from "effect"
import * as Locale from "./Locale.js"
export interface MessageDefinition<Params = never> {
readonly _tag: "MessageDefinition"
readonly _params?: Params
}
export type Contract = Readonly<Record<string, MessageDefinition<unknown>>>
export type MessageParams<Definition> = Definition extends MessageDefinition<infer Params>
? Params
: never
export type MessageKey<Messages extends Contract> = keyof Messages & string
export type Translation<Definition> = [MessageParams<Definition>] extends [never]
? string
: (params: MessageParams<Definition>) => string
export type Catalog<Messages extends Contract> = {
readonly [Key in keyof Messages]: Translation<Messages[Key]>
}
export const text = (): MessageDefinition => ({
_tag: "MessageDefinition",
})
export const message = <Params>(): MessageDefinition<Params> => ({
_tag: "MessageDefinition",
})
export const contract = <const Messages extends Contract>(
messages: Messages,
): Messages => messages
export const catalog = <Messages extends Contract>(
_contract: Messages,
translations: Catalog<Messages>,
): Catalog<Messages> => translations
export class CatalogLoadError extends Error {
readonly _tag = "CatalogLoadError"
constructor(
readonly locale: string,
readonly cause: unknown,
) {
super(`Unable to load the ${locale} translation catalog`)
}
}
export class MissingMessageError extends Error {
readonly _tag = "MissingMessageError"
constructor(
readonly locale: string,
readonly key: string,
) {
super(`Missing translation for ${key} in locale ${locale}`)
}
}
export interface I18nService<Messages extends Contract, Language extends string> {
readonly locale: Language
readonly translate: <Key extends MessageKey<Messages>>(
...args: TranslateArguments<Messages, Key>
) => Effect.Effect<string, MissingMessageError>
}
export type TranslateArguments<
Messages extends Contract,
Key extends MessageKey<Messages>,
> = [MessageParams<Messages[Key]>] extends [never]
? [key: Key]
: [key: Key, params: MessageParams<Messages[Key]>]
export type Loader<Messages extends Contract> = () => Effect.Effect<Catalog<Messages>, unknown>
export type LoaderMap<Messages extends Contract> = Readonly<Record<string, Loader<Messages>>>
export interface I18n<Messages extends Contract, Language extends string> {
readonly service: Context.Service<
I18nService<Messages, Language>,
I18nService<Messages, Language>
>
readonly layer: Layer.Layer<
I18nService<Messages, Language>,
CatalogLoadError,
Locale.Locale
>
}
export interface MakeOptions<Messages extends Contract, Loaders extends LoaderMap<Messages>> {
readonly contract: Messages
readonly loaders: Loaders
readonly fallback: keyof Loaders & string
/** Optional stable identifier when an application creates multiple translators. */
readonly key?: string
}
let nextServiceId = 0
const baseLanguage = (language: string): string => language.toLowerCase().split("-", 1)[0] ?? ""
const resolveLanguage = <Loaders extends Readonly<Record<string, unknown>>>(
preferred: readonly string[],
loaders: Loaders,
fallback: keyof Loaders & string,
): keyof Loaders & string => {
const available = Object.keys(loaders) as Array<keyof Loaders & string>
for (const language of preferred) {
const exact = available.find(candidate => candidate.toLowerCase() === language.toLowerCase())
if (exact !== undefined) return exact
const base = baseLanguage(language)
const languageOnly = available.find(candidate => baseLanguage(candidate) === base)
if (languageOnly !== undefined) return languageOnly
}
return fallback
}
export const make = <
const Messages extends Contract,
const Loaders extends LoaderMap<Messages>,
>(
options: MakeOptions<Messages, Loaders>,
): I18n<Messages, keyof Loaders & string> => {
type Language = keyof Loaders & string
const service = Context.Service<I18nService<Messages, Language>>(
options.key ?? `@effect-view/I18n/I18n/${nextServiceId++}`,
)
const layer = Layer.effect(
service,
Effect.gen(function*() {
const locale = yield* Locale.Locale
const language = resolveLanguage(locale.languages, options.loaders, options.fallback) as Language
const load = options.loaders[language]
const translations = yield* load().pipe(
Effect.mapError(error => new CatalogLoadError(language, error)),
)
return {
locale: language,
translate: <Key extends MessageKey<Messages>>(
...args: TranslateArguments<Messages, Key>
): Effect.Effect<string, MissingMessageError> => {
const key = args[0]
const translation = translations[key]
if (translation === undefined) {
return Effect.fail(new MissingMessageError(language, key))
}
if (typeof translation === "function") {
const format = translation as (params: MessageParams<Messages[Key]>) => string
return Effect.sync(() => format(args[1] as MessageParams<Messages[Key]>))
}
return Effect.succeed(translation as string)
},
}
}),
)
return { service, layer }
}
+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 const thenRun = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never, E = never, R = never>(
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> self: Effect.Effect<LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>, E, R>,
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>,
Schema.SchemaError | TER, E,
Scope.Scope | RD | RE | TRR | TRW Scope.Scope | R
> => Effect.tap( > => Effect.tap(
make(options), self,
form => Effect.forkScoped(form.run), form => Effect.forkScoped(form.run),
) )
+74
View File
@@ -0,0 +1,74 @@
import { Effect } from "effect"
import { describe, expect, it } from "vitest"
import * as Locale from "./Locale.js"
const readLocale = Effect.gen(function*() {
const locale = yield* Locale.Locale
return locale
})
describe("Locale", () => {
it("reads the ordered browser language preferences", async () => {
Object.defineProperty(globalThis.navigator, "languages", {
configurable: true,
value: ["fr-CA", "fr", "en-US"],
})
Object.defineProperty(globalThis.navigator, "language", {
configurable: true,
value: "fr-CA",
})
const locale = await Effect.runPromise(
readLocale.pipe(Effect.provide(Locale.layerBrowser())),
)
expect(locale).toEqual({
language: "fr-CA",
languages: ["fr-CA", "fr", "en-US"],
})
})
it("uses the singular language when the preference list is empty", async () => {
Object.defineProperty(globalThis.navigator, "languages", {
configurable: true,
value: [],
})
Object.defineProperty(globalThis.navigator, "language", {
configurable: true,
value: "de-DE",
})
const locale = await Effect.runPromise(
readLocale.pipe(Effect.provide(Locale.layerBrowser())),
)
expect(locale).toEqual({
language: "de-DE",
languages: ["de-DE"],
})
})
it("supports a fallback when browser APIs are unavailable", async () => {
const originalNavigator = globalThis.navigator
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: undefined,
})
const locale = await Effect.runPromise(
readLocale.pipe(Effect.provide(Locale.layerBrowser({ fallback: "en-GB" }))),
)
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: originalNavigator,
})
expect(locale).toEqual({
language: "en-GB",
languages: [],
})
})
})
+43
View File
@@ -0,0 +1,43 @@
import { Context, Effect, Layer } from "effect"
export interface LocaleService {
/** The first preferred BCP 47 language tag, when one is available. */
readonly language: string
/** The user's preferred BCP 47 language tags, in preference order. */
readonly languages: readonly string[]
}
export class Locale extends Context.Service<Locale, LocaleService>()(
"@effect-view/Locale/Locale",
) {}
export interface BrowserOptions {
/** Used when the layer is constructed outside a browser, such as during SSR. */
readonly fallback?: string
}
/**
* Reads the browser's preferred languages without accessing `navigator` at
* module evaluation time. This keeps the module safe to import during SSR.
*/
export const fromBrowser = (
options: BrowserOptions = {},
): Effect.Effect<LocaleService> => Effect.sync(() => {
const navigator = globalThis.navigator
const languages = navigator?.languages.length > 0
? [...navigator.languages]
: navigator?.language
? [navigator.language]
: []
return {
language: languages[0] ?? options.fallback ?? "en",
languages,
}
})
/** Provides the user's browser language preferences as an Effect service. */
export const layerBrowser = (
options: BrowserOptions = {},
): Layer.Layer<Locale> => Layer.effect(Locale, fromBrowser(options))
+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")
})
})
+140 -54
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 { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js" import * as Lens from "./Lens.js"
import * as View from "./View.js" import * as View from "./View.js"
@@ -16,11 +16,26 @@ extends Pipeable.Pipeable {
readonly latestKey: View.View<Option.Option<K>> readonly latestKey: View.View<Option.Option<K>>
readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>> readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
readonly state: View.View<AsyncResult.AsyncResult<A, E>> readonly state: View.View<LatestMutationState<K, A, E>>
readonly latestFinalResult: View.View<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<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>> mutate(key: K): Effect.Effect<FinalMutationState<K, A, E>>
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<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) 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 latestKey: Lens.Lens<Option.Option<K>>,
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>, readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
readonly state: Lens.Lens<AsyncResult.AsyncResult<A, E>>, readonly state: Lens.Lens<LatestMutationState<K, A, E>>,
readonly latestFinalResult: Lens.Lens<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>, readonly latestFinalState: Lens.Lens<Option.Option<FinalMutationState<K, A, E>>>,
) { ) {
super() 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( return Lens.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(this.start(key)), Effect.andThen(this.start(key)),
Effect.flatMap(state => this.watch(state)), Effect.flatMap(state => this.watch(state)),
Effect.provide(this.context), 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( return Lens.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(this.start(key)), Effect.andThen(this.start(key)),
Effect.tap(state => Effect.forkScoped(this.watch(state))), 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< start(key: K): Effect.Effect<
View.View<AsyncResult.AsyncResult<A, E>>, View.View<MutationState<K, A, E>>,
never, never,
Scope.Scope | R Scope.Scope | R
> { > {
return Effect.gen({ self: this }, function*() { return Effect.gen({ self: this }, function*() {
const previous = yield* Lens.get(this.latestFinalResult) const previous: MutationState<K, A, E> = Option.getOrElse(yield* Lens.get(this.latestFinalState), () => ({
const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make<AsyncResult.AsyncResult<A, E>>( key: Option.some(key) as Option.Some<K>,
Option.getOrElse(previous, () => AsyncResult.initial(false)) result: AsyncResult.initial(),
)) }))
const state = yield* makeMutationStateLens(previous)
const fiber = yield* Effect.forkScoped(Effect.andThen( const fiber = yield* Effect.forkScoped(Effect.andThen(
Lens.update(state, AsyncResult.match({ Lens.update<MutationState<K, A, E>, never, never, never, never>(
onInitial: () => AsyncResult.initial(true),
onSuccess: v => AsyncResult.success(v.value, {
waiting: true,
}),
onFailure: v => AsyncResult.failure(v.cause, {
waiting: true,
previousSuccess: v.previousSuccess,
})
})),
Effect.onExit(this.f(key), exit => Lens.update(
state, state,
previous => Exit.match(exit, { previous => AsyncResult.match(previous.result, {
onSuccess: v => AsyncResult.success(v), onInitial: () => ({
onFailure: c => AsyncResult.match(previous, { key: previous.key,
onInitial: () => AsyncResult.failure(c), result: AsyncResult.initial(true),
onSuccess: v => AsyncResult.failure(c, {
previousSuccess: Option.some(v),
}),
onFailure: v => AsyncResult.failure(c, {
previousSuccess: v.previousSuccess,
})
}), }),
}), onSuccess: result => ({
).pipe( key: previous.key,
Effect.andThen(Effect.all([ result: AsyncResult.success(result.value, {
Effect.fiberId, waiting: true,
Lens.get(this.fiber), }),
])), }),
Effect.flatMap(([fiberId, fiber]) => Option.match(fiber, { onFailure: result => ({
onSome: v => Equal.equals(fiberId, v.id) key: previous.key,
? Lens.set(this.fiber, Option.none()) result: AsyncResult.failure(result.cause, {
: Effect.void, waiting: true,
onNone: () => Effect.void, previousSuccess: result.previousSuccess,
})), }),
}),
}
)), )),
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 => ({
key: previous.key,
result: AsyncResult.success(v),
}),
onFailure: c => Cause.hasInterruptsOnly(c)
? previous
: AsyncResult.match(previous.result, {
onInitial: () => ({
key: previous.key,
result: AsyncResult.failure(c),
}),
onSuccess: v => ({
key: previous.key,
result: AsyncResult.failure(c, {
previousSuccess: Option.some(v),
}),
}),
onFailure: v => ({
key: previous.key,
result: AsyncResult.failure(c, {
previousSuccess: v.previousSuccess,
}),
}),
}),
}),
)) as 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)) yield* Lens.set(this.fiber, Option.some(fiber))
@@ -114,15 +156,15 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
} }
watch( watch(
state: View.View<AsyncResult.AsyncResult<A, E>> state: View.View<MutationState<K, A, E>>
): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> { ): Effect.Effect<FinalMutationState<K, A, E>> {
return View.get(state).pipe( return View.get(state).pipe(
Effect.andThen(initial => Stream.runFoldEffect( Effect.andThen(initial => Stream.runFoldEffect(
View.changes(state), View.changes(state),
() => initial, () => initial,
(_, result) => Effect.as(Lens.set(this.state, result), result), (_, result) => Effect.as(Lens.set(this.state, result), result),
) as Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>), ) as Effect.Effect<FinalMutationState<K, A, E>>),
Effect.tap(result => Lens.set(this.latestFinalResult, Option.some(result))), 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<K>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>())), 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<LatestMutationState<K, A, E>>({
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<AsyncResult.Success<A, E> | AsyncResult.Failure<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 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 { AsyncResult } from "effect/unstable/reactivity"
import * as Form from "./Form.js" import * as Form from "./Form.js"
import * as Lens from "./Lens.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 validationFiber: View.View<Option.Option<Fiber.Fiber<A, Schema.SchemaError>>, never, never>
readonly run: Effect.Effect<void> 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> 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( this.canCommit = Effect.succeed(this).pipe(
Effect.map(self => View.map( Effect.map(self => View.map(
View.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.state), View.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.state),
([value, issues, validationFiber, result]) => ( ([value, issues, validationFiber, state]) => (
Option.isSome(value) && Option.isSome(value) &&
Array.isReadonlyArrayEmpty(issues) && Array.isReadonlyArrayEmpty(issues) &&
Option.isNone(validationFiber) && Option.isNone(validationFiber) &&
!AsyncResult.isWaiting(result) !AsyncResult.isWaiting(state.result)
), ),
)), )),
View.unwrap, View.unwrap,
) )
this.isCommitting = Effect.succeed(this).pipe( 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, 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.tap(() => Lens.set(this.issues, Array.empty())),
Effect.flatMap(value => Lens.set(this.value, Option.some(value))), Effect.flatMap(value => Lens.set(this.value, Option.some(value))),
Effect.catchIf( Effect.catchIf(
SchemaError.isSchemaError, Schema.isSchemaError,
error => Lens.set(this.issues, SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues), 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( return Lens.get(this.value).pipe(
Effect.flatMap(Effect.fromOption), Effect.flatMap(Effect.fromOption),
Effect.flatMap(value => this.submitValue(value)), 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( return Effect.when(
Effect.tap( Effect.tap(
this.mutation.mutate([value, this as any]), this.mutation.mutate([value, this as any]),
result => AsyncResult.isFailure(result) state => AsyncResult.isFailure(state.result)
? Option.match( ? Option.match(
Array.findFirst( Array.findFirst(
result.cause.reasons, state.result.cause.reasons,
reason => Cause.isFailReason(reason) && SchemaError.isSchemaError(reason.error) reason => Cause.isFailReason(reason) && Schema.isSchemaError(reason.error)
? Option.some(reason.error) ? Option.some(reason.error)
: Option.none(), : Option.none(),
), ),
@@ -196,18 +220,13 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
) )
}) })
export declare namespace service { export const thenRun = <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never, E = never, R = never>(
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> self: Effect.Effect<MutationForm<A, I, RD, RE, MA, ME, MR>, E, R>,
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>,
never, E,
Scope.Scope | RD | RE | MR Scope.Scope | R
> => Effect.tap( > => Effect.tap(
make(options), self,
form => Effect.forkScoped(form.run), 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)) const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
yield* Component.useReactEffect(() => Effect.flatMap( yield* Component.useReactEffect(() => Effect.flatMap(
PubSub.isShutdown(pubsub), 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) ), values)
return pubsub 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 { 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 "./Query.js"
@@ -79,6 +80,49 @@ 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])
@@ -136,14 +180,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.service({ const query = yield* Query.make({
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)),
+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 { 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"
@@ -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>( export const thenRun = <K, A, E = never, R = never, E2 = never, R2 = never>(
options: make.Options<K, A, E, R> self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
): Effect.Effect< ): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | R2> => Effect.tap(
Query<K, A, E, R>, self,
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> {
+5 -9
View File
@@ -115,18 +115,14 @@ export const make = Effect.fnUntraced(function* (
) )
}) })
export declare namespace service { export const thenRun = <E = never, R = never>(
export interface Options extends make.Options {} self: Effect.Effect<QueryClientService, E, R>,
} ): 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?: 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") export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey")
+2
View File
@@ -1,8 +1,10 @@
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 Form from "./Form.js" export * as Form from "./Form.js"
export * as I18n from "./I18n.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"
export * as Locale from "./Locale.js"
export * as Memoized from "./Memoized.js" export * as Memoized from "./Memoized.js"
export * as Mutation from "./Mutation.js" export * as Mutation from "./Mutation.js"
export * as MutationForm from "./MutationForm.js" export * as MutationForm from "./MutationForm.js"
+2 -1
View File
@@ -1,4 +1,5 @@
{ {
"$schema": "../../node_modules/@effect/tsgo/schema.json",
"compilerOptions": { "compilerOptions": {
// Enable latest features // Enable latest features
"lib": ["ESNext", "DOM"], "lib": ["ESNext", "DOM"],
@@ -35,5 +36,5 @@
] ]
}, },
"include": ["./src"], "include": ["./src"]
} }
+3 -3
View File
@@ -27,15 +27,15 @@
"vite": "^8.0.16" "vite": "^8.0.16"
}, },
"dependencies": { "dependencies": {
"@effect/platform-browser": "4.0.0-beta.101", "@effect/platform-browser": "4.0.0-rc.109",
"@radix-ui/themes": "^3.3.0", "@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.101", "effect": "4.0.0-rc.109",
"effect-view": "workspace:*", "effect-view": "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.101", "effect": "4.0.0-rc.109",
"react": "^19.2.6" "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, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() {
const form = yield* MutationForm.service({ const form = yield* MutationForm.make({
schema: RegisterSchema, schema: RegisterSchema,
initialEncodedValue: { email: "", password: "" }, initialEncodedValue: { email: "", password: "" },
f: ([value]) => Effect.log(`Registered ${value.email}`), f: ([value]) => Effect.log(`Registered ${value.email}`),
}) }).pipe(
MutationForm.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")
+8 -17
View File
@@ -41,20 +41,7 @@ class AppState extends Context.Service<AppState, {
} }
const LensFormPageView = Component.make("LensFormPageView")(function*() { const LensFormPageView = Component.make("LensFormPage")(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 appState = yield* AppState const appState = yield* AppState
const [ const [
@@ -65,14 +52,16 @@ 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.service({ const form = yield* LensForm.make({
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")
@@ -104,7 +93,9 @@ const UserProfileEditorView = Component.make("UserProfileEditorView")(function*(
</form> </form>
</Container> </Container>
) )
}) }).pipe(
Component.provide(AppState.layer),
)
export const Route = createFileRoute("/lensform")({ 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 keyLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const))
const idLens = Lens.focusTupleAt(keyLens, 1) const idLens = Lens.focusTupleAt(keyLens, 1)
const query = yield* Query.service({ const query = yield* Query.make({
key: keyLens, key: keyLens,
f: ([, id]) => HttpClient.HttpClient.pipe( f: ([, id]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("1 second")), Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), Effect.flatMap(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json), Effect.flatMap(response => response.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)), Effect.flatMap(Schema.decodeUnknownEffect(Post)),
), ),
staleTime: "10 seconds", staleTime: "10 seconds",
}) }).pipe(
Query.thenRun,
)
const mutation = yield* Mutation.make({ const mutation = yield* Mutation.make({
f: ([id]: [id: number]) => HttpClient.HttpClient.pipe( f: ([id]: [id: number]) => HttpClient.HttpClient.pipe(
@@ -85,7 +87,7 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
</Button> </Button>
</Flex> </Flex>
<PostResultView result={mutationState} /> <PostResultView result={mutationState.result} />
<Button onClick={() => runSync(mutation.mutateView([id]))}> <Button onClick={() => runSync(mutation.mutateView([id]))}>
Mutate Mutate