mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-21 14:42:14 -05:00
[AI] Recover from BackendInitFailure and show a meaningful error (#7761)
* [AI] Recover from BackendInitFailure and show a meaningful error When the backend Worker fails to load (e.g., the hashed kcab.worker asset can't be fetched), the SharedWorker would cache the app-init-failure and replay it to every subsequent tab forever, while the FatalError modal showed a misleading "browser version" message. - Retry importScripts in production (3 attempts) so a transient blip doesn't brick the SharedWorker. - Clear lastAppInitFailure when the client acknowledges the failure, when a backend later connects successfully (centralized in broadcastConnect), and when a fresh init arrives with no active groups (the failed leader is gone). - Add a BackendInitFailure branch to FatalError's RenderSimple with a message that points the user at reload / hard refresh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Remove support contact message from FatalError Removed support contact message from FatalError component. * [AI] Fix error propagation in importScriptsWithRetry - Change Promise executor to accept both resolve and reject - Properly propagate errors using .then(resolve).catch(reject) - Fixes issue where errors from recursive retry calls were swallowed Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
5d270340a5
commit
425db2d94d
@@ -25,14 +25,15 @@ const importScriptsWithRetry = async (script, { maxRetries = 5 } = {}) => {
|
||||
}
|
||||
|
||||
// Attempt to retry after a small delay
|
||||
await new Promise(resolve =>
|
||||
setTimeout(async () => {
|
||||
await importScriptsWithRetry(script, {
|
||||
await new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
importScriptsWithRetry(script, {
|
||||
maxRetries: maxRetries - 1,
|
||||
});
|
||||
resolve();
|
||||
}, 5000),
|
||||
);
|
||||
})
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,9 +77,11 @@ self.addEventListener('message', async event => {
|
||||
return;
|
||||
}
|
||||
|
||||
// A single failed importScripts bricks the SharedWorker until
|
||||
// it's evicted, so retry in production too.
|
||||
await importScriptsWithRetry(
|
||||
`${msg.publicUrl}/kcab/kcab.worker.${hash}.js`,
|
||||
{ maxRetries: isDev ? 5 : 0 },
|
||||
{ maxRetries: isDev ? 5 : 3 },
|
||||
);
|
||||
|
||||
backend.initApp(isDev, self).catch(err => {
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('FatalError', () => {
|
||||
expect(screen.getByText(/IndexedDB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the generic simple message for an app-init-failure without a specific cause', () => {
|
||||
it('renders a backend-worker message for a BackendInitFailure', () => {
|
||||
const error = {
|
||||
type: 'app-init-failure',
|
||||
BackendInitFailure: true,
|
||||
@@ -38,6 +38,16 @@ describe('FatalError', () => {
|
||||
|
||||
render(<FatalError error={error} />, { wrapper: TestProviders });
|
||||
|
||||
expect(
|
||||
screen.getByText(/couldn't load a critical backend worker/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the generic simple message for an app-init-failure without a specific cause', () => {
|
||||
const error = { type: 'app-init-failure' };
|
||||
|
||||
render(<FatalError error={error} />, { wrapper: TestProviders });
|
||||
|
||||
expect(
|
||||
screen.getByText(/problem loading the app in this browser version/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -69,10 +69,17 @@ function RenderSimple({ error }: RenderSimpleProps) {
|
||||
</Trans>
|
||||
</Text>
|
||||
);
|
||||
} else if ('BackendInitFailure' in error && error.BackendInitFailure) {
|
||||
msg = (
|
||||
<Text>
|
||||
<Trans>
|
||||
Actual couldn't load a critical backend worker. Reload the page to try
|
||||
again; if the problem persists, do a hard refresh to clear any stale
|
||||
cached assets.
|
||||
</Trans>
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
// This indicates the backend failed to initialize. Show the
|
||||
// user something at least so they aren't looking at a blank
|
||||
// screen
|
||||
msg = (
|
||||
<Text>
|
||||
<Trans>
|
||||
@@ -92,19 +99,6 @@ function RenderSimple({ error }: RenderSimpleProps) {
|
||||
}}
|
||||
>
|
||||
<Text>{msg}</Text>
|
||||
<Text>
|
||||
<Trans>
|
||||
Please get{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
linkColor="muted"
|
||||
to="https://actualbudget.org/contact"
|
||||
>
|
||||
in touch
|
||||
</Link>{' '}
|
||||
for support
|
||||
</Trans>
|
||||
</Text>
|
||||
</SpaceBetween>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,6 +172,47 @@ describe('SharedWorker coordinator', () => {
|
||||
expect.objectContaining({ type: 'app-init-failure', error: 'boom' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the cached init failure when the client acknowledges it', () => {
|
||||
const leader = connectTab(coordinator);
|
||||
sendInit(leader);
|
||||
simulateWorkerConnect(leader);
|
||||
|
||||
sendMsg(leader, {
|
||||
type: '__from-worker',
|
||||
msg: { type: 'app-init-failure', error: 'boom' },
|
||||
});
|
||||
expect(coordinator.getState().lastAppInitFailure).toEqual(
|
||||
expect.objectContaining({ type: 'app-init-failure' }),
|
||||
);
|
||||
|
||||
sendMsg(leader, { name: '__app-init-failure-acknowledged' });
|
||||
expect(coordinator.getState().lastAppInitFailure).toBeNull();
|
||||
|
||||
const port2 = connectTab(coordinator);
|
||||
sendInit(port2);
|
||||
expect(port2.postMessage).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'app-init-failure' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the cached init failure when a backend later connects successfully', () => {
|
||||
const failedLeader = connectTab(coordinator);
|
||||
sendInit(failedLeader);
|
||||
sendMsg(failedLeader, {
|
||||
type: '__from-worker',
|
||||
msg: { type: 'app-init-failure', error: 'boom' },
|
||||
});
|
||||
expect(coordinator.getState().lastAppInitFailure).not.toBeNull();
|
||||
|
||||
sendMsg(failedLeader, { type: 'tab-closing' });
|
||||
|
||||
const newLeader = connectTab(coordinator);
|
||||
sendInit(newLeader);
|
||||
simulateWorkerConnect(newLeader);
|
||||
|
||||
expect(coordinator.getState().lastAppInitFailure).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Load budget ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -100,6 +100,7 @@ export function createCoordinator({
|
||||
}
|
||||
|
||||
function broadcastConnect(budgetId: string) {
|
||||
lastAppInitFailure = null;
|
||||
const connectMsg = { type: 'connect' };
|
||||
broadcastToAllInGroup(budgetId, connectMsg);
|
||||
for (const port of unassignedPorts) {
|
||||
@@ -544,10 +545,22 @@ export function createCoordinator({
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop the cache once the client has surfaced the failure, so a
|
||||
// subsequent init can re-attempt. Falls through to the leader so
|
||||
// the Worker still stops its retry interval.
|
||||
if (msg.name === '__app-init-failure-acknowledged') {
|
||||
lastAppInitFailure = null;
|
||||
}
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────
|
||||
|
||||
if (msg.type === 'init') {
|
||||
cachedInitMsg = msg;
|
||||
// The leader that produced the failure is gone, so the cache
|
||||
// can't recover — drop it and let this tab attempt a fresh init.
|
||||
if (lastAppInitFailure && budgetGroups.size === 0) {
|
||||
lastAppInitFailure = null;
|
||||
}
|
||||
if (lastAppInitFailure) {
|
||||
port.postMessage(lastAppInitFailure);
|
||||
} else {
|
||||
|
||||
6
upcoming-release-notes/7761.md
Normal file
6
upcoming-release-notes/7761.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Surface the backend init failures with a custom error screen and message.
|
||||
Reference in New Issue
Block a user