Files
siftlode/frontend/src/lib/logHooks.ts
T
peter 0d5c6273f1 fix(nav): the address follows the view through overlays, reloads and Back (review round 1)
- A URL mirror names the view again on every entry Back or Forward lands on, so a change made
  under a dialog, a drawer or a sub-view is not undone by the next reload.
- A view pushed from the entry a reload under a dialog started from no longer inherits its overlay
  mark; a reload under two overlays closes onto the page in one step.
- The linked player reopens over a live search, gives itself up when its list moves first, and
  plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve.
- The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and
  Playlists searches end with their page; the feed always names its view and debounces its search.
- Search-box keys are declared per module and kept out of the client log below TRACE.
- Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and
  two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the
  Files sort and viewer added).
2026-10-27 13:52:42 +01:00

217 lines
8.7 KiB
TypeScript

// The client log's app-wide hooks, installed once from main.tsx before anything renders:
// the start snapshot, uncaught errors and rejections, navigation, connectivity, and — only while the
// user's consented level is raised — which control was used and what the tab itself went
// through (hidden/shown, restored from the back-forward cache, resized).
//
// Navigation has no single choke point in this router-free app — `setPage`, the channel and search
// overlays, sub-views, the overlay stack and the URL mirrors each call `history.pushState` /
// `replaceState` themselves (lib/history.ts, NavigationProvider, ChannelsProvider, …). Instrumenting
// each would be one more thing a new call site can forget, so the History methods themselves are
// wrapped here, and `popstate` covers Back/Forward. A replaceState that only saves a scroll position
// changes nothing this logs, so it is not an event.
import { FRONTEND_SHA, FRONTEND_VERSION } from "./version";
import { enabled, errorAttrs, log } from "./log";
import { pathTemplate } from "./http";
// URL parameters that carry what the user TYPED — search text. Logged only at TRACE ("Teljes").
// Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG. The
// shell's own here; each page's search boxes join them at install (ModuleDef.typedUrlKeys).
const TYPED_PARAMS = new Set(["q", "keywords", "search"]);
type Via = "start" | "push" | "replace" | "pop";
/** What a navigation event records of where the tab is now. Built from `history.state`'s own view
* keys and the query, never the full URL (a `#reset=` fragment, a `?token=` link). */
function viewAttrs(): Record<string, unknown> {
const st = (window.history.state ?? {}) as Record<string, unknown>;
const params = new URLSearchParams(window.location.search);
const view: Record<string, unknown> = {
path: pathTemplate(window.location.pathname),
page: typeof st.sfPage === "string" ? st.sfPage : null,
params: [...new Set(params.keys())].sort(),
overlay: st._ov != null,
channel: st._chan != null,
search: st._yt != null,
};
if (enabled("DEBUG")) {
const values: Record<string, string> = {};
for (const [k, v] of params) {
if (!TYPED_PARAMS.has(k) || enabled("TRACE")) values[k] = v;
}
view.values = values;
if (typeof st._sub === "string") view.sub = st._sub;
}
if (enabled("TRACE") && typeof st._yt === "string") view.query = st._yt;
return view;
}
/** A script's path (`/assets/index-B1x9.js`): the bundle name is what finds the fault, and a script
* URL carries no user data — but a malformed one is dropped rather than logged raw. */
function scriptPath(filename: string): string {
try {
return new URL(filename, window.location.href).pathname;
} catch {
return "?";
}
}
let lastView = "";
function recordView(via: Via): void {
const view = viewAttrs();
const signature = JSON.stringify(view);
if (via !== "start" && signature === lastView) return;
lastView = signature;
log.info("nav.view", `${via} ${String(view.page ?? view.path)}`, { via, ...view });
}
// What counts as a control for click capture: anything a user operates.
const CONTROL = [
"button",
"a[href]",
"input",
"select",
"textarea",
"summary",
...[
"button",
"link",
"tab",
"menuitem",
"menuitemradio",
"menuitemcheckbox",
"option",
"switch",
"checkbox",
"radio",
].map((role) => `[role="${role}"]`),
].join(", ");
const ARIA_STATE = ["aria-pressed", "aria-checked", "aria-expanded", "aria-selected"] as const;
type AppCopy = (text: string | null) => string | null;
/** Which control this is — never what it says, unless what it says is the app's own copy.
* Recorded: its test id (its own, else the nearest enclosing one as `within`), its lucide icon, the
* English wording of its aria-label/text/title when that is exactly app copy (`appCopy`), its tag,
* role and input type, its aria state, whether a checkbox is checked, and for a link the path
* TEMPLATE or just "external". Never: an input's value, free label text, an href's query. */
function controlAttrs(el: Element, appCopy: AppCopy): Record<string, unknown> {
const attrs: Record<string, unknown> = { tag: el.tagName.toLowerCase() };
const role = el.getAttribute("role");
if (role) attrs.role = role;
const testId = el.getAttribute("data-testid");
if (testId) attrs.control = testId;
else {
const within = el.parentElement?.closest("[data-testid]")?.getAttribute("data-testid");
if (within) attrs.within = within;
}
const icon = [...(el.querySelector("svg.lucide")?.classList ?? [])].find(
(c) => c.startsWith("lucide-") && c !== "lucide-icon",
);
if (icon) attrs.icon = icon.slice("lucide-".length);
const label =
appCopy(el.getAttribute("aria-label")) ??
appCopy(el.textContent) ??
appCopy(el.getAttribute("title"));
if (label) attrs.label = label;
if (el instanceof HTMLInputElement) {
attrs.type = el.type;
if (el.type === "checkbox" || el.type === "radio") attrs.checked = el.checked;
}
for (const name of ARIA_STATE) {
const value = el.getAttribute(name);
if (value !== null) attrs[name.slice("aria-".length)] = value;
}
if (el instanceof HTMLAnchorElement) {
attrs.href = el.origin === window.location.origin ? pathTemplate(el.pathname) : "external";
}
const page = (window.history.state as Record<string, unknown> | null)?.sfPage;
if (typeof page === "string") attrs.page = page;
return attrs;
}
function installDetailHooks(appCopy: AppCopy): void {
// Capture phase, so a handler that stops propagation cannot hide the click from the log.
document.addEventListener(
"click",
(e) => {
if (!enabled("DEBUG") || !(e.target instanceof Element)) return;
const control = e.target.closest(CONTROL);
if (!control) return;
const attrs = controlAttrs(control, appCopy);
const name = attrs.control ?? attrs.label ?? attrs.icon ?? attrs.within ?? attrs.tag;
log.debug("ui.click", `click ${String(name)}`, attrs);
},
true,
);
document.addEventListener("visibilitychange", () => {
log.debug("app.visibility", document.visibilityState, { state: document.visibilityState });
});
window.addEventListener("pageshow", (e) => {
if (e.persisted) log.debug("app.restored", "restored from the back-forward cache");
});
let resizeTimer: number | null = null;
window.addEventListener("resize", () => {
if (!enabled("DEBUG")) return;
if (resizeTimer !== null) window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
resizeTimer = null;
const viewport = `${window.innerWidth}x${window.innerHeight}`;
log.debug("app.resize", viewport, { viewport });
}, 500);
});
// Connectivity is worth a line at the base level: it explains a burst of failed requests.
window.addEventListener("offline", () => log.warn("app.network", "offline", { online: false }));
window.addEventListener("online", () => log.info("app.network", "online", { online: true }));
}
let installed = false;
/** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice.
* `appCopy` turns on-screen app copy into its English wording (i18n/appCopy.ts) — passed in, so this
* module stays free of the i18n runtime. */
export function installClientLog(
appCopy: AppCopy = () => null,
typedParams: readonly string[] = [],
): void {
if (installed) return;
installed = true;
for (const k of typedParams) TYPED_PARAMS.add(k);
log.info("app.start", "app loaded", {
version: FRONTEND_VERSION,
sha: FRONTEND_SHA,
user_agent: navigator.userAgent,
language: navigator.language,
viewport: `${window.innerWidth}x${window.innerHeight}`,
dpr: window.devicePixelRatio,
});
window.addEventListener("error", (e) => {
// A failed <img>/<script> load also dispatches `error` on window (capture only); those carry no
// `error` object and are not script faults.
if (!(e instanceof ErrorEvent)) return;
log.error("app.uncaught", e.message || "uncaught error", {
...errorAttrs(e.error ?? e.message),
where: e.filename ? `${scriptPath(e.filename)}:${e.lineno}:${e.colno}` : null,
});
});
window.addEventListener("unhandledrejection", (e) => {
log.error("app.unhandled_rejection", "unhandled promise rejection", errorAttrs(e.reason));
});
const history = window.history;
for (const [method, via] of [
["pushState", "push"],
["replaceState", "replace"],
] as const) {
const original = history[method];
history[method] = function (this: History, ...args: Parameters<History["pushState"]>) {
original.apply(this, args);
recordView(via);
};
}
window.addEventListener("popstate", () => recordView("pop"));
recordView("start");
installDetailHooks(appCopy);
}