refactor(vue-db): modernize useLiveQuery reactivity and lifecycle patterns#1302
Open
Danny-Devs wants to merge 1 commit intoTanStack:mainfrom
Open
refactor(vue-db): modernize useLiveQuery reactivity and lifecycle patterns#1302Danny-Devs wants to merge 1 commit intoTanStack:mainfrom
Danny-Devs wants to merge 1 commit intoTanStack:mainfrom
Conversation
…terns Align useLiveQuery with Vue 3's recommended primitives: - Use shallowReactive(Map) instead of reactive(Map) for immutable collection items - Use shallowRef([]) instead of reactive([]) for atomic array replacement - Replace sentinel-throw disabled query detection with BaseQueryBuilder probe - Use getCurrentScope/onScopeDispose instead of getCurrentInstance/onUnmounted - Set gcTime on hook-created collections for immediate cleanup - Use instanceof CollectionImpl instead of duck-typing for collection detection - Remove unnecessary nextTick in onFirstReady callback - Add isEnabled computed to return type Also extract shared test utilities to test-utils.ts and add 17 targeted tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hi Kyle! Super stoked that you are pushing forward the Vue plugin/adapter for Tanstack DB :). I'm a fellow Vue aficionado. Hope that this PR is of high quality and helps the codebase. The description is long because I wanted to make sure to explain everything in a clear and communicative way. Looking forward to helping the Vue plugin reach parity with the React one. Lmk if I can adjust anything! Thanks. - Danny
Summary
Aligns
useLiveQuerywith Vue 3's recommended reactivity primitives and lifecyclepatterns. The React adapter idiomatically uses
useSyncExternalStore; the Solidadapter uses
createMemo+ReactiveMap. This PR brings the Vue adapter's internalscloser to the same standard using Vue's own toolkit.
All 30 existing tests pass unchanged. 17 new tests validate each behavioral improvement.
No breaking changes to public API.
TL;DR
reactive(new Map())shallowReactive(new Map())reactive([])+length=0+push(...)shallowRef([])+.valueassignmentthrow Error('__DISABLED_QUERY__')+ catchBaseQueryBuilderprobe returningnullgetCurrentInstance()+onUnmounted()getCurrentScope()+onScopeDispose()effectScope()— the modern Vue 3 patterngcTime: 1for hook-created collectionsgcTimein config objects is preserved.instanceof CollectionImplnextTick()inonFirstReadyisLoadingreadstrueafter data is readyisEnabledfieldisEnabled: ComputedRef<boolean>isLoading,isReady, etc.What's NOT changed
includeInitialState: true) — kept as a safety netTest plan
useLiveQuery-bestpractices.test.tscovering each change abovetest-utils.ts(used by both test files)Detailed rationale for each change (click to expand — the TL;DR table above is self-contained if you prefer to skip this)
1. State Map:
reactive(new Map())→shallowReactive(new Map())Collection items coming out of the IVM engine are immutable snapshots — they're
replaced wholesale on updates, never mutated in place.
reactive()recursively wrapsevery stored object in a Proxy, creating O(N×M) traps for N items with M properties.
Since no code ever mutates these objects, the deep tracking does work with zero benefit.
shallowReactive()tracks Map operations (set,delete,has,get,size)without proxying the values stored inside. Vue's template reactivity still triggers
correctly when items are added, removed, or replaced — it just skips the unnecessary
deep observation of each item's properties.
2. Data array:
reactive([])→shallowRef([])Two issues with the original approach:
First,
reactive([])deep-proxies every array element, same overhead as #1 above.Second, the update pattern
internalData.length = 0followed byinternalData.push(...items)triggers two separate reactivity notifications per updatecycle. Any
watchEffector computed reading the array sees an intermediate empty statebetween the two operations. Vue's scheduler batches these for template rendering, but
synchronous computed chains can observe the inconsistency.
shallowRef([])with.value = newArrayprovides exactly-once notification semanticsvia atomic reference replacement. No deep proxying of elements, no intermediate states.
3. Disabled queries: sentinel throw →
BaseQueryBuilderprobeThe original implementation detected disabled queries by wrapping the query function
to throw
Error('__DISABLED_QUERY__')when it returnednull/undefined, then catchingthat specific error string.
This uses exception-driven control flow, which has a few practical costs: engines like V8
capture a full stack trace on every
new Error()(allocation pressure when queries togglefrequently), browser devtools configured to "break on caught exceptions" will halt on
every disable, and the string-matching on
error.messageis fragile if error-wrappingmiddleware is involved.
The replacement follows the same approach used by the React and Solid adapters:
instantiate a lightweight, stateless
BaseQueryBuilder, pass it to the query function,and check the return value. If it's
nullorundefined, the query is disabled. Noexceptions, no string matching. Simple branching on a return value.
4. Lifecycle cleanup:
getCurrentInstance()+onUnmounted()→getCurrentScope()+onScopeDispose()getCurrentInstance()only returns a value inside a Vue component'ssetup(). IfuseLiveQueryis called inside a composable used from a standaloneeffectScope()—common in tests, SSR contexts, and utility libraries —
getCurrentInstance()returnsnulland the cleanup callback never registers. The subscription leaks.getCurrentScope()+onScopeDispose()is the modern Vue 3 pattern that works acrossall contexts: components, composables called from components, and standalone
effectScope()blocks. Every component has an effect scope, so this is strictly moregeneral.
The
getCurrentScope()guard is still necessary becauseonScopeDispose()emits a devwarning when called outside any scope (e.g., in tests that call composables directly
without wrapping in an
effectScope).5. GC time: unset →
gcTime: 1for hook-created collectionsWhen
useLiveQueryinternally creates a collection viacreateLiveQueryCollection,that collection is ephemeral — tied to one hook instance in one component. When the
component unmounts, the collection should be garbage-collected immediately.
Without an explicit
gcTime, the library's default retention applies, meaning thecollection lingers in memory after its scope ends. For applications with frequent
mount/unmount cycles (route transitions, tab switches, virtualized lists), ephemeral
collections accumulate in the GC queue unnecessarily.
gcTime: 1(not0, since0disables GC in TanStack DB) signals immediate cleanup.Pre-created collections passed directly to
useLiveQueryare user-managed resources,so their
gcTimeis preserved as-is. For the config-object path, the defaultgcTimeis applied first and the user's spread comes after, so user-specified
gcTimetakesprecedence.
6. Collection detection: duck typing →
instanceof CollectionImplThe original detection checked for the presence of
subscribeChanges,startSyncImmediate, andidproperties. This structural check has a fewfragilities: if
CollectionImplrenames or removes a method in a future version, thecheck silently stops matching (no compile-time signal). Objects with a coincidental
shape overlap produce false positives. And the TypeScript narrowing is weaker — you
get checked-
anyrather than a precise type.instanceof CollectionImplis a single prototype-chain lookup that provides authoritativeidentity checking and precise TypeScript narrowing. The standard tradeoff —
instanceofbreaks across realm boundaries (iframes) — is a non-concern for a UI framework hook
running in the main thread's reactive context.
7. Status timing:
nextTick()inonFirstReady→ synchronous assignmentThe
onFirstReadycallback had itsstatus.valueassignment wrapped innextTick(),deferring it by one microtask. This created a one-tick window where the collection was
already
readybutstatus.valuestill readloading. For UI code that gates skeleton→ content transitions on
isLoading, this could produce a single frame of incorrectrendering.
Removing the
nextTickmakes the status update synchronous with the collection's statetransition. Vue's reactivity system handles synchronous ref assignments correctly — no
deferral is needed here.
8. API surface: added
isEnabledThe return type had boolean computeds for
isLoading,isReady,isIdle,isError,and
isCleanedUp, but lackedisEnabled. Consumers of disabled queries had to writestatus.value !== 'disabled'manually. AddingisEnabledcompletes the statuspredicate set and makes the API self-documenting.
🤖 Generated with Claude Code