From febe445ea1c398a7c9fa6a3333ccd7a31e656006 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Mon, 10 Aug 2026 20:49:56 +0200 Subject: [PATCH 1/4] perf(vue): soft-invalidate Work queries --- .changeset/soft-work-invalidations.md | 5 ++++ packages/vue/src/atomQuery.ts | 11 ++++++++ packages/vue/src/dependencyMetadata.ts | 18 +++++++++++++ packages/vue/src/internal/tanstackQuery.ts | 7 +++++ packages/vue/src/makeClient.ts | 6 ++++- packages/vue/src/mutate.ts | 27 ++++++++++++++++--- .../vue/test/dependencyInvalidation.test.ts | 20 ++++++++++---- 7 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 .changeset/soft-work-invalidations.md diff --git a/.changeset/soft-work-invalidations.md b/.changeset/soft-work-invalidations.md new file mode 100644 index 000000000..71142410a --- /dev/null +++ b/.changeset/soft-work-invalidations.md @@ -0,0 +1,5 @@ +--- +"@effect-app/vue": patch +--- + +Refetch Work overview queries without blocking command completion. diff --git a/packages/vue/src/atomQuery.ts b/packages/vue/src/atomQuery.ts index e2a76899f..c3fd84d81 100644 --- a/packages/vue/src/atomQuery.ts +++ b/packages/vue/src/atomQuery.ts @@ -115,6 +115,17 @@ const atomsForKeys = (keys: ReadonlyArray): ReadonlyArray): Effect.Effect => + Effect.gen(function*() { + const atoms = atomsForKeys(keys) + yield* Effect.forEach(atoms, captureAtomQueryParentSpan, { discard: true, concurrency: "inherit" }) + if (atoms.length === 0) return + yield* Effect.forEach(atoms, (atom) => Effect.sync(() => defaultRegistry.refresh(atom)), { + discard: true + }) + }) + /** * Invalidate the given keys and AWAIT the result. `keyAtoms` resolves all matching hierarchical * keys to a deduplicated set of query atoms. Refresh that set directly: sending the whole key set diff --git a/packages/vue/src/dependencyMetadata.ts b/packages/vue/src/dependencyMetadata.ts index 363c8d228..057d3b5ce 100644 --- a/packages/vue/src/dependencyMetadata.ts +++ b/packages/vue/src/dependencyMetadata.ts @@ -40,3 +40,21 @@ export const getDerivedInvalidationKeys = ( } return keys } + +/** Work overview queries are soft-fresh: domain mutations should not wait for their refetch. */ +export const isSoftInvalidationKey = (key: ReadonlyArray): boolean => key[0] === "$Work" + +export const partitionInvalidationKeys = ( + keys: ReadonlyArray> +): { + readonly awaitKeys: ReadonlyArray> + readonly softKeys: ReadonlyArray> +} => { + const awaitKeys: Array> = [] + const softKeys: Array> = [] + for (const key of keys) { + if (isSoftInvalidationKey(key)) softKeys.push(key) + else awaitKeys.push(key) + } + return { awaitKeys, softKeys } +} diff --git a/packages/vue/src/internal/tanstackQuery.ts b/packages/vue/src/internal/tanstackQuery.ts index 4770349bf..06e32786d 100644 --- a/packages/vue/src/internal/tanstackQuery.ts +++ b/packages/vue/src/internal/tanstackQuery.ts @@ -111,6 +111,13 @@ export const makeTanstackQueryInvalidator = (queryClient: QueryClient): QueryInv }, { discard: true, concurrency: "inherit" } ) + }), + invalidateSoft: (keys) => + Effect.gen(function*() { + const span = yield* Effect.currentParentSpan.pipe(Effect.orElseSucceed(() => undefined)) + for (const queryKey of keys) { + void queryClient.invalidateQueries({ queryKey }, { updateMeta: { span } }) + } }) }) diff --git a/packages/vue/src/makeClient.ts b/packages/vue/src/makeClient.ts index 0856f320e..a9f96df8c 100644 --- a/packages/vue/src/makeClient.ts +++ b/packages/vue/src/makeClient.ts @@ -19,7 +19,7 @@ import * as Struct from "effect/Struct" import type * as AsyncResult from "effect/unstable/reactivity/AsyncResult" import * as Reactivity from "effect/unstable/reactivity/Reactivity" import { type ComputedRef, effectScope, onBeforeUnmount, onScopeDispose, ref, type WatchSource } from "vue" -import { type AtomClientRuntime, invalidateAndAwait, makeAtomClientRuntime } from "./atomQuery.ts" +import { type AtomClientRuntime, invalidateAndAwait, invalidateSoft, makeAtomClientRuntime } from "./atomQuery.ts" import { type Commander, CommanderStatic, type Progress } from "./commander.ts" import { makeTanstackQuery, makeTanstackQueryCacheUpdater, makeTanstackQueryClient, makeTanstackQueryInvalidator } from "./internal/tanstackQuery.ts" import { type I18n } from "./intl.ts" @@ -720,6 +720,10 @@ const makeResolvedAtomQueryInvalidator = (getContext: () => Context.Context invalidateAndAwait(keys).pipe( Effect.provideService(Reactivity.Reactivity, getReactivity()) + ), + invalidateSoft: (keys) => + invalidateSoft(keys).pipe( + Effect.provideService(Reactivity.Reactivity, getReactivity()) ) } } diff --git a/packages/vue/src/mutate.ts b/packages/vue/src/mutate.ts index 7d33f74a0..411e761c3 100644 --- a/packages/vue/src/mutate.ts +++ b/packages/vue/src/mutate.ts @@ -13,8 +13,8 @@ import * as Stream from "effect/Stream" import * as AsyncResult from "effect/unstable/reactivity/AsyncResult" import type * as Reactivity from "effect/unstable/reactivity/Reactivity" import { computed, type ComputedRef, shallowRef } from "vue" -import { invalidateAndAwait } from "./atomQuery.ts" -import { getDerivedInvalidationKeys } from "./dependencyMetadata.ts" +import { invalidateAndAwait, invalidateSoft } from "./atomQuery.ts" +import { getDerivedInvalidationKeys, partitionInvalidationKeys } from "./dependencyMetadata.ts" export type GetQueryKey = (h: { id: string; options?: ClientForOptions }) => string[] @@ -119,10 +119,12 @@ export type QueryInvalidationEffect = ( ) => Effect.Effect export interface QueryInvalidator { readonly invalidateAndAwait: QueryInvalidationEffect + readonly invalidateSoft?: QueryInvalidationEffect } export const atomQueryInvalidator: QueryInvalidator = { - invalidateAndAwait + invalidateAndAwait, + invalidateSoft } export const combineQueryInvalidators = ( @@ -133,6 +135,12 @@ export const combineQueryInvalidators = ( invalidators, (invalidator) => invalidator.invalidateAndAwait(keys), { discard: true, concurrency: "inherit" } + ), + invalidateSoft: (keys) => + Effect.forEach( + invalidators, + (invalidator) => invalidator.invalidateSoft?.(keys) ?? Effect.void, + { discard: true, concurrency: "inherit" } ) }) @@ -322,10 +330,14 @@ const buildInvalidateCache = ( if (!isReadonlyArrayNonEmpty(keys)) return Effect.void + const { awaitKeys, softKeys } = partitionInvalidationKeys(keys) + return Effect .andThen( Effect.annotateCurrentSpan({ keys, + awaitKeys, + softKeys, clientKeys, serverKeys, derivedKeys, @@ -333,7 +345,14 @@ const buildInvalidateCache = ( }), // refetch + AWAIT every live query registered under these keys, so by the time the // mutation resolves the affected queries are fresh. - queryInvalidator.invalidateAndAwait(keys) + Effect.gen(function*() { + if (softKeys.length > 0) { + yield* queryInvalidator.invalidateSoft?.(softKeys) ?? Effect.void + } + if (awaitKeys.length > 0) { + yield* queryInvalidator.invalidateAndAwait(awaitKeys) + } + }) ) .pipe( Effect.tap(Effect.sleep(0.1)), // allow for refs to update etc diff --git a/packages/vue/test/dependencyInvalidation.test.ts b/packages/vue/test/dependencyInvalidation.test.ts index 219a22a5e..57c16fb25 100644 --- a/packages/vue/test/dependencyInvalidation.test.ts +++ b/packages/vue/test/dependencyInvalidation.test.ts @@ -13,7 +13,7 @@ import { TestClock } from "effect/testing" import * as Reactivity from "effect/unstable/reactivity/Reactivity" import { createApp, effectScope, ref } from "vue" import { awaitAtomResult, buildQueryFamily, invalidateAndAwait, makeAtomClientRuntime } from "../src/atomQuery.js" -import { clearQueryReadDependencies, getDerivedInvalidationKeys, setQueryReadDependencies } from "../src/dependencyMetadata.js" +import { clearQueryReadDependencies, getDerivedInvalidationKeys, partitionInvalidationKeys, setQueryReadDependencies } from "../src/dependencyMetadata.js" import { makeTanstackQuery, makeTanstackQueryInvalidator } from "../src/internal/tanstackQuery.js" import { invalidateQueries, makeStreamMutation2, type MutationOptionsBase } from "../src/mutate.js" @@ -58,6 +58,16 @@ it("getDerivedInvalidationKeys returns keys of queries whose reads intersect the } }) +it("partitions Work queries into non-blocking invalidations", () => { + const workKey = ["$Work", "$List", undefined] + const pickListKey = ["$PickList", "$List", undefined] + + expect(partitionInvalidationKeys([workKey, pickListKey])).toEqual({ + awaitKeys: [pickListKey], + softKeys: [workKey] + }) +}) + it("clearing read dependencies drops the query from derivation", () => { const inventoryKey = ["$Inventory", "List", undefined] setQueryReadDependencies(inventoryKey, new Set([repo])) @@ -135,7 +145,7 @@ it("atom engine: a query records its read deps so a command's writes derive it", const unmount = defaultRegistry.mount(atom) try { - await Effect.runPromise(awaitAtomResult(defaultRegistry, atom) as any) + await Effect.runPromise(awaitAtomResult(defaultRegistry, atom)) expect(runs).toBe(1) const fullKey = [...makeQueryKey(self), undefined] @@ -224,7 +234,7 @@ it("atom engine: disposing the query atom clears its recorded reads", async () = const fullKey = [...makeQueryKey(self), undefined] const unmount = defaultRegistry.mount(atom) - await Effect.runPromise(awaitAtomResult(defaultRegistry, atom) as any) + await Effect.runPromise(awaitAtomResult(defaultRegistry, atom)) expect(getDerivedInvalidationKeys(new Set([atomRepo]))).toContainEqual(fullKey) // Disposing the registry runs the atom's finalizers, including `trackReadDependencies`. @@ -291,12 +301,12 @@ const makeAtomHarness = (queryRepo: DataDependencies.DataDependency): EngineHarn return { queryFullKey: [...makeQueryKey(self), undefined], serverInvalidationKey: makeQueryKey(self), - fetchInitial: () => Effect.runPromise(awaitAtomResult(defaultRegistry, atom) as any), + fetchInitial: () => Effect.runPromise(awaitAtomResult(defaultRegistry, atom)), runs: () => runs, runCommand: (options, command) => Effect.runPromise( invalidateQueries({ id: "MatrixAtom.Save" }, options, invalidator)(command, { id: "x" }) - .pipe(Effect.andThen(awaitAtomResult(defaultRegistry, atom).pipe(Effect.exit))) as any + .pipe(Effect.andThen(awaitAtomResult(defaultRegistry, atom).pipe(Effect.exit))) ), dispose: () => { unmount() From d34aeec03516926628bf3ec120d910a4b32f7ee3 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Mon, 10 Aug 2026 20:54:03 +0200 Subject: [PATCH 2/4] refactor(vue): make soft invalidation caller-defined --- .changeset/soft-work-invalidations.md | 2 +- packages/vue/src/dependencyMetadata.ts | 8 ++--- packages/vue/src/makeClient.ts | 15 +++++--- packages/vue/src/mutate.ts | 36 +++++++++++++------ .../vue/test/dependencyInvalidation.test.ts | 16 +++++---- 5 files changed, 50 insertions(+), 27 deletions(-) diff --git a/.changeset/soft-work-invalidations.md b/.changeset/soft-work-invalidations.md index 71142410a..39e84a97e 100644 --- a/.changeset/soft-work-invalidations.md +++ b/.changeset/soft-work-invalidations.md @@ -2,4 +2,4 @@ "@effect-app/vue": patch --- -Refetch Work overview queries without blocking command completion. +Allow applications to mark selected query invalidations as non-blocking. diff --git a/packages/vue/src/dependencyMetadata.ts b/packages/vue/src/dependencyMetadata.ts index 057d3b5ce..030434893 100644 --- a/packages/vue/src/dependencyMetadata.ts +++ b/packages/vue/src/dependencyMetadata.ts @@ -41,11 +41,9 @@ export const getDerivedInvalidationKeys = ( return keys } -/** Work overview queries are soft-fresh: domain mutations should not wait for their refetch. */ -export const isSoftInvalidationKey = (key: ReadonlyArray): boolean => key[0] === "$Work" - export const partitionInvalidationKeys = ( - keys: ReadonlyArray> + keys: ReadonlyArray>, + modeForKey: (key: ReadonlyArray) => "await" | "soft" ): { readonly awaitKeys: ReadonlyArray> readonly softKeys: ReadonlyArray> @@ -53,7 +51,7 @@ export const partitionInvalidationKeys = ( const awaitKeys: Array> = [] const softKeys: Array> = [] for (const key of keys) { - if (isSoftInvalidationKey(key)) softKeys.push(key) + if (modeForKey(key) === "soft") softKeys.push(key) else awaitKeys.push(key) } return { awaitKeys, softKeys } diff --git a/packages/vue/src/makeClient.ts b/packages/vue/src/makeClient.ts index a9f96df8c..20452cf89 100644 --- a/packages/vue/src/makeClient.ts +++ b/packages/vue/src/makeClient.ts @@ -24,7 +24,7 @@ import { type Commander, CommanderStatic, type Progress } from "./commander.ts" import { makeTanstackQuery, makeTanstackQueryCacheUpdater, makeTanstackQueryClient, makeTanstackQueryInvalidator } from "./internal/tanstackQuery.ts" import { type I18n } from "./intl.ts" import { type CommanderResolved, makeUseCommand } from "./makeUseCommand.ts" -import { atomQueryInvalidator, combineQueryInvalidators, type InvalidationEntry, makeMutation, makeStreamMutation2, type MutationOptionsBase, type QueryInvalidator, useMakeMutation } from "./mutate.ts" +import { atomQueryInvalidator, combineQueryInvalidators, type InvalidationEntry, makeMutation, makeStreamMutation2, type MutationOptionsBase, type QueryInvalidationModeForKey, type QueryInvalidator, useMakeMutation } from "./mutate.ts" import { atomQueryCacheUpdater, type AtomQueryNewOptions, combineQueryCacheUpdaters, type CustomUndefinedInitialQueryOptions, makeQuery, makeQueryAtom, makeQueryFamily, makeQueryNew, makeStreamQuery, makeStreamQueryAtom, makeStreamQueryFamily, makeStreamQueryNew, optionalAtomQueryCacheUpdater, type QueryObserverResult, type RefetchOptions, setQueryCacheUpdater, type StreamQueryAtomFamily, type SuspenseQueryView, type UseQueryReturnType } from "./query.ts" import { makeRunPromise } from "./runtime.ts" import { latestDefined } from "./suspense.ts" @@ -427,8 +427,11 @@ export const useMutation: typeof _useMutation = (< * Executes query cache invalidation based on default rules or provided option. * adds a span with the mutation id */ -export const useMutationInt = (queryInvalidator: QueryInvalidator): typeof _useMutation => { - const _useMutation = useMakeMutation(queryInvalidator) +export const useMutationInt = ( + queryInvalidator: QueryInvalidator, + modeForKey?: QueryInvalidationModeForKey +): typeof _useMutation => { + const _useMutation = useMakeMutation(queryInvalidator, modeForKey) return (< I, E, @@ -453,6 +456,8 @@ export interface MakeClientOptions { * Atom-native `.atom()` / `.family()` / `.queryNew()` / `.suspenseNew()` always use Atom. */ readonly legacyQueryEngine?: "atom" | "tanstack" + /** Controls whether a matching query refetch must settle before its triggering mutation completes. */ + readonly invalidationMode?: QueryInvalidationModeForKey } export class QueryImpl { @@ -764,10 +769,10 @@ export const makeClient = ( ) let m: ReturnType - const useMutation = () => m ??= useMutationInt(queryInvalidator) + const useMutation = () => m ??= useMutationInt(queryInvalidator, options?.invalidationMode) let sm2: ReturnType - const useStreamMutation2 = () => sm2 ??= makeStreamMutation2(queryInvalidator) + const useStreamMutation2 = () => sm2 ??= makeStreamMutation2(queryInvalidator, options?.invalidationMode) const legacyUseQuery = legacyQueryEngine === "tanstack" ? makeTanstackQuery(getBaseRt, getTanstackQueryClient()) diff --git a/packages/vue/src/mutate.ts b/packages/vue/src/mutate.ts index 411e761c3..d6a194acd 100644 --- a/packages/vue/src/mutate.ts +++ b/packages/vue/src/mutate.ts @@ -117,6 +117,11 @@ export type InvalidationEntry = InvalidateQueryInstruction = ( keys: ReadonlyArray> ) => Effect.Effect +export type QueryInvalidationMode = "await" | "soft" +export type QueryInvalidationModeForKey = (key: ReadonlyArray) => QueryInvalidationMode + +const awaitInvalidation: QueryInvalidationModeForKey = () => "await" + export interface QueryInvalidator { readonly invalidateAndAwait: QueryInvalidationEffect readonly invalidateSoft?: QueryInvalidationEffect @@ -273,7 +278,8 @@ export const asStreamResult = ( const buildInvalidateCache = ( self: { id: string; options?: ClientForOptions; disableQueryInvalidation?: boolean }, queryInvalidation: MutationOptionsBase["queryInvalidation"] | undefined, - queryInvalidator: QueryInvalidator + queryInvalidator: QueryInvalidator, + modeForKey: QueryInvalidationModeForKey ) => { // Concrete reactivity keys to invalidate: a raw query key, one derived from an `{ id }` // entry, or a compatibility `{ filters: { queryKey } }` entry. @@ -330,7 +336,7 @@ const buildInvalidateCache = ( if (!isReadonlyArrayNonEmpty(keys)) return Effect.void - const { awaitKeys, softKeys } = partitionInvalidationKeys(keys) + const { awaitKeys, softKeys } = partitionInvalidationKeys(keys, modeForKey) return Effect .andThen( @@ -366,9 +372,10 @@ const buildInvalidateCache = ( export const invalidateQueries = ( self: { id: string; options?: ClientForOptions; disableQueryInvalidation?: boolean }, options: MutationOptionsBase | undefined, - queryInvalidator: QueryInvalidator + queryInvalidator: QueryInvalidator, + modeForKey: QueryInvalidationModeForKey = awaitInvalidation ) => { - const invalidateCache = buildInvalidateCache(self, options?.queryInvalidation, queryInvalidator) + const invalidateCache = buildInvalidateCache(self, options?.queryInvalidation, queryInvalidator, modeForKey) const select = options?.select @@ -417,7 +424,10 @@ export interface MutationFn { readonly id: Id } -export const makeMutation = (queryInvalidator: QueryInvalidator) => { +export const makeMutation = ( + queryInvalidator: QueryInvalidator, + modeForKey: QueryInvalidationModeForKey = awaitInvalidation +) => { /** * Pass a function that returns an Effect, e.g from a client action. * Executes query cache invalidation based on default rules or provided option. @@ -427,13 +437,16 @@ export const makeMutation = (queryInvalidator: QueryInvalidator ): MutationFn => { const r = (i: I, options?: MutationOptionsBase) => - invalidateQueries(self, options, queryInvalidator)(self.handler(i), i) + invalidateQueries(self, options, queryInvalidator, modeForKey)(self.handler(i), i) return Object.assign(r, { id: self.id }) as any } return useMutation } -export const useMakeMutation = (queryInvalidator: QueryInvalidator) => { +export const useMakeMutation = ( + queryInvalidator: QueryInvalidator, + modeForKey: QueryInvalidationModeForKey = awaitInvalidation +) => { /** * Pass a function that returns an Effect, e.g from a client action. * Executes query cache invalidation based on default rules or provided option. @@ -443,7 +456,7 @@ export const useMakeMutation = (queryInvalidator: QueryInvalidator self: RequestHandlerWithInput ): MutationFn => { const r = (i: I, options?: MutationOptionsBase) => - invalidateQueries(self, options, queryInvalidator)(self.handler(i), i) + invalidateQueries(self, options, queryInvalidator, modeForKey)(self.handler(i), i) return Object.assign(r, { id: self.id }) as any } return useMutation @@ -457,7 +470,10 @@ export const useMakeMutation = (queryInvalidator: QueryInvalidator * Use with `streamFn` / `Command.streamFn(id)(mutateHandler, ...combinators)` so that * the command manages its own reactive state internally. */ -export const makeStreamMutation2 = (queryInvalidator: QueryInvalidator) => { +export const makeStreamMutation2 = ( + queryInvalidator: QueryInvalidator, + modeForKey: QueryInvalidationModeForKey = awaitInvalidation +) => { return ( self: { id: string @@ -467,7 +483,7 @@ export const makeStreamMutation2 = (queryInvalidator: QueryInvalid }, mergedInvalidation?: MutationOptionsBase["queryInvalidation"] ) => { - const invCache = buildInvalidateCache(self, mergedInvalidation, queryInvalidator) + const invCache = buildInvalidateCache(self, mergedInvalidation, queryInvalidator, modeForKey) const makeInvocationEffect = (input: unknown, source: Stream.Stream) => Effect.gen(function*() { diff --git a/packages/vue/test/dependencyInvalidation.test.ts b/packages/vue/test/dependencyInvalidation.test.ts index 57c16fb25..5a1bf7327 100644 --- a/packages/vue/test/dependencyInvalidation.test.ts +++ b/packages/vue/test/dependencyInvalidation.test.ts @@ -58,14 +58,18 @@ it("getDerivedInvalidationKeys returns keys of queries whose reads intersect the } }) -it("partitions Work queries into non-blocking invalidations", () => { - const workKey = ["$Work", "$List", undefined] +it("partitions caller-selected queries into non-blocking invalidations", () => { + const overviewKey = ["$Overview", "$List", undefined] const pickListKey = ["$PickList", "$List", undefined] - expect(partitionInvalidationKeys([workKey, pickListKey])).toEqual({ - awaitKeys: [pickListKey], - softKeys: [workKey] - }) + expect(partitionInvalidationKeys( + [overviewKey, pickListKey], + (key) => key[0] === "$Overview" ? "soft" : "await" + )) + .toEqual({ + awaitKeys: [pickListKey], + softKeys: [overviewKey] + }) }) it("clearing read dependencies drops the query from derivation", () => { From 6b1c33195b6c947b4d4ea67c8f06ebab51254591 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Mon, 10 Aug 2026 21:00:11 +0200 Subject: [PATCH 3/4] refactor(vue): scope soft invalidation to query consumers --- .changeset/soft-work-invalidations.md | 2 +- packages/vue/src/atomQuery.ts | 10 +++++- packages/vue/src/dependencyMetadata.ts | 32 +++++++++++++++-- packages/vue/src/internal/tanstackQuery.ts | 16 ++++++--- packages/vue/src/makeClient.ts | 15 +++----- packages/vue/src/mutate.ts | 36 ++++++------------- packages/vue/src/query.ts | 7 ++++ .../vue/test/dependencyInvalidation.test.ts | 24 +++++++++---- 8 files changed, 89 insertions(+), 53 deletions(-) diff --git a/.changeset/soft-work-invalidations.md b/.changeset/soft-work-invalidations.md index 39e84a97e..a676a35ba 100644 --- a/.changeset/soft-work-invalidations.md +++ b/.changeset/soft-work-invalidations.md @@ -2,4 +2,4 @@ "@effect-app/vue": patch --- -Allow applications to mark selected query invalidations as non-blocking. +Allow query consumers to opt into non-blocking invalidation refetches. diff --git a/packages/vue/src/atomQuery.ts b/packages/vue/src/atomQuery.ts index c3fd84d81..506476140 100644 --- a/packages/vue/src/atomQuery.ts +++ b/packages/vue/src/atomQuery.ts @@ -32,7 +32,7 @@ import { isHttpClientError } from "effect/unstable/http/HttpClientError" import * as AsyncResult from "effect/unstable/reactivity/AsyncResult" import * as Atom from "effect/unstable/reactivity/Atom" import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry" -import { clearQueryReadDependencies, getQueryReadDependencies, setQueryReadDependencies } from "./dependencyMetadata.ts" +import { clearQueryReadDependencies, getQueryReadDependencies, type QueryInvalidationMode, registerQueryInvalidationMode, setQueryReadDependencies } from "./dependencyMetadata.ts" import { reportRuntimeError } from "./lib.ts" import { beginLiveQueryFetch, endLiveQueryFetch, type LiveQueryOptions, registerLiveQuery } from "./liveQueryInvalidation.ts" @@ -224,6 +224,7 @@ export interface AtomQueryOptions { readonly staleTime?: Duration.Input /** dispose-when-idle (TanStack gcTime; default 5min). "infinity" => keepAlive */ readonly gcTime?: Duration.Input | "infinity" + readonly invalidation?: QueryInvalidationMode /** * Revalidate a stale query on window focus AND on network reconnect (default on, matching * tanstack refetchOnWindowFocus + refetchOnReconnect). @@ -376,6 +377,13 @@ export const withQueryOptions = ( setAtomQueryMetadata(self, opts) const staleTime: Duration.Input = opts.staleTime ?? defaults.staleTime let atom = self + if (liveKey !== undefined) { + atom = Atom.transform(atom, (get) => { + const unregister = registerQueryInvalidationMode(liveKey, opts.invalidation ?? "await") + get.addFinalizer(unregister) + return get(self) + }, { initialValueTarget: self }) + } if (opts.live && liveKey !== undefined) { const liveOptions = opts.live === true ? {} : opts.live atom = Atom.transform(atom, (get) => { diff --git a/packages/vue/src/dependencyMetadata.ts b/packages/vue/src/dependencyMetadata.ts index 030434893..277957815 100644 --- a/packages/vue/src/dependencyMetadata.ts +++ b/packages/vue/src/dependencyMetadata.ts @@ -7,6 +7,33 @@ import * as Hash from "effect/Hash" // cached-within-ttl) — the atom equivalent of the former tanstack query cache. type Entry = { readonly key: ReadonlyArray; readonly reads: DataDependencies.DataDependencies } const readDependencies = new Map() +export type QueryInvalidationMode = "await" | "soft" +type InvalidationModeEntry = { awaitSubscribers: number; softSubscribers: number } +const invalidationModes = new Map() + +export const registerQueryInvalidationMode = ( + key: ReadonlyArray, + mode: QueryInvalidationMode +): () => void => { + const hash = Hash.hash(key) + const entry = invalidationModes.get(hash) ?? { awaitSubscribers: 0, softSubscribers: 0 } + if (mode === "soft") entry.softSubscribers++ + else entry.awaitSubscribers++ + invalidationModes.set(hash, entry) + let active = true + return () => { + if (!active) return + active = false + if (mode === "soft") entry.softSubscribers-- + else entry.awaitSubscribers-- + if (entry.awaitSubscribers === 0 && entry.softSubscribers === 0) invalidationModes.delete(hash) + } +} + +export const getQueryInvalidationMode = (key: ReadonlyArray): QueryInvalidationMode => { + const entry = invalidationModes.get(Hash.hash(key)) + return entry !== undefined && entry.awaitSubscribers === 0 && entry.softSubscribers > 0 ? "soft" : "await" +} export const setQueryReadDependencies = ( key: ReadonlyArray, @@ -42,8 +69,7 @@ export const getDerivedInvalidationKeys = ( } export const partitionInvalidationKeys = ( - keys: ReadonlyArray>, - modeForKey: (key: ReadonlyArray) => "await" | "soft" + keys: ReadonlyArray> ): { readonly awaitKeys: ReadonlyArray> readonly softKeys: ReadonlyArray> @@ -51,7 +77,7 @@ export const partitionInvalidationKeys = ( const awaitKeys: Array> = [] const softKeys: Array> = [] for (const key of keys) { - if (modeForKey(key) === "soft") softKeys.push(key) + if (getQueryInvalidationMode(key) === "soft") softKeys.push(key) else awaitKeys.push(key) } return { awaitKeys, softKeys } diff --git a/packages/vue/src/internal/tanstackQuery.ts b/packages/vue/src/internal/tanstackQuery.ts index 06e32786d..2c9f91ee5 100644 --- a/packages/vue/src/internal/tanstackQuery.ts +++ b/packages/vue/src/internal/tanstackQuery.ts @@ -15,7 +15,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult" import * as Atom from "effect/unstable/reactivity/Atom" import { computed, type MaybeRefOrGetter, shallowRef, toValue, watch, type WatchSource } from "vue" import { replaceEqualDeep } from "../atomQuery.ts" -import { clearQueryReadDependencies, setQueryReadDependencies } from "../dependencyMetadata.ts" +import { clearQueryReadDependencies, registerQueryInvalidationMode, setQueryReadDependencies } from "../dependencyMetadata.ts" import { reportRuntimeError } from "../lib.ts" import type { QueryInvalidator } from "../mutate.ts" import type { CustomDefinedInitialQueryOptions, CustomDefinedPlaceholderQueryOptions, CustomUndefinedInitialQueryOptions, CustomUseQueryOptions, MakeQuery2, QueryCacheUpdater, QueryHandle, QueryObserverResult, RefetchOptions } from "../query.ts" @@ -172,15 +172,21 @@ export const makeTanstackQuery = ( ...(options?.refetchInterval !== undefined ? { refetchInterval: options.refetchInterval } : {}), ...(options?.select !== undefined ? { select: options.select } : {}) } + const resolvedQueryKey = computed(() => { + const input = resolveInput(arg, options?.mode) + return fullQueryKey(q, queryKey, input) + }) + watch( + resolvedQueryKey, + (key, _, onCleanup) => onCleanup(registerQueryInvalidationMode(key, options?.invalidation ?? "await")), + { immediate: true } + ) const tanstack = useTanstackQuery, TData>({ ...tanstackOptions, enabled, throwOnError: false, retry: (retryCount: number, error: unknown) => isRetryable(error) && retryCount < 5, - queryKey: computed(() => { - const input = resolveInput(arg, options?.mode) - return fullQueryKey(q, queryKey, input) - }), + queryKey: resolvedQueryKey, queryFn: ( { meta, signal }: { readonly meta?: { readonly span?: Tracer.AnySpan | undefined } | undefined diff --git a/packages/vue/src/makeClient.ts b/packages/vue/src/makeClient.ts index 20452cf89..a9f96df8c 100644 --- a/packages/vue/src/makeClient.ts +++ b/packages/vue/src/makeClient.ts @@ -24,7 +24,7 @@ import { type Commander, CommanderStatic, type Progress } from "./commander.ts" import { makeTanstackQuery, makeTanstackQueryCacheUpdater, makeTanstackQueryClient, makeTanstackQueryInvalidator } from "./internal/tanstackQuery.ts" import { type I18n } from "./intl.ts" import { type CommanderResolved, makeUseCommand } from "./makeUseCommand.ts" -import { atomQueryInvalidator, combineQueryInvalidators, type InvalidationEntry, makeMutation, makeStreamMutation2, type MutationOptionsBase, type QueryInvalidationModeForKey, type QueryInvalidator, useMakeMutation } from "./mutate.ts" +import { atomQueryInvalidator, combineQueryInvalidators, type InvalidationEntry, makeMutation, makeStreamMutation2, type MutationOptionsBase, type QueryInvalidator, useMakeMutation } from "./mutate.ts" import { atomQueryCacheUpdater, type AtomQueryNewOptions, combineQueryCacheUpdaters, type CustomUndefinedInitialQueryOptions, makeQuery, makeQueryAtom, makeQueryFamily, makeQueryNew, makeStreamQuery, makeStreamQueryAtom, makeStreamQueryFamily, makeStreamQueryNew, optionalAtomQueryCacheUpdater, type QueryObserverResult, type RefetchOptions, setQueryCacheUpdater, type StreamQueryAtomFamily, type SuspenseQueryView, type UseQueryReturnType } from "./query.ts" import { makeRunPromise } from "./runtime.ts" import { latestDefined } from "./suspense.ts" @@ -427,11 +427,8 @@ export const useMutation: typeof _useMutation = (< * Executes query cache invalidation based on default rules or provided option. * adds a span with the mutation id */ -export const useMutationInt = ( - queryInvalidator: QueryInvalidator, - modeForKey?: QueryInvalidationModeForKey -): typeof _useMutation => { - const _useMutation = useMakeMutation(queryInvalidator, modeForKey) +export const useMutationInt = (queryInvalidator: QueryInvalidator): typeof _useMutation => { + const _useMutation = useMakeMutation(queryInvalidator) return (< I, E, @@ -456,8 +453,6 @@ export interface MakeClientOptions { * Atom-native `.atom()` / `.family()` / `.queryNew()` / `.suspenseNew()` always use Atom. */ readonly legacyQueryEngine?: "atom" | "tanstack" - /** Controls whether a matching query refetch must settle before its triggering mutation completes. */ - readonly invalidationMode?: QueryInvalidationModeForKey } export class QueryImpl { @@ -769,10 +764,10 @@ export const makeClient = ( ) let m: ReturnType - const useMutation = () => m ??= useMutationInt(queryInvalidator, options?.invalidationMode) + const useMutation = () => m ??= useMutationInt(queryInvalidator) let sm2: ReturnType - const useStreamMutation2 = () => sm2 ??= makeStreamMutation2(queryInvalidator, options?.invalidationMode) + const useStreamMutation2 = () => sm2 ??= makeStreamMutation2(queryInvalidator) const legacyUseQuery = legacyQueryEngine === "tanstack" ? makeTanstackQuery(getBaseRt, getTanstackQueryClient()) diff --git a/packages/vue/src/mutate.ts b/packages/vue/src/mutate.ts index d6a194acd..411e761c3 100644 --- a/packages/vue/src/mutate.ts +++ b/packages/vue/src/mutate.ts @@ -117,11 +117,6 @@ export type InvalidationEntry = InvalidateQueryInstruction = ( keys: ReadonlyArray> ) => Effect.Effect -export type QueryInvalidationMode = "await" | "soft" -export type QueryInvalidationModeForKey = (key: ReadonlyArray) => QueryInvalidationMode - -const awaitInvalidation: QueryInvalidationModeForKey = () => "await" - export interface QueryInvalidator { readonly invalidateAndAwait: QueryInvalidationEffect readonly invalidateSoft?: QueryInvalidationEffect @@ -278,8 +273,7 @@ export const asStreamResult = ( const buildInvalidateCache = ( self: { id: string; options?: ClientForOptions; disableQueryInvalidation?: boolean }, queryInvalidation: MutationOptionsBase["queryInvalidation"] | undefined, - queryInvalidator: QueryInvalidator, - modeForKey: QueryInvalidationModeForKey + queryInvalidator: QueryInvalidator ) => { // Concrete reactivity keys to invalidate: a raw query key, one derived from an `{ id }` // entry, or a compatibility `{ filters: { queryKey } }` entry. @@ -336,7 +330,7 @@ const buildInvalidateCache = ( if (!isReadonlyArrayNonEmpty(keys)) return Effect.void - const { awaitKeys, softKeys } = partitionInvalidationKeys(keys, modeForKey) + const { awaitKeys, softKeys } = partitionInvalidationKeys(keys) return Effect .andThen( @@ -372,10 +366,9 @@ const buildInvalidateCache = ( export const invalidateQueries = ( self: { id: string; options?: ClientForOptions; disableQueryInvalidation?: boolean }, options: MutationOptionsBase | undefined, - queryInvalidator: QueryInvalidator, - modeForKey: QueryInvalidationModeForKey = awaitInvalidation + queryInvalidator: QueryInvalidator ) => { - const invalidateCache = buildInvalidateCache(self, options?.queryInvalidation, queryInvalidator, modeForKey) + const invalidateCache = buildInvalidateCache(self, options?.queryInvalidation, queryInvalidator) const select = options?.select @@ -424,10 +417,7 @@ export interface MutationFn { readonly id: Id } -export const makeMutation = ( - queryInvalidator: QueryInvalidator, - modeForKey: QueryInvalidationModeForKey = awaitInvalidation -) => { +export const makeMutation = (queryInvalidator: QueryInvalidator) => { /** * Pass a function that returns an Effect, e.g from a client action. * Executes query cache invalidation based on default rules or provided option. @@ -437,16 +427,13 @@ export const makeMutation = ( self: RequestHandlerWithInput ): MutationFn => { const r = (i: I, options?: MutationOptionsBase) => - invalidateQueries(self, options, queryInvalidator, modeForKey)(self.handler(i), i) + invalidateQueries(self, options, queryInvalidator)(self.handler(i), i) return Object.assign(r, { id: self.id }) as any } return useMutation } -export const useMakeMutation = ( - queryInvalidator: QueryInvalidator, - modeForKey: QueryInvalidationModeForKey = awaitInvalidation -) => { +export const useMakeMutation = (queryInvalidator: QueryInvalidator) => { /** * Pass a function that returns an Effect, e.g from a client action. * Executes query cache invalidation based on default rules or provided option. @@ -456,7 +443,7 @@ export const useMakeMutation = ( self: RequestHandlerWithInput ): MutationFn => { const r = (i: I, options?: MutationOptionsBase) => - invalidateQueries(self, options, queryInvalidator, modeForKey)(self.handler(i), i) + invalidateQueries(self, options, queryInvalidator)(self.handler(i), i) return Object.assign(r, { id: self.id }) as any } return useMutation @@ -470,10 +457,7 @@ export const useMakeMutation = ( * Use with `streamFn` / `Command.streamFn(id)(mutateHandler, ...combinators)` so that * the command manages its own reactive state internally. */ -export const makeStreamMutation2 = ( - queryInvalidator: QueryInvalidator, - modeForKey: QueryInvalidationModeForKey = awaitInvalidation -) => { +export const makeStreamMutation2 = (queryInvalidator: QueryInvalidator) => { return ( self: { id: string @@ -483,7 +467,7 @@ export const makeStreamMutation2 = ( }, mergedInvalidation?: MutationOptionsBase["queryInvalidation"] ) => { - const invCache = buildInvalidateCache(self, mergedInvalidation, queryInvalidator, modeForKey) + const invCache = buildInvalidateCache(self, mergedInvalidation, queryInvalidator) const makeInvocationEffect = (input: unknown, source: Stream.Stream) => Effect.gen(function*() { diff --git a/packages/vue/src/query.ts b/packages/vue/src/query.ts index 6f5211415..71dc9339a 100644 --- a/packages/vue/src/query.ts +++ b/packages/vue/src/query.ts @@ -18,6 +18,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult" import * as Atom from "effect/unstable/reactivity/Atom" import { computed, type ComputedRef, effectScope, type MaybeRefOrGetter, onBeforeUnmount, onMounted, onScopeDispose, ref, toValue, type WatchSource } from "vue" import { type AtomClientRuntime, type AtomQueryOptions, awaitAtomResult, buildQueryFamily, buildStreamQueryFamily, disabledQueryAtom, isStaleResult, queryKeyForAtom, refreshAtomWithCurrentSpan, staleTimeMsOf, withQueryOptions } from "./atomQuery.ts" +import type { QueryInvalidationMode } from "./dependencyMetadata.ts" import type { LiveQueryOptions } from "./liveQueryInvalidation.ts" import { latestDefined } from "./suspense.ts" @@ -203,6 +204,7 @@ export interface CustomUseQueryOptions< /** poll: re-fetch every N ms (tanstack refetchInterval) */ readonly refetchInterval?: number readonly live?: boolean | LiveQueryOptions + readonly invalidation?: QueryInvalidationMode readonly select?: (data: TQueryFnData) => TData /** accepted for source compatibility; not used by the atom engine */ readonly retry?: boolean | number @@ -256,6 +258,7 @@ export interface AtomQueryNewOptions TData } @@ -327,6 +330,7 @@ const normalizeQueryOptions = (options?: { readonly refetchInterval?: number readonly refreshEvery?: number readonly live?: boolean | LiveQueryOptions + readonly invalidation?: QueryInvalidationMode }): AtomQueryOptions => { const out: { staleTime?: number @@ -335,6 +339,7 @@ const normalizeQueryOptions = (options?: { structuralSharing?: boolean refetchInterval?: number live?: boolean | LiveQueryOptions + invalidation?: QueryInvalidationMode } = {} if (options?.staleTime !== undefined) out.staleTime = options.staleTime const gcTime = options?.idleTTL ?? options?.gcTime @@ -345,6 +350,7 @@ const normalizeQueryOptions = (options?: { const refetchInterval = options?.refreshEvery ?? options?.refetchInterval if (refetchInterval !== undefined) out.refetchInterval = refetchInterval if (options?.live !== undefined) out.live = options.live + if (options?.invalidation !== undefined) out.invalidation = options.invalidation return out } @@ -521,6 +527,7 @@ const observedAtom = ( readonly refetchInterval?: number readonly refreshEvery?: number readonly live?: boolean | LiveQueryOptions + readonly invalidation?: QueryInvalidationMode } ): Atom.Atom> => withQueryOptions(atom, normalizeQueryOptions(options), queryKeyForAtom(atom)) diff --git a/packages/vue/test/dependencyInvalidation.test.ts b/packages/vue/test/dependencyInvalidation.test.ts index 5a1bf7327..26c291697 100644 --- a/packages/vue/test/dependencyInvalidation.test.ts +++ b/packages/vue/test/dependencyInvalidation.test.ts @@ -13,7 +13,7 @@ import { TestClock } from "effect/testing" import * as Reactivity from "effect/unstable/reactivity/Reactivity" import { createApp, effectScope, ref } from "vue" import { awaitAtomResult, buildQueryFamily, invalidateAndAwait, makeAtomClientRuntime } from "../src/atomQuery.js" -import { clearQueryReadDependencies, getDerivedInvalidationKeys, partitionInvalidationKeys, setQueryReadDependencies } from "../src/dependencyMetadata.js" +import { clearQueryReadDependencies, getDerivedInvalidationKeys, partitionInvalidationKeys, registerQueryInvalidationMode, setQueryReadDependencies } from "../src/dependencyMetadata.js" import { makeTanstackQuery, makeTanstackQueryInvalidator } from "../src/internal/tanstackQuery.js" import { invalidateQueries, makeStreamMutation2, type MutationOptionsBase } from "../src/mutate.js" @@ -58,18 +58,28 @@ it("getDerivedInvalidationKeys returns keys of queries whose reads intersect the } }) -it("partitions caller-selected queries into non-blocking invalidations", () => { +it("uses the strictest active query invalidation mode", () => { const overviewKey = ["$Overview", "$List", undefined] const pickListKey = ["$PickList", "$List", undefined] - expect(partitionInvalidationKeys( - [overviewKey, pickListKey], - (key) => key[0] === "$Overview" ? "soft" : "await" - )) - .toEqual({ + const unregisterSoft = registerQueryInvalidationMode(overviewKey, "soft") + const unregisterAwait = registerQueryInvalidationMode(pickListKey, "await") + const unregisterStrictOverview = registerQueryInvalidationMode(overviewKey, "await") + try { + expect(partitionInvalidationKeys([overviewKey, pickListKey])).toEqual({ + awaitKeys: [overviewKey, pickListKey], + softKeys: [] + }) + unregisterStrictOverview() + expect(partitionInvalidationKeys([overviewKey, pickListKey])).toEqual({ awaitKeys: [pickListKey], softKeys: [overviewKey] }) + } finally { + unregisterStrictOverview() + unregisterSoft() + unregisterAwait() + } }) it("clearing read dependencies drops the query from derivation", () => { From 63b59a0b8d9ed251794e9fb3843fd26244e064e5 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Mon, 10 Aug 2026 21:07:03 +0200 Subject: [PATCH 4/4] fix(vue): supervise soft invalidation --- packages/vue/src/internal/tanstackQuery.ts | 8 +++++--- packages/vue/src/mutate.ts | 8 +++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/vue/src/internal/tanstackQuery.ts b/packages/vue/src/internal/tanstackQuery.ts index 2c9f91ee5..ec43a26ea 100644 --- a/packages/vue/src/internal/tanstackQuery.ts +++ b/packages/vue/src/internal/tanstackQuery.ts @@ -115,9 +115,11 @@ export const makeTanstackQueryInvalidator = (queryClient: QueryClient): QueryInv invalidateSoft: (keys) => Effect.gen(function*() { const span = yield* Effect.currentParentSpan.pipe(Effect.orElseSucceed(() => undefined)) - for (const queryKey of keys) { - void queryClient.invalidateQueries({ queryKey }, { updateMeta: { span } }) - } + yield* Effect.forEach( + keys, + (queryKey) => Effect.promise(() => queryClient.invalidateQueries({ queryKey }, { updateMeta: { span } })), + { discard: true, concurrency: "inherit" } + ) }) }) diff --git a/packages/vue/src/mutate.ts b/packages/vue/src/mutate.ts index 411e761c3..abf6329a7 100644 --- a/packages/vue/src/mutate.ts +++ b/packages/vue/src/mutate.ts @@ -15,6 +15,7 @@ import type * as Reactivity from "effect/unstable/reactivity/Reactivity" import { computed, type ComputedRef, shallowRef } from "vue" import { invalidateAndAwait, invalidateSoft } from "./atomQuery.ts" import { getDerivedInvalidationKeys, partitionInvalidationKeys } from "./dependencyMetadata.ts" +import { reportRuntimeError } from "./lib.ts" export type GetQueryKey = (h: { id: string; options?: ClientForOptions }) => string[] @@ -347,7 +348,12 @@ const buildInvalidateCache = ( // mutation resolves the affected queries are fresh. Effect.gen(function*() { if (softKeys.length > 0) { - yield* queryInvalidator.invalidateSoft?.(softKeys) ?? Effect.void + yield* (queryInvalidator.invalidateSoft?.(softKeys) ?? Effect.void).pipe( + Effect.catchCause((cause) => + reportRuntimeError(cause, { invalidation: "soft", keys: softKeys }).pipe(Effect.asVoid) + ), + Effect.forkDetach({ startImmediately: true }) + ) } if (awaitKeys.length > 0) { yield* queryInvalidator.invalidateAndAwait(awaitKeys)