From fb4a731832471c22ec5160520ed6a4a923663e7f Mon Sep 17 00:00:00 2001 From: Dion Whitehead <260221+dionjwa@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:03:18 -0700 Subject: [PATCH] fromHashString methods since some browser crash on large URLs --- README.md | 23 +++++--- llms.txt | 18 ++++-- package-lock.json | 4 +- package.json | 2 +- src/core/index.ts | 71 +++++++++++++++++++++++ test/from_hash_string_test.ts | 106 ++++++++++++++++++++++++++++++++++ 6 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 test/from_hash_string_test.ts diff --git a/README.md b/README.md index 2abd4e4..d82562c 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,15 @@ All functions are exported from `@metapages/hash-query`. Naming is systematic: - `...FromWindow` / `...InWindow` — read/write `window.location` directly. - `...FromUrl` / `...InUrl` — operate on a URL string or `URL` object, returning a new URL. Nothing is mutated in the browser. -- `...InHashString` — operate on a bare hash string, for full manual control. +- `...InHashString` / `...FromHashString` — operate on a bare hash string, for + full manual control. + +**Reading a large value? Use the `FromHashString` getter.** The `FromUrl` +getters call `new URL()`, and Firefox's url parser hard-caps at 1 MiB +(`network.standard-url.max-length`) — past it the constructor *throws*, where +Chrome and Safari parse on. Building a url out of every param just to read one +of them therefore crashes on an oversize *neighbouring* param, in one browser +only. The `FromHashString` getters never construct a URL and have no such limit. ### Core @@ -214,6 +222,7 @@ All functions are exported from `@metapages/hash-query`. Naming is systematic: getUrlHashParams(url) -> [preHashValue, Record] getUrlHashParamsFromHashString(hash) -> [preHashValue, Record] getHashParamValue(url, key) -> string | undefined +getHashParamValueFromHashString(hash, key) -> string | undefined getHashParamFromWindow(key) -> string | undefined getHashParamsFromWindow() -> [preHashValue, Record] setHashParamInWindow(key, value, opts?) @@ -228,16 +237,16 @@ deleteHashParamFromUrl(url, key) -> URL ### Typed accessors -Each type has the same four functions: +Each type has the same set of functions: | Type | Functions | | --- | --- | | JSON | `setHashParamValueJsonInUrl`, `getHashParamValueJsonFromUrl`, `setHashParamValueJsonInWindow`, `getHashParamValueJsonFromWindow`, `setHashParamValueJsonInHashString`, `getHashParamValueJsonFromHashString` | -| Float | `setHashParamValueFloatInUrl`, `getHashParamValueFloatFromUrl`, `setHashParamValueFloatInWindow`, `getHashParamValueFloatFromWindow` | -| Integer | `setHashParamValueIntInUrl`, `getHashParamValueIntFromUrl`, `setHashParamValueIntInWindow`, `getHashParamValueIntFromWindow` | -| Boolean | `setHashParamValueBooleanInUrl`, `getHashParamValueBooleanFromUrl`, `setHashParamValueBooleanInWindow`, `getHashParamValueBooleanFromWindow` | -| Base64 | `setHashParamValueBase64EncodedInUrl`, `getHashParamValueBase64DecodedFromUrl`, `setHashParamValueBase64EncodedInWindow`, `getHashParamValueBase64DecodedFromWindow` | -| URI-encoded | `setHashParamValueUriEncodedInUrl`, `getHashParamValueUriDecodedFromUrl`, `setHashParamValueUriEncodedInWindow`, `getHashParamValueUriDecodedFromWindow` | +| Float | `setHashParamValueFloatInUrl`, `getHashParamValueFloatFromUrl`, `setHashParamValueFloatInWindow`, `getHashParamValueFloatFromWindow`, `getHashParamValueFloatFromHashString` | +| Integer | `setHashParamValueIntInUrl`, `getHashParamValueIntFromUrl`, `setHashParamValueIntInWindow`, `getHashParamValueIntFromWindow`, `getHashParamValueIntFromHashString` | +| Boolean | `setHashParamValueBooleanInUrl`, `getHashParamValueBooleanFromUrl`, `setHashParamValueBooleanInWindow`, `getHashParamValueBooleanFromWindow`, `getHashParamValueBooleanFromHashString` | +| Base64 | `setHashParamValueBase64EncodedInUrl`, `getHashParamValueBase64DecodedFromUrl`, `setHashParamValueBase64EncodedInWindow`, `getHashParamValueBase64DecodedFromWindow`, `getHashParamValueBase64DecodedFromHashString` | +| URI-encoded | `setHashParamValueUriEncodedInUrl`, `getHashParamValueUriDecodedFromUrl`, `setHashParamValueUriEncodedInWindow`, `getHashParamValueUriDecodedFromWindow`, `getHashParamValueUriDecodedFromHashString` | ### Encoding helpers diff --git a/llms.txt b/llms.txt index ce044b4..96ec8ab 100644 --- a/llms.txt +++ b/llms.txt @@ -92,6 +92,7 @@ Entry points: ## Core functions getUrlHashParams, getUrlHashParamsFromHashString, getHashParamValue, +getHashParamValueFromHashString, getHashParamFromWindow, getHashParamsFromWindow, setHashParamInWindow, setHashParamValueInHashString, setHashParamValueInUrl, setHashParamsInUrl, deleteHashParamFromWindow, deleteHashParamFromUrl @@ -99,10 +100,19 @@ deleteHashParamFromWindow, deleteHashParamFromUrl ## Typed accessors For each of JSON, Float, Int, Boolean, Base64Encoded and UriEncoded there are -`InUrl` / `FromUrl` / `InWindow` / `FromWindow` variants, e.g. -setHashParamValueJsonInWindow, getHashParamValueJsonFromWindow, -setHashParamValueFloatInUrl, getHashParamValueBooleanFromWindow, -setHashParamValueBase64EncodedInWindow, getHashParamValueUriDecodedFromUrl. +`InUrl` / `FromUrl` / `InWindow` / `FromWindow` variants, plus a +`FromHashString` getter, e.g. setHashParamValueJsonInWindow, +getHashParamValueJsonFromWindow, setHashParamValueFloatInUrl, +getHashParamValueBooleanFromWindow, setHashParamValueBase64EncodedInWindow, +getHashParamValueUriDecodedFromUrl, +getHashParamValueBase64DecodedFromHashString. + +Reading a LARGE value: use the `FromHashString` getter. The `FromUrl` getters +call `new URL()`, and Firefox's url parser hard-caps at 1 MiB +(`network.standard-url.max-length`) — past it the constructor THROWS, where +Chrome and Safari parse on. So reading one param by building a url out of all +of them crashes on an oversize NEIGHBOURING param, in Firefox only. The +`FromHashString` getters construct no URL and have no such limit. Encoding helpers: blobToBase64String, blobFromBase64String, stringToBase64String, stringFromBase64String. diff --git a/package-lock.json b/package-lock.json index 447479c..8bdd9cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@metapages/hash-query", - "version": "0.10.3", + "version": "0.11.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@metapages/hash-query", - "version": "0.10.3", + "version": "0.11.0", "dependencies": { "fast-json-stable-stringify": "^2.1.0" }, diff --git a/package.json b/package.json index 32c7959..f42ffa6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metapages/hash-query", - "version": "0.10.3", + "version": "0.11.0", "description": "Store state in the URL hash: typed get/set of hash query params (JSON, base64, boolean, number) + React hooks. Shareable, bookmarkable URL state, no server or database required.", "license": "MIT", "author": "metapages", diff --git a/src/core/index.ts b/src/core/index.ts index 79d3196..1d93cf1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -90,6 +90,27 @@ export const getHashParamValue = ( return hashParams[key]; }; +/** + * The `*FromHashString` getters below mirror their `*FromUrl` siblings but read + * a bare hash string, never a URL. + * + * Reach for these whenever the params are already in hand as a string, and + * ALWAYS when a value may be large. The `*FromUrl` getters call `new URL()`, + * and Firefox's url parser hard-caps at 1 MiB + * (`network.standard-url.max-length`) — past it the constructor THROWS + * (`TypeError: URL constructor: … is not a valid URL`) where Chrome and Safari + * happily parse on. So synthesizing a url out of every param in order to read + * one of them turns an oversize *neighbouring* param into a crash, in one + * browser only. Reading the hash string directly cannot hit that limit at all. + */ +export const getHashParamValueFromHashString = ( + hash: string, + key: string +): string | undefined => { + const [_, hashParams] = getUrlHashParamsFromHashString(hash); + return hashParams[key]; +}; + const isBrowser = (): boolean => { return typeof window !== "undefined" && typeof globalThis.location !== "undefined"; }; @@ -385,6 +406,14 @@ export const getHashParamValueFloatFromUrl = ( return hashParamString ? parseFloat(hashParamString) : undefined; }; +export const getHashParamValueFloatFromHashString = ( + hash: string, + key: string +): number | undefined => { + const hashParamString = getHashParamValueFromHashString(hash, key); + return hashParamString ? parseFloat(hashParamString) : undefined; +}; + export const setHashParamValueFloatInWindow = ( key: string, value: number | undefined, @@ -428,6 +457,14 @@ export const getHashParamValueIntFromUrl = ( return hashParamString ? parseInt(hashParamString) : undefined; }; +export const getHashParamValueIntFromHashString = ( + hash: string, + key: string +): number | undefined => { + const hashParamString = getHashParamValueFromHashString(hash, key); + return hashParamString ? parseInt(hashParamString) : undefined; +}; + export const setHashParamValueIntInWindow = ( key: string, value: number | undefined, @@ -463,6 +500,14 @@ export const getHashParamValueBooleanFromUrl = ( return hashParamString === "true" ? true : false; }; +export const getHashParamValueBooleanFromHashString = ( + hash: string, + key: string +): boolean | undefined => { + const hashParamString = getHashParamValueFromHashString(hash, key); + return hashParamString === "true" ? true : false; +}; + export const setHashParamValueBooleanInWindow = ( key: string, value: boolean | undefined, @@ -506,6 +551,22 @@ export const getHashParamValueBase64DecodedFromUrl = ( : undefined; }; +/** + * Prefer this over the `FromUrl` form for base64 values: they are the large + * ones (source, blobs, encoded documents), and building a url around them just + * to read one is what trips Firefox's 1 MiB url cap. See + * `getHashParamValueFromHashString`. + */ +export const getHashParamValueBase64DecodedFromHashString = ( + hash: string, + key: string +): string | undefined => { + const valueString = getHashParamValueFromHashString(hash, key); + return valueString && valueString !== "" + ? stringFromBase64String(valueString) + : undefined; +}; + export const setHashParamValueBase64EncodedInWindow = ( key: string, value: string | undefined, @@ -553,6 +614,16 @@ export const getHashParamValueUriDecodedFromUrl = ( : undefined; }; +export const getHashParamValueUriDecodedFromHashString = ( + hash: string, + key: string +): string | undefined => { + const valueString = getHashParamValueFromHashString(hash, key); + return valueString && valueString !== "" + ? decodeURIComponent(valueString) + : undefined; +}; + export const setHashParamValueUriEncodedInWindow = ( key: string, value: string | undefined, diff --git a/test/from_hash_string_test.ts b/test/from_hash_string_test.ts new file mode 100644 index 0000000..e794520 --- /dev/null +++ b/test/from_hash_string_test.ts @@ -0,0 +1,106 @@ +/// +import { assertEquals } from "@std/assert"; + +import { + getHashParamValueBase64DecodedFromHashString, + getHashParamValueBase64DecodedFromUrl, + getHashParamValueBooleanFromHashString, + getHashParamValueFloatFromHashString, + getHashParamValueFromHashString, + getHashParamValueIntFromHashString, + getHashParamValueUriDecodedFromHashString, + stringToBase64String, +} from "../src/core/index.ts"; + +// Each `*FromHashString` getter must agree with its `*FromUrl` sibling on +// ordinary input — they differ only in what they accept, never in what they +// decode. +Deno.test({ + name: "FromHashString getters decode every value type", + fn() { + const hash = [ + "#?raw=plain", + "num=1.5", + "count=42", + "flag=true", + `b64=${stringToBase64String("hello ✓")}`, + `uri=${encodeURIComponent("a&b=c")}`, + ].join("&"); + + assertEquals(getHashParamValueFromHashString(hash, "raw"), "plain"); + assertEquals(getHashParamValueFloatFromHashString(hash, "num"), 1.5); + assertEquals(getHashParamValueIntFromHashString(hash, "count"), 42); + assertEquals(getHashParamValueBooleanFromHashString(hash, "flag"), true); + assertEquals( + getHashParamValueBase64DecodedFromHashString(hash, "b64"), + "hello ✓", + ); + assertEquals( + getHashParamValueUriDecodedFromHashString(hash, "uri"), + "a&b=c", + ); + }, +}); + +Deno.test({ + name: "FromHashString getters return undefined for a missing key", + fn() { + const hash = "#?present=1"; + assertEquals(getHashParamValueFromHashString(hash, "absent"), undefined); + assertEquals( + getHashParamValueBase64DecodedFromHashString(hash, "absent"), + undefined, + ); + assertEquals( + getHashParamValueUriDecodedFromHashString(hash, "absent"), + undefined, + ); + }, +}); + +Deno.test({ + name: "FromHashString accepts a bare param string with no leading #? too", + fn() { + assertEquals(getHashParamValueFromHashString("?a=1&b=2", "b"), "2"); + assertEquals(getHashParamValueFromHashString("#?a=1&b=2", "b"), "2"); + }, +}); + +// The reason these getters exist. Firefox's url parser hard-caps at 1 MiB +// (`network.standard-url.max-length`) and `new URL()` THROWS past it, so +// reading one param by synthesizing a url out of all of them crashes on an +// oversize NEIGHBOUR — in Firefox only, which is how this reached production. +// Deno's parser has no such cap, so this cannot assert the throw; it asserts +// the property that makes the throw impossible: the hash-string getters read a +// multi-megabyte param set without ever constructing a URL. +Deno.test({ + name: "FromHashString reads a param set far past Firefox's 1 MiB url cap", + fn() { + const FIREFOX_MAX_URL_CHARS = 1024 * 1024; + const huge = stringToBase64String("x".repeat(2 * FIREFOX_MAX_URL_CHARS)); + const hash = `#?js=${huge}&css=${stringToBase64String("body{}")}`; + + if (hash.length <= FIREFOX_MAX_URL_CHARS) { + throw new Error("fixture is too small to exercise the limit"); + } + + // Reading the SMALL param out of a set dominated by a huge one is the exact + // shape that crashed: applyCssFromUrl read `css` and died on `js`. + assertEquals( + getHashParamValueBase64DecodedFromHashString(hash, "css"), + "body{}", + ); + assertEquals( + getHashParamValueBase64DecodedFromHashString(hash, "js")?.length, + 2 * FIREFOX_MAX_URL_CHARS, + ); + + // Same values, same decode, via a url — allowed here only because Deno's + // parser has no cap. Pins that the two forms agree. + const url = `https://example.com/${hash}`; + assertEquals( + getHashParamValueBase64DecodedFromUrl(url, "css"), + getHashParamValueBase64DecodedFromHashString(hash, "css"), + ); + }, +});