Skip to content

fix: don't throw on invalid Dates when serializing the cache - #209

Merged
dborysov merged 1 commit into
DeeCode-inc:mainfrom
cescoferraro-savant:fix/invalid-date-serialization
Aug 5, 2026
Merged

fix: don't throw on invalid Dates when serializing the cache#209
dborysov merged 1 commit into
DeeCode-inc:mainfrom
cescoferraro-savant:fix/invalid-date-serialization

Conversation

@cescoferraro-savant

Copy link
Copy Markdown
Contributor

Summary

An invalid Date is still instanceof Date, but its time value is NaN and toISOString() throws RangeError: Invalid time value on it. Every Date branch in the serialization path called toISOString() unguarded, so a single invalid Date anywhere in the inspected cache made serialization throw.

The important part is where that exception lands. sync.ts is injected with "world": "MAIN", so it subscribes to the page's real QueryCache. QueryCache.notify iterates its subscribers without catching, so the RangeError unwinds out of the inspected application's own React commit phase and trips its error boundary.

In other words: the devtools crash the app they are inspecting, and the app's stack trace points at whichever component happened to unmount, with no hint that an extension is involved. That took a while to track down on our side:

RangeError: Invalid time value
    at Date.toISOString (<anonymous>)
    at m (chrome-extension://annajfchloimdhceglpgglpeepfghfai/content-scripts/sync.js:1:746)
    at A (chrome-extension://annajfchloimdhceglpgglpeepfghfai/content-scripts/sync.js:1:4935)
    at ...
    at Set.forEach (<anonymous>)
    at Object.batch
    at QueryCache.notify
    at Query.removeObserver

It is also sticky — the bad entry stays in the cache, so every subsequent notify throws again until the QueryClient is torn down (for us, a logout/login).

Changes

Adds a small guard used by all four Date sites, rather than fixing only the one that crashed:

  • utils/serialization.ts:62encodeBigInts (the deep walker; this is the one that crashed)
  • utils/serialization.ts:244prepareForStringify
  • utils/serialization.ts:316serializeToJsLiteral
  • components/TreeView.tsx:269 — panel rendering, which would throw for the same reason

Invalid Dates encode as the wire value "Invalid Date", chosen because new Date("Invalid Date") decodes back to an equally invalid Date — so the existing case "date" decoder needs no change and the round-trip stays faithful. Export emits new Date(NaN).

Verification

npm run compile and npm run lint both clean. npm run prettier:check reports the same 50 pre-existing files before and after, so I left formatting alone rather than mixing an unrelated reformat into this diff.

The repo has no test runner, so I verified out-of-tree against these functions directly, with a QueryCache-shaped payload holding one invalid and one valid Date:

sanity: raw toISOString on an invalid Date
  confirmed baseline throw -> RangeError: Invalid time value

encode / decode round-trip
  PASS  encodeBigInts does not throw
  invalid encoded as: {"__tqcd_type":"date","value":"Invalid Date"}
  valid   encoded as: {"__tqcd_type":"date","value":"2026-04-07T12:00:00.000Z"}
  PASS  invalid Date decodes back to a Date instance
  PASS  invalid Date is still invalid after round-trip
  PASS  valid Date survives round-trip exactly

other serializers
  PASS  stringifyWithBigInt does not throw
  PASS  serializeToJsLiteral does not throw
  js literal for invalid: new Date(NaN)
  js literal for valid:   new Date("2026-04-07T12:00:00.000Z")
  PASS  emitted literal for invalid Date evals to an invalid Date

Against unmodified main the first check fails with exactly the reported RangeError: Invalid time value.

Happy to reshape any of this — including dropping the exported helpers for a plain inline guard, or emitting null instead of "Invalid Date" — if you'd prefer a different convention.

An invalid Date is still `instanceof Date`, but its time value is NaN and
toISOString() throws RangeError: Invalid time value on it. Every Date branch
in serialization called toISOString() unguarded, so a single invalid Date
anywhere in the inspected cache made serialization throw.

sync.ts runs in the page's MAIN world as a QueryCache subscriber, and
QueryCache.notify iterates subscribers without catching, so the RangeError
unwound out of the inspected application's own React commit phase and tripped
its error boundary — the devtools crashed the app it was inspecting.

Invalid Dates now encode as "Invalid Date", which decodes back to an equally
invalid Date, and export emits new Date(NaN).

@dborysov dborysov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch, and thanks for the detective work here — the stack trace and write-up made this a really easy review. Merging as-is.

On your two questions: keep the helpers, they're much nicer than repeating the same check in four places. And stick with "Invalid Date" — the clean round-trip is worth more than a tidier wire value.

I'll follow up with a small change to make the sync script more defensive in general, since invalid dates turned out not to be the only thing that can trip it up. Nothing needed from you — thanks again for the thorough fix!

I will submit the new version somewhere today or tomorrow

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants