@12-apps/ui 1.6.0 → 1.6.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/ui",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "license": "MIT",
5
5
  "packageManager": "pnpm@9.0.0",
6
6
  "type": "module",
@@ -194,7 +194,7 @@
194
194
  "react-dom": "^18.0.0 || ^19.0.0"
195
195
  },
196
196
  "dependencies": {
197
- "@12-apps/forms-core": "^1.5.0",
197
+ "@12-apps/forms-core": "^1.5.1",
198
198
  "@emotion/react": "^11.14.0",
199
199
  "@emotion/styled": "^11.14.0",
200
200
  "@monaco-editor/react": "^4.7.0",
@@ -209,8 +209,8 @@
209
209
  "recharts": "^3.5.1"
210
210
  },
211
211
  "devDependencies": {
212
- "@12-apps/eslint-config": "^1.6.0",
213
- "@12-apps/typescript-config": "^1.6.0",
212
+ "@12-apps/eslint-config": "^1.6.1",
213
+ "@12-apps/typescript-config": "^1.6.1",
214
214
  "@playwright/test": "^1.55.0",
215
215
  "@storybook/addon-docs": "^9.1.10",
216
216
  "@storybook/addon-links": "^9.1.10",
@@ -0,0 +1,117 @@
1
+ import type { BeforeInstallPromptEvent, PwaInstallStash } from './InstallPrompt.types';
2
+
3
+ /**
4
+ * Holds `beforeinstallprompt` from before the app exists.
5
+ *
6
+ * `usePwaInstall` cannot attach its own listener early enough, and no React
7
+ * hook can. Chromium fires the event once, during initial page load, the
8
+ * moment it has a manifest and a registered worker — which in a real app is
9
+ * before hydration, and often before the chunk containing the component has
10
+ * even been downloaded. A listener registered in an effect is not late by
11
+ * milliseconds; it can be late by entire navigations. Nothing calls
12
+ * `preventDefault()` in time, the browser keeps the event, and the page can
13
+ * never ask again.
14
+ *
15
+ * So the capture has to live outside React, and the host has to install it as
16
+ * the very first thing the document does. Two supported ways, in order of
17
+ * reliability:
18
+ *
19
+ * 1. An inline `<script>` in `<head>`, which beats a deferred module bundle:
20
+ *
21
+ * <script>
22
+ * window.addEventListener('beforeinstallprompt', function (e) {
23
+ * e.preventDefault();
24
+ * var s = (window.__pwaInstall = window.__pwaInstall || {});
25
+ * s.event = e; s.firedAt = Date.now();
26
+ * window.dispatchEvent(new Event('pwa-install-available'));
27
+ * });
28
+ * </script>
29
+ *
30
+ * 2. `capturePwaInstallEvent()` as the first statement of the entry module,
31
+ * before `createRoot`. Simpler to wire and correct in most apps, but a
32
+ * module is still deferred, so a very early event can outrun it.
33
+ *
34
+ * Both write the same shape, so the hook does not care which was used.
35
+ */
36
+
37
+ /** Property on `window` holding the captured event. Shared with the inline snippet. */
38
+ export const PWA_INSTALL_STASH_KEY = '__pwaInstall';
39
+
40
+ /** Fired after a capture, so an app already running can adopt the event. */
41
+ export const PWA_INSTALL_AVAILABLE_EVENT = 'pwa-install-available';
42
+
43
+ type StashHost = Window & { [PWA_INSTALL_STASH_KEY]?: PwaInstallStash };
44
+
45
+ /** The stash, or `null` when no capture ever ran (SSR, or a host that skipped wiring). */
46
+ export const readPwaInstallStash = (): PwaInstallStash | null => {
47
+ if (typeof window === 'undefined') return null;
48
+ return (window as StashHost)[PWA_INSTALL_STASH_KEY] ?? null;
49
+ };
50
+
51
+ const ensureStash = (): PwaInstallStash => {
52
+ const host = window as StashHost;
53
+ host[PWA_INSTALL_STASH_KEY] ??= { event: null, firedAt: null, installedAt: null };
54
+ return host[PWA_INSTALL_STASH_KEY];
55
+ };
56
+
57
+ /**
58
+ * The teardown for the capture currently running, so `resetPwaInstallStash`
59
+ * can unwind completely. Held here rather than on the stash because it must
60
+ * survive the stash being deleted, and because it must never be serialised
61
+ * into the diagnostic payload.
62
+ */
63
+ let activeTeardown: (() => void) | null = null;
64
+
65
+ /**
66
+ * Starts capturing. Returns a teardown, and is safe to call twice — a second
67
+ * call is a no-op rather than a duplicate listener, so a host that wires both
68
+ * the inline snippet and this function does not double-handle the event.
69
+ */
70
+ export const capturePwaInstallEvent = (): (() => void) => {
71
+ if (typeof window === 'undefined') return () => undefined;
72
+
73
+ const stash = ensureStash();
74
+ if (stash.capturing === true) return () => undefined;
75
+ stash.capturing = true;
76
+
77
+ const onBeforeInstall = (event: Event): void => {
78
+ // The line the whole component depends on. Without it the browser owns the
79
+ // event, shows at most its own mini-infobar, and discards the handle.
80
+ event.preventDefault();
81
+ stash.event = event as BeforeInstallPromptEvent;
82
+ stash.firedAt = Date.now();
83
+ window.dispatchEvent(new Event(PWA_INSTALL_AVAILABLE_EVENT));
84
+ };
85
+
86
+ const onInstalled = (): void => {
87
+ stash.installedAt = Date.now();
88
+ stash.event = null;
89
+ };
90
+
91
+ window.addEventListener('beforeinstallprompt', onBeforeInstall);
92
+ window.addEventListener('appinstalled', onInstalled);
93
+
94
+ const teardown = () => {
95
+ window.removeEventListener('beforeinstallprompt', onBeforeInstall);
96
+ window.removeEventListener('appinstalled', onInstalled);
97
+ stash.capturing = false;
98
+ if (activeTeardown === teardown) activeTeardown = null;
99
+ };
100
+
101
+ activeTeardown = teardown;
102
+ return teardown;
103
+ };
104
+
105
+ /**
106
+ * Returns the page to its pre-capture state: listeners removed, stash gone.
107
+ *
108
+ * Exists for tests and stories, which need a clean page per case. It unwinds
109
+ * the listeners as well as the data, because a leaked capture from a previous
110
+ * case would go on suppressing events that the next case expects to observe.
111
+ */
112
+ export const resetPwaInstallStash = (): void => {
113
+ if (typeof window === 'undefined') return;
114
+ activeTeardown?.();
115
+ activeTeardown = null;
116
+ delete (window as StashHost)[PWA_INSTALL_STASH_KEY];
117
+ };
@@ -85,6 +85,54 @@ raised from a timer or an effect.
85
85
  All copy defaults to English and every string is a prop — this package is
86
86
  locale-neutral and the consuming app supplies its own wording.
87
87
 
88
+ ## Wiring the capture
89
+
90
+ **Required on Chromium.** Without it this component can never offer an install,
91
+ and it will fail silently — no error, no warning, just an affordance that never
92
+ appears.
93
+
94
+ Chromium fires `beforeinstallprompt` once, during page load, as soon as it has a
95
+ manifest and a registered worker. That is before hydration, and in a code-split
96
+ app it can be before the chunk holding this component has been downloaded at
97
+ all. No React hook can attach a listener in time, and the event is not reissued
98
+ on request. So the capture has to run outside React, and first.
99
+
100
+ Paste this into `<head>`, above the app bundle. A classic inline script is the
101
+ only thing guaranteed to beat a deferred module:
102
+
103
+ ```html
104
+ <script>
105
+ (function () {
106
+ var stash = (window.__pwaInstall = { event: null, firedAt: null, installedAt: null });
107
+ window.addEventListener('beforeinstallprompt', function (event) {
108
+ event.preventDefault();
109
+ stash.event = event;
110
+ stash.firedAt = Date.now();
111
+ window.dispatchEvent(new Event('pwa-install-available'));
112
+ });
113
+ window.addEventListener('appinstalled', function () {
114
+ stash.installedAt = Date.now();
115
+ stash.event = null;
116
+ });
117
+ })();
118
+ </script>
119
+ ```
120
+
121
+ Or call `capturePwaInstallEvent()` as the first statement of your entry module,
122
+ before `createRoot`. Simpler to wire, but a module is still deferred, so a very
123
+ early event can outrun it. Doing both is safe — the capture is idempotent.
124
+
125
+ ```ts
126
+ import { capturePwaInstallEvent } from '@12-apps/ui/utility/InstallPrompt';
127
+
128
+ capturePwaInstallEvent();
129
+ ```
130
+
131
+ `readPwaInstallStash()` exposes what was captured (`event`, `firedAt`,
132
+ `installedAt`), which is worth logging when the prompt does not appear:
133
+ `firedAt` set with no visible affordance means something downstream suppressed
134
+ it, whereas an absent stash means the wiring above is missing.
135
+
88
136
  ## Hook API
89
137
 
90
138
  `usePwaInstall(options)` returns:
@@ -107,7 +155,7 @@ locale-neutral and the consuming app supplies its own wording.
107
155
  - **Storage failures are swallowed.** `localStorage` throws rather than returning
108
156
  `null` in Safari private mode and wherever cookies are blocked; an uncaught throw
109
157
  would break the page on exactly the browsers this component serves.
110
- - **A missed first event recovers on its own.** The listener attaches on mount,
111
- which can be after a cold-load event. Chromium re-fires on navigation and on
112
- re-evaluation of the criteria. A host that needs the very first one can capture
113
- it in its HTML entry point and re-dispatch it once the app has mounted.
158
+ - **A missed event does NOT recover.** Chromium fires `beforeinstallprompt` once
159
+ and will not reissue it on request, so a listener that attaches on mount has
160
+ already lost. This is why "Wiring the capture" above is a requirement rather
161
+ than an optimisation.
@@ -15,6 +15,25 @@ export interface BeforeInstallPromptEvent extends Event {
15
15
  prompt: () => Promise<void>;
16
16
  }
17
17
 
18
+ /**
19
+ * What the pre-React capture left on `window` for the hook to pick up.
20
+ *
21
+ * This is the handoff between code that runs before the app exists and code
22
+ * that runs inside it. Written either by `capturePwaInstallEvent()` or by the
23
+ * equivalent inline snippet in the host's HTML, and read on mount — the event
24
+ * has almost always fired long before any component is alive to hear it.
25
+ */
26
+ export interface PwaInstallStash {
27
+ /** The held event, or `null` if none has fired or it was already spent. */
28
+ event: BeforeInstallPromptEvent | null;
29
+ /** When it was captured. Diagnostic: proves the capture ran and the browser offered. */
30
+ firedAt: number | null;
31
+ /** When `appinstalled` was seen, if it arrived before the app mounted. */
32
+ installedAt: number | null;
33
+ /** Guards against a host wiring both the inline snippet and the function. */
34
+ capturing?: boolean;
35
+ }
36
+
18
37
  /**
19
38
  * How — or whether — this browser can install the app.
20
39
  *
@@ -1,10 +1,18 @@
1
1
  export { InstallPrompt } from './InstallPrompt';
2
2
  export { usePwaInstall } from './usePwaInstall';
3
+ export {
4
+ capturePwaInstallEvent,
5
+ readPwaInstallStash,
6
+ resetPwaInstallStash,
7
+ PWA_INSTALL_AVAILABLE_EVENT,
8
+ PWA_INSTALL_STASH_KEY,
9
+ } from './InstallPrompt.earlyCapture';
3
10
  export type {
4
11
  BeforeInstallPromptEvent,
5
12
  InstallOutcome,
6
13
  InstallPlatform,
7
14
  InstallPromptProps,
8
15
  PwaInstallState,
16
+ PwaInstallStash,
9
17
  UsePwaInstallOptions,
10
18
  } from './InstallPrompt.types';
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
 
3
+ import { PWA_INSTALL_AVAILABLE_EVENT, readPwaInstallStash } from './InstallPrompt.earlyCapture';
3
4
  import {
4
5
  DEFAULT_DISMISS_DAYS,
5
6
  DEFAULT_STORAGE_KEY,
@@ -50,7 +51,9 @@ const useInstalledState = (onInstalled?: () => void) => {
50
51
  }, [onInstalled]);
51
52
 
52
53
  useEffect(() => {
53
- setIsInstalled(isStandaloneDisplay());
54
+ // `appinstalled` can also land before the app mounts, so the stash is
55
+ // consulted for the same reason the deferred event is.
56
+ setIsInstalled(isStandaloneDisplay() || readPwaInstallStash()?.installedAt != null);
54
57
 
55
58
  const markInstalled = () => {
56
59
  setIsInstalled(true);
@@ -65,29 +68,54 @@ const useInstalledState = (onInstalled?: () => void) => {
65
68
  };
66
69
 
67
70
  /**
68
- * Captures and holds the deferred `beforeinstallprompt` event.
71
+ * Holds the deferred `beforeinstallprompt` event.
69
72
  *
70
- * The listener is attached on mount, which is normally AFTER the browser would
71
- * have fired the event on a cold load. Chromium re-fires it on navigation and
72
- * on re-evaluation of the install criteria, so a missed first event resolves
73
- * itself; a host that needs the very first one can attach an early capture in
74
- * its HTML entry point and re-dispatch it.
73
+ * The stash written by `capturePwaInstallEvent` is the PRIMARY source, not a
74
+ * fallback. Chromium fires the event once, during initial page load; a
75
+ * listener attached in an effect runs after hydration and, when the component
76
+ * is code-split, after a download that may not happen until a later route. In
77
+ * a real browser it essentially never catches the event, and the event is not
78
+ * reissued on request — so a hook that relies on its own listener reports
79
+ * "cannot install" forever on the one platform that can.
80
+ *
81
+ * The live listeners below therefore cover only the narrow case they can
82
+ * actually serve: a browser re-evaluating installability while the app is
83
+ * already running, and a host that never wired the capture at all.
75
84
  */
76
85
  const useDeferredPrompt = () => {
77
86
  const deferredRef = useRef<BeforeInstallPromptEvent | null>(null);
78
87
  const [promptReady, setPromptReady] = useState(false);
79
88
 
80
89
  useEffect(() => {
90
+ const hold = (event: BeforeInstallPromptEvent) => {
91
+ deferredRef.current = event;
92
+ setPromptReady(true);
93
+ };
94
+
95
+ // Whatever fired before this component existed.
96
+ const held = readPwaInstallStash()?.event;
97
+ if (held) hold(held);
98
+
99
+ // The capture rang the bell after mount: adopt what it is holding.
100
+ const adopt = () => {
101
+ const fresh = readPwaInstallStash()?.event;
102
+ if (fresh) hold(fresh);
103
+ };
104
+
81
105
  const capture = (event: Event) => {
82
106
  // Without preventDefault the browser owns the event and the handle dies
83
- // with it — this is the line that makes an in-page install button possible.
107
+ // with it. The capture has normally done this already; repeating it is
108
+ // harmless and keeps the hook correct on a host that skipped wiring.
84
109
  event.preventDefault();
85
- deferredRef.current = event as BeforeInstallPromptEvent;
86
- setPromptReady(true);
110
+ hold(event as BeforeInstallPromptEvent);
87
111
  };
88
112
 
113
+ window.addEventListener(PWA_INSTALL_AVAILABLE_EVENT, adopt);
89
114
  window.addEventListener('beforeinstallprompt', capture);
90
- return () => window.removeEventListener('beforeinstallprompt', capture);
115
+ return () => {
116
+ window.removeEventListener(PWA_INSTALL_AVAILABLE_EVENT, adopt);
117
+ window.removeEventListener('beforeinstallprompt', capture);
118
+ };
91
119
  }, []);
92
120
 
93
121
  const clearPrompt = useCallback(() => {