@inglorious/web 6.8.0 → 6.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -971,6 +971,7 @@ To enable the router, add it to your store's types and create a `router` entity.
971
971
  // store.js
972
972
  import { createStore } from "@inglorious/store"
973
973
  import { html } from "@inglorious/web"
974
+ import { PwaUpdate, watchPwaUpdate } from "@inglorious/web/pwa"
974
975
  import { Router, setRoutes } from "@inglorious/web/router"
975
976
 
976
977
  const types = {
@@ -1410,6 +1411,67 @@ mount(store, renderApp, document.getElementById("root"))
1410
1411
 
1411
1412
  ---
1412
1413
 
1414
+ ## PWA Updates
1415
+
1416
+ `@inglorious/web/pwa` keeps an installed PWA on its latest version. It works with the service worker that [`vite-plugin-pwa`](https://vite-pwa-org.netlify.app/) generates, and `@inglorious/create-app --pwa` sets it up for you.
1417
+
1418
+ Updates are not optional. A new version downloads in the background, and is applied with a reload at the first moment nobody is in the middle of something:
1419
+
1420
+ - at launch, before anything is touched;
1421
+ - each time the app goes to the background or comes back to the foreground.
1422
+
1423
+ It also checks for a new version every hour and on every return to the foreground: a phone suspends an installed PWA rather than closing it, so the browser's own check may never come.
1424
+
1425
+ ```javascript
1426
+ import { isNative } from "@inglorious/web/mobile"
1427
+ import { watchPwaUpdate } from "@inglorious/web/pwa"
1428
+ import { registerSW } from "virtual:pwa-register"
1429
+
1430
+ if (!isNative()) {
1431
+ registerSW({
1432
+ onRegisteredSW(url, registration) {
1433
+ if (registration) watchPwaUpdate(registration)
1434
+ },
1435
+ })
1436
+ }
1437
+ ```
1438
+
1439
+ Configure the plugin with `registerType: "prompt"` and `injectRegister: false`: the generated worker then waits for the `SKIP_WAITING` message this module sends, instead of taking over on its own.
1440
+
1441
+ ### Offering "Update now"
1442
+
1443
+ A version that lands while someone is using the app waits for the next trip to the background. To offer it right away, pass the store and register the `PwaUpdate` type:
1444
+
1445
+ ```javascript
1446
+ import { PwaUpdate, watchPwaUpdate } from "@inglorious/web/pwa"
1447
+
1448
+ const store = createStore({ types: { PwaUpdate }, autoCreateEntities: true })
1449
+
1450
+ watchPwaUpdate(registration, store)
1451
+ ```
1452
+
1453
+ The `pwaUpdate` entity contains:
1454
+
1455
+ - `isUpdateReady` — a new version is waiting for the next reload
1456
+
1457
+ Events:
1458
+
1459
+ - `pwaUpdateReady` — sent by `watchPwaUpdate` when a version lands mid-session
1460
+ - `pwaUpdateApply` — moves over to the waiting version now, reloading the page
1461
+
1462
+ ```javascript
1463
+ const renderUpdate = (api) =>
1464
+ api.getEntity("pwaUpdate").isUpdateReady
1465
+ ? html`<button @click=${() => api.notify("#pwaUpdate:pwaUpdateApply")}>
1466
+ Update now
1467
+ </button>`
1468
+ : ""
1469
+ ```
1470
+
1471
+ Inglorious UI's `Snackbar` is the usual place for it. `watchPwaUpdate` takes `{ id, checkEveryMs }` as a third argument, to announce to an entity with another id or to check more or less often, and returns a function that stops watching.
1472
+
1473
+ ---
1474
+
1413
1475
  ## Forms
1414
1476
 
1415
1477
  `@inglorious/web` includes a small but powerful `form` type for managing form state inside your entity store. It offers:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/web",
3
- "version": "6.8.0",
3
+ "version": "6.9.0",
4
4
  "description": "A new web framework that leverages the power of the Inglorious Store combined with the performance and simplicity of lit-html.",
5
5
  "author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "contributors": [
@@ -58,6 +58,10 @@
58
58
  "types": "./types/mobile.d.ts",
59
59
  "import": "./src/mobile/index.js"
60
60
  },
61
+ "./pwa": {
62
+ "types": "./types/pwa.d.ts",
63
+ "import": "./src/pwa/index.js"
64
+ },
61
65
  "./router": {
62
66
  "types": "./types/router.d.ts",
63
67
  "import": "./src/router/index.js"
@@ -105,15 +109,15 @@
105
109
  },
106
110
  "sideEffects": false,
107
111
  "dependencies": {
108
- "@inglorious/utils": "5.0.0",
112
+ "@inglorious/utils": "5.1.0",
109
113
  "@lit-labs/ssr-client": "^1.1.8",
110
114
  "lit-html": "^3.3.3"
111
115
  },
112
116
  "peerDependencies": {
113
- "@inglorious/store": "11.3.0"
117
+ "@inglorious/store": "12.0.0"
114
118
  },
115
119
  "devDependencies": {
116
- "@inglorious/eslint-config": "1.1.4",
120
+ "@inglorious/eslint-config": "1.2.0",
117
121
  "prettier": "^3.9.6",
118
122
  "typescript": "~7.0.2",
119
123
  "vitest": "^5.0.0"
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @typedef {import("../../types/pwa.js").PwaUpdateEntity} PwaUpdateEntity
3
+ */
4
+
5
+ import { applyPwaUpdate } from "./update.js"
6
+
7
+ export function create(entity) {
8
+ entity.isUpdateReady ??= false
9
+ }
10
+
11
+ export function pwaUpdateApply() {
12
+ applyPwaUpdate()
13
+ }
14
+
15
+ export function pwaUpdateReady(entity) {
16
+ entity.isUpdateReady = true
17
+ }
@@ -0,0 +1,5 @@
1
+ import * as handlers from "./handlers.js"
2
+
3
+ export { applyPwaUpdate, watchPwaUpdate } from "./update.js"
4
+
5
+ export const PwaUpdate = { ...handlers }
@@ -0,0 +1,114 @@
1
+ const ONE_HOUR_MS = 3_600_000
2
+ const TAKEOVER_TIMEOUT_MS = 3_000
3
+ const DEFAULT_ID = "pwaUpdate"
4
+ const VISIBILITY_EVENT = "visibilitychange"
5
+
6
+ /**
7
+ * Keeps an installed PWA on its latest version. Updates are not optional: a
8
+ * downloaded version is applied on its own wherever nobody is mid-task — at
9
+ * boot, before anything is touched, and each time the app leaves or comes
10
+ * back to the foreground. A version that lands while someone is using the
11
+ * app is only announced, as `#${id}:pwaUpdateReady`, so the app can offer to
12
+ * move over at once; the next trip to the background moves over regardless.
13
+ *
14
+ * Checks for a new version every `checkEveryMs` and on each return to the
15
+ * foreground: a phone suspends an installed PWA rather than closing it, so
16
+ * the browser's own check on navigation may never come.
17
+ *
18
+ * @param {ServiceWorkerRegistration} registration What `registerSW` from
19
+ * `virtual:pwa-register` hands to `onRegisteredSW`.
20
+ * @param {{ notify: (type: string, payload?: unknown) => void }} [store]
21
+ * Where to announce a mid-session version. Without one, updates are applied
22
+ * silently.
23
+ * @param {{ id?: string, checkEveryMs?: number }} [options]
24
+ * @returns {() => void} Stops watching.
25
+ */
26
+ export function watchPwaUpdate(
27
+ registration,
28
+ store,
29
+ { id = DEFAULT_ID, checkEveryMs = ONE_HOUR_MS } = {},
30
+ ) {
31
+ // A first-ever install has no older version to move from: nothing to
32
+ // announce while the page that installed it keeps running.
33
+ const hadController = Boolean(navigator.serviceWorker?.controller)
34
+ let announced = null
35
+ let isApplying = false
36
+
37
+ apply()
38
+ watchInstalls()
39
+ const interval = setInterval(check, checkEveryMs)
40
+ document.addEventListener(VISIBILITY_EVENT, handleVisibilityChange)
41
+
42
+ return function stop() {
43
+ clearInterval(interval)
44
+ document.removeEventListener(VISIBILITY_EVENT, handleVisibilityChange)
45
+ }
46
+
47
+ // Offline, the check fails: the next one tries again.
48
+ function check() {
49
+ registration.update?.()?.catch?.(() => {})
50
+ }
51
+
52
+ function handleVisibilityChange() {
53
+ if (apply()) return
54
+ if (!document.hidden) check()
55
+ }
56
+
57
+ // One reload per waiting worker: the hidden and visible events of a single
58
+ // trip to the background must not send it twice, nor check for another
59
+ // version while the reload is on its way.
60
+ function apply() {
61
+ if (isApplying) return true
62
+ if (!registration.waiting) return false
63
+ isApplying = true
64
+ applyPwaUpdate(registration)
65
+ return true
66
+ }
67
+
68
+ function announce(worker) {
69
+ if (!hadController || isApplying || announced === worker) return
70
+ announced = worker
71
+ store?.notify(`#${id}:pwaUpdateReady`)
72
+ }
73
+
74
+ function watchInstalls() {
75
+ if (registration.waiting) announce(registration.waiting)
76
+ registration.addEventListener("updatefound", () => {
77
+ const worker = registration.installing
78
+ worker?.addEventListener("statechange", () => {
79
+ if (worker.state === "installed") announce(worker)
80
+ if (worker.state === "activated") announced = null
81
+ })
82
+ })
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Moves the page over to the waiting version: asks the worker to take over
88
+ * with the SKIP_WAITING message that workbox's generated worker listens for,
89
+ * and reloads once it has — not before, or the page just reopens on the old
90
+ * version. With nothing waiting it simply reloads.
91
+ *
92
+ * @param {ServiceWorkerRegistration} [registration] Looked up when omitted.
93
+ * @returns {Promise<void>}
94
+ */
95
+ export async function applyPwaUpdate(registration) {
96
+ const target =
97
+ registration ?? (await navigator.serviceWorker?.getRegistration())
98
+ const worker = target?.waiting
99
+ if (worker) await takeOver(worker)
100
+ location.reload()
101
+ }
102
+
103
+ // Bounded: a worker that never reports back still gets the page reloaded.
104
+ function takeOver(worker) {
105
+ return new Promise((resolve) => {
106
+ const timeout = setTimeout(resolve, TAKEOVER_TIMEOUT_MS)
107
+ worker.addEventListener("statechange", () => {
108
+ if (worker.state !== "activated") return
109
+ clearTimeout(timeout)
110
+ resolve()
111
+ })
112
+ worker.postMessage({ type: "SKIP_WAITING" })
113
+ })
114
+ }
package/types/pwa.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { Api } from "./mount"
2
+
3
+ export interface PwaUpdateEntity {
4
+ id?: string
5
+ type: string
6
+ /** A new version landed mid-session and waits for the next reload. */
7
+ isUpdateReady: boolean
8
+ }
9
+
10
+ export type PwaUpdateType = {
11
+ create(entity: PwaUpdateEntity): void
12
+ /** Moves over to the waiting version now, reloading the page. */
13
+ pwaUpdateApply(): void
14
+ pwaUpdateReady(entity: PwaUpdateEntity): void
15
+ }
16
+
17
+ export declare const PwaUpdate: PwaUpdateType
18
+
19
+ export interface WatchPwaUpdateOptions {
20
+ /** The PwaUpdate entity to announce to. Defaults to `pwaUpdate`. */
21
+ id?: string
22
+ /** How often to check for a new version. Defaults to one hour. */
23
+ checkEveryMs?: number
24
+ }
25
+
26
+ /**
27
+ * Keeps an installed PWA on its latest version, applying a downloaded one at
28
+ * boot and on each trip to or from the background. A version that lands
29
+ * mid-session is announced to `store` as `#${id}:pwaUpdateReady`.
30
+ * @returns A function that stops watching.
31
+ */
32
+ export declare function watchPwaUpdate(
33
+ registration: ServiceWorkerRegistration,
34
+ store?: Pick<Api, "notify">,
35
+ options?: WatchPwaUpdateOptions,
36
+ ): () => void
37
+
38
+ /** Moves the page over to the waiting version and reloads. */
39
+ export declare function applyPwaUpdate(
40
+ registration?: ServiceWorkerRegistration,
41
+ ): Promise<void>