3 Commits

Author SHA1 Message Date
renovate-bot 399eae182c Update docker/build-push-action action to v7
Lint / lint (push) Successful in 1m53s
Test build / test-build (pull_request) Successful in 3m58s
2026-07-31 12:01:53 +00:00
Julien Valverdé 3fef9b1a47 Bump version
Publish / publish (push) Successful in 3m26s
Lint / lint (push) Successful in 47s
2026-07-30 15:45:43 +02:00
Thilawyn 2b1c6aeda9 Add explicit lifecycle pipelines and scheduled query refreshes (#68)
Lint / lint (push) Successful in 48s
Publish / publish (push) Has been cancelled
## Summary

- Replace auto-running `service` constructors with explicit `make(...).pipe(thenRun)` lifecycle pipelines.
- Add scoped, schedule-driven Query refreshes with `Query.withScheduledRefresh`.
- Upgrade the Effect v4 stack to beta 102.
- Add `@effect-view/vite-plugin` to the publish workflow.
- Update examples and documentation for the new APIs.

## Breaking changes

The following constructors have been replaced:

- `Query.service(options)` → `Query.make(options).pipe(Query.thenRun)`
- `QueryClient.service(options)` → `QueryClient.make(options).pipe(QueryClient.thenRun)`
- `MutationForm.service(options)` → `MutationForm.make(options).pipe(MutationForm.thenRun)`
- `LensForm.service(options)` → `LensForm.make(options).pipe(LensForm.thenRun)`

This separates object construction from starting its scoped background behavior.

## Scheduled Query refreshes

Queries can now be refreshed with any Effect `Schedule`:

```ts
const query = yield* Query.make(options).pipe(
  Query.thenRun,
  Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
)

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #68
2026-07-30 15:36:49 +02:00
15 changed files with 201 additions and 96 deletions
+8 -1
View File
@@ -40,6 +40,13 @@ jobs:
access: public access: public
token: ${{ secrets.NPM_TOKEN }} token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org registry: https://registry.npmjs.org
- name: Publish @effect-view/vite-plugin
uses: JS-DevTools/npm-publish@v4
with:
package: packages/vite-plugin
access: public
token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org
- name: Publish effect-fc - name: Publish effect-fc
uses: JS-DevTools/npm-publish@v4 uses: JS-DevTools/npm-publish@v4
with: with:
@@ -67,7 +74,7 @@ jobs:
flavor: | flavor: |
latest=true latest=true
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
flavor: | flavor: |
latest=true latest=true
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
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.101", "effect-lens": "^2.0.1-beta.102",
}, },
"devDependencies": { "devDependencies": {
"@effect/platform-browser": "4.0.0-beta.101", "@effect/platform-browser": "4.0.0-beta.102",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.101", "effect": "4.0.0-beta.102",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"vitest": "^3.2.4", "vitest": "^3.2.4",
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"effect": "4.0.0-beta.101", "effect": "4.0.0-beta.102",
"react": "^19.2.0", "react": "^19.2.0",
}, },
}, },
@@ -104,9 +104,9 @@
"name": "@effect-view/example", "name": "@effect-view/example",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@effect/platform-browser": "4.0.0-beta.101", "@effect/platform-browser": "4.0.0-beta.102",
"@radix-ui/themes": "^3.3.0", "@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.101", "effect": "4.0.0-beta.102",
"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.101", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-05//60oEzMyQyNjk8Ll1mML6gU2RvT9TTaomq5cGmNQu6Gzb6jlWfzn2/ZGzFtehhB9iOpqtjy0QkT5tDh9ElA=="], "@effect-view/example/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.102", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.102" } }, "sha512-DXXVt+oOpukqI3lEaGvvXQxSobzIy3lAsIQvlpVK+MuYBm1o4FHZrHKbrOFItDUvSR1t0kiTncFPRi01YaJUmA=="],
"@effect-view/example/effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="], "@effect-view/example/effect": ["effect@4.0.0-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/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.101", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-05//60oEzMyQyNjk8Ll1mML6gU2RvT9TTaomq5cGmNQu6Gzb6jlWfzn2/ZGzFtehhB9iOpqtjy0QkT5tDh9ElA=="], "effect-view/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.102", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.102" } }, "sha512-DXXVt+oOpukqI3lEaGvvXQxSobzIy3lAsIQvlpVK+MuYBm1o4FHZrHKbrOFItDUvSR1t0kiTncFPRi01YaJUmA=="],
"effect-view/effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="], "effect-view/effect": ["effect@4.0.0-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-lens": ["effect-lens@2.0.1-beta.101", "", { "peerDependencies": { "effect": "4.0.0-beta.101" } }, "sha512-ME5JruBkF3AG34rhb9MlHb1uwEbW70hr6lFITf8tcHBTgVF5QXFCe8nWATraYZpxBHGqM2bOnRvoadmwh2yyvQ=="], "effect-view/effect-lens": ["effect-lens@2.0.1-beta.102", "", { "peerDependencies": { "effect": "4.0.0-beta.102" } }, "sha512-YQSPI/r42Do/24X+RurTd4BLV2EdxG4LcgySpR4Pt7W2Z+wCmoBlSAV+B7rs2lTpqOny3kKTLnn9tbpfQVzSAA=="],
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
+14 -16
View File
@@ -54,11 +54,9 @@ valid decoded value should go:
## Create forms once ## Create forms once
`MutationForm.service` and `LensForm.service` are Effect constructors, not `MutationForm.make` and `LensForm.make` construct forms. Pipe them through the
hooks. Create each root form once and keep it stable. For a component-owned module's `run` operator to start validation or synchronization in the current
form, call the constructor inside `Component.useOnMount`; for a form shared by scope. Create each root form once and keep it stable.
multiple components, create it in an Effect service. Update the existing form's
Lenses rather than reconstructing the form during render.
## MutationForm: validate, then submit ## MutationForm: validate, then submit
@@ -73,7 +71,7 @@ import { Component, MutationForm, View } from "effect-view"
const CreateProfileView = Component.make("CreateProfile")(function* () { const CreateProfileView = Component.make("CreateProfile")(function* () {
const form = yield* Component.useOnMount(() => const form = yield* Component.useOnMount(() =>
MutationForm.service({ MutationForm.make({
schema: ProfileSchema, schema: ProfileSchema,
initialEncodedValue: { initialEncodedValue: {
displayName: "", displayName: "",
@@ -84,7 +82,7 @@ const CreateProfileView = Component.make("CreateProfile")(function* () {
Effect.log( Effect.log(
`Creating ${profile.displayName}, age ${profile.age}`, `Creating ${profile.displayName}, age ${profile.age}`,
), ),
}), }).pipe(MutationForm.thenRun),
) )
const [canCommit, isCommitting] = yield* View.useAll([ const [canCommit, isCommitting] = yield* View.useAll([
@@ -110,9 +108,9 @@ mutation receives the schema's decoded profile, so `profile.age` is a number.
Schema transformations happen before the mutation and schema issues prevent an Schema transformations happen before the mutation and schema issues prevent an
invalid draft from being submitted. invalid draft from being submitted.
`MutationForm.service` also starts initial validation in the current scope. In `MutationForm.thenRun` starts initial validation. In the example above,
the example above, `Component.useOnMount` keeps that validation and the form's `Component.useOnMount` keeps that validation and the form's mutation work tied
mutation work tied to the component that owns them. to the component that owns them.
## LensForm: validate, then synchronize ## LensForm: validate, then synchronize
@@ -136,10 +134,10 @@ const EditProfileView = Component.make("EditProfile")(function* () {
}), }),
) )
const form = yield* LensForm.service({ const form = yield* LensForm.make({
schema: ProfileSchema, schema: ProfileSchema,
target: profile, target: profile,
}) }).pipe(LensForm.thenRun)
return [form, profile] as const return [form, profile] as const
}), }),
@@ -168,8 +166,8 @@ reaches the target. If another part of the application updates the target,
`LensForm` encodes that value back into the draft. `LensForm` encodes that value back into the draft.
Pass `initialEncodedValue` only when the first draft should differ from the Pass `initialEncodedValue` only when the first draft should differ from the
encoded target. Otherwise `LensForm.service` obtains the initial draft by encoded target. Otherwise `LensForm.make` obtains the initial draft by encoding
encoding the target through the schema. the target through the schema.
## Focus into subforms ## Focus into subforms
@@ -427,14 +425,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.service({ const form = yield* MutationForm.make({
schema: AppointmentSchema, schema: AppointmentSchema,
initialEncodedValue: { startsAt: "" }, initialEncodedValue: { startsAt: "" },
f: ([appointment]) => f: ([appointment]) =>
Effect.log( Effect.log(
`Saving ${DateTime.formatIso(appointment.startsAt)}`, `Saving ${DateTime.formatIso(appointment.startsAt)}`,
), ),
}) }).pipe(MutationForm.thenRun)
return [form, Form.focusObjectOn(form, "startsAt")] as const return [form, Form.focusObjectOn(form, "startsAt")] as const
}), }),
+37 -8
View File
@@ -57,11 +57,11 @@ client defaults. Window-focus refresh also requires the optional
## Create a reactive query ## Create a reactive query
A query is driven by a `View` rather than by a value read during one React A query is driven by a `View` rather than by a value read during one React
render. Whenever that key changes, `Query.service` checks the cache and starts render. Whenever that key changes, a running Query checks the cache and starts
the query effect when necessary. the query effect when necessary.
`Query.service` is an Effect constructor, not a hook. Create each query instance `Query.make` constructs the Query and `Query.thenRun` starts it in the current
once and keep it stable. In a component, the usual place is scope. Create each query instance once and keep it stable. In a component, the usual place is
`Component.useOnMount`; a query shared by multiple components can instead be `Component.useOnMount`; a query shared by multiple components can instead be
owned by an Effect service. Change the existing query's reactive key rather owned by an Effect service. Change the existing query's reactive key rather
than reconstructing the query during render. than reconstructing the query during render.
@@ -84,7 +84,7 @@ const PostView = Component.make("Post")(function* () {
yield* SubscriptionRef.make(["post", 1 as number] as const), yield* SubscriptionRef.make(["post", 1 as number] as const),
) )
const query = yield* Query.service({ const query = yield* Query.make({
key, key,
staleTime: "1 minute", staleTime: "1 minute",
f: ([, id]) => f: ([, id]) =>
@@ -95,7 +95,7 @@ const PostView = Component.make("Post")(function* () {
Effect.andThen((response) => response.json), Effect.andThen((response) => response.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)), Effect.andThen(Schema.decodeUnknownEffect(Post)),
), ),
}) }).pipe(Query.thenRun)
return [Lens.focusTupleAt(key, 1), query] as const return [Lens.focusTupleAt(key, 1), query] as const
}), }),
@@ -124,7 +124,7 @@ Effect equality by default, so structurally equal Effect data types work well
as query keys. Supply `keyEquivalence` when the key needs different equality as query keys. Supply `keyEquivalence` when the key needs different equality
semantics. semantics.
`Query.service` starts watching its key in the current scope. The `Query.thenRun` starts watching its key in the current scope. The
`Component.useOnMount` call above keeps both the query instance and its query `Component.useOnMount` call above keeps both the query instance and its query
function identity stable for the component's lifetime. function identity stable for the component's lifetime.
@@ -248,6 +248,35 @@ workflows that need to wait for the final success or failure state.
Invalidating does not itself refetch. Follow it with `refreshView`, change the Invalidating does not itself refetch. Follow it with `refreshView`, change the
key, or allow a later fetch to repopulate the cache. key, or allow a later fetch to repopulate the cache.
### Refresh on an interval
Refresh every five minutes, starting after five minutes:
```ts
import { Schedule } from "effect"
const query = yield* Query.make(options).pipe(
Query.thenRun,
Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
)
```
Limit the number of refreshes:
```ts
const query = yield* Query.make(options).pipe(
Query.thenRun,
Query.withScheduledRefresh(
Schedule.spaced("5 minutes").pipe(
Schedule.upTo({ times: 3 }),
),
),
)
```
The refresh fiber stops with the surrounding scope. The Effect returns the
original query. Cache and `staleTime` rules still apply.
## Staleness and cache lifetime ## Staleness and cache lifetime
`staleTime` controls how long a successful result can satisfy a fetch without `staleTime` controls how long a successful result can satisfy a fetch without
@@ -262,12 +291,12 @@ By default, the client enables refresh on browser window focus. Set it globally
or override it for one query: or override it for one query:
```tsx ```tsx
const query = yield* Query.service({ const query = yield* Query.make({
key, key,
f: loadPost, f: loadPost,
staleTime: "10 seconds", staleTime: "10 seconds",
refreshOnWindowFocus: false, refreshOnWindowFocus: false,
}) }).pipe(Query.thenRun)
``` ```
Window-focus refresh depends on the optional `@effect/platform-browser` Window-focus refresh depends on the optional `@effect/platform-browser`
+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.0", "version": "0.1.1",
"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.101", "@effect/platform-browser": "4.0.0-beta.102",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.101", "effect": "4.0.0-beta.102",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"effect": "4.0.0-beta.101", "effect": "4.0.0-beta.102",
"react": "^19.2.0" "react": "^19.2.0"
}, },
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0", "@standard-schema/spec": "^1.1.0",
"effect-lens": "^2.0.1-beta.101" "effect-lens": "^2.0.1-beta.102"
} }
} }
+5 -10
View File
@@ -189,18 +189,13 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
) )
}) })
export declare namespace service { export const thenRun = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never, E = never, R = never>(
export interface Options<in out A, out I = A, out RD = never, out RE = never, out TER = never, out TEW = never, out TRR = never, out TRW = never> self: Effect.Effect<LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>, E, R>,
extends make.Options<A, I, RD, RE, TER, TEW, TRR, TRW> {}
}
export const service = <A, I = A, RD = never, RE = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: service.Options<A, I, RD, RE, TER, TEW, TRR, TRW>
): Effect.Effect< ): Effect.Effect<
LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>, LensForm<A, I, RD, RE, TER, TEW, TRR, TRW>,
Schema.SchemaError | TER, E,
Scope.Scope | RD | RE | TRR | TRW Scope.Scope | R
> => Effect.tap( > => Effect.tap(
make(options), self,
form => Effect.forkScoped(form.run), form => Effect.forkScoped(form.run),
) )
+5 -10
View File
@@ -196,18 +196,13 @@ export const make = Effect.fnUntraced(function* <A, I = A, RD = never, RE = neve
) )
}) })
export declare namespace service { export const thenRun = <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never, E = never, R = never>(
export interface Options<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, out MR = never> self: Effect.Effect<MutationForm<A, I, RD, RE, MA, ME, MR>, E, R>,
extends make.Options<A, I, RD, RE, MA, ME, MR> {}
}
export const service = <A, I = A, RD = never, RE = never, MA = void, ME = never, MR = never>(
options: service.Options<A, I, RD, RE, MA, ME, MR>
): Effect.Effect< ): Effect.Effect<
MutationForm<A, I, RD, RE, MA, ME, MR>, MutationForm<A, I, RD, RE, MA, ME, MR>,
never, E,
Scope.Scope | RD | RE | MR Scope.Scope | R
> => Effect.tap( > => Effect.tap(
make(options), self,
form => Effect.forkScoped(form.run), form => Effect.forkScoped(form.run),
) )
+47 -3
View File
@@ -1,4 +1,5 @@
import { Effect, type Scope, Stream } from "effect" import { Effect, Schedule, type Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { AsyncResult } from "effect/unstable/reactivity" import { AsyncResult } from "effect/unstable/reactivity"
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import * as Query from "./Query.js" import * as Query from "./Query.js"
@@ -79,6 +80,49 @@ describe("Query", () => {
expect(expectSuccessValue(result[1])).toBe("value:1:2") expect(expectSuccessValue(result[1])).toBe("value:1:2")
}) })
it("withScheduledRefresh lets the Schedule control the first refresh", async () => {
let calls = 0
const key = staticKey<readonly [number]>([1])
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: () => Effect.sync(() => {
calls += 1
return calls
}),
staleTime: "0 millis",
}).pipe(
Query.thenRun,
Query.withScheduledRefresh(
Schedule.spaced("1 second").pipe(
Schedule.upTo({ times: 1 }),
),
),
)
yield* TestClock.adjust("999 millis")
const beforeInterval = calls
yield* TestClock.adjust("1 millis")
const afterFirstInterval = calls
return {
isQuery: Query.isQuery(query),
beforeInterval,
afterFirstInterval,
}
}).pipe(
Effect.provide(TestClock.layer()),
))
expect(result).toEqual({
isQuery: true,
beforeInterval: 1,
afterFirstInterval: 2,
})
})
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => { it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
let calls = 0 let calls = 0
const key = staticKey<readonly [number]>([1]) const key = staticKey<readonly [number]>([1])
@@ -136,14 +180,14 @@ describe("Query", () => {
const key = staticKey<readonly [number]>([1]) const key = staticKey<readonly [number]>([1])
const effect = Effect.gen(function*() { const effect = Effect.gen(function*() {
const query = yield* Query.service({ const query = yield* Query.make({
key, key,
f: ([id]: readonly [number]) => Effect.sync(() => { f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1 calls += 1
return `value:${id}:${calls}` return `value:${id}:${calls}`
}), }),
staleTime: "1 minute", staleTime: "1 minute",
}) }).pipe(Query.thenRun)
const latestFinalState = yield* Effect.sleep("1 millis").pipe( const latestFinalState = yield* Effect.sleep("1 millis").pipe(
Effect.andThen(View.get(query.latestFinalState)), Effect.andThen(View.get(query.latestFinalState)),
+44 -9
View File
@@ -1,4 +1,4 @@
import { Cause, type Context, Duration, Effect, Equal, type Equivalence, Exit, Fiber, Option, Pipeable, Predicate, PubSub, Ref, type Scope, Semaphore, Stream, SubscriptionRef } from "effect" import { Cause, type Context, Duration, Effect, Equal, type Equivalence, Exit, Fiber, Function, Option, Pipeable, Predicate, PubSub, Ref, type Schedule, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/unstable/reactivity" import { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js" import * as Lens from "./Lens.js"
import * as QueryClient from "./QueryClient.js" import * as QueryClient from "./QueryClient.js"
@@ -410,17 +410,52 @@ export const make = Effect.fnUntraced(function* <K, A, E = never, R = never>(
) )
}) })
export const service = <K, A, E = never, R = never>( export const thenRun = <K, A, E = never, R = never, E2 = never, R2 = never>(
options: make.Options<K, A, E, R> self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
): Effect.Effect< ): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | R2> => Effect.tap(
Query<K, A, E, R>, self,
Cause.NoSuchElementError,
Scope.Scope | QueryClient.QueryClient | R
> => Effect.tap(
make(options),
query => Effect.forkScoped(query.run), query => Effect.forkScoped(query.run),
) )
/**
* Refreshes the Query on a schedule and returns it.
*
* @example Refresh every five minutes, starting after five minutes
* ```ts
* yield* Query.make(options).pipe(
* Query.thenRun,
* Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
* )
* ```
*
* @example Refresh at most three times
* ```ts
* yield* Query.make(options).pipe(
* Query.thenRun,
* Query.withScheduledRefresh(
* Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 })),
* ),
* )
* ```
*/
export const withScheduledRefresh: {
<Output, Error, Env>(
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
): <K, A, E, R, E2, R2>(
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
) => Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | Env | R2>
<K, A, E, R, E2, R2, Output, Error, Env>(
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | Env | R2>
} = Function.dual(2, <K, A, E, R, E2, R2, Output, Error, Env>(
self: Effect.Effect<Query<K, A, E, R>, E2, R2>,
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
) => Effect.tap(
self,
query => Effect.forkScoped(Effect.schedule(query.refresh, schedule)),
))
export class QueryStateLens<in out K, in out A, in out E = never> export class QueryStateLens<in out K, in out A, in out E = never>
extends Lens.LensImpl<QueryState<K, A, E>, never, never, never, never> { extends Lens.LensImpl<QueryState<K, A, E>, never, never, never, never> {
+5 -9
View File
@@ -115,18 +115,14 @@ export const make = Effect.fnUntraced(function* (
) )
}) })
export declare namespace service { export const thenRun = <E = never, R = never>(
export interface Options extends make.Options {} self: Effect.Effect<QueryClientService, E, R>,
} ): Effect.Effect<QueryClientService, E, Scope.Scope | R> => Effect.tap(
self,
export const service = (
options?: service.Options
): Effect.Effect<QueryClientService, Cause.NoSuchElementError, Scope.Scope> => Effect.tap(
make(options),
client => Effect.forkScoped(client.run), client => Effect.forkScoped(client.run),
) )
export const layer = (options?: service.Options) => Layer.effect(QueryClient, service(options)) export const layer = (options?: make.Options) => Layer.effect(QueryClient, thenRun(make(options)))
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey") export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey")
+3 -3
View File
@@ -27,15 +27,15 @@
"vite": "^8.0.16" "vite": "^8.0.16"
}, },
"dependencies": { "dependencies": {
"@effect/platform-browser": "4.0.0-beta.101", "@effect/platform-browser": "4.0.0-beta.102",
"@radix-ui/themes": "^3.3.0", "@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.101", "effect": "4.0.0-beta.102",
"effect-view": "workspace:*", "effect-view": "workspace:*",
"react-icons": "^5.6.0" "react-icons": "^5.6.0"
}, },
"overrides": { "overrides": {
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"effect": "4.0.0-beta.101", "effect": "4.0.0-beta.102",
"react": "^19.2.6" "react": "^19.2.6"
} }
} }
+4 -2
View File
@@ -26,11 +26,13 @@ const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() {
})) }))
const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() { const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() {
const form = yield* MutationForm.service({ const form = yield* MutationForm.make({
schema: RegisterSchema, schema: RegisterSchema,
initialEncodedValue: { email: "", password: "" }, initialEncodedValue: { email: "", password: "" },
f: ([value]) => Effect.log(`Registered ${value.email}`), f: ([value]) => Effect.log(`Registered ${value.email}`),
}) }).pipe(
MutationForm.thenRun,
)
const emailField = Form.focusObjectOn(form, "email") const emailField = Form.focusObjectOn(form, "email")
const passwordField = Form.focusObjectOn(form, "password") const passwordField = Form.focusObjectOn(form, "password")
+4 -2
View File
@@ -65,14 +65,16 @@ const UserProfileEditorView = Component.make("UserProfileEditorView")(function*(
Stream.runForEach(Lens.changes(appState.lens), Console.log) Stream.runForEach(Lens.changes(appState.lens), Console.log)
) )
const form = yield* LensForm.service({ const form = yield* LensForm.make({
schema: UserProfileSchema, schema: UserProfileSchema,
target: Lens.focusObjectOn(appState.lens, "currentUser"), target: Lens.focusObjectOn(appState.lens, "currentUser"),
initialEncodedValue: { initialEncodedValue: {
email: "", email: "",
password: "", password: "",
}, },
}) }).pipe(
LensForm.thenRun,
)
const emailField = Form.focusObjectOn(form, "email") const emailField = Form.focusObjectOn(form, "email")
const passwordField = Form.focusObjectOn(form, "password") const passwordField = Form.focusObjectOn(form, "password")
+8 -6
View File
@@ -36,16 +36,18 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
const keyLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const)) const keyLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const))
const idLens = Lens.focusTupleAt(keyLens, 1) const idLens = Lens.focusTupleAt(keyLens, 1)
const query = yield* Query.service({ const query = yield* Query.make({
key: keyLens, key: keyLens,
f: ([, id]) => HttpClient.HttpClient.pipe( f: ([, id]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("1 second")), Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), Effect.flatMap(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json), Effect.flatMap(response => response.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)), Effect.flatMap(Schema.decodeUnknownEffect(Post)),
), ),
staleTime: "10 seconds", staleTime: "10 seconds",
}) }).pipe(
Query.thenRun,
)
const mutation = yield* Mutation.make({ const mutation = yield* Mutation.make({
f: ([id]: [id: number]) => HttpClient.HttpClient.pipe( f: ([id]: [id: number]) => HttpClient.HttpClient.pipe(