Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a76f6bb92 | ||
|
|
a3119a4479 | ||
|
|
661064d940 | ||
|
|
44bea41e39 | ||
|
|
689af07190 |
@@ -0,0 +1,52 @@
|
||||
# Version control and local agent/editor metadata
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.agents
|
||||
.codex
|
||||
.vscode
|
||||
|
||||
# Dependencies and package-manager caches
|
||||
node_modules
|
||||
.npm
|
||||
.yarn
|
||||
.pnpm-store
|
||||
.bun
|
||||
|
||||
# Build output and task-runner caches
|
||||
dist
|
||||
build
|
||||
out
|
||||
.turbo
|
||||
*.tsbuildinfo
|
||||
.cache
|
||||
.parcel-cache
|
||||
.docusaurus
|
||||
.vite
|
||||
*.vite*
|
||||
|
||||
# Test, coverage, and diagnostic output
|
||||
coverage
|
||||
.nyc_output
|
||||
*.lcov
|
||||
reports
|
||||
report.*.json
|
||||
|
||||
# Logs and temporary files
|
||||
*.log
|
||||
*.pid
|
||||
*.seed
|
||||
tmp
|
||||
temp
|
||||
*.tmp
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Local environment and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Package artifacts
|
||||
*.tgz
|
||||
*.tar
|
||||
@@ -8,7 +8,9 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Clone repo
|
||||
with:
|
||||
bun-version: "1.4"
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
@@ -11,6 +11,8 @@ 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
|
||||
@@ -20,7 +22,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Clone repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -55,11 +57,6 @@ jobs:
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
registry: https://registry.npmjs.org
|
||||
|
||||
- name: Clean before Docker build
|
||||
run: |
|
||||
bun clean:cache
|
||||
bun clean:dist
|
||||
bun clean:modules
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
|
||||
@@ -9,14 +9,16 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.4"
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: "24"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Clone repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -32,11 +34,6 @@ jobs:
|
||||
- name: Pack
|
||||
run: bun pack
|
||||
|
||||
- name: Clean before Docker build
|
||||
run: |
|
||||
bun clean:cache
|
||||
bun clean:dist
|
||||
bun clean:modules
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
|
||||
Vendored
+4
-1
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"js/ts.tsdk.path": "./node_modules/typescript/lib",
|
||||
"js/ts.tsdk.additionalLocations": ["./node_modules/typescript/bin"],
|
||||
"js/ts.tsdk.promptToUseWorkspaceVersion": true,
|
||||
"js/ts.experimental.useTsgo": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit"
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
FROM oven/bun:1.3.12-debian@sha256:1b709c9dd883fc1af38c210f7ea5222c552a8d470ea73efbd4b8fcfee798a64b AS bun
|
||||
FROM oven/bun:1.4.0-debian@sha256:5bb0f9be3a1a36a03e27c9a9dd894a3b1ad26657155c7df4dda771e17bf872ef AS bun
|
||||
|
||||
FROM node:22.21.1-trixie-slim@sha256:98e1429d1a0b99378b4de43fa385f0746fd6276faf4feeb6104d91f6bad290f9
|
||||
FROM node:24.19.0-trixie-slim@sha256:0711b541c1c33a8a530ac4f0d391baa9a15b3d804695b1b24a47daa5fb60e74d
|
||||
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bunx /usr/local/bin/
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
@@ -9,4 +9,4 @@ RUN bun install --frozen-lockfile && \
|
||||
bun run build && \
|
||||
bun clean:cache && \
|
||||
bun clean:modules && \
|
||||
bun install --production --frozen-lockfile
|
||||
bun install --production --frozen-lockfile --ignore-scripts
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"name": "@effect-view/monorepo",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.5",
|
||||
"@effect/language-service": "^0.87.1",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@effect/tsgo": "0.36.4",
|
||||
"@types/bun": "^1.4.0",
|
||||
"npm-check-updates": "^23.0.0",
|
||||
"npm-sort": "^0.0.4",
|
||||
"turbo": "^2.10.7",
|
||||
@@ -82,21 +82,21 @@
|
||||
},
|
||||
"packages/effect-view": {
|
||||
"name": "effect-view",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"effect-lens": "^2.0.1-beta.102",
|
||||
"effect-lens": "2.0.1-rc.109",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-browser": "4.0.0-beta.102",
|
||||
"@effect/platform-browser": "4.0.0-rc.109",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"effect": "4.0.0-beta.102",
|
||||
"effect": "4.0.0-rc.109",
|
||||
"jsdom": "^26.1.0",
|
||||
"vitest": "^3.2.4",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
"effect": "4.0.0-beta.102",
|
||||
"effect": "4.0.0-rc.109",
|
||||
"react": "^19.2.0",
|
||||
},
|
||||
},
|
||||
@@ -104,9 +104,9 @@
|
||||
"name": "@effect-view/example",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@effect/platform-browser": "4.0.0-beta.102",
|
||||
"@effect/platform-browser": "4.0.0-rc.109",
|
||||
"@radix-ui/themes": "^3.3.0",
|
||||
"effect": "4.0.0-beta.102",
|
||||
"effect": "4.0.0-rc.109",
|
||||
"effect-view": "workspace:*",
|
||||
"react-icons": "^5.6.0",
|
||||
},
|
||||
@@ -578,12 +578,26 @@
|
||||
|
||||
"@effect-view/vite-plugin": ["@effect-view/vite-plugin@workspace:packages/vite-plugin"],
|
||||
|
||||
"@effect/language-service": ["@effect/language-service@0.87.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-kcljlJmEgqg5mFAM6UShJYJjMqJb3TbHHxrK8Qoubvwugc0aVWpRkbdgQvK5b17puOt3BKXXKOmcV+oQt0oQqQ=="],
|
||||
|
||||
"@effect/platform": ["@effect/platform@0.96.3", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.10", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.21.5" } }, "sha512-LzvIj4HYE++TcTv/cVTCI/GRvQTV0ymwRmgjZMzNB5dU4OS05pTN36RKN0npiOm5orLJgw4yjIv1dNZoYGh/vg=="],
|
||||
|
||||
"@effect/platform-browser": ["@effect/platform-browser@0.76.0", "", { "dependencies": { "multipasta": "^0.2.7" }, "peerDependencies": { "@effect/platform": "^0.96.0", "effect": "^3.21.0" } }, "sha512-cUyBpcLstrP/HiNsIePMBAI6R1+u6aRFlAUZb4wf08y1d1Vqf/Dmxsq14ZjBfnSYiqBPrCeYf1ZI+qMGQQL0RA=="],
|
||||
|
||||
"@effect/tsgo": ["@effect/tsgo@0.36.4", "", { "optionalDependencies": { "@effect/tsgo-darwin-arm64": "0.36.4", "@effect/tsgo-darwin-x64": "0.36.4", "@effect/tsgo-linux-arm": "0.36.4", "@effect/tsgo-linux-arm64": "0.36.4", "@effect/tsgo-linux-x64": "0.36.4", "@effect/tsgo-win32-arm64": "0.36.4", "@effect/tsgo-win32-x64": "0.36.4" }, "bin": { "effect-tsgo": "dist/effect-tsgo.cjs" } }, "sha512-fNmdUV6FgXnvIcG18AVS1SRXt7z9jsyBh5CKuJYmTsE+DgbLaWF0CevKHx7hQPcidDWRVmdJYfU66Jvc/ZEt6w=="],
|
||||
|
||||
"@effect/tsgo-darwin-arm64": ["@effect/tsgo-darwin-arm64@0.36.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oOcWdKQucNch6G4CvidHEzmfgeKEPMvFcz+DXuKGonBLwVHX+i1VYGbZwccEOoiitOg7eWVl4e8S0qKMXg3/gg=="],
|
||||
|
||||
"@effect/tsgo-darwin-x64": ["@effect/tsgo-darwin-x64@0.36.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-4QZh0n/nUoTI7sp4o7gDEviJ/jXH+IFTVqIyQ2w2fqqPoOvaGl9bVoR/2f9j/67hvY4DHcXKaccpmyoRYhaYsw=="],
|
||||
|
||||
"@effect/tsgo-linux-arm": ["@effect/tsgo-linux-arm@0.36.4", "", { "os": "linux", "cpu": "arm" }, "sha512-10oTUnOK/RCfmTMFvMndNIfk61uviyYBRpbxhDd/SiU6DYCmWlnZ/xhGmgyj5uiysOiFNsp3NKxT0d9y4uAz8g=="],
|
||||
|
||||
"@effect/tsgo-linux-arm64": ["@effect/tsgo-linux-arm64@0.36.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-aOv+wVbZWgBt9PGXPA874qgw151GrzkWqaswe+4TXjhdw/M4i35PCUH5T+uaQj3FnQ1NWiPqHIGfNBRjf3xG6w=="],
|
||||
|
||||
"@effect/tsgo-linux-x64": ["@effect/tsgo-linux-x64@0.36.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+BuwiG5d8dLNrrN1PB5GA3gRkBwvlUYmMkm/XkttaLnppp9jePFkB8CgAiNV8j290q7BVO2nQjao5aPkVwmluw=="],
|
||||
|
||||
"@effect/tsgo-win32-arm64": ["@effect/tsgo-win32-arm64@0.36.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-HsW90wbMWOeorUeNMdTqTcMZIDcCXdkeFGXTUkx9K1R21fXSw+QEdPtTmEyKsRMXEH4vqek/HQa0yMkUT0CJzA=="],
|
||||
|
||||
"@effect/tsgo-win32-x64": ["@effect/tsgo-win32-x64@0.36.4", "", { "os": "win32", "cpu": "x64" }, "sha512-q4j1q0ESYxxookjA0ECz/84DEH2MsP51K0aZb3uXKfxagHDASPoWDcVu4BJTcwrK5kdZzvw9QrCPjAXe9WZqng=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||
@@ -1152,7 +1166,7 @@
|
||||
|
||||
"@types/bonjour": ["@types/bonjour@3.5.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
@@ -1426,7 +1440,7 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
@@ -1942,7 +1956,7 @@
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
|
||||
"ini": ["ini@2.0.0", "", {}, "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
@@ -2046,8 +2060,6 @@
|
||||
|
||||
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
||||
|
||||
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
|
||||
|
||||
"latest-version": ["latest-version@7.0.0", "", { "dependencies": { "package-json": "^8.1.0" } }, "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg=="],
|
||||
|
||||
"launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="],
|
||||
@@ -2856,8 +2868,6 @@
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
|
||||
@@ -2940,7 +2950,7 @@
|
||||
|
||||
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
||||
|
||||
"uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
|
||||
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"value-equal": ["value-equal@1.0.1", "", {}, "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="],
|
||||
|
||||
@@ -3052,9 +3062,9 @@
|
||||
|
||||
"@docusaurus/utils/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
|
||||
"@effect-view/example/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.102", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.102" } }, "sha512-DXXVt+oOpukqI3lEaGvvXQxSobzIy3lAsIQvlpVK+MuYBm1o4FHZrHKbrOFItDUvSR1t0kiTncFPRi01YaJUmA=="],
|
||||
"@effect-view/example/@effect/platform-browser": ["@effect/platform-browser@4.0.0-rc.109", "", { "peerDependencies": { "effect": "^4.0.0-rc.109" } }, "sha512-63/hM2dCh0HQb7sRkAhAoZjO4swjRD2vT/eUrs8nXA0LzJMz52ChPsxdnL1D0yg+ZeXtMHQM0MFJxITJXjv+EA=="],
|
||||
|
||||
"@effect-view/example/effect": ["effect@4.0.0-beta.102", "", { "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-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w=="],
|
||||
"@effect-view/example/effect": ["effect@4.0.0-rc.109", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.4" } }, "sha512-6ubcOCtfdbmFO5+vgcT2HsTw5s+n3aMUj4eAIbVpUxP7+VYCwXxxcBHgiWgizOrGO1eGmuOBFek3mM0dFcwaWA=="],
|
||||
|
||||
"@effect-view/vite-plugin/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
@@ -3122,11 +3132,11 @@
|
||||
|
||||
"dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
|
||||
|
||||
"effect-view/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.102", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.102" } }, "sha512-DXXVt+oOpukqI3lEaGvvXQxSobzIy3lAsIQvlpVK+MuYBm1o4FHZrHKbrOFItDUvSR1t0kiTncFPRi01YaJUmA=="],
|
||||
"effect-view/@effect/platform-browser": ["@effect/platform-browser@4.0.0-rc.109", "", { "peerDependencies": { "effect": "^4.0.0-rc.109" } }, "sha512-63/hM2dCh0HQb7sRkAhAoZjO4swjRD2vT/eUrs8nXA0LzJMz52ChPsxdnL1D0yg+ZeXtMHQM0MFJxITJXjv+EA=="],
|
||||
|
||||
"effect-view/effect": ["effect@4.0.0-beta.102", "", { "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-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w=="],
|
||||
"effect-view/effect": ["effect@4.0.0-rc.109", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.4" } }, "sha512-6ubcOCtfdbmFO5+vgcT2HsTw5s+n3aMUj4eAIbVpUxP7+VYCwXxxcBHgiWgizOrGO1eGmuOBFek3mM0dFcwaWA=="],
|
||||
|
||||
"effect-view/effect-lens": ["effect-lens@2.0.1-beta.102", "", { "peerDependencies": { "effect": "4.0.0-beta.102" } }, "sha512-YQSPI/r42Do/24X+RurTd4BLV2EdxG4LcgySpR4Pt7W2Z+wCmoBlSAV+B7rs2lTpqOny3kKTLnn9tbpfQVzSAA=="],
|
||||
"effect-view/effect-lens": ["effect-lens@2.0.1-rc.109", "", { "peerDependencies": { "effect": "4.0.0-rc.109" } }, "sha512-rMluFV/QGvLyXWoLu7YGaEUus/E40LeUXB0bAnzR3yvF1euch0g/gVISYuGlu+mvUahzBEH//+QNi45/0IwfTQ=="],
|
||||
|
||||
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
@@ -3142,8 +3152,6 @@
|
||||
|
||||
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"global-dirs/ini": ["ini@2.0.0", "", {}, "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA=="],
|
||||
|
||||
"got/@sindresorhus/is": ["@sindresorhus/is@5.6.0", "", {}, "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g=="],
|
||||
|
||||
"hpack.js/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
@@ -3338,8 +3346,6 @@
|
||||
|
||||
"sitemap/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
|
||||
|
||||
"sockjs/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
+4
-3
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@effect-view/monorepo",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"packageManager": "bun@1.4.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "effect-tsgo patch --typescript --no-oxlint",
|
||||
"lint:tsc": "turbo lint:tsc",
|
||||
"lint:biome": "turbo lint:biome",
|
||||
"test": "turbo test",
|
||||
@@ -17,8 +18,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.5",
|
||||
"@effect/language-service": "^0.87.1",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@effect/tsgo": "0.36.4",
|
||||
"@types/bun": "^1.4.0",
|
||||
"npm-check-updates": "^23.0.0",
|
||||
"npm-sort": "^0.0.4",
|
||||
"turbo": "^2.10.7",
|
||||
|
||||
@@ -254,6 +254,13 @@ Use `Form.useOptionalInput` when the encoded field is an Effect `Option`. It
|
||||
returns `value` and `setValue` together with `enabled` and `setEnabled`, making
|
||||
it suitable for an optional input that the user can toggle on and off.
|
||||
|
||||
Use `Form.useStatus` for displayed form status. It debounces short-lived state
|
||||
changes, avoiding flicker in pending indicators.
|
||||
|
||||
```tsx
|
||||
const { isValidating, isCommitting, canCommit } = yield* Form.useStatus(form)
|
||||
```
|
||||
|
||||
`useInput` and `useOptionalInput` can be used directly, but they are primarily
|
||||
building blocks for your own reusable input components. A component library
|
||||
can wrap them to consistently handle labels, validation issues, validating
|
||||
@@ -299,11 +306,8 @@ export const TextFieldFormInputView = Component.make(
|
||||
>,
|
||||
) {
|
||||
const input = yield* Form.useInput(props.form, props)
|
||||
const [issues, isValidating, isCommitting] = yield* View.useAll([
|
||||
props.form.issues,
|
||||
props.form.isValidating,
|
||||
props.form.isCommitting,
|
||||
])
|
||||
const [issues] = yield* View.useAll([props.form.issues])
|
||||
const { isValidating, isCommitting } = yield* Form.useStatus(props.form)
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="1">
|
||||
@@ -351,8 +355,9 @@ They expose the schema pipeline as reactive Lenses and Views:
|
||||
| `isCommitting` | Whether a mutation or target write is in progress. |
|
||||
|
||||
The form model itself is Effect code. Effect View adds the React-facing hooks,
|
||||
including `Form.useInput` and `Form.useOptionalInput`, while your own component
|
||||
library remains responsible for rendering controls and layout.
|
||||
including `Form.useInput`, `Form.useOptionalInput`, and `Form.useStatus`, while
|
||||
your own component library remains responsible for rendering controls and
|
||||
layout.
|
||||
|
||||
## Example: local date input, UTC domain value
|
||||
|
||||
@@ -415,7 +420,7 @@ the mutation receives UTC:
|
||||
|
||||
```tsx
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Component, Form, MutationForm, View } from "effect-view"
|
||||
import { Component, Form, MutationForm } from "effect-view"
|
||||
import { DateTimeUtcFromZonedInput } from "./DateTimeUtcFromZonedInput"
|
||||
|
||||
const AppointmentSchema = Schema.Struct({
|
||||
@@ -439,7 +444,7 @@ const AppointmentView = Component.make("Appointment")(function* () {
|
||||
)
|
||||
|
||||
const startsAt = yield* Form.useInput(startsAtField)
|
||||
const [canCommit] = yield* View.useAll([form.canCommit])
|
||||
const { canCommit } = yield* Form.useStatus(form)
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
return (
|
||||
|
||||
@@ -469,24 +469,59 @@ Prefer regular React state for simple, component-local UI concerns. Use
|
||||
`Lens`/`View` when state needs Effect integration, subscriptions, focusing, or
|
||||
sharing. The [State Management guide](./state-management) covers that model.
|
||||
|
||||
## Provide services to a subtree
|
||||
## Provide services to a component
|
||||
|
||||
Use `Component.useLayer` when only one Effect View subtree needs extra
|
||||
services. It builds the layer in a scope and returns the resulting Effect
|
||||
context:
|
||||
Use `Component.provide` to give a component a static layer:
|
||||
|
||||
```tsx title="src/GreetingPageView.tsx"
|
||||
import { Effect } from "effect"
|
||||
```tsx title="src/GreetingViewLive.tsx"
|
||||
import { Component } from "effect-view"
|
||||
import { GreetingView } from "./GreetingView"
|
||||
import { GreetingService } from "./GreetingService"
|
||||
|
||||
export const GreetingViewLive = Component.provide(
|
||||
GreetingView,
|
||||
GreetingService.layer,
|
||||
)
|
||||
```
|
||||
|
||||
The pipeline form is equivalent:
|
||||
|
||||
```tsx
|
||||
export const GreetingViewLive = GreetingView.pipe(
|
||||
Component.provide(GreetingService.layer),
|
||||
)
|
||||
```
|
||||
|
||||
`Component.provide` builds the layer for each mounted component instance and
|
||||
releases its resources when that instance unmounts.
|
||||
|
||||
The layer is constructed during render and must acquire synchronously unless
|
||||
the component is enhanced with [`Async.async`](./async).
|
||||
|
||||
## Provide services to a subtree
|
||||
|
||||
Use `Component.useLayer` to provide a layer from a component to its Effect View
|
||||
subcomponents. This is also the right choice when the layer depends on props,
|
||||
state, or context read inside React: memoize the layer and provide its context
|
||||
manually.
|
||||
|
||||
```tsx title="src/GreetingPageView.tsx"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Component } from "effect-view"
|
||||
import * as React from "react"
|
||||
import { GreetingView } from "./GreetingView"
|
||||
import { GreetingService } from "./GreetingService"
|
||||
|
||||
export const GreetingPageView = Component.make("GreetingPage")(
|
||||
function* () {
|
||||
const context = yield* Component.useLayer(GreetingService.layer)
|
||||
const Greeting = yield* GreetingView.use.pipe(
|
||||
Effect.provide(context),
|
||||
function* (props: { readonly greeting: string }) {
|
||||
const layer = React.useMemo(
|
||||
() => Layer.succeed(GreetingService, {
|
||||
greet: (name) => `${props.greeting}, ${name}`,
|
||||
}),
|
||||
[props.greeting],
|
||||
)
|
||||
const context = yield* Component.useLayer(layer)
|
||||
const Greeting = yield* Effect.provide(GreetingView.use, context)
|
||||
|
||||
return <Greeting name="Effect" />
|
||||
},
|
||||
|
||||
@@ -49,6 +49,6 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
"node": ">=24.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("Subscribable", () => {
|
||||
await Effect.runPromise(Lens.set(right, "b"))
|
||||
await waitFor(() => expect(values).toContainEqual([2, "b"]))
|
||||
|
||||
Fiber.interruptFork(collector)
|
||||
await Effect.runPromise(Fiber.interrupt(collector))
|
||||
})
|
||||
|
||||
it("useAll returns the latest values and rerenders when any input changes", async () => {
|
||||
|
||||
@@ -25,12 +25,12 @@ data modeling into React 19 without replacing React's component model. Yield
|
||||
Effects and services from a component body, let scopes follow the React
|
||||
lifecycle, and return ordinary JSX.
|
||||
|
||||
> **Effect v4 beta:** Effect View is built for the Effect v4 beta release. If
|
||||
> **Effect v4 RC:** Effect View is built for the Effect v4 release candidate. If
|
||||
> your application uses Effect v3, use the legacy
|
||||
> [`effect-fc` package](https://www.npmjs.com/package/effect-fc) instead.
|
||||
|
||||
```bash
|
||||
npm install effect-view effect@beta react
|
||||
npm install effect-view effect@rc react
|
||||
```
|
||||
|
||||
Effect View does not depend on `react-dom`. Install the renderer used by your
|
||||
@@ -127,7 +127,7 @@ The complete documentation is available at **[thila.dev/effect-view](https://thi
|
||||
## Requirements
|
||||
|
||||
- React 19.2 or newer
|
||||
- **Effect v4 beta** (`effect@beta`)
|
||||
- **Effect v4 RC** (`effect@rc`)
|
||||
- TypeScript and `@types/react` for TypeScript projects
|
||||
|
||||
Effect View is renderer-independent and does not require `react-dom`.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "effect-view",
|
||||
"description": "Write React function components with Effect",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"./README.md",
|
||||
"./dist"
|
||||
"./src/**/*.ts",
|
||||
"./dist/**/*.js",
|
||||
"./dist/**/*.js.map",
|
||||
"./dist/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -29,6 +32,10 @@
|
||||
"types": "./dist/Form.d.ts",
|
||||
"default": "./dist/Form.js"
|
||||
},
|
||||
"./I18n": {
|
||||
"types": "./dist/I18n.d.ts",
|
||||
"default": "./dist/I18n.js"
|
||||
},
|
||||
"./Lens": {
|
||||
"types": "./dist/Lens.d.ts",
|
||||
"default": "./dist/Lens.js"
|
||||
@@ -37,6 +44,10 @@
|
||||
"types": "./dist/LensForm.d.ts",
|
||||
"default": "./dist/LensForm.js"
|
||||
},
|
||||
"./Locale": {
|
||||
"types": "./dist/Locale.d.ts",
|
||||
"default": "./dist/Locale.js"
|
||||
},
|
||||
"./Memoized": {
|
||||
"types": "./dist/Memoized.d.ts",
|
||||
"default": "./dist/Memoized.js"
|
||||
@@ -97,19 +108,19 @@
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-browser": "4.0.0-beta.102",
|
||||
"@effect/platform-browser": "4.0.0-rc.109",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"effect": "4.0.0-beta.102",
|
||||
"effect": "4.0.0-rc.109",
|
||||
"jsdom": "^26.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
"effect": "4.0.0-beta.102",
|
||||
"effect": "4.0.0-rc.109",
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"effect-lens": "^2.0.1-beta.102"
|
||||
"effect-lens": "2.0.1-rc.109"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,56 @@ import * as ScopeRegistry from "./ScopeRegistry.js"
|
||||
|
||||
class ValueService extends Context.Service<ValueService, { readonly value: string }>()("ValueService") {}
|
||||
|
||||
class ParentService extends Context.Service<ParentService, { readonly prefix: string }>()("ParentService") {}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("Component", () => {
|
||||
it("provides a layer once per mounted component instance", async () => {
|
||||
const setup = vi.fn()
|
||||
const cleanup = vi.fn()
|
||||
const serviceLayer = Layer.effect(ValueService, Effect.gen(function*() {
|
||||
const parent = yield* ParentService
|
||||
yield* Effect.sync(setup)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(cleanup))
|
||||
return { value: `${parent.prefix} value` }
|
||||
}))
|
||||
const runtime = ReactRuntime.make(Layer.succeed(ParentService, { prefix: "provided" }))
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
const Probe = Component.makeUntraced("ProvidedServiceProbe")(function*() {
|
||||
const service = yield* ValueService
|
||||
return <div>{service.value}</div>
|
||||
}).pipe(
|
||||
Component.provide(serviceLayer),
|
||||
Component.withContext(runtime.context),
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
expect(await screen.findByText("provided value")).toBeTruthy()
|
||||
expect(setup).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
expect(await screen.findByText("provided value")).toBeTruthy()
|
||||
expect(setup).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.unmount()
|
||||
await waitFor(() => expect(cleanup).toHaveBeenCalledTimes(1))
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("does not rerun useOnMount across rerenders after Strict Mode initialization", async () => {
|
||||
const onMount = vi.fn(() => Effect.succeed("mounted"))
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
|
||||
@@ -607,6 +607,57 @@ export const withOptions: {
|
||||
Object.getPrototypeOf(self),
|
||||
))
|
||||
|
||||
export declare namespace provide {
|
||||
export type Result<T extends Component.Any, ROut, E, RIn> = (
|
||||
& Omit<T, keyof Component.AsComponent<T>>
|
||||
& Component<
|
||||
Component.Props<T>,
|
||||
Component.Success<T>,
|
||||
Component.Error<T> | E,
|
||||
Exclude<Component.Context<T>, ROut> | RIn,
|
||||
Component.Function<T>
|
||||
>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a layer to an Effect View component.
|
||||
*
|
||||
* The layer is built once for each mounted instance of the returned component and
|
||||
* is released when that instance unmounts. Any services still required by the
|
||||
* layer remain requirements of the returned component.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const TodosViewLive = Component.provide(TodosView, TodosService.Default)
|
||||
*
|
||||
* // Equivalent pipeline form
|
||||
* const TodosViewLive = TodosView.pipe(
|
||||
* Component.provide(TodosService.Default),
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export const provide: {
|
||||
<ROut, E, RIn>(
|
||||
layer: Layer.Layer<ROut, E, RIn>,
|
||||
): <T extends Component.Any>(self: T) => provide.Result<T, ROut, E, RIn>
|
||||
<T extends Component.Any, ROut, E, RIn>(
|
||||
self: T,
|
||||
layer: Layer.Layer<ROut, E, RIn>,
|
||||
): provide.Result<T, ROut, E, RIn>
|
||||
} = Function.dual(2, <T extends Component.Any, ROut, E, RIn>(
|
||||
self: T,
|
||||
layer: Layer.Layer<ROut, E, RIn>,
|
||||
): provide.Result<T, ROut, E, RIn> => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self, {
|
||||
body: (props: Component.Props<T>) => Effect.flatMap(
|
||||
useLayer(layer),
|
||||
context => Effect.provide(self.body(props), context),
|
||||
),
|
||||
}),
|
||||
Object.getPrototypeOf(self),
|
||||
))
|
||||
|
||||
/**
|
||||
* Wraps an Effect View Component and converts it into a standard React function component,
|
||||
* serving as an **entrypoint** into an Effect View component hierarchy.
|
||||
@@ -1047,7 +1098,7 @@ export const useCallbackPromise = Effect.fnUntraced(function* <Args extends unkn
|
||||
return React.useCallback((...args: Args) => Effect.runPromiseWith(contextRef.current)(f(...args)), deps)
|
||||
})
|
||||
|
||||
export declare namespace useContext {
|
||||
export declare namespace useLayer {
|
||||
export interface Options extends useOnChange.Options {}
|
||||
}
|
||||
|
||||
@@ -1112,7 +1163,7 @@ export declare namespace useContext {
|
||||
*/
|
||||
export const useLayer = <ROut, E, RIn>(
|
||||
layer: Layer.Layer<ROut, E, RIn>,
|
||||
options?: useContext.Options,
|
||||
options?: useLayer.Options,
|
||||
): Effect.Effect<Context.Context<ROut>, E, RIn | Scope.Scope> => useOnChange(() => Effect.flatMap(
|
||||
Effect.context<RIn>(),
|
||||
context => Layer.build(Layer.provide(layer, Layer.succeedContext(context))),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
|
||||
import type * as React from "react"
|
||||
import * as React from "react"
|
||||
import * as Component from "./Component.js"
|
||||
import * as Lens from "./Lens.js"
|
||||
import * as View from "./View.js"
|
||||
@@ -156,6 +156,47 @@ export const focusChunkAt: {
|
||||
})
|
||||
|
||||
|
||||
export declare namespace useStatus {
|
||||
export interface Options {
|
||||
/**
|
||||
* The debounce interval applied to changes in the returned status.
|
||||
*
|
||||
* Defaults to 250 milliseconds.
|
||||
*/
|
||||
readonly debounce?: Duration.Input
|
||||
}
|
||||
|
||||
export interface Success {
|
||||
readonly isValidating: boolean
|
||||
readonly isCommitting: boolean
|
||||
readonly canCommit: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to form status flags for presentation.
|
||||
*
|
||||
* Status changes are debounced to avoid transient UI feedback. The underlying
|
||||
* form state is not changed.
|
||||
*/
|
||||
export const useStatus = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
|
||||
form: Form<P, A, I, ER, EW>,
|
||||
options?: useStatus.Options,
|
||||
): Effect.fn.Return<useStatus.Success, ER, Scope.Scope> {
|
||||
const views = React.useMemo(() => {
|
||||
const debounce = options?.debounce ?? "250 millis"
|
||||
|
||||
return [
|
||||
View.mapStream(form.isValidating, Stream.debounce(debounce)),
|
||||
View.mapStream(form.isCommitting, Stream.debounce(debounce)),
|
||||
View.mapStream(form.canCommit, Stream.debounce(debounce)),
|
||||
] as const
|
||||
}, [form, options?.debounce])
|
||||
|
||||
const [isValidating, isCommitting, canCommit] = yield* View.useAll(views)
|
||||
return { isValidating, isCommitting, canCommit }
|
||||
})
|
||||
|
||||
export namespace useInput {
|
||||
export interface Options {
|
||||
readonly debounce?: Duration.Input
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as I18n from "./I18n.js"
|
||||
import * as Locale from "./Locale.js"
|
||||
|
||||
|
||||
const Messages = I18n.contract({
|
||||
title: I18n.text(),
|
||||
welcome: I18n.message<{ readonly name: string }>(),
|
||||
})
|
||||
|
||||
const English = I18n.catalog(Messages, {
|
||||
title: "Welcome",
|
||||
welcome: ({ name }) => `Hello, ${name}!`,
|
||||
})
|
||||
|
||||
const French = I18n.catalog(Messages, {
|
||||
title: "Bienvenue",
|
||||
welcome: ({ name }) => `Bonjour, ${name}!`,
|
||||
})
|
||||
|
||||
const AppI18n = I18n.make({
|
||||
contract: Messages,
|
||||
fallback: "en",
|
||||
loaders: {
|
||||
en: () => Effect.succeed(English),
|
||||
fr: () => Effect.succeed(French),
|
||||
},
|
||||
})
|
||||
|
||||
const _assertTypeSafety = (service: I18n.I18nService<typeof Messages, "en" | "fr">) => Effect.gen(function*() {
|
||||
yield* service.translate("title")
|
||||
yield* service.translate("welcome", { name: "Ada" })
|
||||
|
||||
// @ts-expect-error Unknown message keys are rejected.
|
||||
yield* service.translate("missing")
|
||||
// @ts-expect-error Parameterized messages require their parameters.
|
||||
yield* service.translate("welcome")
|
||||
// @ts-expect-error Parameters must match the message contract.
|
||||
yield* service.translate("welcome", { userId: 1 })
|
||||
})
|
||||
|
||||
const run = <A, E>(
|
||||
effect: Effect.Effect<A, E, I18n.I18nService<typeof Messages, "en" | "fr">>,
|
||||
language: string,
|
||||
) => effect.pipe(
|
||||
Effect.provide(
|
||||
AppI18n.layer.pipe(
|
||||
Layer.provide(Layer.succeed(Locale.Locale, {
|
||||
language,
|
||||
languages: [language],
|
||||
})),
|
||||
),
|
||||
),
|
||||
Effect.runPromise,
|
||||
)
|
||||
|
||||
describe("I18n", () => {
|
||||
it("selects the exact preferred locale", async () => {
|
||||
const result = await run(Effect.gen(function*() {
|
||||
const i18n = yield* AppI18n.service
|
||||
return [i18n.locale, yield* i18n.translate("title"), yield* i18n.translate("welcome", { name: "Ada" })]
|
||||
}), "fr")
|
||||
|
||||
expect(result).toEqual(["fr", "Bienvenue", "Bonjour, Ada!"])
|
||||
})
|
||||
|
||||
it("matches a base language before using the fallback", async () => {
|
||||
const result = await run(Effect.gen(function*() {
|
||||
const i18n = yield* AppI18n.service
|
||||
return [i18n.locale, yield* i18n.translate("title")]
|
||||
}), "fr-CA")
|
||||
|
||||
expect(result).toEqual(["fr", "Bienvenue"])
|
||||
})
|
||||
|
||||
it("uses the configured fallback when no locale matches", async () => {
|
||||
const result = await run(Effect.gen(function*() {
|
||||
const i18n = yield* AppI18n.service
|
||||
return [i18n.locale, yield* i18n.translate("title")]
|
||||
}), "de")
|
||||
|
||||
expect(result).toEqual(["en", "Welcome"])
|
||||
})
|
||||
|
||||
it("supports asynchronous code-split loaders", async () => {
|
||||
const i18n = I18n.make({
|
||||
contract: Messages,
|
||||
fallback: "en",
|
||||
loaders: {
|
||||
en: () => Effect.promise(async () => English),
|
||||
fr: () => Effect.promise(async () => French),
|
||||
},
|
||||
})
|
||||
|
||||
const result = await Effect.gen(function*() {
|
||||
const service = yield* i18n.service
|
||||
return yield* service.translate("welcome", { name: "Grace" })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
i18n.layer.pipe(
|
||||
Layer.provide(Layer.succeed(Locale.Locale, {
|
||||
language: "en",
|
||||
languages: ["en"],
|
||||
})),
|
||||
),
|
||||
),
|
||||
Effect.runPromise,
|
||||
)
|
||||
|
||||
expect(result).toBe("Hello, Grace!")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import * as Locale from "./Locale.js"
|
||||
|
||||
|
||||
export interface MessageDefinition<Params = never> {
|
||||
readonly _tag: "MessageDefinition"
|
||||
readonly _params?: Params
|
||||
}
|
||||
|
||||
export type Contract = Readonly<Record<string, MessageDefinition<unknown>>>
|
||||
|
||||
export type MessageParams<Definition> = Definition extends MessageDefinition<infer Params>
|
||||
? Params
|
||||
: never
|
||||
|
||||
export type MessageKey<Messages extends Contract> = keyof Messages & string
|
||||
|
||||
export type Translation<Definition> = [MessageParams<Definition>] extends [never]
|
||||
? string
|
||||
: (params: MessageParams<Definition>) => string
|
||||
|
||||
export type Catalog<Messages extends Contract> = {
|
||||
readonly [Key in keyof Messages]: Translation<Messages[Key]>
|
||||
}
|
||||
|
||||
export const text = (): MessageDefinition => ({
|
||||
_tag: "MessageDefinition",
|
||||
})
|
||||
|
||||
export const message = <Params>(): MessageDefinition<Params> => ({
|
||||
_tag: "MessageDefinition",
|
||||
})
|
||||
|
||||
export const contract = <const Messages extends Contract>(
|
||||
messages: Messages,
|
||||
): Messages => messages
|
||||
|
||||
export const catalog = <Messages extends Contract>(
|
||||
_contract: Messages,
|
||||
translations: Catalog<Messages>,
|
||||
): Catalog<Messages> => translations
|
||||
|
||||
export class CatalogLoadError extends Error {
|
||||
readonly _tag = "CatalogLoadError"
|
||||
|
||||
constructor(
|
||||
readonly locale: string,
|
||||
readonly cause: unknown,
|
||||
) {
|
||||
super(`Unable to load the ${locale} translation catalog`)
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingMessageError extends Error {
|
||||
readonly _tag = "MissingMessageError"
|
||||
|
||||
constructor(
|
||||
readonly locale: string,
|
||||
readonly key: string,
|
||||
) {
|
||||
super(`Missing translation for ${key} in locale ${locale}`)
|
||||
}
|
||||
}
|
||||
|
||||
export interface I18nService<Messages extends Contract, Language extends string> {
|
||||
readonly locale: Language
|
||||
readonly translate: <Key extends MessageKey<Messages>>(
|
||||
...args: TranslateArguments<Messages, Key>
|
||||
) => Effect.Effect<string, MissingMessageError>
|
||||
}
|
||||
|
||||
export type TranslateArguments<
|
||||
Messages extends Contract,
|
||||
Key extends MessageKey<Messages>,
|
||||
> = [MessageParams<Messages[Key]>] extends [never]
|
||||
? [key: Key]
|
||||
: [key: Key, params: MessageParams<Messages[Key]>]
|
||||
|
||||
export type Loader<Messages extends Contract> = () => Effect.Effect<Catalog<Messages>, unknown>
|
||||
|
||||
export type LoaderMap<Messages extends Contract> = Readonly<Record<string, Loader<Messages>>>
|
||||
|
||||
export interface I18n<Messages extends Contract, Language extends string> {
|
||||
readonly service: Context.Service<
|
||||
I18nService<Messages, Language>,
|
||||
I18nService<Messages, Language>
|
||||
>
|
||||
readonly layer: Layer.Layer<
|
||||
I18nService<Messages, Language>,
|
||||
CatalogLoadError,
|
||||
Locale.Locale
|
||||
>
|
||||
}
|
||||
|
||||
export interface MakeOptions<Messages extends Contract, Loaders extends LoaderMap<Messages>> {
|
||||
readonly contract: Messages
|
||||
readonly loaders: Loaders
|
||||
readonly fallback: keyof Loaders & string
|
||||
/** Optional stable identifier when an application creates multiple translators. */
|
||||
readonly key?: string
|
||||
}
|
||||
|
||||
let nextServiceId = 0
|
||||
|
||||
const baseLanguage = (language: string): string => language.toLowerCase().split("-", 1)[0] ?? ""
|
||||
|
||||
const resolveLanguage = <Loaders extends Readonly<Record<string, unknown>>>(
|
||||
preferred: readonly string[],
|
||||
loaders: Loaders,
|
||||
fallback: keyof Loaders & string,
|
||||
): keyof Loaders & string => {
|
||||
const available = Object.keys(loaders) as Array<keyof Loaders & string>
|
||||
|
||||
for (const language of preferred) {
|
||||
const exact = available.find(candidate => candidate.toLowerCase() === language.toLowerCase())
|
||||
if (exact !== undefined) return exact
|
||||
|
||||
const base = baseLanguage(language)
|
||||
const languageOnly = available.find(candidate => baseLanguage(candidate) === base)
|
||||
if (languageOnly !== undefined) return languageOnly
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
export const make = <
|
||||
const Messages extends Contract,
|
||||
const Loaders extends LoaderMap<Messages>,
|
||||
>(
|
||||
options: MakeOptions<Messages, Loaders>,
|
||||
): I18n<Messages, keyof Loaders & string> => {
|
||||
type Language = keyof Loaders & string
|
||||
|
||||
const service = Context.Service<I18nService<Messages, Language>>(
|
||||
options.key ?? `@effect-view/I18n/I18n/${nextServiceId++}`,
|
||||
)
|
||||
|
||||
const layer = Layer.effect(
|
||||
service,
|
||||
Effect.gen(function*() {
|
||||
const locale = yield* Locale.Locale
|
||||
const language = resolveLanguage(locale.languages, options.loaders, options.fallback) as Language
|
||||
const load = options.loaders[language]
|
||||
|
||||
const translations = yield* load().pipe(
|
||||
Effect.mapError(error => new CatalogLoadError(language, error)),
|
||||
)
|
||||
|
||||
return {
|
||||
locale: language,
|
||||
translate: <Key extends MessageKey<Messages>>(
|
||||
...args: TranslateArguments<Messages, Key>
|
||||
): Effect.Effect<string, MissingMessageError> => {
|
||||
const key = args[0]
|
||||
const translation = translations[key]
|
||||
|
||||
if (translation === undefined) {
|
||||
return Effect.fail(new MissingMessageError(language, key))
|
||||
}
|
||||
|
||||
if (typeof translation === "function") {
|
||||
const format = translation as (params: MessageParams<Messages[Key]>) => string
|
||||
return Effect.sync(() => format(args[1] as MessageParams<Messages[Key]>))
|
||||
}
|
||||
|
||||
return Effect.succeed(translation as string)
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return { service, layer }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Effect } from "effect"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Locale from "./Locale.js"
|
||||
|
||||
|
||||
const readLocale = Effect.gen(function*() {
|
||||
const locale = yield* Locale.Locale
|
||||
return locale
|
||||
})
|
||||
|
||||
describe("Locale", () => {
|
||||
it("reads the ordered browser language preferences", async () => {
|
||||
Object.defineProperty(globalThis.navigator, "languages", {
|
||||
configurable: true,
|
||||
value: ["fr-CA", "fr", "en-US"],
|
||||
})
|
||||
Object.defineProperty(globalThis.navigator, "language", {
|
||||
configurable: true,
|
||||
value: "fr-CA",
|
||||
})
|
||||
|
||||
const locale = await Effect.runPromise(
|
||||
readLocale.pipe(Effect.provide(Locale.layerBrowser())),
|
||||
)
|
||||
|
||||
expect(locale).toEqual({
|
||||
language: "fr-CA",
|
||||
languages: ["fr-CA", "fr", "en-US"],
|
||||
})
|
||||
})
|
||||
|
||||
it("uses the singular language when the preference list is empty", async () => {
|
||||
Object.defineProperty(globalThis.navigator, "languages", {
|
||||
configurable: true,
|
||||
value: [],
|
||||
})
|
||||
Object.defineProperty(globalThis.navigator, "language", {
|
||||
configurable: true,
|
||||
value: "de-DE",
|
||||
})
|
||||
|
||||
const locale = await Effect.runPromise(
|
||||
readLocale.pipe(Effect.provide(Locale.layerBrowser())),
|
||||
)
|
||||
|
||||
expect(locale).toEqual({
|
||||
language: "de-DE",
|
||||
languages: ["de-DE"],
|
||||
})
|
||||
})
|
||||
|
||||
it("supports a fallback when browser APIs are unavailable", async () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
|
||||
const locale = await Effect.runPromise(
|
||||
readLocale.pipe(Effect.provide(Locale.layerBrowser({ fallback: "en-GB" }))),
|
||||
)
|
||||
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: originalNavigator,
|
||||
})
|
||||
|
||||
expect(locale).toEqual({
|
||||
language: "en-GB",
|
||||
languages: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
|
||||
|
||||
export interface LocaleService {
|
||||
/** The first preferred BCP 47 language tag, when one is available. */
|
||||
readonly language: string
|
||||
/** The user's preferred BCP 47 language tags, in preference order. */
|
||||
readonly languages: readonly string[]
|
||||
}
|
||||
|
||||
export class Locale extends Context.Service<Locale, LocaleService>()(
|
||||
"@effect-view/Locale/Locale",
|
||||
) {}
|
||||
|
||||
export interface BrowserOptions {
|
||||
/** Used when the layer is constructed outside a browser, such as during SSR. */
|
||||
readonly fallback?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the browser's preferred languages without accessing `navigator` at
|
||||
* module evaluation time. This keeps the module safe to import during SSR.
|
||||
*/
|
||||
export const fromBrowser = (
|
||||
options: BrowserOptions = {},
|
||||
): Effect.Effect<LocaleService> => Effect.sync(() => {
|
||||
const navigator = globalThis.navigator
|
||||
const languages = navigator?.languages.length > 0
|
||||
? [...navigator.languages]
|
||||
: navigator?.language
|
||||
? [navigator.language]
|
||||
: []
|
||||
|
||||
return {
|
||||
language: languages[0] ?? options.fallback ?? "en",
|
||||
languages,
|
||||
}
|
||||
})
|
||||
|
||||
/** Provides the user's browser language preferences as an Effect service. */
|
||||
export const layerBrowser = (
|
||||
options: BrowserOptions = {},
|
||||
): Layer.Layer<Locale> => Layer.effect(Locale, fromBrowser(options))
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Cause, Deferred, Effect, Option, type Scope } from "effect"
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Mutation from "./Mutation.js"
|
||||
import * as View from "./View.js"
|
||||
|
||||
|
||||
const runMutationTest = <A, E>(effect: Effect.Effect<A, E, Scope.Scope>) =>
|
||||
Effect.runPromise(Effect.scoped(effect))
|
||||
|
||||
const expectSuccessValue = <A, E>(state: { readonly result: AsyncResult.AsyncResult<A, E> }): A => {
|
||||
expect(AsyncResult.isSuccess(state.result)).toBe(true)
|
||||
|
||||
if (!AsyncResult.isSuccess(state.result))
|
||||
throw new Error(`Expected Success result, received ${state.result._tag}`)
|
||||
|
||||
return state.result.value
|
||||
}
|
||||
|
||||
describe("Mutation", () => {
|
||||
it("runs a mutation and exposes its latest completed state", async () => {
|
||||
const result = await runMutationTest(Effect.gen(function*() {
|
||||
const mutation = yield* Mutation.make({
|
||||
f: (key: number) => Effect.succeed(`value:${key}`),
|
||||
})
|
||||
|
||||
const final = yield* mutation.mutate(1)
|
||||
|
||||
return {
|
||||
isMutation: Mutation.isMutation(mutation),
|
||||
final,
|
||||
latestKey: yield* View.get(mutation.latestKey),
|
||||
state: yield* View.get(mutation.state),
|
||||
latestFinalState: yield* View.get(mutation.latestFinalState),
|
||||
fiber: yield* View.get(mutation.fiber),
|
||||
}
|
||||
}))
|
||||
|
||||
expect(result.isMutation).toBe(true)
|
||||
expect(result.final.key.value).toBe(1)
|
||||
expect(expectSuccessValue(result.final)).toBe("value:1")
|
||||
expect(result.latestKey).toEqual(Option.some(1))
|
||||
expect(result.state.key).toEqual(Option.some(1))
|
||||
expect(expectSuccessValue(result.state)).toBe("value:1")
|
||||
expect(result.latestFinalState).toEqual(Option.some(result.final))
|
||||
expect(result.fiber).toEqual(Option.none())
|
||||
})
|
||||
|
||||
it("records failures while retaining the previous successful value", async () => {
|
||||
const result = await runMutationTest(Effect.gen(function*() {
|
||||
let calls = 0
|
||||
const mutation = yield* Mutation.make({
|
||||
f: (_key: "save") => Effect.sync(() => {
|
||||
calls += 1
|
||||
return calls
|
||||
}).pipe(
|
||||
Effect.flatMap(call => call === 1
|
||||
? Effect.succeed("saved")
|
||||
: Effect.fail("could not save")),
|
||||
),
|
||||
})
|
||||
|
||||
yield* mutation.mutate("save")
|
||||
return yield* mutation.mutate("save")
|
||||
}))
|
||||
|
||||
expect(result.key.value).toBe("save")
|
||||
expect(AsyncResult.isFailure(result.result)).toBe(true)
|
||||
|
||||
if (!AsyncResult.isFailure(result.result))
|
||||
throw new Error(`Expected Failure result, received ${result.result._tag}`)
|
||||
|
||||
expect(result.result.cause).toEqual(Cause.fail("could not save"))
|
||||
expect(Option.isSome(result.result.previousSuccess)).toBe(true)
|
||||
|
||||
if (Option.isSome(result.result.previousSuccess))
|
||||
expect(result.result.previousSuccess.value.value).toBe("saved")
|
||||
})
|
||||
|
||||
it("mutateView returns a waiting state without waiting for completion", async () => {
|
||||
const result = await runMutationTest(Effect.gen(function*() {
|
||||
const deferred = yield* Deferred.make<string>()
|
||||
const mutation = yield* Mutation.make({
|
||||
f: (_key: string) => Deferred.await(deferred),
|
||||
})
|
||||
|
||||
const state = yield* mutation.mutateView("save")
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const pending = yield* View.get(state)
|
||||
const hasRunningFiber = Option.isSome(yield* View.get(mutation.fiber))
|
||||
|
||||
yield* Deferred.succeed(deferred, "saved")
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const final = yield* View.get(mutation.latestFinalState).pipe(Effect.flatMap(Effect.fromOption))
|
||||
|
||||
return { pending, hasRunningFiber, final }
|
||||
}))
|
||||
|
||||
expect(result.pending.key.value).toBe("save")
|
||||
expect(AsyncResult.isInitial(result.pending.result)).toBe(true)
|
||||
expect(result.pending.result.waiting).toBe(true)
|
||||
expect(result.hasRunningFiber).toBe(true)
|
||||
expect(result.final.key.value).toBe("save")
|
||||
expect(expectSuccessValue(result.final)).toBe("saved")
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Context, Effect, Equal, Exit, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
|
||||
import { Cause, type Context, Effect, Exit, type Fiber, Option, Pipeable, Predicate, PubSub, Ref, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
import * as Lens from "./Lens.js"
|
||||
import * as View from "./View.js"
|
||||
@@ -16,11 +16,26 @@ extends Pipeable.Pipeable {
|
||||
|
||||
readonly latestKey: View.View<Option.Option<K>>
|
||||
readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
|
||||
readonly state: View.View<AsyncResult.AsyncResult<A, E>>
|
||||
readonly latestFinalResult: View.View<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>
|
||||
readonly state: View.View<LatestMutationState<K, A, E>>
|
||||
readonly latestFinalState: View.View<Option.Option<FinalMutationState<K, A, E>>>
|
||||
|
||||
mutate(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>
|
||||
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>>
|
||||
mutate(key: K): Effect.Effect<FinalMutationState<K, A, E>>
|
||||
mutateView(key: K): Effect.Effect<View.View<MutationState<K, A, E>>>
|
||||
}
|
||||
|
||||
export interface LatestMutationState<out K, out A, out E = never> {
|
||||
readonly key: Option.Option<K>
|
||||
readonly result: AsyncResult.AsyncResult<A, E>
|
||||
}
|
||||
|
||||
export interface MutationState<out K, out A, out E = never> {
|
||||
readonly key: Option.Some<K>
|
||||
readonly result: AsyncResult.AsyncResult<A, E>
|
||||
}
|
||||
|
||||
export interface FinalMutationState<out K, out A, out E = never> {
|
||||
readonly key: Option.Some<K>
|
||||
readonly result: AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>
|
||||
}
|
||||
|
||||
export const isMutation = (u: unknown): u is Mutation<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
|
||||
@@ -36,20 +51,20 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
|
||||
|
||||
readonly latestKey: Lens.Lens<Option.Option<K>>,
|
||||
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
|
||||
readonly state: Lens.Lens<AsyncResult.AsyncResult<A, E>>,
|
||||
readonly latestFinalResult: Lens.Lens<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>,
|
||||
readonly state: Lens.Lens<LatestMutationState<K, A, E>>,
|
||||
readonly latestFinalState: Lens.Lens<Option.Option<FinalMutationState<K, A, E>>>,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
mutate(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
|
||||
mutate(key: K): Effect.Effect<FinalMutationState<K, A, E>> {
|
||||
return Lens.set(this.latestKey, Option.some(key)).pipe(
|
||||
Effect.andThen(this.start(key)),
|
||||
Effect.flatMap(state => this.watch(state)),
|
||||
Effect.provide(this.context),
|
||||
)
|
||||
}
|
||||
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>> {
|
||||
mutateView(key: K): Effect.Effect<View.View<MutationState<K, A, E>>> {
|
||||
return Lens.set(this.latestKey, Option.some(key)).pipe(
|
||||
Effect.andThen(this.start(key)),
|
||||
Effect.tap(state => Effect.forkScoped(this.watch(state))),
|
||||
@@ -58,54 +73,81 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
|
||||
}
|
||||
|
||||
start(key: K): Effect.Effect<
|
||||
View.View<AsyncResult.AsyncResult<A, E>>,
|
||||
View.View<MutationState<K, A, E>>,
|
||||
never,
|
||||
Scope.Scope | R
|
||||
> {
|
||||
return Effect.gen({ self: this }, function*() {
|
||||
const previous = yield* Lens.get(this.latestFinalResult)
|
||||
const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make<AsyncResult.AsyncResult<A, E>>(
|
||||
Option.getOrElse(previous, () => AsyncResult.initial(false))
|
||||
))
|
||||
const previous: MutationState<K, A, E> = Option.getOrElse(yield* Lens.get(this.latestFinalState), () => ({
|
||||
key: Option.some(key) as Option.Some<K>,
|
||||
result: AsyncResult.initial(),
|
||||
}))
|
||||
const state = yield* makeMutationStateLens(previous)
|
||||
|
||||
const fiber = yield* Effect.forkScoped(Effect.andThen(
|
||||
Lens.update(state, AsyncResult.match({
|
||||
onInitial: () => AsyncResult.initial(true),
|
||||
onSuccess: v => AsyncResult.success(v.value, {
|
||||
Lens.update<MutationState<K, A, E>, never, never, never, never>(
|
||||
state,
|
||||
previous => AsyncResult.match(previous.result, {
|
||||
onInitial: () => ({
|
||||
key: previous.key,
|
||||
result: AsyncResult.initial(true),
|
||||
}),
|
||||
onSuccess: result => ({
|
||||
key: previous.key,
|
||||
result: AsyncResult.success(result.value, {
|
||||
waiting: true,
|
||||
}),
|
||||
onFailure: v => AsyncResult.failure(v.cause, {
|
||||
}),
|
||||
onFailure: result => ({
|
||||
key: previous.key,
|
||||
result: AsyncResult.failure(result.cause, {
|
||||
waiting: true,
|
||||
previousSuccess: v.previousSuccess,
|
||||
})
|
||||
})),
|
||||
previousSuccess: result.previousSuccess,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
)),
|
||||
|
||||
Effect.onExit(this.f(key), exit => Lens.update(
|
||||
Effect.onExit(this.f(previous.key.value), exit => Effect.gen({ self: this }, function*() {
|
||||
const fiberId = yield* Effect.fiberId
|
||||
const fiber = yield* Lens.get(this.fiber)
|
||||
|
||||
if (Option.isSome(fiber) && fiberId === fiber.value.id)
|
||||
yield* Lens.set(this.fiber, Option.none())
|
||||
|
||||
const finalState = (yield* Lens.updateAndGet<MutationState<K, A, E>, never, never, never, never>(
|
||||
state,
|
||||
previous => Exit.match(exit, {
|
||||
onSuccess: v => AsyncResult.success(v),
|
||||
onFailure: c => AsyncResult.match(previous, {
|
||||
onInitial: () => AsyncResult.failure(c),
|
||||
onSuccess: v => AsyncResult.failure(c, {
|
||||
onSuccess: v => ({
|
||||
key: previous.key,
|
||||
result: AsyncResult.success(v),
|
||||
}),
|
||||
onFailure: c => Cause.hasInterruptsOnly(c)
|
||||
? previous
|
||||
: AsyncResult.match(previous.result, {
|
||||
onInitial: () => ({
|
||||
key: previous.key,
|
||||
result: AsyncResult.failure(c),
|
||||
}),
|
||||
onSuccess: v => ({
|
||||
key: previous.key,
|
||||
result: AsyncResult.failure(c, {
|
||||
previousSuccess: Option.some(v),
|
||||
}),
|
||||
onFailure: v => AsyncResult.failure(c, {
|
||||
}),
|
||||
onFailure: v => ({
|
||||
key: previous.key,
|
||||
result: AsyncResult.failure(c, {
|
||||
previousSuccess: v.previousSuccess,
|
||||
})
|
||||
}),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.andThen(Effect.all([
|
||||
Effect.fiberId,
|
||||
Lens.get(this.fiber),
|
||||
])),
|
||||
Effect.flatMap(([fiberId, fiber]) => Option.match(fiber, {
|
||||
onSome: v => Equal.equals(fiberId, v.id)
|
||||
? Lens.set(this.fiber, Option.none())
|
||||
: Effect.void,
|
||||
onNone: () => Effect.void,
|
||||
})),
|
||||
)),
|
||||
}),
|
||||
}),
|
||||
)) as FinalMutationState<K, A, E>
|
||||
|
||||
yield* Lens.set(this.latestFinalState, Option.some(finalState))
|
||||
yield* PubSub.shutdown(state.pubsub)
|
||||
}))
|
||||
))
|
||||
|
||||
yield* Lens.set(this.fiber, Option.some(fiber))
|
||||
@@ -114,15 +156,15 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
|
||||
}
|
||||
|
||||
watch(
|
||||
state: View.View<AsyncResult.AsyncResult<A, E>>
|
||||
): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
|
||||
state: View.View<MutationState<K, A, E>>
|
||||
): Effect.Effect<FinalMutationState<K, A, E>> {
|
||||
return View.get(state).pipe(
|
||||
Effect.andThen(initial => Stream.runFoldEffect(
|
||||
View.changes(state),
|
||||
() => initial,
|
||||
(_, result) => Effect.as(Lens.set(this.state, result), result),
|
||||
) as Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>),
|
||||
Effect.tap(result => Lens.set(this.latestFinalResult, Option.some(result))),
|
||||
) as Effect.Effect<FinalMutationState<K, A, E>>),
|
||||
Effect.tap(result => Lens.set(this.latestFinalState, Option.some(result))),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -147,7 +189,51 @@ export const make = Effect.fnUntraced(function* <K = never, A = void, E = never,
|
||||
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<K>())),
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>())),
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<AsyncResult.AsyncResult<A, E>>(AsyncResult.initial())),
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>())),
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<LatestMutationState<K, A, E>>({
|
||||
key: Option.none(),
|
||||
result: AsyncResult.initial(),
|
||||
})),
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<FinalMutationState<K, A, E>>())),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
export class MutationStateLens<in out K, in out A, in out E = never>
|
||||
extends Lens.LensImpl<MutationState<K, A, E>, never, never, never, never> {
|
||||
constructor(
|
||||
readonly ref: Ref.Ref<MutationState<K, A, E>>,
|
||||
readonly pubsub: PubSub.PubSub<MutationState<K, A, E>>,
|
||||
readonly semaphore: Semaphore.Semaphore,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
get resolve(): Effect.Effect<Lens.LensImpl.Resolved<MutationState<K, A, E>>, never, never> {
|
||||
return Effect.map(
|
||||
Ref.get(this.ref),
|
||||
value => ({
|
||||
value,
|
||||
commit: next => Effect.flatMap(
|
||||
next,
|
||||
value => Effect.andThen(
|
||||
Ref.set(this.ref, value),
|
||||
PubSub.publish(this.pubsub, value),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
get changes() { return Stream.fromPubSub(this.pubsub) }
|
||||
get lock() { return Effect.succeed(this.semaphore.withPermit) }
|
||||
}
|
||||
|
||||
export const makeMutationStateLens = <K, A, E = never>(
|
||||
initial: MutationState<K, A, E>,
|
||||
) => Effect.all([
|
||||
Ref.make(initial),
|
||||
PubSub.unbounded<MutationState<K, A, E>>({ replay: 1 }),
|
||||
Semaphore.make(1),
|
||||
]).pipe(
|
||||
Effect.tap(([, pubsub]) => PubSub.publish(pubsub, initial)),
|
||||
Effect.map(([ref, pubsub, semaphore]) => new MutationStateLens(ref, pubsub, semaphore)),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import { Array, Cause, type Context, Effect, Fiber, Option, Pipeable, Predicate, Schema, SchemaError, SchemaIssue, type Scope, Semaphore, SubscriptionRef } from "effect"
|
||||
import { Array, Cause, type Context, Effect, Fiber, Option, Pipeable, Predicate, Schema, SchemaIssue, type Scope, Semaphore, SubscriptionRef } from "effect"
|
||||
import { AsyncResult } from "effect/unstable/reactivity"
|
||||
import * as Form from "./Form.js"
|
||||
import * as Lens from "./Lens.js"
|
||||
@@ -23,7 +23,14 @@ extends Form.Form<readonly [], A, I, never, never> {
|
||||
readonly validationFiber: View.View<Option.Option<Fiber.Fiber<A, Schema.SchemaError>>, never, never>
|
||||
|
||||
readonly run: Effect.Effect<void>
|
||||
readonly submit: Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, Cause.NoSuchElementError>
|
||||
readonly submit: Effect.Effect<
|
||||
Option.Option<Mutation.FinalMutationState<
|
||||
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
|
||||
MA, ME
|
||||
>>,
|
||||
Cause.NoSuchElementError,
|
||||
never
|
||||
>
|
||||
}
|
||||
|
||||
export class MutationFormImpl<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, in out MR = never>
|
||||
@@ -79,17 +86,20 @@ extends Pipeable.Class implements MutationForm<A, I, RD, RE, MA, ME, MR> {
|
||||
this.canCommit = Effect.succeed(this).pipe(
|
||||
Effect.map(self => View.map(
|
||||
View.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.state),
|
||||
([value, issues, validationFiber, result]) => (
|
||||
([value, issues, validationFiber, state]) => (
|
||||
Option.isSome(value) &&
|
||||
Array.isReadonlyArrayEmpty(issues) &&
|
||||
Option.isNone(validationFiber) &&
|
||||
!AsyncResult.isWaiting(result)
|
||||
!AsyncResult.isWaiting(state.result)
|
||||
),
|
||||
)),
|
||||
View.unwrap,
|
||||
)
|
||||
this.isCommitting = Effect.succeed(this).pipe(
|
||||
Effect.map(self => View.map(self.mutation.state, AsyncResult.isWaiting)),
|
||||
Effect.map(self => View.map(
|
||||
self.mutation.state,
|
||||
state => AsyncResult.isWaiting(state.result),
|
||||
)),
|
||||
View.unwrap,
|
||||
)
|
||||
}
|
||||
@@ -112,7 +122,7 @@ extends Pipeable.Class implements MutationForm<A, I, RD, RE, MA, ME, MR> {
|
||||
Effect.tap(() => Lens.set(this.issues, Array.empty())),
|
||||
Effect.flatMap(value => Lens.set(this.value, Option.some(value))),
|
||||
Effect.catchIf(
|
||||
SchemaError.isSchemaError,
|
||||
Schema.isSchemaError,
|
||||
error => Lens.set(this.issues, SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues),
|
||||
),
|
||||
|
||||
@@ -130,22 +140,36 @@ extends Pipeable.Class implements MutationForm<A, I, RD, RE, MA, ME, MR> {
|
||||
)
|
||||
}
|
||||
|
||||
get submit(): Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, Cause.NoSuchElementError, never> {
|
||||
get submit(): Effect.Effect<
|
||||
Option.Option<Mutation.FinalMutationState<
|
||||
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
|
||||
MA, ME
|
||||
>>,
|
||||
Cause.NoSuchElementError,
|
||||
never
|
||||
> {
|
||||
return Lens.get(this.value).pipe(
|
||||
Effect.flatMap(Effect.fromOption),
|
||||
Effect.flatMap(value => this.submitValue(value)),
|
||||
)
|
||||
}
|
||||
|
||||
submitValue(value: A): Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, never, never> {
|
||||
submitValue(value: A): Effect.Effect<
|
||||
Option.Option<Mutation.FinalMutationState<
|
||||
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
|
||||
MA, ME
|
||||
>>,
|
||||
never,
|
||||
never
|
||||
> {
|
||||
return Effect.when(
|
||||
Effect.tap(
|
||||
this.mutation.mutate([value, this as any]),
|
||||
result => AsyncResult.isFailure(result)
|
||||
state => AsyncResult.isFailure(state.result)
|
||||
? Option.match(
|
||||
Array.findFirst(
|
||||
result.cause.reasons,
|
||||
reason => Cause.isFailReason(reason) && SchemaError.isSchemaError(reason.error)
|
||||
state.result.cause.reasons,
|
||||
reason => Cause.isFailReason(reason) && Schema.isSchemaError(reason.error)
|
||||
? Option.some(reason.error)
|
||||
: Option.none(),
|
||||
),
|
||||
|
||||
@@ -11,7 +11,7 @@ export const useFromReactiveValues = Effect.fnUntraced(function* <const A extend
|
||||
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
|
||||
yield* Component.useReactEffect(() => Effect.flatMap(
|
||||
PubSub.isShutdown(pubsub),
|
||||
shutdown => shutdown ? Effect.succeed(undefined) : Effect.asVoid(PubSub.publish(pubsub, values)),
|
||||
shutdown => shutdown ? Effect.void : Effect.asVoid(PubSub.publish(pubsub, values)),
|
||||
), values)
|
||||
return pubsub
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export * as Async from "./Async.js"
|
||||
export * as Component from "./Component.js"
|
||||
export * as Form from "./Form.js"
|
||||
export * as I18n from "./I18n.js"
|
||||
export * as Lens from "./Lens.js"
|
||||
export * as LensForm from "./LensForm.js"
|
||||
export * as Locale from "./Locale.js"
|
||||
export * as Memoized from "./Memoized.js"
|
||||
export * as Mutation from "./Mutation.js"
|
||||
export * as MutationForm from "./MutationForm.js"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "../../node_modules/@effect/tsgo/schema.json",
|
||||
"compilerOptions": {
|
||||
// Enable latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
@@ -35,5 +36,5 @@
|
||||
]
|
||||
},
|
||||
|
||||
"include": ["./src"],
|
||||
"include": ["./src"]
|
||||
}
|
||||
|
||||
@@ -27,15 +27,15 @@
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-browser": "4.0.0-beta.102",
|
||||
"@effect/platform-browser": "4.0.0-rc.109",
|
||||
"@radix-ui/themes": "^3.3.0",
|
||||
"effect": "4.0.0-beta.102",
|
||||
"effect": "4.0.0-rc.109",
|
||||
"effect-view": "workspace:*",
|
||||
"react-icons": "^5.6.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "^19.2.15",
|
||||
"effect": "4.0.0-beta.102",
|
||||
"effect": "4.0.0-rc.109",
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,20 +41,7 @@ class AppState extends Context.Service<AppState, {
|
||||
}
|
||||
|
||||
|
||||
const LensFormPageView = Component.make("LensFormPageView")(function*() {
|
||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
yield* Effect.addFinalizer(() => Console.log("LensForm route unmounted"))
|
||||
yield* Console.log("LensForm route mounted")
|
||||
}))
|
||||
|
||||
const context = yield* Component.useLayer(AppState.layer)
|
||||
const UserProfileEditor = yield* Effect.provide(UserProfileEditorView.use, context)
|
||||
|
||||
return <UserProfileEditor />
|
||||
})
|
||||
|
||||
|
||||
const UserProfileEditorView = Component.make("UserProfileEditorView")(function*() {
|
||||
const LensFormPageView = Component.make("LensFormPage")(function*() {
|
||||
const appState = yield* AppState
|
||||
|
||||
const [
|
||||
@@ -106,7 +93,9 @@ const UserProfileEditorView = Component.make("UserProfileEditorView")(function*(
|
||||
</form>
|
||||
</Container>
|
||||
)
|
||||
})
|
||||
}).pipe(
|
||||
Component.provide(AppState.layer),
|
||||
)
|
||||
|
||||
|
||||
export const Route = createFileRoute("/lensform")({
|
||||
|
||||
@@ -87,7 +87,7 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<PostResultView result={mutationState} />
|
||||
<PostResultView result={mutationState.result} />
|
||||
|
||||
<Button onClick={() => runSync(mutation.mutateView([id]))}>
|
||||
Mutate
|
||||
|
||||
Reference in New Issue
Block a user