Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/preserve-legacy-json-percent-sequences.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/app': patch
---

Preserve literal percent sequences in legacy JSON URL parameters.
10 changes: 10 additions & 0 deletions packages/app/src/utils/__tests__/queryParsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
24 changes: 8 additions & 16 deletions packages/app/src/utils/queryParsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,9 @@ export const parseAsStringEncoded = createParser<string>({
* 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
Expand Down Expand Up @@ -67,22 +66,15 @@ export function parseAsJsonEncoded<T>(validate?: (value: unknown) => T) {

return createParser<T>({
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;
}
Expand Down
Loading