mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-08 07:29:13 +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:
@@ -5,20 +5,49 @@ export interface EditorRuntimeModule<Runtime> {
|
||||
create(): Runtime;
|
||||
}
|
||||
|
||||
export interface EditorRuntimeLoaderFailure {
|
||||
/** Terminal failures need a page refresh; the rest retry on the next intent. */
|
||||
readonly terminal: boolean;
|
||||
}
|
||||
|
||||
export interface EditorRuntimeLoaderOptions<Runtime> {
|
||||
readonly expectedFingerprint: string;
|
||||
readonly load: (attempt: 0 | 1) => Promise<EditorRuntimeModule<Runtime>>;
|
||||
readonly install: (runtime: Runtime) => void;
|
||||
readonly stateChanged?: (state: EditorRuntimeLoaderState) => void;
|
||||
readonly failed?: (error: unknown) => void;
|
||||
readonly failed?: (error: unknown, info: EditorRuntimeLoaderFailure) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One toast wording for every lazy-runtime failure (#353 AC5). A terminal
|
||||
* failure means this tab runs code from another build — only a refresh helps.
|
||||
* A network failure heals on the next explicit press, so the advice differs.
|
||||
*/
|
||||
export type LazyLoadFailureKey =
|
||||
| 'editor.load_failed' | 'editor.refresh_advice' | 'editor.retry_advice';
|
||||
|
||||
export function lazyLoadFailureMessage(
|
||||
t: (key: LazyLoadFailureKey) => string,
|
||||
info: EditorRuntimeLoaderFailure,
|
||||
): string {
|
||||
return `${t('editor.load_failed')} ${t(info.terminal ? 'editor.refresh_advice' : 'editor.retry_advice')}`;
|
||||
}
|
||||
|
||||
/** Loader-owned marker: the served module belongs to a different build. */
|
||||
class FingerprintMismatchError extends Error {}
|
||||
|
||||
/**
|
||||
* One atomic lazy-runtime boundary shared by all editor entry points.
|
||||
*
|
||||
* A failed module is retried exactly once. Construction happens before
|
||||
* `install`, so a parse, fingerprint or constructor failure cannot leave a
|
||||
* half-installed editor attached to the View card.
|
||||
* A failed module is retried exactly once per load cycle. Construction happens
|
||||
* before `install`, so a parse, fingerprint or constructor failure cannot
|
||||
* leave a half-installed editor attached to the View card.
|
||||
*
|
||||
* Failure outcomes differ (#353): a fingerprint mismatch on either attempt is
|
||||
* terminal — the tab holds another build and only a refresh helps, so `ensure`
|
||||
* keeps returning `false` without importing again. Any other failure (network,
|
||||
* parse) reports via `failed` and returns the loader to `idle`, so the NEXT
|
||||
* explicit user intent starts a fresh cycle. There are no background retries.
|
||||
*/
|
||||
export class EditorRuntimeLoader<Runtime> {
|
||||
private _state: EditorRuntimeLoaderState = 'idle';
|
||||
@@ -43,11 +72,12 @@ export class EditorRuntimeLoader<Runtime> {
|
||||
|
||||
private async _loadWithRetry(): Promise<boolean> {
|
||||
let lastError: unknown = new Error('Editor runtime did not load');
|
||||
let sawMismatch = false;
|
||||
for (const attempt of [0, 1] as const) {
|
||||
try {
|
||||
const module = await this.options.load(attempt);
|
||||
if (module.fingerprint !== this.options.expectedFingerprint) {
|
||||
throw new Error(
|
||||
throw new FingerprintMismatchError(
|
||||
`Editor runtime fingerprint mismatch: expected ${this.options.expectedFingerprint}, got ${module.fingerprint}`,
|
||||
);
|
||||
}
|
||||
@@ -57,10 +87,11 @@ export class EditorRuntimeLoader<Runtime> {
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
lastError = error;
|
||||
if (error instanceof FingerprintMismatchError) sawMismatch = true;
|
||||
}
|
||||
}
|
||||
this._setState('failed');
|
||||
this.options.failed?.(lastError);
|
||||
this._setState(sawMismatch ? 'failed' : 'idle');
|
||||
this.options.failed?.(lastError, { terminal: sawMismatch });
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user