fix: lazy delivery survives flaky networks and stale caches (#353)

Network failure of the editor runtime is no longer terminal: the loader
re-arms to idle and the next explicit press starts a fresh cycle, while a
fingerprint mismatch on either attempt stays terminal. The toast now says
what actually helps — retry advice for the network, refresh advice for a
foreign build — via one shared lazyLoadFailureMessage helper (new i18n key
editor.retry_advice in en/ru/de).

The field smoke caught a second, deeper bug on the way: Chromium records a
FAILED module in the page module map permanently, so retrying the same URL
(even the cache-busted one) never touched the network again. Every retry
now carries a per-cycle nonce and becomes a genuinely new module request.

A proxy-cached stale entry no longer kills the card silently: the entry
facade is rewritten at build time from a static re-export into a top-level
`try{await import(...)}catch{...}` — importers keep the happy-path
guarantee (await import(entry) still resolves only after
customElements.define), and the catch defines a fallback element with a
localized "reload the page" panel. Content-hashed chunks are served with
`public, max-age=31536000, immutable`, and verifyBundleTree now fails on
orphan chunks that the manifest does not name.

Proofs: loader units for re-arm/terminality/toast wording + an AST check
that both loaders forward the terminality flag; smoke_entry_stale (en/ru)
against a tree without the main chunk; smoke_lazy_editor_chunk extended —
second press after network failure now really opens the editor; pytest for
the immutable header; orphan-tree unit; five new registry mutants.
TESTING.md budget line updated to the #352 ceiling alongside.

Issue: #353
User-Visible: yes
This commit is contained in:
Codex
2026-08-28 14:32:08 +00:00
committed by claude[bot]
parent 8cacfda934
commit 78c6020747
49 changed files with 640 additions and 213 deletions
+12 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -49,6 +49,17 @@ export function verifyBundleTree(root) {
throw new Error(`manifest hash mismatch: ${file.path} (${actual} != ${file.sha256})`);
}
}
// #353 К5: a chunk on disk that the manifest does not name is dead weight —
// it would ride into the release zip and mask sync bugs. Fail loudly.
const assetDir = resolve(root, 'houseplan-assets');
if (existsSync(assetDir)) {
const listed = new Set(manifest.files.map((file) => file.path));
for (const name of readdirSync(assetDir)) {
if (name.endsWith('.js') && !listed.has(`houseplan-assets/${name}`)) {
throw new Error(`orphan bundle asset: houseplan-assets/${name}`);
}
}
}
return manifest;
}