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/2] 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 bf80af90815d0e8a515c365ac837477f9aaeed6d Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 30 Jul 2026 12:07:06 +0000 Subject: [PATCH 2/2] Update dependency vitest to v4 --- packages/effect-fc/package.json | 2 +- packages/effect-view/package.json | 2 +- packages/vite-plugin/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/effect-fc/package.json b/packages/effect-fc/package.json index 430a0a0..02b96ac 100644 --- a/packages/effect-fc/package.json +++ b/packages/effect-fc/package.json @@ -42,7 +42,7 @@ "@effect/platform-browser": "^0.76.0", "@testing-library/react": "^16.3.0", "jsdom": "^26.1.0", - "vitest": "^3.2.4" + "vitest": "^4.0.0" }, "peerDependencies": { "@types/react": "^19.2.0", diff --git a/packages/effect-view/package.json b/packages/effect-view/package.json index 6be9216..6a0a8ee 100644 --- a/packages/effect-view/package.json +++ b/packages/effect-view/package.json @@ -101,7 +101,7 @@ "@testing-library/react": "^16.3.0", "effect": "4.0.0-beta.101", "jsdom": "^26.1.0", - "vitest": "^3.2.4" + "vitest": "^4.0.0" }, "peerDependencies": { "@types/react": "^19.2.0", diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index efa78a5..4c3b4fe 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -38,7 +38,7 @@ "devDependencies": { "effect-view": "workspace:*", "vite": "^8.0.16", - "vitest": "^3.2.4" + "vitest": "^4.0.0" }, "peerDependencies": { "effect-view": "^0.1.0", -- 2.52.0