diff --git a/.eslintrc b/.eslintrc index c295fad..b72b240 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,13 +1,14 @@ { - "parser": "babel-eslint", "extends": [ - "standard", - "standard-react", + "react-app", + "react-app/jest", "plugin:prettier/recommended", "prettier" ], "env": { - "node": true + "node": true, + "serviceworker": true, + "browser": true }, "parserOptions": { "ecmaVersion": 2020, diff --git a/README.md b/README.md index 755a11b..15ee144 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ [![NPM](https://img.shields.io/npm/v/@3m1/service-worker-updater.svg)](https://www.npmjs.com/package/@3m1/service-worker-updater) -[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) [![Test](https://github.com/emibcn/service-worker-updater/actions/workflows/test.js.yml/badge.svg)](https://github.com/emibcn/service-worker-updater/actions/workflows/test.js.yml) ![Coverage](https://raw.githubusercontent.com/emibcn/service-worker-updater/badges/main/test-coverage.svg) [![BundlePhobia Minified Size](https://badgen.net/bundlephobia/min/@3m1/service-worker-updater)](https://bundlephobia.com/result?p=@3m1/service-worker-updater) @@ -14,6 +13,7 @@ If you have opted-in for the `register` callback of `serviceWorkerRegistration` in the `index.js` of the [PWA version of Create React APP](https://create-react-app.dev/docs/making-a-progressive-web-app/), you probably want to allow your users to update the application once a new service worker has been detected. ## How it works + Usually, browsers check for a new service worker version of a PWA every few days, or whenever the user reloads the page. But reloading the page does not necessarily updates the service worker. As the code managing the service worker is usually outside the React components tree, the message of a _new service worker detected_ needs to be passed through another mechanism than props or contexts. Here, we use an event triggered over `document`, which will previously have been added a listener. The component that adds the listener **is** inside the React's components tree, and receives and saves the `resgistration` object for later use in the `onLoadNewServiceWorkerAccept` callback. ## Install @@ -35,14 +35,15 @@ yarn add @3m1/service-worker-updater This library is composed by 2 parts: ### `onServiceWorkerUpdate` + Callback to be added to the `serviceWorkerRegistration.register` call on your `index.js`. **This step is mandatory**, or the message will not arrive to your inner component. -```jsx -import React from 'react'; -import ReactDOM from 'react-dom'; -import App from './App'; -import * as serviceWorkerRegistration from './serviceWorkerRegistration'; -import { onServiceWorkerUpdate } from '@3m1/service-worker-updater'; +```tsx +import React from 'react' +import ReactDOM from 'react-dom' +import App from './App' +import * as serviceWorkerRegistration from './serviceWorkerRegistration' +import { onServiceWorkerUpdate } from '@3m1/service-worker-updater' // Render the App ReactDOM.render( @@ -50,66 +51,76 @@ 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: onServiceWorkerUpdate -}); +}) // ... ``` If you are already using the `onUpdate` callback, you need to add this callback in there: -```jsx +```tsx serviceWorkerRegistration.register({ onUpdate: (registration) => { // Your code goes here // ... // Then, call this callback: - onServiceWorkerUpdate(registration); + onServiceWorkerUpdate(registration) } -}); +}) ``` ### `withServiceWorkerUpdater` + HOC to wrap a component which will receive 2 extra `props`: + - `newServiceWorkerDetected`: boolean indicating if a new version of the service worker has been detected. If `true`, you should offer the user some way to update the app. - `onLoadNewServiceWorkerAccept`: a callback which needs to be called once the user accepts to update to the new Service Worker. You choose what actions needs to be taken by the user to update the service worker (a button, a link, a countdown, ...). During its execution, **the page will be reloaded** in order to use the newly activated service worker. **WARNING!** Make sure all unsaved changes are saved before executing it. -```jsx -import React from 'react'; -import { withServiceWorkerUpdater } from '@3m1/service-worker-updater'; +```tsx +import React from 'react' +import { + withServiceWorkerUpdater, + ServiceWorkerUpdaterProps +} from '@3m1/service-worker-updater' -const Updater = (props) => { - const {newServiceWorkerDetected, onLoadNewServiceWorkerAccept} = props; +const Updater = (props: ServiceWorkerUpdaterProps) => { + const { newServiceWorkerDetected, onLoadNewServiceWorkerAccept } = props return newServiceWorkerDetected ? ( <> New version detected. - + - ) : null; // If no update is available, render nothing + ) : null // If no update is available, render nothing } -export default withServiceWorkerUpdater(Updater); +export default withServiceWorkerUpdater(Updater) ``` The message sent to the service worker is `{type: 'SKIP_WAITING'}`, which is the one the [PWA version of Create React APP](https://create-react-app.dev/docs/making-a-progressive-web-app/) expects in order to launch its `self.skipWaiting()` method. If you have a different service worker configuration, you can change it here using the second optional argument: -```jsx -export default withServiceWorkerUpdater( Updater, {message: {myCustomType: 'SKIP_WAITING'} }); +```tsx +export default withServiceWorkerUpdater(Updater, { + message: { myCustomType: 'SKIP_WAITING' } +}) ``` Just before reloading the page, `'Controller loaded'` will be logged with `console.log`. If you want to change it, do it so: -```jsx -export default withServiceWorkerUpdater( Updater, {log: () => console.warn("App updated!")}); +```tsx +export default withServiceWorkerUpdater(Updater, { + log: () => console.warn('App updated!') +}) ``` ## See also + - [React Service Worker](https://www.npmjs.com/package/@medipass/react-service-worker): A headless React component that wraps around the Navigator Service Worker API to manage your service workers. Inspired by Create React App's service worker registration script. - [Service Worker Updater - React Hook & HOC](https://www.npmjs.com/package/service-worker-updater): This package provides React hook and HOC to check for service worker updates. - [@loopmode/cra-workbox-refresh](https://www.npmjs.com/package/@loopmode/cra-workbox-refresh): Helper for `create-react-app` v2 apps that use the workbox service worker. Displays a UI that informs the user about updates and recommends a page refresh. diff --git a/example/package.json b/example/package.json index 9985027..fd56d22 100644 --- a/example/package.json +++ b/example/package.json @@ -10,13 +10,15 @@ "eject": "react-scripts eject" }, "dependencies": { + "@3m1/service-worker-updater": "^1.0.5", "react": "^17.0.2", "react-dom": "^17.0.2", - "react-scripts": "^4.0.3", - "@3m1/service-worker-updater": "^1.0.5" + "react-scripts": "^4.0.3" }, "devDependencies": { - "@babel/plugin-syntax-object-rest-spread": "^7.8.3" + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@types/react": "^17.0.19", + "@types/react-dom": "^17.0.9" }, "eslintConfig": { "extends": "react-app" diff --git a/example/src/App.js b/example/src/App.js deleted file mode 100644 index 562b053..0000000 --- a/example/src/App.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react' -import { withServiceWorkerUpdater } from '@3m1/service-worker-updater' - -const App = (props) => { - const {newServiceWorkerDetected, onLoadNewServiceWorkerAccept} = props; - return newServiceWorkerDetected ? ( - <> - New version detected. - - - ) : null; // If no update is available, render nothing -} - -export default withServiceWorkerUpdater(App) diff --git a/example/src/App.test.js b/example/src/App.test.tsx similarity index 100% rename from example/src/App.test.js rename to example/src/App.test.tsx diff --git a/example/src/App.tsx b/example/src/App.tsx new file mode 100644 index 0000000..de553fd --- /dev/null +++ b/example/src/App.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { + withServiceWorkerUpdater, + ServiceWorkerUpdaterProps +} from '@3m1/service-worker-updater' + +const App = (props: ServiceWorkerUpdaterProps) => { + const { newServiceWorkerDetected, onLoadNewServiceWorkerAccept } = props + return newServiceWorkerDetected ? ( + <> + New version detected. + + + ) : null // If no update is available, render nothing +} + +export default withServiceWorkerUpdater(App) diff --git a/example/src/index.js b/example/src/index.tsx similarity index 97% rename from example/src/index.js rename to example/src/index.tsx index 77ed904..ca16c86 100644 --- a/example/src/index.js +++ b/example/src/index.tsx @@ -1,7 +1,7 @@ import React from 'react' import ReactDOM from 'react-dom' import App from './App' -import * as serviceWorkerRegistration from './serviceWorkerRegistration'; +import * as serviceWorkerRegistration from './serviceWorkerRegistration' import { onServiceWorkerUpdate } from '@3m1/service-worker-updater' // Render the App @@ -10,11 +10,11 @@ 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: onServiceWorkerUpdate -}); +}) diff --git a/example/src/react-app-env.d.ts b/example/src/react-app-env.d.ts new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/example/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/example/src/service-worker.js b/example/src/service-worker.ts similarity index 65% rename from example/src/service-worker.js rename to example/src/service-worker.ts index 0f1e0ce..81528df 100644 --- a/example/src/service-worker.js +++ b/example/src/service-worker.ts @@ -1,3 +1,4 @@ +/// /* eslint-disable no-restricted-globals */ // This service worker can be customized! @@ -7,66 +8,74 @@ // You can also remove this file if you'd prefer not to use a // service worker, and the Workbox build step will be skipped. -import { clientsClaim } from 'workbox-core'; -import { ExpirationPlugin } from 'workbox-expiration'; -import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'; -import { registerRoute } from 'workbox-routing'; -import { StaleWhileRevalidate } from 'workbox-strategies'; +import { clientsClaim } from 'workbox-core' +import { ExpirationPlugin } from 'workbox-expiration' +import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching' +import { registerRoute } from 'workbox-routing' +import { StaleWhileRevalidate } from 'workbox-strategies' -clientsClaim(); +declare const self: ServiceWorkerGlobalScope + +clientsClaim() // Precache all of the assets generated by your build process. // Their URLs are injected into the manifest variable below. // This variable must be present somewhere in your service worker file, // even if you decide not to use precaching. See https://cra.link/PWA -precacheAndRoute(self.__WB_MANIFEST); +precacheAndRoute(self.__WB_MANIFEST) // Set up App Shell-style routing, so that all navigation requests // are fulfilled with your index.html shell. Learn more at // https://developers.google.com/web/fundamentals/architecture/app-shell -const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$'); +const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$') registerRoute( // Return false to exempt requests from being fulfilled by index.html. - ({ request, url }) => { + ({ request, url }: { request: Request; url: URL }) => { // If this isn't a navigation, skip. if (request.mode !== 'navigate') { - return false; - } // If this is a URL that starts with /_, skip. + return false + } + // If this is a URL that starts with /_, skip. if (url.pathname.startsWith('/_')) { - return false; - } // If this looks like a URL for a resource, because it contains // a file extension, skip. + return false + } + // If this looks like a URL for a resource, because it contains + // a file extension, skip. if (url.pathname.match(fileExtensionRegexp)) { - return false; - } // Return true to signal that we want to use the handler. + return false + } - return true; + // Return true to signal that we want to use the handler. + return true }, createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html') -); +) // An example runtime caching route for requests that aren't handled by the // precache, in this case same-origin .png requests like those from in public/ registerRoute( // Add in any other file extensions or routing criteria as needed. - ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'), // Customize this strategy as needed, e.g., by changing to CacheFirst. + ({ url }) => + url.origin === self.location.origin && url.pathname.endsWith('.png'), + // Customize this strategy as needed, e.g., by changing to CacheFirst. new StaleWhileRevalidate({ cacheName: 'images', plugins: [ // Ensure that once this runtime cache reaches a maximum size the // least-recently used images are removed. - new ExpirationPlugin({ maxEntries: 50 }), - ], + new ExpirationPlugin({ maxEntries: 50 }) + ] }) -); +) // This allows the web app to trigger skipWaiting via // registration.waiting.postMessage({type: 'SKIP_WAITING'}) self.addEventListener('message', (event) => { if (event.data && event.data.type === 'SKIP_WAITING') { - self.skipWaiting(); + self.skipWaiting() } -}); +}) // Any other custom service worker logic can go here. diff --git a/example/src/serviceWorkerRegistration.js b/example/src/serviceWorkerRegistration.ts similarity index 76% rename from example/src/serviceWorkerRegistration.js rename to example/src/serviceWorkerRegistration.ts index 2262ecd..7714a60 100644 --- a/example/src/serviceWorkerRegistration.js +++ b/example/src/serviceWorkerRegistration.ts @@ -15,26 +15,33 @@ const isLocalhost = Boolean( // [::1] is the IPv6 localhost address. window.location.hostname === '[::1]' || // 127.0.0.0/8 are considered localhost for IPv4. - window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) -); + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) +) -export function register(config) { +type Config = { + onSuccess?: (registration: ServiceWorkerRegistration) => void + onUpdate?: (registration: ServiceWorkerRegistration) => void +} + +export function register(config?: Config) { if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { // The URL constructor is available in all browsers that support SW. - const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); + const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href) if (publicUrl.origin !== window.location.origin) { // Our service worker won't work if PUBLIC_URL is on a different origin // from what our page is served on. This might happen if a CDN is used to // serve assets; see https://github.com/facebook/create-react-app/issues/2374 - return; + return } window.addEventListener('load', () => { - const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js` if (isLocalhost) { // This is running on localhost. Let's check if a service worker still exists or not. - checkValidServiceWorker(swUrl, config); + checkValidServiceWorker(swUrl, config) // Add some additional logging to localhost, pointing developers to the // service worker/PWA documentation. @@ -42,24 +49,24 @@ export function register(config) { console.log( 'This web app is being served cache-first by a service ' + 'worker. To learn more, visit https://cra.link/PWA' - ); - }); + ) + }) } else { // Is not localhost. Just register service worker - registerValidSW(swUrl, config); + registerValidSW(swUrl, config) } - }); + }) } } -function registerValidSW(swUrl, config) { +function registerValidSW(swUrl: string, config?: Config) { navigator.serviceWorker .register(swUrl) .then((registration) => { registration.onupdatefound = () => { - const installingWorker = registration.installing; + const installingWorker = registration.installing if (installingWorker == null) { - return; + return } installingWorker.onstatechange = () => { if (installingWorker.state === 'installed') { @@ -70,40 +77,40 @@ function registerValidSW(swUrl, config) { console.log( 'New content is available and will be used when all ' + 'tabs for this page are closed. See https://cra.link/PWA.' - ); + ) // Execute callback if (config && config.onUpdate) { - config.onUpdate(registration); + config.onUpdate(registration) } } else { // At this point, everything has been precached. // It's the perfect time to display a // "Content is cached for offline use." message. - console.log('Content is cached for offline use.'); + console.log('Content is cached for offline use.') // Execute callback if (config && config.onSuccess) { - config.onSuccess(registration); + config.onSuccess(registration) } } } - }; - }; + } + } }) .catch((error) => { - console.error('Error during service worker registration:', error); - }); + console.error('Error during service worker registration:', error) + }) } -function checkValidServiceWorker(swUrl, config) { +function checkValidServiceWorker(swUrl: string, config?: Config) { // Check if the service worker can be found. If it can't reload the page. fetch(swUrl, { - headers: { 'Service-Worker': 'script' }, + headers: { 'Service-Worker': 'script' } }) .then((response) => { // Ensure service worker exists, and that we really are getting a JS file. - const contentType = response.headers.get('content-type'); + const contentType = response.headers.get('content-type') if ( response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1) @@ -111,27 +118,29 @@ function checkValidServiceWorker(swUrl, config) { // No service worker found. Probably a different app. Reload the page. navigator.serviceWorker.ready.then((registration) => { registration.unregister().then(() => { - window.location.reload(); - }); - }); + window.location.reload() + }) + }) } else { // Service worker found. Proceed as normal. - registerValidSW(swUrl, config); + registerValidSW(swUrl, config) } }) .catch(() => { - console.log('No internet connection found. App is running in offline mode.'); - }); + console.log( + 'No internet connection found. App is running in offline mode.' + ) + }) } export function unregister() { if ('serviceWorker' in navigator) { navigator.serviceWorker.ready .then((registration) => { - registration.unregister(); + registration.unregister() }) .catch((error) => { - console.error(error.message); - }); + console.error(error.message) + }) } } diff --git a/example/tsconfig.json b/example/tsconfig.json new file mode 100644 index 0000000..7b1d3c6 --- /dev/null +++ b/example/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react" + }, + "include": [ + "src" + ] +} diff --git a/example/yarn.lock b/example/yarn.lock index 0bb6568..d00554c 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -1777,11 +1777,32 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.2.3.tgz#ef65165aea2924c9359205bf748865b8881753c0" integrity sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA== +"@types/prop-types@*": + version "15.7.4" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" + integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== + "@types/q@^1.5.1": version "1.5.4" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24" integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug== +"@types/react-dom@^17.0.9": + version "17.0.9" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.9.tgz#441a981da9d7be117042e1a6fd3dac4b30f55add" + integrity sha512-wIvGxLfgpVDSAMH5utdL9Ngm5Owu0VsGmldro3ORLXV8CShrL8awVj06NuEXFQ5xyaYfdca7Sgbk/50Ri1GdPg== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@^17.0.19": + version "17.0.19" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.19.tgz#8f2a85e8180a43b57966b237d26a29481dacc991" + integrity sha512-sX1HisdB1/ZESixMTGnMxH9TDe8Sk709734fEQZzCV/4lSu9kJCPbo2PbTRoZM+53Pp0P10hYVyReUueGwUi4A== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/resolve@0.0.8": version "0.0.8" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.8.tgz#f26074d238e02659e323ce1a13d041eee280e194" @@ -1789,6 +1810,11 @@ dependencies: "@types/node" "*" +"@types/scheduler@*": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" + integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== + "@types/source-list-map@*": version "0.1.2" resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" @@ -3813,6 +3839,11 @@ cssstyle@^2.3.0: dependencies: cssom "~0.3.6" +csstype@^3.0.2: + version "3.0.8" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340" + integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw== + cyclist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" diff --git a/package.json b/package.json index ac5a519..a1de55f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "homepage": "https://github.com/emibcn/service-worker-updater", "main": "dist/index.js", "module": "dist/index.modern.js", - "source": "src/index.js", + "source": "src/index.ts", + "types": "dist/index.d.ts", "sideEffects": false, "keywords": [ "react", @@ -46,17 +47,15 @@ "devDependencies": { "@testing-library/jest-dom": "^5.11.10", "@testing-library/react": "^12.0.0", + "@types/react": "^17.0.19", "cross-env": "^7.0.3", "eslint": "^7.23.0", "eslint-config-prettier": "^8.1.0", - "eslint-config-standard": "^16.0.2", - "eslint-config-standard-react": "^11.0.1", "eslint-plugin-import": "^2.22.1", "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-promise": "^5.1.0", "eslint-plugin-react": "^7.23.1", - "eslint-plugin-standard": "^5.0.0", "gh-pages": "^3.1.0", "microbundle-crl": "^0.13.11", "npm-run-all": "^4.1.5", diff --git a/src/index.js b/src/index.js deleted file mode 100644 index a964665..0000000 --- a/src/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import withServiceWorkerUpdater from './withServiceWorkerUpdater' -import onServiceWorkerUpdate from './onServiceWorkerUpdate' - -export { withServiceWorkerUpdater, onServiceWorkerUpdate } diff --git a/src/index.test.js b/src/index.test.ts similarity index 100% rename from src/index.test.js rename to src/index.test.ts diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e73b141 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,8 @@ +import withServiceWorkerUpdater, { + ServiceWorkerUpdaterProps, + WithServiceWorkerUpdaterOptions +} from './withServiceWorkerUpdater' +import onServiceWorkerUpdate from './onServiceWorkerUpdate' + +export { withServiceWorkerUpdater, onServiceWorkerUpdate } +export type { ServiceWorkerUpdaterProps, WithServiceWorkerUpdaterOptions } diff --git a/src/onServiceWorkerUpdate.js b/src/onServiceWorkerUpdate.ts similarity index 78% rename from src/onServiceWorkerUpdate.js rename to src/onServiceWorkerUpdate.ts index 4a2c1ff..1d87658 100644 --- a/src/onServiceWorkerUpdate.js +++ b/src/onServiceWorkerUpdate.ts @@ -1,7 +1,6 @@ // When new ServiceWorker is available, trigger an event on `document`, // passing `registration` as extra data -const onServiceWorkerUpdate = (registration) => { - /* global CustomEvent */ +const onServiceWorkerUpdate = (registration: ServiceWorkerRegistration) => { const event = new CustomEvent('onNewServiceWorker', { detail: { registration } }) diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/updateSW.js b/src/updateSW.ts similarity index 86% rename from src/updateSW.js rename to src/updateSW.ts index afaec16..2c275d2 100644 --- a/src/updateSW.js +++ b/src/updateSW.ts @@ -5,7 +5,11 @@ * - Send message to SW to trigger the update * - Once the SW has been updated, reload this window to load new assets */ -const updateSW = (registration, message, log) => { +const updateSW = ( + registration: ServiceWorkerRegistration, + message: unknown, + log: () => void +) => { // `waiting` is the newly detected SW if (registration.waiting) { /* @@ -13,8 +17,8 @@ const updateSW = (registration, message, log) => { * Register an event to controllerchange, wich will be fired when the * `waiting` SW executes `skipWaiting` */ - let preventDevToolsReloadLoop - navigator.serviceWorker.addEventListener('controllerchange', (event) => { + let preventDevToolsReloadLoop = false + navigator.serviceWorker.addEventListener('controllerchange', () => { /* * Ensure refresh is only called once. * This works around a bug in "force update on reload". @@ -27,7 +31,7 @@ const updateSW = (registration, message, log) => { log() // Finally, refresh the page - global.location.reload(true) + global.location.reload() }) /* diff --git a/src/withServiceWorkerUpdater.jsx b/src/withServiceWorkerUpdater.jsx deleted file mode 100644 index 21866ce..0000000 --- a/src/withServiceWorkerUpdater.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { useState, forwardRef, useEffect } from 'react'; -import updateSW from './updateSW'; - -/* - * HOC to generate a Wrapper component which - * will add to the WrappedComponent the next props: - * - newServiceWorkerDetected: boolean - True when a new service - * worker has been detected - * - onLoadNewServiceWorkerAccept: function - callback to execute - * when the user accepts to load the new service worker (and, - * maybe, after saving all data): page will be reloaded - * - * HOC Parameters: - * - WrappedComponent: The React component to wrap - * - message: default: `{type: 'SKIP_WAITING'}` (standard for CRA and others): - * the message to send to the SW to fire the `skipWaiting` service worker method - */ -const withServiceWorkerUpdater = ( - WrappedComponent, - { - message={type: 'SKIP_WAITING'}, - log=() => console.log('Controller loaded'), - }={}) => { - - const SWUpdater = ({forwardedRef, ...props}) => { - - /* - * States managed by this component: - * - registration: received from event listener registered in index on SW registration - * - newServiceWorkerDetected: wether a new SW has been detected - */ - const [registration, setRegistration] = useState(false); - const [newServiceWorkerDetected, setNewServiceWorkerDetected] = useState(false); - - // Callback to execute when user accepts the update - const handleLoadNewServiceWorkerAccept = () => { - updateSW(registration, message, log); - } - - // Add/remove event listeners for event thrown from `index.js` - useEffect(() => { - const handleNewServiceWorker = (event) => { - setRegistration(event.detail.registration); - setNewServiceWorkerDetected(true); - } - - document.addEventListener('onNewServiceWorker', handleNewServiceWorker); - return () => document.removeEventListener('onNewServiceWorker', handleNewServiceWorker); - }, [setRegistration, setNewServiceWorkerDetected]); - - /* - * Render the WrappedComponent with: - * - All passed props - * - This HOC's added props - * - Respecting refs - */ - return ; - } - - // Return wrapper respecting ref - return forwardRef( - (props, ref) => - ); -} - -export default withServiceWorkerUpdater; diff --git a/src/withServiceWorkerUpdater.test.jsx b/src/withServiceWorkerUpdater.test.jsx deleted file mode 100644 index b3d6a29..0000000 --- a/src/withServiceWorkerUpdater.test.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import { render, createEvent, fireEvent, act, waitFor, screen, cleanup } from '@testing-library/react'; -import "@testing-library/jest-dom/extend-expect"; - -import withServiceWorkerUpdater from './withServiceWorkerUpdater'; - -const delay = (millis) => new Promise((resolve) => setTimeout(resolve, millis)); - -const SWDetector = withServiceWorkerUpdater( (props) => ( - <> - -
- { JSON.stringify(props.newServiceWorkerDetected) } -
- -)); - -// Fire event for new service worker detection -const triggerNewServiceWorker = () => { - const event = new CustomEvent('onNewServiceWorker', { detail: { registration: "tested" } }); - fireEvent(document, event); -}; - -test('detects new service worker', async () => { - let app; - act( () => { - app = render(); - }); - - act( triggerNewServiceWorker ); - - await act( async () => { - await waitFor(() => { - const detectedNewSW = app.getByTestId("dashboard-mock-sw-detected"); - expect(detectedNewSW).toHaveTextContent("true"); - }); - }); -}); - -test('detects new service worker acceptance', async () => { - const onLoadNewServiceWorkerAccept = jest.fn(); - let app; - act(() => { - app = render(); - }); - - act( triggerNewServiceWorker ); - - await act( async () => { - // User accepts it - const button = app.getByTestId("dashboard-mock-fn-accept-sw"); - expect(button).toBeInTheDocument(); - fireEvent.click(button); - - await waitFor(() => { - expect(onLoadNewServiceWorkerAccept).toHaveBeenCalledTimes(1); - expect(onLoadNewServiceWorkerAccept).toHaveBeenCalledWith("tested"); - }); - }); -}); diff --git a/src/withServiceWorkerUpdater.test.tsx b/src/withServiceWorkerUpdater.test.tsx new file mode 100644 index 0000000..bf9c5d0 --- /dev/null +++ b/src/withServiceWorkerUpdater.test.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { + render, + fireEvent, + act, + waitFor, + RenderResult +} from '@testing-library/react' +import '@testing-library/jest-dom/extend-expect' + +import withServiceWorkerUpdater from './withServiceWorkerUpdater' + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const delay = (millis: number) => + new Promise((resolve) => setTimeout(resolve, millis)) + +const SWDetector = withServiceWorkerUpdater( + (props: { + onAccept?: (t: string) => void + onLoadNewServiceWorkerAccept?: (a: unknown) => void + newServiceWorkerDetected?: unknown + }) => ( + <> + +
+ {JSON.stringify(props.newServiceWorkerDetected)} +
+ + ) +) + +// Fire event for new service worker detection +const triggerNewServiceWorker = () => { + const event = new CustomEvent('onNewServiceWorker', { + detail: { registration: 'tested' } + }) + fireEvent(document, event) +} + +test('detects new service worker', async () => { + let app: RenderResult + act(() => { + app = render() + }) + + act(triggerNewServiceWorker) + + await act(async () => { + await waitFor(() => { + const detectedNewSW = app.getByTestId('dashboard-mock-sw-detected') + expect(detectedNewSW).toHaveTextContent('true') + }) + }) +}) + +test('detects new service worker acceptance', async () => { + const onLoadNewServiceWorkerAccept = jest.fn() + let app: RenderResult + act(() => { + app = render() + }) + + act(triggerNewServiceWorker) + + await act(async () => { + // User accepts it + const button = app.getByTestId('dashboard-mock-fn-accept-sw') + expect(button).toBeInTheDocument() + fireEvent.click(button) + + await waitFor(() => { + expect(onLoadNewServiceWorkerAccept).toHaveBeenCalledTimes(1) + expect(onLoadNewServiceWorkerAccept).toHaveBeenCalledWith('tested') + }) + }) +}) diff --git a/src/withServiceWorkerUpdater.tsx b/src/withServiceWorkerUpdater.tsx new file mode 100644 index 0000000..40e8d6c --- /dev/null +++ b/src/withServiceWorkerUpdater.tsx @@ -0,0 +1,101 @@ +import React, { useState, forwardRef, useEffect } from 'react' +import updateSW from './updateSW' + +export interface ServiceWorkerUpdaterProps { + newServiceWorkerDetected: boolean + onLoadNewServiceWorkerAccept: () => void +} + +export interface WithServiceWorkerUpdaterOptions { + message?: unknown + log?: () => void +} + +/* + * HOC to generate a Wrapper component which + * will add to the WrappedComponent the next props: + * - newServiceWorkerDetected: boolean - True when a new service + * worker has been detected + * - onLoadNewServiceWorkerAccept: function - callback to execute + * when the user accepts to load the new service worker (and, + * maybe, after saving all data): page will be reloaded + * + * HOC Parameters: + * - WrappedComponent: The React component to wrap + * - message: default: `{type: 'SKIP_WAITING'}` (standard for CRA and others): + * the message to send to the SW to fire the `skipWaiting` service worker method + */ +function withServiceWorkerUpdater

( + WrappedComponent: React.ComponentType

, + { + message = { type: 'SKIP_WAITING' }, + log = () => console.log('Controller loaded') + }: WithServiceWorkerUpdaterOptions = {} +) { + function SWUpdater({ + forwardedRef, + ...props + }: { + forwardedRef: React.ForwardedRef< + React.ComponentType

+ > + }) { + /* + * States managed by this component: + * - registration: received from event listener registered in index on SW registration + * - newServiceWorkerDetected: wether a new SW has been detected + */ + const [registration, setRegistration] = + useState(null) + const [newServiceWorkerDetected, setNewServiceWorkerDetected] = + useState(false) + + // Callback to execute when user accepts the update + const handleLoadNewServiceWorkerAccept = () => { + if (!registration) throw new Error('ServiceWorkerRegistration not found') + + updateSW(registration, message, log) + } + + // Add/remove event listeners for event thrown from `index.js` + useEffect(() => { + const handleNewServiceWorker = (( + event: CustomEvent<{ + registration: ServiceWorkerRegistration + }> + ) => { + setRegistration(event.detail.registration) + setNewServiceWorkerDetected(true) + }) as EventListener + + document.addEventListener('onNewServiceWorker', handleNewServiceWorker) + return () => + document.removeEventListener( + 'onNewServiceWorker', + handleNewServiceWorker + ) + }, [setRegistration, setNewServiceWorkerDetected]) + + /* + * Render the WrappedComponent with: + * - All passed props + * - This HOC's added props + * - Respecting refs + */ + return ( + + ) + } + + // Return wrapper respecting ref + return forwardRef, P>( + (props, ref) => + ) +} + +export default withServiceWorkerUpdater diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2afc55e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es6", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react", + "noImplicitAny": true + }, + "include": ["src"] +} diff --git a/yarn.lock b/yarn.lock index 04af375..671cee0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2714,11 +2714,25 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.2.3.tgz#ef65165aea2924c9359205bf748865b8881753c0" integrity sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA== +"@types/prop-types@*": + version "15.7.4" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" + integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== + "@types/q@^1.5.1": version "1.5.4" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24" integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug== +"@types/react@^17.0.19": + version "17.0.19" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.19.tgz#8f2a85e8180a43b57966b237d26a29481dacc991" + integrity sha512-sX1HisdB1/ZESixMTGnMxH9TDe8Sk709734fEQZzCV/4lSu9kJCPbo2PbTRoZM+53Pp0P10hYVyReUueGwUi4A== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/resolve@0.0.8": version "0.0.8" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.8.tgz#f26074d238e02659e323ce1a13d041eee280e194" @@ -2726,6 +2740,11 @@ dependencies: "@types/node" "*" +"@types/scheduler@*": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" + integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== + "@types/source-list-map@*": version "0.1.2" resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" @@ -4912,6 +4931,11 @@ cssstyle@^2.3.0: dependencies: cssom "~0.3.6" +csstype@^3.0.2: + version "3.0.8" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340" + integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw== + cyclist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" @@ -5525,16 +5549,6 @@ eslint-config-react-app@^6.0.0: dependencies: confusing-browser-globals "^1.0.10" -eslint-config-standard-react@^11.0.1: - version "11.0.1" - resolved "https://registry.yarnpkg.com/eslint-config-standard-react/-/eslint-config-standard-react-11.0.1.tgz#1f488e0062c1e21c4c8584551619f11750658f55" - integrity sha512-4WlBynOqBZJRaX81CBcIGDHqUiqxvw4j/DbEIICz8QkMs3xEncoPgAoysiqCSsg71X92uhaBc8sgqB96smaMmg== - -eslint-config-standard@^16.0.2: - version "16.0.3" - resolved "https://registry.yarnpkg.com/eslint-config-standard/-/eslint-config-standard-16.0.3.tgz#6c8761e544e96c531ff92642eeb87842b8488516" - integrity sha512-x4fmJL5hGqNJKGHSjnLdgA6U6h1YW/G2dW9fA+cyVur4SK6lyue8+UgNKWlZtUDTXvgKDD/Oa3GQjmB5kjtVvg== - eslint-import-resolver-node@^0.3.6: version "0.3.6" resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz#4048b958395da89668252001dbd9eca6b83bacbd" @@ -5660,11 +5674,6 @@ eslint-plugin-react@^7.21.5, eslint-plugin-react@^7.23.1: resolve "^2.0.0-next.3" string.prototype.matchall "^4.0.5" -eslint-plugin-standard@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-standard/-/eslint-plugin-standard-5.0.0.tgz#c43f6925d669f177db46f095ea30be95476b1ee4" - integrity sha512-eSIXPc9wBM4BrniMzJRBm2uoVuXz2EPa+NXPk2+itrVt+r5SbKFERx/IgrK/HmfjddyKVz2f+j+7gBRvu19xLg== - eslint-plugin-testing-library@^3.9.2: version "3.10.2" resolved "https://registry.yarnpkg.com/eslint-plugin-testing-library/-/eslint-plugin-testing-library-3.10.2.tgz#609ec2b0369da7cf2e6d9edff5da153cc31d87bd"