3 Commits

Author SHA1 Message Date
renovate-bot fe9c6125a7 Update docker/login-action action to v4
Test build / test-build (pull_request) Successful in 2m10s
Lint / lint (push) Successful in 48s
2026-07-31 12:01:56 +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 109 additions and 119 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
- 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
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: docker.valverde.cloud registry: docker.valverde.cloud
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
+2 -2
View File
@@ -1,6 +1,6 @@
FROM oven/bun:1.3.14-debian@sha256:9dba1a1b43ce28c9d7931bfc4eb00feb63b0114720a0277a8f939ae4dfc9db6f AS bun FROM oven/bun:1.3.12-debian@sha256:1b709c9dd883fc1af38c210f7ea5222c552a8d470ea73efbd4b8fcfee798a64b AS bun
FROM node:22.23.2-trixie-slim@sha256:517aa41d78545cb1b8c67b13655b4c13ede1ee9df1da8aab54cd7434aefbcaf8 FROM node:22.21.1-trixie-slim@sha256:98e1429d1a0b99378b4de43fa385f0746fd6276faf4feeb6104d91f6bad290f9
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
+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
}), }),
+12 -10
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.
@@ -255,7 +255,8 @@ Refresh every five minutes, starting after five minutes:
```ts ```ts
import { Schedule } from "effect" import { Schedule } from "effect"
yield* query.pipe( const query = yield* Query.make(options).pipe(
Query.thenRun,
Query.withScheduledRefresh(Schedule.spaced("5 minutes")), Query.withScheduledRefresh(Schedule.spaced("5 minutes")),
) )
``` ```
@@ -263,7 +264,8 @@ yield* query.pipe(
Limit the number of refreshes: Limit the number of refreshes:
```ts ```ts
yield* query.pipe( const query = yield* Query.make(options).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 }),
@@ -289,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),
) )
+11 -11
View File
@@ -92,14 +92,14 @@ describe("Query", () => {
return calls return calls
}), }),
staleTime: "0 millis", staleTime: "0 millis",
}) }).pipe(
Query.thenRun,
yield* query.fetch([1]) Query.withScheduledRefresh(
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
@@ -108,7 +108,7 @@ describe("Query", () => {
const afterFirstInterval = calls const afterFirstInterval = calls
return { return {
returnsQuery: returnedQuery === query, isQuery: Query.isQuery(query),
beforeInterval, beforeInterval,
afterFirstInterval, afterFirstInterval,
} }
@@ -117,7 +117,7 @@ describe("Query", () => {
)) ))
expect(result).toEqual({ expect(result).toEqual({
returnsQuery: true, isQuery: true,
beforeInterval: 1, beforeInterval: 1,
afterFirstInterval: 2, afterFirstInterval: 2,
}) })
@@ -180,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)),
+19 -21
View File
@@ -410,14 +410,10 @@ 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),
) )
@@ -426,14 +422,16 @@ export const service = <K, A, E = never, R = never>(
* *
* @example Refresh every five minutes, starting after five minutes * @example Refresh every five minutes, starting after five minutes
* ```ts * ```ts
* yield* query.pipe( * yield* Query.make(options).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.pipe( * yield* Query.make(options).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 })),
* ), * ),
@@ -443,19 +441,19 @@ export const service = <K, A, E = never, R = never>(
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>,
): <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>,
): 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(2, <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>,
) => Effect.schedule(self.refresh, schedule).pipe( ) => Effect.tap(
Effect.forkScoped, self,
Effect.as(self), query => Effect.forkScoped(Effect.schedule(query.refresh, schedule)),
)) ))
+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(