1 Commits

Author SHA1 Message Date
renovate-bot 27bb78dc14 Update actions/setup-node action to v7
Lint / lint (push) Successful in 49s
Test build / test-build (pull_request) Successful in 2m59s
2026-07-28 12:06:05 +00:00
15 changed files with 97 additions and 202 deletions
+1 -8
View File
@@ -40,13 +40,6 @@ jobs:
access: public access: public
token: ${{ secrets.NPM_TOKEN }} token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org registry: https://registry.npmjs.org
- name: Publish @effect-view/vite-plugin
uses: JS-DevTools/npm-publish@v4
with:
package: packages/vite-plugin
access: public
token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org
- name: Publish effect-fc - name: Publish effect-fc
uses: JS-DevTools/npm-publish@v4 uses: JS-DevTools/npm-publish@v4
with: with:
@@ -74,7 +67,7 @@ jobs:
flavor: | flavor: |
latest=true latest=true
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
+2 -2
View File
@@ -10,7 +10,7 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: "22" node-version: "22"
- name: Set up Docker Buildx - name: Set up Docker Buildx
@@ -51,7 +51,7 @@ jobs:
flavor: | flavor: |
latest=true latest=true
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
+11 -11
View File
@@ -85,18 +85,18 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0", "@standard-schema/spec": "^1.1.0",
"effect-lens": "^2.0.1-beta.102", "effect-lens": "^2.0.1-beta.101",
}, },
"devDependencies": { "devDependencies": {
"@effect/platform-browser": "4.0.0-beta.102", "@effect/platform-browser": "4.0.0-beta.101",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.102", "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-beta.102", "effect": "4.0.0-beta.101",
"react": "^19.2.0", "react": "^19.2.0",
}, },
}, },
@@ -104,9 +104,9 @@
"name": "@effect-view/example", "name": "@effect-view/example",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@effect/platform-browser": "4.0.0-beta.102", "@effect/platform-browser": "4.0.0-beta.101",
"@radix-ui/themes": "^3.3.0", "@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.102", "effect": "4.0.0-beta.101",
"effect-view": "workspace:*", "effect-view": "workspace:*",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
}, },
@@ -3052,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-beta.102", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.102" } }, "sha512-DXXVt+oOpukqI3lEaGvvXQxSobzIy3lAsIQvlpVK+MuYBm1o4FHZrHKbrOFItDUvSR1t0kiTncFPRi01YaJUmA=="], "@effect-view/example/@effect/platform-browser": ["@effect/platform-browser@4.0.0-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-beta.102", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w=="], "@effect-view/example/effect": ["effect@4.0.0-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=="],
@@ -3122,11 +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/platform-browser": ["@effect/platform-browser@4.0.0-beta.102", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.102" } }, "sha512-DXXVt+oOpukqI3lEaGvvXQxSobzIy3lAsIQvlpVK+MuYBm1o4FHZrHKbrOFItDUvSR1t0kiTncFPRi01YaJUmA=="], "effect-view/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.101", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-05//60oEzMyQyNjk8Ll1mML6gU2RvT9TTaomq5cGmNQu6Gzb6jlWfzn2/ZGzFtehhB9iOpqtjy0QkT5tDh9ElA=="],
"effect-view/effect": ["effect@4.0.0-beta.102", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w=="], "effect-view/effect": ["effect@4.0.0-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.102", "", { "peerDependencies": { "effect": "4.0.0-beta.102" } }, "sha512-YQSPI/r42Do/24X+RurTd4BLV2EdxG4LcgySpR4Pt7W2Z+wCmoBlSAV+B7rs2lTpqOny3kKTLnn9tbpfQVzSAA=="], "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=="],
+16 -14
View File
@@ -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
@@ -425,14 +427,14 @@ 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
}), }),
+8 -37
View File
@@ -57,11 +57,11 @@ client defaults. Window-focus refresh also requires the optional
## Create a reactive query ## Create a reactive query
A query is driven by a `View` rather than by a value read during one React A query is driven by a `View` rather than by a value read during one React
render. Whenever that key changes, 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.
@@ -84,7 +84,7 @@ const PostView = Component.make("Post")(function* () {
yield* SubscriptionRef.make(["post", 1 as number] as const), yield* SubscriptionRef.make(["post", 1 as number] as const),
) )
const query = yield* Query.make({ const query = yield* Query.service({
key, key,
staleTime: "1 minute", staleTime: "1 minute",
f: ([, id]) => f: ([, id]) =>
@@ -95,7 +95,7 @@ const PostView = Component.make("Post")(function* () {
Effect.andThen((response) => response.json), Effect.andThen((response) => response.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)), Effect.andThen(Schema.decodeUnknownEffect(Post)),
), ),
}).pipe(Query.thenRun) })
return [Lens.focusTupleAt(key, 1), query] as const return [Lens.focusTupleAt(key, 1), query] as const
}), }),
@@ -124,7 +124,7 @@ Effect equality by default, so structurally equal Effect data types work well
as query keys. Supply `keyEquivalence` when the key needs different equality as query keys. Supply `keyEquivalence` when the key needs different equality
semantics. semantics.
`Query.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.
@@ -248,35 +248,6 @@ workflows that need to wait for the final success or failure state.
Invalidating does not itself refetch. Follow it with `refreshView`, change the Invalidating does not itself refetch. Follow it with `refreshView`, change the
key, or allow a later fetch to repopulate the cache. key, or allow a later fetch to repopulate the cache.
### Refresh on an interval
Refresh every five minutes, starting after five minutes:
```ts
import { Schedule } from "effect"
const query = yield* Query.make(options).pipe(
Query.thenRun,
Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
)
```
Limit the number of refreshes:
```ts
const query = yield* Query.make(options).pipe(
Query.thenRun,
Query.withScheduledRefresh(
Schedule.spaced("5 minutes").pipe(
Schedule.upTo({ times: 3 }),
),
),
)
```
The refresh fiber stops with the surrounding scope. The Effect returns the
original query. Cache and `staleTime` rules still apply.
## Staleness and cache lifetime ## Staleness and cache lifetime
`staleTime` controls how long a successful result can satisfy a fetch without `staleTime` controls how long a successful result can satisfy a fetch without
@@ -291,12 +262,12 @@ By default, the client enables refresh on browser window focus. Set it globally
or override it for one query: or override it for one query:
```tsx ```tsx
const query = yield* Query.make({ const query = yield* Query.service({
key, key,
f: loadPost, f: loadPost,
staleTime: "10 seconds", staleTime: "10 seconds",
refreshOnWindowFocus: false, refreshOnWindowFocus: false,
}).pipe(Query.thenRun) })
``` ```
Window-focus refresh depends on the optional `@effect/platform-browser` Window-focus refresh depends on the optional `@effect/platform-browser`
+5 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "effect-view", "name": "effect-view",
"description": "Write React function components with Effect", "description": "Write React function components with Effect",
"version": "0.1.1", "version": "0.1.0",
"type": "module", "type": "module",
"files": [ "files": [
"./README.md", "./README.md",
@@ -97,19 +97,19 @@
"clean:modules": "rm -rf node_modules" "clean:modules": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@effect/platform-browser": "4.0.0-beta.102", "@effect/platform-browser": "4.0.0-beta.101",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.102", "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-beta.102", "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.1-beta.102" "effect-lens": "^2.0.1-beta.101"
} }
} }
+10 -5
View File
@@ -189,13 +189,18 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
) )
}) })
export const thenRun = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never, E = never, R = never>( 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),
) )
+10 -5
View File
@@ -196,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),
) )
+3 -47
View File
@@ -1,5 +1,4 @@
import { Effect, Schedule, type Scope, Stream } from "effect" import { Effect, type Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { AsyncResult } from "effect/unstable/reactivity" import { AsyncResult } from "effect/unstable/reactivity"
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import * as Query from "./Query.js" import * as Query from "./Query.js"
@@ -80,49 +79,6 @@ describe("Query", () => {
expect(expectSuccessValue(result[1])).toBe("value:1:2") expect(expectSuccessValue(result[1])).toBe("value:1:2")
}) })
it("withScheduledRefresh lets the Schedule control the first refresh", async () => {
let calls = 0
const key = staticKey<readonly [number]>([1])
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: () => Effect.sync(() => {
calls += 1
return calls
}),
staleTime: "0 millis",
}).pipe(
Query.thenRun,
Query.withScheduledRefresh(
Schedule.spaced("1 second").pipe(
Schedule.upTo({ times: 1 }),
),
),
)
yield* TestClock.adjust("999 millis")
const beforeInterval = calls
yield* TestClock.adjust("1 millis")
const afterFirstInterval = calls
return {
isQuery: Query.isQuery(query),
beforeInterval,
afterFirstInterval,
}
}).pipe(
Effect.provide(TestClock.layer()),
))
expect(result).toEqual({
isQuery: true,
beforeInterval: 1,
afterFirstInterval: 2,
})
})
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => { it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
let calls = 0 let calls = 0
const key = staticKey<readonly [number]>([1]) const key = staticKey<readonly [number]>([1])
@@ -180,14 +136,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)),
+9 -44
View File
@@ -1,4 +1,4 @@
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 { Cause, type Context, Duration, Effect, Equal, type Equivalence, Exit, Fiber, Option, Pipeable, Predicate, PubSub, Ref, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/unstable/reactivity" import { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js" import * as Lens from "./Lens.js"
import * as QueryClient from "./QueryClient.js" import * as QueryClient from "./QueryClient.js"
@@ -410,52 +410,17 @@ export const make = Effect.fnUntraced(function* <K, A, E = never, R = never>(
) )
}) })
export const thenRun = <K, A, E = never, R = never, E2 = never, R2 = never>( export const service = <K, A, E = never, R = never>(
self: Effect.Effect<Query<K, A, E, R>, E2, R2>, options: make.Options<K, A, E, R>
): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | R2> => Effect.tap( ): Effect.Effect<
self, Query<K, A, E, R>,
Cause.NoSuchElementError,
Scope.Scope | QueryClient.QueryClient | R
> => Effect.tap(
make(options),
query => Effect.forkScoped(query.run), query => Effect.forkScoped(query.run),
) )
/**
* Refreshes the Query on a schedule and returns it.
*
* @example Refresh every five minutes, starting after five minutes
* ```ts
* yield* Query.make(options).pipe(
* Query.thenRun,
* Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
* )
* ```
*
* @example Refresh at most three times
* ```ts
* yield* Query.make(options).pipe(
* Query.thenRun,
* Query.withScheduledRefresh(
* Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 })),
* ),
* )
* ```
*/
export const withScheduledRefresh: {
<Output, Error, Env>(
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
): <K, A, E, R, E2, R2>(
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
) => Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | Env | R2>
<K, A, E, R, E2, R2, Output, Error, Env>(
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | Env | R2>
} = Function.dual(2, <K, A, E, R, E2, R2, Output, Error, Env>(
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
) => Effect.tap(
self,
query => Effect.forkScoped(Effect.schedule(query.refresh, schedule)),
))
export class QueryStateLens<in out K, in out A, in out E = never> export class QueryStateLens<in out K, in out A, in out E = never>
extends Lens.LensImpl<QueryState<K, A, E>, never, never, never, never> { extends Lens.LensImpl<QueryState<K, A, E>, never, never, never, never> {
+9 -5
View File
@@ -115,14 +115,18 @@ export const make = Effect.fnUntraced(function* (
) )
}) })
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?: make.Options) => Layer.effect(QueryClient, thenRun(make(options))) export const layer = (options?: service.Options) => Layer.effect(QueryClient, service(options))
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey") export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey")
+3 -3
View File
@@ -27,15 +27,15 @@
"vite": "^8.0.16" "vite": "^8.0.16"
}, },
"dependencies": { "dependencies": {
"@effect/platform-browser": "4.0.0-beta.102", "@effect/platform-browser": "4.0.0-beta.101",
"@radix-ui/themes": "^3.3.0", "@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.102", "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-beta.102", "effect": "4.0.0-beta.101",
"react": "^19.2.6" "react": "^19.2.6"
} }
} }
+2 -4
View File
@@ -26,13 +26,11 @@ const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() {
})) }))
const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() { const [form, 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")
+2 -4
View File
@@ -65,16 +65,14 @@ const UserProfileEditorView = Component.make("UserProfileEditorView")(function*(
Stream.runForEach(Lens.changes(appState.lens), Console.log) Stream.runForEach(Lens.changes(appState.lens), Console.log)
) )
const form = yield* LensForm.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")
+6 -8
View File
@@ -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(