From a593587d81062606fc12201035ad28f1c666a3d8 Mon Sep 17 00:00:00 2001 From: Dion Whitehead <{ID} {username}@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:15:43 -0700 Subject: [PATCH] Add plain hash param event listeners --- README.md | 75 +++++++ package.json | 2 +- src/core/index.ts | 104 +++++++++ ...hash_param_event_listeners_browser_test.ts | 210 ++++++++++++++++++ 4 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 test/hash_param_event_listeners_browser_test.ts diff --git a/README.md b/README.md index 57ab63a..6e04446 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,25 @@ const jsonBlob = getHashParamValueJsonFromWindow("key"); setHashParamValueJsonInWindow("key", jsonBlob); ``` +Use plain JavaScript hash param listeners (non-React): + +```typescript +import { + addEventListenerHashParamJson, + setHashParamValueJsonInWindow, +} from "@metapages/hash-query"; + +const dispose = addEventListenerHashParamJson("key", (value) => { + // Fires once after a tick with current value, then on each hashchange. + console.log("hash param changed:", value); +}); + +setHashParamValueJsonInWindow("key", { foo: "bar" }); + +// Later: remove listener +dispose(); +``` + ## How it works @@ -62,12 +81,56 @@ useHashParamBoolean, useHashParamFloat, useHashParamInt, useHashParamJson, +useHashParamUriEncoded, } from "@metapages/hash-query/react-hooks"; ``` Usage is the same as the JSON example above (get/set value) +### Plain JavaScript listeners (typed): + +```typescript +import { + addEventListenerHashParamBase64, + addEventListenerHashParamBoolean, + addEventListenerHashParamFloat, + addEventListenerHashParamInt, + addEventListenerHashParamJson, + addEventListenerHashParamUriEncoded, +} from "@metapages/hash-query"; + +const cleanupBase64 = addEventListenerHashParamBase64("name", (value) => { + // value: string | undefined (decoded from base64) +}); +const cleanupBoolean = addEventListenerHashParamBoolean("enabled", (value) => { + // value: boolean | undefined +}); +const cleanupFloat = addEventListenerHashParamFloat("ratio", (value) => { + // value: number | undefined +}); +const cleanupInt = addEventListenerHashParamInt("count", (value) => { + // value: number | undefined +}); +const cleanupJson = addEventListenerHashParamJson<{ foo: string }>( + "blob", + (value) => { + // value: { foo: string } | undefined + } +); +const cleanupUri = addEventListenerHashParamUriEncoded("q", (value) => { + // value: string | undefined (decoded from URI encoding) +}); + +// Each listener fires once after a tick with current value, then on hashchange. +cleanupBase64(); +cleanupBoolean(); +cleanupFloat(); +cleanupInt(); +cleanupJson(); +cleanupUri(); +``` + ### Setting multiple hash parameters at once: ```typescript @@ -134,4 +197,16 @@ setHashParamValueBase64EncodedInUrl getHashParamValueBase64DecodedFromUrl setHashParamValueBase64EncodedInWindow getHashParamValueBase64DecodedFromWindow +# UriEncoded Functions +setHashParamValueUriEncodedInUrl +getHashParamValueUriDecodedFromUrl +setHashParamValueUriEncodedInWindow +getHashParamValueUriDecodedFromWindow +# Listener Functions +addEventListenerHashParamBase64 +addEventListenerHashParamBoolean +addEventListenerHashParamFloat +addEventListenerHashParamInt +addEventListenerHashParamJson +addEventListenerHashParamUriEncoded ``` diff --git a/package.json b/package.json index 4d31817..7925c73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metapages/hash-query", - "version": "0.9.12", + "version": "0.10.0", "repository": { "type": "git", "url": "git+https://github.com/metapages/hash-query.git" diff --git a/src/core/index.ts b/src/core/index.ts index cc83f29..79d3196 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -574,6 +574,110 @@ export const getHashParamValueUriDecodedFromWindow = ( return getHashParamValueUriDecodedFromUrl(globalThis.location.href, key); }; +type HashParamValueListener = (value: T | undefined) => void; + +const addEventListenerHashParam = ( + key: string, + getValue: (key: string) => T | undefined, + onHashParamChange: HashParamValueListener +): (() => void) => { + if (!isBrowser()) { + return () => {}; + } + + let disposed = false; + const onHashChange = (_: HashChangeEvent) => { + if (disposed) { + return; + } + onHashParamChange(getValue(key)); + }; + + globalThis.addEventListener("hashchange", onHashChange); + + // Defer initial call so consumers can subscribe and then process consistently. + setTimeout(() => { + if (disposed) { + return; + } + onHashParamChange(getValue(key)); + }, 0); + + return () => { + if (disposed) { + return; + } + disposed = true; + globalThis.removeEventListener("hashchange", onHashChange); + }; +}; + +export const addEventListenerHashParamBase64 = ( + key: string, + onHashParamChange: HashParamValueListener +): (() => void) => { + return addEventListenerHashParam( + key, + getHashParamValueBase64DecodedFromWindow, + onHashParamChange + ); +}; + +export const addEventListenerHashParamBoolean = ( + key: string, + onHashParamChange: HashParamValueListener +): (() => void) => { + return addEventListenerHashParam( + key, + getHashParamValueBooleanFromWindow, + onHashParamChange + ); +}; + +export const addEventListenerHashParamFloat = ( + key: string, + onHashParamChange: HashParamValueListener +): (() => void) => { + return addEventListenerHashParam( + key, + getHashParamValueFloatFromWindow, + onHashParamChange + ); +}; + +export const addEventListenerHashParamInt = ( + key: string, + onHashParamChange: HashParamValueListener +): (() => void) => { + return addEventListenerHashParam( + key, + getHashParamValueIntFromWindow, + onHashParamChange + ); +}; + +export const addEventListenerHashParamJson = ( + key: string, + onHashParamChange: HashParamValueListener +): (() => void) => { + return addEventListenerHashParam( + key, + getHashParamValueJsonFromWindow, + onHashParamChange + ); +}; + +export const addEventListenerHashParamUriEncoded = ( + key: string, + onHashParamChange: HashParamValueListener +): (() => void) => { + return addEventListenerHashParam( + key, + getHashParamValueUriDecodedFromWindow, + onHashParamChange + ); +}; + export const deleteHashParamFromWindow = ( key: string, opts?: SetHashParamOpts diff --git a/test/hash_param_event_listeners_browser_test.ts b/test/hash_param_event_listeners_browser_test.ts new file mode 100644 index 0000000..3ca047c --- /dev/null +++ b/test/hash_param_event_listeners_browser_test.ts @@ -0,0 +1,210 @@ +/// +import { assertEquals } from "@std/assert"; +import { delay } from "@std/async/delay"; + +import { + addEventListenerHashParamBase64, + addEventListenerHashParamBoolean, + addEventListenerHashParamFloat, + addEventListenerHashParamInt, + addEventListenerHashParamJson, + addEventListenerHashParamUriEncoded, + setHashParamValueBase64EncodedInWindow, + setHashParamValueBooleanInWindow, + setHashParamValueFloatInWindow, + setHashParamValueIntInWindow, + setHashParamValueJsonInWindow, + setHashParamValueUriEncodedInWindow, +} from "../src/core/index.ts"; + +const flushTick = async () => { + await delay(5); +}; + +const setupBrowserGlobals = () => { + const originalWindow = (globalThis as Record).window; + const originalLocation = (globalThis as Record).location; + const originalHistory = (globalThis as Record).history; + const originalDocument = (globalThis as Record).document; + const originalHashChangeEvent = (globalThis as Record) + .HashChangeEvent; + + if (typeof (globalThis as Record).window === "undefined") { + Object.defineProperty(globalThis, "window", { + value: globalThis, + configurable: true, + writable: true, + }); + } + + let locationRef = new URL("https://example.com/"); + Object.defineProperty(globalThis, "location", { + configurable: true, + get: () => locationRef, + set: (next: URL) => { + locationRef = next; + }, + }); + + Object.defineProperty(globalThis, "history", { + value: { + replaceState: (_state: unknown, _title: string, url: string) => { + locationRef = new URL(url, locationRef.origin); + }, + }, + configurable: true, + writable: true, + }); + + if (typeof (globalThis as Record).document === "undefined") { + Object.defineProperty(globalThis, "document", { + value: { title: "test" }, + configurable: true, + writable: true, + }); + } + + if ( + typeof (globalThis as Record).HashChangeEvent === + "undefined" + ) { + class HashChangeEventPolyfill extends Event {} + Object.defineProperty(globalThis, "HashChangeEvent", { + value: HashChangeEventPolyfill, + configurable: true, + writable: true, + }); + } + + return () => { + Object.defineProperty(globalThis, "window", { + value: originalWindow, + configurable: true, + writable: true, + }); + Object.defineProperty(globalThis, "location", { + value: originalLocation, + configurable: true, + writable: true, + }); + Object.defineProperty(globalThis, "history", { + value: originalHistory, + configurable: true, + writable: true, + }); + Object.defineProperty(globalThis, "document", { + value: originalDocument, + configurable: true, + writable: true, + }); + Object.defineProperty(globalThis, "HashChangeEvent", { + value: originalHashChangeEvent, + configurable: true, + writable: true, + }); + }; +}; + +Deno.test({ + name: "hash param listeners fire once after tick and stop after dispose", + async fn() { + const restore = setupBrowserGlobals(); + try { + const runListenerCase = async ( + key: string, + initialValue: T, + nextValue: T, + setValue: (key: string, value: T | undefined) => void, + addListener: ( + key: string, + listener: (value: T | undefined) => void + ) => () => void, + expectedAfterDelete: T | undefined + ) => { + const values: Array = []; + setValue(key, initialValue); + const dispose = addListener(key, (value) => values.push(value)); + + await flushTick(); + assertEquals(values, [initialValue]); + + setValue(key, nextValue); + await flushTick(); + assertEquals(values, [initialValue, nextValue]); + + setValue(key, undefined); + await flushTick(); + assertEquals(values, [initialValue, nextValue, expectedAfterDelete]); + + dispose(); + setValue(key, initialValue); + await flushTick(); + assertEquals(values, [initialValue, nextValue, expectedAfterDelete]); + }; + + await runListenerCase( + "base64", + "hello", + "goodbye", + setHashParamValueBase64EncodedInWindow, + addEventListenerHashParamBase64, + undefined + ); + + const booleanValues: Array = []; + setHashParamValueBooleanInWindow("boolean", true); + const disposeBoolean = addEventListenerHashParamBoolean("boolean", (v) => + booleanValues.push(v) + ); + await flushTick(); + assertEquals(booleanValues, [true]); + setHashParamValueBooleanInWindow("boolean", undefined); + await flushTick(); + assertEquals(booleanValues, [true, false]); + disposeBoolean(); + setHashParamValueBooleanInWindow("boolean", true); + await flushTick(); + assertEquals(booleanValues, [true, false]); + + await runListenerCase( + "float", + 3.14, + 6.28, + setHashParamValueFloatInWindow, + addEventListenerHashParamFloat, + undefined + ); + + await runListenerCase( + "int", + 42, + 99, + setHashParamValueIntInWindow, + addEventListenerHashParamInt, + undefined + ); + + const jsonInitial = { nested: { count: 1 }, arr: [1, 2, 3] }; + const jsonNext = { nested: { count: 2 }, arr: [4, 5, 6] }; + await runListenerCase( + "json", + jsonInitial, + jsonNext, + setHashParamValueJsonInWindow, + addEventListenerHashParamJson, + undefined + ); + + await runListenerCase( + "uri", + "a value with spaces/%", + "next/value", + setHashParamValueUriEncodedInWindow, + addEventListenerHashParamUriEncoded, + undefined + ); + } finally { + restore(); + } + }, +});