app: rework service worker registration

- registration is moved from top-level index to app/sagas.
- mock implementation is provided for tests
- actions are renamed to include "app" prefix
- actions are changed to not use non-serializable arguments
- reducers are changed to not use non-serializable state
This commit is contained in:
David Lechner
2022-03-09 15:48:30 -06:00
parent 8b75e3f1a3
commit 1469d2146e
14 changed files with 269 additions and 142 deletions
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
// mock implementation of serviceWorkerRegistration for testing
type Config = {
onSuccess?: (r: ServiceWorkerRegistration) => void;
onUpdate?: (r: ServiceWorkerRegistration) => void;
};
/** The config that was registered, if any. */
let globalConfig: Config | undefined;
/**
* Mocks the register() function.
*
* This just saves the config for later use.
*
* @param config The config.
*/
export function register(config?: Config): void {
globalConfig = config;
}
/**
* Fires the onSuccess callback that was registered, if any.
*
* @param registration The registration to pass to the callback.
*/
export function _fireOnSuccess(registration: ServiceWorkerRegistration): void {
if (globalConfig && globalConfig.onSuccess) {
globalConfig.onSuccess(registration);
}
}
/**
* Fires the onUpdate callback that was registered, if any.
*
* @param registration The registration to pass to the callback.
*/
export function _fireOnUpdate(registration: ServiceWorkerRegistration): void {
if (globalConfig && globalConfig.onUpdate) {
globalConfig.onUpdate(registration);
}
}
/**
* Mocks the unregister() function.
*/
export function unregister(): void {
// nothing to do for now
}
+7 -11
View File
@@ -5,22 +5,18 @@
import { createAction } from '../actions';
/** Creates an action that requests the app to reload. */
export const reload = createAction((registration: ServiceWorkerRegistration) => ({
/** Action that requests the app to reload. */
export const appReload = createAction(() => ({
type: 'app.action.reload',
registration,
}));
/** Action that requests to check for updates. */
export const checkForUpdate = createAction(
(registration: ServiceWorkerRegistration) => ({
type: 'app.action.checkForUpdate',
registration,
}),
);
/* Action that requests to check for updates. */
export const appCheckForUpdate = createAction(() => ({
type: 'app.action.checkForUpdate',
}));
/** Action that indicates that checking for an update has completed. */
export const didCheckForUpdate = createAction((updateFound: boolean) => ({
export const appDidCheckForUpdate = createAction((updateFound: boolean) => ({
type: 'app.action.didCheckForUpdate',
updateFound,
}));
+28 -35
View File
@@ -7,11 +7,11 @@ import {
serviceWorkerDidUpdate,
} from '../service-worker/actions';
import {
appCheckForUpdate,
appDidCheckForUpdate,
appDidReceiveBeforeInstallPrompt,
appDidResolveInstallPrompt,
appShowInstallPrompt,
checkForUpdate,
didCheckForUpdate,
didInstall,
} from './actions';
import reducers from './reducers';
@@ -23,57 +23,52 @@ test('initial state', () => {
Object {
"checkingForUpdate": false,
"hasUnresolvedInstallPrompt": false,
"isServiceWorkerRegistered": false,
"promptingInstall": false,
"readyForOfflineUse": false,
"serviceWorker": null,
"updateAvailable": false,
}
`);
});
test('serviceWorker', () => {
const registration = {} as ServiceWorkerRegistration;
expect(
reducers(
{ serviceWorker: null } as State,
serviceWorkerDidSucceed(registration),
).serviceWorker,
).toBe(registration);
describe('isServiceWorkerRegistered', () => {
it('should be true when service worker registration succeeds', () => {
expect(
reducers(
{ isServiceWorkerRegistered: false } as State,
serviceWorkerDidSucceed(),
).isServiceWorkerRegistered,
).toBe(true);
});
});
test('checkingForUpdate', () => {
expect(
reducers(
{ checkingForUpdate: false } as State,
checkForUpdate({} as ServiceWorkerRegistration),
).checkingForUpdate,
).toBe(true);
expect(
reducers({ checkingForUpdate: false } as State, didCheckForUpdate(true))
.checkingForUpdate,
).toBe(false);
expect(
reducers({ checkingForUpdate: true } as State, didCheckForUpdate(true))
reducers({ checkingForUpdate: false } as State, appCheckForUpdate())
.checkingForUpdate,
).toBe(true);
expect(
reducers({ checkingForUpdate: true } as State, didCheckForUpdate(false))
reducers({ checkingForUpdate: false } as State, appDidCheckForUpdate(true))
.checkingForUpdate,
).toBe(false);
expect(
reducers(
{ checkingForUpdate: true } as State,
serviceWorkerDidUpdate({} as ServiceWorkerRegistration),
).checkingForUpdate,
reducers({ checkingForUpdate: true } as State, appDidCheckForUpdate(true))
.checkingForUpdate,
).toBe(true);
expect(
reducers({ checkingForUpdate: true } as State, appDidCheckForUpdate(false))
.checkingForUpdate,
).toBe(false);
expect(
reducers({ checkingForUpdate: true } as State, serviceWorkerDidUpdate())
.checkingForUpdate,
).toBe(false);
});
test('updateAvailable', () => {
expect(
reducers(
{ updateAvailable: false } as State,
serviceWorkerDidUpdate({} as ServiceWorkerRegistration),
).updateAvailable,
reducers({ updateAvailable: false } as State, serviceWorkerDidUpdate())
.updateAvailable,
).toBe(true);
});
@@ -124,9 +119,7 @@ describe('promptingInstall', () => {
test('readyForOfflineUse', () => {
expect(
reducers(
{ readyForOfflineUse: false } as State,
serviceWorkerDidSucceed({} as ServiceWorkerRegistration),
).readyForOfflineUse,
reducers({ readyForOfflineUse: false } as State, serviceWorkerDidSucceed())
.readyForOfflineUse,
).toBe(true);
});
+8 -10
View File
@@ -9,31 +9,29 @@ import {
serviceWorkerDidUpdate,
} from '../service-worker/actions';
import {
appCheckForUpdate,
appDidCheckForUpdate,
appDidReceiveBeforeInstallPrompt,
appDidResolveInstallPrompt,
appShowInstallPrompt,
checkForUpdate,
didCheckForUpdate,
didInstall,
} from './actions';
const serviceWorker: Reducer<ServiceWorkerRegistration | null> = (
state = null,
action,
) => {
/** Indicates that the service worker was successfully registered. */
const isServiceWorkerRegistered: Reducer<boolean> = (state = false, action) => {
if (serviceWorkerDidSucceed.matches(action)) {
return action.registration;
return true;
}
return state;
};
const checkingForUpdate: Reducer<boolean> = (state = false, action) => {
if (checkForUpdate.matches(action)) {
if (appCheckForUpdate.matches(action)) {
return true;
}
if (didCheckForUpdate.matches(action)) {
if (appDidCheckForUpdate.matches(action)) {
if (!action.updateFound) {
return false;
}
@@ -94,7 +92,7 @@ const readyForOfflineUse: Reducer<boolean> = (state = false, action) => {
};
export default combineReducers({
serviceWorker,
isServiceWorkerRegistered,
checkingForUpdate,
updateAvailable,
hasUnresolvedInstallPrompt,
+79 -24
View File
@@ -2,19 +2,45 @@
// Copyright (c) 2021-2022 The Pybricks Authors
import { createEvent, fireEvent } from '@testing-library/dom';
import { AsyncSaga, delay } from '../../test';
import { mock } from 'jest-mock-extended';
import { AsyncSaga } from '../../test';
import {
serviceWorkerDidSucceed,
serviceWorkerDidUpdate,
} from '../service-worker/actions';
import { BeforeInstallPromptEvent } from '../utils/dom';
import {
appCheckForUpdate,
appDidCheckForUpdate,
appDidReceiveBeforeInstallPrompt,
appDidResolveInstallPrompt,
appReload,
appShowInstallPrompt,
checkForUpdate,
didCheckForUpdate,
didInstall,
reload,
} from './actions';
import app from './sagas';
jest.mock('../serviceWorkerRegistration');
/**
* Creates an AsyncSaga initialize with a service worker.
* @param registration The service worker registration.
* @returns The saga.
*/
async function createSagaWithRegistration(
registration: ServiceWorkerRegistration,
): Promise<AsyncSaga> {
const saga = new AsyncSaga(app);
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('../serviceWorkerRegistration')._fireOnSuccess(registration);
const action = await saga.take();
expect(action).toEqual(serviceWorkerDidSucceed());
return saga;
}
test('monitorAppInstalled', async () => {
const saga = new AsyncSaga(app);
@@ -37,13 +63,13 @@ test('monitorBeforeInstallPrompt', async () => {
await saga.end();
});
test('reload', async () => {
const saga = new AsyncSaga(app);
test('handleAppReload', async () => {
// mock registration as if service worker was register on app startup
const registration: Partial<ServiceWorkerRegistration> = {
const registration = mock<ServiceWorkerRegistration>({
unregister: jest.fn(),
};
});
const saga = await createSagaWithRegistration(registration);
// @ts-expect-error: JSDOM implementation of location.reload() causes error
delete window.location;
@@ -52,7 +78,7 @@ test('reload', async () => {
reload: jest.fn(),
};
saga.put(reload(registration as ServiceWorkerRegistration));
saga.put(appReload());
expect(registration.unregister).toHaveBeenCalled();
expect(location.reload).toHaveBeenCalled();
@@ -60,26 +86,55 @@ test('reload', async () => {
await saga.end();
});
test('checkForUpdates', async () => {
const saga = new AsyncSaga(app);
describe('handleAppCheckForUpdate', () => {
it('should return true if updates are available', async () => {
// mock registration as if service worker was registered on app startup
const registration = mock<ServiceWorkerRegistration>({
update: jest.fn(),
installing: mock<ServiceWorker>(),
});
// mock registration as if service worker was register on app startup
const registration: Partial<ServiceWorkerRegistration> = {
update: jest.fn(),
installing: null,
};
const saga = await createSagaWithRegistration(registration);
saga.put(checkForUpdate(registration as ServiceWorkerRegistration));
saga.put(appCheckForUpdate());
// yield to allow generators to complete
await delay(0);
const action = await saga.take();
expect(action).toStrictEqual(appDidCheckForUpdate(true));
expect(registration.update).toHaveBeenCalled();
expect(registration.update).toHaveBeenCalled();
await saga.end();
});
const action = await saga.take();
expect(action).toStrictEqual(didCheckForUpdate(false));
it('should return false if no updates are available', async () => {
// mock registration as if service worker was registered on app startup
const registration = mock<ServiceWorkerRegistration>({
update: jest.fn(),
installing: null,
});
await saga.end();
const saga = await createSagaWithRegistration(registration);
saga.put(appCheckForUpdate());
const action = await saga.take();
expect(action).toStrictEqual(appDidCheckForUpdate(false));
expect(registration.update).toHaveBeenCalled();
await saga.end();
});
});
describe('monitorServiceWorkerRegistration', () => {
it('should dispatch serviceWorkerDidUpdate', async () => {
const registration = mock<ServiceWorkerRegistration>();
const saga = await createSagaWithRegistration(registration);
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('../serviceWorkerRegistration')._fireOnUpdate(registration);
const action = await saga.take();
expect(action).toEqual(serviceWorkerDidUpdate());
});
});
test('appShowInstallPrompt', async () => {
+64 -16
View File
@@ -3,17 +3,77 @@
import { eventChannel } from 'redux-saga';
import { call, fork, put, take, takeEvery } from 'typed-redux-saga/macro';
import {
serviceWorkerDidSucceed,
serviceWorkerDidUpdate,
} from '../service-worker/actions';
import * as serviceWorkerRegistration from '../serviceWorkerRegistration';
import { BeforeInstallPromptEvent } from '../utils/dom';
import {
appCheckForUpdate,
appDidCheckForUpdate,
appDidReceiveBeforeInstallPrompt,
appDidResolveInstallPrompt,
appReload,
appShowInstallPrompt,
checkForUpdate,
didCheckForUpdate,
didInstall,
reload,
} from './actions';
/**
* Handles appReload actions.
*
* Must be called (forked) with serviceWorkerRegistration context set.
*/
function* handleAppReload(registration: ServiceWorkerRegistration): Generator {
yield* call(() => registration.unregister());
location.reload();
}
/**
* Handles appCheckForUpdate actions.
*
* Must be called (forked) with serviceWorkerRegistration context set.
*/
function* handleAppCheckForUpdate(registration: ServiceWorkerRegistration): Generator {
yield* call(() => registration.update());
const updateFound = registration.installing !== null;
yield* put(appDidCheckForUpdate(updateFound));
}
/**
* Marshals CRA serviceWorkerRegistration to saga.
*/
function* monitorServiceWorkerRegistration(): Generator {
const chan = eventChannel<{
isUpdate: boolean;
registration: ServiceWorkerRegistration;
}>((emit) => {
serviceWorkerRegistration.register({
onSuccess: (r) => emit({ isUpdate: false, registration: r }),
onUpdate: (r) => emit({ isUpdate: true, registration: r }),
});
// istanbul ignore next: never unregistered
return () => serviceWorkerRegistration.unregister();
});
// HACK: is assumed that this will only be called at most two times, once
// with isUpdate === false and after that, once with isUpdate === true.
while (true) {
const { isUpdate, registration } = yield* take(chan);
if (isUpdate) {
yield* put(serviceWorkerDidUpdate());
} else {
yield* takeEvery(appReload, handleAppReload, registration);
yield* takeEvery(appCheckForUpdate, handleAppCheckForUpdate, registration);
yield* put(serviceWorkerDidSucceed());
}
}
}
function* monitorAppInstalled(): Generator {
const chan = eventChannel<Event>((emit) => {
const listener = (e: Event) => {
@@ -61,20 +121,8 @@ function* monitorBeforeInstallPrompt(): Generator {
}
}
function* handleReload(action: ReturnType<typeof reload>): Generator {
yield* call(() => action.registration.unregister());
location.reload();
}
function* handleCheckForUpdate(action: ReturnType<typeof checkForUpdate>): Generator {
yield* call(() => action.registration.update());
const updateFound = action.registration.installing !== null;
yield* put(didCheckForUpdate(updateFound));
}
export default function* app(): Generator {
yield* fork(monitorServiceWorkerRegistration);
yield* fork(monitorAppInstalled);
yield* fork(monitorBeforeInstallPrompt);
yield* takeEvery(reload, handleReload);
yield* takeEvery(checkForUpdate, handleCheckForUpdate);
}
-13
View File
@@ -16,11 +16,6 @@ import * as I18nToaster from './notifications/I18nToaster';
import { rootReducer } from './reducers';
import reportWebVitals from './reportWebVitals';
import rootSaga, { RootSagaContext } from './sagas';
import {
serviceWorkerDidSucceed,
serviceWorkerDidUpdate,
} from './service-worker/actions';
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
import { defaultTerminalContext } from './terminal/TerminalContext';
import ViewHeightSensor from './utils/ViewHeightSensor';
import { createCountFunc } from './utils/iter';
@@ -65,14 +60,6 @@ ReactDOM.render(
document.getElementById('root'),
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://cra.link/PWA
serviceWorkerRegistration.register({
onUpdate: (r) => store.dispatch(serviceWorkerDidUpdate(r)),
onSuccess: (r) => store.dispatch(serviceWorkerDidSucceed(r)),
});
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
+5 -5
View File
@@ -9,7 +9,7 @@ import {
} from '@pybricks/firmware';
import { AnyAction } from 'redux';
import { AsyncSaga } from '../../test';
import { didCheckForUpdate } from '../app/actions';
import { appDidCheckForUpdate } from '../app/actions';
import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions';
import {
BleDeviceFailToConnectReasonType,
@@ -63,7 +63,7 @@ test.each([
didFailToCompile(['reason']),
add('warning', 'message'),
add('error', 'message', 'url'),
serviceWorkerDidUpdate({} as ServiceWorkerRegistration),
serviceWorkerDidUpdate(),
didFailToFinish(FailToFinishReasonType.TimedOut),
didFailToFinish(
FailToFinishReasonType.BleError,
@@ -89,7 +89,7 @@ test.each([
didFailToFinish(FailToFinishReasonType.FailedToCompile),
didFailToFinish(FailToFinishReasonType.FirmwareSize),
didFailToFinish(FailToFinishReasonType.Unknown, new Error('test error')),
didCheckForUpdate(false),
appDidCheckForUpdate(false),
bleDIServiceDidReceiveFirmwareRevision('3.0.0'),
didFailToSaveAs(new DOMException('test message', 'NotAllowedError')),
fileStorageDidFailToInitialize(new Error('test error')),
@@ -125,8 +125,8 @@ test.each([
bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.Canceled }),
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.Canceled),
didFailToFinish(FailToFinishReasonType.FailedToConnect),
serviceWorkerDidSucceed({} as ServiceWorkerRegistration),
didCheckForUpdate(true),
serviceWorkerDidSucceed(),
appDidCheckForUpdate(true),
bleDIServiceDidReceiveFirmwareRevision(firmwareVersion),
didFailToSaveAs(new DOMException('test message', 'AbortError')),
fileStorageDidFailToExportFile(
+6 -8
View File
@@ -10,7 +10,7 @@ import React from 'react';
import { channel } from 'redux-saga';
import * as semver from 'semver';
import { delay, getContext, put, take, takeEvery } from 'typed-redux-saga/macro';
import { didCheckForUpdate, reload } from '../app/actions';
import { appDidCheckForUpdate, appReload } from '../app/actions';
import { appName } from '../app/constants';
import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions';
import {
@@ -31,7 +31,7 @@ import {
didFailToConnect as bootloaderDidFailToConnect,
} from '../lwp3-bootloader/actions';
import { didCompile, didFailToCompile } from '../mpy/actions';
import { serviceWorkerDidUpdate as serviceWorkerDidUpdate } from '../service-worker/actions';
import { serviceWorkerDidUpdate } from '../service-worker/actions';
import { pythonVersionToSemver } from '../utils/version';
import NotificationAction from './NotificationAction';
import NotificationMessage from './NotificationMessage';
@@ -336,9 +336,7 @@ function* handleAddNotification(action: ReturnType<typeof addNotification>): Gen
});
}
function* showServiceWorkerUpdate(
action: ReturnType<typeof serviceWorkerDidUpdate>,
): Generator {
function* showServiceWorkerUpdate(): Generator {
const ch = channel<React.MouseEvent<HTMLElement>>();
const userAction = dispatchAction(
MessageId.ServiceWorkerUpdateAction,
@@ -358,10 +356,10 @@ function* showServiceWorkerUpdate(
yield* take(ch);
yield* put(reload(action.registration));
yield* put(appReload());
}
function* showNoUpdateInfo(action: ReturnType<typeof didCheckForUpdate>): Generator {
function* showNoUpdateInfo(action: ReturnType<typeof appDidCheckForUpdate>): Generator {
if (action.updateFound) {
// this will be handled by serviceWorkerDidUpdate action
return;
@@ -442,7 +440,7 @@ export default function* (): Generator {
yield* takeEvery(didFailToCompile, showCompilerError);
yield* takeEvery(addNotification, handleAddNotification);
yield* takeEvery(serviceWorkerDidUpdate, showServiceWorkerUpdate);
yield* takeEvery(didCheckForUpdate, showNoUpdateInfo);
yield* takeEvery(appDidCheckForUpdate, showNoUpdateInfo);
yield* takeEvery(bleDIServiceDidReceiveFirmwareRevision, checkVersion);
yield* takeEvery(fileStorageDidFailToInitialize, showFileStorageFailToInitialize);
yield* takeEvery(fileStorageDidFailToReadFile, showFileStorageFailToRead);
+2
View File
@@ -1,3 +1,5 @@
// istanbul ignore file
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler): void => {
+2
View File
@@ -8,6 +8,8 @@
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.
// istanbul ignore file
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching';
+6 -12
View File
@@ -3,16 +3,10 @@
import { createAction } from '../actions';
export const serviceWorkerDidUpdate = createAction(
(registration: ServiceWorkerRegistration) => ({
type: 'serviceWorker.action.didUpdate',
registration,
}),
);
export const serviceWorkerDidUpdate = createAction(() => ({
type: 'serviceWorker.action.didUpdate',
}));
export const serviceWorkerDidSucceed = createAction(
(registration: ServiceWorkerRegistration) => ({
type: 'serviceWorker.action.didSucceed',
registration,
}),
);
export const serviceWorkerDidSucceed = createAction(() => ({
type: 'serviceWorker.action.didSucceed',
}));
+2
View File
@@ -10,6 +10,8 @@
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA
// istanbul ignore file
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
+8 -8
View File
@@ -21,7 +21,7 @@ import { useI18n } from '@shopify/react-i18n';
import React, { useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import AboutDialog from '../about/AboutDialog';
import { appShowInstallPrompt, checkForUpdate, reload } from '../app/actions';
import { appCheckForUpdate, appReload, appShowInstallPrompt } from '../app/actions';
import {
pybricksBugReportsUrl,
pybricksGitterUrl,
@@ -50,7 +50,9 @@ const SettingsDrawer: React.FunctionComponent<SettingsProps> = (props) => {
const showDocs = useSelector((s) => s.settings.showDocs);
const darkMode = useSelector((s) => s.settings.darkMode);
const flashCurrentProgram = useSelector((s) => s.settings.flashCurrentProgram);
const serviceWorker = useSelector((s) => s.app.serviceWorker);
const isServiceWorkerRegistered = useSelector(
(s) => s.app.isServiceWorkerRegistered,
);
const checkingForUpdate = useSelector((s) => s.app.checkingForUpdate);
const updateAvailable = useSelector((s) => s.app.updateAvailable);
const hasUnresolvedInstallPrompt = useSelector(
@@ -290,12 +292,10 @@ const SettingsDrawer: React.FunctionComponent<SettingsProps> = (props) => {
{i18n.translate(SettingsStringId.AppInstallLabel)}
</Button>
)}
{serviceWorker && !updateAvailable && (
{isServiceWorkerRegistered && !updateAvailable && (
<Button
icon="refresh"
onClick={() =>
dispatch(checkForUpdate(serviceWorker))
}
onClick={() => dispatch(appCheckForUpdate())}
loading={checkingForUpdate}
>
{i18n.translate(
@@ -303,10 +303,10 @@ const SettingsDrawer: React.FunctionComponent<SettingsProps> = (props) => {
)}
</Button>
)}
{serviceWorker && updateAvailable && (
{isServiceWorkerRegistered && updateAvailable && (
<Button
icon="refresh"
onClick={() => dispatch(reload(serviceWorker))}
onClick={() => dispatch(appReload())}
>
{i18n.translate(SettingsStringId.AppRestartLabel)}
</Button>