From e412943e6419d2e98f8e14abacd0bcb2b0fd6b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Thu, 30 Jul 2026 13:09:24 +0200 Subject: [PATCH 1/4] Add Query.withScheduledRefresh --- .gitea/workflows/publish.yaml | 7 ++++ packages/docs/docs/query.md | 27 +++++++++++++++ packages/effect-view/src/Query.test.ts | 46 +++++++++++++++++++++++++- packages/effect-view/src/Query.ts | 39 +++++++++++++++++++++- 4 files changed, 117 insertions(+), 2 deletions(-) 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/packages/docs/docs/query.md b/packages/docs/docs/query.md index ccb4efc..b5864a6 100644 --- a/packages/docs/docs/query.md +++ b/packages/docs/docs/query.md @@ -248,6 +248,33 @@ 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" + +yield* query.pipe( + Query.withScheduledRefresh(Schedule.spaced("5 minutes")), +) +``` + +Limit the number of refreshes: + +```ts +yield* query.pipe( + 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 diff --git a/packages/effect-view/src/Query.test.ts b/packages/effect-view/src/Query.test.ts index b518c0d..2a50873 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", + }) + + yield* query.fetch([1]) + const returnedQuery = yield* query.pipe(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 { + returnsQuery: returnedQuery === query, + beforeInterval, + afterFirstInterval, + } + }).pipe( + Effect.provide(TestClock.layer()), + )) + + expect(result).toEqual({ + returnsQuery: true, + beforeInterval: 1, + afterFirstInterval: 2, + }) + }) + it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => { let calls = 0 const key = staticKey([1]) diff --git a/packages/effect-view/src/Query.ts b/packages/effect-view/src/Query.ts index e03bce7..f170cab 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" @@ -421,6 +421,43 @@ export const service = ( 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.pipe( + * Query.withScheduledRefresh(Schedule.spaced("5 minutes")), + * ) + * ``` + * + * @example Refresh at most three times + * ```ts + * yield* query.pipe( + * Query.withScheduledRefresh( + * Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 })), + * ), + * ) + * ``` + */ +export const withScheduledRefresh: { + ( + schedule: Schedule.Schedule, + ): ( + self: Query, + ) => Effect.Effect, never, Scope.Scope | Env> + ( + self: Query, + schedule: Schedule.Schedule, + ): Effect.Effect, never, Scope.Scope | Env> +} = Function.dual(2, ( + self: Query, + schedule: Schedule.Schedule, +) => Effect.schedule(self.refresh, schedule).pipe( + Effect.forkScoped, + Effect.as(self), +)) + export class QueryStateLens extends Lens.LensImpl, never, never, never, never> { -- 2.52.0 From 66b7a08ef7623f5794d0dc61e7897468a9573dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Thu, 30 Jul 2026 15:12:53 +0200 Subject: [PATCH 2/4] Refactor API --- packages/docs/docs/forms.md | 30 +++++++++--------- packages/docs/docs/query.md | 22 +++++++------ packages/effect-view/src/LensForm.ts | 15 +++------ packages/effect-view/src/MutationForm.ts | 15 +++------ packages/effect-view/src/Query.test.ts | 22 ++++++------- packages/effect-view/src/Query.ts | 40 +++++++++++------------- packages/effect-view/src/QueryClient.ts | 14 +++------ packages/example/src/routes/form.tsx | 6 ++-- packages/example/src/routes/lensform.tsx | 6 ++-- packages/example/src/routes/query.tsx | 14 +++++---- 10 files changed, 87 insertions(+), 97 deletions(-) diff --git a/packages/docs/docs/forms.md b/packages/docs/docs/forms.md index 7f0cb1e..d91989a 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.run), ) 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.run` 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.run) 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.run) return [form, Form.focusObjectOn(form, "startsAt")] as const }), diff --git a/packages/docs/docs/query.md b/packages/docs/docs/query.md index b5864a6..c67fd65 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.run` 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.run) 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.run` 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. @@ -255,7 +255,8 @@ Refresh every five minutes, starting after five minutes: ```ts import { Schedule } from "effect" -yield* query.pipe( +const query = yield* Query.make(options).pipe( + Query.run, Query.withScheduledRefresh(Schedule.spaced("5 minutes")), ) ``` @@ -263,7 +264,8 @@ yield* query.pipe( Limit the number of refreshes: ```ts -yield* query.pipe( +const query = yield* Query.make(options).pipe( + Query.run, Query.withScheduledRefresh( Schedule.spaced("5 minutes").pipe( 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: ```tsx -const query = yield* Query.service({ +const query = yield* Query.make({ key, f: loadPost, staleTime: "10 seconds", refreshOnWindowFocus: false, -}) +}).pipe(Query.run) ``` Window-focus refresh depends on the optional `@effect/platform-browser` diff --git a/packages/effect-view/src/LensForm.ts b/packages/effect-view/src/LensForm.ts index 11debd1..ec85983 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 run = ( + 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..1204067 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 run = ( + 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 2a50873..866ef88 100644 --- a/packages/effect-view/src/Query.test.ts +++ b/packages/effect-view/src/Query.test.ts @@ -92,14 +92,14 @@ describe("Query", () => { return calls }), staleTime: "0 millis", - }) - - yield* query.fetch([1]) - const returnedQuery = yield* query.pipe(Query.withScheduledRefresh( - Schedule.spaced("1 second").pipe( - Schedule.upTo({ times: 1 }), + }).pipe( + Query.run, + Query.withScheduledRefresh( + Schedule.spaced("1 second").pipe( + Schedule.upTo({ times: 1 }), + ), ), - )) + ) yield* TestClock.adjust("999 millis") const beforeInterval = calls @@ -108,7 +108,7 @@ describe("Query", () => { const afterFirstInterval = calls return { - returnsQuery: returnedQuery === query, + isQuery: Query.isQuery(query), beforeInterval, afterFirstInterval, } @@ -117,7 +117,7 @@ describe("Query", () => { )) expect(result).toEqual({ - returnsQuery: true, + isQuery: true, beforeInterval: 1, afterFirstInterval: 2, }) @@ -180,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.run) 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 f170cab..aa118b1 100644 --- a/packages/effect-view/src/Query.ts +++ b/packages/effect-view/src/Query.ts @@ -410,14 +410,10 @@ 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 run = ( + self: Effect.Effect, E2, R2>, +): Effect.Effect, E2, Scope.Scope | R2> => Effect.tap( + self, query => Effect.forkScoped(query.run), ) @@ -426,14 +422,16 @@ export const service = ( * * @example Refresh every five minutes, starting after five minutes * ```ts - * yield* query.pipe( + * yield* Query.make(options).pipe( + * Query.run, * Query.withScheduledRefresh(Schedule.spaced("5 minutes")), * ) * ``` * * @example Refresh at most three times * ```ts - * yield* query.pipe( + * yield* Query.make(options).pipe( + * Query.run, * Query.withScheduledRefresh( * Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 })), * ), @@ -443,19 +441,19 @@ export const service = ( export const withScheduledRefresh: { ( schedule: Schedule.Schedule, - ): ( - self: Query, - ) => Effect.Effect, never, Scope.Scope | Env> - ( - self: Query, + ): ( + self: Effect.Effect, E2, R2>, + ) => Effect.Effect, E2, Scope.Scope | Env | R2> + ( + self: Effect.Effect, E2, R2>, schedule: Schedule.Schedule, - ): Effect.Effect, never, Scope.Scope | Env> -} = Function.dual(2, ( - self: Query, + ): Effect.Effect, E2, Scope.Scope | Env | R2> +} = Function.dual(2, ( + self: Effect.Effect, E2, R2>, schedule: Schedule.Schedule, -) => Effect.schedule(self.refresh, schedule).pipe( - Effect.forkScoped, - Effect.as(self), +) => Effect.tap( + self, + query => Effect.forkScoped(Effect.schedule(query.refresh, schedule)), )) diff --git a/packages/effect-view/src/QueryClient.ts b/packages/effect-view/src/QueryClient.ts index 6868fca..7a0d89f 100644 --- a/packages/effect-view/src/QueryClient.ts +++ b/packages/effect-view/src/QueryClient.ts @@ -115,18 +115,14 @@ export const make = Effect.fnUntraced(function* ( ) }) -export declare namespace service { - export interface Options extends make.Options {} -} - -export const service = ( - options?: service.Options -): Effect.Effect => Effect.tap( - make(options), +export const run = ( + self: Effect.Effect, +): Effect.Effect => Effect.tap( + self, 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, run(make(options))) export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey") diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index a6e0cbc..02fb620 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -26,11 +26,13 @@ const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() { })) const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() { - const form = yield* MutationForm.service({ + const form = yield* MutationForm.make({ schema: RegisterSchema, initialEncodedValue: { email: "", password: "" }, f: ([value]) => Effect.log(`Registered ${value.email}`), - }) + }).pipe( + MutationForm.run, + ) const emailField = Form.focusObjectOn(form, "email") const passwordField = Form.focusObjectOn(form, "password") diff --git a/packages/example/src/routes/lensform.tsx b/packages/example/src/routes/lensform.tsx index 95f8030..2841822 100644 --- a/packages/example/src/routes/lensform.tsx +++ b/packages/example/src/routes/lensform.tsx @@ -65,14 +65,16 @@ const UserProfileEditorView = Component.make("UserProfileEditorView")(function*( Stream.runForEach(Lens.changes(appState.lens), Console.log) ) - const form = yield* LensForm.service({ + const form = yield* LensForm.make({ schema: UserProfileSchema, target: Lens.focusObjectOn(appState.lens, "currentUser"), initialEncodedValue: { email: "", password: "", }, - }) + }).pipe( + LensForm.run, + ) const emailField = Form.focusObjectOn(form, "email") const passwordField = Form.focusObjectOn(form, "password") diff --git a/packages/example/src/routes/query.tsx b/packages/example/src/routes/query.tsx index 22b5a39..da260e9 100644 --- a/packages/example/src/routes/query.tsx +++ b/packages/example/src/routes/query.tsx @@ -36,16 +36,18 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() { const keyLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const)) const idLens = Lens.focusTupleAt(keyLens, 1) - const query = yield* Query.service({ + const query = yield* Query.make({ key: keyLens, f: ([, id]) => HttpClient.HttpClient.pipe( - Effect.tap(Effect.sleep("1 second")), - Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), - Effect.andThen(response => response.json), - Effect.andThen(Schema.decodeUnknownEffect(Post)), + Effect.tap(Effect.sleep("500 millis")), + Effect.flatMap(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), + Effect.flatMap(response => response.json), + Effect.flatMap(Schema.decodeUnknownEffect(Post)), ), staleTime: "10 seconds", - }) + }).pipe( + Query.run, + ) const mutation = yield* Mutation.make({ f: ([id]: [id: number]) => HttpClient.HttpClient.pipe( -- 2.52.0 From f8ac04101a655b6b9d496093083ce50e3faa18cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Thu, 30 Jul 2026 15:19:29 +0200 Subject: [PATCH 3/4] run -> thenRun --- packages/docs/docs/forms.md | 8 ++++---- packages/docs/docs/query.md | 12 ++++++------ packages/effect-view/src/LensForm.ts | 2 +- packages/effect-view/src/MutationForm.ts | 2 +- packages/effect-view/src/Query.test.ts | 4 ++-- packages/effect-view/src/Query.ts | 6 +++--- packages/effect-view/src/QueryClient.ts | 4 ++-- packages/example/src/routes/form.tsx | 2 +- packages/example/src/routes/lensform.tsx | 2 +- packages/example/src/routes/query.tsx | 2 +- 10 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/docs/docs/forms.md b/packages/docs/docs/forms.md index d91989a..f2b9062 100644 --- a/packages/docs/docs/forms.md +++ b/packages/docs/docs/forms.md @@ -82,7 +82,7 @@ const CreateProfileView = Component.make("CreateProfile")(function* () { Effect.log( `Creating ${profile.displayName}, age ${profile.age}`, ), - }).pipe(MutationForm.run), + }).pipe(MutationForm.thenRun), ) const [canCommit, isCommitting] = yield* View.useAll([ @@ -108,7 +108,7 @@ 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.run` starts initial validation. In the example above, +`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. @@ -137,7 +137,7 @@ const EditProfileView = Component.make("EditProfile")(function* () { const form = yield* LensForm.make({ schema: ProfileSchema, target: profile, - }).pipe(LensForm.run) + }).pipe(LensForm.thenRun) return [form, profile] as const }), @@ -432,7 +432,7 @@ const AppointmentView = Component.make("Appointment")(function* () { Effect.log( `Saving ${DateTime.formatIso(appointment.startsAt)}`, ), - }).pipe(MutationForm.run) + }).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 c67fd65..039df45 100644 --- a/packages/docs/docs/query.md +++ b/packages/docs/docs/query.md @@ -60,7 +60,7 @@ 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 the query effect when necessary. -`Query.make` constructs the Query and `Query.run` starts it in the current +`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 @@ -95,7 +95,7 @@ const PostView = Component.make("Post")(function* () { Effect.andThen((response) => response.json), Effect.andThen(Schema.decodeUnknownEffect(Post)), ), - }).pipe(Query.run) + }).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.run` 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. @@ -256,7 +256,7 @@ Refresh every five minutes, starting after five minutes: import { Schedule } from "effect" const query = yield* Query.make(options).pipe( - Query.run, + Query.thenRun, Query.withScheduledRefresh(Schedule.spaced("5 minutes")), ) ``` @@ -265,7 +265,7 @@ Limit the number of refreshes: ```ts const query = yield* Query.make(options).pipe( - Query.run, + Query.thenRun, Query.withScheduledRefresh( Schedule.spaced("5 minutes").pipe( Schedule.upTo({ times: 3 }), @@ -296,7 +296,7 @@ const query = yield* Query.make({ f: loadPost, staleTime: "10 seconds", refreshOnWindowFocus: false, -}).pipe(Query.run) +}).pipe(Query.thenRun) ``` Window-focus refresh depends on the optional `@effect/platform-browser` diff --git a/packages/effect-view/src/LensForm.ts b/packages/effect-view/src/LensForm.ts index ec85983..3426d84 100644 --- a/packages/effect-view/src/LensForm.ts +++ b/packages/effect-view/src/LensForm.ts @@ -189,7 +189,7 @@ export const make = Effect.fnUntraced(function* ( +export const thenRun = ( self: Effect.Effect, E, R>, ): Effect.Effect< LensForm, diff --git a/packages/effect-view/src/MutationForm.ts b/packages/effect-view/src/MutationForm.ts index 1204067..27debad 100644 --- a/packages/effect-view/src/MutationForm.ts +++ b/packages/effect-view/src/MutationForm.ts @@ -196,7 +196,7 @@ export const make = Effect.fnUntraced(function* ( +export const thenRun = ( self: Effect.Effect, E, R>, ): Effect.Effect< MutationForm, diff --git a/packages/effect-view/src/Query.test.ts b/packages/effect-view/src/Query.test.ts index 866ef88..af30f89 100644 --- a/packages/effect-view/src/Query.test.ts +++ b/packages/effect-view/src/Query.test.ts @@ -93,7 +93,7 @@ describe("Query", () => { }), staleTime: "0 millis", }).pipe( - Query.run, + Query.thenRun, Query.withScheduledRefresh( Schedule.spaced("1 second").pipe( Schedule.upTo({ times: 1 }), @@ -187,7 +187,7 @@ describe("Query", () => { return `value:${id}:${calls}` }), staleTime: "1 minute", - }).pipe(Query.run) + }).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 aa118b1..8c21126 100644 --- a/packages/effect-view/src/Query.ts +++ b/packages/effect-view/src/Query.ts @@ -410,7 +410,7 @@ export const make = Effect.fnUntraced(function* ( ) }) -export const run = ( +export const thenRun = ( self: Effect.Effect, E2, R2>, ): Effect.Effect, E2, Scope.Scope | R2> => Effect.tap( self, @@ -423,7 +423,7 @@ export const run = ( * @example Refresh every five minutes, starting after five minutes * ```ts * yield* Query.make(options).pipe( - * Query.run, + * Query.thenRun, * Query.withScheduledRefresh(Schedule.spaced("5 minutes")), * ) * ``` @@ -431,7 +431,7 @@ export const run = ( * @example Refresh at most three times * ```ts * yield* Query.make(options).pipe( - * Query.run, + * Query.thenRun, * Query.withScheduledRefresh( * Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 })), * ), diff --git a/packages/effect-view/src/QueryClient.ts b/packages/effect-view/src/QueryClient.ts index 7a0d89f..4c1aa34 100644 --- a/packages/effect-view/src/QueryClient.ts +++ b/packages/effect-view/src/QueryClient.ts @@ -115,14 +115,14 @@ export const make = Effect.fnUntraced(function* ( ) }) -export const run = ( +export const thenRun = ( self: Effect.Effect, ): Effect.Effect => Effect.tap( self, client => Effect.forkScoped(client.run), ) -export const layer = (options?: make.Options) => Layer.effect(QueryClient, run(make(options))) +export const layer = (options?: make.Options) => Layer.effect(QueryClient, thenRun(make(options))) export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey") diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index 02fb620..d61ffb6 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -31,7 +31,7 @@ const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() { initialEncodedValue: { email: "", password: "" }, f: ([value]) => Effect.log(`Registered ${value.email}`), }).pipe( - MutationForm.run, + MutationForm.thenRun, ) const emailField = Form.focusObjectOn(form, "email") diff --git a/packages/example/src/routes/lensform.tsx b/packages/example/src/routes/lensform.tsx index 2841822..1a0c4a3 100644 --- a/packages/example/src/routes/lensform.tsx +++ b/packages/example/src/routes/lensform.tsx @@ -73,7 +73,7 @@ const UserProfileEditorView = Component.make("UserProfileEditorView")(function*( password: "", }, }).pipe( - LensForm.run, + LensForm.thenRun, ) const emailField = Form.focusObjectOn(form, "email") diff --git a/packages/example/src/routes/query.tsx b/packages/example/src/routes/query.tsx index da260e9..95edabd 100644 --- a/packages/example/src/routes/query.tsx +++ b/packages/example/src/routes/query.tsx @@ -46,7 +46,7 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() { ), staleTime: "10 seconds", }).pipe( - Query.run, + Query.thenRun, ) const mutation = yield* Mutation.make({ -- 2.52.0 From d3622f477941bc99ce3be74ef7abda67ebe93332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Thu, 30 Jul 2026 15:33:06 +0200 Subject: [PATCH 4/4] Upgrade Effect v4 --- bun.lock | 22 +++++++++++----------- packages/effect-view/package.json | 8 ++++---- packages/example/package.json | 6 +++--- 3 files changed, 18 insertions(+), 18 deletions(-) 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/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/example/package.json b/packages/example/package.json index e297535..7bfd7da 100644 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -27,15 +27,15 @@ "vite": "^8.0.16" }, "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" }, "overrides": { "@types/react": "^19.2.15", - "effect": "4.0.0-beta.101", + "effect": "4.0.0-beta.102", "react": "^19.2.6" } } -- 2.52.0