commit b885a68037c35040c3c409201cdbb6815c97a7bf
parent 4a5e6849db14e2b772c97820655c85c91aee4ae5
Author: Florian Dold <dold@taler.net>
Date: Mon, 24 Aug 2026 02:28:48 +0200
web-util: parse hash routes and query parameters together
Diffstat:
2 files changed, 81 insertions(+), 8 deletions(-)
diff --git a/packages/web-util/src/context/navigation.test.ts b/packages/web-util/src/context/navigation.test.ts
@@ -0,0 +1,49 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+*/
+
+import assert from "node:assert";
+import { test } from "node:test";
+import { parseHashLocation } from "./navigation.js";
+import { findMatch, urlPattern } from "../utils/route.js";
+
+test("bank transfer route round-trips its path and hash query", () => {
+ const transfer = urlPattern<{
+ account?: string;
+ amount?: string;
+ subject?: string;
+ }>(
+ /^\/wire-transfer\/(?<account>[a-zA-Z0-9._~-]+)$/,
+ ({ account, amount, subject }) => {
+ const query = new URLSearchParams();
+ if (amount) query.set("amount", amount);
+ if (subject) query.set("subject", subject);
+ return `#/wire-transfer/${encodeURIComponent(account ?? "")}?${query}`;
+ },
+ );
+ const href = transfer.url({
+ account: "alice.test~",
+ amount: "EUR:12",
+ subject: "rent August",
+ });
+ const parsed = parseHashLocation(href, "?outer=one&outer=two");
+ const matched = findMatch(
+ { transfer },
+ ["transfer"],
+ parsed.path,
+ parsed.params,
+ );
+
+ assert.equal(matched.name, "transfer");
+ assert.deepEqual(matched.values, { account: "alice.test~" });
+ assert.deepEqual(matched.params, {
+ outer: ["one", "two"],
+ amount: ["EUR:12"],
+ subject: ["rent August"],
+ });
+});
diff --git a/packages/web-util/src/context/navigation.ts b/packages/web-util/src/context/navigation.ts
@@ -51,15 +51,27 @@ export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>(
return findMatch(pagesMap, pageList, path, params);
}
-function getPathAndParamsFromWindow(): {
+export function parseHashLocation(
+ hash: string,
+ search: string,
+): {
path: string;
params: Record<string, string[]>;
} {
- const path =
- typeof window !== "undefined" ? window.location.hash.substring(1) : "/";
+ const rawHash = hash.startsWith("#") ? hash.substring(1) : hash;
+ const queryIndex = rawHash.indexOf("?");
+ const path = queryIndex < 0 ? rawHash : rawHash.substring(0, queryIndex);
const params: Record<string, string[]> = {};
- if (typeof window !== "undefined") {
- for (const [key, value] of new URLSearchParams(window.location.search)) {
+ for (const [key, value] of new URLSearchParams(search)) {
+ if (!params[key]) {
+ params[key] = [];
+ }
+ params[key].push(value);
+ }
+ if (queryIndex >= 0) {
+ for (const [key, value] of new URLSearchParams(
+ rawHash.substring(queryIndex + 1),
+ )) {
if (!params[key]) {
params[key] = [];
}
@@ -69,6 +81,16 @@ function getPathAndParamsFromWindow(): {
return { path, params };
}
+function getPathAndParamsFromWindow(): {
+ path: string;
+ params: Record<string, string[]>;
+} {
+ if (typeof window === "undefined") {
+ return parseHashLocation("/", "");
+ }
+ return parseHashLocation(window.location.hash, window.location.search);
+}
+
const { path: initialPath, params: initialParams } =
getPathAndParamsFromWindow();
@@ -93,9 +115,9 @@ export const BrowserHashNavigationProvider = ({
);
}
function navigateTo(path: string): void {
- const { params } = getPathAndParamsFromWindow();
- setState({ path, params });
- window.location.href = path;
+ const target = new URL(path, window.location.href);
+ setState(parseHashLocation(target.hash, target.search));
+ window.location.href = target.href;
}
useEffect(() => {
@@ -103,8 +125,10 @@ export const BrowserHashNavigationProvider = ({
setState(getPathAndParamsFromWindow());
}
window.addEventListener(PopStateEventType, eventListener);
+ window.addEventListener("hashchange", eventListener);
return () => {
window.removeEventListener(PopStateEventType, eventListener);
+ window.removeEventListener("hashchange", eventListener);
};
}, []);
return h(Context.Provider, {