mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
@@ -174,6 +174,55 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the entry facade so a stale cached `houseplan-card.js` fails loudly
|
||||
* instead of killing the card silently (#353 К3). Rollup emits the facade as a
|
||||
* STATIC re-export of the content-hashed main chunk; after an update a proxy-
|
||||
* cached facade points at a chunk the manifest-gated server no longer serves,
|
||||
* and a static import failure aborts the whole module before any code runs.
|
||||
*
|
||||
* The rewrite keeps the happy path intact via top-level await: an importer's
|
||||
* `await import(entry)` does not resolve until the inner import settles, so
|
||||
* `customElements.define` of the real card still happens-before the importer
|
||||
* continues — demo, smokes and golden run unchanged. Only the failure branch
|
||||
* is new: it defines a minimal fallback element with a human message.
|
||||
*
|
||||
* Must run BEFORE bundleManifestPlugin so the manifest hashes the final code.
|
||||
*/
|
||||
export function entryFallbackPlugin() {
|
||||
return {
|
||||
name: 'houseplan-entry-fallback',
|
||||
generateBundle(_options, bundle) {
|
||||
const entry = Object.values(bundle)
|
||||
.find((item) => item.type === 'chunk' && item.isEntry);
|
||||
if (!entry) throw new Error('entry chunk was not emitted');
|
||||
const pattern = /export\{[^}]*\}from(["'])(\.\/houseplan-assets\/[^"']+\.js)\1;?/g;
|
||||
const matches = [...entry.code.matchAll(pattern)];
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`entry facade re-export count is ${matches.length}, expected 1`);
|
||||
}
|
||||
const asset = matches[0][2];
|
||||
const fallback = 'try{await import("' + asset + '")}'
|
||||
+ 'catch(e){if(!customElements.get("houseplan-card")){'
|
||||
+ 'const l=String(navigator.language||"en").toLowerCase();'
|
||||
+ 'const m=l.startsWith("ru")'
|
||||
+ '?"House Plan обновился — перезагрузите страницу (Ctrl+F5)."'
|
||||
+ ':l.startsWith("de")'
|
||||
+ '?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5)."'
|
||||
+ ':"House Plan was updated — please reload the page (Ctrl+F5).";'
|
||||
+ 'customElements.define("houseplan-card",class extends HTMLElement{'
|
||||
+ 'setConfig(){}getCardSize(){return 1}connectedCallback(){'
|
||||
+ 'this.style.cssText="display:block;box-sizing:border-box;padding:16px;'
|
||||
+ 'border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);'
|
||||
+ 'background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);'
|
||||
+ 'font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";'
|
||||
+ 'this.textContent=m}})}'
|
||||
+ 'console.error("[houseplan] stale entry: the main chunk is unavailable",e)}';
|
||||
entry.code = entry.code.replace(pattern, fallback);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove only files named by the previous generated manifest. */
|
||||
export function cleanBundleOutputPlugin(outputRoot = 'dist') {
|
||||
return {
|
||||
|
||||
+12
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -54,12 +54,15 @@ export const SMOKE_LINKS = [
|
||||
{
|
||||
symbols: [
|
||||
'EditorRuntimeLoader', 'EditorRuntimeLoaderState', 'EditorRuntimeModule',
|
||||
'EditorRuntimeLoaderFailure', 'lazyLoadFailureMessage',
|
||||
'EDITOR_RUNTIME_FINGERPRINT', 'createHouseplanEditorRuntime',
|
||||
],
|
||||
smokes: ['smoke_lazy_editor_chunk.mjs'],
|
||||
because: 'the production-bundle scenario proves that the content-hashed editor chunk stays '
|
||||
+ 'off the View network path, is shared by all three editors, retries once, and refuses an '
|
||||
+ 'incompatible build before editor state is installed (#337)',
|
||||
smokes: ['smoke_lazy_editor_chunk.mjs', 'smoke_entry_stale.mjs'],
|
||||
because: 'the production-bundle scenarios prove that the content-hashed editor chunk stays '
|
||||
+ 'off the View network path, is shared by all three editors, retries once per cycle, '
|
||||
+ 're-arms after a network failure so the next press heals (#353), refuses an incompatible '
|
||||
+ 'build before editor state is installed (#337), and that a stale cached entry shows a '
|
||||
+ 'localized reload panel instead of dying silently (#353)',
|
||||
},
|
||||
{
|
||||
symbols: [
|
||||
|
||||
Reference in New Issue
Block a user