Compare commits

Author SHA1 Message Date
renovate-bot 970b42be2d Update bun minor+patch updates
Lint / lint (push) Failing after 11s
Test build / test-build (pull_request) Failing after 30s
2026-07-27 12:02:24 +00:00
63 changed files with 1085 additions and 4292 deletions

No files matched your search

-52
View File
@@ -1,52 +0,0 @@
# 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
+1 -3
View File
@@ -8,9 +8,7 @@ jobs:
steps:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.4"
- name: Checkout
- name: Clone repo
uses: actions/checkout@v6
- name: Install dependencies
run: bun install --frozen-lockfile
+6 -10
View File
@@ -11,8 +11,6 @@ jobs:
steps:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.4"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
@@ -22,7 +20,7 @@ jobs:
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Checkout
- name: Clone repo
uses: actions/checkout@v6
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -42,13 +40,6 @@ jobs:
access: public
token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org
- name: Publish @effect-view/vite-plugin
uses: JS-DevTools/npm-publish@v4
with:
package: packages/vite-plugin
access: public
token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org
- name: Publish effect-fc
uses: JS-DevTools/npm-publish@v4
with:
@@ -57,6 +48,11 @@ jobs:
token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org
- name: Clean before Docker build
run: |
bun clean:cache
bun clean:dist
bun clean:modules
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@v5
+7 -4
View File
@@ -9,16 +9,14 @@ jobs:
steps:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.4"
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"
node-version: "22"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Checkout
- name: Clone repo
uses: actions/checkout@v6
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -34,6 +32,11 @@ jobs:
- name: Pack
run: bun pack
- name: Clean before Docker build
run: |
bun clean:cache
bun clean:dist
bun clean:modules
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@v5
+1 -4
View File
@@ -1,8 +1,5 @@
{
"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,
"typescript.tsdk": "node_modules/typescript/lib",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit"
}
+3 -3
View File
@@ -1,6 +1,6 @@
FROM oven/bun:1.4.0-debian@sha256:5bb0f9be3a1a36a03e27c9a9dd894a3b1ad26657155c7df4dda771e17bf872ef AS bun
FROM oven/bun:1.3.12-debian@sha256:1b709c9dd883fc1af38c210f7ea5222c552a8d470ea73efbd4b8fcfee798a64b AS bun
FROM node:24.19.0-trixie-slim@sha256:0711b541c1c33a8a530ac4f0d391baa9a15b3d804695b1b24a47daa5fb60e74d
FROM node:22.21.1-trixie-slim@sha256:98e1429d1a0b99378b4de43fa385f0746fd6276faf4feeb6104d91f6bad290f9
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bunx /usr/local/bin/
COPY . /app
WORKDIR /app
@@ -9,4 +9,4 @@ RUN bun install --frozen-lockfile && \
bun run build && \
bun clean:cache && \
bun clean:modules && \
bun install --production --frozen-lockfile --ignore-scripts
bun install --production --frozen-lockfile
+45 -34
View File
@@ -6,8 +6,8 @@
"name": "@effect-view/monorepo",
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@effect/tsgo": "0.36.4",
"@types/bun": "^1.4.0",
"@effect/language-service": "^0.87.1",
"@types/bun": "^1.3.14",
"npm-check-updates": "^23.0.0",
"npm-sort": "^0.0.4",
"turbo": "^2.10.7",
@@ -82,20 +82,21 @@
},
"packages/effect-view": {
"name": "effect-view",
"version": "0.3.0",
"version": "0.1.0",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"effect-lens": "2.0.3",
"effect-lens": "^2.0.1-beta.101",
},
"devDependencies": {
"@effect/platform-browser": "4.0.0-beta.101",
"@testing-library/react": "^16.3.0",
"effect": "^4.0.0",
"effect": "4.0.0-beta.101",
"jsdom": "^26.1.0",
"vitest": "^3.2.4",
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "^4.0.0",
"effect": "4.0.0-beta.101",
"react": "^19.2.0",
},
},
@@ -103,9 +104,9 @@
"name": "@effect-view/example",
"version": "0.0.0",
"dependencies": {
"@effect/platform-browser": "^4.0.0",
"@effect/platform-browser": "4.0.0-beta.101",
"@radix-ui/themes": "^3.3.0",
"effect": "^4.0.0",
"effect": "4.0.0-beta.101",
"effect-view": "workspace:*",
"react-icons": "^5.6.0",
},
@@ -125,7 +126,7 @@
},
"packages/vite-plugin": {
"name": "@effect-view/vite-plugin",
"version": "0.3.0",
"version": "0.0.1",
"dependencies": {
"typescript": "^6.0.3",
},
@@ -135,7 +136,7 @@
"vitest": "^3.2.4",
},
"peerDependencies": {
"effect-view": "^0.3.0",
"effect-view": "^0.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
},
},
@@ -577,26 +578,12 @@
"@effect-view/vite-plugin": ["@effect-view/vite-plugin@workspace:packages/vite-plugin"],
"@effect/language-service": ["@effect/language-service@0.87.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-kcljlJmEgqg5mFAM6UShJYJjMqJb3TbHHxrK8Qoubvwugc0aVWpRkbdgQvK5b17puOt3BKXXKOmcV+oQt0oQqQ=="],
"@effect/platform": ["@effect/platform@0.96.3", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.10", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.21.5" } }, "sha512-LzvIj4HYE++TcTv/cVTCI/GRvQTV0ymwRmgjZMzNB5dU4OS05pTN36RKN0npiOm5orLJgw4yjIv1dNZoYGh/vg=="],
"@effect/platform-browser": ["@effect/platform-browser@0.76.0", "", { "dependencies": { "multipasta": "^0.2.7" }, "peerDependencies": { "@effect/platform": "^0.96.0", "effect": "^3.21.0" } }, "sha512-cUyBpcLstrP/HiNsIePMBAI6R1+u6aRFlAUZb4wf08y1d1Vqf/Dmxsq14ZjBfnSYiqBPrCeYf1ZI+qMGQQL0RA=="],
"@effect/tsgo": ["@effect/tsgo@0.36.4", "", { "optionalDependencies": { "@effect/tsgo-darwin-arm64": "0.36.4", "@effect/tsgo-darwin-x64": "0.36.4", "@effect/tsgo-linux-arm": "0.36.4", "@effect/tsgo-linux-arm64": "0.36.4", "@effect/tsgo-linux-x64": "0.36.4", "@effect/tsgo-win32-arm64": "0.36.4", "@effect/tsgo-win32-x64": "0.36.4" }, "bin": { "effect-tsgo": "dist/effect-tsgo.cjs" } }, "sha512-fNmdUV6FgXnvIcG18AVS1SRXt7z9jsyBh5CKuJYmTsE+DgbLaWF0CevKHx7hQPcidDWRVmdJYfU66Jvc/ZEt6w=="],
"@effect/tsgo-darwin-arm64": ["@effect/tsgo-darwin-arm64@0.36.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oOcWdKQucNch6G4CvidHEzmfgeKEPMvFcz+DXuKGonBLwVHX+i1VYGbZwccEOoiitOg7eWVl4e8S0qKMXg3/gg=="],
"@effect/tsgo-darwin-x64": ["@effect/tsgo-darwin-x64@0.36.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-4QZh0n/nUoTI7sp4o7gDEviJ/jXH+IFTVqIyQ2w2fqqPoOvaGl9bVoR/2f9j/67hvY4DHcXKaccpmyoRYhaYsw=="],
"@effect/tsgo-linux-arm": ["@effect/tsgo-linux-arm@0.36.4", "", { "os": "linux", "cpu": "arm" }, "sha512-10oTUnOK/RCfmTMFvMndNIfk61uviyYBRpbxhDd/SiU6DYCmWlnZ/xhGmgyj5uiysOiFNsp3NKxT0d9y4uAz8g=="],
"@effect/tsgo-linux-arm64": ["@effect/tsgo-linux-arm64@0.36.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-aOv+wVbZWgBt9PGXPA874qgw151GrzkWqaswe+4TXjhdw/M4i35PCUH5T+uaQj3FnQ1NWiPqHIGfNBRjf3xG6w=="],
"@effect/tsgo-linux-x64": ["@effect/tsgo-linux-x64@0.36.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+BuwiG5d8dLNrrN1PB5GA3gRkBwvlUYmMkm/XkttaLnppp9jePFkB8CgAiNV8j290q7BVO2nQjao5aPkVwmluw=="],
"@effect/tsgo-win32-arm64": ["@effect/tsgo-win32-arm64@0.36.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-HsW90wbMWOeorUeNMdTqTcMZIDcCXdkeFGXTUkx9K1R21fXSw+QEdPtTmEyKsRMXEH4vqek/HQa0yMkUT0CJzA=="],
"@effect/tsgo-win32-x64": ["@effect/tsgo-win32-x64@0.36.4", "", { "os": "win32", "cpu": "x64" }, "sha512-q4j1q0ESYxxookjA0ECz/84DEH2MsP51K0aZb3uXKfxagHDASPoWDcVu4BJTcwrK5kdZzvw9QrCPjAXe9WZqng=="],
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
@@ -1165,7 +1152,7 @@
"@types/bonjour": ["@types/bonjour@3.5.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ=="],
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@@ -1439,7 +1426,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -1955,7 +1942,7 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@2.0.0", "", {}, "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA=="],
"ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
@@ -2059,6 +2046,8 @@
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
"latest-version": ["latest-version@7.0.0", "", { "dependencies": { "package-json": "^8.1.0" } }, "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg=="],
"launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="],
@@ -2867,6 +2856,8 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
@@ -2949,7 +2940,7 @@
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
"value-equal": ["value-equal@1.0.1", "", {}, "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="],
@@ -3029,6 +3020,8 @@
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
@@ -3059,9 +3052,9 @@
"@docusaurus/utils/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"@effect-view/example/@effect/platform-browser": ["@effect/platform-browser@4.0.0", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-OHo7EPpF3RVPiI+mjoU9yd05LdCdKPjzDXqs5wRgF9CsdVvwN76B0zgt54DxPUcvMYri1tVJOtLCVnK0tRkNQQ=="],
"@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": ["effect@4.0.0", "", {}, "sha512-ooc1TG5t+FfzgYnFz2ff6BBKyZ7EwBRVXC7c4RhQUAD6/TZ2gTXXMeb4WX7a19ozQo4J73/QW+S00YAIresoMQ=="],
"@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/vite-plugin/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
@@ -3129,9 +3122,11 @@
"dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
"effect-view/effect": ["effect@4.0.0", "", {}, "sha512-ooc1TG5t+FfzgYnFz2ff6BBKyZ7EwBRVXC7c4RhQUAD6/TZ2gTXXMeb4WX7a19ozQo4J73/QW+S00YAIresoMQ=="],
"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-lens": ["effect-lens@2.0.3", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-FLILgEXUf1aKxykcVR+Rc3qmWYPSeb4zLc2NdNEW+RNgnsICTan8rhhXW374e0dwEQAsEkMmcHxeEaQxBqy0jA=="],
"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-lens": ["effect-lens@2.0.1-beta.101", "", { "peerDependencies": { "effect": "4.0.0-beta.101" } }, "sha512-ME5JruBkF3AG34rhb9MlHb1uwEbW70hr6lFITf8tcHBTgVF5QXFCe8nWATraYZpxBHGqM2bOnRvoadmwh2yyvQ=="],
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
@@ -3147,6 +3142,8 @@
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"global-dirs/ini": ["ini@2.0.0", "", {}, "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA=="],
"got/@sindresorhus/is": ["@sindresorhus/is@5.6.0", "", {}, "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g=="],
"hpack.js/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
@@ -3341,6 +3338,8 @@
"sitemap/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
"sockjs/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
@@ -3399,6 +3398,10 @@
"@docusaurus/core/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"@effect-view/example/effect/fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
"@effect-view/example/effect/msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
"@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/base64": ["@jsonjoy.com/base64@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw=="],
"@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/codegen": ["@jsonjoy.com/codegen@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q=="],
@@ -3427,6 +3430,10 @@
"css-select/domutils/dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="],
"effect-view/effect/fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
"effect-view/effect/msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"file-loader/schema-utils/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
@@ -3485,8 +3492,12 @@
"@docusaurus/core/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"@effect-view/example/effect/fast-check/pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
"css-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
"effect-view/effect/fast-check/pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
"file-loader/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"null-loader/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
+3 -4
View File
@@ -1,12 +1,11 @@
{
"name": "@effect-view/monorepo",
"packageManager": "bun@1.4.0",
"packageManager": "bun@1.3.14",
"private": true,
"workspaces": [
"./packages/*"
],
"scripts": {
"prepare": "effect-tsgo patch --typescript --no-oxlint",
"lint:tsc": "turbo lint:tsc",
"lint:biome": "turbo lint:biome",
"test": "turbo test",
@@ -18,8 +17,8 @@
},
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@effect/tsgo": "0.36.4",
"@types/bun": "^1.4.0",
"@effect/language-service": "^0.87.1",
"@types/bun": "^1.3.14",
"npm-check-updates": "^23.0.0",
"npm-sort": "^0.0.4",
"turbo": "^2.10.7",
+25 -28
View File
@@ -54,9 +54,11 @@ valid decoded value should go:
## Create forms once
`MutationForm.make` and `LensForm.make` construct forms. Pipe them through the
module's `run` operator to start validation or synchronization in the current
scope. Create each root form once and keep it stable.
`MutationForm.service` and `LensForm.service` are Effect constructors, not
hooks. Create each root form once and keep it stable. For a component-owned
form, call the constructor inside `Component.useOnMount`; for a form shared by
multiple components, create it in an Effect service. Update the existing form's
Lenses rather than reconstructing the form during render.
## MutationForm: validate, then submit
@@ -71,7 +73,7 @@ import { Component, MutationForm, View } from "effect-view"
const CreateProfileView = Component.make("CreateProfile")(function* () {
const form = yield* Component.useOnMount(() =>
MutationForm.make({
MutationForm.service({
schema: ProfileSchema,
initialEncodedValue: {
displayName: "",
@@ -82,7 +84,7 @@ const CreateProfileView = Component.make("CreateProfile")(function* () {
Effect.log(
`Creating ${profile.displayName}, age ${profile.age}`,
),
}).pipe(MutationForm.thenRun),
}),
)
const [canCommit, isCommitting] = yield* View.useAll([
@@ -108,9 +110,9 @@ mutation receives the schema's decoded profile, so `profile.age` is a number.
Schema transformations happen before the mutation and schema issues prevent an
invalid draft from being submitted.
`MutationForm.thenRun` starts initial validation. In the example above,
`Component.useOnMount` keeps that validation and the form's mutation work tied
to the component that owns them.
`MutationForm.service` also starts initial validation in the current scope. In
the example above, `Component.useOnMount` keeps that validation and the form's
mutation work tied to the component that owns them.
## LensForm: validate, then synchronize
@@ -134,10 +136,10 @@ const EditProfileView = Component.make("EditProfile")(function* () {
}),
)
const form = yield* LensForm.make({
const form = yield* LensForm.service({
schema: ProfileSchema,
target: profile,
}).pipe(LensForm.thenRun)
})
return [form, profile] as const
}),
@@ -166,8 +168,8 @@ reaches the target. If another part of the application updates the target,
`LensForm` encodes that value back into the draft.
Pass `initialEncodedValue` only when the first draft should differ from the
encoded target. Otherwise `LensForm.make` obtains the initial draft by encoding
the target through the schema.
encoded target. Otherwise `LensForm.service` obtains the initial draft by
encoding the target through the schema.
## Focus into subforms
@@ -254,13 +256,6 @@ Use `Form.useOptionalInput` when the encoded field is an Effect `Option`. It
returns `value` and `setValue` together with `enabled` and `setEnabled`, making
it suitable for an optional input that the user can toggle on and off.
Use `Form.useStatus` for displayed form status. It debounces short-lived state
changes, avoiding flicker in pending indicators.
```tsx
const { isValidating, isCommitting, canCommit } = yield* Form.useStatus(form)
```
`useInput` and `useOptionalInput` can be used directly, but they are primarily
building blocks for your own reusable input components. A component library
can wrap them to consistently handle labels, validation issues, validating
@@ -306,8 +301,11 @@ export const TextFieldFormInputView = Component.make(
>,
) {
const input = yield* Form.useInput(props.form, props)
const [issues] = yield* View.useAll([props.form.issues])
const { isValidating, isCommitting } = yield* Form.useStatus(props.form)
const [issues, isValidating, isCommitting] = yield* View.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
])
return (
<Flex direction="column" gap="1">
@@ -355,9 +353,8 @@ They expose the schema pipeline as reactive Lenses and Views:
| `isCommitting` | Whether a mutation or target write is in progress. |
The form model itself is Effect code. Effect View adds the React-facing hooks,
including `Form.useInput`, `Form.useOptionalInput`, and `Form.useStatus`, while
your own component library remains responsible for rendering controls and
layout.
including `Form.useInput` and `Form.useOptionalInput`, while your own component
library remains responsible for rendering controls and layout.
## Example: local date input, UTC domain value
@@ -420,7 +417,7 @@ the mutation receives UTC:
```tsx
import { DateTime, Effect, Schema } from "effect"
import { Component, Form, MutationForm } from "effect-view"
import { Component, Form, MutationForm, View } from "effect-view"
import { DateTimeUtcFromZonedInput } from "./DateTimeUtcFromZonedInput"
const AppointmentSchema = Schema.Struct({
@@ -430,21 +427,21 @@ const AppointmentSchema = Schema.Struct({
const AppointmentView = Component.make("Appointment")(function* () {
const [form, startsAtField] = yield* Component.useOnMount(() =>
Effect.gen(function* () {
const form = yield* MutationForm.make({
const form = yield* MutationForm.service({
schema: AppointmentSchema,
initialEncodedValue: { startsAt: "" },
f: ([appointment]) =>
Effect.log(
`Saving ${DateTime.formatIso(appointment.startsAt)}`,
),
}).pipe(MutationForm.thenRun)
})
return [form, Form.focusObjectOn(form, "startsAt")] as const
}),
)
const startsAt = yield* Form.useInput(startsAtField)
const { canCommit } = yield* Form.useStatus(form)
const [canCommit] = yield* View.useAll([form.canCommit])
const runPromise = yield* Component.useRunPromise()
return (
+12 -47
View File
@@ -24,7 +24,7 @@ For a web application, install Effect View with Effect 4 and React 19.2 or
newer:
```bash npm2yarn
npm install effect-view effect react react-dom
npm install effect-view effect@beta react react-dom
```
```bash npm2yarn
@@ -69,14 +69,14 @@ layers needed by the UI:
```tsx title="src/runtime.ts"
import { Layer } from "effect"
import { FetchHttpClient } from "effect/http"
import { FetchHttpClient } from "effect/unstable/http"
import { ReactRuntime } from "effect-view"
const layer = Layer.empty.pipe(
const AppLive = Layer.empty.pipe(
Layer.provideMerge(FetchHttpClient.layer),
)
export const runtime = ReactRuntime.make(layer)
export const runtime = ReactRuntime.make(AppLive)
```
Keep the runtime stable. Creating it during a React render would create new
@@ -469,59 +469,24 @@ Prefer regular React state for simple, component-local UI concerns. Use
`Lens`/`View` when state needs Effect integration, subscriptions, focusing, or
sharing. The [State Management guide](./state-management) covers that model.
## Provide services to a component
Use `Component.provide` to give a component a static layer:
```tsx title="src/GreetingViewLive.tsx"
import { Component } from "effect-view"
import { GreetingView } from "./GreetingView"
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.
Use `Component.useLayer` when only one Effect View subtree needs extra
services. It builds the layer in a scope and returns the resulting Effect
context:
```tsx title="src/GreetingPageView.tsx"
import { Effect, Layer } from "effect"
import { Effect } from "effect"
import { Component } from "effect-view"
import * as React from "react"
import { GreetingView } from "./GreetingView"
import { GreetingService } from "./GreetingService"
export const GreetingPageView = Component.make("GreetingPage")(
function* (props: { readonly greeting: string }) {
const layer = React.useMemo(
() => Layer.succeed(GreetingService, {
greet: (name) => `${props.greeting}, ${name}`,
}),
[props.greeting],
function* () {
const context = yield* Component.useLayer(GreetingService.layer)
const Greeting = yield* GreetingView.use.pipe(
Effect.provide(context),
)
const context = yield* Component.useLayer(layer)
const Greeting = yield* Effect.provide(GreetingView.use, context)
return <Greeting name="Effect" />
},
+23 -47
View File
@@ -18,10 +18,10 @@ The familiar concepts are present, but the operation itself is an Effect:
| Mutation result | `mutation.state`, a `View<AsyncResult<A, E>>` |
| `isPending` | `result.waiting` |
| `mutateAsync` | `mutation.mutate(key)` |
| Start without awaiting | `mutation.mutateStream(key)` |
| Start without awaiting | `mutation.mutateView(key)` |
Unlike a Query, a Mutation has no cache, reactive key, or automatic execution.
It runs only when `mutate` or `mutateStream` is called. This makes it suitable for
It runs only when `mutate` or `mutateView` is called. This makes it suitable for
commands, while Query remains the model for cached server state.
## Create a mutation
@@ -37,7 +37,7 @@ create it in an Effect service when it should be shared.
```tsx
import { Effect } from "effect"
import { AsyncResult } from "effect/reactivity"
import { AsyncResult } from "effect/unstable/reactivity"
import { Component, Mutation, View } from "effect-view"
import { sendInvite } from "./api"
@@ -62,7 +62,7 @@ const InviteButtonView = Component.make("InviteButton")(
<button
disabled={result.waiting}
onClick={() =>
runSync(mutation.mutateStream({
runSync(mutation.mutateView({
email: props.email,
role: "member",
}))
@@ -132,7 +132,7 @@ during retries or repeated submissions.
A failure contains an Effect `Cause<E>`, not only `E`. Typed failures, defects,
and interruption information remain available for logging or presentation.
## Choose mutate or mutateStream
## Choose mutate or mutateView
Both methods start the same mutation effect. They differ in what the caller
waits for:
@@ -140,7 +140,7 @@ waits for:
| Method | Return value | Best suited to |
| --- | --- | --- |
| `mutate(key)` | The final `Success` or `Failure` | Effect workflows that need the outcome. |
| `mutateStream(key)` | A `Stream` of this call's successive states, ending once it settles | UI callbacks that should return after starting the work. |
| `mutateView(key)` | A live `View<AsyncResult<A, E>>` | UI callbacks that should return after starting the work. |
Use `mutate` with an asynchronous component runner when later logic depends on
the final result:
@@ -164,22 +164,20 @@ The mutation Effect does not fail with `E`. It captures the operation's `Exit`
and returns a final `AsyncResult.Success` or `AsyncResult.Failure`. Inspect or
match that value when control flow depends on the outcome.
Use `mutateStream` when the UI only needs to start the operation and react to its
Use `mutateView` when the UI only needs to start the operation and react to its
state:
```tsx
const runSync = yield* Component.useRunSync()
const startSave = () => {
const states = runSync(mutation.mutateStream(input))
// `states` streams this specific call's states, and ends once it settles.
const state = runSync(mutation.mutateView(input))
// `state` is a View for this specific call.
}
```
`mutateStream` starts the scoped work and returns immediately with a per-call
`Stream`: it emits the call's current state, then each change, and ends once
the call settles or is interrupted. The shared `mutation.state` is also updated
as that call progresses. Consume the stream in a component with `Stream.use`.
`mutateView` starts the scoped work and returns immediately with a per-call
View. The shared `mutation.state` is also updated as that call progresses.
## Track the latest call
@@ -190,7 +188,7 @@ In addition to `state`, a mutation exposes reactive metadata:
| `state` | The latest mutation state published to the shared View. |
| `latestKey` | The most recently supplied input as an `Option<K>`. |
| `latestFinalResult` | The latest completed success or failure as an `Option`. |
| `operation` | The most recently started call as an `Option<Operation>`: its key, state, fiber, and `interrupt`. |
| `fiber` | The most recently started mutation fiber as an `Option`. |
Subscribe to any of them with `View.useAll`:
@@ -209,20 +207,19 @@ any other value accepted by the mutation function.
## Concurrent mutations
Starting a mutation does not automatically interrupt an earlier mutation.
Calls may overlap, and each call has its own state stream. This is useful for
Calls may overlap, and each call has its own state View. This is useful for
independent operations such as uploading several files.
When calls overlap, `mutation.state` follows only the most recently started
call: earlier calls still run to completion, but no longer update it. Use the
stream returned by `mutateStream` when each concurrent operation needs its own
progress indicator:
When calls overlap, `mutation.state` reflects updates published by all calls;
the last update to arrive wins. Use the View returned by `mutateView` when each
concurrent operation needs its own progress indicator:
```tsx
const upload = (file: File) =>
Effect.gen(function* () {
const uploadStates = yield* mutation.mutateStream(file)
// Pass `uploadStates` to the row rendering this file, e.g. via `Stream.use`.
return uploadStates
const uploadState = yield* mutation.mutateView(file)
// Store or pass `uploadState` to the row rendering this file.
return uploadState
})
```
@@ -231,27 +228,6 @@ usually enough to prevent accidental overlap. For “latest request wins”
behavior, model that policy explicitly or use Query when the operation is
actually a reactive read.
## Network mode
Mutations take a `networkMode` option, just like queries. When it is not set,
the `QueryClient`'s `defaultMutationNetworkMode` is used, which is `"online"`
unless you change it. It is separate from `defaultNetworkMode`, which only
applies to queries. The connection status comes from the `AppConnectivity` service,
as described in [Query](./query.md#network-mode-and-offline-support).
| `networkMode` | Behavior |
| --- | --- |
| `"online"` | A call made offline waits for a connection before running, and `state.paused` is `true` meanwhile. |
| `"offlineFirst"` | A call runs right away. If it fails while offline, it is `paused` until the connection returns, then runs again. |
| `"always"` | Calls run regardless of the connection. |
```ts
const mutation = yield* Mutation.make({
f: (input: InviteInput) => sendInvite(input),
networkMode: "always",
})
```
## Update queries after a mutation
Mutations do not invalidate Query caches automatically. Compose invalidation
@@ -264,7 +240,7 @@ const saveAndRefresh = (input: UpdatePostInput) =>
if (AsyncResult.isSuccess(result)) {
yield* posts.invalidateCacheEntry(["post", result.value.id] as const)
yield* posts.refresh
yield* posts.refreshView
}
return result
@@ -277,7 +253,7 @@ also include tracing, transactions, retries, notifications, or parallel cache
updates without introducing a separate callback API.
Remember that Query invalidation removes cached data but does not refetch by
itself. Follow it with `refresh` or `refreshStream` when the current screen should update
itself. Follow it with `refreshView` when the current screen should update
immediately.
## The Effect touch
@@ -290,8 +266,8 @@ execution model:
- Failures are represented as `Cause<E>`, including defects and interruption.
- Fibers are scoped, so component unmounting cleans up in-flight operations.
- `mutate` composes directly inside larger Effect workflows.
- `mutateStream` exposes call-specific progress as a `Stream` for React or
Effect consumers.
- `mutateView` exposes call-specific progress as a View for React or Effect
consumers.
- Retry schedules, timeouts, tracing, logging, metrics, schema validation, and
concurrency controls can be applied with normal Effect operators.
+40 -192
View File
@@ -19,69 +19,56 @@ If you already know TanStack Query, the main concepts translate directly:
| `queryFn` | `f: (key: K) => Effect<A, E, R>` |
| `useQuery` result | `query.state`, a `View<QueryState<K, A, E>>` |
| `isFetching` | `result.waiting` |
| `fetchStatus: 'paused'` | `state.paused` |
| `refetch` | `query.refresh` or `query.refreshStream` |
| `refetch` | `query.refresh` or `query.refreshView` |
| `invalidateQueries` | `query.invalidateCache` or `invalidateCacheEntry` |
| `networkMode` | `networkMode` (`"online"`, `"offlineFirst"` or `"always"`) |
| `onlineManager` | The `AppConnectivity` service |
| `focusManager` | The `AppFocus` service |
The goal is familiar query behavior without leaving Effect's model. Query
functions keep their typed success, error, and service channels. They can use
services from the runtime, be composed with schema decoding and retry policies,
and are interrupted automatically when their scope ends or a new key supersedes
the current request. A `fetch` or `refresh` that a newer request supersedes is
interrupted as well, rather than returning a result.
the current request.
## Provide a QueryClient
Queries share successful results through a `QueryClient`. Add its layer to the
application runtime once, alongside the services used by your query effects. In
a browser, use `QueryClient.layerBrowser`, or `QueryClient.layerBrowserWithOptions`
to change the defaults:
application runtime once, alongside the services used by your query effects:
```tsx title="src/runtime.ts"
import { Layer } from "effect"
import { QueryClient, ReactRuntime } from "effect-view"
const layer = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layerBrowserWithOptions({
const AppLive = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layer({
defaultStaleTime: "30 seconds",
defaultRefreshOnFocus: true,
defaultRefreshOnWindowFocus: true,
cacheGcTime: "5 minutes",
})),
)
export const runtime = ReactRuntime.make(layer)
export const runtime = ReactRuntime.make(AppLive)
```
The client owns the cache and its cleanup lifecycle. Individual queries can
override `staleTime`, `networkMode`, `refreshOnFocus` and
`refreshOnReconnect`; otherwise they inherit the client defaults
(`defaultStaleTime`, `defaultNetworkMode`, `defaultRefreshOnFocus` and
`defaultRefreshOnReconnect`).
The client needs to know whether the app is focused and online, which it gets
from the `AppFocus` and `AppConnectivity` services. The browser layers provide
both from the window's events. `QueryClient.layer` and
`QueryClient.layerWithOptions` leave them for you to provide, for example on
other platforms such as [React Native](#react-native).
override `staleTime` and `refreshOnWindowFocus`; otherwise they inherit the
client defaults. Window-focus refresh also requires the optional
`@effect/platform-browser` package, as described under
[Staleness and cache lifetime](#staleness-and-cache-lifetime).
## Create a reactive query
A query is driven by a `View` rather than by a value read during one React
render. Whenever that key changes, a running Query checks the cache and starts
render. Whenever that key changes, `Query.service` checks the cache and starts
the query effect when necessary.
`Query.make` constructs the Query and `Query.thenRun` starts it in the current
scope. Create each query instance once and keep it stable. In a component, the usual place is
`Query.service` is an Effect constructor, not a hook. Create each query instance
once and keep it stable. In a component, the usual place is
`Component.useOnMount`; a query shared by multiple components can instead be
owned by an Effect service. Change the existing query's reactive key rather
than reconstructing the query during render.
```tsx
import { Effect, Schema, SubscriptionRef } from "effect"
import { HttpClient } from "effect/http"
import { HttpClient } from "effect/unstable/http"
import { Component, Lens, Query, View } from "effect-view"
const Post = Schema.Struct({
@@ -97,7 +84,7 @@ const PostView = Component.make("Post")(function* () {
yield* SubscriptionRef.make(["post", 1 as number] as const),
)
const query = yield* Query.make({
const query = yield* Query.service({
key,
staleTime: "1 minute",
f: ([, id]) =>
@@ -108,7 +95,7 @@ const PostView = Component.make("Post")(function* () {
Effect.andThen((response) => response.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)),
),
}).pipe(Query.thenRun)
})
return [Lens.focusTupleAt(key, 1), query] as const
}),
@@ -137,7 +124,7 @@ Effect equality by default, so structurally equal Effect data types work well
as query keys. Supply `keyEquivalence` when the key needs different equality
semantics.
`Query.thenRun` starts watching its key in the current scope. The
`Query.service` starts watching its key in the current scope. The
`Component.useOnMount` call above keeps both the query instance and its query
function identity stable for the component's lifetime.
@@ -155,7 +142,7 @@ interface QueryState<K, A, E> {
Subscribe with `View.useAll`, then match the result explicitly:
```tsx
import { AsyncResult } from "effect/reactivity"
import { AsyncResult } from "effect/unstable/reactivity"
const [state] = yield* View.useAll([query.state])
@@ -229,7 +216,7 @@ const runSync = yield* Component.useRunSync()
return (
<div>
<button onClick={() => runSync(query.refreshStream)}>
<button onClick={() => runSync(query.refreshView)}>
Refresh current post
</button>
<button onClick={() => runSync(query.invalidateCacheEntry(["post", id] as const))}>
@@ -247,63 +234,20 @@ The methods come in two styles:
| Method | Behavior |
| --- | --- |
| `fetch(key)` | Fetch a specific key and wait for its final state. |
| `fetchStream(key)` | Start fetching and immediately return a `Stream` of the request's states, ending once it settles. |
| `fetchView(key)` | Start fetching and immediately return a live state `View`. |
| `refresh` | Resolve the current key again and wait for its final state. |
| `refreshStream` | Resolve the current key again and immediately return a `Stream` of the request's states, ending once it settles. |
| `refreshView` | Resolve the current key again and immediately return a live state `View`. |
| `invalidateCacheEntry(key)` | Remove the cached success for one key. |
| `invalidateCache` | Remove every cached success associated with this query function. |
The `*Stream` variants are convenient in synchronous UI callbacks: they return
after the scoped request has started. The returned stream emits the request's
current state, then each change, and ends once the request settles or is
superseded; `query.state` keeps publishing progress either way. The non-Stream
variants are useful in Effect workflows that need to wait for the final success
or failure state.
The `*View` variants are convenient in synchronous UI callbacks: they return
after the scoped request has started, while the returned View and `query.state`
continue to publish progress. The non-View variants are useful in Effect
workflows that need to wait for the final success or failure state.
Invalidating does not itself refetch. Follow it with `refresh` or `refreshStream`, change the
Invalidating does not itself refetch. Follow it with `refreshView`, change the
key, or allow a later fetch to repopulate the cache.
### Refresh on an interval
Refresh every five minutes, starting after five minutes:
```ts
import { Schedule } from "effect"
const query = yield* Query.make(options).pipe(
Query.thenRun,
Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
)
```
Limit the number of refreshes:
```ts
const query = yield* Query.make(options).pipe(
Query.thenRun,
Query.withScheduledRefresh(
Schedule.spaced("5 minutes").pipe(
Schedule.upTo({ times: 3 }),
),
),
)
```
The refresh fiber stops with the surrounding scope. The Effect returns the
original query. Cache and `staleTime` rules still apply.
Like TanStack Query's `refetchInterval`, scheduled refreshes are skipped while
the app is not focused, as reported by the `AppFocus` service. A skipped
refresh still counts toward the schedule. Pass `refreshInBackground: true` to
keep refreshing in the background:
```ts
const query = yield* Query.make(options).pipe(
Query.thenRun,
Query.withScheduledRefresh(Schedule.spaced("5 minutes"), { refreshInBackground: true }),
)
```
## Staleness and cache lifetime
`staleTime` controls how long a successful result can satisfy a fetch without
@@ -314,125 +258,29 @@ while the query refreshes it.
accessed are eventually removed after their stale period plus the configured
garbage-collection time.
By default, the client enables refresh when the app regains focus. Set it
globally or override it for one query:
By default, the client enables refresh on browser window focus. Set it globally
or override it for one query:
```tsx
const query = yield* Query.make({
const query = yield* Query.service({
key,
f: loadPost,
staleTime: "10 seconds",
refreshOnFocus: false,
}).pipe(Query.thenRun)
refreshOnWindowFocus: false,
})
```
Regaining focus resolves the current key again, subject to the same freshness
check. Set `refreshOnFocus: "always"` to refresh even when the result is still
fresh. Focus comes from the `AppFocus` service: with
`QueryClient.layerBrowser` the app is focused while the page is visible,
following the document's `visibilitychange` events, like TanStack Query.
Other platforms, such as React Native, provide it themselves, as shown in
[React Native](#react-native).
Window-focus refresh depends on the optional `@effect/platform-browser`
integration. Install it in browser applications that use this behavior:
## Network mode and offline support
A query's `networkMode` decides how it behaves without a network connection:
| `networkMode` | Behavior |
| --- | --- |
| `"online"` (default) | A request waits for a connection before running `f`. Meanwhile its state is `paused`, with `result.waiting` still `true`. |
| `"offlineFirst"` | A request runs right away, e.g. to be served by a service worker or HTTP cache. If it fails while offline, it is `paused` until the connection returns, then runs again. |
| `"always"` | Requests ignore the connection. Suited to queries that do not use the network. |
Use `state.paused` to tell "loading" apart from "waiting for a connection":
```tsx
const [state] = yield* View.useAll([query.state])
if (state.paused) return <p>Offline. Waiting for a connection…</p>
```bash npm2yarn
npm install @effect/platform-browser@beta
```
With `refreshOnReconnect` (enabled by default, except in `"always"` mode),
regaining the connection resolves the current key again, subject to the usual
freshness check. Set it to `"always"` to refresh even when the result is still
fresh. Like TanStack Query, focus and reconnect refreshes never restart a
request that is already in flight: a running request keeps running, and one
paused for lack of a connection resumes on its own. A paused request can still
be superseded or interrupted like any other.
The connection status comes from the `AppConnectivity` service. With
`QueryClient.layerBrowser` it follows `navigator.onLine` and the window's
`online`/`offline` events.
### Provide AppConnectivity and AppFocus
Outside a browser, use `QueryClient.layer` and provide both services yourself.
Each module offers:
| Layer or function | Behavior |
| --- | --- |
| `layerBrowser` | Follows the browser: `navigator.onLine` with the `online`/`offline` events, and the page's visibility. |
| `layerDummy` | Always online, or always focused. Useful for tests and server rendering. |
| `make(view)` | Builds the service from a `View<boolean>`, to provide with `Layer.succeed`. |
```tsx
import { Layer } from "effect"
import { AppConnectivity, AppFocus, QueryClient } from "effect-view"
const queryClientLayer = QueryClient.layer.pipe(
Layer.provide(Layer.mergeAll(AppConnectivity.layerDummy, AppFocus.layerDummy)),
)
```
### React Native
React Native has neither window focus nor `online`/`offline` events. Build
both services from the platform APIs with `make`, and provide them to
`QueryClient.layer`:
```tsx title="src/runtime.ts"
import NetInfo from "@react-native-community/netinfo"
import { Effect, Layer, Queue, Stream } from "effect"
import { AppConnectivity, AppFocus, QueryClient, ReactRuntime, View } from "effect-view"
import { AppState } from "react-native"
const appConnectivityLayer = Layer.succeed(
AppConnectivity.AppConnectivity,
AppConnectivity.make(View.make({
get: Effect.map(Effect.promise(() => NetInfo.fetch()), state => !!state.isConnected),
// NetInfo calls the listener with the current state right away
changes: Stream.callback<boolean>(queue => Effect.acquireRelease(
Effect.sync(() => NetInfo.addEventListener(state => Queue.offerUnsafe(queue, !!state.isConnected))),
unsubscribe => Effect.sync(unsubscribe),
)),
})),
)
const appFocusLayer = Layer.succeed(
AppFocus.AppFocus,
AppFocus.make(View.make({
get: Effect.sync(() => AppState.currentState === "active"),
changes: Stream.concat(
Stream.sync(() => AppState.currentState === "active"),
Stream.callback<boolean>(queue => Effect.acquireRelease(
Effect.sync(() => AppState.addEventListener("change", status => Queue.offerUnsafe(queue, status === "active"))),
subscription => Effect.sync(() => subscription.remove()),
)),
),
})),
)
const layer = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layer),
Layer.provideMerge(appConnectivityLayer),
Layer.provideMerge(appFocusLayer),
)
export const runtime = ReactRuntime.make(layer)
```
A service's `changes` stream starts with the current value, and the listeners
are removed when the layer is released.
When the package is available, focusing the window resolves the current key
again, subject to the same freshness check. Without it,
`refreshOnWindowFocus` has no effect; the rest of the Query API continues to
work normally. The integration is also ignored in non-browser environments.
## The Effect touch
+1 -6
View File
@@ -2,9 +2,6 @@ import type * as Preset from "@docusaurus/preset-classic"
import type { Config } from "@docusaurus/types"
import { themes as prismThemes } from "prism-react-renderer"
import githubDark from "./src/prism/github-dark"
import remarkLineNumbers from "./src/remark/line-numbers"
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
const config: Config = {
@@ -46,7 +43,6 @@ const config: Config = {
sidebarPath: "./sidebars.ts",
editUrl:
"https://github.com/Thiladev/effect-view/tree/main/packages/docs/",
remarkPlugins: [remarkLineNumbers],
lastVersion: "current",
versions: {
current: {
@@ -69,7 +65,6 @@ const config: Config = {
themeConfig: {
// Replace with your project's social card
colorMode: {
defaultMode: "dark",
respectPrefersColorScheme: true,
},
navbar: {
@@ -144,7 +139,7 @@ const config: Config = {
},
prism: {
theme: prismThemes.github,
darkTheme: githubDark,
darkTheme: prismThemes.dracula,
},
} satisfies Preset.ThemeConfig,
}
+1 -1
View File
@@ -49,6 +49,6 @@
]
},
"engines": {
"node": ">=24.0"
"node": ">=20.0"
}
}
+27 -198
View File
@@ -1,208 +1,37 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,400;0,14..32,500;0,14..32,600;0,14..32,700;0,14..32,800;1,14..32,400&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap");
:root {
--ifm-color-primary: #059669;
--ifm-color-primary-dark: #04835d;
--ifm-color-primary-darker: #047857;
--ifm-color-primary-darkest: #036348;
--ifm-color-primary-light: #0aa876;
--ifm-color-primary-lighter: #10b981;
--ifm-color-primary-lightest: #34d399;
--ifm-background-color: #ffffff;
--ifm-background-surface-color: #ffffff;
--ifm-navbar-background-color: #ffffff;
--ifm-footer-background-color: #09090b;
--ifm-code-font-size: 90%;
--ifm-color-primary: #0b6f74;
--ifm-color-primary-dark: #096469;
--ifm-color-primary-darker: #085e63;
--ifm-color-primary-darkest: #074d51;
--ifm-color-primary-light: #0d7a7f;
--ifm-color-primary-lighter: #0e8085;
--ifm-color-primary-lightest: #109096;
--ifm-background-color: #fffaf1;
--ifm-code-font-size: 95%;
--ifm-font-family-base:
"Inter", "Segoe UI", "Helvetica Neue", sans-serif;
--ifm-font-family-monospace:
"JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo,
monospace;
--ifm-heading-font-weight: 700;
--ifm-heading-letter-spacing: -0.02em;
--ifm-menu-color-background-active: rgba(5, 150, 105, 0.08);
--ifm-menu-color-background-hover: rgba(5, 150, 105, 0.06);
--docusaurus-highlighted-code-line-bg: rgba(5, 150, 105, 0.1);
--ev-zinc-50: #fafafa;
--ev-zinc-100: #f4f4f5;
--ev-zinc-200: #e4e4e7;
--ev-zinc-300: #d4d4d8;
--ev-zinc-400: #a1a1aa;
--ev-zinc-500: #71717a;
--ev-zinc-600: #52525b;
--ev-zinc-700: #3f3f46;
--ev-zinc-800: #27272a;
--ev-zinc-900: #18181b;
--ev-zinc-950: #09090b;
}
html {
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
code,
pre,
kbd,
samp {
font-family: var(--ifm-font-family-monospace);
}
.navbar {
border-bottom: 1px solid var(--ev-zinc-200);
box-shadow: none;
}
.navbar__item {
font-family: var(--ifm-font-family-monospace);
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.navbar__brand {
font-weight: 800;
letter-spacing: -0.02em;
}
[data-theme="dark"] {
--ifm-color-primary: #34d399;
--ifm-color-primary-dark: #1fcc8c;
--ifm-color-primary-darker: #10b981;
--ifm-color-primary-darkest: #0a9c6d;
--ifm-color-primary-light: #52dbab;
--ifm-color-primary-lighter: #6ee7b7;
--ifm-color-primary-lightest: #a7f3d0;
--ifm-background-color: #09090b;
--ifm-background-surface-color: #0c0c0f;
--ifm-navbar-background-color: #09090bee;
--ifm-toc-border-color: var(--ev-zinc-800);
--ifm-table-border-color: var(--ev-zinc-800);
--ifm-menu-color-background-active: rgba(52, 211, 153, 0.1);
--ifm-menu-color-background-hover: rgba(52, 211, 153, 0.06);
--docusaurus-highlighted-code-line-bg: rgba(52, 211, 153, 0.16);
}
[data-theme="dark"] .navbar {
border-bottom: 1px solid var(--ev-zinc-800);
backdrop-filter: blur(10px);
}
[data-theme="dark"] .footer {
--ifm-footer-color: var(--ev-zinc-400);
--ifm-footer-link-color: var(--ev-zinc-300);
--ifm-footer-title-color: var(--ev-zinc-100);
border-top: 1px solid var(--ev-zinc-900);
}
.footer {
font-family: var(--ifm-font-family-monospace);
}
.footer__title {
font-size: 0.78rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.footer__link-item {
font-size: 0.85rem;
"Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
--docusaurus-highlighted-code-line-bg: rgba(11, 111, 116, 0.1);
}
.button--primary {
--ifm-button-background-color: var(--ifm-color-primary);
--ifm-button-border-color: var(--ifm-color-primary);
--ifm-button-background-color: #0b6f74;
--ifm-button-border-color: #0b6f74;
}
.menu__link--active,
.table-of-contents__link--active {
font-weight: 600;
.button--secondary {
--ifm-button-background-color: #f4a636;
--ifm-button-border-color: #f4a636;
--ifm-button-color: #1f2526;
}
[data-theme="dark"] .DocSearch-Button {
background: var(--ev-zinc-900);
border: 1px solid var(--ev-zinc-800);
}
/* ---------- Code blocks ---------- */
/* One quiet, low-contrast surface - matches effect.website: a subtle step
off the page background, not a loud bordered card. */
.theme-code-block {
--ifm-pre-padding: 1.25rem;
background: var(--ev-zinc-50);
border: 1px solid var(--ev-zinc-200);
border-radius: 0.5rem;
box-shadow: 1.5px 1.5px 3px rgba(0, 0, 0, 0.12);
}
[data-theme="dark"] .theme-code-block {
background: var(--ev-zinc-900);
border-color: var(--ev-zinc-800);
}
/* The Prism theme sets its own (much louder) background inline - drop it
so the whole frame reads as one surface instead of a title/body seam. */
.theme-code-block pre.thin-scrollbar {
background: transparent !important;
}
/* Title renders only when a `title="..."` fence meta is present */
.theme-code-block:has(> div + div) > div:first-child {
background: transparent;
color: var(--ev-zinc-500);
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
font-weight: 500;
padding: 0.9rem 1.4rem 0;
}
.theme-code-block pre.thin-scrollbar {
font-size: 0.85rem;
line-height: 1.7;
}
[class*="buttonGroup_"] button {
background: transparent;
border: none;
border-radius: 0.35rem;
color: var(--ev-zinc-500);
}
[class*="buttonGroup_"] button:hover {
background: var(--ev-zinc-100);
color: var(--ev-zinc-800);
}
[data-theme="dark"] [class*="buttonGroup_"] button:hover {
background: var(--ev-zinc-900);
color: var(--ev-zinc-100);
}
[class*="codeLineNumber_"] {
border: none;
color: var(--ev-zinc-400);
padding-inline: 1.25rem 1rem !important;
text-align: right !important;
}
[data-theme="dark"] [class*="codeLineNumber_"] {
color: var(--ev-zinc-600);
}
[class*="codeLineContent_"] {
border-inline-start: 1px solid rgba(113, 113, 122, 0.2);
padding-inline-start: 1.25rem;
}
[class*="codeLineNumber_"]::before {
opacity: 1 !important;
[data-theme="dark"] {
--ifm-color-primary: #7ad6d4;
--ifm-color-primary-dark: #5cccca;
--ifm-color-primary-darker: #4cc6c4;
--ifm-color-primary-darkest: #34aaa8;
--ifm-color-primary-light: #98e0de;
--ifm-color-primary-lighter: #a8e6e4;
--ifm-color-primary-lightest: #d5f4f3;
--ifm-background-color: #111c1f;
--docusaurus-highlighted-code-line-bg: rgba(122, 214, 212, 0.18);
}
+355 -309
View File
@@ -1,265 +1,223 @@
.page {
--home-ink: #09090b;
--home-muted: #52525b;
--home-emerald: #059669;
--home-emerald-bright: #10b981;
--home-paper: #ffffff;
--home-card: #ffffff;
--home-line: #e4e4e7;
--home-card-hover-line: rgba(5, 150, 105, 0.4);
--home-terminal-bg: #09090b;
--home-terminal-line: #27272a;
background: var(--home-paper);
--home-ink: #102f31;
--home-muted: #506a6c;
--home-teal: #08777b;
--home-teal-bright: #16a3a1;
--home-amber: #e99526;
--home-paper: #fffdf8;
--home-card: rgba(255, 255, 255, 0.76);
--home-line: rgba(16, 47, 49, 0.12);
min-height: calc(100vh - var(--ifm-navbar-height));
overflow: hidden;
position: relative;
background:
radial-gradient(circle at 8% 5%, rgba(14, 145, 148, 0.2), transparent 28rem),
radial-gradient(circle at 92% 14%, rgba(240, 159, 48, 0.2), transparent 27rem),
linear-gradient(145deg, #f7f2e7 0%, #edf8f6 48%, #fff8ed 100%);
color: var(--home-ink);
overflow: hidden;
position: relative;
}
:global([data-theme="dark"]) .page {
--home-ink: #f4f4f5;
--home-muted: #a1a1aa;
--home-emerald: #34d399;
--home-emerald-bright: #6ee7b7;
--home-paper: #09090b;
--home-card: #0c0c0f;
--home-line: #27272a;
--home-card-hover-line: rgba(52, 211, 153, 0.45);
--home-terminal-bg: #101012;
--home-terminal-line: #27272a;
}
/* ---------- Hero ---------- */
.hero {
overflow: hidden;
padding: 5.5rem 0 4rem;
position: relative;
}
.gridBackdrop {
background-image:
linear-gradient(var(--home-line) 1px, transparent 1px),
linear-gradient(90deg, var(--home-line) 1px, transparent 1px);
background-size: 56px 56px;
linear-gradient(rgba(16, 47, 49, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(16, 47, 49, 0.045) 1px, transparent 1px);
background-size: 48px 48px;
inset: 0;
mask-image: radial-gradient(ellipse 65% 55% at 50% 0%, black, transparent);
opacity: 0.6;
mask-image: linear-gradient(to bottom, black, transparent 62%);
pointer-events: none;
position: absolute;
}
.heroGlow {
background: radial-gradient(
ellipse 60% 55% at 50% -10%,
rgba(16, 185, 129, 0.22),
transparent 70%
);
height: 32rem;
inset: 0 0 auto 0;
pointer-events: none;
position: absolute;
}
.heroGrid {
.hero {
align-items: center;
display: grid;
gap: clamp(2.5rem, 5vw, 5rem);
grid-template-columns: minmax(0, 0.92fr) minmax(500px, 1.08fr);
gap: clamp(3rem, 6vw, 6rem);
grid-template-columns: minmax(0, 0.88fr) minmax(560px, 1.12fr);
min-height: min(820px, calc(100vh - var(--ifm-navbar-height)));
padding-bottom: 6rem;
padding-top: 6rem;
position: relative;
z-index: 1;
}
.heroCopy {
max-width: 600px;
max-width: 650px;
}
.eyebrow {
.eyebrow,
.sectionKicker {
align-items: center;
border: 1px solid var(--home-line);
border-radius: 999px;
color: var(--home-muted);
display: inline-flex;
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
font-weight: 500;
gap: 0.55rem;
letter-spacing: 0.05em;
margin: 0 0 1.75rem;
padding: 0.45rem 1rem;
text-decoration: none !important;
color: var(--home-teal);
display: flex;
font-size: 0.76rem;
font-weight: 850;
gap: 0.65rem;
letter-spacing: 0.18em;
margin: 0 0 1.35rem;
text-transform: uppercase;
transition: border-color 160ms ease, color 160ms ease;
}
.eyebrow:hover {
border-color: var(--home-card-hover-line);
color: var(--home-ink);
}
.eyebrowArrow {
color: var(--home-emerald);
transition: transform 160ms ease;
}
.eyebrow:hover .eyebrowArrow {
transform: translateX(2px);
.pulse {
background: var(--home-teal-bright);
border-radius: 50%;
box-shadow: 0 0 0 5px rgba(22, 163, 161, 0.14);
height: 0.55rem;
width: 0.55rem;
}
.hero h1 {
color: var(--home-ink);
font-size: clamp(2.6rem, 4.6vw, 3.9rem);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.06;
font-size: clamp(3.4rem, 6.4vw, 6.4rem);
font-weight: 780;
letter-spacing: -0.075em;
line-height: 0.92;
margin: 0;
overflow-wrap: normal;
word-break: normal;
}
.hero h1 .unbreakable {
white-space: nowrap;
}
.hero h1 .heroAccent {
background: linear-gradient(105deg, var(--home-teal) 5%, #1a9793 48%, var(--home-amber));
background-clip: text;
color: transparent;
display: inline;
-webkit-background-clip: text;
}
.lede {
color: var(--home-muted);
font-size: clamp(1.02rem, 1.4vw, 1.15rem);
line-height: 1.6;
margin: 1.5rem 0 0;
max-width: 520px;
}
.installBox {
align-items: center;
background: var(--home-terminal-bg);
border: 1px solid var(--home-terminal-line);
border-radius: 0.5rem;
display: flex;
font-family: var(--ifm-font-family-monospace);
font-size: 0.88rem;
gap: 0.7rem;
font-size: clamp(1.08rem, 1.55vw, 1.32rem);
line-height: 1.65;
margin: 2rem 0 0;
max-width: 420px;
padding: 0.75rem 1.1rem;
}
.installPrompt {
color: var(--home-emerald-bright);
font-weight: 700;
}
.installBox code {
background: transparent;
border: 0;
color: #e4e4e7;
padding: 0;
max-width: 630px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.75rem;
gap: 0.8rem;
margin-top: 2.3rem;
}
.primaryAction,
.secondaryAction,
.ctaAction {
align-items: center;
border-radius: 0.5rem;
border-radius: 999px;
display: inline-flex;
font-size: 0.92rem;
font-weight: 600;
gap: 0.6rem;
font-weight: 750;
gap: 0.8rem;
justify-content: center;
padding: 0.72rem 1.25rem;
padding: 0.88rem 1.35rem;
text-decoration: none !important;
transition:
transform 160ms ease,
box-shadow 160ms ease,
background 160ms ease,
border-color 160ms ease;
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.primaryAction {
background: var(--home-emerald);
color: #fff;
background: var(--home-ink);
box-shadow: 0 12px 28px rgba(16, 47, 49, 0.2);
color: #f6fffd;
}
.primaryAction:hover {
background: var(--home-emerald-bright);
color: #06251c;
transform: translateY(-1px);
box-shadow: 0 16px 36px rgba(16, 47, 49, 0.28);
color: #fff;
transform: translateY(-2px);
}
.secondaryAction {
background: transparent;
background: rgba(255, 255, 255, 0.58);
border: 1px solid var(--home-line);
color: var(--home-ink);
}
.secondaryAction:hover {
border-color: var(--home-card-hover-line);
color: var(--home-emerald);
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.9);
color: var(--home-teal);
transform: translateY(-2px);
}
.install {
align-items: center;
color: var(--home-muted);
display: flex;
font-size: 0.84rem;
gap: 0.7rem;
margin-top: 1.4rem;
}
.install span {
color: var(--home-amber);
font-family: var(--ifm-font-family-monospace);
font-weight: 800;
}
.install code {
background: transparent;
border: 0;
color: inherit;
padding: 0;
}
.hotReload {
align-items: center;
color: var(--home-muted);
color: var(--home-teal);
display: inline-flex;
font-family: var(--ifm-font-family-monospace);
font-size: 0.8rem;
font-size: 0.82rem;
font-weight: 750;
gap: 0.5rem;
margin-top: 1.25rem;
margin-top: 0.75rem;
text-decoration: none !important;
}
.hotReload span {
color: var(--home-emerald);
color: var(--home-amber);
font-size: 1rem;
}
.hotReload:hover {
color: var(--home-ink);
color: var(--home-teal-bright);
}
/* ---------- Code showcase ---------- */
.codeStage {
min-width: 0;
perspective: 1400px;
perspective: 1200px;
position: relative;
}
.codeGlow {
background: radial-gradient(
ellipse 70% 70% at 60% 30%,
rgba(16, 185, 129, 0.28),
transparent 70%
);
filter: blur(50px);
inset: 6% 4%;
opacity: 0.75;
background: linear-gradient(115deg, rgba(11, 133, 136, 0.48), rgba(238, 156, 40, 0.38));
filter: blur(55px);
inset: 8% 5%;
opacity: 0.55;
position: absolute;
}
.codeWindow {
background: var(--home-terminal-bg);
border: 1px solid var(--home-terminal-line);
border-radius: 0.75rem;
box-shadow: 0 34px 90px rgba(0, 0, 0, 0.35);
background: #101b1e;
border: 1px solid rgba(166, 225, 220, 0.2);
border-radius: 1.25rem;
box-shadow:
0 34px 90px rgba(18, 49, 52, 0.3),
0 2px 0 rgba(255, 255, 255, 0.06) inset;
overflow: hidden;
position: relative;
transform: rotateY(-4deg) rotateX(2deg);
transition: transform 400ms ease;
}
.codeStage:hover .codeWindow {
transform: rotateY(-1deg) rotateX(0.5deg);
transform: rotateY(-2deg) rotateX(1deg);
}
.windowBar {
align-items: center;
border-bottom: 1px solid var(--home-terminal-line);
color: #a1a1aa;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
color: #b6c9c9;
display: grid;
font-family: var(--ifm-font-family-monospace);
font-size: 0.72rem;
font-size: 0.73rem;
grid-template-columns: 1fr auto 1fr;
padding: 0.75rem 1rem;
padding: 0.78rem 1rem;
}
.windowDots {
@@ -268,37 +226,80 @@
}
.windowDots span {
background: #3f3f46;
background: #4f6d6e;
border-radius: 50%;
height: 0.55rem;
width: 0.55rem;
}
.windowDots span:first-child {
background: #ef8d5b;
}
.windowDots span:nth-child(2) {
background: #e9bc50;
}
.windowDots span:last-child {
background: #65bd92;
}
.windowStatus {
color: var(--home-emerald-bright);
color: #74d2cd;
justify-self: end;
}
.codeWindow :global(.theme-code-block) {
border: 0;
border-radius: 0;
box-shadow: none;
margin: 0;
}
.codeWindow :global(.theme-code-block pre.thin-scrollbar) {
background: var(--home-terminal-bg) !important;
font-size: 0.8rem !important;
line-height: 1.62 !important;
.codeWindow :global(.theme-code-block pre) {
background: #101b1e !important;
color: #d7e5e4 !important;
font-size: 0.78rem;
line-height: 1.62;
max-height: none;
padding: 1.4rem 1.6rem !important;
padding: 1.45rem 1.6rem !important;
}
.codeWindow :global(.token-line),
.codeWindow :global(.token.plain),
.codeWindow :global(.token.imports),
.codeWindow :global(.token.maybe-class-name),
.codeWindow :global(.token.punctuation),
.codeWindow :global(.token.operator),
.codeWindow :global(.token.plain-text),
.codeWindow :global(.token.property-access) {
color: #d7e5e4 !important;
}
.codeWindow :global(.token.keyword) {
color: #c792ea !important;
}
.codeWindow :global(.token.string) {
color: #c3e88d !important;
}
.codeWindow :global(.token.function),
.codeWindow :global(.token.method) {
color: #82aaff !important;
}
.codeWindow :global(.token.tag) {
color: #f07178 !important;
}
.codeWindow :global(.token.builtin) {
color: #ffcb6b !important;
}
.codeLegend {
border-top: 1px solid var(--home-terminal-line);
color: #a1a1aa;
border-top: 1px solid rgba(255, 255, 255, 0.08);
color: #a9bcbc;
display: grid;
font-family: var(--ifm-font-family-monospace);
font-size: 0.7rem;
grid-template-columns: repeat(3, 1fr);
}
@@ -309,18 +310,18 @@
}
.codeLegend span + span {
border-left: 1px solid var(--home-terminal-line);
border-left: 1px solid rgba(255, 255, 255, 0.08);
}
.codeLegend b {
color: var(--home-emerald-bright);
margin-right: 0.3rem;
color: #e8a94b;
font-family: var(--ifm-font-family-monospace);
margin-right: 0.25rem;
}
/* ---------- Features ---------- */
.featuresSection {
padding-bottom: 6rem;
padding-bottom: 7rem;
padding-top: 4rem;
position: relative;
z-index: 1;
}
@@ -329,35 +330,27 @@
display: grid;
gap: 0 3rem;
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
margin-bottom: 2.5rem;
margin-bottom: 2.8rem;
}
.sectionKicker {
color: var(--home-emerald);
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
font-weight: 600;
.sectionHeading .sectionKicker {
grid-column: 1 / -1;
letter-spacing: 0.04em;
margin: 0 0 1rem;
text-transform: uppercase;
}
.sectionHeading h2,
.cta h2 {
color: var(--home-ink);
font-size: clamp(1.9rem, 3.2vw, 2.6rem);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.1;
font-size: clamp(2.25rem, 4vw, 4rem);
letter-spacing: -0.055em;
line-height: 1;
margin: 0;
}
.sectionHeading > p:last-child {
align-self: end;
color: var(--home-muted);
font-size: 1rem;
line-height: 1.6;
font-size: 1.03rem;
line-height: 1.65;
margin: 0;
}
@@ -370,22 +363,41 @@
.featureCard {
background: var(--home-card);
border: 1px solid var(--home-line);
border-radius: 0.75rem;
border-radius: 1.35rem;
color: inherit;
display: flex;
flex-direction: column;
min-height: 300px;
padding: 1.4rem;
min-height: 310px;
overflow: hidden;
padding: 1.45rem;
position: relative;
text-decoration: none !important;
transition:
border-color 160ms ease,
transform 160ms ease;
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
}
.featureCard::before {
background: linear-gradient(90deg, var(--home-teal-bright), var(--home-amber));
content: "";
height: 3px;
left: 1.45rem;
opacity: 0;
position: absolute;
right: 1.45rem;
top: 0;
transform: scaleX(0.5);
transition: opacity 180ms ease, transform 180ms ease;
}
.featureCard:hover {
border-color: var(--home-card-hover-line);
border-color: rgba(8, 119, 123, 0.35);
box-shadow: 0 24px 60px rgba(28, 65, 66, 0.12);
color: inherit;
transform: translateY(-3px);
transform: translateY(-5px);
}
.featureCard:hover::before {
opacity: 1;
transform: scaleX(1);
}
.cardTopline {
@@ -396,36 +408,36 @@
.featureIcon {
align-items: center;
background: rgba(5, 150, 105, 0.1);
border-radius: 0.4rem;
color: var(--home-emerald);
background: rgba(8, 119, 123, 0.1);
border: 1px solid rgba(8, 119, 123, 0.12);
border-radius: 0.8rem;
color: var(--home-teal);
display: flex;
font-family: var(--ifm-font-family-monospace);
font-size: 0.95rem;
font-weight: 700;
height: 2.4rem;
font-size: 1rem;
font-weight: 850;
height: 2.7rem;
justify-content: center;
width: 2.4rem;
width: 2.7rem;
}
.cardIndex {
color: var(--home-muted);
color: rgba(16, 47, 49, 0.32);
font-family: var(--ifm-font-family-monospace);
font-size: 0.72rem;
}
.featureCard h3 {
color: var(--home-ink);
font-size: 1.15rem;
font-weight: 650;
letter-spacing: -0.01em;
margin: 1.8rem 0 0.6rem;
font-size: 1.25rem;
letter-spacing: -0.025em;
margin: 2rem 0 0.7rem;
}
.featureCard > p {
color: var(--home-muted);
font-size: 0.9rem;
line-height: 1.6;
font-size: 0.94rem;
line-height: 1.65;
margin: 0;
}
@@ -434,120 +446,150 @@
display: flex;
justify-content: space-between;
margin-top: auto;
padding-top: 1.4rem;
padding-top: 1.5rem;
}
.cardFooter code {
background: rgba(5, 150, 105, 0.1);
background: rgba(8, 119, 123, 0.08);
border: 0;
color: var(--home-emerald);
font-size: 0.68rem;
padding: 0.32rem 0.5rem;
color: var(--home-teal);
font-size: 0.7rem;
padding: 0.35rem 0.5rem;
}
.cardFooter > span {
color: var(--home-emerald);
font-size: 1rem;
transition: transform 160ms ease;
color: var(--home-amber);
font-size: 1.1rem;
transition: transform 180ms ease;
}
.featureCard:hover .cardFooter > span {
transform: translate(2px, -2px);
transform: translate(3px, -3px);
}
/* ---------- CTA ---------- */
.ctaWrap {
padding-bottom: 6.5rem;
padding-bottom: 7rem;
position: relative;
z-index: 1;
}
.cta {
align-items: center;
background: var(--home-terminal-bg);
border-radius: 1rem;
background:
radial-gradient(circle at 90% 20%, rgba(233, 149, 38, 0.22), transparent 20rem),
linear-gradient(125deg, #12383a, #10292c);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1.75rem;
box-shadow: 0 28px 80px rgba(16, 47, 49, 0.2);
display: flex;
gap: 2.5rem;
gap: 3rem;
justify-content: space-between;
overflow: hidden;
padding: clamp(2rem, 4.5vw, 3.5rem);
position: relative;
}
.ctaGridBackdrop {
background-image:
linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 40px 40px;
inset: 0;
mask-image: radial-gradient(ellipse 70% 100% at 100% 0%, black, transparent);
pointer-events: none;
position: absolute;
}
.ctaCopy {
position: relative;
padding: clamp(2rem, 5vw, 4rem);
}
.cta .sectionKicker {
color: var(--home-emerald-bright);
color: #79d5d1;
}
.cta h2 {
color: #fafafa;
color: #f7fffd;
}
.cta p:last-child {
color: #a1a1aa;
line-height: 1.6;
margin: 0.85rem 0 0;
max-width: 620px;
color: #b9cccc;
line-height: 1.65;
margin: 1rem 0 0;
max-width: 650px;
}
.ctaAction {
background: var(--home-emerald);
color: #fff;
background: #f0a13b;
color: #162d2e;
flex: 0 0 auto;
position: relative;
}
.ctaAction:hover {
background: var(--home-emerald-bright);
color: #06251c;
transform: translateY(-1px);
box-shadow: 0 14px 35px rgba(0, 0, 0, 0.24);
color: #102526;
transform: translateY(-2px);
}
:global([data-theme="dark"]) .page {
--home-ink: #e9f7f5;
--home-muted: #a9bfbd;
--home-teal: #79d5d1;
--home-teal-bright: #5bc8c5;
--home-amber: #f1ad4e;
--home-card: rgba(20, 42, 45, 0.76);
--home-line: rgba(174, 221, 218, 0.14);
background:
radial-gradient(circle at 8% 5%, rgba(33, 154, 154, 0.17), transparent 28rem),
radial-gradient(circle at 92% 14%, rgba(209, 130, 31, 0.13), transparent 27rem),
linear-gradient(145deg, #0f1b1e 0%, #102426 48%, #161e1e 100%);
}
:global([data-theme="dark"]) .gridBackdrop {
background-image:
linear-gradient(rgba(174, 221, 218, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(174, 221, 218, 0.04) 1px, transparent 1px);
}
:global([data-theme="dark"]) .secondaryAction {
background: rgba(19, 43, 46, 0.75);
}
:global([data-theme="dark"]) .secondaryAction:hover {
background: rgba(27, 57, 60, 0.95);
}
:global([data-theme="dark"]) .primaryAction {
background: #dff8f5;
color: #123638;
}
:global([data-theme="dark"]) .primaryAction:hover {
color: #0b282a;
}
:global([data-theme="dark"]) .cardIndex {
color: rgba(220, 242, 239, 0.35);
}
@media screen and (max-width: 1180px) {
.heroGrid {
.hero {
gap: 3rem;
grid-template-columns: minmax(0, 0.9fr) minmax(460px, 1.1fr);
grid-template-columns: minmax(0, 0.85fr) minmax(500px, 1.15fr);
}
.hero h1 {
font-size: clamp(2.4rem, 4.4vw, 3.2rem);
font-size: clamp(3.2rem, 6vw, 5rem);
}
}
@media screen and (max-width: 996px) {
.heroGrid {
.hero {
grid-template-columns: minmax(0, 1fr);
padding-bottom: 4rem;
padding-top: 4.5rem;
}
.heroCopy,
.codeStage,
.codeWindow {
.codeWindow,
.codeWindow :global(.theme-code-block) {
min-width: 0;
width: 100%;
}
.heroCopy {
max-width: 640px;
max-width: 760px;
}
.codeStage {
margin: 0 auto;
max-width: 640px;
max-width: 760px;
width: 100%;
}
.codeWindow {
@@ -557,43 +599,51 @@
.featureGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media screen and (max-width: 700px) {
.hero {
box-sizing: border-box;
margin-left: 0;
margin-right: 0;
max-width: 100vw;
padding-left: 1.15rem;
padding-right: 1.15rem;
width: 100vw;
}
.heroCopy,
.codeStage {
max-width: calc(100vw - 2.3rem);
}
.hero h1 {
font-size: clamp(3rem, 15vw, 4.3rem);
}
.sectionHeading {
grid-template-columns: 1fr;
}
.sectionHeading > p:last-child {
margin-top: 1rem;
}
}
@media screen and (max-width: 700px) {
.hero {
padding: 3.5rem 0 2.5rem;
}
.heroCopy,
.codeStage {
max-width: 100%;
}
.actions {
flex-direction: column;
}
.primaryAction,
.secondaryAction {
width: 100%;
margin-top: 1.25rem;
}
.featureGrid {
grid-template-columns: 1fr;
}
.codeWindow :global(.theme-code-block pre) {
font-size: 0.72rem;
overflow-x: auto;
padding: 1.1rem !important;
.featureCard {
min-height: 275px;
}
.codeLegend {
grid-template-columns: 1fr;
}
.codeLegend span + span {
border-left: 0;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.windowStatus {
@@ -604,29 +654,25 @@
grid-template-columns: 1fr auto;
}
.codeWindow :global(.theme-code-block pre) {
font-size: 0.69rem;
overflow-x: auto;
padding: 1.1rem !important;
}
.cta {
align-items: flex-start;
flex-direction: column;
}
.codeLegend {
grid-template-columns: 1fr;
}
.codeLegend span + span {
border-left: 0;
border-top: 1px solid var(--home-terminal-line);
}
}
@media (prefers-reduced-motion: reduce) {
.featureCard,
.featureCard::before,
.cardFooter > span,
.primaryAction,
.secondaryAction,
.ctaAction,
.eyebrow,
.eyebrowArrow,
.codeWindow {
.ctaAction {
transition: none;
}
}
+22 -44
View File
@@ -91,44 +91,26 @@ export default function Home(): ReactNode {
description="Write React function components as typed Effect programs"
>
<main className={styles.page}>
<section className={styles.hero}>
<div className={styles.gridBackdrop} aria-hidden="true" />
<div className={styles.heroGlow} aria-hidden="true" />
<div className={clsx("container", styles.heroGrid)}>
<section className={clsx("container", styles.hero)}>
<div className={styles.heroCopy}>
<Link className={styles.eyebrow} to="/docs/getting-started">
<span aria-hidden="true">{"//"}</span>
<div className={styles.eyebrow}>
<span className={styles.pulse} />
Effect View for React 19
<span aria-hidden="true" className={styles.eyebrowArrow}>
→
</span>
</Link>
</div>
<h1>
React components,
<br />
powered by Effect.
React <span className={styles.unbreakable}>components,</span>
<span className={styles.heroAccent}> powered by Effect.</span>
</h1>
<p className={styles.lede}>
Bring typed services, scoped resources, reactive state,
server queries, and schema-driven forms into React without
hiding either framework.
Bring typed services, scoped resources, reactive state, server
queries, and schema-driven forms into React without hiding either
framework.
</p>
<div className={styles.installBox}>
<span className={styles.installPrompt} aria-hidden="true">
$
</span>
<code>npm install effect-view effect</code>
</div>
<div className={styles.actions}>
<Link
className={styles.primaryAction}
to="/docs/getting-started"
>
<Link className={styles.primaryAction} to="/docs/getting-started">
Start building
<span aria-hidden="true">→</span>
</Link>
@@ -140,6 +122,10 @@ export default function Home(): ReactNode {
</Link>
</div>
<div className={styles.install}>
<span aria-hidden="true">$</span>
<code>npm install effect-view effect@beta</code>
</div>
<Link
className={styles.hotReload}
to="/docs/getting-started#set-up-hot-reloading-with-vite"
@@ -163,16 +149,9 @@ export default function Home(): ReactNode {
</div>
<CodeBlock language="tsx">{componentExample}</CodeBlock>
<div className={styles.codeLegend}>
<span>
<b>01</b> Yield services
</span>
<span>
<b>02</b> Own the lifecycle
</span>
<span>
<b>03</b> Return JSX
</span>
</div>
<span><b>01</b> Yield services</span>
<span><b>02</b> Own the lifecycle</span>
<span><b>03</b> Return JSX</span>
</div>
</div>
</div>
@@ -180,7 +159,7 @@ export default function Home(): ReactNode {
<section className={clsx("container", styles.featuresSection)}>
<div className={styles.sectionHeading}>
<p className={styles.sectionKicker}>{"// Why Effect View"}</p>
<p className={styles.sectionKicker}>Why Effect View</p>
<h2>One model from render to resources.</h2>
<p>
Effect View adds the pieces React deliberately leaves open while
@@ -214,13 +193,12 @@ export default function Home(): ReactNode {
<section className={clsx("container", styles.ctaWrap)}>
<div className={styles.cta}>
<div className={styles.ctaGridBackdrop} aria-hidden="true" />
<div className={styles.ctaCopy}>
<p className={styles.sectionKicker}>{"// Ready when React is"}</p>
<div>
<p className={styles.sectionKicker}>Ready when React is</p>
<h2>Start with one Effect component.</h2>
<p>
Add a runtime, cross the React boundary once, and grow into
the rest of the toolkit only when you need it.
Add a runtime, cross the React boundary once, and grow into the
rest of the toolkit only when you need it.
</p>
</div>
<Link className={styles.ctaAction} to="/docs/getting-started">
-58
View File
@@ -1,58 +0,0 @@
import type { PrismTheme } from "prism-react-renderer"
/**
* Matches the classic "GitHub Dark" token palette effect.website uses for
* its dark-mode code blocks (extracted from their rendered token styles) -
* prism-react-renderer doesn't ship this one as a preset.
*/
const githubDark: PrismTheme = {
plain: {
color: "#e1e4e8",
backgroundColor: "transparent",
},
styles: [
{
types: ["comment", "prolog", "doctype", "cdata"],
style: { color: "#6a737d" },
},
{
types: ["punctuation", "operator", "entity", "url", "variable"],
style: { color: "#e1e4e8" },
},
{
types: [
"number",
"boolean",
"constant",
"symbol",
"deleted",
"class-name",
"maybe-class-name",
"builtin",
],
style: { color: "#79b8ff" },
},
{
types: ["selector", "attr-name", "string", "char", "inserted", "attr-value"],
style: { color: "#9ecbff" },
},
{
types: ["atrule", "keyword"],
style: { color: "#f97583" },
},
{
types: ["function", "method"],
style: { color: "#b392f0" },
},
{
types: ["regex", "important"],
style: { color: "#ffab70" },
},
{
types: ["tag"],
style: { color: "#85e89d" },
},
],
}
export default githubDark
-38
View File
@@ -1,38 +0,0 @@
const numberedLanguages = new Set(["ts", "tsx", "js", "jsx"])
interface MdastNode {
type: string
lang?: string
meta?: string | null
value?: string
children?: MdastNode[]
}
function visitCodeNodes(node: MdastNode, onCode: (code: MdastNode) => void): void {
if (!node.children) return
for (const child of node.children) {
if (child.type === "code") onCode(child)
visitCodeNodes(child, onCode)
}
}
/**
* Turns on line numbers for multi-line ts/tsx/js/jsx fences that don't
* already opt in or out, matching Effect's docs code block presentation.
*/
export default function remarkLineNumbers() {
return (tree: MdastNode) => {
visitCodeNodes(tree, (code) => {
const lang = (code.lang ?? "").toLowerCase()
if (!numberedLanguages.has(lang)) return
const meta = code.meta ?? ""
if (/showLineNumbers/.test(meta)) return
const lineCount = (code.value ?? "").split("\n").length
if (lineCount <= 1) return
code.meta = `${meta} showLineNumbers`.trim()
})
}
}
+2 -2
View File
@@ -26,8 +26,8 @@
"vite": "^8.0.16"
},
"dependencies": {
"@effect/platform": "^0.96.1",
"@effect/platform-browser": "^0.76.0",
"@effect/platform": "^0.97.0",
"@effect/platform-browser": "^0.77.0",
"@radix-ui/themes": "^3.3.0",
"@typed/id": "^0.17.2",
"effect": "^3.21.2",
+1 -1
View File
@@ -39,7 +39,7 @@
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect/platform-browser": "^0.76.0",
"@effect/platform-browser": "^0.77.0",
"@testing-library/react": "^16.3.0",
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
+1 -1
View File
@@ -51,7 +51,7 @@ describe("Subscribable", () => {
await Effect.runPromise(Lens.set(right, "b"))
await waitFor(() => expect(values).toContainEqual([2, "b"]))
await Effect.runPromise(Fiber.interrupt(collector))
Fiber.interruptFork(collector)
})
it("useAll returns the latest values and rerenders when any input changes", async () => {
-53
View File
@@ -1,53 +0,0 @@
# effect-view
`effect-view` lets a React function component be described as an Effect program: yield services, create scoped resources, subscribe to reactive state, and turn Effects into React callbacks inside a component body, then convert that description into a normal React function component at a React boundary.
Requires Effect v4 and React 19.2+. Peer dependencies: `effect`, `react`, `@types/react`. Not tied to `react-dom` — any React renderer works.
When writing effect-view code, use the actual current source and tests in `src/` as ground truth over anything remembered from training — the API is pre-1.0 and still moving. The files below are a concise reference; read the linked one(s) before writing code that touches that concern.
## Core model
1. `ReactRuntime` builds the Effect services available to the UI and exposes them through React context.
2. `Component.make` defines a component body as an Effect generator.
3. `Component.withContext` converts a `Component` into a normal React component at a React boundary (app root, router, third-party library). Apply it only there — never between two effect-view components.
4. Inside another effect-view component, compose children by yielding their `.use` Effect.
5. Every rendered component instance owns a root `Scope.Scope`, opened on mount and closed on unmount; regular component setup must complete **synchronously** during render unless the component is wrapped with `Async.async`.
## Find the right doc by what you're trying to do
**Setting up the app** — building the runtime, providing it to the tree → [ReactRuntime.md](./ai-docs/ReactRuntime.md)
**Defining a component, its lifecycle, or running Effects from event handlers** → [Component.md](./ai-docs/Component.md)
**Storing, reading, or subscribing to state** (local, shared via a service, or focused into a nested field) → [State.md](./ai-docs/State.md) — `Lens` (read/write) and `View` (read-only)
**Rendering something that needs to wait on an async Effect, or avoiding unnecessary re-renders/re-fetches** → [Async.md](./ai-docs/Async.md) — `Async` (suspend on an Effect) and `Memoized` (`React.memo` wrapper)
**Fetching/caching server data** (reactive keys, staleness, background refresh, invalidation) → [Query.md](./ai-docs/Query.md) — includes `QueryClient`, the cache service `Query` runs against
**Triggering a write** (save, delete, upload, send) with pending/error state → [Mutation.md](./ai-docs/Mutation.md)
**Building a schema-driven form**:
- shared concepts (encoded vs decoded value, focusing into fields, input/status hooks) → [Form.md](./ai-docs/Form.md) — read this first
- a form that submits a valid value via a `Mutation` → [MutationForm.md](./ai-docs/MutationForm.md)
- a form that keeps a target `Lens` continuously synchronized with a valid draft → [LensForm.md](./ai-docs/LensForm.md)
**Small utilities**:
- bridging React-tracked values into an Effect `PubSub` → [PubSub.md](./ai-docs/PubSub.md)
- consuming a raw Effect `Stream` as React state → [Stream.md](./ai-docs/Stream.md)
## Choosing between async integrations
- One-off async read before rendering → `Async.async`.
- Cached/shared/refreshable server reads → `Query`.
- User-triggered writes with observable pending/error state → `Mutation`.
- Async work in an event handler with no need for `Mutation` state → `Component.useRunPromise`/`useCallbackPromise`.
- Subscriptions or background work tied to component lifecycle → a scoped fiber forked from `Component.useReactEffect`.
## Common pitfalls
- Never yield an asynchronous Effect directly from a regular (non-`Async`) component body.
- effect-view hooks are still React hooks under the hood: call them unconditionally, at the top level, in a stable order — never in branches, loops, or after a suspend point.
- Keep `ReactRuntime` instances and `Layer` references stable; building them during render creates new resources and a new React context every time.
- `Component.withContext` requires a matching `ReactRuntime.Provider` above it in the tree.
-109
View File
@@ -1,109 +0,0 @@
# effect-view
## 0.3.0
### Minor Changes
- [`3875bc4`](https://git.valverde.cloud/Thilawyn/effect-view/commit/3875bc4f691dfa670137142e3bd62c226f9f5886) - Fix race conditions in `Query` and `Mutation`.
These were most visible on React Native, where scheduling made them easy to hit:
- A request's final state could be lost, leaving `state` stuck on `waiting: true` and the result out of the cache.
- A superseded request could overwrite the state of a newer one, or be recorded in `latestFinalState` while still waiting.
- Concurrent `fetch`/`refresh` calls superseding a running request could leave an extra request running that was never interrupted.
Each run of a Query or Mutation is now an `Operation` (new `Operation` module), and only the current one updates `state` and `latestFinalState`.
- [`3875bc4`](https://git.valverde.cloud/Thilawyn/effect-view/commit/3875bc4f691dfa670137142e3bd62c226f9f5886) - **Breaking:** replace `fetchView`, `refreshView` and `mutateView` with `fetchStream`, `refreshStream` and `mutateStream`.
They start the request the same way, but return a `Stream` of that request's states instead of a `View`. The stream emits the current state first, so subscribing late still yields the final state, then ends once the request settles or is interrupted. Consume it in a component with `Stream.use`.
```ts
// Before
runSync(query.refreshView)
runSync(mutation.mutateView(input))
// After
runSync(query.refreshStream)
runSync(mutation.mutateStream(input))
```
- [`3875bc4`](https://git.valverde.cloud/Thilawyn/effect-view/commit/3875bc4f691dfa670137142e3bd62c226f9f5886) - **Breaking:** a `fetch` or `refresh` superseded by a newer request is now interrupted, instead of returning its unfinished state typed as final. Its state goes back to the previous result, no longer waiting.
- [`3875bc4`](https://git.valverde.cloud/Thilawyn/effect-view/commit/3875bc4f691dfa670137142e3bd62c226f9f5886) - **Breaking:** remove `fiber` from `Query` and `Mutation`. Use the new `operation` View instead, which holds the current `Operation` (its `key`, `state`, `stream`, `fiber`, `interrupt` and `await`).
```ts
// Before
const fiber = yield* View.get(query.fiber)
// After
const operation = yield* View.get(query.operation)
if (Option.isSome(operation)) yield* operation.value.interrupt
```
- [`3875bc4`](https://git.valverde.cloud/Thilawyn/effect-view/commit/3875bc4f691dfa670137142e3bd62c226f9f5886) - **Breaking:** rename `FinalQueryState` to `QueryFinalState`, `FinalMutationState` to `MutationFinalState`, and `LatestMutationState` to `MutationLatestState`. Remove `QueryStateLens`, `makeQueryStateLens`, `MutationStateLens` and `makeMutationStateLens`.
- [`3875bc4`](https://git.valverde.cloud/Thilawyn/effect-view/commit/3875bc4f691dfa670137142e3bd62c226f9f5886) - **Breaking:** when mutations overlap, `mutation.state` and `latestFinalState` now follow only the most recently started call. Earlier calls still run to completion but no longer update them. Use `mutateStream` to follow each call separately.
- [`acb05fc`](https://git.valverde.cloud/Thilawyn/effect-view/commit/acb05fce44c6e67a9924ce878f20e39a4f59af71) - **Breaking:** `QueryClient.layer` is now a `Layer` using the default options. Use `QueryClient.layerWithOptions` to pass options.
```ts
// Before
QueryClient.layer()
QueryClient.layer({ defaultStaleTime: "30 seconds" })
// After
QueryClient.layer
QueryClient.layerWithOptions({ defaultStaleTime: "30 seconds" })
```
- [`2ed417a`](https://git.valverde.cloud/Thilawyn/effect-view/commit/2ed417a49f283c120af4034d1a496d3fe304dd05) - Add offline support to `Query`, like TanStack Query's `networkMode`.
- The new `networkMode` option (`Operation.NetworkMode`) is `"online"` by default: a request made offline waits for a connection before running, and its state has `paused: true` meanwhile, with `result.waiting` still `true`. With `"offlineFirst"`, a request runs right away, and if it fails while offline, it is paused until the connection returns, then runs again. With `"always"`, requests ignore the connection.
- The new `refreshOnReconnect` option refreshes the current key when the connection returns, subject to `staleTime`. It defaults to `true`, except in `"always"` mode.
- `refreshOnFocus` and `refreshOnReconnect` accept `"always"`, to refresh even a fresh result (`Query.RefreshOn`).
- `QueryClient` takes the matching defaults: `defaultNetworkMode` and `defaultRefreshOnReconnect`.
- Focus and reconnect refreshes no longer restart a request that is already running, and a paused request resumes by itself.
The connection status comes from the new `AppConnectivity` service, and focus from the new `AppFocus` service. Each module provides `layerBrowser` (`navigator.onLine` with the `online`/`offline` events, and the page's visibility, like TanStack Query), `layerDummy` (always online or focused, e.g. for tests) and `make(view)` to build the service from any `View<boolean>`, e.g. on React Native.
- [`79d9061`](https://git.valverde.cloud/Thilawyn/effect-view/commit/79d906190754193b1b86bd07cd176d402180509f) - **Breaking:** `Mutation` takes a `networkMode` option too, defaulting to the client's new `defaultMutationNetworkMode` (`"online"` by default). In the default `"online"` mode, mutations called offline are now paused until the connection returns. `Mutation.make` (and `MutationForm.make`) now require a `QueryClient`.
- [`2ed417a`](https://git.valverde.cloud/Thilawyn/effect-view/commit/2ed417a49f283c120af4034d1a496d3fe304dd05) - **Breaking:** `QueryClient.layer` and `QueryClient.layerWithOptions` now require the `AppConnectivity` and `AppFocus` services. In a browser, use `QueryClient.layerBrowser` and `QueryClient.layerBrowserWithOptions`, which provide both.
```ts
// Before
QueryClient.layer
QueryClient.layerWithOptions({ defaultStaleTime: "30 seconds" })
// After, in a browser
QueryClient.layerBrowser
QueryClient.layerBrowserWithOptions({ defaultStaleTime: "30 seconds" })
// After, elsewhere
QueryClient.layer.pipe(
Layer.provide(Layer.mergeAll(AppConnectivity.layerDummy, AppFocus.layerDummy)),
)
```
- [`2ed417a`](https://git.valverde.cloud/Thilawyn/effect-view/commit/2ed417a49f283c120af4034d1a496d3fe304dd05) - **Breaking:** rename `refreshOnWindowFocus` to `refreshOnFocus`, and `defaultRefreshOnWindowFocus` to `defaultRefreshOnFocus`. Focus is now read from the `AppFocus` service instead of the window directly, so it can be provided on any platform.
```ts
// Before
Query.make({ key, f, refreshOnWindowFocus: false })
QueryClient.layerWithOptions({ defaultRefreshOnWindowFocus: false })
// After
Query.make({ key, f, refreshOnFocus: false })
QueryClient.layerBrowserWithOptions({ defaultRefreshOnFocus: false })
```
- **Breaking:** like TanStack Query's `refetchInterval`, `Query.withScheduledRefresh` now skips refreshes while the app is not focused, as reported by the `AppFocus` service. A skipped refresh still counts toward the schedule. Pass `refreshInBackground: true` to keep the previous behavior.
```ts
Query.withScheduledRefresh(Schedule.spaced("5 minutes"), { refreshInBackground: true })
```
### Patch Changes
- [`3875bc4`](https://git.valverde.cloud/Thilawyn/effect-view/commit/3875bc4f691dfa670137142e3bd62c226f9f5886) - Serving a fresh result from the cache no longer resets that cache entry's staleness timer.
+3 -3
View File
@@ -25,12 +25,12 @@ data modeling into React 19 without replacing React's component model. Yield
Effects and services from a component body, let scopes follow the React
lifecycle, and return ordinary JSX.
> **Effect v4:** Effect View is built for Effect v4. If
> **Effect v4 beta:** Effect View is built for the Effect v4 beta release. If
> your application uses Effect v3, use the legacy
> [`effect-fc` package](https://www.npmjs.com/package/effect-fc) instead.
```bash
npm install effect-view effect react
npm install effect-view effect@beta react
```
Effect View does not depend on `react-dom`. Install the renderer used by your
@@ -127,7 +127,7 @@ The complete documentation is available at **[thila.dev/effect-view](https://thi
## Requirements
- React 19.2 or newer
- Effect 4 or newer
- **Effect v4 beta** (`effect@beta`)
- TypeScript and `@types/react` for TypeScript projects
Effect View is renderer-independent and does not require `react-dom`.
-58
View File
@@ -1,58 +0,0 @@
# Async and Memoized
Two `Component` traits that control render behavior: `Async` lets a component's body suspend on an asynchronous Effect; `Memoized` skips re-rendering a component when its props haven't changed. They're commonly combined, since an unmemoized `Async` component restarts its async computation on every unrelated parent render.
## Async
Components run synchronously by default (the body must complete without suspending during render). `Async.async` lifts a component so its body may await an asynchronous Effect before returning JSX; React Suspense handles the wait.
```tsx
import { Effect } from "effect"
import { Async, Component } from "effect-view"
export const UserCard = Component.make("UserCard")(function* ({ userId }: { readonly userId: string }) {
const user = yield* Component.useOnChange(() => loadUser(userId), [userId])
return <article>{user.name}</article>
}).pipe(Async.async)
```
Render with a fallback (per-use or as a component default):
```tsx
const User = yield* UserCard.use
<User userId="123" fallback={<p>Loading user...</p>} />
```
```tsx
.pipe(Async.async, Async.withOptions({ defaultFallback: <p>Loading user...</p> }))
```
Rules:
- **Hook ordering**: place every React hook and effect-view hook helper *before* the first operation that may suspend. After a suspend point, the generator continuation runs outside React's synchronous render phase, so no hooks may follow it.
- The `promise` prop name is reserved on async components (used internally) — do not declare a prop with that name.
When to reach for `Async` vs alternatives:
- One-off asynchronous read before rendering → `Async.async`.
- Cached/shared/refreshable server reads → `Query` (see `Query.md`).
- User-triggered writes with pending/error state → `Mutation` (see `Mutation.md`).
- Async work in an event handler with no need for `Mutation` state → `useRunPromise`/`useCallbackPromise` (see `Component.md`).
- Subscriptions or background work tied to lifecycle → a scoped fiber forked from `useReactEffect`.
## Memoized
Wraps a component's rendered function with `React.memo`, so an unrelated parent re-render doesn't re-run the component (or restart an `Async` child's in-flight computation) when props are unchanged.
```tsx
import { Async, Component, Memoized } from "effect-view"
export const UserCard = Component.make("UserCard")(function* ({ userId }: { readonly userId: string }) {
const user = yield* Component.useOnChange(() => loadUser(userId), [userId])
return <article>{user.name}</article>
}).pipe(Async.async, Memoized.memoized)
```
- Default comparison is `Object.is` per prop (React.memo default), except on `Async` components where `fallback` is excluded from the comparison by default.
- Override with `Memoized.withOptions({ propsEquivalence })`, e.g. `Equal.asEquivalence()` for full structural equality on immutable data. Supplying `propsEquivalence` replaces the default entirely (so `fallback` is included again for `Async` components unless you exclude it yourself).
- `Memoized` is not exclusive to `Async` — it applies to any `Component` — but it matters most there, since an unmemoized async child otherwise closes/reopens its dependency scope and re-runs its async setup on every parent render.
-103
View File
@@ -1,103 +0,0 @@
# Component
Defines a React function component as an Effect program. A `Component` is a description, not yet a React component — cross into React with `Component.withContext` or `.use`.
## Define
```tsx
import { Effect } from "effect"
import { Component } from "effect-view"
export const HelloView = Component.make("HelloView")(function* (props: { readonly name: string }) {
const message = yield* Effect.succeed(`Hello, ${props.name}`)
return <h1>{message}</h1>
})
```
- `Component.make(spanName?)(generatorBody, ...pipeArgs)` — same overloads as `Effect.fn`/`Effect.gen`: a generator body, or a body plus `(_, props) => next` pipeline steps. Passing a `spanName` wraps the body in a tracing span and sets `displayName`.
- `Component.makeUntraced` is identical but skips the automatic span (still sets `displayName` from the name argument).
- The component's props type, return type, error channel, and required services (`R`) are all inferred from the generator body.
## Cross into React
```tsx
export const Hello = HelloView.pipe(Component.withContext(runtime.context))
// <Hello name="Effect" />
```
`Component.withContext(context)` reads the Effect context supplied by the matching `ReactRuntime.Provider` and turns the component into a plain `React.FC`. Apply it only at boundaries where plain React (a router, a third-party lib, an app root) needs a function component — never between two effect-view components.
## Compose inside effect-view
```tsx
const Hello = yield* HelloView.use
return <Hello name="Effect" />
```
`component.use` is an `Effect<F, never, Exclude<R, Scope.Scope>>` that binds the child to the current Effect context/scope and returns a stable function-component reference. Yield it from a parent component body; do not call `withContext` here.
## Lifecycle hooks
Hooks are plain React hooks under the hood: call them unconditionally, at the top level, in the same order every render — never in branches, loops, callbacks, or after a suspend point.
Every rendered component instance gets a root `Scope.Scope`, created on mount and closed on unmount, provided to the whole body. `Effect.addFinalizer`, `Effect.acquireRelease`, `Effect.forkScoped` used directly in the body run against this scope.
| Hook | Purpose | Scope | Closes |
|---|---|---|---|
| body | produce rendered output | component root scope | unmount |
| `useOnMount(() => effect)` | compute + cache once | component root scope | unmount |
| `useOnChange(() => effect, deps)` | recompute on deps change | new scope per dep set | deps change / unmount |
| `useReactEffect(() => effect, deps?)` | post-commit side effect (`Effect.useEffect` analog) | new scope | deps change / unmount |
| `useReactLayoutEffect(() => effect, deps?)` | pre-paint side effect | new scope | deps change / unmount |
| `useLayer(layer, options?)` | build + provide a `Layer`, returns its `Context` | new scope tied to layer identity | layer ref changes / unmount |
```tsx
const state = yield* Component.useOnMount(() =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.log("disposed"))
return Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
}),
)
```
- Setup passed to `useOnMount`/`useOnChange`/`useLayer` must complete **synchronously** in a regular component (it runs during render). Wrap the component with `Async.async` to allow suspending setup.
- `useReactEffect`/`useReactLayoutEffect` setup must also start synchronously but may `Effect.forkScoped` async work into the hook's own scope.
- `useRunSync`/`useRunPromise`/`useCallbackSync`/`useCallbackPromise` do not create a new scope; they capture the component root scope (and any extra services you request) by default.
## Run Effects from event handlers
```tsx
const runPromise = yield* Component.useRunPromise() // or useRunPromise<Scope.Scope | SomeService>()
<button onClick={() => void runPromise(saveUser(user))}>Save</button>
```
- `useRunSync<R>()` — only for Effects guaranteed to complete synchronously.
- `useRunPromise<R>()` — for Effects that may suspend/sleep/fetch.
- `useCallbackSync(f, deps)` / `useCallbackPromise(f, deps)` — memoized variants (same deps semantics as `React.useCallback`) for passing stable callbacks to children.
- Both runners provide `Scope.Scope` automatically; add extra services with an explicit type argument (`useRunPromise<Scope.Scope | UserRepository>()`).
## Provide services
Static layer, one instance per mounted component, disposed on unmount:
```tsx
const GreetingViewLive = GreetingView.pipe(Component.provide(GreetingService.layer))
```
Layer built from render-time state (props/context), provided to children explicitly:
```tsx
const layer = React.useMemo(() => Layer.succeed(GreetingService, {...}), [props.greeting])
const context = yield* Component.useLayer(layer)
const Greeting = yield* Effect.provide(GreetingView.use, context)
return <Greeting name="Effect" />
```
Keep layer references stable (module scope or `React.useMemo`) — a new layer object triggers rebuild and finalizer cleanup. Async layer construction requires `Async.async` on the owning component.
## Common pitfalls
- Never yield an asynchronous Effect from a regular component body — use `Async.async`, `Query`, a `Mutation` callback, or a scoped fiber forked from a post-commit hook instead.
- `Component.withContext` needs a matching `ReactRuntime.Provider` above it in the tree.
- Prefer `useRunPromise` over `useRunSync` for event handlers that may be async.
- Regular React hooks, refs, context, and state work normally inside a component body alongside `yield*`.
-59
View File
@@ -1,59 +0,0 @@
# Form
The shared model implemented by both root form types (`MutationForm.md`, `LensForm.md`) and every subform focused from them. A schema is the single source of truth for shape, validation, and the decoded value the application receives; `Form` supplies reactive state and lifecycle on top of it — it does not render anything.
A schema distinguishes the **encoded value** the UI edits from the **decoded value** the application uses:
```tsx
import { Schema } from "effect"
const ProfileSchema = Schema.Struct({
displayName: Schema.String.check(Schema.isMinLength(1, { message: "Enter a display name" })),
age: Schema.NumberFromString, // input edits a string, app gets a number
contact: Schema.Struct({
email: Schema.String.check(Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, { message: "Enter a valid email" })),
}),
})
```
## The Form interface
| Member | Meaning |
|---|---|
| `encodedValue` | writable input-shaped state (a `Lens`) |
| `value` | decoded value as `Option<A>`; `None` until decoding succeeds |
| `issues` | Standard Schema issues scoped to this form's path |
| `isValidating` | whether schema decoding is currently running |
| `canCommit` | whether the root has a valid value and is ready to commit |
| `isCommitting` | whether a mutation or target write is in progress |
There is no separate "field" type — a field is just a `Form` focused on part of its parent.
## Focus into subforms
```tsx
const displayNameField = Form.focusObjectOn(form, "displayName")
const contactForm = Form.focusObjectOn(form, "contact")
const emailField = form.pipe(Form.focusObjectOn("contact"), Form.focusObjectOn("email"))
```
- `Form.focusObjectOn` (struct key), `Form.focusArrayAt` (array index), `Form.focusTupleAt` (tuple index), `Form.focusChunkAt` (Chunk index). All are dual API: data-first, or curried for chaining through nested paths with `pipe`.
- A focused form exposes `encodedValue`/`value`/`issues` scoped to that path; `isValidating`/`canCommit`/`isCommitting` stay connected to the root form.
- Focus once (e.g. at component setup), not on every render.
## Bind a subform to an input
```tsx
const input = yield* Form.useInput(emailField, { debounce: "250 millis" })
<input value={input.value} onChange={e => input.setValue(e.currentTarget.value)} />
```
- `Form.useInput(form, { debounce? })` returns `{ value, setValue }` from the subform's encoded value. `setValue` writes the form and re-runs the schema pipeline. `debounce` delays propagation to the form (the displayed value updates immediately) — useful for text inputs to avoid validating every keystroke.
- `Form.useOptionalInput(form, { defaultValue, debounce? })` is for a field whose encoded value is `Option<I>`: returns `{ value, setValue, enabled, setEnabled }` for a togglable optional input. `value`/`setValue` operate on the unwrapped `I`; `defaultValue` is used as `value` while `enabled` is `false` (i.e. while the encoded field is `None`), and `defaultValue` is required.
- `Form.useStatus(form, { debounce? })` returns `{ isValidating, isCommitting, canCommit }`, debounced (default 250ms) to avoid flicker in pending indicators.
These hooks are building blocks for your own reusable input components — wrap them once to handle labels, issues, disabled state, and styling consistently; both hooks accept any `Form.Form`, so the same input component works with subforms from `MutationForm` or `LensForm`.
## Schema-owned conversions
Because the schema defines both directions, it can own an entire domain conversion — e.g. a local `datetime-local` input string decoding to a UTC `DateTime.Utc` and back — with no manual parsing in components. See `MutationForm.md` for a complete example (`DateTimeUtcFromZonedInput`).
-25
View File
@@ -1,25 +0,0 @@
# LensForm
A root form (implements `Form.Form`, see `Form.md`) that keeps an encoded draft synchronized in both directions with a target `Lens` of decoded application data. Use for settings panels, inspectors, and edit screens where a valid change should update existing state without a final submit.
```tsx
import { Effect, SubscriptionRef } from "effect"
import { Component, Lens, LensForm, View } from "effect-view"
const [form, profile] = yield* Component.useOnMount(() =>
Effect.gen(function* () {
const profile = Lens.fromSubscriptionRef(
yield* SubscriptionRef.make({ displayName: "Ada", age: 37, contact: { email: "ada@example.com" } }),
)
const form = yield* LensForm.make({ schema: ProfileSchema, target: profile }).pipe(LensForm.thenRun)
return [form, profile] as const
}),
)
const [savedProfile, isCommitting] = yield* View.useAll([profile, form.isCommitting])
```
- `LensForm.make({ schema, target, initialEncodedValue? })` — `target` holds decoded data. `LensForm.thenRun` starts synchronization.
- A valid edit is decoded and written to `target` automatically; an invalid edit stays in the form (so the user can correct it) and never reaches `target`. If something else updates `target`, `LensForm` encodes that value back into the draft.
- Pass `initialEncodedValue` only when the first draft should differ from the encoded target; otherwise `LensForm.make` derives the initial draft by encoding the current target through the schema.
- No `submit` method — commits happen continuously as valid edits arrive. Focus into subforms/fields with `Form.focusObjectOn`/etc. and bind with `Form.useInput` exactly as with `MutationForm` (see `Form.md`).
-93
View File
@@ -1,93 +0,0 @@
# Mutation
effect-view's counterpart to TanStack Query mutations: user-triggered asynchronous work (save, delete, upload, send) as an Effect. No cache, no reactive key, no automatic execution — it runs only when called.
| TanStack Mutation | effect-view |
|---|---|
| mutation variables | the input key `K` |
| `mutationFn` | `f: (key: K) => Effect<A, E, R>` |
| mutation result | `mutation.state`, a `View<{ key: Option<K>; result: AsyncResult<A, E> }>` |
| `isPending` | `state.result.waiting` |
| `mutateAsync` | `mutation.mutate(key)` |
| start without awaiting | `mutation.mutateStream(key)` |
## Create
```tsx
import { Mutation } from "effect-view"
const mutation = yield* Component.useOnMount(() =>
Mutation.make({ f: (input: InviteInput) => sendInvite(input) }),
)
```
`Mutation.make({ f })` is an Effect constructor, not a hook — create each instance once (`Component.useOnMount` for component-owned, an Effect service for shared) and keep it stable. `f` keeps its full `Effect<A, E, R>` type; required services are captured from the creation context, so callbacks don't reconstruct dependencies. Fibers belong to the creation scope and are interrupted if that scope closes while running.
## AsyncResult state
`mutation.state` is a `View` of `{ key: Option<K>, result: AsyncResult<A, E> }`. `result` starts `Initial` (`waiting: false`); calling `mutate`/`mutateStream` sets `waiting: true`, then publishes `Success` or `Failure`. Match on `state.result`, not `state` itself:
```tsx
import { AsyncResult } from "effect/reactivity"
const [state] = yield* View.useAll([mutation.state])
AsyncResult.match(state.result, {
onInitial: ({ waiting }) => (...),
onFailure: ({ cause, previousSuccess, waiting }) => (...), // cause: Cause<E>
onSuccess: ({ value, waiting }) => (...),
})
```
`waiting` is independent of the result tag: after one success, starting another call keeps the value visible while `waiting: true`; if that call fails, the failure can retain `previousSuccess`. Failures carry a full `Cause<E>`.
## mutate vs mutateStream
| Method | Returns | Use for |
|---|---|---|
| `mutate(key)` | the final `MutationFinalState` (`{ key: Option.Some<K>, result: Success \| Failure }`) | an Effect workflow that needs the outcome |
| `mutateStream(key)` | a per-call `Stream<{ key: Option.Some<K>, result: AsyncResult<A, E> }>` of the call's states, starting with the current one and ending once it settles | a UI callback that just starts the work |
```tsx
const runPromise = yield* Component.useRunPromise()
void runPromise(Effect.gen(function* () {
const final = yield* mutation.mutate(input)
if (AsyncResult.isSuccess(final.result)) yield* Effect.log(`Saved ${final.result.value.id}`)
}))
```
```tsx
const runSync = yield* Component.useRunSync()
const states = runSync(mutation.mutateStream(input)) // a Stream for this specific call; consume with `Stream.use`
```
The mutation Effect never fails with `E` itself — it captures the operation's `Exit` and always resolves to a final state wrapping an `AsyncResult.Success`/`Failure`.
## Reactive metadata
| Member | Meaning |
|---|---|
| `state` | latest mutation state, shared `View` |
| `latestKey` | most recent input, `Option<K>` |
| `latestFinalState` | latest completed final state, `Option<MutationFinalState<K, A, E>>` |
| `operation` | most recently started call, `Option<Operation>` (key, state, `fiber`, `interrupt`) |
## Concurrency
Starting a mutation does not interrupt an earlier one — calls can overlap, each with its own `mutateStream` stream; `mutation.state` (and `latestFinalState`) follow only the most recently started call; earlier calls still run to completion but no longer update them. For a single submit button, disabling while `result.waiting` is usually enough. Use per-call `mutateStream` streams (e.g. per uploaded file) when concurrent operations each need their own progress indicator.
## Network mode
Like queries, `Mutation.make({ f, networkMode? })` takes a `networkMode` (falls back to the `QueryClient`'s `defaultMutationNetworkMode`, `"online"` by default — separate from the queries' `defaultNetworkMode`). In `"online"` mode a call made offline waits for the `AppConnectivity` service to report a connection before running `f`; meanwhile `state.paused` is `true` (with `result.waiting` still `true`). `"offlineFirst"` runs `f` right away, and if it fails while offline, pauses until the connection returns and runs it again. `"always"` ignores the connection.
## Updating queries after a mutation
Mutations never auto-invalidate `Query` caches — compose it explicitly:
```tsx
const final = yield* updatePost.mutate(input)
if (AsyncResult.isSuccess(final.result)) {
yield* posts.invalidateCacheEntry(["post", final.result.value.id] as const)
yield* posts.refresh // invalidation alone does not refetch
}
```
@@ -1,51 +0,0 @@
# MutationForm
A root form (implements `Form.Form`, see `Form.md`) that owns a local encoded draft and passes the valid decoded value to a `Mutation` when `submit` runs. Use for registration, checkout, search — any workflow with an explicit submit action.
```tsx
import { Effect } from "effect"
import { Component, MutationForm, View } from "effect-view"
const form = yield* Component.useOnMount(() =>
MutationForm.make({
schema: ProfileSchema,
initialEncodedValue: { displayName: "", age: "", contact: { email: "" } },
f: ([profile, form]) => Effect.log(`Creating ${profile.displayName}, age ${profile.age}`),
}).pipe(MutationForm.thenRun),
)
const [canCommit, isCommitting] = yield* View.useAll([form.canCommit, form.isCommitting])
const runPromise = yield* Component.useRunPromise()
<button disabled={!canCommit || isCommitting} onClick={() => void runPromise(form.submit)}>
{isCommitting ? "Creating..." : "Create profile"}
</button>
```
- `MutationForm.make({ schema, initialEncodedValue, f })` constructs the form; the underlying `Mutation`'s input key is the tuple `[decodedValue, form]`, not just the decoded value. Most `f` implementations only destructure the first element (`profile.age` is a number even though the input edited a string); `form` is included so `f` can, if needed, read other form state (e.g. `form.issues`, `form.encodedValue`) while handling the submission. `MutationForm.thenRun` starts initial validation in the current scope.
- Create once and keep stable — the usual home is `Component.useOnMount`.
- `form.submit` runs the mutation only when the form can currently commit; schema issues block submission before the mutation ever runs.
- Focus into subforms/fields with `Form.focusObjectOn`/`focusArrayAt`/etc. (see `Form.md`) and bind them to inputs with `Form.useInput`.
- If `f` fails with a `Schema.SchemaError`, `form.submit` formats that error into `form.issues` automatically — the same formatting path used for client-side decoding errors — instead of only surfacing it as a mutation failure. Any other failure from `f` is left as the mutation's `Failure` and does not touch `form.issues`.
## Example: schema-owned date conversion
```tsx
class DateTimeUtcFromZoned extends Schema.transformOrFail(Schema.DateTimeZonedFromSelf, Schema.DateTimeUtcFromSelf, {
strict: true,
decode: input => ParseResult.succeed(DateTime.toUtc(input)),
encode: DateTime.setZoneCurrent,
}) {}
export class DateTimeUtcFromZonedInput extends Schema.transformOrFail(Schema.String, DateTimeUtcFromZoned, {
strict: true,
decode: (input, _options, ast) => Effect.flatMap(DateTime.CurrentTimeZone, timeZone =>
Option.match(DateTime.makeZoned(input, { timeZone, adjustForTimeZone: true }), {
onSome: ParseResult.succeed,
onNone: () => ParseResult.fail(new ParseResult.Type(ast, input, "Enter a valid date and time")),
})),
encode: value => ParseResult.succeed(DateTime.formatIsoZoned(value).slice(0, 16)),
}) {}
```
An `<input type="datetime-local">` edits `"2026-07-22T14:30"`; the schema decodes it to a UTC `DateTime.Utc` (`decode(...).pipe requires DateTime.CurrentTimeZone` — provide `DateTime.layerCurrentZoneLocal` in the runtime) and the mutation receives the UTC instant directly. No manual date parsing in the component.
-9
View File
@@ -1,9 +0,0 @@
# PubSub
Re-exports Effect's `PubSub` module in full (`export * from "effect/PubSub"`) and adds one component hook.
```tsx
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
```
`PubSub.useFromReactiveValues(values: DependencyList)` creates a scoped, unbounded `PubSub` on mount and publishes `values` to it every time the dependency array changes (skipping publish once the PubSub has been shut down). Useful for bridging a set of React-tracked reactive values into an Effect `Stream`-based consumer inside the component's scope.
-165
View File
@@ -1,165 +0,0 @@
# Query
effect-view's take on TanStack Query: reactive query keys, cached results, stale times, background refresh, window-focus refetching, cache invalidation — but the query function is an `Effect` and the observable state is a `View` (see `State.md`).
| TanStack Query | effect-view |
|---|---|
| `QueryClient` | the `QueryClient` Effect service (below) |
| `queryKey` | a reactive key supplied as a `View<K>` |
| `queryFn` | `f: (key: K) => Effect<A, E, R>` |
| `useQuery` result | `query.state`, a `View<QueryState<K, A, E>>` |
| `isFetching` | `result.waiting` |
| `fetchStatus: 'paused'` | `state.paused` |
| `refetch` | `query.refresh` / `query.refreshStream` |
| `invalidateQueries` | `query.invalidateCache` / `invalidateCacheEntry` |
| `networkMode` | `networkMode` (`"online"` / `"offlineFirst"` / `"always"`) |
| `onlineManager` / `focusManager` | the `AppConnectivity` / `AppFocus` services |
## QueryClient: the shared cache
Every `Query` reads and writes through a `QueryClient`, the Effect service that owns the cache and its garbage collection. Add it once to the application runtime (browser):
```tsx
import { Layer } from "effect"
import { QueryClient, ReactRuntime } from "effect-view"
const appLayer = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layerBrowserWithOptions({
defaultStaleTime: "30 seconds", // default: "0 minutes"
defaultRefreshOnFocus: true, // default: true
cacheGcTime: "5 minutes", // default: "5 minutes"
})),
)
export const runtime = ReactRuntime.make(appLayer)
```
`QueryClient.layer` (defaults) / `QueryClient.layerWithOptions(options)` build the service and fork its background garbage-collection loop into the layer's scope. Individual queries may override `staleTime`/`networkMode`/`refreshOnFocus`/`refreshOnReconnect`; unset options fall back to these client defaults (`defaultStaleTime`, `defaultNetworkMode`, `defaultRefreshOnFocus`, `defaultRefreshOnReconnect`); mutations use `defaultMutationNetworkMode` instead of `defaultNetworkMode`. `cacheGcTime` controls how long a stale, unaccessed cache entry is kept before eviction. They require the `AppConnectivity` and `AppFocus` services (see "Network mode and offline support"); `QueryClient.layerBrowser` / `QueryClient.layerBrowserWithOptions(options)` provide the browser implementations of both. You interact with the client only indirectly through `Query` instances — no need to call `QueryClientService` methods directly.
## Create and run a query
```tsx
import { Effect, Schema, SubscriptionRef } from "effect"
import { HttpClient } from "effect/http"
import { Component, Lens, Query, View } from "effect-view"
const [postId, query] = yield* Component.useOnMount(() =>
Effect.gen(function* () {
const key = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const))
const query = yield* Query.make({
key,
staleTime: "1 minute",
f: ([, id]) =>
HttpClient.HttpClient.pipe(
Effect.andThen(client => client.get(`https://example.com/posts/${id}`)),
Effect.andThen(res => res.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)),
),
}).pipe(Query.thenRun)
return [Lens.focusTupleAt(key, 1), query] as const
}),
)
```
- `Query.make({ key, f, staleTime?, networkMode?, refreshOnFocus?, refreshOnReconnect?, keyEquivalence? })` constructs the query; `Query.thenRun` starts watching `key` in the current scope.
- Create each query once and keep it stable — the usual home is `Component.useOnMount` (component-owned) or an Effect service (shared across components). Change the key, don't recreate the query, when input changes.
- Keys use Effect equality by default (`keyEquivalence` overrides it). A tuple key plays the same role as `['post', id]` in TanStack Query.
- `f` keeps its full `Effect<A, E, R>` type: required services, schema decoding, retries, tracing all compose normally. The context is captured at creation time.
- Changing the key interrupts the previous in-flight request; a fresh cached success or a new run for the new key follows.
## Render AsyncResult
`query.state: View<QueryState<K, A, E>>` where `QueryState = { key: K; result: AsyncResult<A, E> }`.
```tsx
import { AsyncResult } from "effect/reactivity"
const [state] = yield* View.useAll([query.state])
AsyncResult.match(state.result, {
onInitial: ({ waiting }) => waiting ? <p>Loading...</p> : <p>Not loaded.</p>,
onFailure: ({ cause, previousSuccess, waiting }) => (/* cause: Cause<E>, previousSuccess: Option<Success> */),
onSuccess: ({ value, waiting }) => (/* value: A, waiting: refreshing in background */),
})
```
`waiting` is independent of the result tag: a background refresh keeps a `Success` successful (with its value) while `waiting: true`; a failed refresh can retain `previousSuccess`. Failures carry a full `Cause<E>` (typed errors, defects, interruption), not just `E`.
## Refresh, fetch, invalidate
| Method | Behavior |
|---|---|
| `fetch(key)` | fetch a specific key, wait for its final state |
| `fetchStream(key)` | start fetching a key, return immediately with a `Stream` of that request's states (ends once it settles or is superseded) |
| `refresh` | resolve the current key again, wait for its final state |
| `refreshStream` | resolve the current key again, return immediately with a `Stream` of that request's states (ends once it settles or is superseded) |
| `invalidateCacheEntry(key)` | remove the cached success for one key |
| `invalidateCache` | remove every cached success for this query |
The `*Stream` variants suit synchronous UI callbacks (`runSync(query.refreshStream)`); the non-`Stream` variants suit Effect workflows waiting on the outcome. Each stream emits its request's current state first, so subscribing late still yields the final state. **Invalidating does not refetch by itself** — follow with `refresh`/`refreshStream`, a key change, or a later natural fetch.
```ts
import { Schedule } from "effect"
const query = yield* Query.make(options).pipe(
Query.thenRun,
Query.withScheduledRefresh(Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 }))),
)
```
`Query.withScheduledRefresh(schedule, options?)` forks a refresh fiber tied to the surrounding scope; cache/`staleTime` rules still apply. As with TanStack's `refetchInterval`, refreshes due while the app is not focused (`AppFocus`) are skipped (still counting as a schedule recurrence) unless `{ refreshInBackground: true }`.
## Staleness and lifetime
- `staleTime` (per query, falls back to `QueryClient` default): how long a successful result satisfies a fetch without re-running `f`. A stale entry stays available as previous data while it refreshes.
- `cacheGcTime` (on `QueryClient`): entries unused for `staleTime + cacheGcTime` are evicted.
- `refreshOnFocus` (per query, falls back to `QueryClient` default): re-resolves the current key when the app regains focus, as reported by the `AppFocus` service (page visibility, via the document's `visibilitychange` events, with `AppFocus.layerBrowser`). `true` refreshes only a stale result, `"always"` regardless of `staleTime` (type `Query.RefreshOn`).
## Network mode and offline support
- `networkMode: "online"` (default): a request waits for a connection before running `f`; meanwhile `state.paused` is `true` (with `result.waiting` still `true`). Paused requests can be superseded/interrupted like any other, which clears `paused`.
- `networkMode: "offlineFirst"`: runs `f` right away (e.g. served by a service worker / HTTP cache); if it fails while offline, the request is `paused` until the connection returns, then `f` runs again. A failure while online settles normally.
- `networkMode: "always"`: ignores the connection (for queries that don't use the network).
- `refreshOnReconnect` (defaults to the client default, `false` in `"always"` mode): re-resolves the current key when the connection returns, subject to freshness (`"always"`: regardless of `staleTime`). Focus/reconnect refreshes never restart an in-flight request (a paused one resumes by itself), as in TanStack Query.
- The connection comes from the `AppConnectivity` service (`navigator.onLine` + window `online`/`offline` events with `AppConnectivity.layerBrowser`). `AppConnectivity` and `AppFocus` are `Context.Service`s and must be provided to `QueryClient.layer`/`layerWithOptions` (the `layerBrowser*` variants do it for you).
- Each module has `layerBrowser` (`navigator.onLine` + `online`/`offline` events; page visibility), `layerDummy` (always online / always focused; tests, SSR) and `make(view: View<boolean>)` to build the service from any `View`, provided with `Layer.succeed`. A `View`'s `changes` stream starts with the current value.
- React Native: build them from platform APIs:
```ts
const appConnectivityLayer = Layer.succeed(
AppConnectivity.AppConnectivity,
AppConnectivity.make(View.make({
get: Effect.map(Effect.promise(() => NetInfo.fetch()), state => !!state.isConnected),
// NetInfo calls the listener with the current state right away
changes: Stream.callback<boolean>(queue => Effect.acquireRelease(
Effect.sync(() => NetInfo.addEventListener(state => Queue.offerUnsafe(queue, !!state.isConnected))),
unsubscribe => Effect.sync(unsubscribe),
)),
})),
)
const appFocusLayer = Layer.succeed(
AppFocus.AppFocus,
AppFocus.make(View.make({
get: Effect.sync(() => AppState.currentState === "active"),
changes: Stream.concat(
Stream.sync(() => AppState.currentState === "active"),
Stream.callback<boolean>(queue => Effect.acquireRelease(
Effect.sync(() => AppState.addEventListener("change", status => Queue.offerUnsafe(queue, status === "active"))),
subscription => Effect.sync(() => subscription.remove()),
)),
),
})),
)
const appLayer = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layer),
Layer.provideMerge(appConnectivityLayer),
Layer.provideMerge(appFocusLayer),
)
```
## The Effect touch
Request fibers belong to the creation scope and are interrupted on unmount, key replacement, or scope closure. A `fetch`/`refresh` superseded by a newer request is interrupted (it does not return a result), and the interrupted request's state stops waiting. Results are `View`s usable outside React too. Mutations do not auto-invalidate queries — compose it explicitly (see `Mutation.md`).
@@ -1,39 +0,0 @@
# ReactRuntime
Owns a managed Effect runtime and exposes it to a React subtree through context. Every effect-view application has exactly one root runtime per independent Effect context tree.
## Create and provide
```tsx
import { Layer } from "effect"
import { ReactRuntime } from "effect-view"
const layer = Layer.empty // add application layers here
export const runtime = ReactRuntime.make(layer)
```
```tsx
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { ReactRuntime } from "effect-view"
import { runtime } from "./runtime"
import { App } from "./App"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReactRuntime.Provider runtime={runtime} fallback={<p>Starting...</p>}>
<App />
</ReactRuntime.Provider>
</StrictMode>,
)
```
- `ReactRuntime.make(layer, memoMap?)` builds a `ManagedRuntime` and a `React.Context` in one value. Define it at module scope — never inside a render.
- `ReactRuntime.Provider` builds the runtime layer (which can suspend, hence `fallback`), makes the resulting context available via React context, and disposes the managed runtime on unmount.
- With a router, keep the provider above the router provider. Put a React error boundary above it if runtime construction can fail.
## Rules
- One runtime instance per app (or per independent subtree that needs its own root context).
- `ReactRuntime.Provider` only supplies context; it never builds automatically at other boundaries — `Component.withContext` reads it explicitly.
-87
View File
@@ -1,87 +0,0 @@
# State: Lens and View
`Lens` and `View` are effect-view's state primitives, re-exported in full from [`effect-lens`](https://www.npmjs.com/package/effect-lens/v/beta) with two React hooks added on top (`Lens.useState`, `View.useAll`). The core data model, constructors, and focus/derive API belong to `effect-lens` — consult its docs for anything not covered here.
- **`View<A>`** is the read-only half: a current value plus a stream of changes. Use it for anything a component should only observe (derived/computed state, `Query`/`Mutation` state, a read-only field exposed by a service).
- **`Lens<A>`** is a `View<A>` that can also be written to. Every `Lens` is a `View`, so anywhere a `View` is expected, a `Lens` works too.
## Create state
```tsx
import { SubscriptionRef } from "effect"
import { Lens } from "effect-view"
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
```
The usual pattern: build state with an Effect primitive (`SubscriptionRef`), then wrap it as a `Lens` with a matching constructor.
## Where to store it
| Owner | When |
|---|---|
| Effect service (`Context.Service` + `Layer`) | shared by multiple components / application-level state |
| `Component.useOnMount` | owned by one component instance (or a shallow subtree receiving it via props) |
| plain `React.useState` | simple local UI state with no need for Effect integration, subscriptions, or sharing |
```tsx
class CounterState extends Context.Service<CounterState, {
readonly count: Lens.Lens<number>
readonly doubled: View.View<number>
}>()("CounterState") {
static readonly layer = Layer.effect(CounterState, Effect.gen(function* () {
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
const doubled = View.map(count, n => n * 2) // derived, read-only
return { count, doubled } as const
}))
}
```
## Read: View.useAll
```tsx
const [count, doubled] = yield* View.useAll([state.count, state.doubled])
```
- Reads current values during render, then subscribes via a scoped stream and updates React state on change.
- This is the default way to read a `Lens` too, since `Lens` is a `View`.
- `View.useAll(views, { equivalence? })` — `equivalence` controls when a combined change across the supplied views counts as meaningful (defaults to comparing element-wise with `Equal.strictEqual()`).
## Write
```tsx
yield* Lens.update(state.count, n => n + 1)
yield* Lens.set(state.count, 0)
```
## Lens.useState — read/write tuple for controlled inputs
Use when a JSX API wants React's `[value, setValue]` shape (controlled `<input>`, checkbox, select, third-party `value`/`onChange` props). If a component only needs to *display* the value, prefer `View.useAll` — reach for `Lens.useState` only where reading and writing need to be wired together in React's local-state shape.
```tsx
const [name, setName] = yield* Lens.useState(state.name)
<input value={name} onChange={e => setName(e.currentTarget.value)} />
```
Calling the setter writes through the `Lens`, so every other subscriber (another `Lens.useState`, or a `View.useAll` elsewhere) sees the update. `Lens.useState(lens, { equivalence? })` controls when a change triggers a re-render. The setter accepts a plain value or a `prev => next` updater, same as `React.useState`'s.
## Focused Lenses
A focused Lens is still a `Lens` — read it with `View.useAll` or `Lens.useState` like any other.
```tsx
const nameLens = Lens.focusObjectOn(state.profile, "name")
const cityLens = state.profile.pipe(
Lens.focusObjectOn("contact"),
Lens.focusObjectOn("address"),
Lens.focusObjectOn("city"),
)
```
- Focus helpers (`focusObjectOn`, `focusArrayAt`, `focusTupleAt`, `focusChunkAt`, ...) are dual API: data-first (`Lens.focusObjectOn(lens, key)`) or curried for `pipe` chaining through nested paths.
- Create focused lenses once (e.g. in `Component.useOnMount`), not on every render. Writes through a focused lens propagate to the parent lens.
- Full focus/derive/custom-write API: see the `effect-lens` docs.
## Bridging plain React state into a Lens
`Lens.useFromReactState([value, setValue])` wraps an existing React state tuple as a `Lens`, keeping both directions in sync — useful when integrating a third-party hook that already owns `[value, setValue]` state.
-12
View File
@@ -1,12 +0,0 @@
# Stream
Re-exports Effect's `Stream` module in full (`export * from "effect/Stream"`) and adds one component hook for consuming a stream as React state.
```tsx
const latest = yield* Stream.use(someStream) // Effect<Option<A>, never, R>
const latest = yield* Stream.use(someStream, initialValue) // Effect<Some<A>, never, R>
```
`Stream.use(stream, initialValue?)` subscribes to `stream` for the component's lifetime (via a scoped fiber forked in a post-commit effect) and returns the latest emitted value as React state, deduped with strict equality. Without `initialValue` the result starts as `Option.none()` until the first emission; with one, it starts as `Option.some(initialValue)`.
Prefer `View.useAll` (see `State.md`) when the source is already a `View`/`Lens` — reach for `Stream.use` when you have a raw Effect `Stream` to observe directly.
+6 -24
View File
@@ -1,18 +1,11 @@
{
"name": "effect-view",
"description": "Write React function components with Effect",
"version": "0.3.0",
"version": "0.1.0",
"type": "module",
"files": [
"./README.md",
"./AGENTS.md",
"./CHANGELOG.md",
"./src/**/*.ts",
"!./src/**/*.test.ts",
"./dist/**/*.js",
"./dist/**/*.js.map",
"./dist/**/*.d.ts",
"./ai-docs/**/*.md"
"./dist"
],
"license": "MIT",
"repository": {
@@ -24,14 +17,6 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./AppConnectivity": {
"types": "./dist/AppConnectivity.d.ts",
"default": "./dist/AppConnectivity.js"
},
"./AppFocus": {
"types": "./dist/AppFocus.d.ts",
"default": "./dist/AppFocus.js"
},
"./Async": {
"types": "./dist/Async.d.ts",
"default": "./dist/Async.js"
@@ -64,10 +49,6 @@
"types": "./dist/MutationForm.d.ts",
"default": "./dist/MutationForm.js"
},
"./Operation": {
"types": "./dist/Operation.d.ts",
"default": "./dist/Operation.js"
},
"./PubSub": {
"types": "./dist/PubSub.d.ts",
"default": "./dist/PubSub.js"
@@ -116,18 +97,19 @@
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect/platform-browser": "4.0.0-beta.102",
"@testing-library/react": "^16.3.0",
"effect": "^4.0.0",
"effect": "4.0.0-beta.102",
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "^4.0.0",
"effect": "4.0.0-beta.102",
"react": "^19.2.0"
},
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"effect-lens": "2.0.3"
"effect-lens": "^2.0.1-beta.101"
}
}
@@ -1,63 +0,0 @@
import { Context, Effect, Layer, Pipeable, Predicate, Queue, Stream } from "effect"
import * as View from "./View.js"
export const AppConnectivityServiceTypeId: unique symbol = Symbol.for("@effect-view/AppConnectivity/AppConnectivityService")
export type AppConnectivityServiceTypeId = typeof AppConnectivityServiceTypeId
/** Whether the app has a network connection, as used by Query's `networkMode` and `refreshOnReconnect`. */
export interface AppConnectivityService extends Pipeable.Pipeable {
readonly [AppConnectivityServiceTypeId]: AppConnectivityServiceTypeId
readonly online: View.View<boolean>
}
export const isAppConnectivityService = (u: unknown): u is AppConnectivityService => Predicate.hasProperty(u, AppConnectivityServiceTypeId)
export class AppConnectivityServiceImpl extends Pipeable.Class implements AppConnectivityService {
readonly [AppConnectivityServiceTypeId]: AppConnectivityServiceTypeId = AppConnectivityServiceTypeId
constructor(
readonly online: View.View<boolean>,
) {
super()
}
}
export const make = (online: View.View<boolean>): AppConnectivityService => new AppConnectivityServiceImpl(online)
/** Whether the app has a network connection, as used by Query's `networkMode` and `refreshOnReconnect`. */
export class AppConnectivity extends Context.Service<AppConnectivity, AppConnectivityService>()(
"@effect-view/AppConnectivity/AppConnectivity"
) {}
export const layerDummy: Layer.Layer<AppConnectivity> = Layer.succeed(
AppConnectivity,
make(View.make({
get: Effect.succeed(true),
changes: Stream.succeed(true),
})),
)
export const layerBrowser: Layer.Layer<AppConnectivity> = Layer.succeed(
AppConnectivity,
make(View.make({
get: Effect.sync(() => globalThis.navigator.onLine),
changes: Stream.concat(
Stream.sync(() => globalThis.navigator.onLine),
Stream.callback<boolean>(queue => Effect.acquireRelease(
Effect.sync(() => {
const onlineListener = () => Queue.offerUnsafe(queue, true)
globalThis.window.addEventListener("online", onlineListener)
const offlineListener = () => Queue.offerUnsafe(queue, false)
globalThis.window.addEventListener("offline", offlineListener)
return [onlineListener, offlineListener] as const
}),
([onlineListener, offlineListener]) => Effect.sync(() => {
globalThis.window.removeEventListener("online", onlineListener)
globalThis.window.removeEventListener("offline", offlineListener)
}),
)),
),
})),
)
-60
View File
@@ -1,60 +0,0 @@
import { Context, Effect, Layer, Pipeable, Predicate, Queue, Stream } from "effect"
import * as View from "./View.js"
export const AppFocusServiceTypeId: unique symbol = Symbol.for("@effect-view/AppFocus/AppFocusService")
export type AppFocusServiceTypeId = typeof AppFocusServiceTypeId
/** Whether the app is focused, as used by Query's `refreshOnFocus`. */
export interface AppFocusService extends Pipeable.Pipeable {
readonly [AppFocusServiceTypeId]: AppFocusServiceTypeId
readonly focused: View.View<boolean>
}
export const isAppFocusService = (u: unknown): u is AppFocusService => Predicate.hasProperty(u, AppFocusServiceTypeId)
export class AppFocusServiceImpl extends Pipeable.Class implements AppFocusService {
readonly [AppFocusServiceTypeId]: AppFocusServiceTypeId = AppFocusServiceTypeId
constructor(
readonly focused: View.View<boolean>,
) {
super()
}
}
export const make = (focused: View.View<boolean>): AppFocusService => new AppFocusServiceImpl(focused)
/** Whether the app is focused, as used by Query's `refreshOnFocus`. */
export class AppFocus extends Context.Service<AppFocus, AppFocusService>()(
"@effect-view/AppFocus/AppFocus"
) {}
export const layerDummy: Layer.Layer<AppFocus> = Layer.succeed(
AppFocus,
make(View.make({
get: Effect.succeed(true),
changes: Stream.succeed(true),
})),
)
const isDocumentVisible = () => globalThis.document.visibilityState === "visible"
/** Focused while the page is visible, following the document's `visibilitychange` events. */
export const layerBrowser: Layer.Layer<AppFocus> = Layer.succeed(
AppFocus,
make(View.make({
get: Effect.sync(isDocumentVisible),
changes: Stream.concat(
Stream.sync(isDocumentVisible),
Stream.callback<boolean>(queue => Effect.acquireRelease(
Effect.sync(() => {
const listener = () => Queue.offerUnsafe(queue, isDocumentVisible())
globalThis.document.addEventListener("visibilitychange", listener)
return listener
}),
listener => Effect.sync(() => globalThis.document.removeEventListener("visibilitychange", listener)),
)),
),
})),
)
@@ -10,56 +10,11 @@ import * as ScopeRegistry from "./ScopeRegistry.js"
class ValueService extends Context.Service<ValueService, { readonly value: string }>()("ValueService") {}
class ParentService extends Context.Service<ParentService, { readonly prefix: string }>()("ParentService") {}
afterEach(() => {
vi.useRealTimers()
})
describe("Component", () => {
it("provides a layer once per mounted component instance", async () => {
const setup = vi.fn()
const cleanup = vi.fn()
const serviceLayer = Layer.effect(ValueService, Effect.gen(function*() {
const parent = yield* ParentService
yield* Effect.sync(setup)
yield* Effect.addFinalizer(() => Effect.sync(cleanup))
return { value: `${parent.prefix} value` }
}))
const runtime = ReactRuntime.make(Layer.succeed(ParentService, { prefix: "provided" }))
const effectRuntime = await runtime.runtime.context()
const Probe = Component.makeUntraced("ProvidedServiceProbe")(function*() {
const service = yield* ValueService
return <div>{service.value}</div>
}).pipe(
Component.provide(serviceLayer),
Component.withContext(runtime.context),
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
expect(await screen.findByText("provided value")).toBeTruthy()
expect(setup).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
expect(await screen.findByText("provided value")).toBeTruthy()
expect(setup).toHaveBeenCalledTimes(1)
view.unmount()
await waitFor(() => expect(cleanup).toHaveBeenCalledTimes(1))
await runtime.runtime.dispose()
})
it("does not rerun useOnMount across rerenders after Strict Mode initialization", async () => {
const onMount = vi.fn(() => Effect.succeed("mounted"))
const runtime = ReactRuntime.make(Layer.empty)
+2 -53
View File
@@ -607,57 +607,6 @@ export const withOptions: {
Object.getPrototypeOf(self),
))
export declare namespace provide {
export type Result<T extends Component.Any, ROut, E, RIn> = (
& Omit<T, keyof Component.AsComponent<T>>
& Component<
Component.Props<T>,
Component.Success<T>,
Component.Error<T> | E,
Exclude<Component.Context<T>, ROut> | RIn,
Component.Function<T>
>
)
}
/**
* Provides a layer to an Effect View component.
*
* The layer is built once for each mounted instance of the returned component and
* is released when that instance unmounts. Any services still required by the
* layer remain requirements of the returned component.
*
* @example
* ```tsx
* const TodosViewLive = Component.provide(TodosView, TodosService.Default)
*
* // Equivalent pipeline form
* const TodosViewLive = TodosView.pipe(
* Component.provide(TodosService.Default),
* )
* ```
*/
export const provide: {
<ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>,
): <T extends Component.Any>(self: T) => provide.Result<T, ROut, E, RIn>
<T extends Component.Any, ROut, E, RIn>(
self: T,
layer: Layer.Layer<ROut, E, RIn>,
): provide.Result<T, ROut, E, RIn>
} = Function.dual(2, <T extends Component.Any, ROut, E, RIn>(
self: T,
layer: Layer.Layer<ROut, E, RIn>,
): provide.Result<T, ROut, E, RIn> => Object.setPrototypeOf(
Object.assign(function() {}, self, {
body: (props: Component.Props<T>) => Effect.flatMap(
useLayer(layer),
context => Effect.provide(self.body(props), context),
),
}),
Object.getPrototypeOf(self),
))
/**
* Wraps an Effect View Component and converts it into a standard React function component,
* serving as an **entrypoint** into an Effect View component hierarchy.
@@ -1098,7 +1047,7 @@ export const useCallbackPromise = Effect.fnUntraced(function* <Args extends unkn
return React.useCallback((...args: Args) => Effect.runPromiseWith(contextRef.current)(f(...args)), deps)
})
export declare namespace useLayer {
export declare namespace useContext {
export interface Options extends useOnChange.Options {}
}
@@ -1163,7 +1112,7 @@ export declare namespace useLayer {
*/
export const useLayer = <ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>,
options?: useLayer.Options,
options?: useContext.Options,
): Effect.Effect<Context.Context<ROut>, E, RIn | Scope.Scope> => useOnChange(() => Effect.flatMap(
Effect.context<RIn>(),
context => Layer.build(Layer.provide(layer, Layer.succeedContext(context))),
+1 -42
View File
@@ -1,6 +1,6 @@
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
import * as React from "react"
import type * as React from "react"
import * as Component from "./Component.js"
import * as Lens from "./Lens.js"
import * as View from "./View.js"
@@ -156,47 +156,6 @@ export const focusChunkAt: {
})
export declare namespace useStatus {
export interface Options {
/**
* The debounce interval applied to changes in the returned status.
*
* Defaults to 250 milliseconds.
*/
readonly debounce?: Duration.Input
}
export interface Success {
readonly isValidating: boolean
readonly isCommitting: boolean
readonly canCommit: boolean
}
}
/**
* Subscribes to form status flags for presentation.
*
* Status changes are debounced to avoid transient UI feedback. The underlying
* form state is not changed.
*/
export const useStatus = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
form: Form<P, A, I, ER, EW>,
options?: useStatus.Options,
): Effect.fn.Return<useStatus.Success, ER, Scope.Scope> {
const views = React.useMemo(() => {
const debounce = options?.debounce ?? "250 millis"
return [
View.mapStream(form.isValidating, Stream.debounce(debounce)),
View.mapStream(form.isCommitting, Stream.debounce(debounce)),
View.mapStream(form.canCommit, Stream.debounce(debounce)),
] as const
}, [form, options?.debounce])
const [isValidating, isCommitting, canCommit] = yield* View.useAll(views)
return { isValidating, isCommitting, canCommit }
})
export namespace useInput {
export interface Options {
readonly debounce?: Duration.Input
+10 -5
View File
@@ -189,13 +189,18 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
)
})
export const thenRun = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never, E = never, R = never>(
self: Effect.Effect<LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>, E, R>,
export declare namespace service {
export interface Options<in out A, out I = A, out RD = never, out RE = never, out TER = never, out TEW = never, out TRR = never, out TRW = never>
extends make.Options<A, I, RD, RE, TER, TEW, TRR, TRW> {}
}
export const service = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: service.Options<A, I, RD, RE, TER, TEW, TRR, TRW>
): Effect.Effect<
LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>,
E,
Scope.Scope | R
Schema.SchemaError | TER,
Scope.Scope | RD | RE | TRR | TRW
> => Effect.tap(
self,
make(options),
form => Effect.forkScoped(form.run),
)
-277
View File
@@ -1,277 +0,0 @@
import { Cause, Deferred, Effect, Fiber, Layer, Option, type Scope, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/reactivity"
import { describe, expect, it } from "vitest"
import * as AppConnectivity from "./AppConnectivity.js"
import * as AppFocus from "./AppFocus.js"
import * as Lens from "./Lens.js"
import * as Mutation from "./Mutation.js"
import * as QueryClient from "./QueryClient.js"
import * as View from "./View.js"
const runMutationTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
Effect.runPromise(Effect.scoped(effect.pipe(
Effect.provide(QueryClient.layer.pipe(
Layer.provide(Layer.mergeAll(AppConnectivity.layerDummy, AppFocus.layerDummy)),
)),
)))
/** Runs `f` with an AppConnectivity whose online status is controlled through the returned ref. */
const withAppConnectivity = <A, E, R>(
initial: boolean,
f: (online: SubscriptionRef.SubscriptionRef<boolean>) => Effect.Effect<A, E, R>,
) => Effect.flatMap(
SubscriptionRef.make(initial),
online => Effect.provide(f(online), Layer.succeed(AppConnectivity.AppConnectivity, AppConnectivity.make(Lens.fromSubscriptionRef(online)))),
)
const currentFiber = <K, A, E, R>(mutation: Mutation.Mutation<K, A, E, R>) => Effect.flatMap(
View.get(mutation.operation),
Option.match({
onSome: operation => View.get(operation.fiber),
onNone: () => Effect.succeedNone,
}),
)
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* currentFiber(mutation),
}
}))
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("runs a second mutation with its own key, not the previous one", async () => {
const result = await runMutationTest(Effect.gen(function*() {
const calls: Array<string> = []
const mutation = yield* Mutation.make({
f: (key: string) => Effect.sync(() => {
calls.push(key)
return key
}),
})
const first = yield* mutation.mutate("a")
const second = yield* mutation.mutate("b")
return { calls, first, second }
}))
expect(result.calls).toEqual(["a", "b"])
expect(result.first.key.value).toBe("a")
expect(expectSuccessValue(result.first)).toBe("a")
expect(result.second.key.value).toBe("b")
expect(expectSuccessValue(result.second)).toBe("b")
})
it("mutateStream returns without waiting for completion, and streams the states until settled", 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 stream = yield* mutation.mutateStream("save")
const states = yield* Effect.forkScoped(Stream.runCollect(stream))
yield* Effect.yieldNow
const hasRunningFiber = Option.isSome(yield* currentFiber(mutation))
yield* Deferred.succeed(deferred, "saved")
return {
states: yield* Fiber.join(states).pipe(Effect.timeout("1 second")),
hasRunningFiber,
}
}))
expect(result.hasRunningFiber).toBe(true)
expect(result.states).toHaveLength(2)
const [pending, final] = result.states
expect(pending.key.value).toBe("save")
expect(AsyncResult.isInitial(pending.result)).toBe(true)
expect(pending.result.waiting).toBe(true)
expect(final.key.value).toBe("save")
expect(expectSuccessValue(final)).toBe("saved")
})
it("exposes the most recently started operation", async () => {
const result = await runMutationTest(Effect.gen(function*() {
const mutation = yield* Mutation.make({
f: (key: string) => Effect.succeed(key),
})
yield* mutation.mutate("a")
yield* mutation.mutate("b")
return Option.map(yield* View.get(mutation.operation), operation => operation.key)
}))
expect(result).toEqual(Option.some(Option.some("b")))
})
it("an older mutation finishing late does not overwrite the newer state", async () => {
const result = await runMutationTest(Effect.gen(function*() {
const slow = yield* Deferred.make<string>()
const mutation = yield* Mutation.make({
f: (key: string) => key === "a" ? Deferred.await(slow) : Effect.succeed(key),
})
yield* Effect.asVoid(mutation.mutateStream("a"))
yield* Effect.yieldNow
yield* mutation.mutate("b")
yield* Deferred.succeed(slow, "a")
yield* Effect.sleep("10 millis")
return {
state: yield* View.get(mutation.state),
latestFinalState: yield* View.get(mutation.latestFinalState),
}
}))
expect(result.state.key).toEqual(Option.some("b"))
expect(expectSuccessValue(result.state)).toBe("b")
expect(Option.map(result.latestFinalState, s => s.key.value)).toEqual(Option.some("b"))
})
})
describe("Mutation network mode", () => {
it("in online mode, a mutation made offline is paused until the connection returns", async () => {
const result = await runMutationTest(withAppConnectivity(false, online => Effect.gen(function*() {
let calls = 0
const mutation = yield* Mutation.make({
f: (key: number) => Effect.sync(() => {
calls += 1
return `value:${key}`
}),
})
const stream = yield* mutation.mutateStream(1)
const states = yield* Effect.forkScoped(Stream.runCollect(stream))
yield* Effect.yieldNow
const whileOffline = { calls, state: yield* View.get(mutation.state) }
yield* SubscriptionRef.set(online, true)
return {
whileOffline,
states: yield* Fiber.join(states).pipe(Effect.timeout("1 second")),
calls,
}
})))
expect(result.whileOffline.calls).toBe(0)
expect(result.whileOffline.state.paused).toBe(true)
expect(result.whileOffline.state.result.waiting).toBe(true)
expect(result.calls).toBe(1)
const last = result.states.at(-1)
if (!last)
throw new Error("Expected at least one state")
expect(last.paused).toBe(false)
expect(expectSuccessValue(last)).toBe("value:1")
})
it("in always mode, mutations ignore the connection", async () => {
const result = await runMutationTest(withAppConnectivity(false, () => Effect.gen(function*() {
const mutation = yield* Mutation.make({
f: (key: number) => Effect.succeed(`value:${key}`),
networkMode: "always",
})
return yield* mutation.mutate(1).pipe(Effect.timeout("1 second"))
})))
expect(expectSuccessValue(result)).toBe("value:1")
expect(result.paused).toBe(false)
})
it("uses the client's defaultMutationNetworkMode, not defaultNetworkMode", async () => {
const result = await Effect.runPromise(Effect.scoped(Effect.gen(function*() {
const mutation = yield* Mutation.make({
f: (key: number) => Effect.succeed(`value:${key}`),
})
return yield* mutation.mutate(1).pipe(Effect.timeout("1 second"))
}).pipe(
Effect.provide(QueryClient.layerWithOptions({
defaultNetworkMode: "online",
defaultMutationNetworkMode: "always",
}).pipe(
Layer.provide(Layer.mergeAll(
Layer.succeed(AppConnectivity.AppConnectivity, AppConnectivity.make(View.make({
get: Effect.succeed(false),
changes: Stream.make(false),
}))),
AppFocus.layerDummy,
)),
)),
)))
expect(expectSuccessValue(result)).toBe("value:1")
expect(result.paused).toBe(false)
})
})
+83 -97
View File
@@ -1,9 +1,7 @@
import { Context, Effect, Option, Pipeable, Predicate, type Scope, type Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/reactivity"
import { type Context, Effect, Equal, Exit, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js"
import * as Operation from "./Operation.js"
import * as QueryClient from "./QueryClient.js"
import type * as View from "./View.js"
import * as View from "./View.js"
export const MutationTypeId: unique symbol = Symbol.for("@effect-view/Mutation/Mutation")
@@ -13,39 +11,16 @@ export interface Mutation<in out K, out A, out E = never, in out R = never>
extends Pipeable.Pipeable {
readonly [MutationTypeId]: MutationTypeId
readonly context: Context.Context<Mutation.Context<R>>
readonly context: Context.Context<Scope.Scope | R>
readonly f: (key: K) => Effect.Effect<A, E, R>
readonly networkMode: Operation.NetworkMode
readonly latestKey: View.View<Option.Option<K>>
readonly operation: View.View<Option.Option<Operation.Operation<Option.Some<K>, A, E, R>>>
readonly state: View.View<MutationLatestState<K, A, E>>
readonly latestFinalState: View.View<Option.Option<MutationFinalState<K, A, E>>>
readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
readonly state: View.View<AsyncResult.AsyncResult<A, E>>
readonly latestFinalResult: View.View<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>
mutate(key: K): Effect.Effect<MutationFinalState<K, A, E>>
mutateStream(key: K): Effect.Effect<Stream.Stream<MutationState<K, A, E>>>
}
export namespace Mutation {
export type Context<R> = Scope.Scope | QueryClient.QueryClient | QueryClient.QueryClientService.Context | R
}
export interface MutationLatestState<out K, out A, out E = never> {
readonly key: Option.Option<K>
readonly result: AsyncResult.AsyncResult<A, E>
readonly paused: boolean
}
export interface MutationState<out K, out A, out E = never> {
readonly key: Option.Some<K>
readonly result: AsyncResult.AsyncResult<A, E>
readonly paused: boolean
}
export interface MutationFinalState<out K, out A, out E = never> {
readonly key: Option.Some<K>
readonly result: AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>
readonly paused: false
mutate(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>>
}
export const isMutation = (u: unknown): u is Mutation<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
@@ -56,77 +31,99 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
readonly [MutationTypeId]: MutationTypeId = MutationTypeId
constructor(
readonly context: Context.Context<Mutation.Context<R>>,
readonly context: Context.Context<Scope.Scope | R>,
readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly networkMode: Operation.NetworkMode,
readonly latestKey: Lens.Lens<Option.Option<K>>,
readonly operation: Lens.Lens<Option.Option<Operation.Operation<Option.Some<K>, A, E, R>>>,
readonly state: Lens.Lens<MutationLatestState<K, A, E>>,
readonly latestFinalState: Lens.Lens<Option.Option<MutationFinalState<K, A, E>>>,
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
readonly state: Lens.Lens<AsyncResult.AsyncResult<A, E>>,
readonly latestFinalResult: Lens.Lens<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>,
) {
super()
}
mutate(key: K): Effect.Effect<MutationFinalState<K, A, E>> {
return this.start(key).pipe(
Effect.flatMap(operation => operation.await),
mutate(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
return Lens.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(this.start(key)),
Effect.flatMap(state => this.watch(state)),
Effect.provide(this.context),
)
}
mutateStream(key: K): Effect.Effect<Stream.Stream<MutationState<K, A, E>>> {
return this.start(key).pipe(
Effect.map(operation => operation.stream),
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>> {
return Lens.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(this.start(key)),
Effect.tap(state => Effect.forkScoped(this.watch(state))),
Effect.provide(this.context),
)
}
/** Starts a new Operation, which supersedes the current one without interrupting it. */
start(key: K): Effect.Effect<
Operation.Operation<Option.Some<K>, A, E, R>,
View.View<AsyncResult.AsyncResult<A, E>>,
never,
Mutation.Context<R>
Scope.Scope | R
> {
return Effect.gen({ self: this }, function*() {
const latestFinalState = yield* Lens.get(this.latestFinalState)
const operation = yield* Operation.make({
key: Option.some(key) as Option.Some<K>,
effect: this.f(key),
previous: Option.isSome(latestFinalState)
? latestFinalState.value.result
: AsyncResult.initial<A, E>(),
listener: this.listener,
networkMode: this.networkMode,
})
const previous = yield* Lens.get(this.latestFinalResult)
const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make<AsyncResult.AsyncResult<A, E>>(
Option.getOrElse(previous, () => AsyncResult.initial(false))
))
yield* Lens.set(this.latestKey, Option.some(key))
yield* Lens.set(this.operation, Option.some(operation))
yield* operation.start
return operation
const fiber = yield* Effect.forkScoped(Effect.andThen(
Lens.update(state, AsyncResult.match({
onInitial: () => AsyncResult.initial(true),
onSuccess: v => AsyncResult.success(v.value, {
waiting: true,
}),
onFailure: v => AsyncResult.failure(v.cause, {
waiting: true,
previousSuccess: v.previousSuccess,
})
}
})),
/** Runs `effect` only if `operation` is the current Operation. */
ifCurrent(
operation: Operation.Operation<Option.Some<K>, A, E, R>,
effect: Effect.Effect<void>,
): Effect.Effect<void> {
return Effect.flatMap(
Lens.get(this.operation),
current => Option.isSome(current) && current.value === operation
? effect
Effect.onExit(this.f(key), exit => Lens.update(
state,
previous => Exit.match(exit, {
onSuccess: v => AsyncResult.success(v),
onFailure: c => AsyncResult.match(previous, {
onInitial: () => AsyncResult.failure(c),
onSuccess: v => AsyncResult.failure(c, {
previousSuccess: Option.some(v),
}),
onFailure: v => AsyncResult.failure(c, {
previousSuccess: v.previousSuccess,
})
}),
}),
).pipe(
Effect.andThen(Effect.all([
Effect.fiberId,
Lens.get(this.fiber),
])),
Effect.flatMap(([fiberId, fiber]) => Option.match(fiber, {
onSome: v => Equal.equals(fiberId, v.id)
? Lens.set(this.fiber, Option.none())
: Effect.void,
)
onNone: () => Effect.void,
})),
)),
))
yield* Lens.set(this.fiber, Option.some(fiber))
return state
})
}
/** Records the state of the current Operation. Superseded Operations are ignored. */
get listener(): Operation.OperationListener<Option.Some<K>, A, E, R> {
return {
onChange: (operation, state) => this.ifCurrent(operation, Lens.set(this.state, state)),
onSettle: (operation, state) => this.ifCurrent(operation, Lens.set(this.latestFinalState, Option.some(state))),
onInterrupt: () => Effect.void,
}
watch(
state: View.View<AsyncResult.AsyncResult<A, E>>
): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
return View.get(state).pipe(
Effect.andThen(initial => Stream.runFoldEffect(
View.changes(state),
() => initial,
(_, result) => Effect.as(Lens.set(this.state, result), result),
) as Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>),
Effect.tap(result => Lens.set(this.latestFinalResult, Option.some(result))),
)
}
}
@@ -134,7 +131,6 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
export declare namespace make {
export interface Options<K = never, A = void, E = never, R = never> {
readonly f: (key: K) => Effect.Effect<A, E, R>
readonly networkMode?: Operation.NetworkMode
}
}
@@ -143,25 +139,15 @@ export const make = Effect.fnUntraced(function* <K = never, A = void, E = never,
): Effect.fn.Return<
Mutation<K, A, E, R>,
never,
Scope.Scope | QueryClient.QueryClient | R
Scope.Scope | R
> {
const client = yield* QueryClient.QueryClient
return new MutationImpl(
Context.mergeAll(
client.context,
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | R>(),
),
yield* Effect.context<Scope.Scope | R>(),
options.f,
options.networkMode ?? client.defaultMutationNetworkMode,
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<K>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Operation.Operation<Option.Some<K>, A, E, R>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<MutationLatestState<K, A, E>>({
key: Option.none(),
result: AsyncResult.initial(),
paused: false,
})),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<MutationFinalState<K, 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(Option.none<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>())),
)
})
+23 -43
View File
@@ -1,10 +1,9 @@
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { Array, Cause, type Context, Effect, Fiber, Option, Pipeable, Predicate, Schema, SchemaIssue, type Scope, Semaphore, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/reactivity"
import { Array, Cause, type Context, Effect, Fiber, Option, Pipeable, Predicate, Schema, SchemaError, SchemaIssue, type Scope, Semaphore, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import * as Form from "./Form.js"
import * as Lens from "./Lens.js"
import * as Mutation from "./Mutation.js"
import type * as QueryClient from "./QueryClient.js"
import * as View from "./View.js"
@@ -24,14 +23,7 @@ extends Form.Form<readonly [], A, I, never, never> {
readonly validationFiber: View.View<Option.Option<Fiber.Fiber<A, Schema.SchemaError>>, never, never>
readonly run: Effect.Effect<void>
readonly submit: Effect.Effect<
Option.Option<Mutation.MutationFinalState<
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
MA, ME
>>,
Cause.NoSuchElementError,
never
>
readonly submit: Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, Cause.NoSuchElementError>
}
export class MutationFormImpl<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, in out MR = never>
@@ -87,20 +79,17 @@ extends Pipeable.Class implements MutationForm<A, I, RD, RE, MA, ME, MR> {
this.canCommit = Effect.succeed(this).pipe(
Effect.map(self => View.map(
View.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.state),
([value, issues, validationFiber, state]) => (
([value, issues, validationFiber, result]) => (
Option.isSome(value) &&
Array.isReadonlyArrayEmpty(issues) &&
Option.isNone(validationFiber) &&
!AsyncResult.isWaiting(state.result)
!AsyncResult.isWaiting(result)
),
)),
View.unwrap,
)
this.isCommitting = Effect.succeed(this).pipe(
Effect.map(self => View.map(
self.mutation.state,
state => AsyncResult.isWaiting(state.result),
)),
Effect.map(self => View.map(self.mutation.state, AsyncResult.isWaiting)),
View.unwrap,
)
}
@@ -123,7 +112,7 @@ extends Pipeable.Class implements MutationForm<A, I, RD, RE, MA, ME, MR> {
Effect.tap(() => Lens.set(this.issues, Array.empty())),
Effect.flatMap(value => Lens.set(this.value, Option.some(value))),
Effect.catchIf(
Schema.isSchemaError,
SchemaError.isSchemaError,
error => Lens.set(this.issues, SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues),
),
@@ -141,36 +130,22 @@ extends Pipeable.Class implements MutationForm<A, I, RD, RE, MA, ME, MR> {
)
}
get submit(): Effect.Effect<
Option.Option<Mutation.MutationFinalState<
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
MA, ME
>>,
Cause.NoSuchElementError,
never
> {
get submit(): Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, Cause.NoSuchElementError, never> {
return Lens.get(this.value).pipe(
Effect.flatMap(Effect.fromOption),
Effect.flatMap(value => this.submitValue(value)),
)
}
submitValue(value: A): Effect.Effect<
Option.Option<Mutation.MutationFinalState<
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
MA, ME
>>,
never,
never
> {
submitValue(value: A): Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, never, never> {
return Effect.when(
Effect.tap(
this.mutation.mutate([value, this as any]),
state => AsyncResult.isFailure(state.result)
result => AsyncResult.isFailure(result)
? Option.match(
Array.findFirst(
state.result.cause.reasons,
reason => Cause.isFailReason(reason) && Schema.isSchemaError(reason.error)
result.cause.reasons,
reason => Cause.isFailReason(reason) && SchemaError.isSchemaError(reason.error)
? Option.some(reason.error)
: Option.none(),
),
@@ -205,7 +180,7 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
): Effect.fn.Return<
MutationForm<A, I, RD, RE, MA, ME, MR>,
never,
Scope.Scope | QueryClient.QueryClient | RD | RE | MR
Scope.Scope | RD | RE | MR
> {
return new MutationFormImpl(
options.schema,
@@ -221,13 +196,18 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
)
})
export const thenRun = <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never, E = never, R = never>(
self: Effect.Effect<MutationForm<A, I, RD, RE, MA, ME, MR>, E, R>,
export declare namespace service {
export interface Options<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, out MR = never>
extends make.Options<A, I, RD, RE, MA, ME, MR> {}
}
export const service = <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never>(
options: service.Options<A, I, RD, RE, MA, ME, MR>
): Effect.Effect<
MutationForm<A, I, RD, RE, MA, ME, MR>,
E,
Scope.Scope | R
never,
Scope.Scope | RD | RE | MR
> => Effect.tap(
self,
make(options),
form => Effect.forkScoped(form.run),
)
-331
View File
@@ -1,331 +0,0 @@
import { Cause, type Context, Deferred, Effect, Exit, Fiber, Function, Option, Pipeable, Predicate, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/reactivity"
import * as AppConnectivity from "./AppConnectivity.js"
import * as Lens from "./Lens.js"
import type * as Query from "./Query.js"
import * as View from "./View.js"
export const OperationTypeId: unique symbol = Symbol.for("@effect-view/Operation/Operation")
export type OperationTypeId = typeof OperationTypeId
/**
* A single run of a Query's or Mutation's effect for one key: owns its fiber and its state, from the previous result
* through waiting to the final result.
*
* An Operation that is interrupted has no final state: its state stops waiting, and awaiting it is interrupted.
*/
export interface Operation<out K, out A, out E = never, in out R = never>
extends Pipeable.Pipeable {
readonly [OperationTypeId]: OperationTypeId
readonly context: Context.Context<Operation.Context<R>>
readonly key: K
readonly effect: Effect.Effect<A, E, R>
readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
readonly state: View.View<OperationState<K, A, E>>
/**
* The successive states of the Operation, starting from the current one and ending once the Operation has settled or
* aborted.
*/
readonly stream: Stream.Stream<OperationState<K, A, E>>
/**
* Forks `effect` in the current scope, moving the state to waiting, then to the result of `effect`. Depending on the
* network mode, the Operation may be paused while `online` is false (see `NetworkMode`).
* Returns the running fiber, or `None` if it has already completed.
*/
readonly start: Effect.Effect<Option.Option<Fiber.Fiber<A, E>>>
readonly interrupt: Effect.Effect<void>
/** Waits for the Operation to settle, and returns its final state. Interrupted if the Operation is interrupted. */
readonly await: Effect.Effect<OperationFinalState<K, A, E>>
}
export namespace Operation {
export type Context<R> = Query.Query.Context<R>
}
/** How an Operation reports to its owner (a Query or a Mutation). */
export interface OperationListener<in out K, in out A, in out E = never, in out R = never> {
onChange(
operation: Operation<K, A, E, R>,
state: OperationState<K, A, E>,
): Effect.Effect<void>
/** Called once, when the Operation settles with a final state. */
onSettle(
operation: Operation<K, A, E, R>,
state: OperationFinalState<K, A, E>,
): Effect.Effect<void>
/** Called once, when the Operation is interrupted before reaching a final state. */
onInterrupt(
operation: Operation<K, A, E, R>,
state: OperationState<K, A, E>,
): Effect.Effect<void>
}
export interface OperationState<out K, out A, out E = never> {
readonly key: K
readonly result: AsyncResult.AsyncResult<A, E>
/** Whether the Operation is waiting for a network connection before running its effect. */
readonly paused: boolean
}
export interface OperationFinalState<out K, out A, out E = never> {
readonly key: K
readonly result: AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>
readonly paused: false
}
/**
* - `"online"`: an Operation waits for a network connection (see `AppConnectivity`) before running, and is `paused`
* meanwhile.
* - `"offlineFirst"`: an Operation runs once regardless of the network connection, e.g. for effects that may be served
* from a cache. If it fails while offline, it waits for a network connection, `paused` meanwhile, and runs again.
* - `"always"`: Operations ignore the network connection, e.g. for effects that do not use the network.
*/
export type NetworkMode = "online" | "offlineFirst" | "always"
export const isOperation = (u: unknown): u is Operation<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, OperationTypeId)
export class OperationImpl<in out K, in out A, in out E = never, in out R = never>
extends Pipeable.Class implements Operation<K, A, E, R> {
readonly [OperationTypeId]: OperationTypeId = OperationTypeId
constructor(
readonly context: Context.Context<Operation.Context<R>>,
readonly key: K,
readonly effect: Effect.Effect<A, E, R>,
readonly listener: OperationListener<K, A, E, R>,
readonly networkMode: NetworkMode,
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
readonly state: Lens.Lens<OperationState<K, A, E>>,
readonly result: Deferred.Deferred<OperationFinalState<K, A, E>>,
) {
super()
}
get start(): Effect.Effect<Option.Option<Fiber.Fiber<A, E>>> {
return Effect.gen({ self: this }, function*() {
// Started immediately so that `onExit` is always registered, even if the fiber is interrupted right away:
// otherwise the Operation would never settle or be reported as interrupted
const fiber = yield* Effect.forkScoped(
Effect.onExit(
Effect.andThen(
this.update(state => withWaiting(state, true)),
this.runWithNetworkMode,
),
exit => Effect.flatMap(
Lens.get(this.state),
state => Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
? this.abort(withWaiting(state, false))
: this.settle(done(state, exit)),
),
),
{ startImmediately: true },
)
// The fiber may already have completed and settled the Operation
if (!fiber.pollUnsafe())
yield* Lens.set(this.fiber, Option.some(fiber))
return yield* Lens.get(this.fiber)
}).pipe(
Effect.provide(this.context)
)
}
// biome-ignore lint/suspicious/useGetterReturn: the switch is exhaustive, which TypeScript checks
get runWithNetworkMode(): Effect.Effect<A, E, AppConnectivity.AppConnectivity | R> {
switch (this.networkMode) {
case "online":
return Effect.andThen(this.awaitOnline, this.effect)
case "offlineFirst":
return this.runRetryingOnReconnect
case "always":
return this.effect
}
}
/** Runs `effect`, and if it fails while offline, waits for `online`, paused meanwhile, and runs it again. */
get runRetryingOnReconnect(): Effect.Effect<A, E, AppConnectivity.AppConnectivity | R> {
return Effect.catchCause(
this.effect,
cause => Effect.gen({ self: this }, function*() {
const { online } = yield* AppConnectivity.AppConnectivity
if (Cause.hasInterruptsOnly(cause) || (yield* View.get(online)))
return yield* Effect.failCause(cause)
yield* this.awaitOnline
return yield* this.runRetryingOnReconnect
}),
)
}
/** Waits for `online`, paused meanwhile. */
get awaitOnline(): Effect.Effect<void, never, AppConnectivity.AppConnectivity> {
return Effect.gen({ self: this }, function*() {
const { online } = yield* AppConnectivity.AppConnectivity
if (yield* View.get(online))
return
yield* this.update(state => ({ ...state, paused: true }))
yield* Stream.runHead(Stream.filter(View.changes(online), Function.identity))
yield* this.update(state => ({ ...state, paused: false }))
})
}
get stream(): Stream.Stream<OperationState<K, A, E>> {
// The state is waiting from the moment the Operation starts, and stops waiting once it has settled or aborted
return Stream.takeUntil(View.changes(this.state), state => !state.result.waiting)
}
get interrupt(): Effect.Effect<void> {
return Effect.flatMap(Lens.get(this.fiber), Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
}))
}
get await(): Effect.Effect<OperationFinalState<K, A, E>> {
return Deferred.await(this.result)
}
update(
f: (state: OperationState<K, A, E>) => OperationState<K, A, E>,
): Effect.Effect<OperationState<K, A, E>> {
return Effect.tap(
Lens.updateAndGet(this.state, f),
state => this.listener.onChange(this, state),
)
}
settle(state: OperationFinalState<K, A, E>): Effect.Effect<void> {
return Effect.gen({ self: this }, function*() {
yield* Lens.set(this.fiber, Option.none())
yield* this.update(() => state)
yield* this.listener.onSettle(this, state)
yield* Deferred.succeed(this.result, state)
})
}
abort(state: OperationState<K, A, E>): Effect.Effect<void> {
return Effect.gen({ self: this }, function*() {
yield* Lens.set(this.fiber, Option.none())
yield* this.update(() => state)
yield* this.listener.onInterrupt(this, state)
yield* Deferred.interrupt(this.result)
})
}
}
export declare namespace make {
export interface Options<K, A, E = never, R = never> {
readonly key: K
readonly effect: Effect.Effect<A, E, R>
readonly previous: AsyncResult.AsyncResult<A, E>
readonly listener: OperationListener<K, A, E, R>
readonly networkMode: NetworkMode
}
}
export const make = Effect.fnUntraced(function* <K, A, E = never, R = never>(
options: make.Options<K, A, E, R>
): Effect.fn.Return<Operation<K, A, E, R>, never, Operation.Context<R>> {
return new OperationImpl(
yield* Effect.context<Operation.Context<R>>(),
options.key,
options.effect,
options.listener,
options.networkMode,
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<OperationState<K, A, E>>({
key: options.key,
result: options.previous,
paused: false,
})),
yield* Deferred.make<OperationFinalState<K, A, E>>(),
)
})
export declare namespace makeSettled {
export interface Options<K, A, E = never, R = never> {
readonly key: K
readonly effect: Effect.Effect<A, E, R>
readonly state: OperationFinalState<K, A, E>
}
}
/**
* Makes an Operation that has already settled with `state`, without running its effect, e.g. to serve a result from a
* cache. Being already settled, it never notifies a listener.
*/
export const makeSettled = Effect.fnUntraced(function* <K, A, E = never, R = never>(
options: makeSettled.Options<K, A, E, R>
): Effect.fn.Return<Operation<K, A, E, R>, never, Operation.Context<R>> {
const result = yield* Deferred.make<OperationFinalState<K, A, E>>()
yield* Deferred.succeed(result, options.state)
return new OperationImpl(
yield* Effect.context<Operation.Context<R>>(),
options.key,
options.effect,
{
onChange: () => Effect.void,
onSettle: () => Effect.void,
onInterrupt: () => Effect.void,
},
"always",
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<OperationState<K, A, E>>(options.state)),
result,
)
})
const withWaiting = <K, A, E>(
state: OperationState<K, A, E>,
waiting: boolean,
): OperationState<K, A, E> => ({
key: state.key,
result: AsyncResult.match(state.result, {
onInitial: () => AsyncResult.initial(waiting),
onSuccess: result => AsyncResult.success(result.value, {
waiting,
}),
onFailure: result => AsyncResult.failure(result.cause, {
waiting,
previousSuccess: result.previousSuccess,
}),
}),
paused: false,
})
const done = <K, A, E>(
state: OperationState<K, A, E>,
exit: Exit.Exit<A, E>,
): OperationFinalState<K, A, E> => Exit.match(exit, {
onSuccess: v => ({
key: state.key,
result: AsyncResult.success(v),
paused: false,
}),
onFailure: c => ({
key: state.key,
result: AsyncResult.match(state.result, {
onInitial: () => AsyncResult.failure(c),
onSuccess: v => AsyncResult.failure(c, {
previousSuccess: Option.some(v),
}),
onFailure: v => AsyncResult.failure(c, {
previousSuccess: v.previousSuccess,
}),
}),
paused: false,
}),
})
+1 -1
View File
@@ -11,7 +11,7 @@ export const useFromReactiveValues = Effect.fnUntraced(function* <const A extend
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
yield* Component.useReactEffect(() => Effect.flatMap(
PubSub.isShutdown(pubsub),
shutdown => shutdown ? Effect.void : Effect.asVoid(PubSub.publish(pubsub, values)),
shutdown => shutdown ? Effect.succeed(undefined) : Effect.asVoid(PubSub.publish(pubsub, values)),
), values)
return pubsub
})
@@ -1,440 +0,0 @@
import { Deferred, Effect, Fiber, Layer, Option, Schedule, type Scope, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/reactivity"
import { describe, expect, it } from "vitest"
import * as AppConnectivity from "./AppConnectivity.js"
import * as AppFocus from "./AppFocus.js"
import * as Lens from "./Lens.js"
import * as Query from "./Query.js"
import * as QueryClient from "./QueryClient.js"
import * as View from "./View.js"
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
Effect.runPromise(Effect.scoped(effect.pipe(
Effect.provide(QueryClient.layer.pipe(
Layer.provide(Layer.mergeAll(AppConnectivity.layerDummy, AppFocus.layerDummy)),
)),
)))
/** Runs `f` with an AppConnectivity whose online status is controlled through the returned ref. */
const withAppConnectivity = <A, E, R>(
initial: boolean,
f: (online: SubscriptionRef.SubscriptionRef<boolean>) => Effect.Effect<A, E, R>,
) => Effect.flatMap(
SubscriptionRef.make(initial),
online => Effect.provide(f(online), Layer.succeed(AppConnectivity.AppConnectivity, AppConnectivity.make(Lens.fromSubscriptionRef(online)))),
)
/** Runs `f` with an AppFocus whose focus is controlled through the returned ref. */
const withAppFocus = <A, E, R>(
initial: boolean,
f: (focused: SubscriptionRef.SubscriptionRef<boolean>) => Effect.Effect<A, E, R>,
) => Effect.flatMap(
SubscriptionRef.make(initial),
focused => Effect.provide(f(focused), Layer.succeed(AppFocus.AppFocus, AppFocus.make(Lens.fromSubscriptionRef(focused)))),
)
const staticKey = <K>(key: K): View.View<K> => View.make({
get: Effect.succeed(key),
changes: Stream.make(key),
})
describe("Query network mode", () => {
it("in online mode, a request made offline is paused until the connection returns", async () => {
const result = await runQueryTest(withAppConnectivity(false, online => Effect.gen(function*() {
let calls = 0
const query = yield* Query.make({
key: staticKey(1),
f: (id: number) => Effect.sync(() => {
calls += 1
return `value:${id}`
}),
})
const stream = yield* query.fetchStream(1)
const states = yield* Effect.forkScoped(Stream.runCollect(stream))
yield* Effect.yieldNow
const whileOffline = { calls, state: yield* View.get(query.state) }
yield* SubscriptionRef.set(online, true)
return {
whileOffline,
states: yield* Fiber.join(states).pipe(Effect.timeout("1 second")),
calls,
}
})))
expect(result.whileOffline.calls).toBe(0)
expect(result.whileOffline.state.paused).toBe(true)
expect(result.whileOffline.state.result.waiting).toBe(true)
expect(result.calls).toBe(1)
// The stream starts from the current state, which is already paused
expect(result.states.map(s => [s.result._tag, s.result.waiting, s.paused])).toEqual([
["Initial", true, true],
["Initial", true, false],
["Success", false, false],
])
})
it("in always mode, requests ignore the connection", async () => {
const result = await runQueryTest(withAppConnectivity(false, () => Effect.gen(function*() {
const query = yield* Query.make({
key: staticKey(1),
f: (id: number) => Effect.succeed(`value:${id}`),
networkMode: "always",
})
return yield* query.fetch(1).pipe(Effect.timeout("1 second"))
})))
expect(AsyncResult.isSuccess(result.result)).toBe(true)
expect(result.paused).toBe(false)
})
it("interrupting a paused request leaves it neither paused nor waiting", async () => {
const result = await runQueryTest(withAppConnectivity(false, () => Effect.gen(function*() {
const query = yield* Query.make({
key: staticKey(1),
f: (id: number) => Effect.succeed(`value:${id}`),
})
const stream = yield* query.fetchStream(1)
yield* Effect.yieldNow
const operation = yield* View.get(query.operation)
if (Option.isSome(operation))
yield* operation.value.interrupt
return {
states: yield* Stream.runCollect(stream).pipe(Effect.timeout("1 second")),
state: yield* View.get(query.state),
}
})))
expect(result.state.paused).toBe(false)
expect(result.state.result.waiting).toBe(false)
expect(result.states.at(-1)?.paused).toBe(false)
})
it("refreshes a stale query when the connection returns", async () => {
const result = await runQueryTest(withAppConnectivity(true, online => Effect.gen(function*() {
let calls = 0
const query = yield* Query.make({
key: staticKey(1),
f: (id: number) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "0 millis",
}).pipe(Query.thenRun)
yield* Effect.sleep("10 millis")
const afterStart = calls
yield* SubscriptionRef.set(online, false)
yield* Effect.sleep("10 millis")
const whileOffline = calls
yield* SubscriptionRef.set(online, true)
yield* Effect.sleep("10 millis")
return { afterStart, whileOffline, afterReconnect: calls, state: yield* View.get(query.state) }
})))
expect(result).toMatchObject({ afterStart: 1, whileOffline: 1, afterReconnect: 2 })
expect(AsyncResult.isSuccess(result.state.result) && result.state.result.value).toBe("value:1:2")
})
it("reconnecting resumes a paused request instead of restarting it", async () => {
const result = await runQueryTest(withAppConnectivity(false, online => Effect.gen(function*() {
let started = 0
let completed = 0
const query = yield* Query.make({
key: staticKey(1),
f: (id: number) => Effect.gen(function*() {
started += 1
yield* Effect.sleep("5 millis")
completed += 1
return `value:${id}`
}),
}).pipe(Query.thenRun)
yield* Effect.sleep("10 millis")
const pausedWhileOffline = (yield* View.get(query.state)).paused
yield* SubscriptionRef.set(online, true)
yield* Effect.sleep("30 millis")
return { pausedWhileOffline, started, completed, state: yield* View.get(query.state) }
})))
expect(result.pausedWhileOffline).toBe(true)
expect(result.started).toBe(1)
expect(result.completed).toBe(1)
expect(AsyncResult.isSuccess(result.state.result)).toBe(true)
})
it("a focus or reconnect refresh does not restart a request that is already in flight", async () => {
const result = await runQueryTest(withAppConnectivity(true, online => withAppFocus(true, focused => Effect.gen(function*() {
let started = 0
const query = yield* Query.make({
key: staticKey(1),
f: () => Effect.gen(function*() {
started += 1
yield* Effect.sleep("40 millis")
return started
}),
staleTime: "0 millis",
}).pipe(Query.thenRun)
yield* Effect.sleep("10 millis")
yield* SubscriptionRef.set(focused, false)
yield* SubscriptionRef.set(focused, true)
yield* SubscriptionRef.set(online, false)
yield* SubscriptionRef.set(online, true)
yield* Effect.sleep("100 millis")
return { started, state: yield* View.get(query.state) }
}))))
expect(result.started).toBe(1)
expect(AsyncResult.isSuccess(result.state.result)).toBe(true)
})
it("does not refresh on reconnect when refreshOnReconnect is false", async () => {
const calls = await runQueryTest(withAppConnectivity(true, online => Effect.gen(function*() {
let calls = 0
yield* Query.make({
key: staticKey(1),
f: () => Effect.sync(() => { calls += 1 }),
staleTime: "0 millis",
refreshOnReconnect: false,
}).pipe(Query.thenRun)
yield* Effect.sleep("10 millis")
yield* SubscriptionRef.set(online, false)
yield* SubscriptionRef.set(online, true)
yield* Effect.sleep("10 millis")
return calls
})))
expect(calls).toBe(1)
})
it("refreshes a stale query when the app becomes focused, but not at startup", async () => {
const result = await runQueryTest(withAppFocus(true, focused => Effect.gen(function*() {
let calls = 0
const gate = yield* Deferred.make<void>()
yield* Query.make({
key: staticKey(1),
f: () => Effect.andThen(Effect.sync(() => { calls += 1 }), Deferred.succeed(gate, undefined)),
staleTime: "0 millis",
}).pipe(Query.thenRun)
yield* Deferred.await(gate)
yield* Effect.sleep("10 millis")
const afterStart = calls
yield* SubscriptionRef.set(focused, false)
yield* SubscriptionRef.set(focused, true)
yield* Effect.sleep("10 millis")
return { afterStart, afterFocus: calls }
})))
expect(result).toEqual({ afterStart: 1, afterFocus: 2 })
})
it("in offlineFirst mode, a request made offline runs right away", async () => {
const result = await runQueryTest(withAppConnectivity(false, () => Effect.gen(function*() {
const query = yield* Query.make({
key: staticKey(1),
f: (id: number) => Effect.succeed(`value:${id}`),
networkMode: "offlineFirst",
})
return yield* query.fetch(1).pipe(Effect.timeout("1 second"))
})))
expect(AsyncResult.isSuccess(result.result)).toBe(true)
expect(result.paused).toBe(false)
})
it("in offlineFirst mode, a request that fails offline is paused, then runs again when the connection returns", async () => {
const result = await runQueryTest(withAppConnectivity(false, online => Effect.gen(function*() {
let calls = 0
const query = yield* Query.make({
key: staticKey(1),
f: (id: number) => Effect.suspend(() => {
calls += 1
return calls === 1 ? Effect.fail("offline") : Effect.succeed(`value:${id}`)
}),
networkMode: "offlineFirst",
})
const stream = yield* query.fetchStream(1)
const states = yield* Effect.forkScoped(Stream.runCollect(stream))
yield* Effect.yieldNow
const whileOffline = { calls, state: yield* View.get(query.state) }
yield* SubscriptionRef.set(online, true)
return {
whileOffline,
states: yield* Fiber.join(states).pipe(Effect.timeout("1 second")),
calls,
}
})))
expect(result.whileOffline.calls).toBe(1)
expect(result.whileOffline.state.paused).toBe(true)
expect(result.whileOffline.state.result.waiting).toBe(true)
expect(result.calls).toBe(2)
const last = result.states.at(-1)
expect(last && AsyncResult.isSuccess(last.result) && last.result.value).toBe("value:1")
expect(last?.paused).toBe(false)
})
it("in offlineFirst mode, a request that fails online is not run again", async () => {
const result = await runQueryTest(withAppConnectivity(true, () => Effect.gen(function*() {
let calls = 0
const query = yield* Query.make({
key: staticKey(1),
f: (_id: number) => Effect.suspend(() => {
calls += 1
return Effect.fail("error")
}),
networkMode: "offlineFirst",
})
return { final: yield* query.fetch(1).pipe(Effect.timeout("1 second")), calls }
})))
expect(AsyncResult.isFailure(result.final.result)).toBe(true)
expect(result.calls).toBe(1)
})
it("refreshes a fresh query on focus only when refreshOnFocus is \"always\"", async () => {
const result = await runQueryTest(withAppFocus(true, focused => Effect.gen(function*() {
const calls = { true: 0, always: 0 }
const makeQuery = (refreshOnFocus: true | "always") => Query.make({
key: staticKey(refreshOnFocus),
f: () => Effect.sync(() => { calls[refreshOnFocus === true ? "true" : "always"] += 1 }),
staleTime: "1 hour",
refreshOnFocus,
}).pipe(Query.thenRun)
yield* makeQuery(true)
yield* makeQuery("always")
yield* Effect.sleep("10 millis")
yield* SubscriptionRef.set(focused, false)
yield* SubscriptionRef.set(focused, true)
yield* Effect.sleep("10 millis")
return calls
})))
expect(result).toEqual({ true: 1, always: 2 })
})
it("refreshes a fresh query on reconnect when refreshOnReconnect is \"always\"", async () => {
const calls = await runQueryTest(withAppConnectivity(true, online => Effect.gen(function*() {
let calls = 0
yield* Query.make({
key: staticKey(1),
f: () => Effect.sync(() => { calls += 1 }),
staleTime: "1 hour",
refreshOnReconnect: "always",
}).pipe(Query.thenRun)
yield* Effect.sleep("10 millis")
yield* SubscriptionRef.set(online, false)
yield* SubscriptionRef.set(online, true)
yield* Effect.sleep("10 millis")
return calls
})))
expect(calls).toBe(2)
})
})
describe("browser layers", () => {
it("AppConnectivity.layerBrowser follows the online and offline events", async () => {
const values = await Effect.runPromise(Effect.gen(function*() {
const { online } = yield* AppConnectivity.AppConnectivity
const values = yield* Effect.forkScoped(Stream.runCollect(Stream.take(View.changes(online), 3)))
yield* Effect.sleep("10 millis")
window.dispatchEvent(new Event("offline"))
window.dispatchEvent(new Event("online"))
return yield* Fiber.join(values).pipe(Effect.timeout("1 second"))
}).pipe(
Effect.provide(AppConnectivity.layerBrowser),
Effect.scoped,
))
expect(values).toEqual([navigator.onLine, false, true])
})
it("AppFocus.layerBrowser follows the document's visibility", async () => {
let visibilityState: DocumentVisibilityState = "visible"
const setVisibility = (state: DocumentVisibilityState) => {
visibilityState = state
document.dispatchEvent(new Event("visibilitychange"))
}
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibilityState })
try {
const values = await Effect.runPromise(Effect.gen(function*() {
const { focused } = yield* AppFocus.AppFocus
const values = yield* Effect.forkScoped(Stream.runCollect(Stream.take(View.changes(focused), 3)))
yield* Effect.sleep("10 millis")
setVisibility("hidden")
setVisibility("visible")
return yield* Fiber.join(values).pipe(Effect.timeout("1 second"))
}).pipe(
Effect.provide(AppFocus.layerBrowser),
Effect.scoped,
))
expect(values).toEqual([true, false, true])
}
finally {
// Restores jsdom's own getter, from the prototype
delete (document as { visibilityState?: unknown }).visibilityState
}
})
})
describe("withScheduledRefresh", () => {
const countRefreshes = (focused: boolean, options?: Query.withScheduledRefresh.Options) =>
runQueryTest(withAppFocus(focused, () => Effect.gen(function*() {
let calls = 0
yield* Query.make({
key: staticKey(1),
f: () => Effect.sync(() => { calls += 1 }),
staleTime: "0 millis",
}).pipe(
Query.thenRun,
Query.withScheduledRefresh(Schedule.spaced("10 millis").pipe(Schedule.upTo({ times: 2 })), options),
)
yield* Effect.sleep("100 millis")
return calls
})))
it("refreshes while the app is focused", async () => {
expect(await countRefreshes(true)).toBe(3)
})
it("skips refreshes while the app is not focused", async () => {
expect(await countRefreshes(false)).toBe(1)
})
it("keeps refreshing while the app is not focused with refreshInBackground", async () => {
expect(await countRefreshes(false, { refreshInBackground: true })).toBe(3)
})
})
+6 -250
View File
@@ -1,9 +1,6 @@
import { Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, type Scope, Stream } from "effect"
import { AsyncResult } from "effect/reactivity"
import { TestClock } from "effect/testing"
import { Effect, type Scope, Stream } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import { describe, expect, it } from "vitest"
import * as AppConnectivity from "./AppConnectivity.js"
import * as AppFocus from "./AppFocus.js"
import * as Query from "./Query.js"
import * as QueryClient from "./QueryClient.js"
import * as View from "./View.js"
@@ -11,9 +8,7 @@ import * as View from "./View.js"
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
Effect.runPromise(Effect.scoped(effect.pipe(
Effect.provide(QueryClient.layer.pipe(
Layer.provide(Layer.mergeAll(AppConnectivity.layerDummy, AppFocus.layerDummy)),
)),
Effect.provide(QueryClient.layer()),
)))
const staticKey = <K>(key: K): View.View<K> => View.make({
@@ -22,7 +17,7 @@ const staticKey = <K>(key: K): View.View<K> => View.make({
})
const expectSuccessValue = <A, E>(
state: Query.QueryFinalState<unknown, A, E>,
state: Query.FinalQueryState<unknown, A, E>,
): A => {
expect(AsyncResult.isSuccess(state.result)).toBe(true)
@@ -84,49 +79,6 @@ describe("Query", () => {
expect(expectSuccessValue(result[1])).toBe("value:1:2")
})
it("withScheduledRefresh lets the Schedule control the first refresh", async () => {
let calls = 0
const key = staticKey<readonly [number]>([1])
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: () => Effect.sync(() => {
calls += 1
return calls
}),
staleTime: "0 millis",
}).pipe(
Query.thenRun,
Query.withScheduledRefresh(
Schedule.spaced("1 second").pipe(
Schedule.upTo({ times: 1 }),
),
),
)
yield* TestClock.adjust("999 millis")
const beforeInterval = calls
yield* TestClock.adjust("1 millis")
const afterFirstInterval = calls
return {
isQuery: Query.isQuery(query),
beforeInterval,
afterFirstInterval,
}
}).pipe(
Effect.provide(TestClock.layer()),
))
expect(result).toEqual({
isQuery: true,
beforeInterval: 1,
afterFirstInterval: 2,
})
})
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
let calls = 0
const key = staticKey<readonly [number]>([1])
@@ -184,14 +136,14 @@ describe("Query", () => {
const key = staticKey<readonly [number]>([1])
const effect = Effect.gen(function*() {
const query = yield* Query.make({
const query = yield* Query.service({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
}).pipe(Query.thenRun)
})
const latestFinalState = yield* Effect.sleep("1 millis").pipe(
Effect.andThen(View.get(query.latestFinalState)),
@@ -212,200 +164,4 @@ describe("Query", () => {
expect(result.state.key).toEqual([1])
expect(expectSuccessValue(result.latestFinalState)).toBe("value:1:1")
})
it("a superseded fetch does not overwrite the newer state", async () => {
const result = await runQueryTest(Effect.gen(function*() {
const never = yield* Deferred.make<string>()
const query = yield* Query.make({
key: staticKey(1),
f: (id: number) => id === 1 ? Deferred.await(never) : Effect.succeed(`value:${id}`),
})
yield* Effect.asVoid(query.fetchStream(1))
yield* Effect.yieldNow
const second = yield* query.fetch(2)
yield* Effect.sleep("10 millis")
return {
second,
state: yield* View.get(query.state),
latestFinalState: yield* View.get(query.latestFinalState),
}
}))
expect(expectSuccessValue(result.second)).toBe("value:2")
expect(result.state.key).toBe(2)
expect(result.state.result.waiting).toBe(false)
expect(Option.map(result.latestFinalState, s => s.key)).toEqual(Option.some(2))
})
it("an interrupted fetch is not recorded as a final state", async () => {
const result = await runQueryTest(Effect.gen(function*() {
const never = yield* Deferred.make<string>()
const query = yield* Query.make({
key: staticKey(1),
f: (_id: number) => Deferred.await(never),
})
yield* Effect.asVoid(query.fetchStream(1))
yield* Effect.yieldNow
yield* Effect.asVoid(query.fetchStream(2))
yield* Effect.sleep("10 millis")
return {
state: yield* View.get(query.state),
latestFinalState: yield* View.get(query.latestFinalState),
}
}))
expect(result.state.key).toBe(2)
expect(result.latestFinalState).toEqual(Option.none())
})
it("a superseded fetch is interrupted, and its state stops waiting", async () => {
const result = await runQueryTest(Effect.gen(function*() {
const never = yield* Deferred.make<string>()
const query = yield* Query.make({
key: staticKey(1),
f: (id: number) => id === 1 ? Deferred.await(never) : Effect.succeed(`value:${id}`),
})
const firstFetch = yield* Effect.forkScoped(query.fetch(1))
yield* Effect.yieldNow
const first = yield* query.fetchStream(1)
yield* query.fetch(2)
return {
firstFetch: yield* Fiber.await(firstFetch),
firstStates: yield* Stream.runCollect(first).pipe(Effect.timeout("1 second")),
}
}))
expect(Exit.hasInterrupts(result.firstFetch)).toBe(true)
expect(result.firstStates.at(-1)?.result.waiting).toBe(false)
})
it("fetchStream ends once the fetch settles, and gives the final state to late subscribers", async () => {
const result = await runQueryTest(Effect.gen(function*() {
const gate = yield* Deferred.make<void>()
const query = yield* Query.make({
key: staticKey(1),
f: (id: number) => Effect.as(Deferred.await(gate), `value:${id}`),
})
const stream = yield* query.fetchStream(1)
const early = yield* Effect.forkScoped(Stream.runCollect(stream))
yield* Effect.yieldNow
yield* Deferred.succeed(gate, undefined)
const earlyStates = yield* Fiber.join(early).pipe(Effect.timeout("1 second"))
const lateStates = yield* Stream.runCollect(stream).pipe(Effect.timeout("1 second"))
return { earlyStates, lateStates }
}))
expect(result.earlyStates.map(s => [s.result._tag, s.result.waiting])).toEqual([
["Initial", true],
["Success", false],
])
expect(result.lateStates.map(s => s.result._tag)).toEqual(["Success"])
})
it("fetchStream ends once the fetch is aborted", async () => {
const result = await runQueryTest(Effect.gen(function*() {
const never = yield* Deferred.make<string>()
const query = yield* Query.make({
key: staticKey(1),
f: (id: number) => id === 1 ? Deferred.await(never) : Effect.succeed(`value:${id}`),
})
const stream = yield* query.fetchStream(1)
const states = yield* Effect.forkScoped(Stream.runCollect(stream))
yield* Effect.yieldNow
yield* query.fetch(2)
return yield* Fiber.join(states).pipe(Effect.timeout("1 second"))
}))
expect(result.at(-1)?.result.waiting).toBe(false)
})
it("exposes the current operation, replaced by each new request", async () => {
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key: staticKey(1),
f: (id: number) => Effect.succeed(`value:${id}`),
})
const before = yield* View.get(query.operation)
yield* query.fetch(1)
const first = yield* View.get(query.operation)
yield* query.fetch(2)
const second = yield* View.get(query.operation)
return {
before: Option.isNone(before),
firstKey: Option.map(first, operation => operation.key),
secondKey: Option.map(second, operation => operation.key),
}
}))
expect(result).toEqual({
before: true,
firstKey: Option.some(1),
secondKey: Option.some(2),
})
})
it("concurrent fetches superseding a running fetch leave a single running fiber", async () => {
const result = await runQueryTest(Effect.gen(function*() {
const never = yield* Deferred.make<string>()
let running = 0
const query = yield* Query.make({
key: staticKey(1),
f: (_id: number) => Effect.suspend(() => {
running += 1
return Deferred.await(never)
}).pipe(
// Asynchronous cleanup (e.g. aborting a request), so that interrupting it suspends
Effect.ensuring(Effect.andThen(Effect.sleep("1 millis"), Effect.sync(() => { running -= 1 }))),
),
})
yield* Effect.asVoid(query.fetchStream(0))
yield* Effect.yieldNow
yield* Effect.all([query.fetchStream(1), query.fetchStream(2)], { concurrency: "unbounded", discard: true })
yield* Effect.sleep("10 millis")
return running
}))
expect(result).toBe(1)
})
it("concurrent fetches never leave an orphaned running fiber", async () => {
const result = await runQueryTest(Effect.gen(function*() {
const never = yield* Deferred.make<string>()
let running = 0
const query = yield* Query.make({
key: staticKey(1),
f: (_id: number) => Effect.suspend(() => {
running += 1
return Deferred.await(never)
}).pipe(
Effect.ensuring(Effect.sync(() => { running -= 1 })),
),
})
yield* Effect.all([query.fetchStream(1), query.fetchStream(2)], { concurrency: "unbounded" })
yield* Effect.sleep("10 millis")
const runningBeforeInterrupt = running
const operation = yield* View.get(query.operation)
if (Option.isSome(operation))
yield* operation.value.interrupt
yield* Effect.sleep("10 millis")
return { runningBeforeInterrupt, runningAfterInterrupt: running }
}))
expect(result).toEqual({ runningBeforeInterrupt: 1, runningAfterInterrupt: 0 })
})
})
+278 -273
View File
@@ -1,9 +1,6 @@
import { type Cause, Context, Duration, Effect, Equal, type Equivalence, Function, Option, Pipeable, Predicate, type Schedule, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/reactivity"
import * as AppConnectivity from "./AppConnectivity.js"
import * as AppFocus from "./AppFocus.js"
import { Cause, type Context, Duration, Effect, Equal, type Equivalence, Exit, Fiber, Option, Pipeable, Predicate, PubSub, Ref, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js"
import * as Operation from "./Operation.js"
import * as QueryClient from "./QueryClient.js"
import * as View from "./View.js"
@@ -15,55 +12,38 @@ export interface Query<in out K, out A, out E = never, in out R = never>
extends Pipeable.Pipeable {
readonly [QueryTypeId]: QueryTypeId
readonly context: Context.Context<Query.Context<R>>
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>
readonly key: View.View<K>
readonly keyEquivalence: Equivalence.Equivalence<K>
readonly f: (key: K) => Effect.Effect<A, E, R>
readonly staleTime: Duration.Duration
readonly networkMode: Operation.NetworkMode
readonly refreshOnFocus: RefreshOn
readonly refreshOnReconnect: RefreshOn
readonly refreshOnWindowFocus: boolean
readonly operation: View.View<Option.Option<Operation.Operation<K, A, E, QueryClient.QueryClient | R>>>
readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
readonly state: View.View<QueryState<K, A, E>>
readonly latestFinalState: View.View<Option.Option<QueryFinalState<K, A, E>>>
readonly latestFinalState: View.View<Option.Option<FinalQueryState<K, A, E>>>
readonly run: Effect.Effect<void>
fetch(key: K): Effect.Effect<QueryFinalState<K, A, E>, Cause.NoSuchElementError>
fetchStream(key: K): Effect.Effect<Stream.Stream<QueryState<K, A, E>>, Cause.NoSuchElementError>
readonly refresh: Effect.Effect<QueryFinalState<K, A, E>, Cause.NoSuchElementError>
readonly refreshStream: Effect.Effect<Stream.Stream<QueryState<K, A, E>>, Cause.NoSuchElementError>
fetch(key: K): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError>
fetchView(key: K): Effect.Effect<View.View<QueryState<K, A, E>>, Cause.NoSuchElementError>
readonly refresh: Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError>
readonly refreshView: Effect.Effect<View.View<QueryState<K, A, E>>, Cause.NoSuchElementError>
readonly invalidateCache: Effect.Effect<void>
invalidateCacheEntry(key: K): Effect.Effect<void>
}
export namespace Query {
export type Context<R> = Scope.Scope | QueryClient.QueryClient | QueryClient.QueryClientService.Context | R
}
export interface QueryState<out K, out A, out E = never> {
readonly key: K
readonly result: AsyncResult.AsyncResult<A, E>
/** Whether the request is waiting for a network connection before fetching. See `networkMode`. */
readonly paused: boolean
}
export interface QueryFinalState<out K, out A, out E = never> {
export interface FinalQueryState<out K, out A, out E = never> {
readonly key: K
readonly result: AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>
readonly paused: false
}
/**
* When a Query refreshes its current key on an event, such as the app regaining focus or connection:
* - `true`: if the cached result is stale.
* - `"always"`: regardless of `staleTime`.
* - `false`: never.
*/
export type RefreshOn = boolean | "always"
export const isQuery = (u: unknown): u is Query<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId)
@@ -72,22 +52,19 @@ extends Pipeable.Class implements Query<K, A, E, R> {
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
constructor(
readonly context: Context.Context<Query.Context<R>>,
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>,
readonly key: View.View<K>,
readonly keyEquivalence: Equivalence.Equivalence<K>,
readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly staleTime: Duration.Duration,
readonly networkMode: Operation.NetworkMode,
readonly refreshOnFocus: RefreshOn,
readonly refreshOnReconnect: RefreshOn,
readonly refreshOnWindowFocus: boolean,
readonly operation: Lens.Lens<Option.Option<Operation.Operation<K, A, E, QueryClient.QueryClient | R>>>,
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
readonly state: Lens.Lens<QueryState<K, A, E>>,
readonly latestFinalState: Lens.Lens<Option.Option<QueryFinalState<K, A, E>>>,
readonly latestFinalState: Lens.Lens<Option.Option<FinalQueryState<K, A, E>>>,
readonly runSemaphore: Semaphore.Semaphore,
readonly startSemaphore: Semaphore.Semaphore,
) {
super()
}
@@ -96,22 +73,33 @@ extends Pipeable.Class implements Query<K, A, E, R> {
return Effect.all([
Stream.runForEach(
this.key.changes,
key => this.start(this.previousFor(key)),
key => Effect.gen({ self: this }, function*() {
yield* this.interrupt
const latestFinalState = yield* Lens.get(this.latestFinalState)
const state = yield* this.startCached(
Option.isSome(latestFinalState) && this.keyEquivalence(key, latestFinalState.value.key)
? latestFinalState.value
: {
key,
result: AsyncResult.initial(false),
}
)
yield* Effect.forkScoped(this.watch(state))
}),
),
this.refreshOnFocus
? Effect.flatMap(AppFocus.AppFocus, ({ focused }) => Stream.runForEach(
becomesTrue(focused),
() => this.refreshUnlessWaiting(this.refreshOnFocus === "always"),
))
: Effect.void,
this.refreshOnReconnect
? Effect.flatMap(AppConnectivity.AppConnectivity, ({ online }) => Stream.runForEach(
becomesTrue(online),
() => this.refreshUnlessWaiting(this.refreshOnReconnect === "always"),
))
: Effect.void,
Effect.promise(() => import("@effect/platform-browser")).pipe(
Effect.flatMap(({ BrowserStream }) => this.refreshOnWindowFocus
? Stream.runForEach(
BrowserStream.fromEventListenerWindow("focus"),
() => this.refreshView,
)
: Effect.void
),
Effect.catchDefect(() => Effect.void),
),
], { concurrency: "unbounded" }).pipe(
Effect.ignore,
this.runSemaphore.withPermits(1),
@@ -120,156 +108,219 @@ extends Pipeable.Class implements Query<K, A, E, R> {
}
get interrupt(): Effect.Effect<void> {
return Effect.flatMap(Lens.get(this.operation), Option.match({
onSome: operation => operation.interrupt,
return Effect.flatMap(Lens.get(this.fiber), Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
}))
}
fetch(key: K): Effect.Effect<QueryFinalState<K, A, E>, Cause.NoSuchElementError> {
return this.start(Effect.succeed({
fetch(key: K): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> {
return Effect.gen({ self: this }, function*() {
yield* this.interrupt
const state = yield* this.startCached({
key,
result: AsyncResult.initial(false),
paused: false,
})).pipe(
Effect.flatMap(operation => operation.await),
})
return yield* this.watch(state)
}).pipe(
Effect.provide(this.context),
)
}
fetchStream(key: K): Effect.Effect<
Stream.Stream<QueryState<K, A, E>>,
fetchView(key: K): Effect.Effect<
View.View<QueryState<K, A, E>>,
Cause.NoSuchElementError
> {
return this.start(Effect.succeed({
key,
result: AsyncResult.initial(false),
paused: false,
})).pipe(
Effect.map(operation => operation.stream),
Effect.provide(this.context),
)
}
get refresh(): Effect.Effect<QueryFinalState<K, A, E>, Cause.NoSuchElementError> {
return this.start(this.previousForLatestKey).pipe(
Effect.flatMap(operation => operation.await),
Effect.provide(this.context),
)
}
get refreshStream(): Effect.Effect<
Stream.Stream<QueryState<K, A, E>>,
Cause.NoSuchElementError
> {
return this.start(this.previousForLatestKey).pipe(
Effect.map(operation => operation.stream),
Effect.provide(this.context),
)
}
/**
* Refreshes the current key, unless a request is already in flight. Used when the app regains focus or connection:
* a request that is running keeps running, and one paused for lack of a connection resumes by itself. With
* `ignoreStaleTime`, a fresh cached result is refreshed too.
*/
refreshUnlessWaiting(ignoreStaleTime: boolean): Effect.Effect<void, Cause.NoSuchElementError, Query.Context<R>> {
return Effect.flatMap(
Lens.get(this.state),
state => state.result.waiting
? Effect.void
: Effect.asVoid(this.start(this.previousForLatestKey, ignoreStaleTime)),
)
}
previousFor(key: K): Effect.Effect<QueryState<K, A, E>> {
return Effect.map(Lens.get(this.latestFinalState), latestFinalState =>
Option.isSome(latestFinalState) && this.keyEquivalence(key, latestFinalState.value.key)
? latestFinalState.value
: {
key,
result: AsyncResult.initial(false),
paused: false,
}
)
}
get previousForLatestKey(): Effect.Effect<QueryState<K, A, E>> {
return Effect.flatMap(Lens.get(this.state), state => this.previousFor(state.key))
}
/**
* Interrupts the current Operation and replaces it with a new one, started from `previous` or served from the
* cache unless `ignoreStaleTime` is true.
*/
start(
previous: Effect.Effect<QueryState<K, A, E>>,
ignoreStaleTime = false,
): Effect.Effect<
Operation.Operation<K, A, E, QueryClient.QueryClient | R>,
Cause.NoSuchElementError,
Query.Context<R>
> {
return Effect.gen({ self: this }, function*() {
yield* this.interrupt
const { key, result } = yield* previous
const entry = yield* this.getCacheEntry(key)
const isFresh = !ignoreStaleTime
&& Option.isSome(entry)
&& !(yield* QueryClient.isQueryClientCacheEntryStale(entry.value))
const effect = Effect.tap(this.f(key), v => this.setCacheEntry(key, AsyncResult.success(v)))
if (Option.isSome(entry) && isFresh) {
const state: QueryFinalState<K, A, E> = {
const state = yield* this.startCached({
key,
result: entry.value.result as AsyncResult.Success<A, E>,
paused: false,
}
const operation = yield* Operation.makeSettled({ key, effect, state })
yield* Lens.set(this.operation, Option.some(operation))
yield* this.listener.onChange(operation, state)
yield* this.listener.onSettle(operation, state)
return operation
}
else {
const operation = yield* Operation.make({
key,
effect,
previous: Option.isSome(entry) ? entry.value.result as AsyncResult.AsyncResult<A, E> : result,
listener: this.listener,
networkMode: this.networkMode,
result: AsyncResult.initial(false),
})
yield* Lens.set(this.operation, Option.some(operation))
yield* operation.start
return operation
}
yield* Effect.forkScoped(this.watch(state))
return state
}).pipe(
this.startSemaphore.withPermits(1),
Effect.provide(this.context),
)
}
/** Runs `effect` only if `operation` is the current Operation. */
ifCurrent(
operation: Operation.Operation<K, A, E, QueryClient.QueryClient | R>,
effect: Effect.Effect<void>,
): Effect.Effect<void> {
return Effect.flatMap(
Lens.get(this.operation),
current => Option.isSome(current) && current.value === operation
? effect
: Effect.void,
get refresh(): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> {
return Effect.gen({ self: this }, function*() {
yield* this.interrupt
const latestState = yield* Lens.get(this.state)
const latestFinalState = yield* Lens.get(this.latestFinalState)
const state = yield* this.startCached(
Option.isSome(latestFinalState) && this.keyEquivalence(latestState.key, latestFinalState.value.key)
? latestFinalState.value
: {
key: latestState.key,
result: AsyncResult.initial(false),
}
)
return yield* this.watch(state)
}).pipe(
Effect.provide(this.context),
)
}
/** Records the state of the current Operation. Superseded Operations are ignored. */
get listener(): Operation.OperationListener<K, A, E, QueryClient.QueryClient | R> {
return {
onChange: (operation, state) => this.ifCurrent(operation, Lens.set(this.state, state)),
onSettle: (operation, state) => this.ifCurrent(operation, Lens.set(this.latestFinalState, Option.some(state))),
onInterrupt: () => Effect.void,
get refreshView(): Effect.Effect<
View.View<QueryState<K, A, E>>,
Cause.NoSuchElementError
> {
return Effect.gen({ self: this }, function*() {
yield* this.interrupt
const latestState = yield* Lens.get(this.state)
const latestFinalState = yield* Lens.get(this.latestFinalState)
const state = yield* this.startCached(
Option.isSome(latestFinalState) && this.keyEquivalence(latestState.key, latestFinalState.value.key)
? latestFinalState.value
: {
key: latestState.key,
result: AsyncResult.initial(false),
}
)
yield* Effect.forkScoped(this.watch(state))
return state
}).pipe(
Effect.provide(this.context),
)
}
startCached(
previous: QueryState<K, A, E>,
): Effect.Effect<
View.View<QueryState<K, A, E>>,
Cause.NoSuchElementError,
Scope.Scope | QueryClient.QueryClient | R
> {
return Effect.flatMap(this.getCacheEntry(previous.key), Option.match({
onSome: entry => Effect.flatMap(
QueryClient.isQueryClientCacheEntryStale(entry),
isStale => isStale
? this.start({
key: previous.key,
result: entry.result as AsyncResult.AsyncResult<A, E>,
})
: Effect.succeed(View.make({
get: Effect.succeed({
key: previous.key,
result: entry.result as AsyncResult.AsyncResult<A, E>,
}),
get changes() {
return Stream.make({
key: previous.key,
result: entry.result as AsyncResult.AsyncResult<A, E>,
})
},
})),
),
onNone: () => this.start(previous),
}))
}
start(
previous: QueryState<K, A, E>,
): Effect.Effect<
View.View<QueryState<K, A, E>>,
never,
Scope.Scope | R
> {
return Effect.gen({ self: this }, function*() {
const state = yield* makeQueryStateLens(previous)
const fiber = yield* Effect.forkScoped(Effect.andThen(
Lens.update<QueryState<K, A, E>, never, never, never, never>(
state,
previous => AsyncResult.match(previous.result, {
onInitial: () => ({
key: previous.key,
result: AsyncResult.initial(true),
}),
onSuccess: result => ({
key: previous.key,
result: AsyncResult.success(result.value, {
waiting: true,
}),
}),
onFailure: result => ({
key: previous.key,
result: AsyncResult.failure(result.cause, {
waiting: true,
previousSuccess: result.previousSuccess,
}),
}),
}
)),
Effect.onExit(this.f(previous.key), exit => Effect.gen({ self: this }, function*() {
const fiberId = yield* Effect.fiberId
const fiber = yield* Lens.get(this.fiber)
if (Option.isSome(fiber) && fiberId === fiber.value.id)
yield* Lens.set(this.fiber, Option.none())
const finalState = (yield* Lens.updateAndGet<QueryState<K, A, E>, never, never, never, never>(
state,
previous => Exit.match(exit, {
onSuccess: v => ({
key: previous.key,
result: AsyncResult.success(v),
}),
onFailure: c => Cause.hasInterruptsOnly(c)
? previous
: AsyncResult.match(previous.result, {
onInitial: () => ({
key: previous.key,
result: AsyncResult.failure(c),
}),
onSuccess: v => ({
key: previous.key,
result: AsyncResult.failure(c, {
previousSuccess: Option.some(v),
}),
}),
onFailure: v => ({
key: previous.key,
result: AsyncResult.failure(c, {
previousSuccess: v.previousSuccess,
}),
}),
}),
}),
)) as FinalQueryState<K, A, E>
yield* Lens.set(this.latestFinalState, Option.some(finalState))
yield* PubSub.shutdown(state.pubsub)
}))
))
yield* Lens.set(this.fiber, Option.some(fiber))
return state
})
}
watch(
view: View.View<QueryState<K, A, E>>
): Effect.Effect<FinalQueryState<K, A, E>, never, QueryClient.QueryClient> {
return Effect.gen({ self: this }, function*() {
const initial = yield* View.get(view)
const final = yield* Stream.runFoldEffect(
View.changes(view),
() => initial,
(_, state) => Effect.as(Lens.set(this.state, state), state),
) as Effect.Effect<FinalQueryState<K, A, E>>
yield* Lens.set(this.latestFinalState, Option.some(final))
if (AsyncResult.isSuccess(final.result))
yield* this.setCacheEntry(final.key, final.result)
return final
})
}
makeCacheKey(key: K): QueryClient.QueryClientCacheKey {
@@ -326,10 +377,7 @@ export declare namespace make {
readonly f: (key: K) => Effect.Effect<A, E, R>
readonly staleTime?: Duration.Input
readonly networkMode?: Operation.NetworkMode
readonly refreshOnFocus?: RefreshOn
/** Defaults to the client's `defaultRefreshOnReconnect`, and to `false` in `"always"` network mode. */
readonly refreshOnReconnect?: RefreshOn
readonly refreshOnWindowFocus?: boolean
}
}
@@ -341,118 +389,75 @@ export const make = Effect.fnUntraced(function* <K, A, E = never, R = never>(
Scope.Scope | QueryClient.QueryClient | R
> {
const client = yield* QueryClient.QueryClient
const networkMode = options.networkMode ?? client.defaultNetworkMode
return new QueryImpl(
Context.mergeAll(
client.context,
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | R>(),
),
options.key,
options.keyEquivalence ?? Equal.asEquivalence(),
options.f,
options.staleTime ? yield* Effect.fromOption(Duration.fromInput(options.staleTime)) : client.defaultStaleTime,
networkMode,
options.refreshOnFocus ?? client.defaultRefreshOnFocus,
options.refreshOnReconnect ?? (networkMode === "always" ? false : client.defaultRefreshOnReconnect),
options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus,
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Operation.Operation<K, A, E, QueryClient.QueryClient | R>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<QueryState<K, A, E>>({
key: yield* View.get(options.key),
result: AsyncResult.initial(false),
paused: false,
})),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<QueryFinalState<K, A, E>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<FinalQueryState<K, A, E>>())),
yield* Semaphore.make(1),
yield* Semaphore.make(1),
)
})
/** Emits each time `view` becomes true, after its current value. */
const becomesTrue = (view: View.View<boolean>): Stream.Stream<true> => View.changes(view).pipe(
Stream.changes,
Stream.drop(1),
Stream.filter((value): value is true => value),
)
export const thenRun = <K, A, E = never, R = never, E2 = never, R2 = never>(
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | R2> => Effect.tap(
self,
export const service = <K, A, E = never, R = never>(
options: make.Options<K, A, E, R>
): Effect.Effect<
Query<K, A, E, R>,
Cause.NoSuchElementError,
Scope.Scope | QueryClient.QueryClient | R
> => Effect.tap(
make(options),
query => Effect.forkScoped(query.run),
)
export declare namespace withScheduledRefresh {
export interface Options {
/**
* Whether to keep refreshing while the app is not focused (see `AppFocus`). Defaults to `false`: a refresh due
* while the app is not focused is skipped, but still counts as a recurrence of the schedule.
*/
readonly refreshInBackground?: boolean
export class QueryStateLens<in out K, in out A, in out E = never>
extends Lens.LensImpl<QueryState<K, A, E>, never, never, never, never> {
constructor(
readonly ref: Ref.Ref<QueryState<K, A, E>>,
readonly pubsub: PubSub.PubSub<QueryState<K, A, E>>,
readonly semaphore: Semaphore.Semaphore,
) {
super()
}
get resolve(): Effect.Effect<Lens.LensImpl.Resolved<QueryState<K, A, E>>, never, never> {
return Effect.map(
Ref.get(this.ref),
value => ({
value,
commit: next => Effect.flatMap(
next,
value => Effect.andThen(
Ref.set(this.ref, value),
PubSub.publish(this.pubsub, value),
),
),
}),
)
}
get changes() { return Stream.fromPubSub(this.pubsub) }
get lock() { return Effect.succeed(this.semaphore.withPermit) }
}
/**
* Refreshes the Query on a schedule and returns it. By default, refreshes are skipped while the app is not focused.
*
* @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 })),
* ),
* )
* ```
*
* @example Keep refreshing while the app is in the background
* ```ts
* yield* Query.make(options).pipe(
* Query.thenRun,
* Query.withScheduledRefresh(Schedule.spaced("5 minutes"), { refreshInBackground: true }),
* )
* ```
*/
export const withScheduledRefresh: {
<Output, Error, Env>(
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
options?: withScheduledRefresh.Options,
): <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>,
options?: withScheduledRefresh.Options,
): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | Env | R2>
} = Function.dual(args => Effect.isEffect(args[0]), <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>,
options?: withScheduledRefresh.Options,
) => Effect.tap(
self,
query => Effect.forkScoped(Effect.schedule(
options?.refreshInBackground
? query.refresh
: Effect.flatMap(
Effect.provide(
Effect.flatMap(AppFocus.AppFocus, ({ focused }) => View.get(focused)),
query.context,
),
focused => focused
? Effect.asVoid(query.refresh)
: Effect.void,
),
schedule,
)),
))
export const makeQueryStateLens = <K, A, E = never>(
initial: QueryState<K, A, E>,
) => Effect.all([
Ref.make(initial),
PubSub.unbounded<QueryState<K, A, E>>({ replay: 1 }),
Semaphore.make(1),
]).pipe(
Effect.tap(([, pubsub]) => PubSub.publish(pubsub, initial)),
Effect.map(([ref, pubsub, semaphore]) => new QueryStateLens(ref, pubsub, semaphore)),
)
+18 -81
View File
@@ -1,10 +1,6 @@
import { type Cause, Context, DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, Layer, type Option, Pipeable, Predicate, Schedule, type Scope, Semaphore, SubscriptionRef } from "effect"
import type { AsyncResult } from "effect/reactivity"
import * as AppConnectivity from "./AppConnectivity.js"
import * as AppFocus from "./AppFocus.js"
import type { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js"
import type * as Operation from "./Operation.js"
import type * as Query from "./Query.js"
import type * as View from "./View.js"
@@ -14,18 +10,12 @@ export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
export interface QueryClientService extends Pipeable.Pipeable {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
readonly context: Context.Context<QueryClientService.Context>
readonly cache: View.View<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly cacheGcTime: Duration.Duration
readonly defaultStaleTime: Duration.Duration
readonly defaultNetworkMode: Operation.NetworkMode
readonly defaultRefreshOnFocus: Query.RefreshOn
readonly defaultRefreshOnReconnect: Query.RefreshOn
readonly defaultMutationNetworkMode: Operation.NetworkMode
readonly defaultRefreshOnWindowFocus: boolean
readonly run: Effect.Effect<void, Cause.NoSuchElementError>
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>>
setCacheEntry(
key: QueryClientCacheKey,
@@ -36,9 +26,9 @@ export interface QueryClientService extends Pipeable.Pipeable {
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void>
}
export namespace QueryClientService {
export type Context = AppConnectivity.AppConnectivity | AppFocus.AppFocus
}
export class QueryClient extends Context.Service<QueryClient, QueryClientService>()(
"@effect-view/QueryClient/QueryClient"
) {}
export class QueryClientServiceImpl
extends Pipeable.Class
@@ -46,16 +36,10 @@ implements QueryClientService {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId
constructor(
readonly context: Context.Context<QueryClientService.Context>,
readonly cache: Lens.Lens<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
readonly cacheGcTime: Duration.Duration,
readonly defaultStaleTime: Duration.Duration,
readonly defaultNetworkMode: Operation.NetworkMode,
readonly defaultRefreshOnFocus: Query.RefreshOn,
readonly defaultRefreshOnReconnect: Query.RefreshOn,
readonly defaultMutationNetworkMode: Operation.NetworkMode,
readonly defaultRefreshOnWindowFocus: boolean,
readonly runSemaphore: Semaphore.Semaphore,
) {
super()
@@ -115,81 +99,34 @@ export declare namespace make {
export interface Options {
readonly cacheGcTime?: Duration.Input
readonly defaultStaleTime?: Duration.Input
/** The default network mode of queries. */
readonly defaultNetworkMode?: Operation.NetworkMode
readonly defaultRefreshOnFocus?: Query.RefreshOn
readonly defaultRefreshOnReconnect?: Query.RefreshOn
/** The default network mode of mutations. */
readonly defaultMutationNetworkMode?: Operation.NetworkMode
readonly defaultRefreshOnWindowFocus?: boolean
}
}
export const make = Effect.fnUntraced(function* (
options: make.Options = {}
): Effect.fn.Return<
QueryClientService,
Cause.NoSuchElementError,
QueryClientService.Context
> {
): Effect.fn.Return<QueryClientService, Cause.NoSuchElementError, never> {
return new QueryClientServiceImpl(
Context.empty().pipe(
Context.add(AppConnectivity.AppConnectivity, yield* AppConnectivity.AppConnectivity),
Context.add(AppFocus.AppFocus, yield* AppFocus.AppFocus),
),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>())),
yield* Effect.fromOption(Duration.fromInput(options.cacheGcTime ?? "5 minutes")),
yield* Effect.fromOption(Duration.fromInput(options.defaultStaleTime ?? "0 minutes")),
options.defaultNetworkMode ?? "online",
options.defaultRefreshOnFocus ?? true,
options.defaultRefreshOnReconnect ?? true,
options.defaultMutationNetworkMode ?? "online",
options.defaultRefreshOnWindowFocus ?? true,
yield* Semaphore.make(1),
)
})
export const thenRun = <E = never, R = never>(
self: Effect.Effect<QueryClientService, E, R>,
): Effect.Effect<QueryClientService, E, Scope.Scope | R> => Effect.tap(
self,
export declare namespace service {
export interface Options extends make.Options {}
}
export const service = (
options?: service.Options
): Effect.Effect<QueryClientService, Cause.NoSuchElementError, Scope.Scope> => Effect.tap(
make(options),
client => Effect.forkScoped(client.run),
)
export class QueryClient extends Context.Service<QueryClient, QueryClientService>()(
"@effect-view/QueryClient/QueryClient"
) {}
export const layer: Layer.Layer<
QueryClient,
Cause.NoSuchElementError,
QueryClientService.Context
> = Layer.effect(QueryClient, thenRun(make()))
export const layerWithOptions = (options: make.Options): Layer.Layer<
QueryClient,
Cause.NoSuchElementError,
QueryClientService.Context
> => Layer.effect(QueryClient, thenRun(make(options)))
export const layerBrowser: Layer.Layer<
QueryClient,
Cause.NoSuchElementError,
never
> = layer.pipe(
Layer.provide(AppConnectivity.layerBrowser),
Layer.provide(AppFocus.layerBrowser),
)
export const layerBrowserWithOptions = (options: make.Options): Layer.Layer<
QueryClient,
Cause.NoSuchElementError,
never
> => layerWithOptions(options).pipe(
Layer.provide(AppConnectivity.layerBrowser),
Layer.provide(AppFocus.layerBrowser),
)
export const layer = (options?: service.Options) => Layer.effect(QueryClient, service(options))
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey")
+2 -14
View File
@@ -1,5 +1,5 @@
import { Effect, Equivalence, Stream, SubscriptionRef } from "effect"
import { Lens, View } from "effect-lens"
import { Effect, Equivalence, Stream } from "effect"
import { View } from "effect-lens"
import * as React from "react"
import * as Component from "./Component.js"
@@ -40,15 +40,3 @@ export const useAll = Effect.fnUntraced(function* <const T extends readonly View
return reactStateValue as any
})
export const useFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
values: A,
): Effect.fn.Return<View.View<A, never, never>> {
const lens = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make(values),
Lens.fromSubscriptionRef,
))
yield* Component.useReactEffect(() => Lens.set(lens, values), values)
return lens
})
-3
View File
@@ -1,5 +1,3 @@
export * as AppConnectivity from "./AppConnectivity.js"
export * as AppFocus from "./AppFocus.js"
export * as Async from "./Async.js"
export * as Component from "./Component.js"
export * as Form from "./Form.js"
@@ -8,7 +6,6 @@ export * as LensForm from "./LensForm.js"
export * as Memoized from "./Memoized.js"
export * as Mutation from "./Mutation.js"
export * as MutationForm from "./MutationForm.js"
export * as Operation from "./Operation.js"
export * as PubSub from "./PubSub.js"
export * as Query from "./Query.js"
export * as QueryClient from "./QueryClient.js"
+1 -2
View File
@@ -1,5 +1,4 @@
{
"$schema": "../../node_modules/@effect/tsgo/schema.json",
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
@@ -36,5 +35,5 @@
]
},
"include": ["./src"]
"include": ["./src"],
}
+3 -3
View File
@@ -27,15 +27,15 @@
"vite": "^8.0.16"
},
"dependencies": {
"@effect/platform-browser": "^4.0.0",
"@effect/platform-browser": "4.0.0-beta.102",
"@radix-ui/themes": "^3.3.0",
"effect": "^4.0.0",
"effect": "4.0.0-beta.102",
"effect-view": "workspace:*",
"react-icons": "^5.6.0"
},
"overrides": {
"@types/react": "^19.2.15",
"effect": "^4.0.0",
"effect": "4.0.0-beta.102",
"react": "^19.2.6"
}
}
+2 -4
View File
@@ -26,13 +26,11 @@ const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() {
}))
const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() {
const form = yield* MutationForm.make({
const form = yield* MutationForm.service({
schema: RegisterSchema,
initialEncodedValue: { email: "", password: "" },
f: ([value]) => Effect.log(`Registered ${value.email}`),
}).pipe(
MutationForm.thenRun,
)
})
const emailField = Form.focusObjectOn(form, "email")
const passwordField = Form.focusObjectOn(form, "password")
+17 -8
View File
@@ -41,7 +41,20 @@ class AppState extends Context.Service<AppState, {
}
const LensFormPageView = Component.make("LensFormPage")(function*() {
const LensFormPageView = Component.make("LensFormPageView")(function*() {
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("LensForm route unmounted"))
yield* Console.log("LensForm route mounted")
}))
const context = yield* Component.useLayer(AppState.layer)
const UserProfileEditor = yield* Effect.provide(UserProfileEditorView.use, context)
return <UserProfileEditor />
})
const UserProfileEditorView = Component.make("UserProfileEditorView")(function*() {
const appState = yield* AppState
const [
@@ -52,16 +65,14 @@ const LensFormPageView = Component.make("LensFormPage")(function*() {
Stream.runForEach(Lens.changes(appState.lens), Console.log)
)
const form = yield* LensForm.make({
const form = yield* LensForm.service({
schema: UserProfileSchema,
target: Lens.focusObjectOn(appState.lens, "currentUser"),
initialEncodedValue: {
email: "",
password: "",
},
}).pipe(
LensForm.thenRun,
)
})
const emailField = Form.focusObjectOn(form, "email")
const passwordField = Form.focusObjectOn(form, "password")
@@ -93,9 +104,7 @@ const LensFormPageView = Component.make("LensFormPage")(function*() {
</form>
</Container>
)
}).pipe(
Component.provide(AppState.layer),
)
})
export const Route = createFileRoute("/lensform")({
+11 -13
View File
@@ -1,8 +1,8 @@
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Effect, Schema, SubscriptionRef } from "effect"
import { HttpClient } from "effect/http"
import { AsyncResult } from "effect/reactivity"
import { HttpClient } from "effect/unstable/http"
import { AsyncResult } from "effect/unstable/reactivity"
import { Component, Lens, Mutation, Query, View } from "effect-view"
import { runtime } from "@/runtime"
@@ -36,18 +36,16 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
const keyLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const))
const idLens = Lens.focusTupleAt(keyLens, 1)
const query = yield* Query.make({
const query = yield* Query.service({
key: keyLens,
f: ([, id]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")),
Effect.flatMap(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.flatMap(response => response.json),
Effect.flatMap(Schema.decodeUnknownEffect(Post)),
Effect.tap(Effect.sleep("1 second")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)),
),
staleTime: "10 seconds",
}).pipe(
Query.thenRun,
)
})
const mutation = yield* Mutation.make({
f: ([id]: [id: number]) => HttpClient.HttpClient.pipe(
@@ -79,7 +77,7 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
<PostResultView result={queryState.result} />
<Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runSync(query.refreshStream)}>
<Button onClick={() => runSync(query.refreshView)}>
Refresh
</Button>
<Button onClick={() => runSync(query.invalidateCache)}>
@@ -87,9 +85,9 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
</Button>
</Flex>
<PostResultView result={mutationState.result} />
<PostResultView result={mutationState} />
<Button onClick={() => runSync(mutation.mutateStream([id]))}>
<Button onClick={() => runSync(mutation.mutateView([id]))}>
Mutate
</Button>
</Flex>
+2 -2
View File
@@ -1,11 +1,11 @@
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
import { DateTime, Layer } from "effect"
import { FetchHttpClient } from "effect/http"
import { FetchHttpClient } from "effect/unstable/http"
import { QueryClient, ReactRuntime } from "effect-view"
export const layer = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layerBrowser),
Layer.provideMerge(QueryClient.layer()),
Layer.provideMerge(DateTime.layerCurrentZoneLocal),
Layer.provideMerge(Clipboard.layer),
Layer.provideMerge(Geolocation.layer),
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@effect-view/vite-plugin",
"description": "Vite Fast Refresh support for Effect View components",
"version": "0.3.0",
"version": "0.0.1",
"type": "module",
"files": [
"./README.md",
@@ -41,7 +41,7 @@
"vitest": "^3.2.4"
},
"peerDependencies": {
"effect-view": "^0.3.0",
"effect-view": "^0.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
}
+38 -131
View File
@@ -33,10 +33,11 @@ interface ComponentImports {
interface Definition {
readonly expression: ts.Expression
readonly wrapTarget: ts.Expression
readonly factoryCall: ts.CallExpression
readonly body: ts.FunctionLikeDeclaration | undefined
readonly id: string
readonly site: EntrypointSite.Pipe | undefined
readonly pipeline: ts.CallExpression | undefined
readonly entrypointIndex: number
}
const defaultInclude = /\.[cm]?[jt]sx?$/
@@ -139,13 +140,6 @@ const findFactoryCall = (
if (result)
return
// Never look inside a nested function body: a factory call there
// belongs to a different, unrelated component (e.g. one composed
// dynamically inside this component's own render), not to the
// composition shape of the definition being analyzed.
if (node !== root && ts.isFunctionLike(node))
return
if (ts.isCallExpression(node)) {
if (isFactoryCallee(node.expression, imports)) {
result = {
@@ -171,83 +165,18 @@ const findFactoryCall = (
return result
}
const isPipeline = (node: ts.Node): node is ts.CallExpression =>
ts.isCallExpression(node)
&& ts.isPropertyAccessExpression(node.expression)
&& node.expression.name.text === "pipe"
const isPipeline = (expression: ts.Expression): expression is ts.CallExpression =>
ts.isCallExpression(expression)
&& ts.isPropertyAccessExpression(expression.expression)
&& expression.expression.name.text === "pipe"
const entrypointName = (callee: ts.Expression): boolean =>
ts.isPropertyAccessExpression(callee)
const isEntrypoint = (expression: ts.Expression): boolean => {
if (!ts.isCallExpression(expression))
return false
const callee = expression.expression
return ts.isPropertyAccessExpression(callee)
&& (callee.name.text === "withRuntime" || callee.name.text === "withContext")
/**
* A curried entrypoint call (`withContext(context)`) used as one step of a
* `.pipe(...)` chain: the descriptor produced by the preceding steps must be
* registered *before* this step runs, since it is what converts the
* descriptor into a plain React function component.
*/
const isEntrypoint = (expression: ts.Expression): boolean =>
ts.isCallExpression(expression) && entrypointName(expression.expression)
/**
* The data-first form of an entrypoint call (`withContext(descriptor,
* context)`), which can appear anywhere - not only inside a `.pipe(...)`
* chain - and always takes the descriptor to convert as its first argument.
*/
const isEntrypointDataFirst = (node: ts.Node): node is ts.CallExpression =>
ts.isCallExpression(node)
&& entrypointName(node.expression)
&& node.arguments.length >= 2
declare namespace EntrypointSite {
export interface Pipe {
readonly kind: "pipe"
readonly pipeCall: ts.CallExpression
readonly argIndex: number
}
export interface DataFirst {
readonly kind: "dataFirst"
readonly call: ts.CallExpression
}
}
type EntrypointSite = EntrypointSite.Pipe | EntrypointSite.DataFirst
/**
* Finds where, anywhere within a definition's composition expression, a
* descriptor is converted into a plain React function component - whether
* through a `.pipe(..., withContext(context))` chain or a direct
* `withContext(descriptor, context)` call. Does not look inside nested
* function bodies, for the same reason as `findFactoryCall`.
*/
const findEntrypointSite = (root: ts.Node): EntrypointSite | undefined => {
let result: EntrypointSite | undefined
const visit = (node: ts.Node): void => {
if (result)
return
if (node !== root && ts.isFunctionLike(node))
return
if (isPipeline(node)) {
const argIndex = node.arguments.findIndex(isEntrypoint)
if (argIndex >= 0) {
result = { kind: "pipe", pipeCall: node, argIndex }
return
}
}
else if (isEntrypointDataFirst(node)) {
result = { kind: "dataFirst", call: node }
return
}
ts.forEachChild(node, visit)
}
visit(root)
return result
}
const makeDefinition = (
@@ -256,33 +185,25 @@ const makeDefinition = (
imports: ComponentImports,
): Definition | undefined => {
const factory = findFactoryCall(expression, imports)
const site = findEntrypointSite(expression)
// A `.pipe(..., withContext(context))` chain always needs the splice
// treatment, whether or not a factory call could be found in this same
// statement (its base may be a component defined elsewhere).
if (site?.kind === "pipe")
return { expression, wrapTarget: expression, body: factory?.body, id, site }
// `withContext(descriptor, context)` always needs its descriptor
// argument wrapped directly, since by the time the call returns the
// result is a plain function component, not a Component descriptor.
if (site?.kind === "dataFirst") {
const target = site.call.arguments[0]
if (!target)
return undefined
return { expression, wrapTarget: target, body: factory?.body, id, site: undefined }
}
// No entrypoint conversion happens within this statement: the
// descriptor remains a Component.Any throughout, however it got here
// (a bare factory call, a `.pipe()` of traits with no withContext, or a
// user-defined composition helper wrapping either) - safe to register
// the whole expression.
if (!factory)
return undefined
return { expression, wrapTarget: expression, body: factory.body, id, site: undefined }
const pipeline = isPipeline(expression) ? expression : undefined
if (expression !== factory.factoryCall && !pipeline)
return undefined
const entrypointIndex = pipeline
? pipeline.arguments.findIndex(isEntrypoint)
: -1
return {
expression,
factoryCall: factory.factoryCall,
body: factory.body,
id,
pipeline,
entrypointIndex,
}
}
const collectDefinitions = (
@@ -339,19 +260,9 @@ const collectDefinitions = (
return definitions
}
/**
* Computes a signature for the *shape* of a component's hook calls: the
* ordered sequence of hook names it calls at its top level, ignoring
* everything else (argument contents, dependency array entries, unrelated
* logic). Two bodies calling the same hooks in the same order hash to the
* same signature even if most of their code differs, so edits that don't
* touch which hooks run - the overwhelming majority of edits - refresh in
* place instead of forcing a remount. This mirrors how React's own Fast
* Refresh only forces a remount when the hook call sequence itself changes,
* not when a hook's arguments or the surrounding logic change.
*/
const hookSignature = (
body: ts.FunctionLikeDeclaration | undefined,
sourceFile: ts.SourceFile,
): string => {
if (!body?.body)
return "unknown"
@@ -365,18 +276,14 @@ const hookSignature = (
if (ts.isCallExpression(node)) {
const callee = node.expression
const localName = ts.isIdentifier(callee)
const name = ts.isIdentifier(callee)
? callee.text
: ts.isPropertyAccessExpression(callee)
? callee.name.text
: undefined
if (localName && /^use[A-Z0-9]/.test(localName)) {
const qualifier = ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)
? `${callee.expression.text}.`
: ""
hooks.push(`${qualifier}${localName}`)
}
if (name && /^use[A-Z0-9]/.test(name))
hooks.push(node.getText(sourceFile))
}
ts.forEachChild(node, visit)
@@ -477,10 +384,10 @@ export function effectView(
const edits: Edit[] = []
for (const definition of definitions) {
const signature = hookSignature(definition.body)
const signature = hookSignature(definition.body, sourceFile)
if (definition.site?.kind === "pipe") {
const entrypoint = definition.site.pipeCall.arguments[definition.site.argIndex]
if (definition.pipeline && definition.entrypointIndex >= 0) {
const entrypoint = definition.pipeline.arguments[definition.entrypointIndex]
if (!entrypoint)
continue
edits.push({
@@ -496,8 +403,8 @@ export function effectView(
continue
}
const start = definition.wrapTarget.getStart(sourceFile)
const end = definition.wrapTarget.end
const start = definition.expression.getStart(sourceFile)
const end = definition.expression.end
edits.push({
start,
end,
-73
View File
@@ -135,77 +135,4 @@ export const Legacy = Component.makeUntraced(function*() {
})
`)).toBeUndefined()
})
it("registers a component wrapped by a user-defined helper function", async () => {
const result = await transform(`
import { Component } from "effect-view"
const withLogging = (view) => view
export const LoggedView = withLogging(Component.make("LoggedView")(function*() {
return <div />
}))
`)
expect(result).toContain("__effectViewRefresh(withLogging(Component.make(\"LoggedView\")")
expect(result).toContain("\"src/View.tsx:LoggedView\"")
})
it("wraps the descriptor argument of a data-first withContext call", async () => {
const result = await transform(`
import { Component } from "effect-view"
const HomeBase = Component.make("Home")(function*() {
return <div />
})
export const Home = Component.withContext(HomeBase, runtime.context)
`)
expect(result).toContain("__effectViewRefresh(Component.make(\"Home\")")
expect(result).toContain("Component.withContext(__effectViewRefresh(HomeBase, import.meta.hot, \"src/View.tsx:Home\", \"unknown\", false), runtime.context)")
})
it("keeps the same hook signature when only literal content inside a hook call changes", async () => {
const before = await transform(`
import { Component } from "effect-view"
export const CounterView = Component.make("CounterView")(function*() {
const value = yield* Component.useOnMount(() => loadInitial(1))
return <div>{value}</div>
})
`)
const after = await transform(`
import { Component } from "effect-view"
export const CounterView = Component.make("CounterView")(function*() {
const value = yield* Component.useOnMount(() => loadInitial(2))
return <div>{value}</div>
})
`)
const signatureOf = (code: string | undefined) => code?.match(/"src\/View\.tsx:CounterView", "([a-z0-9]+)"/)?.[1]
expect(signatureOf(before)).toBeDefined()
expect(signatureOf(before)).toBe(signatureOf(after))
})
it("changes the hook signature when a hook call is added", async () => {
const before = await transform(`
import { Component } from "effect-view"
export const CounterView = Component.make("CounterView")(function*() {
const value = yield* Component.useOnMount(() => loadInitial())
return <div>{value}</div>
})
`)
const after = await transform(`
import { Component } from "effect-view"
export const CounterView = Component.make("CounterView")(function*() {
const value = yield* Component.useOnMount(() => loadInitial())
yield* Component.useReactEffect(() => trackView(), [])
return <div>{value}</div>
})
`)
const signatureOf = (code: string | undefined) => code?.match(/"src\/View\.tsx:CounterView", "([a-z0-9]+)"/)?.[1]
expect(signatureOf(before)).toBeDefined()
expect(signatureOf(before)).not.toBe(signatureOf(after))
})
})