feat: Add hardened error handling and retries (#2451)

- Closes #1830
 - Closes #2099
This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 4bc787e2b7
commit 47bcce93d3
182 changed files with 7877 additions and 4043 deletions
+51 -11
View File
@@ -2,6 +2,7 @@ import { ViewContext } from 'view';
import { log } from '../../utils/debug';
import { getStreamCameraID } from '../../utils/substream';
import { View } from '../../view/view';
import { getViewTargetID } from '../../view/target-id';
import { InitializationAspect } from '../initialization-manager';
import { CardViewAPI } from '../types';
import { ViewFactory } from './factory';
@@ -111,19 +112,39 @@ export class ViewManager implements ViewManagerInterface {
baseView: this._view,
...options,
});
// A non-throwing factory call clears any prior view_incompatible /
// media_query state — ensures a previously-dismissed mid-session popup
// does not linger invisibly and re-pop on the next evaluation cycle,
// and that a stale media_query failure from an abandoned gallery /
// viewer doesn't follow the user into an unrelated view.
this._api.getIssueManager().reset('view_incompatible');
this._api.getIssueManager().reset('media_query');
} catch (e) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
if (!this._view) {
view = this._getFailSafeView(viewFactoryFunc);
}
this._api.getIssueManager().trigger('view_incompatible', { error: e });
}
if (view) {
this._setView(view);
}
}
private _markViewLoadingQuery(view: View, index: number): View {
return view.mergeInContext({ loading: { query: index } });
private _getFailSafeView(
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
): View | null {
try {
return viewFactoryFunc({ baseView: null, failSafe: true });
} catch {
return null;
}
}
private _markViewAsNotLoadingQuery(view: View): View {
return view.removeContextProperty('loading', 'query');
private _markViewLoadingQuery(view: View, index: number): void {
view.mergeInContext({ loading: { query: index } });
}
private _markViewAsNotLoadingQuery(view: View): void {
view.removeContextProperty('loading', 'query');
}
private _isAllowedToSetView(): boolean {
@@ -162,8 +183,16 @@ export class ViewManager implements ViewManagerInterface {
...options?.params,
},
});
this._api.getIssueManager().reset('view_incompatible');
// A new query is about to run, so any stale media_query error from a
// previous attempt is no longer meaningful. If this new query also
// fails, it will re-trigger below.
this._api.getIssueManager().reset('media_query');
} catch (e) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
if (!this._view) {
initialView = this._getFailSafeView(viewFactoryFunc);
}
this._api.getIssueManager().trigger('view_incompatible', { error: e });
}
if (!initialView) {
@@ -205,13 +234,23 @@ export class ViewManager implements ViewManagerInterface {
// the contrary, small changes such as the user zooming in are fine to
// merge into the resultant view.
if (this._view.context?.loading?.query === loadingIndex) {
this._setView(this._markViewAsNotLoadingQuery(this._view.clone()));
const view = this._view.clone();
this._markViewAsNotLoadingQuery(view);
this._setView(view);
}
return;
}
if (error) {
this._api.getMessageManager().setErrorIfHigherPriority(error);
// Clear the loading flag before surfacing the error. Otherwise the
// view stays marked in-flight and components (gallery, viewer) keep
// rendering "Awaiting media" on top of the error notification.
if (this._view?.context?.loading?.query === loadingIndex) {
const view = this._view.clone();
this._markViewAsNotLoadingQuery(view);
this._setView(view);
}
this._api.getIssueManager().trigger('media_query', { error });
return;
}
@@ -221,6 +260,8 @@ export class ViewManager implements ViewManagerInterface {
return;
}
this._api.getIssueManager().reset('media_query');
const newView = this._view.clone();
if (this._view.context?.loading?.query === loadingIndex) {
this._markViewAsNotLoadingQuery(newView);
@@ -279,7 +320,7 @@ export class ViewManager implements ViewManagerInterface {
);
}
public initialize = async (): Promise<boolean> => {
public initialize = async (): Promise<void> => {
// If the query string contains a view related action, we don't set any view
// here and allow that action to be triggered by the next call of to execute
// query actions (called at least once per render cycle).
@@ -289,7 +330,6 @@ export class ViewManager implements ViewManagerInterface {
// query is answered.
this.setViewDefaultWithNewQuery({ failSafe: true });
}
return true;
};
private _setView(view: Readonly<View> | null): void {
@@ -312,13 +352,13 @@ export class ViewManager implements ViewManagerInterface {
this._api.getCardElementManager().scrollReset();
}
this._api.getMessageManager().reset();
this._api.getStyleManager().setExpandedMode();
this._api.getConditionStateManager()?.setState({
view: view?.view,
camera: view?.camera ?? undefined,
displayMode: view?.displayMode ?? undefined,
targetID: view ? getViewTargetID(view) ?? undefined : undefined,
});
this._api.getCardElementManager().update();