Update Query
This commit is contained in:
1 parent
43bc165a1c
commit
63b2c62fa3
9 files changed
+130
-41
No files matched your search
@@ -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=="],
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)),
|
||||
)),
|
||||
),
|
||||
})),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
)),
|
||||
))
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user