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 17:19:41 +03:00
parent 3a25b0d908
commit 4a241cef93
49 changed files with 640 additions and 213 deletions
+58
View File
@@ -634,6 +634,64 @@ const MUTANT_DEFINITIONS = [
replace: ' // mutant: pointer capture removed\n const plan = resolution.plan;',
}],
},
{
id: 'lazy-loader-network-failure-terminal',
guard: 'node --test --test-name-pattern="#353 AC1" test/editor-runtime-loader.test.mjs',
because: 'a transient network failure must re-arm the loader for the next explicit press — '
+ 'a terminal state blocks the editor until a page refresh (#353 К1)',
patches: [{
file: 'src/editor-runtime-loader.ts',
find: " this._setState(sawMismatch ? 'failed' : 'idle');",
replace: " this._setState('failed');",
}],
},
{
id: 'lazy-loader-terminality-hardcoded',
guard: 'node --test --test-name-pattern="#353" test/editor-runtime-loader.test.mjs',
because: 'a hardcoded terminal flag shows the wrong advice — the network toast must invite '
+ 'another press, the mismatch toast must demand a refresh (#353 К2/AC5)',
patches: [{
file: 'src/editor-runtime-loader.ts',
find: ' this.options.failed?.(lastError, { terminal: sawMismatch });',
replace: ' this.options.failed?.(lastError, { terminal: true });',
}],
},
{
id: 'lazy-chunk-cache-control-reverted',
guard: 'node scripts/backend-test-guard.mjs hashed_chunks_are_immutably_cacheable '
+ 'tests_backend/test_frontend_assets.py',
because: 'content-hashed chunk bodies never change under their URL — reverting to no-cache '
+ 'reopens the stale-proxy window and refetches on every editor entry (#353 К4)',
patches: [{
file: 'custom_components/houseplan/frontend_asset_manifest.py',
find: 'ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"',
replace: 'ASSET_CACHE_CONTROL = "no-cache"',
}],
},
{
id: 'bundle-tree-orphans-ignored',
guard: 'node --test --test-name-pattern="#353 AC4" test/bundle-assets.test.mjs',
because: 'an unlisted chunk on disk rides into the release zip and masks sync bugs — '
+ 'the tree check must fail loudly on orphans (#353 К5)',
patches: [{
file: 'scripts/bundle-tree.mjs',
find: " if (name.endsWith('.js') && !listed.has(`houseplan-assets/${name}`)) {\n"
+ ' throw new Error(`orphan bundle asset: houseplan-assets/${name}`);\n'
+ ' }',
replace: " void name; void listed;",
}],
},
{
id: 'entry-fallback-rewrite-skipped',
guard: 'node demo/smoke_entry_stale.mjs',
because: 'without the rewrite the entry keeps a STATIC re-export: after an update a cached '
+ 'entry aborts before any code runs and the card dies silently (#353 К3)',
patches: [{
file: 'scripts/bundle-manifest.mjs',
find: ' entry.code = entry.code.replace(pattern, fallback);',
replace: ' void fallback;',
}],
},
{
id: 'junction-limit-key-precision-lost',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '