Changelog
Every release of @guebbit/vue-toolkit, newest first. A BREAKING change only ships in a major version, with its migration next to it. Upgrading from 4.x: see the migration guide.
5.2.0
Added
- Every
watch*handle (watchTarget,watchAll,watchByParent,watchAny,watchSearch, and CRUD'swatchOne/watchList) exposes its query's state:data(the watched data as cached now, following local edits),isFetching,isPendinganddataUpdatedAt. A per-watcher spinner no longer needs anisLoadingkey of its own.IWatchHandlecarries the four new fields. watchAllandwatchByParenttakeonSuccess/onError/onSettled, likewatchTargetandwatchSearch. The context they receive is the bucketkey(watchAll) or the parent id (watchByParent).IWatchListSettingsgains two optional type parameters for them,<T, C>; its bare form keeps working.invalidateTarget(id)andinvalidateLists(): mark one record (an alias reaches the record it points at, and every other alias of it) or every list of the currentdependsOnstale. Watched ones refetch; the promise settles once they have.watchTarget, CRUD'swatchOne,useStructureSearchApi's filters source andactivateAutoHydrateaccept a plain value as well as a Ref or a getter (MaybeRefOrGetter), likewatchByParentalready did.- The package ships source maps and declaration maps, with
src/: debuggers and "Go to definition" land on the TypeScript source. - Docs: the site shows the version it documents and each page's last update, has local search, "edit this page" links, a changelog page, and a "Your first CRUD screen" guide.
Changed
itemDictionaryandparentHasManyofuseStructureRestApi/useStructureSearchApi/useStructureCrudApiare typedComputedRef(read-only views of the cache, as they always were at runtime) instead ofRef. Code that only reads them is unaffected; a write to them never had any effect and is now a type error.watchAny's return type isIWatchHandle<F | undefined>, which now includesdata; the separate& { data }intersection is gone. Same shape as before.handleSubmit(useStructureFormValidation) anddismissMessage(useNotificationsStore) now call their handler synchronously instead of one microtask later. The handler getsform.valueas it is whenhandleSubmitis called; a synchronous throw still becomes a rejection.
Fixed
searchNow(useStructureCrudApi) without asearchoperation rejected, but still reset the current page to 1. It now leaves the page alone.updateTargetwithout an explicitidstored an id-less response (an acknowledgement, amerge: truepartial) under a fabricated id — a phantom record, with a console warning — instead of on the record being updated (itemData's own id). The response now always lands on the resolved id the update addressed.- A
partial: truelist fetch (fetchAll,fetchByParent,fetchPaginate,fetchSearchand their watchers) that brought in a record not cached yet movedlastInsertedIdentifier, as if the record had just been created. It is a server answer like any other fetch, so it no longer does. Freshness is unchanged: a partial record still keeps its own (a new one stays stale).
5.1.1
Fixed
applyServerErrors: whenonUnmappedis given, it now also receives a rejection that carried nothing — a network failure, a bare 500, a 409 with an empty body — asonUnmapped([]), and the call returnstrue. Before, such a rejection reached nothing:onUnmappedstayed silent, nothing landed on the form, and the only signal was afalsereturn that callers usingonUnmappeddid not check, so a failed submit showed no error at all. An empty array means "the rejection said nothing usable; show your generic error".showFormErrorsstays off in that case (the form itself displays nothing). WithoutonUnmappednothing changes: the toolkit has no text of its own to show, so the call still returnsfalse. A caller that also reports onfalsestops getting thatfalsewhen it passesonUnmapped, which removes a double-report rather than creating one.
5.1.0
Added
useNotificationsStore: persistent messages — ones that stay until the user deletes them and that often belong to someone else (a server inbox,localStorage). One store, oneaddMessage: the app decides where a message comes from and where a deletion goes.addMessage(message, type?, timeout?, options?)takes an optional 4th argument,IAddMessageOptions(id,persistent,createdAt,meta,onDismiss), exported from the package. Every existing call keeps working.- A caller-owned
idalready inhistoryupdates that entry in place (message,type,persistent,meta,onDismiss), keepsvisibleandcreatedAt, and does not re-arm the timeout — a refetch never brings back a toast the user closed. dismissMessage(id): Promise<void>: the user is done with a message. It leaveshistoryoptimistically, then calls itsonDismiss; if that rejects, the message goes back to its original index and the error is re-thrown. Unknown ids resolve as a no-op.persistentMessages:historyfiltered bypersistent, newestcreatedAtfirst.IToastMessagegainspersistent,createdAtandmeta.hideMessage(leaves the toast stack) andremoveMessage(the app drops it) never callonDismiss, so a sync that finds a message gone at its source does not echo a delete back.onDismisscallbacks are kept outside the reactive state, sohistorystays serializable.
npm run try:frontend: builds, packs, and installs the tarball into a frontend checkout ($FRONTEND_DIR, default../boilerplate-vue-frontend) with--no-save, to try a change before publishing. See the new Development guide.
Changed
- Contributor scripts:
complete:check(the read-only gate) is nowcomplete, and the fixing pipeline that wascompleteis nowcomplete:fix. The pre-commit hook, the release workflow andprepublishOnlyruncomplete.
5.0.0
useStructureRestApi is rebuilt directly on TanStack Query as the single source of truth for records, lists, searches, freshness, invalidation, and loading. TanStack came in with 2.0 (afb162b) only to replace the old TTL timestamps: the hand-rolled dictionary stayed around it through 4.x, so the toolkit ran two caches, and TanStack was used as a clock that never knew what was actually on screen. This release closes four related frontend bugs traced to that split:
- the next user to log in could still see the previous user's data (nothing scoped the cache to who the data belonged to);
- a language switch emptied whatever page was on screen (same root cause: nothing kept fetched pages tied to a language);
- a paginated list's pager could vanish on a cache hit (the server-reported total was never stored alongside the page that produced it);
- another store's list could go stale on screen indefinitely (invalidating it did nothing, because nothing was watching it live).
All four are now structural, not patched: a resource's cache lives entirely in one shared QueryClient, scoped by a dependsOn getter (whose data, which language), and every watch* call is an active TanStack observer that reacts to invalidation on its own.
Changed
- BREAKING —
@guebbit/vue-toolkitnow peers on@tanstack/vue-query ^5.103instead of@tanstack/query-core.vue-queryisquery-coreplus the Vue layer (it re-exports all of it and depends on an exact version internally), so installing@tanstack/query-coredirectly alongside it is a real failure, not a warning — two copies means a client built from the wrong one silently never fetches, andinstanceof CancelledErroranswersfalseacross the pair. Remove@tanstack/query-corefrom your ownpackage.json, install@tanstack/vue-query, and set it up once per app:ts4.x's private client setimport { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' const queryClient = new QueryClient() app.use(VueQueryPlugin, { queryClient })retry: falseandnetworkMode: 'always'; anew QueryClient()keeps TanStack's defaults instead, so awatch*query retries a failure 3 times (in a browser) before reporting it, and queries and mutations pause while the browser reports itself offline. To keep the 4.x behaviour, set them on the client:tsThere is no private, toolkit-ownednew QueryClient({ defaultOptions: { queries: { retry: false, networkMode: 'always' }, mutations: { networkMode: 'always' } } })QueryClientany more — everyuseStructureRestApi/useStructureSearchApi/useStructureCrudApicall either takes one explicitly via itsqueryClientoption or finds it through this injection (which also works inside a Pinia setup store, onpinia >=2.1— see below). One shared client per app is what makes cross-resource cache invalidation possible at all. ThequeryClienta resource returns is that shared client, soresource.queryClient.clear()now wipes every resource in the app: useresetAll()to drop one resource's data. Thevuepeer moves from>=3.0.0to^3.4— one minor pastvue-query's own floor (3.3): see Fixed, below, for why. On Vue 3.0–3.3, upgrade Vue to 3.4 or later first. - BREAKING —
piniapeer floor moves from>=2.0.0to^2.1 || ^3 || ^4. Inside a component'ssetup(), a Pinia SETUP store finds the injection context on any Pinia version. Built outside a component instead (a router guard,main.ts:useStore(pinia)) it needed Pinia to wrap its own setup function inapp.runWithContext, which only exists from2.1— before that,useQueryClient()inside one threw, since there was no injection context at all. This never worked outside a component on2.0.x, so the new floor states the real requirement rather than removing support. Pass thequeryClientoption instead to work on any Pinia version — it needs no injection at all. Thezodpeer also moves from>=4.4.3to^4.4.3(caret, not open-ended) — no consumer on a current major is affected. - BREAKING —
loadingKey→resourceKey, and it is now required, with no random fallback. It's the key every query and mutation a resource makes starts with — whatqueryClient.invalidateQueries({queryKey:[resourceKey]})addresses from another store, and whatsetQueryDefaultsuses to keep records cached. A random default would silently defeat both for any resource that forgot to set one. The returnedloadingKeyfield is renamed the same way: readresourceKey. It must also be unique per record type: every resource now shares oneQueryClient(4.x gave each its own), so two resources under the same key share cache entries — user1and product1collide. Resources that shared aloadingKeyfor one combined spinner take distinctresourceKeys, read together withuseIsLoading(['users', 'products']). - BREAKING —
TTL→staleTime, both as a composable option and a per-call setting. "Time to live" implied the data gets removed once it expires; it doesn't — stale data keeps rendering while a fresh copy downloads, same as always. Only the name changed. - BREAKING —
lastUpdateKey: 'x'→key: ['x'], now an array. It still namespaces independent buckets for an otherwise-identical list orfetchAnycall, and is now also whatisLoading(key)matches a call by — on queries and mutations (createTarget,updateTarget,deleteTarget,mutateAnytake it for that).fetchTarget/fetchMultiple/watchTargettake nokey: a record has exactly one cache entry, keyed only by id.checkAny's first argument follows:checkAny('x')→checkAny(['x']). - BREAKING — per-call settings are typed per method: each method accepts only the fields it reads, and passing another is a type error.
fetchTarget/fetchMultiple:forced,merge,staleTime.fetchAny:forced,staleTime,key.createTarget/deleteTarget/mutateAny:key.updateTarget:merge,key,applyResponse(IUpdateTargetSettings).watchTarget:forced,merge,staleTimeplus its callbacks (IWatchTargetSettings).checkAll/checkByParent/checkPaginate:key,staleTime;checkTarget/checkMultiple/checkAny:staleTime(checkAny's key is its first argument). List fetches and their watchers take the wholeIFetchSettings. - BREAKING —
mismatch→partial, same dual effect (merge instead of replace, and don't count the write as freshly-fetched) — renamed because "the response holds partial records" is what it always meant; "mismatch" described nothing about the actual behaviour. - BREAKING —
fetchAgain→applyResponse(updateTargetonly): trust the response as the record's new, full data. Same option, clearer name — it never re-fetched anything, the old name promised a request that doesn't happen. An empty response (undefined/null) keeps the optimistic patch as the record; the resource's lists are invalidated either way. - BREAKING — records are read-only. 4.x's
itemDictionaryandparentHasManywere writable refs, so writing into a record read back (getRecord(1).name = 'x') or into the dictionary changed the store. OnuseStructureRestApithey are now computed views over the cache: a write intogetRecord(x)oritemDictionaryis ignored with a Vue warning, andparentHasManyis rebuilt from the cache on every change, so an in-place edit of it is lost. Being computeds, a Pinia setup store treats them as getters, not state: records are no longer in$state, so$patch,$subscribe, SSR state serialization and persistence plugins no longer carry them. Migration: write throughaddRecord/editRecord/updateTarget, and link children withaddToParent; persist or transfer records through theQueryClient(persistQueryClient, or TanStack'sdehydrate/hydrate), not through Pinia state. - BREAKING —
IToastType→EToastType(useNotificationsStore). TheIprefix names an interface; an enum gets no such prefix. Update the import and every reference. - BREAKING —
fetchMultipleresolves the requested ids: one slot per id, the fetched ones first, then the cached ones,undefinedfor an id the server left out. 4.x resolved every item the server returned (whatever its id), then the cached ones. - BREAKING — raw cache keys carry record and parent ids as strings:
[resourceKey, 'target', dependsOn, String(id)],[resourceKey, 'parent', dependsOn, String(parentId), ...key].5and'5'(a route param) now address one entry. A rawqueryClientcall that addresses a record or a parent list must use the string id. - BREAKING (types) —
useStructureCrudApi's read operations take nooptionsparameter:list: () => …,search: (filters, page, pageSize) => …,get: (id) => …. It was never forwarded to them;Onow types onlycreate/update/remove.updateTarget'sapiCallis typed() => Promise<F>(4.x:() => Promise<F | (T | undefined)[]>); a response that is not a record object (empty, an array, a primitive) keeps the optimistic patch. - BREAKING —
forcedkeeps its name and its intent (skip the cache, get fresh data), but its mechanism changed: it now runs the call withstaleTime: 0instead of dropping the cache entry first. A concurrent, non-forced call for the same thing joins this same request instead of racing it — dropping the entry first would have cancelled that other call with a rejection. - BREAKING — a search's
apiCall(andfetchSearch/watchSearch/the CRUDsearchoperation) now resolves{ items, totalItems }instead of a bare array, and so dofetchSearch, the watcher'ssearch(),searchNow()andresetFilters().totalItemsis stored alongside the page it came from, so it — and the derivedpageTotal— now survive a cache hit, which closes the vanishing-pager bug above.fetchPage(unfiltered, viafetchPaginate) still resolves a bare array and discardstotalItems. - BREAKING — The applied search. What
pageItemList,pageTotalandtotalItemsshow follows the applied search — a detached copy of the filters (plus itskey) taken when a search is applied — not the live filters. Onlysearch(),fetchSearch, and the CRUDsearchNow()/resetFilters()apply the live filters (andwatchSearchonce at creation, withimmediate); a page or page-size change fetches another page of the applied search without re-reading them. Fixes a real bug: binding a filter form's inputs straight to the livefiltersblanked the list while the user typed, before Search was pressed. Withimmediate: false, nothing is applied, so nothing is fetched, until the firstsearch(): a page or page-size change before it does nothing, where 4.x fetched on any page change from creation. Migration: callsearch()(orsearchNow()) wherever an edit of the filters should take effect, and once before relying on page changes of animmediate: falsewatcher. - BREAKING — on
useStructureSearchApi/useStructureCrudApi, changingpageSizenow also resetspageCurrentto1, synchronously, in the same tick — a page number from the old page size rarely means anything under the new one. The reset watcher runs withflush: 'sync'specifically so it always lands before vue-query's own (pre-flush) key watcher can react to either change — otherwise, depending on where the watching call was started (a click handler, anonMountedafter anawait, a parent building a child's store), the key watcher could flush first and fire one wasted request for the OLD page at the new size before landing on page 1. ⚠ SettingpageCurrentto something else right after, in that same tick, now keeps that value instead of being forced back to 1 — useful for restoring?page=4&size=25from a URL. The client-side pagination ofuseStructureDataManagement/useStructureRestApiis unchanged. - BREAKING — every watcher returns a handle
{ stop, refetch, error }(IWatchHandle).watchTarget(and the CRUDwatchOne) returned a bare stop function (WatchStopHandle), andwatchSearch(the CRUDwatchList) returned{ stop, search }. Migration:const stop = watchTarget(...)→const { stop } = watchTarget(...).refetch()fetches now, joins a fetch already running instead of restarting it, and resolves with the watched data as cached after the fetch (a failure leaves the previous data); a watcher never rejects — failures show inerror(andonErrorwhere offered).watchSearchaddssearch()(IWatchSearchHandle), which resolvesundefinedon failure;watchAnyaddsdata. - BREAKING —
useStructureCrudApi'soperationsparameter is required: it no longer defaults to{}.settingswas already effectively required, since it carriesresourceKey. Migration:useStructureCrudApi()→useStructureCrudApi({}, { resourceKey: '...' }). - BREAKING — a resource's data outlives the effect scope that built it. 4.x cleared the dictionary (and its private client) when the owning component or store was disposed; now stopping a scope ends the resource's cache subscriptions and its watchers, and every entry stays in the shared
QueryClient. CallresetAll()first, or let adependsOnchange drop it, when data must leave memory. - BREAKING —
watchTarget/watchSearch(and the CRUDwatchOne/watchList) are now active: built on TanStack'suseQuery, so aninvalidateQueriescall from anywhere, or adependsOnchange, re-runs them on their own — no imperative refetch needed. TheironSuccess/onError/onSettledfollow: 4.x ran them once per id change, page change orsearch(); they now run once for every fetch of the watched query that lands, background ones included — after an invalidation (every create/update/delete refetches an active list or search), on window focus or reconnect once the data is stale, or when anything else fetches the same key. Migration: make the callbacks safe to repeat — never reset a form being edited fromonSuccess, say — or compare against the last id/filters handled;queryOptions: { refetchOnWindowFocus: false, refetchOnReconnect: false }drops the focus/reconnect ones. watchOne(idSource, settings?)forwardssettings(forced,merge,staleTime, and theonSuccess/onError/onSettledcallbacks) towatchTarget.- BREAKING —
loadingstays on every resource, now derived from TanStack: true while any query or mutation under the resource'sresourceKeyis in flight (the same asisLoading()) — every instance sharing thatresourceKey, and the background refetches of active watchers, included. 4.x counted this instance's own calls (or itsloadingKeyin the core store). Migration: a spinner that should follow only some calls tags them withkeyand readsisLoading(key). resetAll()now drops every kind of a resource's entries under the currentdependsOn(records, lists, searches) in one call, rather than only resetting the local dictionary and parent relations, and refetches what active watchers show: the views empty, then the watched data comes back.resetRecords()removes records only, and marks the lists stale (their ids may now point at nothing), so active list watchers refetch.- Cache lifetime: records, parent lists (
getListByParent) and search pages (pageItemList) are never garbage-collected, as the 4.x dictionary and search index never were — a search fetched imperatively (fetchSearch, the CRUDsearchNow()with nowatchList()) keeps showing with no watcher. They leave throughdependsOn,resetAllandmaxRecords(records also throughresetRecordsand deletes). Theall/page/anyentries follow the client'sgcTime(TanStack's default: 5 minutes in a browser once nothing observes them); nothing renders them directly, so one leaving only means the next call asks the server again. - BREAKING —
maxRecords' default drops from100_000to10_000— a record costs more as a TanStack query than as a plain object (measured for 10 000 records: ~2.2 MB as plain objects, ~10.5 MB as queries, i.e. ~0.8–1 KB more per record), so the same backstop now sits at a tighter bound, and crossing it drops more (next entry). Migration: an app holding more than 10 000 records per scope (infinite scroll overitemList, a big lookup table) setsmaxRecordsexplicitly —100_000keeps the 4.x bound. - Crossing
maxRecordsnow drops every other entry of the current scope — lists and searches too, not only records — while the list fetch that crossed it keeps its own entry, resolving its items and caching its list. Only records not cached yet count towards the bound (4.x counted the whole incoming batch), so refetching a list, or a record, that is already cached never triggers the wipe. Queries still fetching, or something is actively watching, are spared too (see Fixed, below, for the latter).fetchMultipleenforces the bound as well as every list-shaped fetch; the mutations andaddRecord/editRecordnever do, since they write one record you already hold data for, not a batch of possibly-new ones. parentHasMany/getRecordsByParent/getListByParentonuseStructureRestApiread the cache: a parent's children are the union of its keyless entry and everyfetchByParent(..., { key })bucket for it, in first-seen order, without duplicates.addToParentwrites the keyless entry;removeFromParent/removeDuplicateChildrenedit every bucket of the parent. Edited entries are marked stale, so the nextfetchByParentasks the server.- BREAKING —
watchTarget(apiCall, idSource, settings?):apiCallfirst,idSourcesecond — the same orderfetchTargetandwatchByParentalready use. Swap the first two arguments at every call site:watchTarget(userId, get)→watchTarget(get, userId). - BREAKING —
useCoreStore'ssetLoading(key, value)makes both parameters required, andgetLoading(key)requires its key too.setLoading('x')used to quietly storefalse— a caller meaning to mark work as started, with the value typo'd away or refactored out, silently marked it finished instead. Pass the value (and the key) explicitly everywhere now. - BREAKING —
updateTarget/deleteTarget's mutation key now carries the id asString(id), the same form a record's query key already uses, instead of the rawKvalue. Only visible to a hand-writtenisMutating/isLoadingfilter matching on the mutation key's id segment directly — matchString(id)instead ofid. This is what a future per-recordisSaving(id)needs to filter reliably regardless of whetheridwas passed as a number or a string. - BREAKING — Type naming, per the options-vs-return split (VD4):
IStructureRestApi(used to name the options) is nowIStructureRestApiOptions;IStructureCrudSettingsis nowIStructureCrudApiOptions.IStructureRestApi,IStructureSearchApiand the newIStructureDataManagementApinow name each composable's return type instead (previously unnamed/inferred), matchingIStructureCrudApi, which already did. All four return types are now hand-written, explicit interfaces rather thanReturnType<typeof ...>inference — this also means their.d.tsoutput never references this package's owninternal/modules or@vue/reactivity/@vue/shareddirectly (V4.2), which failed to resolve under a strict pnpm layout. RenameIStructureRestApi/IStructureCrudSettingsimports to the new options names; code naming the old (absent) return types starts naming the new ones instead. - BREAKING —
K's default type, on every structure composable, is nowTIdOf<T>— the type ofT['id']whenThas one,string | numberotherwise — instead ofkeyof T(the union of field names). The defaultidentifiersis'id', so this is the type its value naturally has;keyof Tnever described what a caller actually got back fromcreateIdentifier/getRecord. Code that relied on the old default (callinggetRecord('id')with the literal field name, say) passesKexplicitly now:useStructureRestApi<IUser, keyof IUser>(...). - BREAKING —
useStructureCrudApi'screateOne/updateOne/deleteOnetake one settings object instead of a bare per-calloptionsargument:createOne(data, { requestOptions, key? }),updateOne(id, data, { requestOptions, merge?, applyResponse?, key? }),deleteOne(id, { requestOptions, key? }).requestOptionsis the oldoptions, forwarded to the operation the same way —createOne(data, opts)→createOne(data, { requestOptions: opts }).createOneandupdateOnealso newly exposecreateTarget'sdummyDataandupdateTarget'smerge/applyResponse, previously unreachable from the CRUD layer; all three takekey. - BREAKING —
useStructureFormValidation'shandleSubmitruns one submit at a time. 4.x ran every call, so a double click (or Enter plus a click) ran the handler twice — two POSTs — and the first to finish clearedisSubmittingwhile the other was still running. AhandleSubmitcalled while another is still running now resolvesfalseat once: no validation, no handler call,showFormErrorsuntouched.falsenow means "the handler did not run", validation failure or not. Migration: to submit again, wait for the running submit to settle (awaitit, or watchisSubmittingturnfalse); a flow that deliberately ran two handlers concurrently calls the second one directly instead of throughhandleSubmit. - BREAKING —
package.jsondeclaresengines.node: ">=22"; 4.x declared no engine at all. Node 20 reached end-of-life on 2026-04-30. npm only warns on a mismatch, but Yarn 1 andengine-strictinstalls refuse it. Migration: run your build, SSR and tests on Node 22 or later. - BREAKING — every read
apiCall—fetch*/watch*,fetchAny/watchSearch, and CRUD'slist/search/get— receives anIFetchContext({ signal }) as its last argument, where 4.x passed nothing (fetchTarget, keylessfetchAny, CRUDlist/get/search) or only the id (watchTarget);fetchMultiple's receives(missingIds, context)(see Added).signalis a lazy getter: reading it is what asks TanStack to abort the underlying fetch once nothing needs it any more (an unmounted watcher, a mutation cancelling that same record's read). Forward it tofetch/axios for a request that is genuinely cancelled, not just an answer later discarded. AnapiCallthat ignores its arguments keeps working, but a function passed by name whose own optional parameter sits in that slot now receives the context instead of its default:fetchAny(api.stats)withstats(range = '7d')gets{ signal }asrange. Migration: wrap such a function in an arrow,fetchAny(() => api.stats()). - BREAKING —
fetchSearch(apiCall, filters, page, pageSize)appliespageandpageSizeto the livepageCurrent/pageSize, sopageItemList/totalItemsshow the page it just fetched. 4.x only fetched and cached it, leaving the screen on whatever page was current before, until something else movedpageCurrent.pageSizedefaults to the current one, so leaving it out keeps the size on screen. Migration:fetchSearchmoves the screen to the page it fetches, so do not call it to prefetch a page the screen should not show yet. - BREAKING —
applyServerErrorsno longer drops form-level and unmapped server messages. WithoutonUnmapped, 4.x discarded them and returnedfalse; they now go toformLevelErrors(whichisValidincludes, so it staysfalseuntilclearErrors/resetForm),showFormErrorsturns on, and the call returnstrue. It also returnstrueafter handing every message toonUnmapped, where 4.x returnedfalsewhenever nothing reachedformErrors— the docs' own snippet then showed a second, generic message on top of the oneonUnmappedalready displayed.falsenow means only that the rejection carried nothing at all. Migration: renderformLevelErrors(a banner above the form, say) or passonUnmappedto display them yourself, and drop any fallback message shown onfalseafteronUnmappeddisplayed one. - BREAKING (types) —
useStructureDataManagement'sparentHasMany/addToParent/removeFromParent/removeDuplicateChildrentype a child id asK, the id's own value type, where 4.x typed it astypeof identifier— the identifier FIELD NAME, alwaysstring, so a numeric id needed anas nevercast. Code passing string ids to a numeric-id resource, or readingparentHasManyasstring[], no longer compiles. Migration: pass child ids in the id's own type, drop the casts, and readparentHasManyasK[].
Added
queryOptions: a narrow, explicit pick of TanStack's ownuseQueryoptions (retry,retryDelay,refetchInterval,refetchOnWindowFocus,refetchOnReconnect). Set on the resource as a default for everywatch*call it makes, or per watcher (watchTarget,watchAll,watchByParent,watchAny,watchSearch) to override that default for one call.queryFn/queryKey/gcTimestay the engine's, as always. Reaches only ACTIVE queries (watch*), not one-shotfetch*reads, which have no observer for these options to mean anything on.isSaving(id):truewhile anupdateTarget/deleteTargetmutation on recordidis running — a per-row pending signal for a row's own spinner, distinct fromloading/isLoading, which cover the whole resource. A plain function, likeisLoading: call it inside acomputedor a template. Deliberately excludescreateTarget(no stable id of its own yet) andmutateAny(no record id at all).suspense()on everywatch*handle (watchTarget,watchAll,watchByParent,watchAny,watchSearch): wraps TanStack's ownsuspense(), resolving once the watched data is cached — fetching first if needed. For SSR: call it inonServerPrefetch, so the fetch happens during server rendering and the client hydrates from an already-populated cache instead of fetching again on mount. Likerefetch(), never rejects — a failure shows inerror. A watcher that is not currently enabled (no id yet,enabled: false) resolves right away with whatever is cached, instead of waiting forenabledto turn true — TanStack's ownsuspense()on a disabled query never resolves at all.useStructureSearchApi'spageItemListkeeps showing whatever was shown a moment ago while a page, size OR filters change is in flight, instead of dropping to[]until the new page lands (totalItemsalready did this). A newisPlaceholder: ComputedRef<boolean>istrueexactly whilepageItemListis showing that fallback,falseonce the real current page is cached (including an empty one) or on a genuinely empty first load. Cleared on adependsOnchange or aresetAll(), so one user's or one language's rows never flash onto another's screen.ISearchFetchContext<F>:fetchSearch'sapiCallnow receives{ filters, page, pageSize, signal }as its context — the search this call was asked for, FROZEN at the moment it started — instead of the plain{ signal }read context. AnapiCalltyped on the old, plain context still compiles; it just ignores the new fields.package.jsonmetadata:description,keywords,homepage,bugs,repository(needed for npm provenance) andauthor. Previously all unset.engines.nodeis new too — see its own entry under Changed.watchAll/watchByParent/watchAnyacceptenabledand a reactivekey(a Ref, ComputedRef or getter for either).watchByParentalso idles automatically — no fetch,apiCallnever called — while itsparentIdis nullish, instead of callingapiCall(undefined).useNotificationsStore.addMessagereturns the new message's id, matching what the docs already promised as the argument tohideMessage/showMessage/removeMessage— previously a caller had to re-readhistory[history.length - 1].idto get it.fetchMultiple'sapiCallnow receives the ids it actually needs to fetch —(missingIds, context) => …, exactlycheckMultiple(ids).expiredIds— instead of no arguments at all. BuildingGET /users?ids=1,2frommissingIdsmeans an id already fresh in the cache is never re-requested; previously this required callingcheckMultipleyourself first.dependsOn— a getter returning the values a resource's data depends on (whose data, which language):dependsOn: () => [session.viewer?.id ?? 'guest', locale.value]. Read fresh at the start of every call; when it changes, the resource's entries under the old value — records included — are cancelled and removed (not merely marked stale), every activewatch*re-runs under the new one, and an answer or rollback that settles afterwards is dropped. Closes the cross-user and cross-language data leaks above.watchAll,watchByParent,watchAny— the active counterparts offetchAll/fetchByParent/fetchAny, so a view never has to fetch imperatively fromonMountedor a click handler.watchAll/watchByParenttake the fullIFetchSettings(forced: every mount or key switch asks the server);watchByParent'sparentIdmay be a plain value, a Ref or a getter, and re-runs when it changes, and itsapiCallreceives the parent id the running query is for.watchAny(apiCall, { key, forced?, staleTime? })requireskey(an active query needs a stable identity) and also returnsdata, since its answer isn't a record.mutateAny(apiCall, { key? })— a command that doesn't fitcreateTarget/updateTarget/deleteTarget's record shape, run as a one-shot TanStack mutation. Does not auto-invalidate anything — it has no defined relationship to any record or list.isLoading(key?)on every resource — a plain function, true while a query or mutation of the resource runs whosekeystarts with the given segments (isLoading(['dash'])covers a call keyed['dash', 'w1']); no key: anything of the resource. Call it inside acomputed.useIsLoading(prefixes?, queryClient?)— a new standalone composable: "is any of these resources busy" across the whole app, matching byresourceKeystring prefix (the same ruleuseCoreStore'sisLoadingalready uses). Built onuseIsFetching/useIsMutating; the optionalqueryClientis for code with no injection context.totalItemsonuseStructureSearchApi/useStructureCrudApi— the applied search's server-reported total, for "124 orders";pageTotalis now derived from it instead of a local item count. While a new page loads it keeps the applied search's latest known total.searchGet(filters, page, pageSize, { key? })— an optional fourth argument reads a page cached under akeybucket.resourceKeyandeditRecordson the objectuseStructureRestApireturns.- Exported types:
IWatchHandle,IWatchCallbacks,IWatchTargetSettings,IWatchSearchHandle,ISearchResult,IUpdateTargetSettings,IRecordStore,IFetchContext,TIdOf,ICreateOneSettings,IUpdateOneSettings,IDeleteOneSettings,IWatchListSettings,IWatchAnySettings,TListCall,TMultipleCall,IStructureRestApi(now the return type),IStructureSearchApi,IStructureDataManagementApi. useStructureDataManagementgains an optional, advanced third parameter,recordStore: the write surface its CRUD methods go through instead of owning a plainrefdirectly. Defaults to a local, in-memory store (so the composable is unaffected on its own) —useStructureRestApisupplies a TanStack-backed one, which is what turnsitemDictionaryinto a live, read-only view of the query cache rather than a second copy of it.- Documentation page for
useIsLoading, and diagrams of the cache layout, thedependsOnflow and the applied search.
Removed
- BREAKING —
destroy(),startLoading()/stopLoading(),saveRecords, thegetLoading/setLoadingoptions, the per-callloadingandloadingKeysettings,fetchLike,searchCleanup()/resetSearches()/searchCachedand theISearchCachetype.destroy(): there is no private client left to tear down; stopping the owning scope ends the subscriptions and watchers, andresetAll()removes the data.- Loading:
loadingitself stays (see Changed); what is gone is the manual ref-count and its wiring. TanStack already knows what is in flight, so there is nothing to connect an external store to (getLoading/setLoading), and no per-call opt-out (loading: false) or per-call loading name (loadingKey): tag a call withkeyand askisLoading(key). fetchLike: storing the record "as fetched" is now whatcreateTargetalways does, and whatupdateTargetdoes with its response;updateTargetopts out withapplyResponse: false.- The search page→ids index: a search's pages are cache entries now, read directly; there is no separate index to clean up or cap.
searchKeyGen, fromuseStructureSearchApi's returned object: internal machinery (a thin wrapper over the same canonicalization every cache key in the toolkit already uses), not something a caller ever needs —searchGet/checkSearchalready accept the filters object directly. If you were building a pre-serialised key by hand, pass the filters object instead.
Fixed
useStructureFormValidation'sinitialFormData/serverErrorsrefs wereref<T>(...)/ref<Partial<Record<keyof T, string[]>>>(...)with no cast: undervue's declared floor (^3.4, tested via the peer-floor CI job now also runningtest:types— V4.5),Ref<T>'s older, single-parameter form routes every.valuethroughUnwrapRef<T>, which cannot resolve for a genericTinvolvingany— a build error, not a runtime bug, but a real one for anyone actually on the declared floor. Cast pastUnwrapRef, matchingform/formErrors's existing casts.IStructureFormValidation<T>wasReturnType<typeof useStructureFormValidation<T>>: becauseTcan involveany(the record constraint every structure composable uses), Vue's own ref inference could not fully resolve, and the published.d.tsfell back toimport("@vue/shared")andimport("@vue/reactivity")type references — packages this package does not depend on directly, which do not resolve under a strict (pnpm-style)node_moduleslayout. Replaced with an explicit, hand-written interface (the same fix V2.8 already applied to REST/Search/ DataManagement/CRUD), with no shape change.tests/package/dtsGuard.mjs(added in V4.1) now also scans every public.d.tsfor@vue/reactivity/@vue/sharedand this package's owninternal/modules, so a future leak fails the build instead of shipping.useStructureFormValidation'sschemaoption was typed with Zod's ownZodType<T>, imported fromzod— an OPTIONAL peer. The published.d.tscarried that import, which breaks type-checking for any app that has not installedzod, whether or not it uses this composable.schemais now typed with a new, exportedIValidationSchema<T>: Zod'ssafeParsecontract described structurally, so this package's types never referencezod. Any real Zod schema satisfies it as-is. A newtests/package/dtsGuard.mjs, run as part oftest:package, fails the build if a published.d.tsever referenceszodagain.useStructureSearchApi'sknownTotalusedArray.prototype.toSorted, which needs Safari 16 (September 2022) — unusably recent for a library with no stated browser floor. Replaced with a single-pass max overdataUpdatedAt, with no behaviour change: both picked the single most recently cached page's total.useStructureDataManagementalways built its own localparentHasManystate (a plain ref and its editors) even underuseStructureRestApi, where that state was immediately shadowed by a second, TanStack-backed implementation of the samehasMany/belongsTosurface built separately inrestResource.ts— dead reactive state allocated, and silently discarded, on every REST-mode resource. Relations now go through anIRelationStoreseam, the same patternIRecordStorealready uses for records: a local dictionary by default, and the TanStack-backed oneuseStructureRestApipasses, so nothing local is built when the REST store supplies it.useStructureDataManagement'slastInsertedIdentifier/lastInsertedIdentifiersare documented as tracking newly CREATED records, butaddRecord/editRecordmoved them on every write, including one storing a record the server merely reported (a list fetch, aGETby id) —useStructureRestApi'sfetchTarget/fetchAll/watch*overwrote them on every call, with nothing about an actual create.IRecordStoregains an optionalisFetching();addRecord/editRecordskip the tracking side effect when it reports true.useStructureRestApi'screateTarget(a real create, also stored through the sameasFetched-wrapped write for its own freshness reasons) marks it explicitly instead of relying on that side effect.useUploadProgress'strackshared itsprogressref across overlapping calls with no ordering guard: whichever call settled first reset the bar to idle even while a second call was still in flight, and a stale call's late progress event could overwrite the newer call's percentage. A per-call token now scopes both the reporter and the idle-reset to the call that started it, so only the newesttrackcall can moveprogress.useStructureFormValidation's root-level Zod issues (an emptypath— a cross-field.refine()with nopathoption, say) were silently dropped:validate()could returnfalsewhileisValidstayedtrueand nothing showed on screen. A newformLevelErrors: Ref<string[]>now holds them, andisValidincludes it (applyServerErrorsfills it too — see Changed).clearErrors/resetFormclearformLevelErrorsalong withformErrors.useStructureFormValidation'srevalidateOnre-ran the publicvalidate(), which replacesformErrors/formLevelErrorswholesale from a fresh schema parse — wiping out any error set bysetFieldError/applyServerErrors(a "this email is already taken" from the server, say) the moment a watched source changed, even though the schema itself has nothing to say about whether that error still applies.revalidateOnnow merges the refreshed schema result UNDER those server-set errors instead of replacing them. An explicitvalidate()call still replaces everything, since a submit is a fresh attempt the server will answer again.useStructureFormValidation's baseline (initialFormData) andformwere shallow copies:setInitialData,resetForm,setFormand the initial state all spread one level deep only, so a nested field stayed the SAME object acrossinitialData, the baseline andform. A hydratedreadonly()record (selectedRecord, say) then left that nested object genuinely read-only insideformtoo — writing to it failed silently (Vue's own dev warning) instead of editing the form.resetFormalso handed the live form and the baseline the same nested object, so editing one edited the other. All four now use a detached (deep) copy, sharing no plain object or array with the source (Set, Map and Date fields also detached now — see Fixed, below).watchByParentcalledapiCall(undefined)when itsparentIdwas nullish, instead of idling: a screen whose parent id starts out unknown (still loading from a route param, say) fired a request forundefinedon mount. It now idles — no fetch,apiCallnever called — exactly likewatchTargetalready does for a nullish id, untilparentIdbecomes a real value.- A resource built outside an effect scope (a component's
setup(), a Pinia setup store, aneffectScope()) had its cache subscriptions leak silently for theQueryClient's whole lifetime, with no way to stop them and nothing telling you it happened. It now emits aconsole.warnnaming theresourceKey, once per key. asFetchedrestored its internal "this write is a server answer" flag without afinally: a throw inside the writes it wraps left the flag stuck on, so every later local write (an optimistic edit, a plainaddRecord) was wrongly stamped fresh instead of keeping its previous freshness. Wrapped intry/finallynow.editRecordread throughdictionaryon every call, which under the REST layer is a computed that rebuilds its entire scope from the query cache whenever it is stale — and each write invalidates it for the next read. Merging or partially writing a batch (merge/partialsettings on a list fetch) callededitRecordonce per item, so a batch of N items over a cache of M records did O(N × M) work: 1.7s for 1k items over 3k cached, measured before this fix.IRecordStoregains an optionalread(id)— an O(1) lookup both built-in stores implement — andeditRecordreads through it instead, bringing the same batch back to milliseconds.- A
maxRecordswipe could empty a recordwatchTargetwas actively showing on screen, with nothing telling it to refetch — the crossing batch kept its own entry, but any other cached record, watched or not, was dropped the same way. A record with an active observer is no longer evicted by the wipe (it still counts toward the bound, it is just never the one removed). Single- record fetches (fetchTarget/watchTarget) also never enforced the bound at all: browsing many detail pages one at a time, each cached withgcTime: Infinity, grew the cache without limit; they now enforce it exactly like a list-shaped fetch does, for a genuinely new id. - A resource's start-up sweep dropped every cache entry of its
resourceKeyunder any otherdependsOnvalue, and adependsOnswitch dropped everything under the old value the same way — both on the assumption that nothing still alive could be using it. True for an ordinary scope change, false the moment a second instance of the sameresourceKeyis alive under a different scope: creating it wiped the first instance's data, and either instance moving itsdependsOnaway from a scope the other was still showing dropped that scope's data out from under it. A new live-scope registry (src/internal/scopeRegistry.ts) tracks how many instances currently claim each scope; the sweep and the switch's drop now only remove a scope nothing claims any more, so two instances of the same resource under different scopes coexist. fetchTarget/watchTargetby an alternate key stored the record twice:fetchTarget(apiCall, 'my-slug')resolving{ id: 7 }wrote the record under7(its own id) and, separately, under'my-slug'(whatever TanStack writes the query function's return value back under) — two independent copies that would silently diverge the moment either one changed. The requested key now holds an alias entry instead (pointing at7, never a copy of the record);getRecord,selectedRecordand everything that reads throughgetRecordfollow it one hop, anditemDictionary/itemListonly ever see the record once.watchTargetclearedselectedIdentifieron any failed fetch, including a background refetch of a record already on screen: a transient network error blanked the detail view even though the previously fetched record was still sitting right there in the cache. The selection is now cleared only when it still points at the failed id and nothing is cached for it any more — a genuine "there is nothing left to show" case, not "the latest refetch happened to fail".- A watcher's
onSuccess/onError/onSettledran synchronously inside TanStack's own query-cache notify dispatch. A callback that threw there corrupted that dispatch mid-flight — TanStack read it as the fetch itself failing, putting the query into an error state, firingonErrorfor a fetch that had actually succeeded, and (onwatchTarget) clearing the selection. The callbacks now run a microtask after the dispatch that triggered them has fully finished, so a throw inside one surfaces as an ordinary uncaught error instead. - TanStack's
hydrate()— whatpersistQueryClientuses to restore a cache persisted on a previous visit — added a restored query straight into the cache already holding data, without ever going through.setData(), so only anaddedcache event fired, neverupdated/success. The record view (itemDictionary,itemList,getRecord, …) is a computed keyed off a data counter that only moves on a counted cache event; an uncountedaddedleft it stuck on its old (usually empty) value until something unrelated to the restore changed the same kind. Anaddedevent with data now counts too. updateTarget/deleteTargetsnapshotted the record to roll back to before cancelling its in-flight read, not right before applying the optimistic edit: two mutations on the same id started in the same tick both captured the same "before either ran" snapshot. If the older one succeeded and the newer one then failed, the newer one's rollback restored that shared snapshot — undoing the older one's already-confirmed change, not just its own — and left the record stamped as fresh with no way to tell it was wrong. The snapshot is now taken right before applying, so a same-tick sibling's rollback returns to what the other call itself left behind; and a rollback, or a success skipped because a newer mutation now owns the record, invalidates it, so an active watcher reconciles it with the server instead of trusting the local guess.updateTarget/deleteTargetcancelled every list read of the resource's current scope (all,parent,page,search), not just the record's own: an unrelated in-flightfetchAll(or a search page, or another parent's list) resolved[]on that cancellation and never retried on its own. Reads were also unprotected the other way —fetchTarget's id-less path could resurrect a record adeleteTargethad just removed, andfetchMultiplecould overwrite a value anupdateTargethad just applied, whichever answer happened to arrive last. A mutation now cancels only the record's own query; a per-resource write guard tracks which id a mutation owns and for how long, and every read's write to a record —fetch*/watch*and search alike — is dropped once a mutation on that id has started since the read began, or is still running.useStructureDataManagement/useStructureRestApi's composite identifiers (identifiers: ['a', 'b']) collided whenever a value contained the delimiter:{ a: 'x|y', b: 'z' }and{ a: 'x', b: 'y|z' }both joined to'x|y|z', so adding the second silently overwrote the first.createIdentifier,getRecord's multi-argument form andeditRecord's arrayidnow escape each value before joining: every character of the delimiter, and the escape character itself, gets the escape character in front. That holds for any delimiter, multi-character and self-overlapping ones ('::','--') included. The escape character is a backslash unless the delimiter contains one, then the next character code the delimiter does not use. Different tuples never produce the same id; an id whose values use none of those characters is unchanged.updateTarget's success handler applied the server response unconditionally once its scope was still current, with no check for a newer mutation on the same id: adeleteTargetremoving a record while an olderupdateTargetfor it was still in flight could be undone by that update's late, stale success — resurrecting a record the app had just deleted. Success now applies only while the record still holds exactly what this call's own optimistic edit left it as, the same rule the rollback path already followed for a failure.createTargetleft thedummyDataplaceholder on screen when the API resolved empty; it is now removed whatever the call resolved, and the lists are marked stale on any success.- A failed
fetchOnecleared the selection even when a newerfetchOnehad selected another id meanwhile; it now clears it only while it is still the failed id. useStructureCrudApi'sresetFilters()restored whatever had been typed instead of the initial filters:ref(initialFilters)wrapped the caller's object, so a form editingfiltersin place rewroteinitialFilterstoo.filtersis now a detached copy that never shares objects withinitialFilters.removeFromParenton a parent with no recorded children threw aTypeError; it now leaves that parent with an empty list.useStructureDataManagement'sgetRecordsByParent/getListByParentreturned nothing for a parent id of0or''; those are real parent ids now, and only an omitted (undefined) one means "no parent".docs/guide/getting-started.mdclaimed@tanstack/query-core/vue-querywere regular dependencies landing in your bundle automatically; neither was ever true (the former was a peer, the latter wasn't referenced at all). Corrected alongside the peer-dependency change above.dist's.js.map/.d.ts.mapfiles pointed atsrc/, which the package never ships; a debugger stepping into the library, or a bundler resolving a source map, hit a 404.sourceMapanddeclarationMapare off;dist's.d.tsfiles still carry full types, just no map.- Under Vue 3.3, changing
pageSizeonuseStructureSearchApi/useStructureCrudApicould refetch the old page number at the new page size before resetting to page 1, firing one extra request: Vue 3.3's watcher scheduler could flush the active query's key before thepageSize→pageCurrentreset watcher runs.vuepeer floor moves to^3.4, which orders them correctly. distcouldn't be loaded by plain Node or bymoduleResolution: node16/nodenext:tscemitted extensionless relative imports ('./stores/core'), which Node's own ESM loader refuses (ERR_MODULE_NOT_FOUND). A bundler-free consumer — SSR that externalizes the package, some test runners — hit this on every import.src/now imports its own modules with a.jsextension, andtsconfig.jsonbuilds withmodule/moduleResolution: NodeNext, so the compiler refuses a missing one from now on.useStructureDataManagement'spageSizeaccepted any written value, including0or negative —pageTotal(Math.ceil(totalItems / pageSize)) then read asInfinity, whileNaNmade itNaN, a fraction a fractional page, andInfinityan empty first page.pageSizeis now acustomRefthat keeps it a whole number of at least 1: a write is rounded down and clamped to 1, and a non-finite write (NaN,Infinity) is ignored, keeping the current size.editRecordcheckedidwith!id, so a falsy-but-real id (0,'') was treated as "no id given" and silently fell back to inferring the id fromdatainstead of editing the record it was actually asked for. Checksid === undefinedinstead —0and''are real ids.useAsyncAction'srun,useUploadProgress'strackanduseLivenessProbe'scheckall called the caller's function directly, before any promise existed, so a SYNCHRONOUS throw (a throwingbuildOptions, a null dereference before the firstawait) escaped instead of settling into the composable's own state:run()leftloadingstucktruewitherrorunset,track()leftisUploadingstuck at 0%, anduseLivenessProbethrew out of its own creation. A new internalpromiseTry(new Promise((resolve) => resolve(fn())), the same contract as ES2025Promise.try) now wraps all three call sites, so a synchronous throw settles exactly like a rejection.useLivenessProbe'scheck(), called afterstop()(a late "Retry" click on an unmounted banner), still probed and, on failure, scheduled a retry timer nothing would ever cancel — every retry scheduling the next, forever.stop()now sets astoppedflagcheck()checks first: afterstop(),check()resolves without probing, and no timer is ever scheduled.- A one-shot
fetch*read that failed on a key an activewatch*was showing reset that query in place — the same handling every other removal gives an observed entry — which wiped the watcher's ownerrorback tonullalong with it. The empty, error-only entry a failed one-shot read leaves behind is now removed only while nothing watches it; a watched one is left untouched, so the watcher keeps showing the error and retries it on its own. useStructureCrudApi'ssearchNow/resetFiltershandedfetchSearcha closure that read livefilters/pageSizewhen IT RAN, not whensearchNow/resetFilterswas called. TanStack keeps the lastapiCall/queryFna query was given and re-runs that same closure on an unrelated invalidation (invalidateQueries, another mutation's own invalidation) — not a fresh call. So adeleteOneelsewhere could re-runresetFilters's old closure with whatever was typed in the filter form by then (not submitted), and the answer landed under the search that was actually applied. Both now read the search fromfetchSearch's new context (seeISearchFetchContextunder Added) — a snapshot frozen when the call started — so a later re-run asks the same question again.useStructureSearchApi's placeholder fallback (pageItemList/isPlaceholder, see Added) only looked among pages of the currently APPLIED filters. Applying filters that had never been fetched before — genuinely new ones, not merely a page or size change — dropped the list to[]withisPlaceholder: false: exactly the combination the docs reserve for "genuinely empty," so an empty-state message flashed on every new search, contrary to the feature's own stated intent ("page/size/filters change"). It now falls back to whatever was shown a moment ago regardless of what changed, the same idea as TanStack's ownkeepPreviousData.detachedCopy(and souseStructureFormValidation'sform/setForm/the baseline) rebuilt only plain objects and arrays; a Set or Map field was kept by reference. Vue proxies a Set or Map like any other object, soform.tags.add(x)(a tag picker) was a real, reactive edit that reached the source record AND the reset baseline —resetForm()kept it.isDirty'sJSON.stringifycompounded this:JSON.stringify(new Set(['x']))is"{}"for any Set, so it couldn't see the edit either way.detachedCopynow rebuilds Set/Map (values recursively; a Map's keys stay by reference, so a Map keyed by object identity still resolves.get()) and Date too;isDirtycompares bystableKeyinstead ofJSON.stringify, which also makes property order a non- difference. Class instances are still kept by reference — there is no safe generic clone for one — documented as policy, not a gap.- A late answer's "is this still mine to store?" check judged by the FETCHING INSTANCE's own current
dependsOn(), not by whether anything still showed the scope the fetch actually ran under. Two instances of oneresourceKeysharing a client under different scopes is documented as supported (dependsOn, "two shops compared side by side"); instance A moving itsdependsOnelsewhere while its own fetch for the old scope was still in flight made that fetch's answer "late" by A's own reckoning, even while instance B still showed that exact scope — the rows were dropped, but{ ids: [] }was cached as fresh under it, so B's next read silently resolved empty instead of asking the server.fetchSearchhad the same problem the other way: cut short by adependsOnchange, it rebuilt its page key to read the total AFTER the change, pairing its (correctly empty) answer with the NEW scope's total instead of0. An answer now belongs to the scope baked into its own running query's key (resourceKeys.ts'sscopeOf) — fixed for that query's whole life — and is storable as long as ANY instance still claims that scope (the existing live-scope registry,scopeRegistry.isLive), not just the fetching instance's own.fetchSearchnow builds its page key once and reads the total off that same key. - Only
getRecordfollowed a record fetched by an alternate key (fetchTarget(apiCall, 'my-slug')resolving{ id: 7 }) to the record it pointed at — every WRITE used the id as given instead:updateTarget(..., 'my-slug')created a second, id-less record; a failed update by slug deleted the pointer on rollback;deleteTarget(..., 'my-slug')removed only the pointer, leaving record7initemList;invalidateRecord(7)never reached a screen watching'my-slug'; and once record7was actually removed, the'my-slug'pointer stayed cached and fresh, servingundefinedwithout ever asking the server again. Every write and removal —editRecord,deleteRecord,updateTarget,deleteTarget, invalidation — now follows the pointer to the record it names, and reaches every OTHER pointer to that same record too, through one place (queryRecordStore.ts'sresolve/keyOf,resourceKeys.ts's newrefersTopredicate). A server-returned record — a read, an update response, or a create response — is stored under one rule (storeServerRecord): always under its own id, with the address it was requested by, if different, becoming a pointer instead of a second, divergent copy.isSaving(id)andupdateTarget/deleteTarget's own mutation key now resolveidfirst too, soisSaving('my- slug')andisSaving(7)agree. Known limit, documented: an address never fetched before cannot be resolved ahead of the server's answer — the very firstupdateTarget(..., 'my-slug')with no prior fetch by that slug applies its optimistic patch under'my-slug'itself; the update's own response then resolves it into a proper pointer. - The "a read never overwrites a record a newer mutation has touched" guarantee had three holes, all traced to
src/internal/writeGuard.ts: a private, per-useStructureRestApi-INSTANCE map no other instance of the same resource (sharing the same client) could see; keyed by the id AS GIVEN ('1'from a route param,1fromcreateIdentifier, treated as different records); and bypassed outright bytargetQueryFunction, which — when the guard blocked its write — still RESOLVED with the raw fetched item, and TanStack wrote that return value back under the record's key regardless, so a read landing while a delete was in flight could bring the deleted record back. A related gap (R10): withapplyResponse: falseor an ack-only response, a read landing mid-update re-stamped the still-unconfirmed optimistic patch as freshly fetched.writeGuard.tsis deleted outright. "Is recordidbeing changed?" is now asked of TanStack's ownMutationCache(src/internal/recordMutations.ts) — the exact questionisSavingalready asks — so every instance sharing a client sees every mutation, ids are always compared as strings, and there is nothing left to prune by hand (a settled mutation leaves the cache on its owngcTime, same as everything else).updateTarget/deleteTarget's optimistic protocol now runs directly on TanStack's own mutation lifecycle (onMutate/onSuccess/onError/onSettled) instead of a hand-chained.then(): TanStack marks a mutationpending— visible to every read's own check — beforeonMutate(which cancels the record's read, snapshots and applies the change) even starts, closing the window where a read could begin unaware of it. A blocked read now cancels its OWN query instead of resolving with its stale answer; TanStack then discards whatever it would have written and reverts to what is already cached, with that entry's ORIGINAL timestamp — fixing both the resurrection and R10 in the same change. The public API, and the documented mutation key layout ([resourceKey, 'update' | 'delete', id]), are unchanged. - A
maxRecordswipe spared a query with its OWN active observer, but not what that query merely REFERENCED: a watched list's own entry survived, but its rows are separatetargetentries with no observer of their own, so the wipe dropped them anyway — the list stayed cached and fresh, pointing at ids that resolved to nothing, with nothing telling it to refetch. A watched alternate key (watchTarget(get, () => 'my-slug')) had the same problem the other way: the pointer entry survived (it has the observer), the record it pointed at did not.enforceMaxRecordsnow collects what is on screen straight from the cache before it runs — a watched list's ownids, and a watched alias'saliasOf— and spares those records too, on top of directly-watched ones. stableKey(used for every internal cache key: search filters,dependsOnsnapshots) turned any Set or Map into{}for the same reason (canonicalizewalks a value throughObject.keys(), which is[]for both). Two searches whose filters held different Sets shared ONE cache entry.stableKeynow expands a Set/Map into a tagged, order-independent shape first (sorted by each item's ownstableKey, so insertion order is never a difference, and a Set never collides with a plain array of the same items) — a local pre-pass ahead of@guebbit/js-toolkit's owncanonicalizehandling this natively.watchTargetwith no id andwatchByParentwith no parent id sat on the placeholder key[resourceKey, 'any', dependsOn, 'idle']— exactly the keyfetchAny/watchAnybuild forkey: ['idle'], so such a fetch settled the idle watcher (onSuccesswith nothing to show). The placeholder is now a kind of its own,[resourceKey, 'idle', dependsOn], which no caller key can reach.watchByParent'srefetch()with a nullish parent id calledapiCallwith that nullish id: TanStack'srefetch()runs a query even while it is disabled. It now resolves[]without fetching, likewatchTarget's already did with no id. A watcher disabled through its ownenabled: falsestill fetches onrefetch(), as TanStack's on-demand pattern expects.queryOptions(on the resource, or on a watcher) was spread intouseQueryas-is, so any key it carried beyond the five documented ones — from a JS caller, or a widened object — overrode the engine's own setting:gcTime: 0dropped a watched record the moment its watcher stopped. Onlyretry,retryDelay,refetchInterval,refetchOnWindowFocusandrefetchOnReconnectare passed on now; anything else is ignored.watchSearch(apiCall, { forced: true })'ssearch()counted a cached page as fresh anyway:search()'s ownforcedargument defaults tofalse, and that default replaced the watcher's setting. The argument now only adds forcing to one search; it never lifts the watcher's.useStructureDataManagement's default (local) relation store compared child ids with===, while the REST store compares them as strings:removeFromParent('p', '1')(a route param) never unlinked a linked1, andaddToParentlinked the same child again on every call, thoughIRelationStorepromises a no-op for a child already linked. Both stores now compare child ids the way the record dictionary keys them —1and'1'are the same child, a symbol only matches itself — for linking, unlinking andremoveDuplicateChildrenalike.useStructureFormValidationread its error records through the prototype chain: a field named like anObject.prototypemember (constructor,toString,valueOf) looked present before anything was stored under it.validate()threw on a failing field namedconstructor, andapplyServerErrorsthrew on a server message for any such name — for a field the form does not have (which belongs informLevelErrors) as well as for one it does. Both now read own keys only.package.json'slicensefield was"AGPLv3.0", which is not a valid SPDX expression: npm warns on it and license scanners cannot identify it. It is now"AGPL-3.0-only", matching the bundledLICENSE.
4.0.0
Changed
- BREAKING —
useCoreStore'sisLoadingis a function taking optional key prefixes, not a computed.isLoading()still answers "is anything loading at all";isLoading(['account', 'cart'])answers only for the keys starting with one of those. The old computed collapsed an already-granular dictionary back to one bit before any UI saw it, so a background poll lit the same indicator as a user's save. Replacecore.isLoadingwithcore.isLoading(), and note it no longer comes out ofstoreToRefs— destructure it from the store and call it inside acomputed. loadingsis typedRecord<string, boolean>:Object.entriesnever returned thesymbolhalf, so a symbol key could be set and then never matched by anything reading the dictionary.
3.1.0
Added
- Added
useAsyncAction: loading/data/error state for ONE async call, and the wrapper that drives it. The counterpart to theuseStructure*family rather than a competitor — those are about records (identified, cached, mutated), this is about a one-shot call whose answer is just a payload.fetchAnyalready covered the loading half but exposes neitherdatanorerror, which is what left a dashboard writing the same three-ref block once per endpoint. It never rejects: a failure lands inerrorand the promise still resolves, so one dead endpoint renders one error panel instead of blanking the page. That makes it a composable for READS — a write whose outcome the user asked for should reject and let the view answer with the toast it already writes. Only the newestrunmay write, so a double-clicked Refresh cannot overwrite a fresh answer with a stale one, nor clearloadingwhile its successor is still in flight. - Added
useLivenessProbe: a connectivity banner's whole state in one boolean. Probes once on creation, again on every browseronlineevent, then retries slowly and ONLY while down — a reachable target is never polled twice. The retry chain has exactly one owner, which is the failure this exists to prevent: twoonlineevents while down otherwise leave two independent loops running, each spawning its own successor forever, with teardown able to cancel only the last one scheduled. What is being probed is the caller's business — anything that rejects when unreachable will do. Stops with the surrounding effect scope;stop()is idempotent and a probe that outlives teardown can no longer write todown. - Exported
IAsyncAction,IAsyncActionSettings,TErrorResolver,ILivenessProbeandILivenessProbeSettings. - Added documentation pages for
useAsyncActionanduseLivenessProbe.
Changed
- BREAKING for anyone pinning it —
@guebbit/js-toolkitmoved from^1.2.1to^2.2.0.useAsyncAction's default error resolver is its newextractErrorMessage, which is what makes a plain-object rejection work: an interceptor that normalises failures rejects with an object literal rather than anError, and anything testinginstanceof Errorreads a real API refusal as "nothing usable". Nothing else in the package changed behaviour.
3.0.0
Removed
- BREAKING — removed
dialogsfromuseNotificationsStore. It was a bareRecord<string, boolean>with no actions of its own, so it carried no behaviour worth sharing: dialog visibility is an application concern and belongs in a consumer's own store. Replacenotifications.dialogs[name]with a localref({} as Record<string, boolean>).
Added
- Added
useStructureCrudApi, the highest-level entry point: a whole resource — list, filtered search, read, create, update, delete — declared as the API calls that reach it. It isuseStructureSearchApiwith the wiring done, and spreads everything that composable returns, so it is a convenience and never a ceiling. Every operation (list,search,get,create,update,remove) is optional; a method whose operation was not supplied returns a rejection naming it. Owns thefiltersref, so the filters driving the search and the filters scopingpageItemListcannot be two different objects.optimisticPatchcovers the case where the update payload is not a partial record — a multipart form whose payload carries aFilethe record has no business holding. - Added
useUploadProgress: reactiveprogress/isUploadingfor one upload, plus atrackwrapper that returns to idle however the request ends. HTTP-client-agnostic — the client-specific part is a singlebuildOptionsfunction passed in, so the toolkit never names axios.track(send, { enabled: false })callssendwith no options at all, for the form whose file is optional. useStructureFormValidationnow ownsshowFormErrorsacross the whole submit flow:handleSubmitreveals on a validation failure, hides on success, and leaves it off when the handler itself throws (an API failure is not a statement about any field).- Added
revealErrors()touseStructureFormValidation: turnsshowFormErrorson, waits for the render, focuses the first invalid field, then callsonInvalid. The wait is why it is a method — fields only acquire their invalid markers once the flag has propagated, so focusing any earlier finds nothing. - Added
applyServerErrors(error, options?)touseStructureFormValidation: attaches the errors an API rejected a submit with to the fields they belong to. Finds them at the root, under.dataor under.response.data, and understands field maps,{ field, message }lists (includingname/param/pathandmsgspellings) and bare string lists. Merges rather than replaces, and returnsfalsewhen the rejection carried nothing displayable, so the caller knows it still owes the user a message. - Added
formElement,invalidFieldSelectorandonInvalidoptions touseStructureFormValidation. Focus targets[aria-invalid="true"]by default; omittingformElementmakesrevealErrorsa pure state change with no DOM access, for SSR and node tests. - Exported
IStructureFormValidation,IStructureCrudApiandIUploadProgressreturn-type aliases, plusDEFAULT_INVALID_FIELD_SELECTOR.
Changed
- Documented that
watchTargetselects the record it fetches whilefetchTargetdoes not, and why:watchTargetis bound to a screen's id, whereas a prefetch or background refresh throughfetchTargetmust not steal the selection from what the user is looking at.useStructureCrudApi'sfetchOneis the selecting variant. - Added documentation pages for
useStructureSearchApi(previously undocumented despite being a core composable),useStructureCrudApianduseUploadProgress. handleSubmitis written as a promise chain rather thanasync/awaitwithtry/finally, matching the style of the rest of the package. Behaviour is unchanged: thePromise.resolve()the chain starts from is what keeps a synchronous throw inside the submit handler arriving at the caller as a rejection, exactly asawaitdid.src/no longer containsasync/awaitanywhere.
2.2.0
Added
useStructureFormValidationtakes a thirdoptionsargument with arevalidateOnoption: aWatchSource(or array of them) that re-runsvalidate()over the unchanged form data whenever it changes. It exists for language switches —formErrorsholds resolved strings, so an error already on screen cannot re-translate itself no matter how the schema is shaped, and only re-parsing produces the new wording. It fires only while errors are on display, so a pristine form is not validated just because the language changed. Deliberately generic: the toolkit stays i18n-agnostic and the same hook covers any other reason to re-validate.
Changed
- Documented that a schema whose messages are thunks (
error: () => t('…')) is resolved just as late as a getter schema, and is the safer of the two:toValue(schema)runs insidevalidate()and nowhere else, while a getter that is accidentally invoked at the call site type-checks and silently freezes the language.
2.1.3
Fixed
createIdentifier(inuseStructureDataManagement/useStructureRestApi) now honours a custom single identifier passed as its second argument. Previously the custom value was ignored for non-array identifiers and the default identifier was used instead, socreateIdentifier(item, 'slug')returneditem.idrather thanitem.slug. The array form was unaffected.
Added
- Added mutation testing with Stryker:
stryker.conf.jsonand atest:mutationnpm script. See the new Testing guide page for what mutation testing is and how to read the results. - Expanded the test suite with the gaps mutation testing surfaced: effect-stability tests (reactive re-evaluation of
selectedRecord/itemList/pagination computeds and the ref-countedloadingflag), effect-scope auto-teardown, direct unit tests forstableNormalizeandgenerateFallbackValue, identifier-resolution/batch-edit/guard branches ofuseStructureDataManagement, and notification auto-hide timeouts.
2.1.0
Added
- Added
useStructureSearchApi, a composable built on top ofuseStructureRestApithat owns everything filter/search-related:fetchSearch,watchSearch(andwatchCurrentSearch),checkSearch,searchGet,searchCleanup,resetSearches,isPageCached,isPaginateCached, and a search-scopedpageItemList. - Added
watchTargettouseStructureRestApi: watches an id source (Ref/ComputedRef/getter) and re-runsfetchTargetwhenever it changes, withonSuccess/onError/onSettledcallbacks. - Exported
stableNormalizefromsrc/utils/stableNormalize.tsas part of the public API (previously an internal helper of the REST composable).
Changed
- Search/filter concerns moved out of
useStructureRestApientirely:fetchSearch,searchGet,searchGetTotal,searchSetTotal,searchCleanup,searchCached,searchTotals, andstableNormalizeare no longer part ofuseStructureRestApi— useuseStructureSearchApiinstead. fetchPaginateonuseStructureRestApiis now a generic, filter-agnostic paginated fetch keyed strictly by(lastUpdateKey, pageSize, page). It no longer acceptsfilters, no longer supports the[items, total]tuple response shape, and no longer manages a total — a search/pagination layer built on top (likeuseStructureSearchApi) derives its own key from filters and reads any server-reported total out of its own response.resetAll/destroyonuseStructureRestApino longer reset search state (there is none left to reset). Consumers usinguseStructureSearchApiget mergedresetAll/destroythat also clear itssearchCached.
Removed
- Removed
fetchSearch,SearchApiResult,ISearchCache,searchGet,searchGetTotal,searchSetTotal, andsearchCleanupfromuseStructureRestApi(superseded byuseStructureSearchApi).
2.0.1
Added
- Added
setInitialDatatouseStructureFormValidation, to replace the reset baseline thatresetForm/isDirtyuse. - Added
activateAutoHydratetouseStructureFormValidation: watches a reactive source (e.g. a fetched record) and, once it resolves to a defined value, adopts it as the new reset baseline and applies it to the form. - Exposed
showFormErrorsfromuseStructureFormValidation.
2.0.0
Added
- Added a richer public API around
useStructureRestApi, including:fetchPaginate- pre-flight cache/freshness helpers:
checkTarget,checkAll,checkByParent,checkAny,checkSearch,checkPaginate,checkMultiple - search total helpers:
searchGetTotal,searchSetTotal - lifecycle helpers:
destroy,resetSearches,resetAll - exposed
queryClient - exposed
maxRecords
- Added
generateFallbackValue()export. - Added
lastInsertedIdentifier,lastInsertedIdentifiers, andlastInsertedRecordto data-management/rest composables. - Added complete documentation site pages for composables and stores.
- Added CI, docs deployment, commitlint, and husky-based repository automation.
Changed
useStructureRestApiwas substantially reworked.- TanStack Query is now the core cache/freshness engine behind the REST composable, replacing the older internal TTL bookkeeping approach.
- Caching behavior is now more robust and explicit:
- per-query freshness is handled through a query client
- search/page caches are better namespaced
- per-item cache seeding is shared across list/search/detail/mutation flows
fetchSearchnow supports both:- plain item arrays
- tuple responses in the form
[items, total]
searchGetnow takespageSizeinto account, making search caches page-size-aware.fetchPaginateis now the dedicated pagination-oriented API instead of relying on customfetchAllpatterns.- Root exports now use
export *, making more types/helpers part of the public surface. - Notifications and identifier generation now use a safer fallback id generator rather than assuming
crypto.randomUUID()is always available. - Loading management is now ref-counted, making concurrent requests safer.
- Tooling/scripts were modernized:
- package version bumped to
2.0 - prettier changed to check mode by default
- new
complete:checkscript - husky setup moved to
prepare
- package version bumped to
- Documentation and README were rewritten to reflect the actual library direction and usage.
Improved
- Optimistic update/delete/create flows are more reliable, including rollback behavior.
- Cache invalidation and stale handling are more coherent across list/detail/search flows.
- Search key generation is now stable even for nested objects.
- Parent/child relation handling and record lifecycle behavior are more explicit.
- Test coverage was heavily expanded and reorganized around:
- unit behavior
- TTL/freshness behavior
- pagination
- search
- lifecycle
- modifier semantics
- end-to-end intent scenarios
Notes
- This release is not just an internal refactor: the public API evolved slightly, especially around pagination, search totals, cache inspection, and lifecycle control.
- The biggest architectural shift is that TanStack became the backbone of fetch freshness, deduplication, and cache coordination, avoiding more custom wheel-reinventing in the data layer.