diff --git a/.gitea/workflows/publish.yaml b/.gitea/workflows/publish.yaml
index f553df5..3625031 100644
--- a/.gitea/workflows/publish.yaml
+++ b/.gitea/workflows/publish.yaml
@@ -40,6 +40,13 @@ jobs:
access: public
token: ${{ secrets.NPM_TOKEN }}
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
uses: JS-DevTools/npm-publish@v4
with:
diff --git a/bun.lock b/bun.lock
index 5239904..79a0670 100644
--- a/bun.lock
+++ b/bun.lock
@@ -85,18 +85,18 @@
"version": "0.1.0",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
- "effect-lens": "^2.0.1-beta.101",
+ "effect-lens": "^2.0.1-beta.102",
},
"devDependencies": {
- "@effect/platform-browser": "4.0.0-beta.101",
+ "@effect/platform-browser": "4.0.0-beta.102",
"@testing-library/react": "^16.3.0",
- "effect": "4.0.0-beta.101",
+ "effect": "4.0.0-beta.102",
"jsdom": "^26.1.0",
"vitest": "^3.2.4",
},
"peerDependencies": {
"@types/react": "^19.2.0",
- "effect": "4.0.0-beta.101",
+ "effect": "4.0.0-beta.102",
"react": "^19.2.0",
},
},
@@ -104,9 +104,9 @@
"name": "@effect-view/example",
"version": "0.0.0",
"dependencies": {
- "@effect/platform-browser": "4.0.0-beta.101",
+ "@effect/platform-browser": "4.0.0-beta.102",
"@radix-ui/themes": "^3.3.0",
- "effect": "4.0.0-beta.101",
+ "effect": "4.0.0-beta.102",
"effect-view": "workspace:*",
"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=="],
- "@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=="],
@@ -3122,11 +3122,11 @@
"dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
- "effect-view/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.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=="],
diff --git a/packages/docs/docs/forms.md b/packages/docs/docs/forms.md
index 7f0cb1e..f2b9062 100644
--- a/packages/docs/docs/forms.md
+++ b/packages/docs/docs/forms.md
@@ -54,11 +54,9 @@ valid decoded value should go:
## Create forms once
-`MutationForm.service` and `LensForm.service` are Effect constructors, not
-hooks. Create each root form once and keep it stable. For a component-owned
-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.make` and `LensForm.make` construct forms. Pipe them through the
+module's `run` operator to start validation or synchronization in the current
+scope. Create each root form once and keep it stable.
## MutationForm: validate, then submit
@@ -73,7 +71,7 @@ import { Component, MutationForm, View } from "effect-view"
const CreateProfileView = Component.make("CreateProfile")(function* () {
const form = yield* Component.useOnMount(() =>
- MutationForm.service({
+ MutationForm.make({
schema: ProfileSchema,
initialEncodedValue: {
displayName: "",
@@ -84,7 +82,7 @@ const CreateProfileView = Component.make("CreateProfile")(function* () {
Effect.log(
`Creating ${profile.displayName}, age ${profile.age}`,
),
- }),
+ }).pipe(MutationForm.thenRun),
)
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
invalid draft from being submitted.
-`MutationForm.service` also starts initial validation in the current scope. In
-the example above, `Component.useOnMount` keeps that validation and the form's
-mutation work tied to the component that owns them.
+`MutationForm.thenRun` starts initial validation. In the example above,
+`Component.useOnMount` keeps that validation and the form's mutation work tied
+to the component that owns them.
## 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,
target: profile,
- })
+ }).pipe(LensForm.thenRun)
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.
Pass `initialEncodedValue` only when the first draft should differ from the
-encoded target. Otherwise `LensForm.service` obtains the initial draft by
-encoding the target through the schema.
+encoded target. Otherwise `LensForm.make` obtains the initial draft by encoding
+the target through the schema.
## Focus into subforms
@@ -427,14 +425,14 @@ const AppointmentSchema = Schema.Struct({
const AppointmentView = Component.make("Appointment")(function* () {
const [form, startsAtField] = yield* Component.useOnMount(() =>
Effect.gen(function* () {
- const form = yield* MutationForm.service({
+ const form = yield* MutationForm.make({
schema: AppointmentSchema,
initialEncodedValue: { startsAt: "" },
f: ([appointment]) =>
Effect.log(
`Saving ${DateTime.formatIso(appointment.startsAt)}`,
),
- })
+ }).pipe(MutationForm.thenRun)
return [form, Form.focusObjectOn(form, "startsAt")] as const
}),
diff --git a/packages/docs/docs/query.md b/packages/docs/docs/query.md
index ccb4efc..039df45 100644
--- a/packages/docs/docs/query.md
+++ b/packages/docs/docs/query.md
@@ -57,11 +57,11 @@ client defaults. Window-focus refresh also requires the optional
## Create a reactive query
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.
-`Query.service` is an Effect constructor, not a hook. Create each query instance
-once and keep it stable. In a component, the usual place is
+`Query.make` constructs the Query and `Query.thenRun` starts it in the current
+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
owned by an Effect service. Change the existing query's reactive key rather
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),
)
- const query = yield* Query.service({
+ const query = yield* Query.make({
key,
staleTime: "1 minute",
f: ([, id]) =>
@@ -95,7 +95,7 @@ const PostView = Component.make("Post")(function* () {
Effect.andThen((response) => response.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)),
),
- })
+ }).pipe(Query.thenRun)
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
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
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
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
`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:
```tsx
-const query = yield* Query.service({
+const query = yield* Query.make({
key,
f: loadPost,
staleTime: "10 seconds",
refreshOnWindowFocus: false,
-})
+}).pipe(Query.thenRun)
```
Window-focus refresh depends on the optional `@effect/platform-browser`
diff --git a/packages/effect-view/package.json b/packages/effect-view/package.json
index 6be9216..677772f 100644
--- a/packages/effect-view/package.json
+++ b/packages/effect-view/package.json
@@ -97,19 +97,19 @@
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
- "@effect/platform-browser": "4.0.0-beta.101",
+ "@effect/platform-browser": "4.0.0-beta.102",
"@testing-library/react": "^16.3.0",
- "effect": "4.0.0-beta.101",
+ "effect": "4.0.0-beta.102",
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"@types/react": "^19.2.0",
- "effect": "4.0.0-beta.101",
+ "effect": "4.0.0-beta.102",
"react": "^19.2.0"
},
"dependencies": {
"@standard-schema/spec": "^1.1.0",
- "effect-lens": "^2.0.1-beta.101"
+ "effect-lens": "^2.0.1-beta.102"
}
}
diff --git a/packages/effect-view/src/LensForm.ts b/packages/effect-view/src/LensForm.ts
index 11debd1..3426d84 100644
--- a/packages/effect-view/src/LensForm.ts
+++ b/packages/effect-view/src/LensForm.ts
@@ -189,18 +189,13 @@ export const make = Effect.fnUntraced(function*
- extends make.Options {}
-}
-
-export const service = (
- options: service.Options
+export const thenRun = (
+ self: Effect.Effect, E, R>,
): Effect.Effect<
LensForm,
- Schema.SchemaError | TER,
- Scope.Scope | RD | RE | TRR | TRW
+ E,
+ Scope.Scope | R
> => Effect.tap(
- make(options),
+ self,
form => Effect.forkScoped(form.run),
)
diff --git a/packages/effect-view/src/MutationForm.ts b/packages/effect-view/src/MutationForm.ts
index 160bbdb..27debad 100644
--- a/packages/effect-view/src/MutationForm.ts
+++ b/packages/effect-view/src/MutationForm.ts
@@ -196,18 +196,13 @@ export const make = Effect.fnUntraced(function*
- extends make.Options {}
-}
-
-export const service = (
- options: service.Options
+export const thenRun = (
+ self: Effect.Effect, E, R>,
): Effect.Effect<
MutationForm,
- never,
- Scope.Scope | RD | RE | MR
+ E,
+ Scope.Scope | R
> => Effect.tap(
- make(options),
+ self,
form => Effect.forkScoped(form.run),
)
diff --git a/packages/effect-view/src/Query.test.ts b/packages/effect-view/src/Query.test.ts
index b518c0d..af30f89 100644
--- a/packages/effect-view/src/Query.test.ts
+++ b/packages/effect-view/src/Query.test.ts
@@ -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 { describe, expect, it } from "vitest"
import * as Query from "./Query.js"
@@ -79,6 +80,49 @@ describe("Query", () => {
expect(expectSuccessValue(result[1])).toBe("value:1:2")
})
+ it("withScheduledRefresh lets the Schedule control the first refresh", async () => {
+ let calls = 0
+ const key = staticKey([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 () => {
let calls = 0
const key = staticKey([1])
@@ -136,14 +180,14 @@ describe("Query", () => {
const key = staticKey([1])
const effect = Effect.gen(function*() {
- const query = yield* Query.service({
+ const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
- })
+ }).pipe(Query.thenRun)
const latestFinalState = yield* Effect.sleep("1 millis").pipe(
Effect.andThen(View.get(query.latestFinalState)),
diff --git a/packages/effect-view/src/Query.ts b/packages/effect-view/src/Query.ts
index e03bce7..8c21126 100644
--- a/packages/effect-view/src/Query.ts
+++ b/packages/effect-view/src/Query.ts
@@ -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 * as Lens from "./Lens.js"
import * as QueryClient from "./QueryClient.js"
@@ -410,17 +410,52 @@ export const make = Effect.fnUntraced(function* (
)
})
-export const service = (
- options: make.Options
-): Effect.Effect<
- Query,
- Cause.NoSuchElementError,
- Scope.Scope | QueryClient.QueryClient | R
-> => Effect.tap(
- make(options),
+export const thenRun = (
+ self: Effect.Effect, E2, R2>,
+): Effect.Effect, E2, Scope.Scope | R2> => Effect.tap(
+ self,
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: {
+