diff --git a/app/benchmark/utils/useSearchParamsState.test.ts b/app/benchmark/utils/useSearchParamsState.test.ts new file mode 100644 index 0000000..d78fffd --- /dev/null +++ b/app/benchmark/utils/useSearchParamsState.test.ts @@ -0,0 +1,17 @@ +import { + decodeSearchParamState, + encodeSearchParamState, +} from "./useSearchParamsState"; + +describe("search param state codec", () => { + it("round trips unicode values through base64 JSON", () => { + const value = { + label: "Ethereum - Base — Sepolia", + params: { + quote: "smart “wallet”", + }, + }; + + expect(decodeSearchParamState(encodeSearchParamState(value))).toEqual(value); + }); +}); diff --git a/app/benchmark/utils/useSearchParamsState.ts b/app/benchmark/utils/useSearchParamsState.ts index faf0794..356bbfa 100644 --- a/app/benchmark/utils/useSearchParamsState.ts +++ b/app/benchmark/utils/useSearchParamsState.ts @@ -1,6 +1,25 @@ import { useCallback, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +export function encodeSearchParamState(value: unknown): string { + const jsonString = JSON.stringify(value); + const bytes = new TextEncoder().encode(jsonString); + let binaryString = ""; + + for (const byte of bytes) { + binaryString += String.fromCharCode(byte); + } + + return btoa(binaryString); +} + +export function decodeSearchParamState(value: string): T { + const binaryString = atob(value); + const bytes = Uint8Array.from(binaryString, (char) => char.charCodeAt(0)); + const jsonString = new TextDecoder().decode(bytes); + return JSON.parse(jsonString) as T; +} + /** * Hook for storing state in URL search parameters as base64 encoded JSON * Supports multiple instances without conflicts @@ -27,8 +46,7 @@ export function useSearchParamsState( if (paramValue) { try { // Decode base64 back to JSON string and then parse - const jsonString = atob(paramValue); - return JSON.parse(jsonString) as T; + return decodeSearchParamState(paramValue); } catch (e) { console.error( `Error parsing state from URL parameter ${paramName}:`, @@ -52,7 +70,7 @@ export function useSearchParamsState( // keeps filter churn out of the back-button history, matching the // react-router original's `{ replace: true }`. const newParams = new URLSearchParams(searchParams.toString()); - newParams.set(paramName, btoa(JSON.stringify(newValue))); + newParams.set(paramName, encodeSearchParamState(newValue)); router.replace(`${pathname}?${newParams.toString()}`, { scroll: false, });