diff --git a/.changeset/big-integer-search-params.md b/.changeset/big-integer-search-params.md new file mode 100644 index 0000000000..bdc1ba40c1 --- /dev/null +++ b/.changeset/big-integer-search-params.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +fix: preserve integer search param literals beyond Number.MAX_SAFE_INTEGER as strings — JSON.parse silently rounds them (e.g. an 18-digit ad id `120247103250460643` becomes `120247103250460640`), corrupting the value on any redirect or link re-serialization diff --git a/packages/router-core/src/searchParams.ts b/packages/router-core/src/searchParams.ts index 740d36441c..a33d12a903 100644 --- a/packages/router-core/src/searchParams.ts +++ b/packages/router-core/src/searchParams.ts @@ -1,12 +1,30 @@ import { decode, encode } from './qss' import type { AnySchema } from './validators' +const integerLiteralRegex = /^-?\d+$/ + +/** + * `JSON.parse`, except integer literals beyond `Number.MAX_SAFE_INTEGER` are + * rejected (kept as strings by the callers below): parsing them to a float64 + * silently rounds the value (e.g. "120247103250460643" becomes + * 120247103250460640), so any re-serialization corrupts the original — a + * common hazard with 18-digit ad/click ids in marketing URLs. + */ +function parseJsonPreservingUnsafeIntegers(str: string): any { + if (integerLiteralRegex.test(str) && !Number.isSafeInteger(Number(str))) { + throw new Error('Integer literal exceeds safe integer range') + } + return JSON.parse(str) +} + /** Default `parseSearch` that strips leading '?' and JSON-parses values. */ -export const defaultParseSearch = parseSearchWith(JSON.parse) +export const defaultParseSearch = parseSearchWith( + parseJsonPreservingUnsafeIntegers, +) /** Default `stringifySearch` using JSON.stringify for complex values. */ export const defaultStringifySearch = stringifySearchWith( JSON.stringify, - JSON.parse, + parseJsonPreservingUnsafeIntegers, ) /** diff --git a/packages/router-core/tests/searchParams.test.ts b/packages/router-core/tests/searchParams.test.ts index 006e119be5..3133c345ef 100644 --- a/packages/router-core/tests/searchParams.test.ts +++ b/packages/router-core/tests/searchParams.test.ts @@ -99,3 +99,29 @@ describe('Search Params serialization and deserialization', () => { ) }) }) + +describe('Unsafe integer literals', () => { + /* + * Integer literals beyond Number.MAX_SAFE_INTEGER cannot survive a trip + * through float64 — JSON.parse would silently round them (e.g. an 18-digit + * ad id "120247103250460643" becomes 120247103250460640). They are kept as + * strings so they round-trip losslessly. + */ + test.each([ + ['120247103250460643'], + ['-120247103250460643'], + ['9007199254740993'], + ])('unsafe integer literal %s is preserved as a string', (literal) => { + const parsed = defaultParseSearch(`?foo=${literal}`) + expect(parsed).toEqual({ foo: literal }) + const restringified = defaultStringifySearch(parsed) + expect(defaultParseSearch(restringified)).toEqual({ foo: literal }) + }) + + test('safe integer literals still parse as numbers', () => { + expect(defaultParseSearch('?foo=123')).toEqual({ foo: 123 }) + expect(defaultParseSearch(`?foo=${Number.MAX_SAFE_INTEGER}`)).toEqual({ + foo: Number.MAX_SAFE_INTEGER, + }) + }) +})