Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c150864292 | ||
|
|
e412943e64 |
No files matched your search
@@ -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
|
|
||||||
@@ -8,9 +8,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
- name: Clone repo
|
||||||
bun-version: "1.4"
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
|
||||||
bun-version: "1.4"
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
@@ -22,7 +20,7 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Clone repo
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
@@ -57,6 +55,11 @@ jobs:
|
|||||||
token: ${{ secrets.NPM_TOKEN }}
|
token: ${{ secrets.NPM_TOKEN }}
|
||||||
registry: https://registry.npmjs.org
|
registry: https://registry.npmjs.org
|
||||||
|
|
||||||
|
- name: Clean before Docker build
|
||||||
|
run: |
|
||||||
|
bun clean:cache
|
||||||
|
bun clean:dist
|
||||||
|
bun clean:modules
|
||||||
- name: Generate Docker metadata
|
- name: Generate Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
|
|||||||
@@ -9,16 +9,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
|
||||||
bun-version: "1.4"
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "22"
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Checkout
|
- name: Clone repo
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
@@ -34,6 +32,11 @@ jobs:
|
|||||||
- name: Pack
|
- name: Pack
|
||||||
run: bun pack
|
run: bun pack
|
||||||
|
|
||||||
|
- name: Clean before Docker build
|
||||||
|
run: |
|
||||||
|
bun clean:cache
|
||||||
|
bun clean:dist
|
||||||
|
bun clean:modules
|
||||||
- name: Generate Docker metadata
|
- name: Generate Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
|
|||||||
Vendored
+1
-4
@@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"js/ts.tsdk.path": "./node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"js/ts.tsdk.additionalLocations": ["./node_modules/typescript/bin"],
|
|
||||||
"js/ts.tsdk.promptToUseWorkspaceVersion": true,
|
|
||||||
"js/ts.experimental.useTsgo": true,
|
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.biome": "explicit"
|
"source.fixAll.biome": "explicit"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
FROM oven/bun:1.4.0-debian@sha256:5bb0f9be3a1a36a03e27c9a9dd894a3b1ad26657155c7df4dda771e17bf872ef AS bun
|
FROM oven/bun:1.3.14-debian@sha256:9dba1a1b43ce28c9d7931bfc4eb00feb63b0114720a0277a8f939ae4dfc9db6f AS bun
|
||||||
|
|
||||||
FROM node:24.19.0-trixie-slim@sha256:0711b541c1c33a8a530ac4f0d391baa9a15b3d804695b1b24a47daa5fb60e74d
|
FROM node:22.23.2-trixie-slim@sha256:517aa41d78545cb1b8c67b13655b4c13ede1ee9df1da8aab54cd7434aefbcaf8
|
||||||
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bunx /usr/local/bin/
|
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bunx /usr/local/bin/
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -9,4 +9,4 @@ RUN bun install --frozen-lockfile && \
|
|||||||
bun run build && \
|
bun run build && \
|
||||||
bun clean:cache && \
|
bun clean:cache && \
|
||||||
bun clean:modules && \
|
bun clean:modules && \
|
||||||
bun install --production --frozen-lockfile --ignore-scripts
|
bun install --production --frozen-lockfile
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
"name": "@effect-view/monorepo",
|
"name": "@effect-view/monorepo",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.5.5",
|
"@biomejs/biome": "^2.5.5",
|
||||||
"@effect/tsgo": "0.36.4",
|
"@effect/language-service": "^0.87.1",
|
||||||
"@types/bun": "^1.4.0",
|
"@types/bun": "^1.3.14",
|
||||||
"npm-check-updates": "^23.0.0",
|
"npm-check-updates": "^23.0.0",
|
||||||
"npm-sort": "^0.0.4",
|
"npm-sort": "^0.0.4",
|
||||||
"turbo": "^2.10.7",
|
"turbo": "^2.10.7",
|
||||||
@@ -82,20 +82,21 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-view": {
|
"packages/effect-view": {
|
||||||
"name": "effect-view",
|
"name": "effect-view",
|
||||||
"version": "0.3.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.1.0",
|
"@standard-schema/spec": "^1.1.0",
|
||||||
"effect-lens": "2.0.3",
|
"effect-lens": "^2.0.1-beta.101",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@effect/platform-browser": "4.0.0-beta.101",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"effect": "^4.0.0",
|
"effect": "4.0.0-beta.101",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"effect": "^4.0.0",
|
"effect": "4.0.0-beta.101",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -103,9 +104,9 @@
|
|||||||
"name": "@effect-view/example",
|
"name": "@effect-view/example",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-browser": "^4.0.0",
|
"@effect/platform-browser": "4.0.0-beta.101",
|
||||||
"@radix-ui/themes": "^3.3.0",
|
"@radix-ui/themes": "^3.3.0",
|
||||||
"effect": "^4.0.0",
|
"effect": "4.0.0-beta.101",
|
||||||
"effect-view": "workspace:*",
|
"effect-view": "workspace:*",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
},
|
},
|
||||||
@@ -125,7 +126,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vite-plugin": {
|
"packages/vite-plugin": {
|
||||||
"name": "@effect-view/vite-plugin",
|
"name": "@effect-view/vite-plugin",
|
||||||
"version": "0.3.0",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
},
|
},
|
||||||
@@ -135,7 +136,7 @@
|
|||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"effect-view": "^0.3.0",
|
"effect-view": "^0.1.0",
|
||||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.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-view/vite-plugin": ["@effect-view/vite-plugin@workspace:packages/vite-plugin"],
|
||||||
|
|
||||||
|
"@effect/language-service": ["@effect/language-service@0.87.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-kcljlJmEgqg5mFAM6UShJYJjMqJb3TbHHxrK8Qoubvwugc0aVWpRkbdgQvK5b17puOt3BKXXKOmcV+oQt0oQqQ=="],
|
||||||
|
|
||||||
"@effect/platform": ["@effect/platform@0.96.3", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.10", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.21.5" } }, "sha512-LzvIj4HYE++TcTv/cVTCI/GRvQTV0ymwRmgjZMzNB5dU4OS05pTN36RKN0npiOm5orLJgw4yjIv1dNZoYGh/vg=="],
|
"@effect/platform": ["@effect/platform@0.96.3", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.10", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.21.5" } }, "sha512-LzvIj4HYE++TcTv/cVTCI/GRvQTV0ymwRmgjZMzNB5dU4OS05pTN36RKN0npiOm5orLJgw4yjIv1dNZoYGh/vg=="],
|
||||||
|
|
||||||
"@effect/platform-browser": ["@effect/platform-browser@0.76.0", "", { "dependencies": { "multipasta": "^0.2.7" }, "peerDependencies": { "@effect/platform": "^0.96.0", "effect": "^3.21.0" } }, "sha512-cUyBpcLstrP/HiNsIePMBAI6R1+u6aRFlAUZb4wf08y1d1Vqf/Dmxsq14ZjBfnSYiqBPrCeYf1ZI+qMGQQL0RA=="],
|
"@effect/platform-browser": ["@effect/platform-browser@0.76.0", "", { "dependencies": { "multipasta": "^0.2.7" }, "peerDependencies": { "@effect/platform": "^0.96.0", "effect": "^3.21.0" } }, "sha512-cUyBpcLstrP/HiNsIePMBAI6R1+u6aRFlAUZb4wf08y1d1Vqf/Dmxsq14ZjBfnSYiqBPrCeYf1ZI+qMGQQL0RA=="],
|
||||||
|
|
||||||
"@effect/tsgo": ["@effect/tsgo@0.36.4", "", { "optionalDependencies": { "@effect/tsgo-darwin-arm64": "0.36.4", "@effect/tsgo-darwin-x64": "0.36.4", "@effect/tsgo-linux-arm": "0.36.4", "@effect/tsgo-linux-arm64": "0.36.4", "@effect/tsgo-linux-x64": "0.36.4", "@effect/tsgo-win32-arm64": "0.36.4", "@effect/tsgo-win32-x64": "0.36.4" }, "bin": { "effect-tsgo": "dist/effect-tsgo.cjs" } }, "sha512-fNmdUV6FgXnvIcG18AVS1SRXt7z9jsyBh5CKuJYmTsE+DgbLaWF0CevKHx7hQPcidDWRVmdJYfU66Jvc/ZEt6w=="],
|
|
||||||
|
|
||||||
"@effect/tsgo-darwin-arm64": ["@effect/tsgo-darwin-arm64@0.36.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oOcWdKQucNch6G4CvidHEzmfgeKEPMvFcz+DXuKGonBLwVHX+i1VYGbZwccEOoiitOg7eWVl4e8S0qKMXg3/gg=="],
|
|
||||||
|
|
||||||
"@effect/tsgo-darwin-x64": ["@effect/tsgo-darwin-x64@0.36.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-4QZh0n/nUoTI7sp4o7gDEviJ/jXH+IFTVqIyQ2w2fqqPoOvaGl9bVoR/2f9j/67hvY4DHcXKaccpmyoRYhaYsw=="],
|
|
||||||
|
|
||||||
"@effect/tsgo-linux-arm": ["@effect/tsgo-linux-arm@0.36.4", "", { "os": "linux", "cpu": "arm" }, "sha512-10oTUnOK/RCfmTMFvMndNIfk61uviyYBRpbxhDd/SiU6DYCmWlnZ/xhGmgyj5uiysOiFNsp3NKxT0d9y4uAz8g=="],
|
|
||||||
|
|
||||||
"@effect/tsgo-linux-arm64": ["@effect/tsgo-linux-arm64@0.36.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-aOv+wVbZWgBt9PGXPA874qgw151GrzkWqaswe+4TXjhdw/M4i35PCUH5T+uaQj3FnQ1NWiPqHIGfNBRjf3xG6w=="],
|
|
||||||
|
|
||||||
"@effect/tsgo-linux-x64": ["@effect/tsgo-linux-x64@0.36.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+BuwiG5d8dLNrrN1PB5GA3gRkBwvlUYmMkm/XkttaLnppp9jePFkB8CgAiNV8j290q7BVO2nQjao5aPkVwmluw=="],
|
|
||||||
|
|
||||||
"@effect/tsgo-win32-arm64": ["@effect/tsgo-win32-arm64@0.36.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-HsW90wbMWOeorUeNMdTqTcMZIDcCXdkeFGXTUkx9K1R21fXSw+QEdPtTmEyKsRMXEH4vqek/HQa0yMkUT0CJzA=="],
|
|
||||||
|
|
||||||
"@effect/tsgo-win32-x64": ["@effect/tsgo-win32-x64@0.36.4", "", { "os": "win32", "cpu": "x64" }, "sha512-q4j1q0ESYxxookjA0ECz/84DEH2MsP51K0aZb3uXKfxagHDASPoWDcVu4BJTcwrK5kdZzvw9QrCPjAXe9WZqng=="],
|
|
||||||
|
|
||||||
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
||||||
|
|
||||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||||
@@ -1165,7 +1152,7 @@
|
|||||||
|
|
||||||
"@types/bonjour": ["@types/bonjour@3.5.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ=="],
|
"@types/bonjour": ["@types/bonjour@3.5.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||||
|
|
||||||
@@ -2059,6 +2046,8 @@
|
|||||||
|
|
||||||
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
||||||
|
|
||||||
|
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
|
||||||
|
|
||||||
"latest-version": ["latest-version@7.0.0", "", { "dependencies": { "package-json": "^8.1.0" } }, "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg=="],
|
"latest-version": ["latest-version@7.0.0", "", { "dependencies": { "package-json": "^8.1.0" } }, "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg=="],
|
||||||
|
|
||||||
"launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="],
|
"launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="],
|
||||||
@@ -2867,6 +2856,8 @@
|
|||||||
|
|
||||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
|
|
||||||
|
"toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
|
||||||
|
|
||||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||||
|
|
||||||
"tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
|
"tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
|
||||||
@@ -2949,7 +2940,7 @@
|
|||||||
|
|
||||||
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
||||||
|
|
||||||
"uuid": ["uuid@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=="],
|
"value-equal": ["value-equal@1.0.1", "", {}, "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="],
|
||||||
|
|
||||||
@@ -3029,6 +3020,8 @@
|
|||||||
|
|
||||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"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=="],
|
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
|
||||||
|
|
||||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|
||||||
|
"global-dirs/ini": ["ini@2.0.0", "", {}, "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA=="],
|
||||||
|
|
||||||
"got/@sindresorhus/is": ["@sindresorhus/is@5.6.0", "", {}, "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g=="],
|
"got/@sindresorhus/is": ["@sindresorhus/is@5.6.0", "", {}, "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g=="],
|
||||||
|
|
||||||
"hpack.js/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
"hpack.js/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||||
@@ -3341,6 +3338,8 @@
|
|||||||
|
|
||||||
"sitemap/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
|
"sitemap/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
|
||||||
|
|
||||||
|
"sockjs/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||||
|
|
||||||
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
@@ -3399,6 +3398,10 @@
|
|||||||
|
|
||||||
"@docusaurus/core/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"null-loader/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|||||||
+3
-4
@@ -1,12 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@effect-view/monorepo",
|
"name": "@effect-view/monorepo",
|
||||||
"packageManager": "bun@1.4.0",
|
"packageManager": "bun@1.3.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./packages/*"
|
"./packages/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "effect-tsgo patch --typescript --no-oxlint",
|
|
||||||
"lint:tsc": "turbo lint:tsc",
|
"lint:tsc": "turbo lint:tsc",
|
||||||
"lint:biome": "turbo lint:biome",
|
"lint:biome": "turbo lint:biome",
|
||||||
"test": "turbo test",
|
"test": "turbo test",
|
||||||
@@ -18,8 +17,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.5.5",
|
"@biomejs/biome": "^2.5.5",
|
||||||
"@effect/tsgo": "0.36.4",
|
"@effect/language-service": "^0.87.1",
|
||||||
"@types/bun": "^1.4.0",
|
"@types/bun": "^1.3.14",
|
||||||
"npm-check-updates": "^23.0.0",
|
"npm-check-updates": "^23.0.0",
|
||||||
"npm-sort": "^0.0.4",
|
"npm-sort": "^0.0.4",
|
||||||
"turbo": "^2.10.7",
|
"turbo": "^2.10.7",
|
||||||
|
|||||||
+25
-28
@@ -54,9 +54,11 @@ valid decoded value should go:
|
|||||||
|
|
||||||
## Create forms once
|
## Create forms once
|
||||||
|
|
||||||
`MutationForm.make` and `LensForm.make` construct forms. Pipe them through the
|
`MutationForm.service` and `LensForm.service` are Effect constructors, not
|
||||||
module's `run` operator to start validation or synchronization in the current
|
hooks. Create each root form once and keep it stable. For a component-owned
|
||||||
scope. Create each root form once and keep it stable.
|
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
|
## MutationForm: validate, then submit
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@ import { Component, MutationForm, View } from "effect-view"
|
|||||||
|
|
||||||
const CreateProfileView = Component.make("CreateProfile")(function* () {
|
const CreateProfileView = Component.make("CreateProfile")(function* () {
|
||||||
const form = yield* Component.useOnMount(() =>
|
const form = yield* Component.useOnMount(() =>
|
||||||
MutationForm.make({
|
MutationForm.service({
|
||||||
schema: ProfileSchema,
|
schema: ProfileSchema,
|
||||||
initialEncodedValue: {
|
initialEncodedValue: {
|
||||||
displayName: "",
|
displayName: "",
|
||||||
@@ -82,7 +84,7 @@ const CreateProfileView = Component.make("CreateProfile")(function* () {
|
|||||||
Effect.log(
|
Effect.log(
|
||||||
`Creating ${profile.displayName}, age ${profile.age}`,
|
`Creating ${profile.displayName}, age ${profile.age}`,
|
||||||
),
|
),
|
||||||
}).pipe(MutationForm.thenRun),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const [canCommit, isCommitting] = yield* View.useAll([
|
const [canCommit, isCommitting] = yield* View.useAll([
|
||||||
@@ -108,9 +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
|
Schema transformations happen before the mutation and schema issues prevent an
|
||||||
invalid draft from being submitted.
|
invalid draft from being submitted.
|
||||||
|
|
||||||
`MutationForm.thenRun` starts initial validation. In the example above,
|
`MutationForm.service` also starts initial validation in the current scope. In
|
||||||
`Component.useOnMount` keeps that validation and the form's mutation work tied
|
the example above, `Component.useOnMount` keeps that validation and the form's
|
||||||
to the component that owns them.
|
mutation work tied to the component that owns them.
|
||||||
|
|
||||||
## LensForm: validate, then synchronize
|
## 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,
|
schema: ProfileSchema,
|
||||||
target: profile,
|
target: profile,
|
||||||
}).pipe(LensForm.thenRun)
|
})
|
||||||
|
|
||||||
return [form, profile] as const
|
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.
|
`LensForm` encodes that value back into the draft.
|
||||||
|
|
||||||
Pass `initialEncodedValue` only when the first draft should differ from the
|
Pass `initialEncodedValue` only when the first draft should differ from the
|
||||||
encoded target. Otherwise `LensForm.make` obtains the initial draft by encoding
|
encoded target. Otherwise `LensForm.service` obtains the initial draft by
|
||||||
the target through the schema.
|
encoding the target through the schema.
|
||||||
|
|
||||||
## Focus into subforms
|
## 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
|
returns `value` and `setValue` together with `enabled` and `setEnabled`, making
|
||||||
it suitable for an optional input that the user can toggle on and off.
|
it suitable for an optional input that the user can toggle on and off.
|
||||||
|
|
||||||
Use `Form.useStatus` for displayed form status. It debounces short-lived state
|
|
||||||
changes, avoiding flicker in pending indicators.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const { isValidating, isCommitting, canCommit } = yield* Form.useStatus(form)
|
|
||||||
```
|
|
||||||
|
|
||||||
`useInput` and `useOptionalInput` can be used directly, but they are primarily
|
`useInput` and `useOptionalInput` can be used directly, but they are primarily
|
||||||
building blocks for your own reusable input components. A component library
|
building blocks for your own reusable input components. A component library
|
||||||
can wrap them to consistently handle labels, validation issues, validating
|
can wrap them to consistently handle labels, validation issues, validating
|
||||||
@@ -306,8 +301,11 @@ export const TextFieldFormInputView = Component.make(
|
|||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
const input = yield* Form.useInput(props.form, props)
|
const input = yield* Form.useInput(props.form, props)
|
||||||
const [issues] = yield* View.useAll([props.form.issues])
|
const [issues, isValidating, isCommitting] = yield* View.useAll([
|
||||||
const { isValidating, isCommitting } = yield* Form.useStatus(props.form)
|
props.form.issues,
|
||||||
|
props.form.isValidating,
|
||||||
|
props.form.isCommitting,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" gap="1">
|
<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. |
|
| `isCommitting` | Whether a mutation or target write is in progress. |
|
||||||
|
|
||||||
The form model itself is Effect code. Effect View adds the React-facing hooks,
|
The form model itself is Effect code. Effect View adds the React-facing hooks,
|
||||||
including `Form.useInput`, `Form.useOptionalInput`, and `Form.useStatus`, while
|
including `Form.useInput` and `Form.useOptionalInput`, while your own component
|
||||||
your own component library remains responsible for rendering controls and
|
library remains responsible for rendering controls and layout.
|
||||||
layout.
|
|
||||||
|
|
||||||
## Example: local date input, UTC domain value
|
## Example: local date input, UTC domain value
|
||||||
|
|
||||||
@@ -420,7 +417,7 @@ the mutation receives UTC:
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { DateTime, Effect, Schema } from "effect"
|
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"
|
import { DateTimeUtcFromZonedInput } from "./DateTimeUtcFromZonedInput"
|
||||||
|
|
||||||
const AppointmentSchema = Schema.Struct({
|
const AppointmentSchema = Schema.Struct({
|
||||||
@@ -430,21 +427,21 @@ const AppointmentSchema = Schema.Struct({
|
|||||||
const AppointmentView = Component.make("Appointment")(function* () {
|
const AppointmentView = Component.make("Appointment")(function* () {
|
||||||
const [form, startsAtField] = yield* Component.useOnMount(() =>
|
const [form, startsAtField] = yield* Component.useOnMount(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const form = yield* MutationForm.make({
|
const form = yield* MutationForm.service({
|
||||||
schema: AppointmentSchema,
|
schema: AppointmentSchema,
|
||||||
initialEncodedValue: { startsAt: "" },
|
initialEncodedValue: { startsAt: "" },
|
||||||
f: ([appointment]) =>
|
f: ([appointment]) =>
|
||||||
Effect.log(
|
Effect.log(
|
||||||
`Saving ${DateTime.formatIso(appointment.startsAt)}`,
|
`Saving ${DateTime.formatIso(appointment.startsAt)}`,
|
||||||
),
|
),
|
||||||
}).pipe(MutationForm.thenRun)
|
})
|
||||||
|
|
||||||
return [form, Form.focusObjectOn(form, "startsAt")] as const
|
return [form, Form.focusObjectOn(form, "startsAt")] as const
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const startsAt = yield* Form.useInput(startsAtField)
|
const startsAt = yield* Form.useInput(startsAtField)
|
||||||
const { canCommit } = yield* Form.useStatus(form)
|
const [canCommit] = yield* View.useAll([form.canCommit])
|
||||||
const runPromise = yield* Component.useRunPromise()
|
const runPromise = yield* Component.useRunPromise()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ For a web application, install Effect View with Effect 4 and React 19.2 or
|
|||||||
newer:
|
newer:
|
||||||
|
|
||||||
```bash npm2yarn
|
```bash npm2yarn
|
||||||
npm install effect-view effect react react-dom
|
npm install effect-view effect@beta react react-dom
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash npm2yarn
|
```bash npm2yarn
|
||||||
@@ -69,14 +69,14 @@ layers needed by the UI:
|
|||||||
|
|
||||||
```tsx title="src/runtime.ts"
|
```tsx title="src/runtime.ts"
|
||||||
import { Layer } from "effect"
|
import { Layer } from "effect"
|
||||||
import { FetchHttpClient } from "effect/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { ReactRuntime } from "effect-view"
|
import { ReactRuntime } from "effect-view"
|
||||||
|
|
||||||
const layer = Layer.empty.pipe(
|
const AppLive = Layer.empty.pipe(
|
||||||
Layer.provideMerge(FetchHttpClient.layer),
|
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
|
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
|
`Lens`/`View` when state needs Effect integration, subscriptions, focusing, or
|
||||||
sharing. The [State Management guide](./state-management) covers that model.
|
sharing. The [State Management guide](./state-management) covers that model.
|
||||||
|
|
||||||
## Provide services to a 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
|
## Provide services to a subtree
|
||||||
|
|
||||||
Use `Component.useLayer` to provide a layer from a component to its Effect View
|
Use `Component.useLayer` when only one Effect View subtree needs extra
|
||||||
subcomponents. This is also the right choice when the layer depends on props,
|
services. It builds the layer in a scope and returns the resulting Effect
|
||||||
state, or context read inside React: memoize the layer and provide its context
|
context:
|
||||||
manually.
|
|
||||||
|
|
||||||
```tsx title="src/GreetingPageView.tsx"
|
```tsx title="src/GreetingPageView.tsx"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Component } from "effect-view"
|
import { Component } from "effect-view"
|
||||||
import * as React from "react"
|
|
||||||
import { GreetingView } from "./GreetingView"
|
import { GreetingView } from "./GreetingView"
|
||||||
import { GreetingService } from "./GreetingService"
|
import { GreetingService } from "./GreetingService"
|
||||||
|
|
||||||
export const GreetingPageView = Component.make("GreetingPage")(
|
export const GreetingPageView = Component.make("GreetingPage")(
|
||||||
function* (props: { readonly greeting: string }) {
|
function* () {
|
||||||
const layer = React.useMemo(
|
const context = yield* Component.useLayer(GreetingService.layer)
|
||||||
() => Layer.succeed(GreetingService, {
|
const Greeting = yield* GreetingView.use.pipe(
|
||||||
greet: (name) => `${props.greeting}, ${name}`,
|
Effect.provide(context),
|
||||||
}),
|
|
||||||
[props.greeting],
|
|
||||||
)
|
)
|
||||||
const context = yield* Component.useLayer(layer)
|
|
||||||
const Greeting = yield* Effect.provide(GreetingView.use, context)
|
|
||||||
|
|
||||||
return <Greeting name="Effect" />
|
return <Greeting name="Effect" />
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>>` |
|
| Mutation result | `mutation.state`, a `View<AsyncResult<A, E>>` |
|
||||||
| `isPending` | `result.waiting` |
|
| `isPending` | `result.waiting` |
|
||||||
| `mutateAsync` | `mutation.mutate(key)` |
|
| `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.
|
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.
|
commands, while Query remains the model for cached server state.
|
||||||
|
|
||||||
## Create a mutation
|
## Create a mutation
|
||||||
@@ -37,7 +37,7 @@ create it in an Effect service when it should be shared.
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { AsyncResult } from "effect/reactivity"
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
import { Component, Mutation, View } from "effect-view"
|
import { Component, Mutation, View } from "effect-view"
|
||||||
import { sendInvite } from "./api"
|
import { sendInvite } from "./api"
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ const InviteButtonView = Component.make("InviteButton")(
|
|||||||
<button
|
<button
|
||||||
disabled={result.waiting}
|
disabled={result.waiting}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
runSync(mutation.mutateStream({
|
runSync(mutation.mutateView({
|
||||||
email: props.email,
|
email: props.email,
|
||||||
role: "member",
|
role: "member",
|
||||||
}))
|
}))
|
||||||
@@ -132,7 +132,7 @@ during retries or repeated submissions.
|
|||||||
A failure contains an Effect `Cause<E>`, not only `E`. Typed failures, defects,
|
A failure contains an Effect `Cause<E>`, not only `E`. Typed failures, defects,
|
||||||
and interruption information remain available for logging or presentation.
|
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
|
Both methods start the same mutation effect. They differ in what the caller
|
||||||
waits for:
|
waits for:
|
||||||
@@ -140,7 +140,7 @@ waits for:
|
|||||||
| Method | Return value | Best suited to |
|
| Method | Return value | Best suited to |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `mutate(key)` | The final `Success` or `Failure` | Effect workflows that need the outcome. |
|
| `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
|
Use `mutate` with an asynchronous component runner when later logic depends on
|
||||||
the final result:
|
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
|
and returns a final `AsyncResult.Success` or `AsyncResult.Failure`. Inspect or
|
||||||
match that value when control flow depends on the outcome.
|
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:
|
state:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const runSync = yield* Component.useRunSync()
|
const runSync = yield* Component.useRunSync()
|
||||||
|
|
||||||
const startSave = () => {
|
const startSave = () => {
|
||||||
const states = runSync(mutation.mutateStream(input))
|
const state = runSync(mutation.mutateView(input))
|
||||||
// `states` streams this specific call's states, and ends once it settles.
|
// `state` is a View for this specific call.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`mutateStream` starts the scoped work and returns immediately with a per-call
|
`mutateView` 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
|
View. The shared `mutation.state` is also updated as that call progresses.
|
||||||
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`.
|
|
||||||
|
|
||||||
## Track the latest call
|
## 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. |
|
| `state` | The latest mutation state published to the shared View. |
|
||||||
| `latestKey` | The most recently supplied input as an `Option<K>`. |
|
| `latestKey` | The most recently supplied input as an `Option<K>`. |
|
||||||
| `latestFinalResult` | The latest completed success or failure as an `Option`. |
|
| `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`:
|
Subscribe to any of them with `View.useAll`:
|
||||||
|
|
||||||
@@ -209,20 +207,19 @@ any other value accepted by the mutation function.
|
|||||||
## Concurrent mutations
|
## Concurrent mutations
|
||||||
|
|
||||||
Starting a mutation does not automatically interrupt an earlier mutation.
|
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.
|
independent operations such as uploading several files.
|
||||||
|
|
||||||
When calls overlap, `mutation.state` follows only the most recently started
|
When calls overlap, `mutation.state` reflects updates published by all calls;
|
||||||
call: earlier calls still run to completion, but no longer update it. Use the
|
the last update to arrive wins. Use the View returned by `mutateView` when each
|
||||||
stream returned by `mutateStream` when each concurrent operation needs its own
|
concurrent operation needs its own progress indicator:
|
||||||
progress indicator:
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const upload = (file: File) =>
|
const upload = (file: File) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const uploadStates = yield* mutation.mutateStream(file)
|
const uploadState = yield* mutation.mutateView(file)
|
||||||
// Pass `uploadStates` to the row rendering this file, e.g. via `Stream.use`.
|
// Store or pass `uploadState` to the row rendering this file.
|
||||||
return uploadStates
|
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
|
behavior, model that policy explicitly or use Query when the operation is
|
||||||
actually a reactive read.
|
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
|
## Update queries after a mutation
|
||||||
|
|
||||||
Mutations do not invalidate Query caches automatically. Compose invalidation
|
Mutations do not invalidate Query caches automatically. Compose invalidation
|
||||||
@@ -264,7 +240,7 @@ const saveAndRefresh = (input: UpdatePostInput) =>
|
|||||||
|
|
||||||
if (AsyncResult.isSuccess(result)) {
|
if (AsyncResult.isSuccess(result)) {
|
||||||
yield* posts.invalidateCacheEntry(["post", result.value.id] as const)
|
yield* posts.invalidateCacheEntry(["post", result.value.id] as const)
|
||||||
yield* posts.refresh
|
yield* posts.refreshView
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -277,7 +253,7 @@ also include tracing, transactions, retries, notifications, or parallel cache
|
|||||||
updates without introducing a separate callback API.
|
updates without introducing a separate callback API.
|
||||||
|
|
||||||
Remember that Query invalidation removes cached data but does not refetch by
|
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.
|
immediately.
|
||||||
|
|
||||||
## The Effect touch
|
## The Effect touch
|
||||||
@@ -290,8 +266,8 @@ execution model:
|
|||||||
- Failures are represented as `Cause<E>`, including defects and interruption.
|
- Failures are represented as `Cause<E>`, including defects and interruption.
|
||||||
- Fibers are scoped, so component unmounting cleans up in-flight operations.
|
- Fibers are scoped, so component unmounting cleans up in-flight operations.
|
||||||
- `mutate` composes directly inside larger Effect workflows.
|
- `mutate` composes directly inside larger Effect workflows.
|
||||||
- `mutateStream` exposes call-specific progress as a `Stream` for React or
|
- `mutateView` exposes call-specific progress as a View for React or Effect
|
||||||
Effect consumers.
|
consumers.
|
||||||
- Retry schedules, timeouts, tracing, logging, metrics, schema validation, and
|
- Retry schedules, timeouts, tracing, logging, metrics, schema validation, and
|
||||||
concurrency controls can be applied with normal Effect operators.
|
concurrency controls can be applied with normal Effect operators.
|
||||||
|
|
||||||
|
|||||||
+42
-167
@@ -19,69 +19,56 @@ If you already know TanStack Query, the main concepts translate directly:
|
|||||||
| `queryFn` | `f: (key: K) => Effect<A, E, R>` |
|
| `queryFn` | `f: (key: K) => Effect<A, E, R>` |
|
||||||
| `useQuery` result | `query.state`, a `View<QueryState<K, A, E>>` |
|
| `useQuery` result | `query.state`, a `View<QueryState<K, A, E>>` |
|
||||||
| `isFetching` | `result.waiting` |
|
| `isFetching` | `result.waiting` |
|
||||||
| `fetchStatus: 'paused'` | `state.paused` |
|
| `refetch` | `query.refresh` or `query.refreshView` |
|
||||||
| `refetch` | `query.refresh` or `query.refreshStream` |
|
|
||||||
| `invalidateQueries` | `query.invalidateCache` or `invalidateCacheEntry` |
|
| `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
|
The goal is familiar query behavior without leaving Effect's model. Query
|
||||||
functions keep their typed success, error, and service channels. They can use
|
functions keep their typed success, error, and service channels. They can use
|
||||||
services from the runtime, be composed with schema decoding and retry policies,
|
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
|
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
|
the current request.
|
||||||
interrupted as well, rather than returning a result.
|
|
||||||
|
|
||||||
## Provide a QueryClient
|
## Provide a QueryClient
|
||||||
|
|
||||||
Queries share successful results through a `QueryClient`. Add its layer to the
|
Queries share successful results through a `QueryClient`. Add its layer to the
|
||||||
application runtime once, alongside the services used by your query effects. In
|
application runtime once, alongside the services used by your query effects:
|
||||||
a browser, use `QueryClient.layerBrowser`, or `QueryClient.layerBrowserWithOptions`
|
|
||||||
to change the defaults:
|
|
||||||
|
|
||||||
```tsx title="src/runtime.ts"
|
```tsx title="src/runtime.ts"
|
||||||
import { Layer } from "effect"
|
import { Layer } from "effect"
|
||||||
import { QueryClient, ReactRuntime } from "effect-view"
|
import { QueryClient, ReactRuntime } from "effect-view"
|
||||||
|
|
||||||
const layer = Layer.empty.pipe(
|
const AppLive = Layer.empty.pipe(
|
||||||
Layer.provideMerge(QueryClient.layerBrowserWithOptions({
|
Layer.provideMerge(QueryClient.layer({
|
||||||
defaultStaleTime: "30 seconds",
|
defaultStaleTime: "30 seconds",
|
||||||
defaultRefreshOnFocus: true,
|
defaultRefreshOnWindowFocus: true,
|
||||||
cacheGcTime: "5 minutes",
|
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
|
The client owns the cache and its cleanup lifecycle. Individual queries can
|
||||||
override `staleTime`, `networkMode`, `refreshOnFocus` and
|
override `staleTime` and `refreshOnWindowFocus`; otherwise they inherit the
|
||||||
`refreshOnReconnect`; otherwise they inherit the client defaults
|
client defaults. Window-focus refresh also requires the optional
|
||||||
(`defaultStaleTime`, `defaultNetworkMode`, `defaultRefreshOnFocus` and
|
`@effect/platform-browser` package, as described under
|
||||||
`defaultRefreshOnReconnect`).
|
[Staleness and cache lifetime](#staleness-and-cache-lifetime).
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
## Create a reactive query
|
## Create a reactive query
|
||||||
|
|
||||||
A query is driven by a `View` rather than by a value read during one React
|
A query is driven by a `View` rather than by a value read during one React
|
||||||
render. Whenever that key changes, a running Query checks the cache and starts
|
render. Whenever that key changes, `Query.service` checks the cache and starts
|
||||||
the query effect when necessary.
|
the query effect when necessary.
|
||||||
|
|
||||||
`Query.make` constructs the Query and `Query.thenRun` starts it in the current
|
`Query.service` is an Effect constructor, not a hook. Create each query instance
|
||||||
scope. Create each query instance once and keep it stable. In a component, the usual place is
|
once and keep it stable. In a component, the usual place is
|
||||||
`Component.useOnMount`; a query shared by multiple components can instead be
|
`Component.useOnMount`; a query shared by multiple components can instead be
|
||||||
owned by an Effect service. Change the existing query's reactive key rather
|
owned by an Effect service. Change the existing query's reactive key rather
|
||||||
than reconstructing the query during render.
|
than reconstructing the query during render.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Effect, Schema, SubscriptionRef } from "effect"
|
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"
|
import { Component, Lens, Query, View } from "effect-view"
|
||||||
|
|
||||||
const Post = Schema.Struct({
|
const Post = Schema.Struct({
|
||||||
@@ -97,7 +84,7 @@ const PostView = Component.make("Post")(function* () {
|
|||||||
yield* SubscriptionRef.make(["post", 1 as number] as const),
|
yield* SubscriptionRef.make(["post", 1 as number] as const),
|
||||||
)
|
)
|
||||||
|
|
||||||
const query = yield* Query.make({
|
const query = yield* Query.service({
|
||||||
key,
|
key,
|
||||||
staleTime: "1 minute",
|
staleTime: "1 minute",
|
||||||
f: ([, id]) =>
|
f: ([, id]) =>
|
||||||
@@ -108,7 +95,7 @@ const PostView = Component.make("Post")(function* () {
|
|||||||
Effect.andThen((response) => response.json),
|
Effect.andThen((response) => response.json),
|
||||||
Effect.andThen(Schema.decodeUnknownEffect(Post)),
|
Effect.andThen(Schema.decodeUnknownEffect(Post)),
|
||||||
),
|
),
|
||||||
}).pipe(Query.thenRun)
|
})
|
||||||
|
|
||||||
return [Lens.focusTupleAt(key, 1), query] as const
|
return [Lens.focusTupleAt(key, 1), query] as const
|
||||||
}),
|
}),
|
||||||
@@ -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
|
as query keys. Supply `keyEquivalence` when the key needs different equality
|
||||||
semantics.
|
semantics.
|
||||||
|
|
||||||
`Query.thenRun` starts watching its key in the current scope. The
|
`Query.service` starts watching its key in the current scope. The
|
||||||
`Component.useOnMount` call above keeps both the query instance and its query
|
`Component.useOnMount` call above keeps both the query instance and its query
|
||||||
function identity stable for the component's lifetime.
|
function identity stable for the component's lifetime.
|
||||||
|
|
||||||
@@ -155,7 +142,7 @@ interface QueryState<K, A, E> {
|
|||||||
Subscribe with `View.useAll`, then match the result explicitly:
|
Subscribe with `View.useAll`, then match the result explicitly:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { AsyncResult } from "effect/reactivity"
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
|
|
||||||
const [state] = yield* View.useAll([query.state])
|
const [state] = yield* View.useAll([query.state])
|
||||||
|
|
||||||
@@ -229,7 +216,7 @@ const runSync = yield* Component.useRunSync()
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button onClick={() => runSync(query.refreshStream)}>
|
<button onClick={() => runSync(query.refreshView)}>
|
||||||
Refresh current post
|
Refresh current post
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => runSync(query.invalidateCacheEntry(["post", id] as const))}>
|
<button onClick={() => runSync(query.invalidateCacheEntry(["post", id] as const))}>
|
||||||
@@ -247,20 +234,18 @@ The methods come in two styles:
|
|||||||
| Method | Behavior |
|
| Method | Behavior |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `fetch(key)` | Fetch a specific key and wait for its final state. |
|
| `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. |
|
| `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. |
|
| `invalidateCacheEntry(key)` | Remove the cached success for one key. |
|
||||||
| `invalidateCache` | Remove every cached success associated with this query function. |
|
| `invalidateCache` | Remove every cached success associated with this query function. |
|
||||||
|
|
||||||
The `*Stream` variants are convenient in synchronous UI callbacks: they return
|
The `*View` variants are convenient in synchronous UI callbacks: they return
|
||||||
after the scoped request has started. The returned stream emits the request's
|
after the scoped request has started, while the returned View and `query.state`
|
||||||
current state, then each change, and ends once the request settles or is
|
continue to publish progress. The non-View variants are useful in Effect
|
||||||
superseded; `query.state` keeps publishing progress either way. The non-Stream
|
workflows that need to wait for the final success or failure state.
|
||||||
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.
|
key, or allow a later fetch to repopulate the cache.
|
||||||
|
|
||||||
### Refresh on an interval
|
### Refresh on an interval
|
||||||
@@ -270,8 +255,7 @@ Refresh every five minutes, starting after five minutes:
|
|||||||
```ts
|
```ts
|
||||||
import { Schedule } from "effect"
|
import { Schedule } from "effect"
|
||||||
|
|
||||||
const query = yield* Query.make(options).pipe(
|
yield* query.pipe(
|
||||||
Query.thenRun,
|
|
||||||
Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
|
Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@@ -279,8 +263,7 @@ const query = yield* Query.make(options).pipe(
|
|||||||
Limit the number of refreshes:
|
Limit the number of refreshes:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const query = yield* Query.make(options).pipe(
|
yield* query.pipe(
|
||||||
Query.thenRun,
|
|
||||||
Query.withScheduledRefresh(
|
Query.withScheduledRefresh(
|
||||||
Schedule.spaced("5 minutes").pipe(
|
Schedule.spaced("5 minutes").pipe(
|
||||||
Schedule.upTo({ times: 3 }),
|
Schedule.upTo({ times: 3 }),
|
||||||
@@ -292,18 +275,6 @@ const query = yield* Query.make(options).pipe(
|
|||||||
The refresh fiber stops with the surrounding scope. The Effect returns the
|
The refresh fiber stops with the surrounding scope. The Effect returns the
|
||||||
original query. Cache and `staleTime` rules still apply.
|
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
|
## Staleness and cache lifetime
|
||||||
|
|
||||||
`staleTime` controls how long a successful result can satisfy a fetch without
|
`staleTime` controls how long a successful result can satisfy a fetch without
|
||||||
@@ -314,125 +285,29 @@ while the query refreshes it.
|
|||||||
accessed are eventually removed after their stale period plus the configured
|
accessed are eventually removed after their stale period plus the configured
|
||||||
garbage-collection time.
|
garbage-collection time.
|
||||||
|
|
||||||
By default, the client enables refresh when the app regains focus. Set it
|
By default, the client enables refresh on browser window focus. Set it globally
|
||||||
globally or override it for one query:
|
or override it for one query:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const query = yield* Query.make({
|
const query = yield* Query.service({
|
||||||
key,
|
key,
|
||||||
f: loadPost,
|
f: loadPost,
|
||||||
staleTime: "10 seconds",
|
staleTime: "10 seconds",
|
||||||
refreshOnFocus: false,
|
refreshOnWindowFocus: false,
|
||||||
}).pipe(Query.thenRun)
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
Regaining focus resolves the current key again, subject to the same freshness
|
Window-focus refresh depends on the optional `@effect/platform-browser`
|
||||||
check. Set `refreshOnFocus: "always"` to refresh even when the result is still
|
integration. Install it in browser applications that use this behavior:
|
||||||
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).
|
|
||||||
|
|
||||||
## Network mode and offline support
|
```bash npm2yarn
|
||||||
|
npm install @effect/platform-browser@beta
|
||||||
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>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
With `refreshOnReconnect` (enabled by default, except in `"always"` mode),
|
When the package is available, focusing the window resolves the current key
|
||||||
regaining the connection resolves the current key again, subject to the usual
|
again, subject to the same freshness check. Without it,
|
||||||
freshness check. Set it to `"always"` to refresh even when the result is still
|
`refreshOnWindowFocus` has no effect; the rest of the Query API continues to
|
||||||
fresh. Like TanStack Query, focus and reconnect refreshes never restart a
|
work normally. The integration is also ignored in non-browser environments.
|
||||||
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.
|
|
||||||
|
|
||||||
## The Effect touch
|
## The Effect touch
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import type * as Preset from "@docusaurus/preset-classic"
|
|||||||
import type { Config } from "@docusaurus/types"
|
import type { Config } from "@docusaurus/types"
|
||||||
import { themes as prismThemes } from "prism-react-renderer"
|
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...)
|
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
@@ -46,7 +43,6 @@ const config: Config = {
|
|||||||
sidebarPath: "./sidebars.ts",
|
sidebarPath: "./sidebars.ts",
|
||||||
editUrl:
|
editUrl:
|
||||||
"https://github.com/Thiladev/effect-view/tree/main/packages/docs/",
|
"https://github.com/Thiladev/effect-view/tree/main/packages/docs/",
|
||||||
remarkPlugins: [remarkLineNumbers],
|
|
||||||
lastVersion: "current",
|
lastVersion: "current",
|
||||||
versions: {
|
versions: {
|
||||||
current: {
|
current: {
|
||||||
@@ -69,7 +65,6 @@ const config: Config = {
|
|||||||
themeConfig: {
|
themeConfig: {
|
||||||
// Replace with your project's social card
|
// Replace with your project's social card
|
||||||
colorMode: {
|
colorMode: {
|
||||||
defaultMode: "dark",
|
|
||||||
respectPrefersColorScheme: true,
|
respectPrefersColorScheme: true,
|
||||||
},
|
},
|
||||||
navbar: {
|
navbar: {
|
||||||
@@ -144,7 +139,7 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
prism: {
|
prism: {
|
||||||
theme: prismThemes.github,
|
theme: prismThemes.github,
|
||||||
darkTheme: githubDark,
|
darkTheme: prismThemes.dracula,
|
||||||
},
|
},
|
||||||
} satisfies Preset.ThemeConfig,
|
} satisfies Preset.ThemeConfig,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0"
|
"node": ">=20.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
:root {
|
||||||
--ifm-color-primary: #059669;
|
--ifm-color-primary: #0b6f74;
|
||||||
--ifm-color-primary-dark: #04835d;
|
--ifm-color-primary-dark: #096469;
|
||||||
--ifm-color-primary-darker: #047857;
|
--ifm-color-primary-darker: #085e63;
|
||||||
--ifm-color-primary-darkest: #036348;
|
--ifm-color-primary-darkest: #074d51;
|
||||||
--ifm-color-primary-light: #0aa876;
|
--ifm-color-primary-light: #0d7a7f;
|
||||||
--ifm-color-primary-lighter: #10b981;
|
--ifm-color-primary-lighter: #0e8085;
|
||||||
--ifm-color-primary-lightest: #34d399;
|
--ifm-color-primary-lightest: #109096;
|
||||||
|
--ifm-background-color: #fffaf1;
|
||||||
--ifm-background-color: #ffffff;
|
--ifm-code-font-size: 95%;
|
||||||
--ifm-background-surface-color: #ffffff;
|
|
||||||
--ifm-navbar-background-color: #ffffff;
|
|
||||||
--ifm-footer-background-color: #09090b;
|
|
||||||
|
|
||||||
--ifm-code-font-size: 90%;
|
|
||||||
--ifm-font-family-base:
|
--ifm-font-family-base:
|
||||||
"Inter", "Segoe UI", "Helvetica Neue", sans-serif;
|
"Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||||
--ifm-font-family-monospace:
|
--docusaurus-highlighted-code-line-bg: rgba(11, 111, 116, 0.1);
|
||||||
"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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button--primary {
|
.button--primary {
|
||||||
--ifm-button-background-color: var(--ifm-color-primary);
|
--ifm-button-background-color: #0b6f74;
|
||||||
--ifm-button-border-color: var(--ifm-color-primary);
|
--ifm-button-border-color: #0b6f74;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu__link--active,
|
.button--secondary {
|
||||||
.table-of-contents__link--active {
|
--ifm-button-background-color: #f4a636;
|
||||||
font-weight: 600;
|
--ifm-button-border-color: #f4a636;
|
||||||
|
--ifm-button-color: #1f2526;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .DocSearch-Button {
|
[data-theme="dark"] {
|
||||||
background: var(--ev-zinc-900);
|
--ifm-color-primary: #7ad6d4;
|
||||||
border: 1px solid var(--ev-zinc-800);
|
--ifm-color-primary-dark: #5cccca;
|
||||||
}
|
--ifm-color-primary-darker: #4cc6c4;
|
||||||
|
--ifm-color-primary-darkest: #34aaa8;
|
||||||
/* ---------- Code blocks ---------- */
|
--ifm-color-primary-light: #98e0de;
|
||||||
/* One quiet, low-contrast surface - matches effect.website: a subtle step
|
--ifm-color-primary-lighter: #a8e6e4;
|
||||||
off the page background, not a loud bordered card. */
|
--ifm-color-primary-lightest: #d5f4f3;
|
||||||
|
--ifm-background-color: #111c1f;
|
||||||
.theme-code-block {
|
--docusaurus-highlighted-code-line-bg: rgba(122, 214, 212, 0.18);
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
@@ -1,265 +1,223 @@
|
|||||||
.page {
|
.page {
|
||||||
--home-ink: #09090b;
|
--home-ink: #102f31;
|
||||||
--home-muted: #52525b;
|
--home-muted: #506a6c;
|
||||||
--home-emerald: #059669;
|
--home-teal: #08777b;
|
||||||
--home-emerald-bright: #10b981;
|
--home-teal-bright: #16a3a1;
|
||||||
--home-paper: #ffffff;
|
--home-amber: #e99526;
|
||||||
--home-card: #ffffff;
|
--home-paper: #fffdf8;
|
||||||
--home-line: #e4e4e7;
|
--home-card: rgba(255, 255, 255, 0.76);
|
||||||
--home-card-hover-line: rgba(5, 150, 105, 0.4);
|
--home-line: rgba(16, 47, 49, 0.12);
|
||||||
--home-terminal-bg: #09090b;
|
min-height: calc(100vh - var(--ifm-navbar-height));
|
||||||
--home-terminal-line: #27272a;
|
overflow: hidden;
|
||||||
background: var(--home-paper);
|
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);
|
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 {
|
.gridBackdrop {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(var(--home-line) 1px, transparent 1px),
|
linear-gradient(rgba(16, 47, 49, 0.045) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, var(--home-line) 1px, transparent 1px);
|
linear-gradient(90deg, rgba(16, 47, 49, 0.045) 1px, transparent 1px);
|
||||||
background-size: 56px 56px;
|
background-size: 48px 48px;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
mask-image: radial-gradient(ellipse 65% 55% at 50% 0%, black, transparent);
|
mask-image: linear-gradient(to bottom, black, transparent 62%);
|
||||||
opacity: 0.6;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroGlow {
|
.hero {
|
||||||
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 {
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: clamp(2.5rem, 5vw, 5rem);
|
gap: clamp(3rem, 6vw, 6rem);
|
||||||
grid-template-columns: minmax(0, 0.92fr) minmax(500px, 1.08fr);
|
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;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroCopy {
|
.heroCopy {
|
||||||
max-width: 600px;
|
max-width: 650px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow,
|
||||||
|
.sectionKicker {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid var(--home-line);
|
color: var(--home-teal);
|
||||||
border-radius: 999px;
|
display: flex;
|
||||||
color: var(--home-muted);
|
font-size: 0.76rem;
|
||||||
display: inline-flex;
|
font-weight: 850;
|
||||||
font-family: var(--ifm-font-family-monospace);
|
gap: 0.65rem;
|
||||||
font-size: 0.78rem;
|
letter-spacing: 0.18em;
|
||||||
font-weight: 500;
|
margin: 0 0 1.35rem;
|
||||||
gap: 0.55rem;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin: 0 0 1.75rem;
|
|
||||||
padding: 0.45rem 1rem;
|
|
||||||
text-decoration: none !important;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
transition: border-color 160ms ease, color 160ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow:hover {
|
.pulse {
|
||||||
border-color: var(--home-card-hover-line);
|
background: var(--home-teal-bright);
|
||||||
color: var(--home-ink);
|
border-radius: 50%;
|
||||||
}
|
box-shadow: 0 0 0 5px rgba(22, 163, 161, 0.14);
|
||||||
|
height: 0.55rem;
|
||||||
.eyebrowArrow {
|
width: 0.55rem;
|
||||||
color: var(--home-emerald);
|
|
||||||
transition: transform 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow:hover .eyebrowArrow {
|
|
||||||
transform: translateX(2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
color: var(--home-ink);
|
color: var(--home-ink);
|
||||||
font-size: clamp(2.6rem, 4.6vw, 3.9rem);
|
font-size: clamp(3.4rem, 6.4vw, 6.4rem);
|
||||||
font-weight: 800;
|
font-weight: 780;
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.075em;
|
||||||
line-height: 1.06;
|
line-height: 0.92;
|
||||||
margin: 0;
|
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 {
|
.lede {
|
||||||
color: var(--home-muted);
|
color: var(--home-muted);
|
||||||
font-size: clamp(1.02rem, 1.4vw, 1.15rem);
|
font-size: clamp(1.08rem, 1.55vw, 1.32rem);
|
||||||
line-height: 1.6;
|
line-height: 1.65;
|
||||||
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;
|
|
||||||
margin: 2rem 0 0;
|
margin: 2rem 0 0;
|
||||||
max-width: 420px;
|
max-width: 630px;
|
||||||
padding: 0.75rem 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.installPrompt {
|
|
||||||
color: var(--home-emerald-bright);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.installBox code {
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
color: #e4e4e7;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem;
|
gap: 0.8rem;
|
||||||
margin-top: 1.75rem;
|
margin-top: 2.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primaryAction,
|
.primaryAction,
|
||||||
.secondaryAction,
|
.secondaryAction,
|
||||||
.ctaAction {
|
.ctaAction {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 0.5rem;
|
border-radius: 999px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
font-size: 0.92rem;
|
font-weight: 750;
|
||||||
font-weight: 600;
|
gap: 0.8rem;
|
||||||
gap: 0.6rem;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.72rem 1.25rem;
|
padding: 0.88rem 1.35rem;
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
transition:
|
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
transform 160ms ease,
|
|
||||||
box-shadow 160ms ease,
|
|
||||||
background 160ms ease,
|
|
||||||
border-color 160ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.primaryAction {
|
.primaryAction {
|
||||||
background: var(--home-emerald);
|
background: var(--home-ink);
|
||||||
color: #fff;
|
box-shadow: 0 12px 28px rgba(16, 47, 49, 0.2);
|
||||||
|
color: #f6fffd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primaryAction:hover {
|
.primaryAction:hover {
|
||||||
background: var(--home-emerald-bright);
|
box-shadow: 0 16px 36px rgba(16, 47, 49, 0.28);
|
||||||
color: #06251c;
|
color: #fff;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondaryAction {
|
.secondaryAction {
|
||||||
background: transparent;
|
background: rgba(255, 255, 255, 0.58);
|
||||||
border: 1px solid var(--home-line);
|
border: 1px solid var(--home-line);
|
||||||
color: var(--home-ink);
|
color: var(--home-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondaryAction:hover {
|
.secondaryAction:hover {
|
||||||
border-color: var(--home-card-hover-line);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
color: var(--home-emerald);
|
color: var(--home-teal);
|
||||||
transform: translateY(-1px);
|
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 {
|
.hotReload {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--home-muted);
|
color: var(--home-teal);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
font-family: var(--ifm-font-family-monospace);
|
font-size: 0.82rem;
|
||||||
font-size: 0.8rem;
|
font-weight: 750;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 1.25rem;
|
margin-top: 0.75rem;
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hotReload span {
|
.hotReload span {
|
||||||
color: var(--home-emerald);
|
color: var(--home-amber);
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hotReload:hover {
|
.hotReload:hover {
|
||||||
color: var(--home-ink);
|
color: var(--home-teal-bright);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Code showcase ---------- */
|
|
||||||
|
|
||||||
.codeStage {
|
.codeStage {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
perspective: 1400px;
|
perspective: 1200px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codeGlow {
|
.codeGlow {
|
||||||
background: radial-gradient(
|
background: linear-gradient(115deg, rgba(11, 133, 136, 0.48), rgba(238, 156, 40, 0.38));
|
||||||
ellipse 70% 70% at 60% 30%,
|
filter: blur(55px);
|
||||||
rgba(16, 185, 129, 0.28),
|
inset: 8% 5%;
|
||||||
transparent 70%
|
opacity: 0.55;
|
||||||
);
|
|
||||||
filter: blur(50px);
|
|
||||||
inset: 6% 4%;
|
|
||||||
opacity: 0.75;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codeWindow {
|
.codeWindow {
|
||||||
background: var(--home-terminal-bg);
|
background: #101b1e;
|
||||||
border: 1px solid var(--home-terminal-line);
|
border: 1px solid rgba(166, 225, 220, 0.2);
|
||||||
border-radius: 0.75rem;
|
border-radius: 1.25rem;
|
||||||
box-shadow: 0 34px 90px rgba(0, 0, 0, 0.35);
|
box-shadow:
|
||||||
|
0 34px 90px rgba(18, 49, 52, 0.3),
|
||||||
|
0 2px 0 rgba(255, 255, 255, 0.06) inset;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
transform: rotateY(-4deg) rotateX(2deg);
|
transform: rotateY(-2deg) rotateX(1deg);
|
||||||
transition: transform 400ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeStage:hover .codeWindow {
|
|
||||||
transform: rotateY(-1deg) rotateX(0.5deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.windowBar {
|
.windowBar {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid var(--home-terminal-line);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
color: #a1a1aa;
|
color: #b6c9c9;
|
||||||
display: grid;
|
display: grid;
|
||||||
font-family: var(--ifm-font-family-monospace);
|
font-family: var(--ifm-font-family-monospace);
|
||||||
font-size: 0.72rem;
|
font-size: 0.73rem;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.78rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.windowDots {
|
.windowDots {
|
||||||
@@ -268,37 +226,80 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.windowDots span {
|
.windowDots span {
|
||||||
background: #3f3f46;
|
background: #4f6d6e;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
height: 0.55rem;
|
height: 0.55rem;
|
||||||
width: 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 {
|
.windowStatus {
|
||||||
color: var(--home-emerald-bright);
|
color: #74d2cd;
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codeWindow :global(.theme-code-block) {
|
.codeWindow :global(.theme-code-block) {
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codeWindow :global(.theme-code-block pre.thin-scrollbar) {
|
.codeWindow :global(.theme-code-block pre) {
|
||||||
background: var(--home-terminal-bg) !important;
|
background: #101b1e !important;
|
||||||
font-size: 0.8rem !important;
|
color: #d7e5e4 !important;
|
||||||
line-height: 1.62 !important;
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.62;
|
||||||
max-height: none;
|
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 {
|
.codeLegend {
|
||||||
border-top: 1px solid var(--home-terminal-line);
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
color: #a1a1aa;
|
color: #a9bcbc;
|
||||||
display: grid;
|
display: grid;
|
||||||
font-family: var(--ifm-font-family-monospace);
|
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
@@ -309,18 +310,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.codeLegend span + span {
|
.codeLegend span + span {
|
||||||
border-left: 1px solid var(--home-terminal-line);
|
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.codeLegend b {
|
.codeLegend b {
|
||||||
color: var(--home-emerald-bright);
|
color: #e8a94b;
|
||||||
margin-right: 0.3rem;
|
font-family: var(--ifm-font-family-monospace);
|
||||||
|
margin-right: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Features ---------- */
|
|
||||||
|
|
||||||
.featuresSection {
|
.featuresSection {
|
||||||
padding-bottom: 6rem;
|
padding-bottom: 7rem;
|
||||||
|
padding-top: 4rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
@@ -329,35 +330,27 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0 3rem;
|
gap: 0 3rem;
|
||||||
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
|
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: 2.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionKicker {
|
.sectionHeading .sectionKicker {
|
||||||
color: var(--home-emerald);
|
|
||||||
font-family: var(--ifm-font-family-monospace);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 600;
|
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
letter-spacing: 0.04em;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionHeading h2,
|
.sectionHeading h2,
|
||||||
.cta h2 {
|
.cta h2 {
|
||||||
color: var(--home-ink);
|
color: var(--home-ink);
|
||||||
font-size: clamp(1.9rem, 3.2vw, 2.6rem);
|
font-size: clamp(2.25rem, 4vw, 4rem);
|
||||||
font-weight: 700;
|
letter-spacing: -0.055em;
|
||||||
letter-spacing: -0.02em;
|
line-height: 1;
|
||||||
line-height: 1.1;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionHeading > p:last-child {
|
.sectionHeading > p:last-child {
|
||||||
align-self: end;
|
align-self: end;
|
||||||
color: var(--home-muted);
|
color: var(--home-muted);
|
||||||
font-size: 1rem;
|
font-size: 1.03rem;
|
||||||
line-height: 1.6;
|
line-height: 1.65;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,22 +363,41 @@
|
|||||||
.featureCard {
|
.featureCard {
|
||||||
background: var(--home-card);
|
background: var(--home-card);
|
||||||
border: 1px solid var(--home-line);
|
border: 1px solid var(--home-line);
|
||||||
border-radius: 0.75rem;
|
border-radius: 1.35rem;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 300px;
|
min-height: 310px;
|
||||||
padding: 1.4rem;
|
overflow: hidden;
|
||||||
|
padding: 1.45rem;
|
||||||
|
position: relative;
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
transition:
|
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||||
border-color 160ms ease,
|
}
|
||||||
transform 160ms 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 {
|
.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;
|
color: inherit;
|
||||||
transform: translateY(-3px);
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureCard:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scaleX(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardTopline {
|
.cardTopline {
|
||||||
@@ -396,36 +408,36 @@
|
|||||||
|
|
||||||
.featureIcon {
|
.featureIcon {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(5, 150, 105, 0.1);
|
background: rgba(8, 119, 123, 0.1);
|
||||||
border-radius: 0.4rem;
|
border: 1px solid rgba(8, 119, 123, 0.12);
|
||||||
color: var(--home-emerald);
|
border-radius: 0.8rem;
|
||||||
|
color: var(--home-teal);
|
||||||
display: flex;
|
display: flex;
|
||||||
font-family: var(--ifm-font-family-monospace);
|
font-family: var(--ifm-font-family-monospace);
|
||||||
font-size: 0.95rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 850;
|
||||||
height: 2.4rem;
|
height: 2.7rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 2.4rem;
|
width: 2.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardIndex {
|
.cardIndex {
|
||||||
color: var(--home-muted);
|
color: rgba(16, 47, 49, 0.32);
|
||||||
font-family: var(--ifm-font-family-monospace);
|
font-family: var(--ifm-font-family-monospace);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featureCard h3 {
|
.featureCard h3 {
|
||||||
color: var(--home-ink);
|
color: var(--home-ink);
|
||||||
font-size: 1.15rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 650;
|
letter-spacing: -0.025em;
|
||||||
letter-spacing: -0.01em;
|
margin: 2rem 0 0.7rem;
|
||||||
margin: 1.8rem 0 0.6rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.featureCard > p {
|
.featureCard > p {
|
||||||
color: var(--home-muted);
|
color: var(--home-muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.94rem;
|
||||||
line-height: 1.6;
|
line-height: 1.65;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,120 +446,150 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding-top: 1.4rem;
|
padding-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardFooter code {
|
.cardFooter code {
|
||||||
background: rgba(5, 150, 105, 0.1);
|
background: rgba(8, 119, 123, 0.08);
|
||||||
border: 0;
|
border: 0;
|
||||||
color: var(--home-emerald);
|
color: var(--home-teal);
|
||||||
font-size: 0.68rem;
|
font-size: 0.7rem;
|
||||||
padding: 0.32rem 0.5rem;
|
padding: 0.35rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardFooter > span {
|
.cardFooter > span {
|
||||||
color: var(--home-emerald);
|
color: var(--home-amber);
|
||||||
font-size: 1rem;
|
font-size: 1.1rem;
|
||||||
transition: transform 160ms ease;
|
transition: transform 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featureCard:hover .cardFooter > span {
|
.featureCard:hover .cardFooter > span {
|
||||||
transform: translate(2px, -2px);
|
transform: translate(3px, -3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- CTA ---------- */
|
|
||||||
|
|
||||||
.ctaWrap {
|
.ctaWrap {
|
||||||
padding-bottom: 6.5rem;
|
padding-bottom: 7rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta {
|
.cta {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--home-terminal-bg);
|
background:
|
||||||
border-radius: 1rem;
|
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;
|
display: flex;
|
||||||
gap: 2.5rem;
|
gap: 3rem;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: clamp(2rem, 4.5vw, 3.5rem);
|
padding: clamp(2rem, 5vw, 4rem);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta .sectionKicker {
|
.cta .sectionKicker {
|
||||||
color: var(--home-emerald-bright);
|
color: #79d5d1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta h2 {
|
.cta h2 {
|
||||||
color: #fafafa;
|
color: #f7fffd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta p:last-child {
|
.cta p:last-child {
|
||||||
color: #a1a1aa;
|
color: #b9cccc;
|
||||||
line-height: 1.6;
|
line-height: 1.65;
|
||||||
margin: 0.85rem 0 0;
|
margin: 1rem 0 0;
|
||||||
max-width: 620px;
|
max-width: 650px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctaAction {
|
.ctaAction {
|
||||||
background: var(--home-emerald);
|
background: #f0a13b;
|
||||||
color: #fff;
|
color: #162d2e;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctaAction:hover {
|
.ctaAction:hover {
|
||||||
background: var(--home-emerald-bright);
|
box-shadow: 0 14px 35px rgba(0, 0, 0, 0.24);
|
||||||
color: #06251c;
|
color: #102526;
|
||||||
transform: translateY(-1px);
|
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) {
|
@media screen and (max-width: 1180px) {
|
||||||
.heroGrid {
|
.hero {
|
||||||
gap: 3rem;
|
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 {
|
.hero h1 {
|
||||||
font-size: clamp(2.4rem, 4.4vw, 3.2rem);
|
font-size: clamp(3.2rem, 6vw, 5rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 996px) {
|
@media screen and (max-width: 996px) {
|
||||||
.heroGrid {
|
.hero {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
padding-bottom: 4rem;
|
||||||
|
padding-top: 4.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroCopy,
|
.heroCopy,
|
||||||
.codeStage,
|
.codeStage,
|
||||||
.codeWindow {
|
.codeWindow,
|
||||||
|
.codeWindow :global(.theme-code-block) {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroCopy {
|
.heroCopy {
|
||||||
max-width: 640px;
|
max-width: 760px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codeStage {
|
.codeStage {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 640px;
|
max-width: 760px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codeWindow {
|
.codeWindow {
|
||||||
@@ -557,43 +599,51 @@
|
|||||||
.featureGrid {
|
.featureGrid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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 {
|
.sectionHeading {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionHeading > p:last-child {
|
.sectionHeading > p:last-child {
|
||||||
margin-top: 1rem;
|
margin-top: 1.25rem;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.featureGrid {
|
.featureGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codeWindow :global(.theme-code-block pre) {
|
.featureCard {
|
||||||
font-size: 0.72rem;
|
min-height: 275px;
|
||||||
overflow-x: auto;
|
}
|
||||||
padding: 1.1rem !important;
|
|
||||||
|
.codeLegend {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLegend span + span {
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.windowStatus {
|
.windowStatus {
|
||||||
@@ -604,29 +654,25 @@
|
|||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.codeWindow :global(.theme-code-block pre) {
|
||||||
|
font-size: 0.69rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1.1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
.cta {
|
.cta {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.featureCard,
|
.featureCard,
|
||||||
|
.featureCard::before,
|
||||||
|
.cardFooter > span,
|
||||||
.primaryAction,
|
.primaryAction,
|
||||||
.secondaryAction,
|
.secondaryAction,
|
||||||
.ctaAction,
|
.ctaAction {
|
||||||
.eyebrow,
|
|
||||||
.eyebrowArrow,
|
|
||||||
.codeWindow {
|
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,44 +91,26 @@ export default function Home(): ReactNode {
|
|||||||
description="Write React function components as typed Effect programs"
|
description="Write React function components as typed Effect programs"
|
||||||
>
|
>
|
||||||
<main className={styles.page}>
|
<main className={styles.page}>
|
||||||
<section className={styles.hero}>
|
|
||||||
<div className={styles.gridBackdrop} aria-hidden="true" />
|
<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}>
|
<div className={styles.heroCopy}>
|
||||||
<Link className={styles.eyebrow} to="/docs/getting-started">
|
<div className={styles.eyebrow}>
|
||||||
<span aria-hidden="true">{"//"}</span>
|
<span className={styles.pulse} />
|
||||||
Effect View for React 19
|
Effect View for React 19
|
||||||
<span aria-hidden="true" className={styles.eyebrowArrow}>
|
</div>
|
||||||
→
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<h1>
|
<h1>
|
||||||
React components,
|
React <span className={styles.unbreakable}>components,</span>
|
||||||
<br />
|
<span className={styles.heroAccent}> powered by Effect.</span>
|
||||||
powered by Effect.
|
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className={styles.lede}>
|
<p className={styles.lede}>
|
||||||
Bring typed services, scoped resources, reactive state,
|
Bring typed services, scoped resources, reactive state, server
|
||||||
server queries, and schema-driven forms into React without
|
queries, and schema-driven forms into React without hiding either
|
||||||
hiding either framework.
|
framework.
|
||||||
</p>
|
</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}>
|
<div className={styles.actions}>
|
||||||
<Link
|
<Link className={styles.primaryAction} to="/docs/getting-started">
|
||||||
className={styles.primaryAction}
|
|
||||||
to="/docs/getting-started"
|
|
||||||
>
|
|
||||||
Start building
|
Start building
|
||||||
<span aria-hidden="true">→</span>
|
<span aria-hidden="true">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -140,6 +122,10 @@ export default function Home(): ReactNode {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.install}>
|
||||||
|
<span aria-hidden="true">$</span>
|
||||||
|
<code>npm install effect-view effect@beta</code>
|
||||||
|
</div>
|
||||||
<Link
|
<Link
|
||||||
className={styles.hotReload}
|
className={styles.hotReload}
|
||||||
to="/docs/getting-started#set-up-hot-reloading-with-vite"
|
to="/docs/getting-started#set-up-hot-reloading-with-vite"
|
||||||
@@ -163,16 +149,9 @@ export default function Home(): ReactNode {
|
|||||||
</div>
|
</div>
|
||||||
<CodeBlock language="tsx">{componentExample}</CodeBlock>
|
<CodeBlock language="tsx">{componentExample}</CodeBlock>
|
||||||
<div className={styles.codeLegend}>
|
<div className={styles.codeLegend}>
|
||||||
<span>
|
<span><b>01</b> Yield services</span>
|
||||||
<b>01</b> Yield services
|
<span><b>02</b> Own the lifecycle</span>
|
||||||
</span>
|
<span><b>03</b> Return JSX</span>
|
||||||
<span>
|
|
||||||
<b>02</b> Own the lifecycle
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<b>03</b> Return JSX
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +159,7 @@ export default function Home(): ReactNode {
|
|||||||
|
|
||||||
<section className={clsx("container", styles.featuresSection)}>
|
<section className={clsx("container", styles.featuresSection)}>
|
||||||
<div className={styles.sectionHeading}>
|
<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>
|
<h2>One model from render to resources.</h2>
|
||||||
<p>
|
<p>
|
||||||
Effect View adds the pieces React deliberately leaves open while
|
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)}>
|
<section className={clsx("container", styles.ctaWrap)}>
|
||||||
<div className={styles.cta}>
|
<div className={styles.cta}>
|
||||||
<div className={styles.ctaGridBackdrop} aria-hidden="true" />
|
<div>
|
||||||
<div className={styles.ctaCopy}>
|
<p className={styles.sectionKicker}>Ready when React is</p>
|
||||||
<p className={styles.sectionKicker}>{"// Ready when React is"}</p>
|
|
||||||
<h2>Start with one Effect component.</h2>
|
<h2>Start with one Effect component.</h2>
|
||||||
<p>
|
<p>
|
||||||
Add a runtime, cross the React boundary once, and grow into
|
Add a runtime, cross the React boundary once, and grow into the
|
||||||
the rest of the toolkit only when you need it.
|
rest of the toolkit only when you need it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link className={styles.ctaAction} to="/docs/getting-started">
|
<Link className={styles.ctaAction} to="/docs/getting-started">
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -51,7 +51,7 @@ describe("Subscribable", () => {
|
|||||||
await Effect.runPromise(Lens.set(right, "b"))
|
await Effect.runPromise(Lens.set(right, "b"))
|
||||||
await waitFor(() => expect(values).toContainEqual([2, "b"]))
|
await waitFor(() => expect(values).toContainEqual([2, "b"]))
|
||||||
|
|
||||||
await Effect.runPromise(Fiber.interrupt(collector))
|
Fiber.interruptFork(collector)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("useAll returns the latest values and rerenders when any input changes", async () => {
|
it("useAll returns the latest values and rerenders when any input changes", async () => {
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -25,12 +25,12 @@ data modeling into React 19 without replacing React's component model. Yield
|
|||||||
Effects and services from a component body, let scopes follow the React
|
Effects and services from a component body, let scopes follow the React
|
||||||
lifecycle, and return ordinary JSX.
|
lifecycle, and return ordinary JSX.
|
||||||
|
|
||||||
> **Effect v4:** 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
|
> your application uses Effect v3, use the legacy
|
||||||
> [`effect-fc` package](https://www.npmjs.com/package/effect-fc) instead.
|
> [`effect-fc` package](https://www.npmjs.com/package/effect-fc) instead.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install effect-view effect react
|
npm install effect-view effect@beta react
|
||||||
```
|
```
|
||||||
|
|
||||||
Effect View does not depend on `react-dom`. Install the renderer used by your
|
Effect View does not depend on `react-dom`. Install the renderer used by your
|
||||||
@@ -127,7 +127,7 @@ The complete documentation is available at **[thila.dev/effect-view](https://thi
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- React 19.2 or newer
|
- React 19.2 or newer
|
||||||
- Effect 4 or newer
|
- **Effect v4 beta** (`effect@beta`)
|
||||||
- TypeScript and `@types/react` for TypeScript projects
|
- TypeScript and `@types/react` for TypeScript projects
|
||||||
|
|
||||||
Effect View is renderer-independent and does not require `react-dom`.
|
Effect View is renderer-independent and does not require `react-dom`.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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*`.
|
|
||||||
@@ -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`).
|
|
||||||
@@ -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`).
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,18 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "effect-view",
|
"name": "effect-view",
|
||||||
"description": "Write React function components with Effect",
|
"description": "Write React function components with Effect",
|
||||||
"version": "0.3.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"./README.md",
|
"./README.md",
|
||||||
"./AGENTS.md",
|
"./dist"
|
||||||
"./CHANGELOG.md",
|
|
||||||
"./src/**/*.ts",
|
|
||||||
"!./src/**/*.test.ts",
|
|
||||||
"./dist/**/*.js",
|
|
||||||
"./dist/**/*.js.map",
|
|
||||||
"./dist/**/*.d.ts",
|
|
||||||
"./ai-docs/**/*.md"
|
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -24,14 +17,6 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"default": "./dist/index.js"
|
"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": {
|
"./Async": {
|
||||||
"types": "./dist/Async.d.ts",
|
"types": "./dist/Async.d.ts",
|
||||||
"default": "./dist/Async.js"
|
"default": "./dist/Async.js"
|
||||||
@@ -64,10 +49,6 @@
|
|||||||
"types": "./dist/MutationForm.d.ts",
|
"types": "./dist/MutationForm.d.ts",
|
||||||
"default": "./dist/MutationForm.js"
|
"default": "./dist/MutationForm.js"
|
||||||
},
|
},
|
||||||
"./Operation": {
|
|
||||||
"types": "./dist/Operation.d.ts",
|
|
||||||
"default": "./dist/Operation.js"
|
|
||||||
},
|
|
||||||
"./PubSub": {
|
"./PubSub": {
|
||||||
"types": "./dist/PubSub.d.ts",
|
"types": "./dist/PubSub.d.ts",
|
||||||
"default": "./dist/PubSub.js"
|
"default": "./dist/PubSub.js"
|
||||||
@@ -116,18 +97,19 @@
|
|||||||
"clean:modules": "rm -rf node_modules"
|
"clean:modules": "rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@effect/platform-browser": "4.0.0-beta.101",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"effect": "^4.0.0",
|
"effect": "4.0.0-beta.101",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"effect": "^4.0.0",
|
"effect": "4.0.0-beta.101",
|
||||||
"react": "^19.2.0"
|
"react": "^19.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.1.0",
|
"@standard-schema/spec": "^1.1.0",
|
||||||
"effect-lens": "2.0.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)
|
|
||||||
}),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
@@ -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 ValueService extends Context.Service<ValueService, { readonly value: string }>()("ValueService") {}
|
||||||
|
|
||||||
class ParentService extends Context.Service<ParentService, { readonly prefix: string }>()("ParentService") {}
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Component", () => {
|
describe("Component", () => {
|
||||||
it("provides a layer once per mounted component instance", async () => {
|
|
||||||
const setup = vi.fn()
|
|
||||||
const cleanup = vi.fn()
|
|
||||||
const serviceLayer = Layer.effect(ValueService, Effect.gen(function*() {
|
|
||||||
const parent = yield* ParentService
|
|
||||||
yield* Effect.sync(setup)
|
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(cleanup))
|
|
||||||
return { value: `${parent.prefix} value` }
|
|
||||||
}))
|
|
||||||
const runtime = ReactRuntime.make(Layer.succeed(ParentService, { prefix: "provided" }))
|
|
||||||
const effectRuntime = await runtime.runtime.context()
|
|
||||||
|
|
||||||
const Probe = Component.makeUntraced("ProvidedServiceProbe")(function*() {
|
|
||||||
const service = yield* ValueService
|
|
||||||
return <div>{service.value}</div>
|
|
||||||
}).pipe(
|
|
||||||
Component.provide(serviceLayer),
|
|
||||||
Component.withContext(runtime.context),
|
|
||||||
)
|
|
||||||
|
|
||||||
const view = render(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(await screen.findByText("provided value")).toBeTruthy()
|
|
||||||
expect(setup).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<runtime.context.Provider value={effectRuntime}>
|
|
||||||
<Probe />
|
|
||||||
</runtime.context.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(await screen.findByText("provided value")).toBeTruthy()
|
|
||||||
expect(setup).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
view.unmount()
|
|
||||||
await waitFor(() => expect(cleanup).toHaveBeenCalledTimes(1))
|
|
||||||
await runtime.runtime.dispose()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not rerun useOnMount across rerenders after Strict Mode initialization", async () => {
|
it("does not rerun useOnMount across rerenders after Strict Mode initialization", async () => {
|
||||||
const onMount = vi.fn(() => Effect.succeed("mounted"))
|
const onMount = vi.fn(() => Effect.succeed("mounted"))
|
||||||
const runtime = ReactRuntime.make(Layer.empty)
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
|
|||||||
@@ -607,57 +607,6 @@ export const withOptions: {
|
|||||||
Object.getPrototypeOf(self),
|
Object.getPrototypeOf(self),
|
||||||
))
|
))
|
||||||
|
|
||||||
export declare namespace provide {
|
|
||||||
export type Result<T extends Component.Any, ROut, E, RIn> = (
|
|
||||||
& Omit<T, keyof Component.AsComponent<T>>
|
|
||||||
& Component<
|
|
||||||
Component.Props<T>,
|
|
||||||
Component.Success<T>,
|
|
||||||
Component.Error<T> | E,
|
|
||||||
Exclude<Component.Context<T>, ROut> | RIn,
|
|
||||||
Component.Function<T>
|
|
||||||
>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides a layer to an Effect View component.
|
|
||||||
*
|
|
||||||
* The layer is built once for each mounted instance of the returned component and
|
|
||||||
* is released when that instance unmounts. Any services still required by the
|
|
||||||
* layer remain requirements of the returned component.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const TodosViewLive = Component.provide(TodosView, TodosService.Default)
|
|
||||||
*
|
|
||||||
* // Equivalent pipeline form
|
|
||||||
* const TodosViewLive = TodosView.pipe(
|
|
||||||
* Component.provide(TodosService.Default),
|
|
||||||
* )
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const provide: {
|
|
||||||
<ROut, E, RIn>(
|
|
||||||
layer: Layer.Layer<ROut, E, RIn>,
|
|
||||||
): <T extends Component.Any>(self: T) => provide.Result<T, ROut, E, RIn>
|
|
||||||
<T extends Component.Any, ROut, E, RIn>(
|
|
||||||
self: T,
|
|
||||||
layer: Layer.Layer<ROut, E, RIn>,
|
|
||||||
): provide.Result<T, ROut, E, RIn>
|
|
||||||
} = Function.dual(2, <T extends Component.Any, ROut, E, RIn>(
|
|
||||||
self: T,
|
|
||||||
layer: Layer.Layer<ROut, E, RIn>,
|
|
||||||
): provide.Result<T, ROut, E, RIn> => Object.setPrototypeOf(
|
|
||||||
Object.assign(function() {}, self, {
|
|
||||||
body: (props: Component.Props<T>) => Effect.flatMap(
|
|
||||||
useLayer(layer),
|
|
||||||
context => Effect.provide(self.body(props), context),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
Object.getPrototypeOf(self),
|
|
||||||
))
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps an Effect View Component and converts it into a standard React function component,
|
* Wraps an Effect View Component and converts it into a standard React function component,
|
||||||
* serving as an **entrypoint** into an Effect View component hierarchy.
|
* serving as an **entrypoint** into an Effect View component hierarchy.
|
||||||
@@ -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)
|
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 {}
|
export interface Options extends useOnChange.Options {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1163,7 +1112,7 @@ export declare namespace useLayer {
|
|||||||
*/
|
*/
|
||||||
export const useLayer = <ROut, E, RIn>(
|
export const useLayer = <ROut, E, RIn>(
|
||||||
layer: Layer.Layer<ROut, E, RIn>,
|
layer: Layer.Layer<ROut, E, RIn>,
|
||||||
options?: useLayer.Options,
|
options?: useContext.Options,
|
||||||
): Effect.Effect<Context.Context<ROut>, E, RIn | Scope.Scope> => useOnChange(() => Effect.flatMap(
|
): Effect.Effect<Context.Context<ROut>, E, RIn | Scope.Scope> => useOnChange(() => Effect.flatMap(
|
||||||
Effect.context<RIn>(),
|
Effect.context<RIn>(),
|
||||||
context => Layer.build(Layer.provide(layer, Layer.succeedContext(context))),
|
context => Layer.build(Layer.provide(layer, Layer.succeedContext(context))),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
||||||
import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
|
import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
|
||||||
import * as React from "react"
|
import type * as React from "react"
|
||||||
import * as Component from "./Component.js"
|
import * as Component from "./Component.js"
|
||||||
import * as Lens from "./Lens.js"
|
import * as Lens from "./Lens.js"
|
||||||
import * as View from "./View.js"
|
import * as View from "./View.js"
|
||||||
@@ -156,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 namespace useInput {
|
||||||
export interface Options {
|
export interface Options {
|
||||||
readonly debounce?: Duration.Input
|
readonly debounce?: Duration.Input
|
||||||
|
|||||||
@@ -189,13 +189,18 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const thenRun = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never, E = never, R = never>(
|
export declare namespace service {
|
||||||
self: Effect.Effect<LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>, E, R>,
|
export interface Options<in out A, out I = A, out RD = never, out RE = never, out TER = never, out TEW = never, out TRR = never, out TRW = never>
|
||||||
|
extends make.Options<A, I, RD, RE, TER, TEW, TRR, TRW> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const service = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never>(
|
||||||
|
options: service.Options<A, I, RD, RE, TER, TEW, TRR, TRW>
|
||||||
): Effect.Effect<
|
): Effect.Effect<
|
||||||
LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>,
|
LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>,
|
||||||
E,
|
Schema.SchemaError | TER,
|
||||||
Scope.Scope | R
|
Scope.Scope | RD | RE | TRR | TRW
|
||||||
> => Effect.tap(
|
> => Effect.tap(
|
||||||
self,
|
make(options),
|
||||||
form => Effect.forkScoped(form.run),
|
form => Effect.forkScoped(form.run),
|
||||||
)
|
)
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Context, Effect, Option, Pipeable, Predicate, type Scope, type Stream, SubscriptionRef } from "effect"
|
import { type Context, Effect, Equal, Exit, type Fiber, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
|
||||||
import { AsyncResult } from "effect/reactivity"
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
import * as Lens from "./Lens.js"
|
import * as Lens from "./Lens.js"
|
||||||
import * as Operation from "./Operation.js"
|
import * as View from "./View.js"
|
||||||
import * as QueryClient from "./QueryClient.js"
|
|
||||||
import type * as View from "./View.js"
|
|
||||||
|
|
||||||
|
|
||||||
export const MutationTypeId: unique symbol = Symbol.for("@effect-view/Mutation/Mutation")
|
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 {
|
extends Pipeable.Pipeable {
|
||||||
readonly [MutationTypeId]: MutationTypeId
|
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 f: (key: K) => Effect.Effect<A, E, R>
|
||||||
readonly networkMode: Operation.NetworkMode
|
|
||||||
|
|
||||||
readonly latestKey: View.View<Option.Option<K>>
|
readonly latestKey: View.View<Option.Option<K>>
|
||||||
readonly operation: View.View<Option.Option<Operation.Operation<Option.Some<K>, A, E, R>>>
|
readonly fiber: View.View<Option.Option<Fiber.Fiber<A, E>>>
|
||||||
readonly state: View.View<MutationLatestState<K, A, E>>
|
readonly state: View.View<AsyncResult.AsyncResult<A, E>>
|
||||||
readonly latestFinalState: View.View<Option.Option<MutationFinalState<K, 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>>
|
mutate(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>
|
||||||
mutateStream(key: K): Effect.Effect<Stream.Stream<MutationState<K, A, E>>>
|
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isMutation = (u: unknown): u is Mutation<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
|
export const isMutation = (u: unknown): u is Mutation<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
|
||||||
@@ -56,77 +31,99 @@ extends Pipeable.Class implements Mutation<K, A, E, R> {
|
|||||||
readonly [MutationTypeId]: MutationTypeId = MutationTypeId
|
readonly [MutationTypeId]: MutationTypeId = MutationTypeId
|
||||||
|
|
||||||
constructor(
|
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 f: (key: K) => Effect.Effect<A, E, R>,
|
||||||
readonly networkMode: Operation.NetworkMode,
|
|
||||||
|
|
||||||
readonly latestKey: Lens.Lens<Option.Option<K>>,
|
readonly latestKey: Lens.Lens<Option.Option<K>>,
|
||||||
readonly operation: Lens.Lens<Option.Option<Operation.Operation<Option.Some<K>, A, E, R>>>,
|
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
|
||||||
readonly state: Lens.Lens<MutationLatestState<K, A, E>>,
|
readonly state: Lens.Lens<AsyncResult.AsyncResult<A, E>>,
|
||||||
readonly latestFinalState: Lens.Lens<Option.Option<MutationFinalState<K, A, E>>>,
|
readonly latestFinalResult: Lens.Lens<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>,
|
||||||
|
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|
||||||
mutate(key: K): Effect.Effect<MutationFinalState<K, A, E>> {
|
mutate(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
|
||||||
return this.start(key).pipe(
|
return Lens.set(this.latestKey, Option.some(key)).pipe(
|
||||||
Effect.flatMap(operation => operation.await),
|
Effect.andThen(this.start(key)),
|
||||||
|
Effect.flatMap(state => this.watch(state)),
|
||||||
Effect.provide(this.context),
|
Effect.provide(this.context),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
mutateStream(key: K): Effect.Effect<Stream.Stream<MutationState<K, A, E>>> {
|
mutateView(key: K): Effect.Effect<View.View<AsyncResult.AsyncResult<A, E>>> {
|
||||||
return this.start(key).pipe(
|
return Lens.set(this.latestKey, Option.some(key)).pipe(
|
||||||
Effect.map(operation => operation.stream),
|
Effect.andThen(this.start(key)),
|
||||||
|
Effect.tap(state => Effect.forkScoped(this.watch(state))),
|
||||||
Effect.provide(this.context),
|
Effect.provide(this.context),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Starts a new Operation, which supersedes the current one without interrupting it. */
|
|
||||||
start(key: K): Effect.Effect<
|
start(key: K): Effect.Effect<
|
||||||
Operation.Operation<Option.Some<K>, A, E, R>,
|
View.View<AsyncResult.AsyncResult<A, E>>,
|
||||||
never,
|
never,
|
||||||
Mutation.Context<R>
|
Scope.Scope | R
|
||||||
> {
|
> {
|
||||||
return Effect.gen({ self: this }, function*() {
|
return Effect.gen({ self: this }, function*() {
|
||||||
const latestFinalState = yield* Lens.get(this.latestFinalState)
|
const previous = yield* Lens.get(this.latestFinalResult)
|
||||||
const operation = yield* Operation.make({
|
const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make<AsyncResult.AsyncResult<A, E>>(
|
||||||
key: Option.some(key) as Option.Some<K>,
|
Option.getOrElse(previous, () => AsyncResult.initial(false))
|
||||||
effect: this.f(key),
|
))
|
||||||
previous: Option.isSome(latestFinalState)
|
|
||||||
? latestFinalState.value.result
|
|
||||||
: AsyncResult.initial<A, E>(),
|
|
||||||
listener: this.listener,
|
|
||||||
networkMode: this.networkMode,
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* Lens.set(this.latestKey, Option.some(key))
|
const fiber = yield* Effect.forkScoped(Effect.andThen(
|
||||||
yield* Lens.set(this.operation, Option.some(operation))
|
Lens.update(state, AsyncResult.match({
|
||||||
yield* operation.start
|
onInitial: () => AsyncResult.initial(true),
|
||||||
return operation
|
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. */
|
Effect.onExit(this.f(key), exit => Lens.update(
|
||||||
ifCurrent(
|
state,
|
||||||
operation: Operation.Operation<Option.Some<K>, A, E, R>,
|
previous => Exit.match(exit, {
|
||||||
effect: Effect.Effect<void>,
|
onSuccess: v => AsyncResult.success(v),
|
||||||
): Effect.Effect<void> {
|
onFailure: c => AsyncResult.match(previous, {
|
||||||
return Effect.flatMap(
|
onInitial: () => AsyncResult.failure(c),
|
||||||
Lens.get(this.operation),
|
onSuccess: v => AsyncResult.failure(c, {
|
||||||
current => Option.isSome(current) && current.value === operation
|
previousSuccess: Option.some(v),
|
||||||
? effect
|
}),
|
||||||
|
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,
|
: 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. */
|
watch(
|
||||||
get listener(): Operation.OperationListener<Option.Some<K>, A, E, R> {
|
state: View.View<AsyncResult.AsyncResult<A, E>>
|
||||||
return {
|
): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>> {
|
||||||
onChange: (operation, state) => this.ifCurrent(operation, Lens.set(this.state, state)),
|
return View.get(state).pipe(
|
||||||
onSettle: (operation, state) => this.ifCurrent(operation, Lens.set(this.latestFinalState, Option.some(state))),
|
Effect.andThen(initial => Stream.runFoldEffect(
|
||||||
onInterrupt: () => Effect.void,
|
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 declare namespace make {
|
||||||
export interface Options<K = never, A = void, E = never, R = never> {
|
export interface Options<K = never, A = void, E = never, R = never> {
|
||||||
readonly f: (key: K) => Effect.Effect<A, E, R>
|
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<
|
): Effect.fn.Return<
|
||||||
Mutation<K, A, E, R>,
|
Mutation<K, A, E, R>,
|
||||||
never,
|
never,
|
||||||
Scope.Scope | QueryClient.QueryClient | R
|
Scope.Scope | R
|
||||||
> {
|
> {
|
||||||
const client = yield* QueryClient.QueryClient
|
|
||||||
|
|
||||||
return new MutationImpl(
|
return new MutationImpl(
|
||||||
Context.mergeAll(
|
yield* Effect.context<Scope.Scope | R>(),
|
||||||
client.context,
|
|
||||||
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | R>(),
|
|
||||||
),
|
|
||||||
options.f,
|
options.f,
|
||||||
options.networkMode ?? client.defaultMutationNetworkMode,
|
|
||||||
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<K>())),
|
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(Option.none<Fiber.Fiber<A, E>>())),
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<MutationLatestState<K, A, E>>({
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<AsyncResult.AsyncResult<A, E>>(AsyncResult.initial())),
|
||||||
key: Option.none(),
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>())),
|
||||||
result: AsyncResult.initial(),
|
|
||||||
paused: false,
|
|
||||||
})),
|
|
||||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<MutationFinalState<K, A, E>>())),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
||||||
import { Array, Cause, type Context, Effect, Fiber, Option, Pipeable, Predicate, Schema, SchemaIssue, type Scope, Semaphore, SubscriptionRef } from "effect"
|
import { Array, Cause, type Context, Effect, Fiber, Option, Pipeable, Predicate, Schema, SchemaError, SchemaIssue, type Scope, Semaphore, SubscriptionRef } from "effect"
|
||||||
import { AsyncResult } from "effect/reactivity"
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
import * as Form from "./Form.js"
|
import * as Form from "./Form.js"
|
||||||
import * as Lens from "./Lens.js"
|
import * as Lens from "./Lens.js"
|
||||||
import * as Mutation from "./Mutation.js"
|
import * as Mutation from "./Mutation.js"
|
||||||
import type * as QueryClient from "./QueryClient.js"
|
|
||||||
import * as View from "./View.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 validationFiber: View.View<Option.Option<Fiber.Fiber<A, Schema.SchemaError>>, never, never>
|
||||||
|
|
||||||
readonly run: Effect.Effect<void>
|
readonly run: Effect.Effect<void>
|
||||||
readonly submit: Effect.Effect<
|
readonly submit: Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, Cause.NoSuchElementError>
|
||||||
Option.Option<Mutation.MutationFinalState<
|
|
||||||
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
|
|
||||||
MA, ME
|
|
||||||
>>,
|
|
||||||
Cause.NoSuchElementError,
|
|
||||||
never
|
|
||||||
>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MutationFormImpl<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, in out MR = never>
|
export class MutationFormImpl<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, in out MR = never>
|
||||||
@@ -87,20 +79,17 @@ extends Pipeable.Class implements MutationForm<A, I, RD, RE, MA, ME, MR> {
|
|||||||
this.canCommit = Effect.succeed(this).pipe(
|
this.canCommit = Effect.succeed(this).pipe(
|
||||||
Effect.map(self => View.map(
|
Effect.map(self => View.map(
|
||||||
View.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.state),
|
View.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.state),
|
||||||
([value, issues, validationFiber, state]) => (
|
([value, issues, validationFiber, result]) => (
|
||||||
Option.isSome(value) &&
|
Option.isSome(value) &&
|
||||||
Array.isReadonlyArrayEmpty(issues) &&
|
Array.isReadonlyArrayEmpty(issues) &&
|
||||||
Option.isNone(validationFiber) &&
|
Option.isNone(validationFiber) &&
|
||||||
!AsyncResult.isWaiting(state.result)
|
!AsyncResult.isWaiting(result)
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
View.unwrap,
|
View.unwrap,
|
||||||
)
|
)
|
||||||
this.isCommitting = Effect.succeed(this).pipe(
|
this.isCommitting = Effect.succeed(this).pipe(
|
||||||
Effect.map(self => View.map(
|
Effect.map(self => View.map(self.mutation.state, AsyncResult.isWaiting)),
|
||||||
self.mutation.state,
|
|
||||||
state => AsyncResult.isWaiting(state.result),
|
|
||||||
)),
|
|
||||||
View.unwrap,
|
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.tap(() => Lens.set(this.issues, Array.empty())),
|
||||||
Effect.flatMap(value => Lens.set(this.value, Option.some(value))),
|
Effect.flatMap(value => Lens.set(this.value, Option.some(value))),
|
||||||
Effect.catchIf(
|
Effect.catchIf(
|
||||||
Schema.isSchemaError,
|
SchemaError.isSchemaError,
|
||||||
error => Lens.set(this.issues, SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues),
|
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<
|
get submit(): Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, Cause.NoSuchElementError, never> {
|
||||||
Option.Option<Mutation.MutationFinalState<
|
|
||||||
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
|
|
||||||
MA, ME
|
|
||||||
>>,
|
|
||||||
Cause.NoSuchElementError,
|
|
||||||
never
|
|
||||||
> {
|
|
||||||
return Lens.get(this.value).pipe(
|
return Lens.get(this.value).pipe(
|
||||||
Effect.flatMap(Effect.fromOption),
|
Effect.flatMap(Effect.fromOption),
|
||||||
Effect.flatMap(value => this.submitValue(value)),
|
Effect.flatMap(value => this.submitValue(value)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
submitValue(value: A): Effect.Effect<
|
submitValue(value: A): Effect.Effect<Option.Option<AsyncResult.Success<MA, ME> | AsyncResult.Failure<MA, ME>>, never, never> {
|
||||||
Option.Option<Mutation.MutationFinalState<
|
|
||||||
readonly [value: A, form: MutationForm<A, I, RD, RE, unknown, unknown, unknown>],
|
|
||||||
MA, ME
|
|
||||||
>>,
|
|
||||||
never,
|
|
||||||
never
|
|
||||||
> {
|
|
||||||
return Effect.when(
|
return Effect.when(
|
||||||
Effect.tap(
|
Effect.tap(
|
||||||
this.mutation.mutate([value, this as any]),
|
this.mutation.mutate([value, this as any]),
|
||||||
state => AsyncResult.isFailure(state.result)
|
result => AsyncResult.isFailure(result)
|
||||||
? Option.match(
|
? Option.match(
|
||||||
Array.findFirst(
|
Array.findFirst(
|
||||||
state.result.cause.reasons,
|
result.cause.reasons,
|
||||||
reason => Cause.isFailReason(reason) && Schema.isSchemaError(reason.error)
|
reason => Cause.isFailReason(reason) && SchemaError.isSchemaError(reason.error)
|
||||||
? Option.some(reason.error)
|
? Option.some(reason.error)
|
||||||
: Option.none(),
|
: Option.none(),
|
||||||
),
|
),
|
||||||
@@ -205,7 +180,7 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
|
|||||||
): Effect.fn.Return<
|
): Effect.fn.Return<
|
||||||
MutationForm<A, I, RD, RE, MA, ME, MR>,
|
MutationForm<A, I, RD, RE, MA, ME, MR>,
|
||||||
never,
|
never,
|
||||||
Scope.Scope | QueryClient.QueryClient | RD | RE | MR
|
Scope.Scope | RD | RE | MR
|
||||||
> {
|
> {
|
||||||
return new MutationFormImpl(
|
return new MutationFormImpl(
|
||||||
options.schema,
|
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>(
|
export declare namespace service {
|
||||||
self: Effect.Effect<MutationForm<A, I, RD, RE, MA, ME, MR>, E, R>,
|
export interface Options<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, out MR = never>
|
||||||
|
extends make.Options<A, I, RD, RE, MA, ME, MR> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const service = <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never>(
|
||||||
|
options: service.Options<A, I, RD, RE, MA, ME, MR>
|
||||||
): Effect.Effect<
|
): Effect.Effect<
|
||||||
MutationForm<A, I, RD, RE, MA, ME, MR>,
|
MutationForm<A, I, RD, RE, MA, ME, MR>,
|
||||||
E,
|
never,
|
||||||
Scope.Scope | R
|
Scope.Scope | RD | RE | MR
|
||||||
> => Effect.tap(
|
> => Effect.tap(
|
||||||
self,
|
make(options),
|
||||||
form => Effect.forkScoped(form.run),
|
form => Effect.forkScoped(form.run),
|
||||||
)
|
)
|
||||||
@@ -1,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,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
@@ -11,7 +11,7 @@ export const useFromReactiveValues = Effect.fnUntraced(function* <const A extend
|
|||||||
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
|
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
|
||||||
yield* Component.useReactEffect(() => Effect.flatMap(
|
yield* Component.useReactEffect(() => Effect.flatMap(
|
||||||
PubSub.isShutdown(pubsub),
|
PubSub.isShutdown(pubsub),
|
||||||
shutdown => shutdown ? Effect.void : Effect.asVoid(PubSub.publish(pubsub, values)),
|
shutdown => shutdown ? Effect.succeed(undefined) : Effect.asVoid(PubSub.publish(pubsub, values)),
|
||||||
), values)
|
), values)
|
||||||
return pubsub
|
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, type Scope, Stream } from "effect"
|
import { Effect, Schedule, type Scope, Stream } from "effect"
|
||||||
import { AsyncResult } from "effect/reactivity"
|
|
||||||
import { TestClock } from "effect/testing"
|
import { TestClock } from "effect/testing"
|
||||||
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
import { describe, expect, it } from "vitest"
|
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 Query from "./Query.js"
|
||||||
import * as QueryClient from "./QueryClient.js"
|
import * as QueryClient from "./QueryClient.js"
|
||||||
import * as View from "./View.js"
|
import * as View from "./View.js"
|
||||||
@@ -11,9 +9,7 @@ import * as View from "./View.js"
|
|||||||
|
|
||||||
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
|
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
|
||||||
Effect.runPromise(Effect.scoped(effect.pipe(
|
Effect.runPromise(Effect.scoped(effect.pipe(
|
||||||
Effect.provide(QueryClient.layer.pipe(
|
Effect.provide(QueryClient.layer()),
|
||||||
Layer.provide(Layer.mergeAll(AppConnectivity.layerDummy, AppFocus.layerDummy)),
|
|
||||||
)),
|
|
||||||
)))
|
)))
|
||||||
|
|
||||||
const staticKey = <K>(key: K): View.View<K> => View.make({
|
const staticKey = <K>(key: K): View.View<K> => View.make({
|
||||||
@@ -22,7 +18,7 @@ const staticKey = <K>(key: K): View.View<K> => View.make({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const expectSuccessValue = <A, E>(
|
const expectSuccessValue = <A, E>(
|
||||||
state: Query.QueryFinalState<unknown, A, E>,
|
state: Query.FinalQueryState<unknown, A, E>,
|
||||||
): A => {
|
): A => {
|
||||||
expect(AsyncResult.isSuccess(state.result)).toBe(true)
|
expect(AsyncResult.isSuccess(state.result)).toBe(true)
|
||||||
|
|
||||||
@@ -96,14 +92,14 @@ describe("Query", () => {
|
|||||||
return calls
|
return calls
|
||||||
}),
|
}),
|
||||||
staleTime: "0 millis",
|
staleTime: "0 millis",
|
||||||
}).pipe(
|
})
|
||||||
Query.thenRun,
|
|
||||||
Query.withScheduledRefresh(
|
yield* query.fetch([1])
|
||||||
|
const returnedQuery = yield* query.pipe(Query.withScheduledRefresh(
|
||||||
Schedule.spaced("1 second").pipe(
|
Schedule.spaced("1 second").pipe(
|
||||||
Schedule.upTo({ times: 1 }),
|
Schedule.upTo({ times: 1 }),
|
||||||
),
|
),
|
||||||
),
|
))
|
||||||
)
|
|
||||||
|
|
||||||
yield* TestClock.adjust("999 millis")
|
yield* TestClock.adjust("999 millis")
|
||||||
const beforeInterval = calls
|
const beforeInterval = calls
|
||||||
@@ -112,7 +108,7 @@ describe("Query", () => {
|
|||||||
const afterFirstInterval = calls
|
const afterFirstInterval = calls
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isQuery: Query.isQuery(query),
|
returnsQuery: returnedQuery === query,
|
||||||
beforeInterval,
|
beforeInterval,
|
||||||
afterFirstInterval,
|
afterFirstInterval,
|
||||||
}
|
}
|
||||||
@@ -121,7 +117,7 @@ describe("Query", () => {
|
|||||||
))
|
))
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
isQuery: true,
|
returnsQuery: true,
|
||||||
beforeInterval: 1,
|
beforeInterval: 1,
|
||||||
afterFirstInterval: 2,
|
afterFirstInterval: 2,
|
||||||
})
|
})
|
||||||
@@ -184,14 +180,14 @@ describe("Query", () => {
|
|||||||
const key = staticKey<readonly [number]>([1])
|
const key = staticKey<readonly [number]>([1])
|
||||||
|
|
||||||
const effect = Effect.gen(function*() {
|
const effect = Effect.gen(function*() {
|
||||||
const query = yield* Query.make({
|
const query = yield* Query.service({
|
||||||
key,
|
key,
|
||||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||||
calls += 1
|
calls += 1
|
||||||
return `value:${id}:${calls}`
|
return `value:${id}:${calls}`
|
||||||
}),
|
}),
|
||||||
staleTime: "1 minute",
|
staleTime: "1 minute",
|
||||||
}).pipe(Query.thenRun)
|
})
|
||||||
|
|
||||||
const latestFinalState = yield* Effect.sleep("1 millis").pipe(
|
const latestFinalState = yield* Effect.sleep("1 millis").pipe(
|
||||||
Effect.andThen(View.get(query.latestFinalState)),
|
Effect.andThen(View.get(query.latestFinalState)),
|
||||||
@@ -212,200 +208,4 @@ describe("Query", () => {
|
|||||||
expect(result.state.key).toEqual([1])
|
expect(result.state.key).toEqual([1])
|
||||||
expect(expectSuccessValue(result.latestFinalState)).toBe("value:1: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 })
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
+296
-254
@@ -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 { Cause, type Context, Duration, Effect, Equal, type Equivalence, Exit, Fiber, Function, Option, Pipeable, Predicate, PubSub, Ref, type Schedule, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||||
import { AsyncResult } from "effect/reactivity"
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
import * as AppConnectivity from "./AppConnectivity.js"
|
|
||||||
import * as AppFocus from "./AppFocus.js"
|
|
||||||
import * as Lens from "./Lens.js"
|
import * as Lens from "./Lens.js"
|
||||||
import * as Operation from "./Operation.js"
|
|
||||||
import * as QueryClient from "./QueryClient.js"
|
import * as QueryClient from "./QueryClient.js"
|
||||||
import * as View from "./View.js"
|
import * as View from "./View.js"
|
||||||
|
|
||||||
@@ -15,55 +12,38 @@ export interface Query<in out K, out A, out E = never, in out R = never>
|
|||||||
extends Pipeable.Pipeable {
|
extends Pipeable.Pipeable {
|
||||||
readonly [QueryTypeId]: QueryTypeId
|
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 key: View.View<K>
|
||||||
readonly keyEquivalence: Equivalence.Equivalence<K>
|
readonly keyEquivalence: Equivalence.Equivalence<K>
|
||||||
readonly f: (key: K) => Effect.Effect<A, E, R>
|
readonly f: (key: K) => Effect.Effect<A, E, R>
|
||||||
|
|
||||||
readonly staleTime: Duration.Duration
|
readonly staleTime: Duration.Duration
|
||||||
readonly networkMode: Operation.NetworkMode
|
readonly refreshOnWindowFocus: boolean
|
||||||
readonly refreshOnFocus: RefreshOn
|
|
||||||
readonly refreshOnReconnect: RefreshOn
|
|
||||||
|
|
||||||
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 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>
|
readonly run: Effect.Effect<void>
|
||||||
fetch(key: K): Effect.Effect<QueryFinalState<K, A, E>, Cause.NoSuchElementError>
|
fetch(key: K): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError>
|
||||||
fetchStream(key: K): Effect.Effect<Stream.Stream<QueryState<K, A, E>>, Cause.NoSuchElementError>
|
fetchView(key: K): Effect.Effect<View.View<QueryState<K, A, E>>, Cause.NoSuchElementError>
|
||||||
readonly refresh: Effect.Effect<QueryFinalState<K, A, E>, Cause.NoSuchElementError>
|
readonly refresh: Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError>
|
||||||
readonly refreshStream: Effect.Effect<Stream.Stream<QueryState<K, A, E>>, Cause.NoSuchElementError>
|
readonly refreshView: Effect.Effect<View.View<QueryState<K, A, E>>, Cause.NoSuchElementError>
|
||||||
|
|
||||||
readonly invalidateCache: Effect.Effect<void>
|
readonly invalidateCache: Effect.Effect<void>
|
||||||
invalidateCacheEntry(key: K): 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> {
|
export interface QueryState<out K, out A, out E = never> {
|
||||||
readonly key: K
|
readonly key: K
|
||||||
readonly result: AsyncResult.AsyncResult<A, E>
|
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 key: K
|
||||||
readonly result: AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>
|
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)
|
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
|
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly context: Context.Context<Query.Context<R>>,
|
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>,
|
||||||
readonly key: View.View<K>,
|
readonly key: View.View<K>,
|
||||||
readonly keyEquivalence: Equivalence.Equivalence<K>,
|
readonly keyEquivalence: Equivalence.Equivalence<K>,
|
||||||
readonly f: (key: K) => Effect.Effect<A, E, R>,
|
readonly f: (key: K) => Effect.Effect<A, E, R>,
|
||||||
|
|
||||||
readonly staleTime: Duration.Duration,
|
readonly staleTime: Duration.Duration,
|
||||||
readonly networkMode: Operation.NetworkMode,
|
readonly refreshOnWindowFocus: boolean,
|
||||||
readonly refreshOnFocus: RefreshOn,
|
|
||||||
readonly refreshOnReconnect: RefreshOn,
|
|
||||||
|
|
||||||
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 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 runSemaphore: Semaphore.Semaphore,
|
||||||
readonly startSemaphore: Semaphore.Semaphore,
|
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
@@ -96,22 +73,33 @@ extends Pipeable.Class implements Query<K, A, E, R> {
|
|||||||
return Effect.all([
|
return Effect.all([
|
||||||
Stream.runForEach(
|
Stream.runForEach(
|
||||||
this.key.changes,
|
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.promise(() => import("@effect/platform-browser")).pipe(
|
||||||
? Effect.flatMap(AppFocus.AppFocus, ({ focused }) => Stream.runForEach(
|
Effect.flatMap(({ BrowserStream }) => this.refreshOnWindowFocus
|
||||||
becomesTrue(focused),
|
? Stream.runForEach(
|
||||||
() => this.refreshUnlessWaiting(this.refreshOnFocus === "always"),
|
BrowserStream.fromEventListenerWindow("focus"),
|
||||||
))
|
() => this.refreshView,
|
||||||
: Effect.void,
|
)
|
||||||
|
: Effect.void
|
||||||
this.refreshOnReconnect
|
),
|
||||||
? Effect.flatMap(AppConnectivity.AppConnectivity, ({ online }) => Stream.runForEach(
|
Effect.catchDefect(() => Effect.void),
|
||||||
becomesTrue(online),
|
),
|
||||||
() => this.refreshUnlessWaiting(this.refreshOnReconnect === "always"),
|
|
||||||
))
|
|
||||||
: Effect.void,
|
|
||||||
], { concurrency: "unbounded" }).pipe(
|
], { concurrency: "unbounded" }).pipe(
|
||||||
Effect.ignore,
|
Effect.ignore,
|
||||||
this.runSemaphore.withPermits(1),
|
this.runSemaphore.withPermits(1),
|
||||||
@@ -120,156 +108,219 @@ extends Pipeable.Class implements Query<K, A, E, R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get interrupt(): Effect.Effect<void> {
|
get interrupt(): Effect.Effect<void> {
|
||||||
return Effect.flatMap(Lens.get(this.operation), Option.match({
|
return Effect.flatMap(Lens.get(this.fiber), Option.match({
|
||||||
onSome: operation => operation.interrupt,
|
onSome: Fiber.interrupt,
|
||||||
onNone: () => Effect.void,
|
onNone: () => Effect.void,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(key: K): Effect.Effect<QueryFinalState<K, A, E>, Cause.NoSuchElementError> {
|
fetch(key: K): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> {
|
||||||
return this.start(Effect.succeed({
|
return Effect.gen({ self: this }, function*() {
|
||||||
|
yield* this.interrupt
|
||||||
|
const state = yield* this.startCached({
|
||||||
key,
|
key,
|
||||||
result: AsyncResult.initial(false),
|
result: AsyncResult.initial(false),
|
||||||
paused: false,
|
})
|
||||||
})).pipe(
|
return yield* this.watch(state)
|
||||||
Effect.flatMap(operation => operation.await),
|
}).pipe(
|
||||||
Effect.provide(this.context),
|
Effect.provide(this.context),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchStream(key: K): Effect.Effect<
|
fetchView(key: K): Effect.Effect<
|
||||||
Stream.Stream<QueryState<K, A, E>>,
|
View.View<QueryState<K, A, E>>,
|
||||||
Cause.NoSuchElementError
|
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*() {
|
return Effect.gen({ self: this }, function*() {
|
||||||
yield* this.interrupt
|
yield* this.interrupt
|
||||||
|
const state = yield* this.startCached({
|
||||||
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> = {
|
|
||||||
key,
|
key,
|
||||||
result: entry.value.result as AsyncResult.Success<A, E>,
|
result: AsyncResult.initial(false),
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
yield* Lens.set(this.operation, Option.some(operation))
|
|
||||||
yield* operation.start
|
yield* Effect.forkScoped(this.watch(state))
|
||||||
return operation
|
return state
|
||||||
}
|
|
||||||
}).pipe(
|
}).pipe(
|
||||||
this.startSemaphore.withPermits(1),
|
Effect.provide(this.context),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runs `effect` only if `operation` is the current Operation. */
|
get refresh(): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> {
|
||||||
ifCurrent(
|
return Effect.gen({ self: this }, function*() {
|
||||||
operation: Operation.Operation<K, A, E, QueryClient.QueryClient | R>,
|
yield* this.interrupt
|
||||||
effect: Effect.Effect<void>,
|
const latestState = yield* Lens.get(this.state)
|
||||||
): Effect.Effect<void> {
|
const latestFinalState = yield* Lens.get(this.latestFinalState)
|
||||||
return Effect.flatMap(
|
|
||||||
Lens.get(this.operation),
|
const state = yield* this.startCached(
|
||||||
current => Option.isSome(current) && current.value === operation
|
Option.isSome(latestFinalState) && this.keyEquivalence(latestState.key, latestFinalState.value.key)
|
||||||
? effect
|
? latestFinalState.value
|
||||||
: Effect.void,
|
: {
|
||||||
|
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 refreshView(): Effect.Effect<
|
||||||
get listener(): Operation.OperationListener<K, A, E, QueryClient.QueryClient | R> {
|
View.View<QueryState<K, A, E>>,
|
||||||
return {
|
Cause.NoSuchElementError
|
||||||
onChange: (operation, state) => this.ifCurrent(operation, Lens.set(this.state, state)),
|
> {
|
||||||
onSettle: (operation, state) => this.ifCurrent(operation, Lens.set(this.latestFinalState, Option.some(state))),
|
return Effect.gen({ self: this }, function*() {
|
||||||
onInterrupt: () => Effect.void,
|
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 {
|
makeCacheKey(key: K): QueryClient.QueryClientCacheKey {
|
||||||
@@ -326,10 +377,7 @@ export declare namespace make {
|
|||||||
readonly f: (key: K) => Effect.Effect<A, E, R>
|
readonly f: (key: K) => Effect.Effect<A, E, R>
|
||||||
|
|
||||||
readonly staleTime?: Duration.Input
|
readonly staleTime?: Duration.Input
|
||||||
readonly networkMode?: Operation.NetworkMode
|
readonly refreshOnWindowFocus?: boolean
|
||||||
readonly refreshOnFocus?: RefreshOn
|
|
||||||
/** Defaults to the client's `defaultRefreshOnReconnect`, and to `false` in `"always"` network mode. */
|
|
||||||
readonly refreshOnReconnect?: RefreshOn
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,118 +389,112 @@ export const make = Effect.fnUntraced(function* <K, A, E = never, R = never>(
|
|||||||
Scope.Scope | QueryClient.QueryClient | R
|
Scope.Scope | QueryClient.QueryClient | R
|
||||||
> {
|
> {
|
||||||
const client = yield* QueryClient.QueryClient
|
const client = yield* QueryClient.QueryClient
|
||||||
const networkMode = options.networkMode ?? client.defaultNetworkMode
|
|
||||||
|
|
||||||
return new QueryImpl(
|
return new QueryImpl(
|
||||||
Context.mergeAll(
|
|
||||||
client.context,
|
|
||||||
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | R>(),
|
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | R>(),
|
||||||
),
|
|
||||||
options.key,
|
options.key,
|
||||||
options.keyEquivalence ?? Equal.asEquivalence(),
|
options.keyEquivalence ?? Equal.asEquivalence(),
|
||||||
options.f,
|
options.f,
|
||||||
|
|
||||||
options.staleTime ? yield* Effect.fromOption(Duration.fromInput(options.staleTime)) : client.defaultStaleTime,
|
options.staleTime ? yield* Effect.fromOption(Duration.fromInput(options.staleTime)) : client.defaultStaleTime,
|
||||||
networkMode,
|
options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus,
|
||||||
options.refreshOnFocus ?? client.defaultRefreshOnFocus,
|
|
||||||
options.refreshOnReconnect ?? (networkMode === "always" ? false : client.defaultRefreshOnReconnect),
|
|
||||||
|
|
||||||
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>>({
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<QueryState<K, A, E>>({
|
||||||
key: yield* View.get(options.key),
|
key: yield* View.get(options.key),
|
||||||
result: AsyncResult.initial(false),
|
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),
|
||||||
yield* Semaphore.make(1),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Emits each time `view` becomes true, after its current value. */
|
export const service = <K, A, E = never, R = never>(
|
||||||
const becomesTrue = (view: View.View<boolean>): Stream.Stream<true> => View.changes(view).pipe(
|
options: make.Options<K, A, E, R>
|
||||||
Stream.changes,
|
): Effect.Effect<
|
||||||
Stream.drop(1),
|
Query<K, A, E, R>,
|
||||||
Stream.filter((value): value is true => value),
|
Cause.NoSuchElementError,
|
||||||
)
|
Scope.Scope | QueryClient.QueryClient | R
|
||||||
|
> => Effect.tap(
|
||||||
export const thenRun = <K, A, E = never, R = never, E2 = never, R2 = never>(
|
make(options),
|
||||||
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
|
|
||||||
): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | R2> => Effect.tap(
|
|
||||||
self,
|
|
||||||
query => Effect.forkScoped(query.run),
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes the Query on a schedule and returns it. By default, refreshes are skipped while the app is not focused.
|
* Refreshes the Query on a schedule and returns it.
|
||||||
*
|
*
|
||||||
* @example Refresh every five minutes, starting after five minutes
|
* @example Refresh every five minutes, starting after five minutes
|
||||||
* ```ts
|
* ```ts
|
||||||
* yield* Query.make(options).pipe(
|
* yield* query.pipe(
|
||||||
* Query.thenRun,
|
|
||||||
* Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
|
* Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
|
||||||
* )
|
* )
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @example Refresh at most three times
|
* @example Refresh at most three times
|
||||||
* ```ts
|
* ```ts
|
||||||
* yield* Query.make(options).pipe(
|
* yield* query.pipe(
|
||||||
* Query.thenRun,
|
|
||||||
* Query.withScheduledRefresh(
|
* Query.withScheduledRefresh(
|
||||||
* Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 })),
|
* 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: {
|
export const withScheduledRefresh: {
|
||||||
<Output, Error, Env>(
|
<Output, Error, Env>(
|
||||||
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
|
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
|
||||||
options?: withScheduledRefresh.Options,
|
): <K, A, E, R>(
|
||||||
): <K, A, E, R, E2, R2>(
|
self: Query<K, A, E, R>,
|
||||||
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
|
) => Effect.Effect<Query<K, A, E, R>, never, Scope.Scope | Env>
|
||||||
) => Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | Env | R2>
|
<K, A, E, R, Output, Error, Env>(
|
||||||
<K, A, E, R, E2, R2, Output, Error, Env>(
|
self: Query<K, A, E, R>,
|
||||||
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
|
|
||||||
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
|
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
|
||||||
options?: withScheduledRefresh.Options,
|
): Effect.Effect<Query<K, A, E, R>, never, Scope.Scope | Env>
|
||||||
): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | Env | R2>
|
} = Function.dual(2, <K, A, E, R, Output, Error, Env>(
|
||||||
} = Function.dual(args => Effect.isEffect(args[0]), <K, A, E, R, E2, R2, Output, Error, Env>(
|
self: Query<K, A, E, R>,
|
||||||
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
|
|
||||||
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
|
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
|
||||||
options?: withScheduledRefresh.Options,
|
) => Effect.schedule(self.refresh, schedule).pipe(
|
||||||
) => Effect.tap(
|
Effect.forkScoped,
|
||||||
self,
|
Effect.as(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 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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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)),
|
||||||
|
)
|
||||||
@@ -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 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 type { AsyncResult } from "effect/unstable/reactivity"
|
||||||
import * as AppConnectivity from "./AppConnectivity.js"
|
|
||||||
import * as AppFocus from "./AppFocus.js"
|
|
||||||
import * as Lens from "./Lens.js"
|
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"
|
import type * as View from "./View.js"
|
||||||
|
|
||||||
|
|
||||||
@@ -14,18 +10,12 @@ export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
|
|||||||
export interface QueryClientService extends Pipeable.Pipeable {
|
export interface QueryClientService extends Pipeable.Pipeable {
|
||||||
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
|
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
|
||||||
|
|
||||||
readonly context: Context.Context<QueryClientService.Context>
|
|
||||||
readonly cache: View.View<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
|
readonly cache: View.View<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
|
||||||
readonly cacheGcTime: Duration.Duration
|
readonly cacheGcTime: Duration.Duration
|
||||||
|
|
||||||
readonly defaultStaleTime: Duration.Duration
|
readonly defaultStaleTime: Duration.Duration
|
||||||
readonly defaultNetworkMode: Operation.NetworkMode
|
readonly defaultRefreshOnWindowFocus: boolean
|
||||||
readonly defaultRefreshOnFocus: Query.RefreshOn
|
|
||||||
readonly defaultRefreshOnReconnect: Query.RefreshOn
|
|
||||||
readonly defaultMutationNetworkMode: Operation.NetworkMode
|
|
||||||
|
|
||||||
readonly run: Effect.Effect<void, Cause.NoSuchElementError>
|
readonly run: Effect.Effect<void, Cause.NoSuchElementError>
|
||||||
|
|
||||||
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>>
|
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>>
|
||||||
setCacheEntry(
|
setCacheEntry(
|
||||||
key: QueryClientCacheKey,
|
key: QueryClientCacheKey,
|
||||||
@@ -36,9 +26,9 @@ export interface QueryClientService extends Pipeable.Pipeable {
|
|||||||
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void>
|
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace QueryClientService {
|
export class QueryClient extends Context.Service<QueryClient, QueryClientService>()(
|
||||||
export type Context = AppConnectivity.AppConnectivity | AppFocus.AppFocus
|
"@effect-view/QueryClient/QueryClient"
|
||||||
}
|
) {}
|
||||||
|
|
||||||
export class QueryClientServiceImpl
|
export class QueryClientServiceImpl
|
||||||
extends Pipeable.Class
|
extends Pipeable.Class
|
||||||
@@ -46,16 +36,10 @@ implements QueryClientService {
|
|||||||
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId
|
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId = QueryClientServiceTypeId
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly context: Context.Context<QueryClientService.Context>,
|
|
||||||
readonly cache: Lens.Lens<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
|
readonly cache: Lens.Lens<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
|
||||||
readonly cacheGcTime: Duration.Duration,
|
readonly cacheGcTime: Duration.Duration,
|
||||||
|
|
||||||
readonly defaultStaleTime: Duration.Duration,
|
readonly defaultStaleTime: Duration.Duration,
|
||||||
readonly defaultNetworkMode: Operation.NetworkMode,
|
readonly defaultRefreshOnWindowFocus: boolean,
|
||||||
readonly defaultRefreshOnFocus: Query.RefreshOn,
|
|
||||||
readonly defaultRefreshOnReconnect: Query.RefreshOn,
|
|
||||||
readonly defaultMutationNetworkMode: Operation.NetworkMode,
|
|
||||||
|
|
||||||
readonly runSemaphore: Semaphore.Semaphore,
|
readonly runSemaphore: Semaphore.Semaphore,
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
@@ -115,81 +99,34 @@ export declare namespace make {
|
|||||||
export interface Options {
|
export interface Options {
|
||||||
readonly cacheGcTime?: Duration.Input
|
readonly cacheGcTime?: Duration.Input
|
||||||
readonly defaultStaleTime?: Duration.Input
|
readonly defaultStaleTime?: Duration.Input
|
||||||
/** The default network mode of queries. */
|
readonly defaultRefreshOnWindowFocus?: boolean
|
||||||
readonly defaultNetworkMode?: Operation.NetworkMode
|
|
||||||
readonly defaultRefreshOnFocus?: Query.RefreshOn
|
|
||||||
readonly defaultRefreshOnReconnect?: Query.RefreshOn
|
|
||||||
/** The default network mode of mutations. */
|
|
||||||
readonly defaultMutationNetworkMode?: Operation.NetworkMode
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const make = Effect.fnUntraced(function* (
|
export const make = Effect.fnUntraced(function* (
|
||||||
options: make.Options = {}
|
options: make.Options = {}
|
||||||
): Effect.fn.Return<
|
): Effect.fn.Return<QueryClientService, Cause.NoSuchElementError, never> {
|
||||||
QueryClientService,
|
|
||||||
Cause.NoSuchElementError,
|
|
||||||
QueryClientService.Context
|
|
||||||
> {
|
|
||||||
return new QueryClientServiceImpl(
|
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>())),
|
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>())),
|
||||||
yield* Effect.fromOption(Duration.fromInput(options.cacheGcTime ?? "5 minutes")),
|
yield* Effect.fromOption(Duration.fromInput(options.cacheGcTime ?? "5 minutes")),
|
||||||
yield* Effect.fromOption(Duration.fromInput(options.defaultStaleTime ?? "0 minutes")),
|
yield* Effect.fromOption(Duration.fromInput(options.defaultStaleTime ?? "0 minutes")),
|
||||||
|
options.defaultRefreshOnWindowFocus ?? true,
|
||||||
options.defaultNetworkMode ?? "online",
|
|
||||||
options.defaultRefreshOnFocus ?? true,
|
|
||||||
options.defaultRefreshOnReconnect ?? true,
|
|
||||||
options.defaultMutationNetworkMode ?? "online",
|
|
||||||
|
|
||||||
yield* Semaphore.make(1),
|
yield* Semaphore.make(1),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const thenRun = <E = never, R = never>(
|
export declare namespace service {
|
||||||
self: Effect.Effect<QueryClientService, E, R>,
|
export interface Options extends make.Options {}
|
||||||
): Effect.Effect<QueryClientService, E, Scope.Scope | R> => Effect.tap(
|
}
|
||||||
self,
|
|
||||||
|
export const service = (
|
||||||
|
options?: service.Options
|
||||||
|
): Effect.Effect<QueryClientService, Cause.NoSuchElementError, Scope.Scope> => Effect.tap(
|
||||||
|
make(options),
|
||||||
client => Effect.forkScoped(client.run),
|
client => Effect.forkScoped(client.run),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const layer = (options?: service.Options) => Layer.effect(QueryClient, service(options))
|
||||||
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 QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey")
|
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Effect, Equivalence, Stream, SubscriptionRef } from "effect"
|
import { Effect, Equivalence, Stream } from "effect"
|
||||||
import { Lens, View } from "effect-lens"
|
import { View } from "effect-lens"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as Component from "./Component.js"
|
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
|
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
|
|
||||||
})
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
export * as AppConnectivity from "./AppConnectivity.js"
|
|
||||||
export * as AppFocus from "./AppFocus.js"
|
|
||||||
export * as Async from "./Async.js"
|
export * as Async from "./Async.js"
|
||||||
export * as Component from "./Component.js"
|
export * as Component from "./Component.js"
|
||||||
export * as Form from "./Form.js"
|
export * as Form from "./Form.js"
|
||||||
@@ -8,7 +6,6 @@ export * as LensForm from "./LensForm.js"
|
|||||||
export * as Memoized from "./Memoized.js"
|
export * as Memoized from "./Memoized.js"
|
||||||
export * as Mutation from "./Mutation.js"
|
export * as Mutation from "./Mutation.js"
|
||||||
export * as MutationForm from "./MutationForm.js"
|
export * as MutationForm from "./MutationForm.js"
|
||||||
export * as Operation from "./Operation.js"
|
|
||||||
export * as PubSub from "./PubSub.js"
|
export * as PubSub from "./PubSub.js"
|
||||||
export * as Query from "./Query.js"
|
export * as Query from "./Query.js"
|
||||||
export * as QueryClient from "./QueryClient.js"
|
export * as QueryClient from "./QueryClient.js"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../../node_modules/@effect/tsgo/schema.json",
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Enable latest features
|
// Enable latest features
|
||||||
"lib": ["ESNext", "DOM"],
|
"lib": ["ESNext", "DOM"],
|
||||||
@@ -36,5 +35,5 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
"include": ["./src"]
|
"include": ["./src"],
|
||||||
}
|
}
|
||||||
@@ -27,15 +27,15 @@
|
|||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-browser": "^4.0.0",
|
"@effect/platform-browser": "4.0.0-beta.101",
|
||||||
"@radix-ui/themes": "^3.3.0",
|
"@radix-ui/themes": "^3.3.0",
|
||||||
"effect": "^4.0.0",
|
"effect": "4.0.0-beta.101",
|
||||||
"effect-view": "workspace:*",
|
"effect-view": "workspace:*",
|
||||||
"react-icons": "^5.6.0"
|
"react-icons": "^5.6.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"effect": "^4.0.0",
|
"effect": "4.0.0-beta.101",
|
||||||
"react": "^19.2.6"
|
"react": "^19.2.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,13 +26,11 @@ const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||||
const form = yield* MutationForm.make({
|
const form = yield* MutationForm.service({
|
||||||
schema: RegisterSchema,
|
schema: RegisterSchema,
|
||||||
initialEncodedValue: { email: "", password: "" },
|
initialEncodedValue: { email: "", password: "" },
|
||||||
f: ([value]) => Effect.log(`Registered ${value.email}`),
|
f: ([value]) => Effect.log(`Registered ${value.email}`),
|
||||||
}).pipe(
|
})
|
||||||
MutationForm.thenRun,
|
|
||||||
)
|
|
||||||
|
|
||||||
const emailField = Form.focusObjectOn(form, "email")
|
const emailField = Form.focusObjectOn(form, "email")
|
||||||
const passwordField = Form.focusObjectOn(form, "password")
|
const passwordField = Form.focusObjectOn(form, "password")
|
||||||
|
|||||||
@@ -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 appState = yield* AppState
|
||||||
|
|
||||||
const [
|
const [
|
||||||
@@ -52,16 +65,14 @@ const LensFormPageView = Component.make("LensFormPage")(function*() {
|
|||||||
Stream.runForEach(Lens.changes(appState.lens), Console.log)
|
Stream.runForEach(Lens.changes(appState.lens), Console.log)
|
||||||
)
|
)
|
||||||
|
|
||||||
const form = yield* LensForm.make({
|
const form = yield* LensForm.service({
|
||||||
schema: UserProfileSchema,
|
schema: UserProfileSchema,
|
||||||
target: Lens.focusObjectOn(appState.lens, "currentUser"),
|
target: Lens.focusObjectOn(appState.lens, "currentUser"),
|
||||||
initialEncodedValue: {
|
initialEncodedValue: {
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
},
|
},
|
||||||
}).pipe(
|
})
|
||||||
LensForm.thenRun,
|
|
||||||
)
|
|
||||||
|
|
||||||
const emailField = Form.focusObjectOn(form, "email")
|
const emailField = Form.focusObjectOn(form, "email")
|
||||||
const passwordField = Form.focusObjectOn(form, "password")
|
const passwordField = Form.focusObjectOn(form, "password")
|
||||||
@@ -93,9 +104,7 @@ const LensFormPageView = Component.make("LensFormPage")(function*() {
|
|||||||
</form>
|
</form>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}).pipe(
|
})
|
||||||
Component.provide(AppState.layer),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/lensform")({
|
export const Route = createFileRoute("/lensform")({
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
|
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { Effect, Schema, SubscriptionRef } from "effect"
|
import { Effect, Schema, SubscriptionRef } from "effect"
|
||||||
import { HttpClient } from "effect/http"
|
import { HttpClient } from "effect/unstable/http"
|
||||||
import { AsyncResult } from "effect/reactivity"
|
import { AsyncResult } from "effect/unstable/reactivity"
|
||||||
import { Component, Lens, Mutation, Query, View } from "effect-view"
|
import { Component, Lens, Mutation, Query, View } from "effect-view"
|
||||||
import { runtime } from "@/runtime"
|
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 keyLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const))
|
||||||
const idLens = Lens.focusTupleAt(keyLens, 1)
|
const idLens = Lens.focusTupleAt(keyLens, 1)
|
||||||
|
|
||||||
const query = yield* Query.make({
|
const query = yield* Query.service({
|
||||||
key: keyLens,
|
key: keyLens,
|
||||||
f: ([, id]) => HttpClient.HttpClient.pipe(
|
f: ([, id]) => HttpClient.HttpClient.pipe(
|
||||||
Effect.tap(Effect.sleep("500 millis")),
|
Effect.tap(Effect.sleep("1 second")),
|
||||||
Effect.flatMap(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
|
||||||
Effect.flatMap(response => response.json),
|
Effect.andThen(response => response.json),
|
||||||
Effect.flatMap(Schema.decodeUnknownEffect(Post)),
|
Effect.andThen(Schema.decodeUnknownEffect(Post)),
|
||||||
),
|
),
|
||||||
staleTime: "10 seconds",
|
staleTime: "10 seconds",
|
||||||
}).pipe(
|
})
|
||||||
Query.thenRun,
|
|
||||||
)
|
|
||||||
|
|
||||||
const mutation = yield* Mutation.make({
|
const mutation = yield* Mutation.make({
|
||||||
f: ([id]: [id: number]) => HttpClient.HttpClient.pipe(
|
f: ([id]: [id: number]) => HttpClient.HttpClient.pipe(
|
||||||
@@ -79,7 +77,7 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
|
|||||||
<PostResultView result={queryState.result} />
|
<PostResultView result={queryState.result} />
|
||||||
|
|
||||||
<Flex direction="row" justify="center" align="center" gap="1">
|
<Flex direction="row" justify="center" align="center" gap="1">
|
||||||
<Button onClick={() => runSync(query.refreshStream)}>
|
<Button onClick={() => runSync(query.refreshView)}>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => runSync(query.invalidateCache)}>
|
<Button onClick={() => runSync(query.invalidateCache)}>
|
||||||
@@ -87,9 +85,9 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<PostResultView result={mutationState.result} />
|
<PostResultView result={mutationState} />
|
||||||
|
|
||||||
<Button onClick={() => runSync(mutation.mutateStream([id]))}>
|
<Button onClick={() => runSync(mutation.mutateView([id]))}>
|
||||||
Mutate
|
Mutate
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
|
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
|
||||||
import { DateTime, Layer } from "effect"
|
import { DateTime, Layer } from "effect"
|
||||||
import { FetchHttpClient } from "effect/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { QueryClient, ReactRuntime } from "effect-view"
|
import { QueryClient, ReactRuntime } from "effect-view"
|
||||||
|
|
||||||
|
|
||||||
export const layer = Layer.empty.pipe(
|
export const layer = Layer.empty.pipe(
|
||||||
Layer.provideMerge(QueryClient.layerBrowser),
|
Layer.provideMerge(QueryClient.layer()),
|
||||||
Layer.provideMerge(DateTime.layerCurrentZoneLocal),
|
Layer.provideMerge(DateTime.layerCurrentZoneLocal),
|
||||||
Layer.provideMerge(Clipboard.layer),
|
Layer.provideMerge(Clipboard.layer),
|
||||||
Layer.provideMerge(Geolocation.layer),
|
Layer.provideMerge(Geolocation.layer),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@effect-view/vite-plugin",
|
"name": "@effect-view/vite-plugin",
|
||||||
"description": "Vite Fast Refresh support for Effect View components",
|
"description": "Vite Fast Refresh support for Effect View components",
|
||||||
"version": "0.3.0",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"./README.md",
|
"./README.md",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"effect-view": "^0.3.0",
|
"effect-view": "^0.1.0",
|
||||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,10 +33,11 @@ interface ComponentImports {
|
|||||||
|
|
||||||
interface Definition {
|
interface Definition {
|
||||||
readonly expression: ts.Expression
|
readonly expression: ts.Expression
|
||||||
readonly wrapTarget: ts.Expression
|
readonly factoryCall: ts.CallExpression
|
||||||
readonly body: ts.FunctionLikeDeclaration | undefined
|
readonly body: ts.FunctionLikeDeclaration | undefined
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly site: EntrypointSite.Pipe | undefined
|
readonly pipeline: ts.CallExpression | undefined
|
||||||
|
readonly entrypointIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultInclude = /\.[cm]?[jt]sx?$/
|
const defaultInclude = /\.[cm]?[jt]sx?$/
|
||||||
@@ -139,13 +140,6 @@ const findFactoryCall = (
|
|||||||
if (result)
|
if (result)
|
||||||
return
|
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 (ts.isCallExpression(node)) {
|
||||||
if (isFactoryCallee(node.expression, imports)) {
|
if (isFactoryCallee(node.expression, imports)) {
|
||||||
result = {
|
result = {
|
||||||
@@ -171,83 +165,18 @@ const findFactoryCall = (
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPipeline = (node: ts.Node): node is ts.CallExpression =>
|
const isPipeline = (expression: ts.Expression): expression is ts.CallExpression =>
|
||||||
ts.isCallExpression(node)
|
ts.isCallExpression(expression)
|
||||||
&& ts.isPropertyAccessExpression(node.expression)
|
&& ts.isPropertyAccessExpression(expression.expression)
|
||||||
&& node.expression.name.text === "pipe"
|
&& expression.expression.name.text === "pipe"
|
||||||
|
|
||||||
const entrypointName = (callee: ts.Expression): boolean =>
|
const isEntrypoint = (expression: ts.Expression): boolean => {
|
||||||
ts.isPropertyAccessExpression(callee)
|
if (!ts.isCallExpression(expression))
|
||||||
|
return false
|
||||||
|
|
||||||
|
const callee = expression.expression
|
||||||
|
return ts.isPropertyAccessExpression(callee)
|
||||||
&& (callee.name.text === "withRuntime" || callee.name.text === "withContext")
|
&& (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 = (
|
const makeDefinition = (
|
||||||
@@ -256,33 +185,25 @@ const makeDefinition = (
|
|||||||
imports: ComponentImports,
|
imports: ComponentImports,
|
||||||
): Definition | undefined => {
|
): Definition | undefined => {
|
||||||
const factory = findFactoryCall(expression, imports)
|
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)
|
if (!factory)
|
||||||
return undefined
|
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 = (
|
const collectDefinitions = (
|
||||||
@@ -339,19 +260,9 @@ const collectDefinitions = (
|
|||||||
return definitions
|
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 = (
|
const hookSignature = (
|
||||||
body: ts.FunctionLikeDeclaration | undefined,
|
body: ts.FunctionLikeDeclaration | undefined,
|
||||||
|
sourceFile: ts.SourceFile,
|
||||||
): string => {
|
): string => {
|
||||||
if (!body?.body)
|
if (!body?.body)
|
||||||
return "unknown"
|
return "unknown"
|
||||||
@@ -365,18 +276,14 @@ const hookSignature = (
|
|||||||
|
|
||||||
if (ts.isCallExpression(node)) {
|
if (ts.isCallExpression(node)) {
|
||||||
const callee = node.expression
|
const callee = node.expression
|
||||||
const localName = ts.isIdentifier(callee)
|
const name = ts.isIdentifier(callee)
|
||||||
? callee.text
|
? callee.text
|
||||||
: ts.isPropertyAccessExpression(callee)
|
: ts.isPropertyAccessExpression(callee)
|
||||||
? callee.name.text
|
? callee.name.text
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
if (localName && /^use[A-Z0-9]/.test(localName)) {
|
if (name && /^use[A-Z0-9]/.test(name))
|
||||||
const qualifier = ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)
|
hooks.push(node.getText(sourceFile))
|
||||||
? `${callee.expression.text}.`
|
|
||||||
: ""
|
|
||||||
hooks.push(`${qualifier}${localName}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ts.forEachChild(node, visit)
|
ts.forEachChild(node, visit)
|
||||||
@@ -477,10 +384,10 @@ export function effectView(
|
|||||||
const edits: Edit[] = []
|
const edits: Edit[] = []
|
||||||
|
|
||||||
for (const definition of definitions) {
|
for (const definition of definitions) {
|
||||||
const signature = hookSignature(definition.body)
|
const signature = hookSignature(definition.body, sourceFile)
|
||||||
|
|
||||||
if (definition.site?.kind === "pipe") {
|
if (definition.pipeline && definition.entrypointIndex >= 0) {
|
||||||
const entrypoint = definition.site.pipeCall.arguments[definition.site.argIndex]
|
const entrypoint = definition.pipeline.arguments[definition.entrypointIndex]
|
||||||
if (!entrypoint)
|
if (!entrypoint)
|
||||||
continue
|
continue
|
||||||
edits.push({
|
edits.push({
|
||||||
@@ -496,8 +403,8 @@ export function effectView(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = definition.wrapTarget.getStart(sourceFile)
|
const start = definition.expression.getStart(sourceFile)
|
||||||
const end = definition.wrapTarget.end
|
const end = definition.expression.end
|
||||||
edits.push({
|
edits.push({
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
|
|||||||
@@ -135,77 +135,4 @@ export const Legacy = Component.makeUntraced(function*() {
|
|||||||
})
|
})
|
||||||
`)).toBeUndefined()
|
`)).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))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user