Files
siftlode/frontend/src/lib/logHooks.ts
T
peter 6d57d62ac8 fix(nav): a linked player, file and search survive what the app does by itself (review round 2)
- Playlists: a link over a playlist the account does not hold plays alone; the rail's own
  fallback no longer counts as a pick that gives the player up.
- The linked opener opens or clears nothing once the address names another player, and stops
  looking (and fetching its row) once it settled.
- Files: a list the server's cap cut — the sorted or searched one too — keeps the link, with a
  notice of its own (EN/HU).
- History: an in-place view rewrite (search refine, count, channel to channel, sub-view)
  drops a reloaded entry's overlay marks (replaceView); a landing on a channel opened from a
  search keeps the search; the Feed press on a search opens the plain feed.
- Files/Playlists: an emptied search is written at once.
- Client log: plf/cf (column text filters) are typed keys; the shell has none of its own.
- Contract: writers outside lib/ take a literal or a lib/ function's result; every URL key is
  sorted typed or not; the writer regexes are held by a fixture; sub-views named; the bookmark
  net reads page tags.
- Feed defaults in one constant; one live-search queue helper; Scheduler writes through the
  Activity codec; notRun copy (EN/HU) names Forward and a plain "Search YouTube" button.
2026-10-27 16:08:52 +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 — a search, a column's text filter. 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 has none; each page's join at install (ModuleDef.typedUrlKeys).
const TYPED_PARAMS = new Set<string>();
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);
}