fix(admin): verify the whole SPA build graph, not just the entry

find_missing_admin_chunks() only parsed index.js, but a lazily
imported page chunk pulls in its own deps (shared-api, PageHeader,
sortable-options…). A deploy that drops one of those still leaves the
entry internally consistent, so the existing notice never fires and the
failure only shows up when an admin opens that menu — every schemaPage
keeps working, which makes the breakage look arbitrary.

The same scan never looked at CSS at all: 18 stylesheets are reachable
from the graph and none of them were checked, so a missing page
stylesheet ships an unstyled screen with no warning.

Walk the module graph transitively from index.js, queueing each
discovered dependency, and match .js and .css specifiers. Deleting
PageHeader now also reports every page that depends on it.

This is a diagnostic hardening only — it makes an incomplete deploy loud
instead of guessing at the cause of a broken page.
This commit is contained in:
kate522 committed 2026-11-01 13:41:58 +08:00
1 parent df12f51199
commit 324bcfdadc
3 files changed
+131 -12

No files matched your search

+52 -11
View File
@@ -145,28 +145,69 @@ class Admin {
}
/**
* Parse index.js dynamic imports and report missing chunk files.
* Walk the whole build/ module graph and report files a chunk asks for but
* that are not on disk.
*
* @return string[]
* Starting from index.js is not enough: a lazily imported page chunk pulls
* in its own deps (shared-api, PageHeader, …), so a deploy that drops any
* of those still leaves the entry consistent while the route 404s at
* runtime. Traversing the graph transitively also catches CSS, which the
* entry-only scan never verified — a missing page stylesheet silently
* ships an unstyled admin screen.
*
* @return string[] Missing relative file names, e.g. "UsersBadge.a1b2c3.js".
*/
private static function find_missing_admin_chunks(string $build_dir, string $entry_file): array {
$src = file_get_contents($entry_file);
if (!is_string($src) || $src === '') {
if (!is_file($entry_file)) {
return ['index.js'];
}
$entry_src = file_get_contents($entry_file);
if (!is_string($entry_src) || $entry_src === '') {
return ['index.js unreadable'];
}
if (!preg_match_all('/\.\/([A-Za-z0-9_.-]+\.js)/', $src, $matches)) {
return [];
}
// Relative specifiers the bundler emits: static `from "./x.js"`,
// dynamic `import("./x.js")`, bare `import "./x.css"` and the
// __vite__mapDeps lookup table.
$specifier = '/\.\/([A-Za-z0-9_.-]+\.(?:js|css))/';
$missing = [];
foreach (array_unique($matches[1]) as $chunk) {
if (!is_file($build_dir . $chunk)) {
$missing[] = $chunk;
$visited = [];
$queue = ['index.js'];
while ($queue !== []) {
$file = array_shift($queue);
if (isset($visited[$file])) {
continue;
}
$visited[$file] = true;
$path = $build_dir . $file;
// A .css entry in the graph is a leaf: nothing is imported from it.
if (!is_file($path) || substr($file, -3) !== '.js') {
continue;
}
$src = file_get_contents($path);
if (!is_string($src) || $src === '') {
continue;
}
if (!preg_match_all($specifier, $src, $matches)) {
continue;
}
foreach (array_unique($matches[1]) as $dep) {
if (!is_file($build_dir . $dep)) {
$missing[$dep] = true;
continue;
}
$queue[] = $dep;
}
}
return $missing;
return array_keys($missing);
}
public function add_module_type_attribute(string $tag, string $handle): string {