Skip to content

Commit 4f4dc23

Browse files
committed
fix: harden hosted update fallback checks
1 parent 3f87dc6 commit 4f4dc23

4 files changed

Lines changed: 129 additions & 3 deletions

File tree

src/app/hooks/useSwUpdateAvailable.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,19 @@ describe('useSwUpdateAvailable', () => {
141141
vi.useRealTimers();
142142
});
143143

144+
it('still performs automatic hosted update checks when service workers are unavailable', async () => {
145+
Object.defineProperty(window, 'navigator', {
146+
configurable: true,
147+
value: {},
148+
});
149+
150+
renderHook(() => useSwUpdateAvailable());
151+
152+
await waitFor(() => {
153+
expect(appUpdatesMocks.checkForAppUpdates).toHaveBeenCalledTimes(1);
154+
});
155+
});
156+
144157
it('skips hidden checks and retries once the app becomes visible again', async () => {
145158
setVisibility('hidden');
146159

src/app/hooks/useSwUpdateAvailable.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ export function useSwUpdateAvailable(): boolean {
3434

3535
const requestAutomaticUpdateCheck = () => {
3636
if (disposed) return;
37-
if (!('serviceWorker' in navigator)) return;
3837
if (document.visibilityState === 'hidden' || updateCheckInFlight) return;
3938

4039
updateCheckInFlight = true;

src/app/utils/appUpdates.test.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,52 @@ describe('appUpdates', () => {
386386
expect((result as Error).message).toBe(
387387
'Failed to check for updates. Reload the app and try again.'
388388
);
389-
expect(fetchMock).not.toHaveBeenCalled();
389+
expect(fetchMock).toHaveBeenCalledTimes(1);
390+
});
391+
392+
it('falls back to the hosted shell check when one service worker probe fails', async () => {
393+
const currentRegistration = createRegistration('/current');
394+
const secondaryRegistration = createRegistration('/secondary');
395+
secondaryRegistration.update.mockRejectedValueOnce(new TypeError('network failed'));
396+
fetchMock.mockResolvedValueOnce(
397+
new Response(
398+
`
399+
<!doctype html>
400+
<html>
401+
<head>
402+
<link rel="stylesheet" href="/assets/index-next.css" />
403+
</head>
404+
<body>
405+
<script type="module" src="/assets/index-next.js"></script>
406+
</body>
407+
</html>
408+
`,
409+
{ status: 200, headers: { 'Content-Type': 'text/html' } }
410+
)
411+
);
412+
413+
Object.defineProperty(window, 'navigator', {
414+
configurable: true,
415+
value: {
416+
serviceWorker: {
417+
controller: { postMessage: vi.fn() },
418+
getRegistration: vi.fn().mockResolvedValue(currentRegistration),
419+
getRegistrations: vi.fn().mockResolvedValue([currentRegistration, secondaryRegistration]),
420+
ready: Promise.resolve(currentRegistration),
421+
addEventListener: vi.fn(),
422+
removeEventListener: vi.fn(),
423+
},
424+
},
425+
});
426+
427+
const resultPromise = checkForAppUpdates();
428+
await vi.runAllTimersAsync();
429+
430+
await expect(resultPromise).resolves.toEqual({
431+
kind: 'update-available',
432+
message: 'A newer hosted app version is ready to apply.',
433+
canApply: true,
434+
});
390435
});
391436

392437
it('returns once any registration confirms an update', async () => {
@@ -771,6 +816,64 @@ describe('appUpdates', () => {
771816
expect(mockReloadWithTelemetry).toHaveBeenCalledWith('apply_pending_app_update');
772817
});
773818

819+
it('clears a stale hosted shell update detection after a later up-to-date check', async () => {
820+
fetchMock
821+
.mockResolvedValueOnce(
822+
new Response(
823+
`
824+
<!doctype html>
825+
<html>
826+
<head>
827+
<link rel="stylesheet" href="/assets/index-next.css" />
828+
</head>
829+
<body>
830+
<script type="module" src="/assets/index-next.js"></script>
831+
</body>
832+
</html>
833+
`,
834+
{ status: 200, headers: { 'Content-Type': 'text/html' } }
835+
)
836+
)
837+
.mockResolvedValueOnce(
838+
new Response(
839+
`
840+
<!doctype html>
841+
<html>
842+
<head>
843+
<link rel="stylesheet" href="/assets/index-current.css" />
844+
</head>
845+
<body>
846+
<script type="module" src="/assets/index-current.js"></script>
847+
</body>
848+
</html>
849+
`,
850+
{ status: 200, headers: { 'Content-Type': 'text/html' } }
851+
)
852+
)
853+
.mockRejectedValueOnce(new TypeError('network failed'));
854+
855+
const firstCheckPromise = checkForAppUpdates();
856+
await vi.runAllTimersAsync();
857+
await expect(firstCheckPromise).resolves.toEqual({
858+
kind: 'update-available',
859+
message: 'A newer hosted app version is ready to apply.',
860+
canApply: true,
861+
});
862+
863+
const secondCheckPromise = checkForAppUpdates();
864+
await vi.runAllTimersAsync();
865+
await expect(secondCheckPromise).resolves.toEqual({
866+
kind: 'up-to-date',
867+
message: 'You are already on the latest available web app version.',
868+
canApply: false,
869+
});
870+
871+
await applyPendingAppUpdate();
872+
873+
expect(mockClearClientCachesAndServiceWorkers).not.toHaveBeenCalled();
874+
expect(mockReloadWithTelemetry).not.toHaveBeenCalled();
875+
});
876+
774877
it('reports hosted updates even when service workers are unavailable', async () => {
775878
mockHasServiceWorker.mockReturnValue(false);
776879
fetchMock.mockResolvedValueOnce(

src/app/utils/appUpdates.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ export async function checkForAppUpdates(): Promise<AppUpdateCheckResult> {
402402
};
403403
}
404404

405-
if (registrations.length > 0 && (successfulUpdates.length === 0 || rejectedUpdates.length > 0)) {
405+
if (registrations.length > 0 && successfulUpdates.length === 0) {
406406
const firstError = rejectedUpdates[0]?.reason;
407407
throw firstError instanceof Error
408408
? new Error(UPDATE_CHECK_FAILURE_MESSAGE, { cause: firstError })
@@ -419,6 +419,17 @@ export async function checkForAppUpdates(): Promise<AppUpdateCheckResult> {
419419
};
420420
}
421421

422+
if (hostedAppShellUpdateStatus === 'up-to-date') {
423+
hostedAppShellUpdateDetected = false;
424+
}
425+
426+
if (rejectedUpdates.length > 0) {
427+
const firstError = rejectedUpdates[0]?.reason;
428+
throw firstError instanceof Error
429+
? new Error(UPDATE_CHECK_FAILURE_MESSAGE, { cause: firstError })
430+
: new Error(UPDATE_CHECK_FAILURE_MESSAGE);
431+
}
432+
422433
if (registrations.length === 0) {
423434
if (hostedAppShellUpdateStatus === 'up-to-date') {
424435
return {

0 commit comments

Comments
 (0)