From a3f67bf017846732f14563d603896b7a5c793b99 Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Mon, 27 Jul 2026 14:22:23 -0400 Subject: [PATCH] Fix blank page from stale cached assets; surface errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser or CDN holding an assets/app.js from before the shell's element IDs were renamed runs it against the newer index.php, where it grabs btn-logout/btn-rescan/nav-user/nav-username/app — all now null — and throws before it can unhide #app-shell. The result is a blank page whose only clue is a TypeError in the console. - index.php: asset() stamps local css/js with the file mtime, so a deploy is a new URL and a stale copy can't be paired with a newer shell. - index.php: install an error handler ahead of every other script (capture phase, so failed resource loads are caught too) that paints uncaught errors, rejections and load failures into a #boot-error panel. It works even when app.js itself is what failed, which is this case. - includes/db.php: add a register_shutdown_function fatal handler, since set_exception_handler never sees parse errors, exhausted memory or a failed require — those were ending requests with an empty body. Both handlers flush partial output and answer in JSON; display_errors stays off so a warning can't corrupt a JSON body. - New optional DEBUG config flag adds exception type, file:line and trace to those responses. config.php is gitignored, so installs without the setting default to false. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014oyi9DMdhALrGz989MNNZJ --- README.md | 20 +++++++++++++++++ assets/style.css | 19 ++++++++++++++++ config.example.php | 5 +++++ includes/db.php | 53 +++++++++++++++++++++++++++++++++++++++------ index.php | 54 +++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 142 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3cabf86..a58befc 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,10 @@ Top-level folders become courses on the home page. the web server / PHP user). - DB credentials (change the password in production). - `ALLOW_REGISTRATION`: set `false` once your users have accounts. + - `DEBUG`: include the exception type, `file:line` and stack trace in API + error responses. Errors go to the PHP error log either way, so leave this + `false` on anything publicly reachable. Installs whose `config.php` + predates the setting behave as if it were `false`. No external binaries are needed — PHP with PDO/MySQL is the whole dependency list. @@ -212,6 +216,22 @@ next scan creates it. pure PHP, so listings show durations before anyone plays the video. If a file can't be parsed, the browser reports its duration on first play instead. +## When something breaks + +`index.php` stamps `assets/*.css|js` with the file's mtime (`asset()`), so a +deploy is always a fresh URL. Without that, a browser or CDN holding a stale +`app.js` runs it against a newer shell, it grabs element IDs that no longer +exist, and it throws before unhiding the shell — a blank page whose only clue +is in the console. If you ever do see a blank page, hard-refresh and purge the +CDN cache first. + +A small handler installed ahead of every other script catches uncaught errors, +unhandled rejections and failed script/stylesheet loads, and paints them into a +`#boot-error` panel — so a failure that stops the app is visible on the page +rather than silent. Server-side, uncaught exceptions *and* fatals (via +`register_shutdown_function`, which `set_exception_handler` never sees) are +returned as JSON, with detail gated behind `DEBUG`. + ## Production notes - PHP streams the video in 512 KB chunks with Range support, which is fine for diff --git a/assets/style.css b/assets/style.css index 56487f5..1ecff2e 100644 --- a/assets/style.css +++ b/assets/style.css @@ -1166,3 +1166,22 @@ button, input { font-family: inherit; font-size: inherit; } * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; } .is-scanning .scan-spin, .is-scanning .scan-mobile svg { animation: spin 1.4s linear infinite !important; } } + +/* Boot failure report (index.php installs the handler that creates this). + Deliberately self-contained — no custom properties — because the whole + point is to stay readable when the app never started. */ +#boot-error { + position: fixed; + inset: 16px; + z-index: 9999; + overflow: auto; + margin: 0; + padding: 20px; + border: 1px solid #B84A3C; + border-radius: 10px; + background: #FFF6F4; + color: #5A1E16; + font: 500 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/config.example.php b/config.example.php index 208ea81..c43e991 100644 --- a/config.example.php +++ b/config.example.php @@ -18,3 +18,8 @@ define('COMPLETE_THRESHOLD', 0.95); // Allow new users to self-register from the login page. define('ALLOW_REGISTRATION', true); + +// Return the exception type, file:line and stack trace in API error responses. +// Errors are written to the PHP error log either way — this only controls what +// the browser is told, so leave it false on anything publicly reachable. +define('DEBUG', false); diff --git a/includes/db.php b/includes/db.php index e64c4a3..ace8025 100644 --- a/includes/db.php +++ b/includes/db.php @@ -1,16 +1,57 @@ 'Server error: ' . $e->getMessage()]); + while (ob_get_level() > 0) { + ob_end_clean(); + } + echo json_encode(DEBUG ? ['error' => $message] + $debug : ['error' => $message]); + }; + + // Uncaught exceptions become JSON errors (the SPA surfaces them) instead + // of blank 500 pages. + set_exception_handler(function (Throwable $e) use ($emit): void { + error_log($e); + $emit('Server error: ' . $e->getMessage(), [ + 'type' => get_class($e), + 'file' => $e->getFile() . ':' . $e->getLine(), + 'trace' => explode("\n", $e->getTraceAsString()), + ]); + }); + + // set_exception_handler never sees fatals (parse errors, exhausted memory, + // failed require) — those end the request silently and are the usual cause + // of an endpoint returning an empty body. Catch them on shutdown. + register_shutdown_function(function () use ($emit): void { + $err = error_get_last(); + if ($err === null || !($err['type'] & (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR))) { + return; + } + $emit('Fatal error: ' . $err['message'], [ + 'file' => $err['file'] . ':' . $err['line'], + ]); }); } diff --git a/index.php b/index.php index 34b331b..73468a1 100644 --- a/index.php +++ b/index.php @@ -1,4 +1,21 @@ - + @@ -10,7 +27,38 @@ - + + + + @@ -80,6 +128,6 @@ - +