1 parent
0b636e0eaf
commit
43bc165a1c
13 files changed
+274
-45
No files matched your search
@@ -234,13 +234,15 @@ actually a reactive read.
|
||||
## Network mode
|
||||
|
||||
Mutations take a `networkMode` option, just like queries. When it is not set,
|
||||
the `QueryClient`'s `defaultNetworkMode` is used, which is `"online"` unless
|
||||
you change it. The connection status comes from the `AppConnectivity` service,
|
||||
the `QueryClient`'s `defaultMutationNetworkMode` is used, which is `"online"`
|
||||
unless you change it. It is separate from `defaultNetworkMode`, which only
|
||||
applies to queries. The connection status comes from the `AppConnectivity` service,
|
||||
as described in [Query](./query.md#network-mode-and-offline-support).
|
||||
|
||||
| `networkMode` | Behavior |
|
||||
| --- | --- |
|
||||
| `"online"` | A call made offline waits for a connection before running, and `state.paused` is `true` meanwhile. |
|
||||
| `"offlineFirst"` | A call runs right away. If it fails while offline, it is `paused` until the connection returns, then runs again. |
|
||||
| `"always"` | Calls run regardless of the connection. |
|
||||
|
||||
```ts
|
||||
|
||||
@@ -22,7 +22,7 @@ If you already know TanStack Query, the main concepts translate directly:
|
||||
| `fetchStatus: 'paused'` | `state.paused` |
|
||||
| `refetch` | `query.refresh` or `query.refreshStream` |
|
||||
| `invalidateQueries` | `query.invalidateCache` or `invalidateCacheEntry` |
|
||||
| `networkMode` | `networkMode` (`"online"` or `"always"`) |
|
||||
| `networkMode` | `networkMode` (`"online"`, `"offlineFirst"` or `"always"`) |
|
||||
| `onlineManager` | The `AppConnectivity` service |
|
||||
| `focusManager` | The `AppFocus` service |
|
||||
|
||||
@@ -315,7 +315,8 @@ const query = yield* Query.make({
|
||||
```
|
||||
|
||||
Regaining focus resolves the current key again, subject to the same freshness
|
||||
check. Focus comes from the `AppFocus` service: with
|
||||
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.
|
||||
Other platforms, such as React Native, provide it themselves, as shown in
|
||||
[React Native](#react-native).
|
||||
@@ -327,6 +328,7 @@ A query's `networkMode` decides how it behaves without a network connection:
|
||||
| `networkMode` | Behavior |
|
||||
| --- | --- |
|
||||
| `"online"` (default) | A request waits for a connection before running `f`. Meanwhile its state is `paused`, with `result.waiting` still `true`. |
|
||||
| `"offlineFirst"` | A request runs right away, e.g. to be served by a service worker or HTTP cache. If it fails while offline, it is `paused` until the connection returns, then runs again. |
|
||||
| `"always"` | Requests ignore the connection. Suited to queries that do not use the network. |
|
||||
|
||||
Use `state.paused` to tell "loading" apart from "waiting for a connection":
|
||||
@@ -337,9 +339,10 @@ const [state] = yield* View.useAll([query.state])
|
||||
if (state.paused) return <p>Offline. Waiting for a connection…</p>
|
||||
```
|
||||
|
||||
With `refreshOnReconnect` (enabled by default in `"online"` mode), regaining
|
||||
the connection resolves the current key again, subject to the usual freshness
|
||||
check. Like TanStack Query, focus and reconnect refreshes never restart a
|
||||
With `refreshOnReconnect` (enabled by default, except in `"always"` mode),
|
||||
regaining the connection resolves the current key again, subject to the usual
|
||||
freshness check. Set it to `"always"` to refresh even when the result is still
|
||||
fresh. Like TanStack Query, focus and reconnect refreshes never restart a
|
||||
request that is already in flight: a running request keeps running, and one
|
||||
paused for lack of a connection resumes on its own. A paused request can still
|
||||
be superseded or interrupted like any other.
|
||||
|
||||
@@ -59,14 +59,15 @@
|
||||
|
||||
- [`2ed417a`](https://git.valverde.cloud/Thilawyn/effect-view/commit/2ed417a49f283c120af4034d1a496d3fe304dd05) - Add offline support to `Query`, like TanStack Query's `networkMode`.
|
||||
|
||||
- The new `networkMode` option (`Operation.NetworkMode`) is `"online"` by default: a request made offline waits for a connection before running, and its state has `paused: true` meanwhile, with `result.waiting` still `true`. With `"always"`, requests ignore the connection.
|
||||
- The new `refreshOnReconnect` option refreshes the current key when the connection returns, subject to `staleTime`. It defaults to `true` in `"online"` mode, and to `false` otherwise.
|
||||
- The new `networkMode` option (`Operation.NetworkMode`) is `"online"` by default: a request made offline waits for a connection before running, and its state has `paused: true` meanwhile, with `result.waiting` still `true`. With `"offlineFirst"`, a request runs right away, and if it fails while offline, it is paused until the connection returns, then runs again. With `"always"`, requests ignore the connection.
|
||||
- The new `refreshOnReconnect` option refreshes the current key when the connection returns, subject to `staleTime`. It defaults to `true`, except in `"always"` mode.
|
||||
- `refreshOnFocus` and `refreshOnReconnect` accept `"always"`, to refresh even a fresh result (`Query.RefreshOn`).
|
||||
- `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.
|
||||
|
||||
- [`79d9061`](https://git.valverde.cloud/Thilawyn/effect-view/commit/79d906190754193b1b86bd07cd176d402180509f) - **Breaking:** `Mutation` takes a `networkMode` option too, defaulting to the client's `defaultNetworkMode`. Mutations called offline are now paused until the connection returns, unless `networkMode` is `"always"`. `Mutation.make` (and `MutationForm.make`) now require a `QueryClient`.
|
||||
- [`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`.
|
||||
|
||||
- [`2ed417a`](https://git.valverde.cloud/Thilawyn/effect-view/commit/2ed417a49f283c120af4034d1a496d3fe304dd05) - **Breaking:** `QueryClient.layer` and `QueryClient.layerWithOptions` now require the `AppConnectivity` and `AppFocus` services. In a browser, use `QueryClient.layerBrowser` and `QueryClient.layerBrowserWithOptions`, which provide both.
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ Starting a mutation does not interrupt an earlier one — calls can overlap, eac
|
||||
|
||||
## Network mode
|
||||
|
||||
Like queries, `Mutation.make({ f, networkMode? })` takes a `networkMode` (falls back to the `QueryClient`'s `defaultNetworkMode`, `"online"` by default). In `"online"` mode a call made offline waits for the `AppConnectivity` service to report a connection before running `f`; meanwhile `state.paused` is `true` (with `result.waiting` still `true`). `"always"` ignores the connection.
|
||||
Like queries, `Mutation.make({ f, networkMode? })` takes a `networkMode` (falls back to the `QueryClient`'s `defaultMutationNetworkMode`, `"online"` by default — separate from the queries' `defaultNetworkMode`). In `"online"` mode a call made offline waits for the `AppConnectivity` service to report a connection before running `f`; meanwhile `state.paused` is `true` (with `result.waiting` still `true`). `"offlineFirst"` runs `f` right away, and if it fails while offline, pauses until the connection returns and runs it again. `"always"` ignores the connection.
|
||||
|
||||
## Updating queries after a mutation
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ effect-view's take on TanStack Query: reactive query keys, cached results, stale
|
||||
| `fetchStatus: 'paused'` | `state.paused` |
|
||||
| `refetch` | `query.refresh` / `query.refreshStream` |
|
||||
| `invalidateQueries` | `query.invalidateCache` / `invalidateCacheEntry` |
|
||||
| `networkMode` | `networkMode` (`"online"` / `"always"`) |
|
||||
| `networkMode` | `networkMode` (`"online"` / `"offlineFirst"` / `"always"`) |
|
||||
| `onlineManager` / `focusManager` | the `AppConnectivity` / `AppFocus` services |
|
||||
|
||||
## QueryClient: the shared cache
|
||||
@@ -33,7 +33,7 @@ const appLayer = Layer.empty.pipe(
|
||||
export const runtime = ReactRuntime.make(appLayer)
|
||||
```
|
||||
|
||||
`QueryClient.layer` (defaults) / `QueryClient.layerWithOptions(options)` build the service and fork its background garbage-collection loop into the layer's scope. Individual queries may override `staleTime`/`networkMode`/`refreshOnFocus`/`refreshOnReconnect`; unset options fall back to these client defaults (`defaultStaleTime`, `defaultNetworkMode`, `defaultRefreshOnFocus`, `defaultRefreshOnReconnect`). `cacheGcTime` controls how long a stale, unaccessed cache entry is kept before eviction. They require the `AppConnectivity` and `AppFocus` services (see "Network mode and offline support"); `QueryClient.layerBrowser` / `QueryClient.layerBrowserWithOptions(options)` provide the browser implementations of both. You interact with the client only indirectly through `Query` instances — no need to call `QueryClientService` methods directly.
|
||||
`QueryClient.layer` (defaults) / `QueryClient.layerWithOptions(options)` build the service and fork its background garbage-collection loop into the layer's scope. Individual queries may override `staleTime`/`networkMode`/`refreshOnFocus`/`refreshOnReconnect`; unset options fall back to these client defaults (`defaultStaleTime`, `defaultNetworkMode`, `defaultRefreshOnFocus`, `defaultRefreshOnReconnect`); mutations use `defaultMutationNetworkMode` instead of `defaultNetworkMode`. `cacheGcTime` controls how long a stale, unaccessed cache entry is kept before eviction. They require the `AppConnectivity` and `AppFocus` services (see "Network mode and offline support"); `QueryClient.layerBrowser` / `QueryClient.layerBrowserWithOptions(options)` provide the browser implementations of both. You interact with the client only indirectly through `Query` instances — no need to call `QueryClientService` methods directly.
|
||||
|
||||
## Create and run a query
|
||||
|
||||
@@ -114,13 +114,14 @@ const query = yield* Query.make(options).pipe(
|
||||
|
||||
- `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`).
|
||||
- `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`).
|
||||
|
||||
## Network mode and offline support
|
||||
|
||||
- `networkMode: "online"` (default): a request waits for a connection before running `f`; meanwhile `state.paused` is `true` (with `result.waiting` still `true`). Paused requests can be superseded/interrupted like any other, which clears `paused`.
|
||||
- `networkMode: "offlineFirst"`: runs `f` right away (e.g. served by a service worker / HTTP cache); if it fails while offline, the request is `paused` until the connection returns, then `f` runs again. A failure while online settles normally.
|
||||
- `networkMode: "always"`: ignores the connection (for queries that don't use the network).
|
||||
- `refreshOnReconnect` (defaults to the client default in `"online"` mode, `false` otherwise): re-resolves the current key when the connection returns, subject to freshness. Focus/reconnect refreshes never restart an in-flight request (a paused one resumes by itself), as in TanStack Query.
|
||||
- `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.
|
||||
- React Native: build them from platform APIs:
|
||||
|
||||
@@ -48,7 +48,7 @@ export const layerBrowser: Layer.Layer<AppConnectivity> = Layer.succeed(
|
||||
Effect.sync(() => {
|
||||
const onlineListener = () => Queue.offerUnsafe(queue, true)
|
||||
globalThis.window.addEventListener("online", onlineListener)
|
||||
const offlineListener = () => Queue.offerUnsafe(queue, true)
|
||||
const offlineListener = () => Queue.offerUnsafe(queue, false)
|
||||
globalThis.window.addEventListener("offline", offlineListener)
|
||||
|
||||
return [onlineListener, offlineListener] as const
|
||||
|
||||
@@ -48,7 +48,7 @@ export const layerBrowser: Layer.Layer<AppFocus> = Layer.succeed(
|
||||
Effect.sync(() => {
|
||||
const focusListener = () => Queue.offerUnsafe(queue, true)
|
||||
globalThis.window.addEventListener("focus", focusListener)
|
||||
const blurListener = () => Queue.offerUnsafe(queue, true)
|
||||
const blurListener = () => Queue.offerUnsafe(queue, false)
|
||||
globalThis.window.addEventListener("blur", blurListener)
|
||||
|
||||
return [focusListener, blurListener] as const
|
||||
|
||||
@@ -247,4 +247,31 @@ describe("Mutation network mode", () => {
|
||||
expect(expectSuccessValue(result)).toBe("value:1")
|
||||
expect(result.paused).toBe(false)
|
||||
})
|
||||
|
||||
it("uses the client's defaultMutationNetworkMode, not defaultNetworkMode", async () => {
|
||||
const result = await Effect.runPromise(Effect.scoped(Effect.gen(function*() {
|
||||
const mutation = yield* Mutation.make({
|
||||
f: (key: number) => Effect.succeed(`value:${key}`),
|
||||
})
|
||||
|
||||
return yield* mutation.mutate(1).pipe(Effect.timeout("1 second"))
|
||||
}).pipe(
|
||||
Effect.provide(QueryClient.layerWithOptions({
|
||||
defaultNetworkMode: "online",
|
||||
defaultMutationNetworkMode: "always",
|
||||
}).pipe(
|
||||
Layer.provide(Layer.mergeAll(
|
||||
Layer.succeed(AppConnectivity.AppConnectivity, AppConnectivity.make(View.make({
|
||||
get: Effect.succeed(false),
|
||||
changes: Stream.make(false),
|
||||
}))),
|
||||
AppFocus.layerDummy,
|
||||
)),
|
||||
)),
|
||||
)))
|
||||
|
||||
expect(expectSuccessValue(result)).toBe("value:1")
|
||||
expect(result.paused).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -153,7 +153,7 @@ export const make = Effect.fnUntraced(function* <K = never, A = void, E = never,
|
||||
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | R>(),
|
||||
),
|
||||
options.f,
|
||||
options.networkMode ?? client.defaultNetworkMode,
|
||||
options.networkMode ?? client.defaultMutationNetworkMode,
|
||||
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<K>())),
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Operation.Operation<Option.Some<K>, A, E, R>>())),
|
||||
|
||||
@@ -32,8 +32,8 @@ extends Pipeable.Pipeable {
|
||||
readonly stream: Stream.Stream<OperationState<K, A, E>>
|
||||
|
||||
/**
|
||||
* Forks `effect` in the current scope, moving the state to waiting, then to the result of `effect`. In `"online"`
|
||||
* network mode, while `online` is false the Operation is paused and waits before running `effect`.
|
||||
* Forks `effect` in the current scope, moving the state to waiting, then to the result of `effect`. Depending on the
|
||||
* network mode, the Operation may be paused while `online` is false (see `NetworkMode`).
|
||||
* Returns the running fiber, or `None` if it has already completed.
|
||||
*/
|
||||
readonly start: Effect.Effect<Option.Option<Fiber.Fiber<A, E>>>
|
||||
@@ -82,9 +82,11 @@ export interface OperationFinalState<out K, out A, out E = never> {
|
||||
/**
|
||||
* - `"online"`: an Operation waits for a network connection (see `AppConnectivity`) before running, and is `paused`
|
||||
* meanwhile.
|
||||
* - `"offlineFirst"`: an Operation runs once regardless of the network connection, e.g. for effects that may be served
|
||||
* from a cache. If it fails while offline, it waits for a network connection, `paused` meanwhile, and runs again.
|
||||
* - `"always"`: Operations ignore the network connection, e.g. for effects that do not use the network.
|
||||
*/
|
||||
export type NetworkMode = "online" | "always"
|
||||
export type NetworkMode = "online" | "offlineFirst" | "always"
|
||||
|
||||
export const isOperation = (u: unknown): u is Operation<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, OperationTypeId)
|
||||
|
||||
@@ -115,9 +117,7 @@ extends Pipeable.Class implements Operation<K, A, E, R> {
|
||||
Effect.onExit(
|
||||
Effect.andThen(
|
||||
this.update(state => withWaiting(state, true)),
|
||||
this.networkMode === "online"
|
||||
? Effect.andThen(this.awaitOnline, this.effect)
|
||||
: this.effect,
|
||||
this.runWithNetworkMode,
|
||||
),
|
||||
exit => Effect.flatMap(
|
||||
Lens.get(this.state),
|
||||
@@ -138,6 +138,33 @@ extends Pipeable.Class implements Operation<K, A, E, R> {
|
||||
)
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/useGetterReturn: the switch is exhaustive, which TypeScript checks
|
||||
get runWithNetworkMode(): Effect.Effect<A, E, AppConnectivity.AppConnectivity | R> {
|
||||
switch (this.networkMode) {
|
||||
case "online":
|
||||
return Effect.andThen(this.awaitOnline, this.effect)
|
||||
case "offlineFirst":
|
||||
return this.runRetryingOnReconnect
|
||||
case "always":
|
||||
return this.effect
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs `effect`, and if it fails while offline, waits for `online`, paused meanwhile, and runs it again. */
|
||||
get runRetryingOnReconnect(): Effect.Effect<A, E, AppConnectivity.AppConnectivity | R> {
|
||||
return Effect.catchCause(
|
||||
this.effect,
|
||||
cause => Effect.gen({ self: this }, function*() {
|
||||
const { online } = yield* AppConnectivity.AppConnectivity
|
||||
if (Cause.hasInterruptsOnly(cause) || (yield* View.get(online)))
|
||||
return yield* Effect.failCause(cause)
|
||||
|
||||
yield* this.awaitOnline
|
||||
return yield* this.runRetryingOnReconnect
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Waits for `online`, paused meanwhile. */
|
||||
get awaitOnline(): Effect.Effect<void, never, AppConnectivity.AppConnectivity> {
|
||||
return Effect.gen({ self: this }, function*() {
|
||||
|
||||
@@ -245,4 +245,153 @@ describe("Query network mode", () => {
|
||||
|
||||
expect(result).toEqual({ afterStart: 1, afterFocus: 2 })
|
||||
})
|
||||
|
||||
it("in offlineFirst mode, a request made offline runs right away", async () => {
|
||||
const result = await runQueryTest(withAppConnectivity(false, () => Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key: staticKey(1),
|
||||
f: (id: number) => Effect.succeed(`value:${id}`),
|
||||
networkMode: "offlineFirst",
|
||||
})
|
||||
|
||||
return yield* query.fetch(1).pipe(Effect.timeout("1 second"))
|
||||
})))
|
||||
|
||||
expect(AsyncResult.isSuccess(result.result)).toBe(true)
|
||||
expect(result.paused).toBe(false)
|
||||
})
|
||||
|
||||
it("in offlineFirst mode, a request that fails offline is paused, then runs again when the connection returns", async () => {
|
||||
const result = await runQueryTest(withAppConnectivity(false, online => Effect.gen(function*() {
|
||||
let calls = 0
|
||||
const query = yield* Query.make({
|
||||
key: staticKey(1),
|
||||
f: (id: number) => Effect.suspend(() => {
|
||||
calls += 1
|
||||
return calls === 1 ? Effect.fail("offline") : Effect.succeed(`value:${id}`)
|
||||
}),
|
||||
networkMode: "offlineFirst",
|
||||
})
|
||||
|
||||
const stream = yield* query.fetchStream(1)
|
||||
const states = yield* Effect.forkScoped(Stream.runCollect(stream))
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const whileOffline = { calls, state: yield* View.get(query.state) }
|
||||
yield* SubscriptionRef.set(online, true)
|
||||
|
||||
return {
|
||||
whileOffline,
|
||||
states: yield* Fiber.join(states).pipe(Effect.timeout("1 second")),
|
||||
calls,
|
||||
}
|
||||
})))
|
||||
|
||||
expect(result.whileOffline.calls).toBe(1)
|
||||
expect(result.whileOffline.state.paused).toBe(true)
|
||||
expect(result.whileOffline.state.result.waiting).toBe(true)
|
||||
|
||||
expect(result.calls).toBe(2)
|
||||
const last = result.states.at(-1)
|
||||
expect(last && AsyncResult.isSuccess(last.result) && last.result.value).toBe("value:1")
|
||||
expect(last?.paused).toBe(false)
|
||||
})
|
||||
|
||||
it("in offlineFirst mode, a request that fails online is not run again", async () => {
|
||||
const result = await runQueryTest(withAppConnectivity(true, () => Effect.gen(function*() {
|
||||
let calls = 0
|
||||
const query = yield* Query.make({
|
||||
key: staticKey(1),
|
||||
f: (_id: number) => Effect.suspend(() => {
|
||||
calls += 1
|
||||
return Effect.fail("error")
|
||||
}),
|
||||
networkMode: "offlineFirst",
|
||||
})
|
||||
|
||||
return { final: yield* query.fetch(1).pipe(Effect.timeout("1 second")), calls }
|
||||
})))
|
||||
|
||||
expect(AsyncResult.isFailure(result.final.result)).toBe(true)
|
||||
expect(result.calls).toBe(1)
|
||||
})
|
||||
|
||||
it("refreshes a fresh query on focus only when refreshOnFocus is \"always\"", async () => {
|
||||
const result = await runQueryTest(withAppFocus(true, focused => Effect.gen(function*() {
|
||||
const calls = { true: 0, always: 0 }
|
||||
const makeQuery = (refreshOnFocus: true | "always") => Query.make({
|
||||
key: staticKey(refreshOnFocus),
|
||||
f: () => Effect.sync(() => { calls[refreshOnFocus === true ? "true" : "always"] += 1 }),
|
||||
staleTime: "1 hour",
|
||||
refreshOnFocus,
|
||||
}).pipe(Query.thenRun)
|
||||
|
||||
yield* makeQuery(true)
|
||||
yield* makeQuery("always")
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* SubscriptionRef.set(focused, false)
|
||||
yield* SubscriptionRef.set(focused, true)
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
return calls
|
||||
})))
|
||||
|
||||
expect(result).toEqual({ true: 1, always: 2 })
|
||||
})
|
||||
|
||||
it("refreshes a fresh query on reconnect when refreshOnReconnect is \"always\"", async () => {
|
||||
const calls = await runQueryTest(withAppConnectivity(true, online => Effect.gen(function*() {
|
||||
let calls = 0
|
||||
yield* Query.make({
|
||||
key: staticKey(1),
|
||||
f: () => Effect.sync(() => { calls += 1 }),
|
||||
staleTime: "1 hour",
|
||||
refreshOnReconnect: "always",
|
||||
}).pipe(Query.thenRun)
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* SubscriptionRef.set(online, false)
|
||||
yield* SubscriptionRef.set(online, true)
|
||||
yield* Effect.sleep("10 millis")
|
||||
return calls
|
||||
})))
|
||||
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("browser layers", () => {
|
||||
it("AppConnectivity.layerBrowser follows the online and offline events", async () => {
|
||||
const values = await Effect.runPromise(Effect.gen(function*() {
|
||||
const { online } = yield* AppConnectivity.AppConnectivity
|
||||
const values = yield* Effect.forkScoped(Stream.runCollect(Stream.take(View.changes(online), 3)))
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
window.dispatchEvent(new Event("offline"))
|
||||
window.dispatchEvent(new Event("online"))
|
||||
return yield* Fiber.join(values).pipe(Effect.timeout("1 second"))
|
||||
}).pipe(
|
||||
Effect.provide(AppConnectivity.layerBrowser),
|
||||
Effect.scoped,
|
||||
))
|
||||
|
||||
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")
|
||||
|
||||
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,
|
||||
))
|
||||
|
||||
expect(values).toEqual([document.hasFocus(), false, true])
|
||||
})
|
||||
})
|
||||
@@ -22,8 +22,8 @@ extends Pipeable.Pipeable {
|
||||
|
||||
readonly staleTime: Duration.Duration
|
||||
readonly networkMode: Operation.NetworkMode
|
||||
readonly refreshOnFocus: boolean
|
||||
readonly refreshOnReconnect: boolean
|
||||
readonly refreshOnFocus: RefreshOn
|
||||
readonly refreshOnReconnect: RefreshOn
|
||||
|
||||
readonly operation: View.View<Option.Option<Operation.Operation<K, A, E, QueryClient.QueryClient | R>>>
|
||||
readonly state: View.View<QueryState<K, A, E>>
|
||||
@@ -56,6 +56,14 @@ export interface QueryFinalState<out K, out A, out E = never> {
|
||||
readonly paused: false
|
||||
}
|
||||
|
||||
/**
|
||||
* When a Query refreshes its current key on an event, such as the app regaining focus or connection:
|
||||
* - `true`: if the cached result is stale.
|
||||
* - `"always"`: regardless of `staleTime`.
|
||||
* - `false`: never.
|
||||
*/
|
||||
export type RefreshOn = boolean | "always"
|
||||
|
||||
export const isQuery = (u: unknown): u is Query<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId)
|
||||
|
||||
|
||||
@@ -71,8 +79,8 @@ extends Pipeable.Class implements Query<K, A, E, R> {
|
||||
|
||||
readonly staleTime: Duration.Duration,
|
||||
readonly networkMode: Operation.NetworkMode,
|
||||
readonly refreshOnFocus: boolean,
|
||||
readonly refreshOnReconnect: boolean,
|
||||
readonly refreshOnFocus: RefreshOn,
|
||||
readonly refreshOnReconnect: RefreshOn,
|
||||
|
||||
readonly operation: Lens.Lens<Option.Option<Operation.Operation<K, A, E, QueryClient.QueryClient | R>>>,
|
||||
readonly state: Lens.Lens<QueryState<K, A, E>>,
|
||||
@@ -94,14 +102,14 @@ extends Pipeable.Class implements Query<K, A, E, R> {
|
||||
this.refreshOnFocus
|
||||
? Effect.flatMap(AppFocus.AppFocus, ({ focused }) => Stream.runForEach(
|
||||
becomesTrue(focused),
|
||||
() => this.refreshUnlessWaiting,
|
||||
() => this.refreshUnlessWaiting(this.refreshOnFocus === "always"),
|
||||
))
|
||||
: Effect.void,
|
||||
|
||||
this.refreshOnReconnect
|
||||
? Effect.flatMap(AppConnectivity.AppConnectivity, ({ online }) => Stream.runForEach(
|
||||
becomesTrue(online),
|
||||
() => this.refreshUnlessWaiting,
|
||||
() => this.refreshUnlessWaiting(this.refreshOnReconnect === "always"),
|
||||
))
|
||||
: Effect.void,
|
||||
], { concurrency: "unbounded" }).pipe(
|
||||
@@ -162,14 +170,15 @@ extends Pipeable.Class implements Query<K, A, E, R> {
|
||||
|
||||
/**
|
||||
* Refreshes the current key, unless a request is already in flight. Used when the app regains focus or connection:
|
||||
* a request that is running keeps running, and one paused for lack of a connection resumes by itself.
|
||||
* a request that is running keeps running, and one paused for lack of a connection resumes by itself. With
|
||||
* `ignoreStaleTime`, a fresh cached result is refreshed too.
|
||||
*/
|
||||
get refreshUnlessWaiting(): Effect.Effect<void, Cause.NoSuchElementError, Query.Context<R>> {
|
||||
refreshUnlessWaiting(ignoreStaleTime: boolean): Effect.Effect<void, Cause.NoSuchElementError, Query.Context<R>> {
|
||||
return Effect.flatMap(
|
||||
Lens.get(this.state),
|
||||
state => state.result.waiting
|
||||
? Effect.void
|
||||
: Effect.asVoid(this.start(this.previousForLatestKey)),
|
||||
: Effect.asVoid(this.start(this.previousForLatestKey, ignoreStaleTime)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -191,10 +200,11 @@ extends Pipeable.Class implements Query<K, A, E, R> {
|
||||
|
||||
/**
|
||||
* Interrupts the current Operation and replaces it with a new one, started from `previous` or served from the
|
||||
* cache.
|
||||
* cache unless `ignoreStaleTime` is true.
|
||||
*/
|
||||
start(
|
||||
previous: Effect.Effect<QueryState<K, A, E>>,
|
||||
ignoreStaleTime = false,
|
||||
): Effect.Effect<
|
||||
Operation.Operation<K, A, E, QueryClient.QueryClient | R>,
|
||||
Cause.NoSuchElementError,
|
||||
@@ -205,7 +215,9 @@ extends Pipeable.Class implements Query<K, A, E, R> {
|
||||
|
||||
const { key, result } = yield* previous
|
||||
const entry = yield* this.getCacheEntry(key)
|
||||
const isFresh = Option.isSome(entry) && !(yield* QueryClient.isQueryClientCacheEntryStale(entry.value))
|
||||
const isFresh = !ignoreStaleTime
|
||||
&& Option.isSome(entry)
|
||||
&& !(yield* QueryClient.isQueryClientCacheEntryStale(entry.value))
|
||||
|
||||
const effect = Effect.tap(this.f(key), v => this.setCacheEntry(key, AsyncResult.success(v)))
|
||||
|
||||
@@ -315,9 +327,9 @@ export declare namespace make {
|
||||
|
||||
readonly staleTime?: Duration.Input
|
||||
readonly networkMode?: Operation.NetworkMode
|
||||
readonly refreshOnFocus?: boolean
|
||||
/** Defaults to the client's `defaultRefreshOnReconnect` in `"online"` network mode, and to `false` otherwise. */
|
||||
readonly refreshOnReconnect?: boolean
|
||||
readonly refreshOnFocus?: RefreshOn
|
||||
/** Defaults to the client's `defaultRefreshOnReconnect`, and to `false` in `"always"` network mode. */
|
||||
readonly refreshOnReconnect?: RefreshOn
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +355,7 @@ export const make = Effect.fnUntraced(function* <K, A, E = never, R = never>(
|
||||
options.staleTime ? yield* Effect.fromOption(Duration.fromInput(options.staleTime)) : client.defaultStaleTime,
|
||||
networkMode,
|
||||
options.refreshOnFocus ?? client.defaultRefreshOnFocus,
|
||||
options.refreshOnReconnect ?? (networkMode === "online" && client.defaultRefreshOnReconnect),
|
||||
options.refreshOnReconnect ?? (networkMode === "always" ? false : client.defaultRefreshOnReconnect),
|
||||
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Operation.Operation<K, A, E, QueryClient.QueryClient | R>>())),
|
||||
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<QueryState<K, A, E>>({
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as AppConnectivity from "./AppConnectivity.js"
|
||||
import * as AppFocus from "./AppFocus.js"
|
||||
import * as Lens from "./Lens.js"
|
||||
import type * as Operation from "./Operation.js"
|
||||
import type * as Query from "./Query.js"
|
||||
import type * as View from "./View.js"
|
||||
|
||||
|
||||
@@ -19,8 +20,9 @@ export interface QueryClientService extends Pipeable.Pipeable {
|
||||
|
||||
readonly defaultStaleTime: Duration.Duration
|
||||
readonly defaultNetworkMode: Operation.NetworkMode
|
||||
readonly defaultRefreshOnFocus: boolean
|
||||
readonly defaultRefreshOnReconnect: boolean
|
||||
readonly defaultRefreshOnFocus: Query.RefreshOn
|
||||
readonly defaultRefreshOnReconnect: Query.RefreshOn
|
||||
readonly defaultMutationNetworkMode: Operation.NetworkMode
|
||||
|
||||
readonly run: Effect.Effect<void, Cause.NoSuchElementError>
|
||||
|
||||
@@ -50,8 +52,9 @@ implements QueryClientService {
|
||||
|
||||
readonly defaultStaleTime: Duration.Duration,
|
||||
readonly defaultNetworkMode: Operation.NetworkMode,
|
||||
readonly defaultRefreshOnFocus: boolean,
|
||||
readonly defaultRefreshOnReconnect: boolean,
|
||||
readonly defaultRefreshOnFocus: Query.RefreshOn,
|
||||
readonly defaultRefreshOnReconnect: Query.RefreshOn,
|
||||
readonly defaultMutationNetworkMode: Operation.NetworkMode,
|
||||
|
||||
readonly runSemaphore: Semaphore.Semaphore,
|
||||
) {
|
||||
@@ -112,9 +115,12 @@ export declare namespace make {
|
||||
export interface Options {
|
||||
readonly cacheGcTime?: Duration.Input
|
||||
readonly defaultStaleTime?: Duration.Input
|
||||
/** The default network mode of queries. */
|
||||
readonly defaultNetworkMode?: Operation.NetworkMode
|
||||
readonly defaultRefreshOnFocus?: boolean
|
||||
readonly defaultRefreshOnReconnect?: boolean
|
||||
readonly defaultRefreshOnFocus?: Query.RefreshOn
|
||||
readonly defaultRefreshOnReconnect?: Query.RefreshOn
|
||||
/** The default network mode of mutations. */
|
||||
readonly defaultMutationNetworkMode?: Operation.NetworkMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +143,7 @@ export const make = Effect.fnUntraced(function* (
|
||||
options.defaultNetworkMode ?? "online",
|
||||
options.defaultRefreshOnFocus ?? true,
|
||||
options.defaultRefreshOnReconnect ?? true,
|
||||
options.defaultMutationNetworkMode ?? "online",
|
||||
|
||||
yield* Semaphore.make(1),
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user