Update Query
Lint / lint (push) Successful in 25s
Test build / test-build (pull_request) Successful in 1m16s

This commit is contained in:
Julien Valverdé committed 2026-10-11 22:43:03 +02:00
1 parent 43bc165a1c
commit 63b2c62fa3
9 files changed
+130 -41

No files matched your search

+1 -4
View File
@@ -82,13 +82,12 @@
},
"packages/effect-view": {
"name": "effect-view",
"version": "0.2.0",
"version": "0.3.0",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"effect-lens": "2.0.3",
},
"devDependencies": {
"@effect/platform-browser": "^4.0.0",
"@testing-library/react": "^16.3.0",
"effect": "^4.0.0",
"jsdom": "^26.1.0",
@@ -3130,8 +3129,6 @@
"dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
"effect-view/@effect/platform-browser": ["@effect/platform-browser@4.0.0", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-OHo7EPpF3RVPiI+mjoU9yd05LdCdKPjzDXqs5wRgF9CsdVvwN76B0zgt54DxPUcvMYri1tVJOtLCVnK0tRkNQQ=="],
"effect-view/effect": ["effect@4.0.0", "", {}, "sha512-ooc1TG5t+FfzgYnFz2ff6BBKyZ7EwBRVXC7c4RhQUAD6/TZ2gTXXMeb4WX7a19ozQo4J73/QW+S00YAIresoMQ=="],
"effect-view/effect-lens": ["effect-lens@2.0.3", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-FLILgEXUf1aKxykcVR+Rc3qmWYPSeb4zLc2NdNEW+RNgnsICTan8rhhXW374e0dwEQAsEkMmcHxeEaQxBqy0jA=="],
+15 -2
View File
@@ -292,6 +292,18 @@ const query = yield* Query.make(options).pipe(
The refresh fiber stops with the surrounding scope. The Effect returns the
original query. Cache and `staleTime` rules still apply.
Like TanStack Query's `refetchInterval`, scheduled refreshes are skipped while
the app is not focused, as reported by the `AppFocus` service. A skipped
refresh still counts toward the schedule. Pass `refreshInBackground: true` to
keep refreshing in the background:
```ts
const query = yield* Query.make(options).pipe(
Query.thenRun,
Query.withScheduledRefresh(Schedule.spaced("5 minutes"), { refreshInBackground: true }),
)
```
## Staleness and cache lifetime
`staleTime` controls how long a successful result can satisfy a fetch without
@@ -317,7 +329,8 @@ const query = yield* Query.make({
Regaining focus resolves the current key again, subject to the same freshness
check. Set `refreshOnFocus: "always"` to refresh even when the result is still
fresh. Focus comes from the `AppFocus` service: with
`QueryClient.layerBrowser` it follows the window's `focus` and `blur` events.
`QueryClient.layerBrowser` the app is focused while the page is visible,
following the document's `visibilitychange` events, like TanStack Query.
Other platforms, such as React Native, provide it themselves, as shown in
[React Native](#react-native).
@@ -358,7 +371,7 @@ Each module offers:
| Layer or function | Behavior |
| --- | --- |
| `layerBrowser` | Follows the browser's window events. |
| `layerBrowser` | Follows the browser: `navigator.onLine` with the `online`/`offline` events, and the page's visibility. |
| `layerDummy` | Always online, or always focused. Useful for tests and server rendering. |
| `make(view)` | Builds the service from a `View<boolean>`, to provide with `Layer.succeed`. |
+7 -1
View File
@@ -65,7 +65,7 @@
- `QueryClient` takes the matching defaults: `defaultNetworkMode` and `defaultRefreshOnReconnect`.
- Focus and reconnect refreshes no longer restart a request that is already running, and a paused request resumes by itself.
The connection status comes from the new `AppConnectivity` service, and focus from the new `AppFocus` service. Each module provides `layerBrowser` (window events), `layerDummy` (always online or focused, e.g. for tests) and `make(view)` to build the service from any `View<boolean>`, e.g. on React Native.
The connection status comes from the new `AppConnectivity` service, and focus from the new `AppFocus` service. Each module provides `layerBrowser` (`navigator.onLine` with the `online`/`offline` events, and the page's visibility, like TanStack Query), `layerDummy` (always online or focused, e.g. for tests) and `make(view)` to build the service from any `View<boolean>`, e.g. on React Native.
- [`79d9061`](https://git.valverde.cloud/Thilawyn/effect-view/commit/79d906190754193b1b86bd07cd176d402180509f) - **Breaking:** `Mutation` takes a `networkMode` option too, defaulting to the client's new `defaultMutationNetworkMode` (`"online"` by default). In the default `"online"` mode, mutations called offline are now paused until the connection returns. `Mutation.make` (and `MutationForm.make`) now require a `QueryClient`.
@@ -98,6 +98,12 @@
QueryClient.layerBrowserWithOptions({ defaultRefreshOnFocus: false })
```
- **Breaking:** like TanStack Query's `refetchInterval`, `Query.withScheduledRefresh` now skips refreshes while the app is not focused, as reported by the `AppFocus` service. A skipped refresh still counts toward the schedule. Pass `refreshInBackground: true` to keep the previous behavior.
```ts
Query.withScheduledRefresh(Schedule.spaced("5 minutes"), { refreshInBackground: true })
```
### Patch Changes
- [`3875bc4`](https://git.valverde.cloud/Thilawyn/effect-view/commit/3875bc4f691dfa670137142e3bd62c226f9f5886) - Serving a fresh result from the cache no longer resets that cache entry's staleness timer.
+3 -3
View File
@@ -108,13 +108,13 @@ const query = yield* Query.make(options).pipe(
)
```
`Query.withScheduledRefresh(schedule)` forks a refresh fiber tied to the surrounding scope; cache/`staleTime` rules still apply.
`Query.withScheduledRefresh(schedule, options?)` forks a refresh fiber tied to the surrounding scope; cache/`staleTime` rules still apply. As with TanStack's `refetchInterval`, refreshes due while the app is not focused (`AppFocus`) are skipped (still counting as a schedule recurrence) unless `{ refreshInBackground: true }`.
## Staleness and lifetime
- `staleTime` (per query, falls back to `QueryClient` default): how long a successful result satisfies a fetch without re-running `f`. A stale entry stays available as previous data while it refreshes.
- `cacheGcTime` (on `QueryClient`): entries unused for `staleTime + cacheGcTime` are evicted.
- `refreshOnFocus` (per query, falls back to `QueryClient` default): re-resolves the current key when the app regains focus, as reported by the `AppFocus` service (window `focus`/`blur` events with `AppFocus.layerBrowser`). `true` refreshes only a stale result, `"always"` regardless of `staleTime` (type `Query.RefreshOn`).
- `refreshOnFocus` (per query, falls back to `QueryClient` default): re-resolves the current key when the app regains focus, as reported by the `AppFocus` service (page visibility, via the document's `visibilitychange` events, with `AppFocus.layerBrowser`). `true` refreshes only a stale result, `"always"` regardless of `staleTime` (type `Query.RefreshOn`).
## Network mode and offline support
@@ -123,7 +123,7 @@ const query = yield* Query.make(options).pipe(
- `networkMode: "always"`: ignores the connection (for queries that don't use the network).
- `refreshOnReconnect` (defaults to the client default, `false` in `"always"` mode): re-resolves the current key when the connection returns, subject to freshness (`"always"`: regardless of `staleTime`). Focus/reconnect refreshes never restart an in-flight request (a paused one resumes by itself), as in TanStack Query.
- The connection comes from the `AppConnectivity` service (`navigator.onLine` + window `online`/`offline` events with `AppConnectivity.layerBrowser`). `AppConnectivity` and `AppFocus` are `Context.Service`s and must be provided to `QueryClient.layer`/`layerWithOptions` (the `layerBrowser*` variants do it for you).
- Each module has `layerBrowser` (window events), `layerDummy` (always online / always focused; tests, SSR) and `make(view: View<boolean>)` to build the service from any `View`, provided with `Layer.succeed`. A `View`'s `changes` stream starts with the current value.
- Each module has `layerBrowser` (`navigator.onLine` + `online`/`offline` events; page visibility), `layerDummy` (always online / always focused; tests, SSR) and `make(view: View<boolean>)` to build the service from any `View`, provided with `Layer.succeed`. A `View`'s `changes` stream starts with the current value.
- React Native: build them from platform APIs:
```ts
-1
View File
@@ -116,7 +116,6 @@
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect/platform-browser": "^4.0.0",
"@testing-library/react": "^16.3.0",
"effect": "^4.0.0",
"jsdom": "^26.1.0",
+9 -12
View File
@@ -38,25 +38,22 @@ export const layerDummy: Layer.Layer<AppFocus> = Layer.succeed(
})),
)
const isDocumentVisible = () => globalThis.document.visibilityState === "visible"
/** Focused while the page is visible, following the document's `visibilitychange` events. */
export const layerBrowser: Layer.Layer<AppFocus> = Layer.succeed(
AppFocus,
make(View.make({
get: Effect.sync(() => globalThis.document.hasFocus()),
get: Effect.sync(isDocumentVisible),
changes: Stream.concat(
Stream.sync(() => globalThis.document.hasFocus()),
Stream.sync(isDocumentVisible),
Stream.callback<boolean>(queue => Effect.acquireRelease(
Effect.sync(() => {
const focusListener = () => Queue.offerUnsafe(queue, true)
globalThis.window.addEventListener("focus", focusListener)
const blurListener = () => Queue.offerUnsafe(queue, false)
globalThis.window.addEventListener("blur", blurListener)
return [focusListener, blurListener] as const
}),
([focusListener, blurListener]) => Effect.sync(() => {
globalThis.window.removeEventListener("focus", focusListener)
globalThis.window.removeEventListener("blur", blurListener)
const listener = () => Queue.offerUnsafe(queue, isDocumentVisible())
globalThis.document.addEventListener("visibilitychange", listener)
return listener
}),
listener => Effect.sync(() => globalThis.document.removeEventListener("visibilitychange", listener)),
)),
),
})),
+57 -14
View File
@@ -1,4 +1,4 @@
import { Deferred, Effect, Fiber, Layer, Option, type Scope, Stream, SubscriptionRef } from "effect"
import { Deferred, Effect, Fiber, Layer, Option, Schedule, type Scope, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/reactivity"
import { describe, expect, it } from "vitest"
import * as AppConnectivity from "./AppConnectivity.js"
@@ -378,20 +378,63 @@ describe("browser layers", () => {
expect(values).toEqual([navigator.onLine, false, true])
})
it("AppFocus.layerBrowser follows the focus and blur events", async () => {
const values = await Effect.runPromise(Effect.gen(function*() {
const { focused } = yield* AppFocus.AppFocus
const values = yield* Effect.forkScoped(Stream.runCollect(Stream.take(View.changes(focused), 3)))
yield* Effect.sleep("10 millis")
it("AppFocus.layerBrowser follows the document's visibility", async () => {
let visibilityState: DocumentVisibilityState = "visible"
const setVisibility = (state: DocumentVisibilityState) => {
visibilityState = state
document.dispatchEvent(new Event("visibilitychange"))
}
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibilityState })
window.dispatchEvent(new Event("blur"))
window.dispatchEvent(new Event("focus"))
return yield* Fiber.join(values).pipe(Effect.timeout("1 second"))
}).pipe(
Effect.provide(AppFocus.layerBrowser),
Effect.scoped,
))
try {
const values = await Effect.runPromise(Effect.gen(function*() {
const { focused } = yield* AppFocus.AppFocus
const values = yield* Effect.forkScoped(Stream.runCollect(Stream.take(View.changes(focused), 3)))
yield* Effect.sleep("10 millis")
expect(values).toEqual([document.hasFocus(), false, true])
setVisibility("hidden")
setVisibility("visible")
return yield* Fiber.join(values).pipe(Effect.timeout("1 second"))
}).pipe(
Effect.provide(AppFocus.layerBrowser),
Effect.scoped,
))
expect(values).toEqual([true, false, true])
}
finally {
// Restores jsdom's own getter, from the prototype
delete (document as { visibilityState?: unknown }).visibilityState
}
})
})
describe("withScheduledRefresh", () => {
const countRefreshes = (focused: boolean, options?: Query.withScheduledRefresh.Options) =>
runQueryTest(withAppFocus(focused, () => Effect.gen(function*() {
let calls = 0
yield* Query.make({
key: staticKey(1),
f: () => Effect.sync(() => { calls += 1 }),
staleTime: "0 millis",
}).pipe(
Query.thenRun,
Query.withScheduledRefresh(Schedule.spaced("10 millis").pipe(Schedule.upTo({ times: 2 })), options),
)
yield* Effect.sleep("100 millis")
return calls
})))
it("refreshes while the app is focused", async () => {
expect(await countRefreshes(true)).toBe(3)
})
it("skips refreshes while the app is not focused", async () => {
expect(await countRefreshes(false)).toBe(1)
})
it("keeps refreshing while the app is not focused with refreshInBackground", async () => {
expect(await countRefreshes(false, { refreshInBackground: true })).toBe(3)
})
})
+37 -3
View File
@@ -384,8 +384,18 @@ export const thenRun = <K, A, E = never, R = never, E2 = never, R2 = never>(
query => Effect.forkScoped(query.run),
)
export declare namespace withScheduledRefresh {
export interface Options {
/**
* Whether to keep refreshing while the app is not focused (see `AppFocus`). Defaults to `false`: a refresh due
* while the app is not focused is skipped, but still counts as a recurrence of the schedule.
*/
readonly refreshInBackground?: boolean
}
}
/**
* Refreshes the Query on a schedule and returns it.
* Refreshes the Query on a schedule and returns it. By default, refreshes are skipped while the app is not focused.
*
* @example Refresh every five minutes, starting after five minutes
* ```ts
@@ -404,21 +414,45 @@ export const thenRun = <K, A, E = never, R = never, E2 = never, R2 = never>(
* ),
* )
* ```
*
* @example Keep refreshing while the app is in the background
* ```ts
* yield* Query.make(options).pipe(
* Query.thenRun,
* Query.withScheduledRefresh(Schedule.spaced("5 minutes"), { refreshInBackground: true }),
* )
* ```
*/
export const withScheduledRefresh: {
<Output, Error, Env>(
schedule: Schedule.Schedule<Output, unknown, Error, Env>,
options?: withScheduledRefresh.Options,
): <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>,
options?: withScheduledRefresh.Options,
): Effect.Effect<Query<K, A, E, R>, E2, Scope.Scope | Env | R2>
} = Function.dual(2, <K, A, E, R, E2, R2, Output, Error, Env>(
} = Function.dual(args => Effect.isEffect(args[0]), <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>,
options?: withScheduledRefresh.Options,
) => Effect.tap(
self,
query => Effect.forkScoped(Effect.schedule(query.refresh, schedule)),
query => Effect.forkScoped(Effect.schedule(
options?.refreshInBackground
? query.refresh
: Effect.flatMap(
Effect.provide(
Effect.flatMap(AppFocus.AppFocus, ({ focused }) => View.get(focused)),
query.context,
),
focused => focused
? Effect.asVoid(query.refresh)
: Effect.void,
),
schedule,
)),
))
+1 -1
View File
@@ -5,7 +5,7 @@ import { QueryClient, ReactRuntime } from "effect-view"
export const layer = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layer),
Layer.provideMerge(QueryClient.layerBrowser),
Layer.provideMerge(DateTime.layerCurrentZoneLocal),
Layer.provideMerge(Clipboard.layer),
Layer.provideMerge(Geolocation.layer),