app: rework BeforeInstallPromptEvent

This removed passing the BeforeInstallPromptEvent as an action argument
(action arguments should be serializable). Instead the saga just keeps
a reference to the event and actions are used only to resolve the state
of the UI.
This commit is contained in:
David Lechner
2022-03-08 13:37:43 -06:00
parent 7884f047b5
commit 907826cf0e
6 changed files with 119 additions and 87 deletions
+11 -13
View File
@@ -4,7 +4,6 @@
// Actions for the app in general.
import { createAction } from '../actions';
import { BeforeInstallPromptEvent } from '../utils/dom';
/** Creates an action that requests the app to reload. */
export const reload = createAction((registration: ServiceWorkerRegistration) => ({
@@ -27,23 +26,22 @@ export const didCheckForUpdate = createAction((updateFound: boolean) => ({
}));
/* Action that indicates the browser wants to prompt the use to install the app. */
export const didBeforeInstallPrompt = createAction(
(event: BeforeInstallPromptEvent) => ({
type: 'app.action.didBeforeInstallPrompt',
event,
}),
);
export const appDidReceiveBeforeInstallPrompt = createAction(() => ({
type: 'app.action.didBeforeInstallPrompt',
}));
/* Action that requests to prompt the user to install the app. */
export const installPrompt = createAction((event: BeforeInstallPromptEvent) => ({
type: 'app.action.installPrompt',
event,
export const appShowInstallPrompt = createAction(() => ({
type: 'app.action.showInstallPrompt',
}));
/* Action that indicates that the user responded to the install prompt. */
export const didInstallPrompt = createAction(() => ({
type: 'app.action.didInstallPrompt',
}));
export const appDidResolveInstallPrompt = createAction(
(result: { outcome: 'accepted' | 'dismissed'; platform: string }) => ({
type: 'app.action.didResolveInstallPrompt',
result,
}),
);
/* Action that indicates app was installed. */
export const didInstall = createAction(() => ({
+45 -26
View File
@@ -6,14 +6,13 @@ import {
serviceWorkerDidSucceed,
serviceWorkerDidUpdate,
} from '../service-worker/actions';
import { BeforeInstallPromptEvent } from '../utils/dom';
import {
appDidReceiveBeforeInstallPrompt,
appDidResolveInstallPrompt,
appShowInstallPrompt,
checkForUpdate,
didBeforeInstallPrompt,
didCheckForUpdate,
didInstall,
didInstallPrompt,
installPrompt,
} from './actions';
import reducers from './reducers';
@@ -22,8 +21,8 @@ type State = ReturnType<typeof reducers>;
test('initial state', () => {
expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(`
Object {
"beforeInstallPrompt": null,
"checkingForUpdate": false,
"hasUnresolvedInstallPrompt": false,
"promptingInstall": false,
"readyForOfflineUse": false,
"serviceWorker": null,
@@ -78,29 +77,49 @@ test('updateAvailable', () => {
).toBe(true);
});
test('beforeInstallPrompt', () => {
const event = {} as BeforeInstallPromptEvent;
expect(
reducers({ beforeInstallPrompt: null } as State, didBeforeInstallPrompt(event))
.beforeInstallPrompt,
).toBe(event);
expect(
reducers({ beforeInstallPrompt: event } as State, didInstall())
.beforeInstallPrompt,
).toBe(null);
describe('hasUnresolvedInstallPrompt', () => {
it('should be true after BeforeInstallPromptEvent is received', () => {
expect(
reducers(
{ hasUnresolvedInstallPrompt: false } as State,
appDidReceiveBeforeInstallPrompt(),
).hasUnresolvedInstallPrompt,
).toBe(true);
});
it('should be false after the app was successfully installed', () => {
expect(
reducers({ hasUnresolvedInstallPrompt: true } as State, didInstall())
.hasUnresolvedInstallPrompt,
).toBe(false);
});
});
test('promptingInstall', () => {
expect(
reducers(
{ promptingInstall: false } as State,
installPrompt({} as BeforeInstallPromptEvent),
).promptingInstall,
).toBe(true);
expect(
reducers({ promptingInstall: true } as State, didInstallPrompt())
.promptingInstall,
).toBe(false);
describe('promptingInstall', () => {
it('should be true when action requesting to show install prompt is seen', () => {
expect(
reducers({ promptingInstall: false } as State, appShowInstallPrompt())
.promptingInstall,
).toBe(true);
});
it('should be false when user accepts the prompt', () => {
expect(
reducers(
{ promptingInstall: true } as State,
appDidResolveInstallPrompt({ outcome: 'accepted', platform: 'web' }),
).promptingInstall,
).toBe(false);
});
it('should be false when user dismisses the prompt', () => {
expect(
reducers(
{ promptingInstall: true } as State,
appDidResolveInstallPrompt({ outcome: 'dismissed', platform: 'web' }),
).promptingInstall,
).toBe(false);
});
});
test('readyForOfflineUse', () => {
+15 -14
View File
@@ -8,14 +8,13 @@ import {
serviceWorkerDidSucceed,
serviceWorkerDidUpdate,
} from '../service-worker/actions';
import { BeforeInstallPromptEvent } from '../utils/dom';
import {
appDidReceiveBeforeInstallPrompt,
appDidResolveInstallPrompt,
appShowInstallPrompt,
checkForUpdate,
didBeforeInstallPrompt,
didCheckForUpdate,
didInstall,
didInstallPrompt,
installPrompt,
} from './actions';
const serviceWorker: Reducer<ServiceWorkerRegistration | null> = (
@@ -57,27 +56,29 @@ const updateAvailable: Reducer<boolean> = (state = false, action) => {
return state;
};
const beforeInstallPrompt: Reducer<BeforeInstallPromptEvent | null> = (
state = null,
action,
) => {
if (didBeforeInstallPrompt.matches(action)) {
return action.event;
/**
* Indicates that the app has received the BeforeInstallPromptEvent but the
* app has not been installed yet.
*/
const hasUnresolvedInstallPrompt: Reducer<boolean> = (state = false, action) => {
if (appDidReceiveBeforeInstallPrompt.matches(action)) {
return true;
}
if (didInstall.matches(action)) {
return null;
return false;
}
return state;
};
/** Indicates that the browser install prompt is active. */
const promptingInstall: Reducer<boolean> = (state = false, action) => {
if (installPrompt.matches(action)) {
if (appShowInstallPrompt.matches(action)) {
return true;
}
if (didInstallPrompt.matches(action)) {
if (appDidResolveInstallPrompt.matches(action)) {
return false;
}
@@ -96,7 +97,7 @@ export default combineReducers({
serviceWorker,
checkingForUpdate,
updateAvailable,
beforeInstallPrompt,
hasUnresolvedInstallPrompt,
promptingInstall,
readyForOfflineUse,
});
+25 -16
View File
@@ -1,15 +1,16 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Copyright (c) 2021-2022 The Pybricks Authors
import { createEvent, fireEvent } from '@testing-library/dom';
import { AsyncSaga, delay } from '../../test';
import { BeforeInstallPromptEvent } from '../utils/dom';
import {
appDidReceiveBeforeInstallPrompt,
appDidResolveInstallPrompt,
appShowInstallPrompt,
checkForUpdate,
didBeforeInstallPrompt,
didCheckForUpdate,
didInstall,
didInstallPrompt,
installPrompt,
reload,
} from './actions';
import app from './sagas';
@@ -28,11 +29,10 @@ test('monitorAppInstalled', async () => {
test('monitorBeforeInstallPrompt', async () => {
const saga = new AsyncSaga(app);
const event = new Event('beforeinstallprompt') as BeforeInstallPromptEvent;
window.dispatchEvent(event);
fireEvent(window, createEvent('beforeinstallprompt', window));
const action = await saga.take();
expect(action).toStrictEqual(didBeforeInstallPrompt(event));
expect(action).toStrictEqual(appDidReceiveBeforeInstallPrompt());
await saga.end();
});
@@ -82,21 +82,30 @@ test('checkForUpdates', async () => {
await saga.end();
});
test('installPrompt', async () => {
test('appShowInstallPrompt', async () => {
const userChoice = {
outcome: <'accepted' | 'dismissed'>'accepted',
platform: 'web',
};
const saga = new AsyncSaga(app);
// mock registration as if service worker was register on app startup
const event: Partial<BeforeInstallPromptEvent> = {
prompt: jest.fn(),
userChoice: Promise.resolve({ outcome: 'accepted', platform: 'web' }),
};
const event = createEvent('beforeinstallprompt', window);
Object.assign(event, <Partial<BeforeInstallPromptEvent>>{
prompt: () => Promise.resolve<void>(undefined),
userChoice: Promise.resolve(userChoice),
});
saga.put(installPrompt(event as BeforeInstallPromptEvent));
expect(event.prompt).toHaveBeenCalled();
fireEvent(window, event);
const action = await saga.take();
expect(action).toStrictEqual(didInstallPrompt());
expect(action).toStrictEqual(appDidReceiveBeforeInstallPrompt());
saga.put(appShowInstallPrompt());
const action2 = await saga.take();
expect(action2).toStrictEqual(appDidResolveInstallPrompt(userChoice));
await saga.end();
});
+16 -11
View File
@@ -5,12 +5,12 @@ import { eventChannel } from 'redux-saga';
import { call, fork, put, take, takeEvery } from 'typed-redux-saga/macro';
import { BeforeInstallPromptEvent } from '../utils/dom';
import {
appDidReceiveBeforeInstallPrompt,
appDidResolveInstallPrompt,
appShowInstallPrompt,
checkForUpdate,
didBeforeInstallPrompt,
didCheckForUpdate,
didInstall,
didInstallPrompt,
installPrompt,
reload,
} from './actions';
@@ -31,6 +31,15 @@ function* monitorAppInstalled(): Generator {
}
}
function* handleBeforeInstallPromptEvent(event: BeforeInstallPromptEvent): Generator {
// wait for user to request to install the app - may never happen
yield* take(appShowInstallPrompt);
yield* call(() => event.prompt());
const choice = yield* call(() => event.userChoice);
yield* put(appDidResolveInstallPrompt(choice));
}
function* monitorBeforeInstallPrompt(): Generator {
const chan = eventChannel<BeforeInstallPromptEvent>((emit) => {
const listener = (e: BeforeInstallPromptEvent) => {
@@ -43,9 +52,12 @@ function* monitorBeforeInstallPrompt(): Generator {
return () => window.removeEventListener('beforeinstallprompt', listener);
});
// in theory, the before install prompt event should only happen once, so
// we don't bother canceling the forked task when another event is received
while (true) {
const event = yield* take(chan);
yield* put(didBeforeInstallPrompt(event));
yield* fork(handleBeforeInstallPromptEvent, event);
yield* put(appDidReceiveBeforeInstallPrompt());
}
}
@@ -60,16 +72,9 @@ function* handleCheckForUpdate(action: ReturnType<typeof checkForUpdate>): Gener
yield* put(didCheckForUpdate(updateFound));
}
function* handleInstallPrompt(action: ReturnType<typeof installPrompt>): Generator {
yield* call(() => action.event.prompt());
yield* call(() => action.event.userChoice);
yield* put(didInstallPrompt());
}
export default function* app(): Generator {
yield* fork(monitorAppInstalled);
yield* fork(monitorBeforeInstallPrompt);
yield* takeEvery(reload, handleReload);
yield* takeEvery(checkForUpdate, handleCheckForUpdate);
yield* takeEvery(installPrompt, handleInstallPrompt);
}
+7 -7
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Copyright (c) 2021-2022 The Pybricks Authors
import {
AnchorButton,
@@ -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 { checkForUpdate, installPrompt, reload } from '../app/actions';
import { appShowInstallPrompt, checkForUpdate, reload } from '../app/actions';
import {
pybricksBugReportsUrl,
pybricksGitterUrl,
@@ -53,7 +53,9 @@ const SettingsDrawer: React.FunctionComponent<SettingsProps> = (props) => {
const serviceWorker = useSelector((s) => s.app.serviceWorker);
const checkingForUpdate = useSelector((s) => s.app.checkingForUpdate);
const updateAvailable = useSelector((s) => s.app.updateAvailable);
const beforeInstallPrompt = useSelector((s) => s.app.beforeInstallPrompt);
const hasUnresolvedInstallPrompt = useSelector(
(s) => s.app.hasUnresolvedInstallPrompt,
);
const promptingInstall = useSelector((s) => s.app.promptingInstall);
const readyForOfflineUse = useSelector((s) => s.app.readyForOfflineUse);
const hubName = useSelector((s) => s.settings.hubName);
@@ -279,12 +281,10 @@ const SettingsDrawer: React.FunctionComponent<SettingsProps> = (props) => {
}
>
<ButtonGroup minimal={true} vertical={true} alignText="left">
{beforeInstallPrompt && (
{hasUnresolvedInstallPrompt && (
<Button
icon="add"
onClick={() =>
dispatch(installPrompt(beforeInstallPrompt))
}
onClick={() => dispatch(appShowInstallPrompt())}
loading={promptingInstall}
>
{i18n.translate(SettingsStringId.AppInstallLabel)}