diff --git a/.changeset/preserve-legacy-json-percent-sequences.md b/.changeset/preserve-legacy-json-percent-sequences.md new file mode 100644 index 0000000000..4d5b6a7197 --- /dev/null +++ b/.changeset/preserve-legacy-json-percent-sequences.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +Preserve literal percent sequences in legacy JSON URL parameters. diff --git a/packages/app/src/utils/__tests__/queryParsers.test.ts b/packages/app/src/utils/__tests__/queryParsers.test.ts index ff1bf66873..9a0e7529dc 100644 --- a/packages/app/src/utils/__tests__/queryParsers.test.ts +++ b/packages/app/src/utils/__tests__/queryParsers.test.ts @@ -69,6 +69,16 @@ describe('parseAsJsonEncoded', () => { expect(jsonParser.parse(raw)).toEqual([{ key: 'hello world' }]); }); + it.each(['%2F', '%20', '%25'])( + 'preserves literal %s text in legacy JSON', + sequence => { + const raw = JSON.stringify([{ query: `path${sequence}segment` }]); + expect(jsonParser.parse(raw)).toEqual([ + { query: `path${sequence}segment` }, + ]); + }, + ); + it('returns null for malformed JSON after successful URI decode', () => { // A valid percent-sequence that decodes to something that is not JSON. expect(jsonParser.parse('not-json')).toBeNull(); diff --git a/packages/app/src/utils/queryParsers.ts b/packages/app/src/utils/queryParsers.ts index 5e165eda2c..b3b6b83705 100644 --- a/packages/app/src/utils/queryParsers.ts +++ b/packages/app/src/utils/queryParsers.ts @@ -36,10 +36,9 @@ export const parseAsStringEncoded = createParser({ * Same double-encoding protection as parseAsStringEncoded, but wraps * JSON.stringify / JSON.parse around the value. * - * Backward compatible: old URLs where nuqs wrote raw JSON (with '+' for - * spaces, unencoded '[', ']', etc.) are handled via a fallback to plain - * JSON.parse after the decodeURIComponent step naturally resolves '%22' → - * '"', '+' → ' ', etc. via URLSearchParams.get(). + * Backward compatible: old URLs where nuqs wrote raw JSON are parsed before + * URI decoding, so literal '%XX' text inside JSON strings is preserved. New + * URLs are decoded before JSON.parse so their encoded structure still works. * * Optional `validate`: a guard that either returns the (narrowed) value or * throws / returns `null` for a shape mismatch. A stale, hand-edited, or @@ -67,22 +66,15 @@ export function parseAsJsonEncoded(validate?: (value: unknown) => T) { return createParser({ parse: value => { - let decoded: string; try { - decoded = decodeURIComponent(value); + return finalize(JSON.parse(value)); } catch { - // Malformed URI sequence — value is likely old-format plain JSON. - try { - return finalize(JSON.parse(value)); - } catch { - return null; - } + // New-format values are percent-encoded, so they cannot be parsed as + // raw JSON. Fall through to the decoded representation. } - // URI decoded successfully; parse the decoded string as JSON. - // This handles both new-format (double-encoded) and old-format URLs, - // since decodeURIComponent is a no-op on plain JSON strings. + try { - return finalize(JSON.parse(decoded)); + return finalize(JSON.parse(decodeURIComponent(value))); } catch { return null; }