@flighthq/lifecycle 0.1.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.
@@ -0,0 +1,2 @@
1
+ export * from './lifecycle';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './lifecycle';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC"}
@@ -0,0 +1,15 @@
1
+ import type { AppLaunchKind, AppLifecycle, AppLifecycleState, LifecycleBackend } from '@flighthq/types';
2
+ export declare function attachAppLifecycle(app: AppLifecycle): void;
3
+ export declare function createAppLifecycle(): AppLifecycle;
4
+ export declare function createWebLifecycleBackend(): LifecycleBackend;
5
+ export declare function detachAppLifecycle(app: AppLifecycle): void;
6
+ export declare function disposeAppLifecycle(app: AppLifecycle): void;
7
+ export declare function getAppLaunchKind(): AppLaunchKind;
8
+ export declare function getAppLifecycleState(): AppLifecycleState;
9
+ export declare function getLifecycleBackend(): LifecycleBackend;
10
+ export declare function isAppActive(): boolean;
11
+ export declare function isAppBackground(): boolean;
12
+ export declare function isAppInactive(): boolean;
13
+ export declare function requestAppBack(app: AppLifecycle): boolean;
14
+ export declare function setLifecycleBackend(backend: LifecycleBackend | null): void;
15
+ //# sourceMappingURL=lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../src/lifecycle.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,aAAa,EACb,YAAY,EACZ,iBAAiB,EAEjB,gBAAgB,EACjB,MAAM,iBAAiB,CAAC;AAYzB,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CAqC1D;AAGD,wBAAgB,kBAAkB,IAAI,YAAY,CAUjD;AAmBD,wBAAgB,yBAAyB,IAAI,gBAAgB,CA4E5D;AAGD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CAM1D;AAID,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CAG3D;AAMD,wBAAgB,gBAAgB,IAAI,aAAa,CAGhD;AAGD,wBAAgB,oBAAoB,IAAI,iBAAiB,CAExD;AAGD,wBAAgB,mBAAmB,IAAI,gBAAgB,CAGtD;AAGD,wBAAgB,WAAW,IAAI,OAAO,CAErC;AAGD,wBAAgB,eAAe,IAAI,OAAO,CAEzC;AAID,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAOD,wBAAgB,cAAc,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAGzD;AAGD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,gBAAgB,GAAG,IAAI,GAAG,IAAI,CAE1E"}
@@ -0,0 +1,223 @@
1
+ import { createSignal, emitSignal } from '@flighthq/signals';
2
+ // Begins delivering lifecycle changes to `app`'s signals by subscribing to the active backend. On
3
+ // each change it reads the current state and emits onStateChange plus onResume when transitioning
4
+ // to 'active' and onPause when leaving 'active'. The web backend drives onBackButton only in
5
+ // environments that support it; native hosts emit it through their own backend. Idempotent: a prior
6
+ // subscription is torn down first. Pair with detachAppLifecycle/disposeAppLifecycle.
7
+ //
8
+ // onStateChange is "raw, not deduped" — it fires on every backend notification regardless of
9
+ // whether the derived state changed. onResume/onPause are deduped edges: 'active'→non-'active'
10
+ // fires onPause (including the interruption edge 'active'→'inactive'); non-'active'→'active' fires
11
+ // onResume. The 'inactive'→'background' and reverse transitions do not fire onPause/onResume again.
12
+ export function attachAppLifecycle(app) {
13
+ detachAppLifecycle(app);
14
+ const backend = getLifecycleBackend();
15
+ let previous = backend.getState();
16
+ const unsubscribeState = backend.subscribe(() => {
17
+ const state = backend.getState();
18
+ emitSignal(app.onStateChange, state);
19
+ if (state === 'active' && previous !== 'active') {
20
+ emitSignal(app.onResume);
21
+ // Warm resume: restore saved state.
22
+ const saved = _savedState.get(app);
23
+ if (saved !== undefined) {
24
+ emitSignal(app.onRestoreState, saved);
25
+ }
26
+ }
27
+ else if (state !== 'active' && previous === 'active') {
28
+ emitSignal(app.onPause);
29
+ // Collect transient UI state for potential restore on next resume.
30
+ const stateBag = {};
31
+ emitSignal(app.onSaveState, stateBag);
32
+ _savedState.set(app, stateBag);
33
+ }
34
+ previous = state;
35
+ });
36
+ let unsubscribeMemory;
37
+ const memSub = backend.subscribeMemoryWarning;
38
+ if (memSub !== undefined) {
39
+ unsubscribeMemory = memSub.call(backend, (level) => {
40
+ emitSignal(app.onMemoryWarning, level);
41
+ });
42
+ }
43
+ _subscriptions.set(app, () => {
44
+ unsubscribeState();
45
+ unsubscribeMemory?.();
46
+ });
47
+ }
48
+ // Allocates an AppLifecycle event entity with inert signals; call attachAppLifecycle to start delivery.
49
+ export function createAppLifecycle() {
50
+ return {
51
+ onStateChange: createSignal(),
52
+ onResume: createSignal(),
53
+ onPause: createSignal(),
54
+ onBackButton: createSignal(),
55
+ onMemoryWarning: createSignal(),
56
+ onSaveState: createSignal(),
57
+ onRestoreState: createSignal(),
58
+ };
59
+ }
60
+ // Builds the default web backend over document visibility, window focus/blur, and pagehide/pageshow
61
+ // events. Produces three states:
62
+ // 'active' — document visible and window focused
63
+ // 'inactive' — document visible but window not focused (app switcher, control-center, other window)
64
+ // 'background' — document.hidden (tab hidden or page unloading)
65
+ // Degrades to state 'active' and a no-op subscription where document/window are absent (SSR/jsdom).
66
+ //
67
+ // getLaunchKind() approximates cold vs. warm using PerformanceNavigationTiming.type:
68
+ // 'back_forward' → 'warm' — page was restored from the bfcache (JS heap was frozen and thawed;
69
+ // the closest web equivalent of a process warm-resume)
70
+ // all others → 'cold' — 'navigate', 'reload', 'prerender' are each a fresh page lifecycle
71
+ // Falls back to 'cold' when the Performance Navigation Timing API is unavailable (SSR/jsdom).
72
+ //
73
+ // subscribeMemoryWarning() wires the experimental 'memory-pressure' window event (Chrome origin
74
+ // trial / behind flags). The event detail carries a 'critical' pressure string; this backend maps
75
+ // it to 'critical' and fires 'normal' on the subsequent resolution event when present. Falls back to
76
+ // no-op unsubscribe when the event is not supported (no standard API is widely deployed as of 2026).
77
+ export function createWebLifecycleBackend() {
78
+ let _windowFocused = typeof document !== 'undefined';
79
+ return {
80
+ getState() {
81
+ if (typeof document === 'undefined')
82
+ return 'active';
83
+ if (document.hidden)
84
+ return 'background';
85
+ return _windowFocused ? 'active' : 'inactive';
86
+ },
87
+ subscribe(listener) {
88
+ if (typeof document === 'undefined' || typeof window === 'undefined')
89
+ return () => { };
90
+ // Initialize focus state at subscribe time.
91
+ _windowFocused = document.hasFocus();
92
+ const onFocus = () => {
93
+ _windowFocused = true;
94
+ listener();
95
+ };
96
+ const onBlur = () => {
97
+ _windowFocused = false;
98
+ listener();
99
+ };
100
+ document.addEventListener('visibilitychange', listener);
101
+ window.addEventListener('pagehide', listener);
102
+ window.addEventListener('pageshow', listener);
103
+ window.addEventListener('focus', onFocus);
104
+ window.addEventListener('blur', onBlur);
105
+ return () => {
106
+ document.removeEventListener('visibilitychange', listener);
107
+ window.removeEventListener('pagehide', listener);
108
+ window.removeEventListener('pageshow', listener);
109
+ window.removeEventListener('focus', onFocus);
110
+ window.removeEventListener('blur', onBlur);
111
+ };
112
+ },
113
+ getLaunchKind() {
114
+ // PerformanceNavigationTiming.type === 'back_forward' indicates the page was restored from the
115
+ // browser's back/forward cache (bfcache). The JS heap was frozen and thawed without a fresh
116
+ // process start — the closest analog to a mobile warm-resume. All other navigation types
117
+ // ('navigate', 'reload', 'prerender') are a fresh page lifecycle, i.e. cold.
118
+ if (typeof performance === 'undefined')
119
+ return 'cold';
120
+ const entries = performance.getEntriesByType('navigation');
121
+ if (entries.length > 0 && entries[0].type === 'back_forward')
122
+ return 'warm';
123
+ return 'cold';
124
+ },
125
+ subscribeMemoryWarning(listener) {
126
+ // The 'memory-pressure' window event is an experimental Chrome API (origin trial / behind
127
+ // flags as of 2026). The event carries a detail object with a 'pressure' string. This backend
128
+ // maps 'critical' pressure to AppMemoryPressure 'critical'; a pressure-resolved / 'moderate'
129
+ // event maps to 'moderate'. Falls back to no-op unsubscribe when the event is not supported
130
+ // (no cross-browser API for memory pressure is widely deployed yet).
131
+ if (typeof window === 'undefined')
132
+ return () => { };
133
+ // Feature-detect: attempt to register and immediately remove a passive listener to check
134
+ // whether the event is supported. If addEventListener silently ignores the event type there
135
+ // is nothing we can do — we just return no-op without pretending to be subscribed.
136
+ const onPressure = (e) => {
137
+ const detail = e.detail;
138
+ const pressure = detail?.pressure;
139
+ if (pressure === 'critical') {
140
+ listener('critical');
141
+ }
142
+ else if (pressure === 'moderate') {
143
+ listener('moderate');
144
+ }
145
+ else {
146
+ // Unknown pressure level — treat as moderate rather than silently dropping.
147
+ listener('moderate');
148
+ }
149
+ };
150
+ const onPressureRelieved = () => {
151
+ listener('normal');
152
+ };
153
+ window.addEventListener('memory-pressure', onPressure);
154
+ window.addEventListener('memory-pressure-relieved', onPressureRelieved);
155
+ return () => {
156
+ window.removeEventListener('memory-pressure', onPressure);
157
+ window.removeEventListener('memory-pressure-relieved', onPressureRelieved);
158
+ };
159
+ },
160
+ };
161
+ }
162
+ // Stops delivery to `app` and forgets its subscription. Safe to call when not attached.
163
+ export function detachAppLifecycle(app) {
164
+ const unsubscribe = _subscriptions.get(app);
165
+ if (unsubscribe !== undefined) {
166
+ unsubscribe();
167
+ _subscriptions.delete(app);
168
+ }
169
+ }
170
+ // Releases `app` for garbage collection by detaching its backend subscription. The signals remain
171
+ // plain GC-managed memory afterward.
172
+ export function disposeAppLifecycle(app) {
173
+ detachAppLifecycle(app);
174
+ _savedState.delete(app);
175
+ }
176
+ // Returns the kind of launch — 'cold' (fresh process) or 'warm' (resumed from background). The web
177
+ // backend approximates this via PerformanceNavigationTiming.type ('back_forward' → 'warm', all
178
+ // others → 'cold'). Returns 'warm' as a safe fallback when the backend does not implement
179
+ // getLaunchKind (legacy or minimal backends that pre-date the optional method).
180
+ export function getAppLaunchKind() {
181
+ const backend = getLifecycleBackend();
182
+ return backend.getLaunchKind !== undefined ? backend.getLaunchKind() : 'warm';
183
+ }
184
+ // Returns the current application lifecycle state from the active backend.
185
+ export function getAppLifecycleState() {
186
+ return getLifecycleBackend().getState();
187
+ }
188
+ // The active lifecycle backend, or a lazily-created web default. There is always a backend.
189
+ export function getLifecycleBackend() {
190
+ if (_backend === null)
191
+ _backend = createWebLifecycleBackend();
192
+ return _backend;
193
+ }
194
+ // Returns true when the application is in the 'active' state (visible and focused).
195
+ export function isAppActive() {
196
+ return getLifecycleBackend().getState() === 'active';
197
+ }
198
+ // Returns true when the application is in the 'background' state (hidden/suspended).
199
+ export function isAppBackground() {
200
+ return getLifecycleBackend().getState() === 'background';
201
+ }
202
+ // Returns true when the application is in the 'inactive' state (visible but not focused —
203
+ // e.g. app switcher, control-center overlay, incoming call).
204
+ export function isAppInactive() {
205
+ return getLifecycleBackend().getState() === 'inactive';
206
+ }
207
+ // Emits app.onBackButton and returns whether the back action may proceed. Returns false when a
208
+ // listener vetoed by calling cancelSignal(app.onBackButton), meaning the listener handled
209
+ // navigation itself. Returns true when no listener consumed the event and the host should perform
210
+ // the default back action (navigate up or exit). Mirrors the onCloseRequest/requestCloseWindow
211
+ // veto contract from @flighthq/application.
212
+ export function requestAppBack(app) {
213
+ emitSignal(app.onBackButton);
214
+ return app.onBackButton.data?.cancelled !== true;
215
+ }
216
+ // Installs a native host lifecycle backend; pass null to fall back to the web default.
217
+ export function setLifecycleBackend(backend) {
218
+ _backend = backend;
219
+ }
220
+ let _backend = null;
221
+ const _savedState = new WeakMap();
222
+ const _subscriptions = new WeakMap();
223
+ //# sourceMappingURL=lifecycle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifecycle.js","sourceRoot":"","sources":["../src/lifecycle.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@flighthq/lifecycle",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "src/**/*.test.ts",
16
+ "!dist/**/*.test.js",
17
+ "!dist/**/*.test.d.ts",
18
+ "!dist/**/*.test.js.map",
19
+ "!dist/**/*.test.d.ts.map"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -b",
23
+ "clean": "tsc -b --clean",
24
+ "test": "vitest run --config vitest.config.ts",
25
+ "test:watch": "vitest --watch --config vitest.config.ts",
26
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
27
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
28
+ },
29
+ "dependencies": {
30
+ "@flighthq/signals": "0.1.0",
31
+ "@flighthq/types": "0.1.0"
32
+ },
33
+ "devDependencies": {
34
+ "typescript": "^5.3.0"
35
+ },
36
+ "description": "Application foreground/background lifecycle state and resume/pause/back signals over a swappable web/native backend",
37
+ "sideEffects": false
38
+ }
@@ -0,0 +1,550 @@
1
+ import { cancelSignal, connectSignal } from '@flighthq/signals';
2
+ import type { AppLifecycleState, AppMemoryPressure, LifecycleBackend } from '@flighthq/types';
3
+
4
+ import {
5
+ attachAppLifecycle,
6
+ createAppLifecycle,
7
+ createWebLifecycleBackend,
8
+ detachAppLifecycle,
9
+ disposeAppLifecycle,
10
+ getAppLaunchKind,
11
+ getAppLifecycleState,
12
+ getLifecycleBackend,
13
+ isAppActive,
14
+ isAppBackground,
15
+ isAppInactive,
16
+ requestAppBack,
17
+ setLifecycleBackend,
18
+ } from './lifecycle';
19
+
20
+ type FakeBackend = LifecycleBackend & {
21
+ state: AppLifecycleState;
22
+ fire: () => void;
23
+ fireMemory: (level: AppMemoryPressure) => void;
24
+ };
25
+
26
+ function fakeBackend(): FakeBackend {
27
+ let stateListener: (() => void) | null = null;
28
+ let memoryListener: ((level: AppMemoryPressure) => void) | null = null;
29
+ return {
30
+ state: 'active',
31
+ getState() {
32
+ return this.state;
33
+ },
34
+ subscribe(l) {
35
+ stateListener = l;
36
+ return () => {
37
+ stateListener = null;
38
+ };
39
+ },
40
+ subscribeMemoryWarning(l) {
41
+ memoryListener = l;
42
+ return () => {
43
+ memoryListener = null;
44
+ };
45
+ },
46
+ fire() {
47
+ stateListener?.();
48
+ },
49
+ fireMemory(level: AppMemoryPressure) {
50
+ memoryListener?.(level);
51
+ },
52
+ };
53
+ }
54
+
55
+ afterEach(() => setLifecycleBackend(null));
56
+
57
+ describe('attachAppLifecycle', () => {
58
+ it('emits onPause and onStateChange when leaving active for background', () => {
59
+ const backend = fakeBackend();
60
+ setLifecycleBackend(backend);
61
+ const app = createAppLifecycle();
62
+ let changes = 0;
63
+ let pauses = 0;
64
+ connectSignal(app.onStateChange, () => changes++);
65
+ connectSignal(app.onPause, () => pauses++);
66
+ attachAppLifecycle(app);
67
+ backend.state = 'background';
68
+ backend.fire();
69
+ expect(changes).toBe(1);
70
+ expect(pauses).toBe(1);
71
+ });
72
+
73
+ it('emits onPause when leaving active for inactive', () => {
74
+ const backend = fakeBackend();
75
+ setLifecycleBackend(backend);
76
+ const app = createAppLifecycle();
77
+ let pauses = 0;
78
+ connectSignal(app.onPause, () => pauses++);
79
+ attachAppLifecycle(app);
80
+ backend.state = 'inactive';
81
+ backend.fire();
82
+ expect(pauses).toBe(1);
83
+ });
84
+
85
+ it('does not double-fire onPause for inactive → background', () => {
86
+ const backend = fakeBackend();
87
+ backend.state = 'inactive';
88
+ setLifecycleBackend(backend);
89
+ const app = createAppLifecycle();
90
+ let pauses = 0;
91
+ connectSignal(app.onPause, () => pauses++);
92
+ attachAppLifecycle(app);
93
+ backend.state = 'background';
94
+ backend.fire();
95
+ // inactive → background should not re-fire onPause (already paused)
96
+ expect(pauses).toBe(0);
97
+ });
98
+
99
+ it('emits onResume when returning to active from background', () => {
100
+ const backend = fakeBackend();
101
+ backend.state = 'background';
102
+ setLifecycleBackend(backend);
103
+ const app = createAppLifecycle();
104
+ let resumes = 0;
105
+ connectSignal(app.onResume, () => resumes++);
106
+ attachAppLifecycle(app);
107
+ backend.state = 'active';
108
+ backend.fire();
109
+ expect(resumes).toBe(1);
110
+ });
111
+
112
+ it('emits onResume when returning to active from inactive', () => {
113
+ const backend = fakeBackend();
114
+ backend.state = 'inactive';
115
+ setLifecycleBackend(backend);
116
+ const app = createAppLifecycle();
117
+ let resumes = 0;
118
+ connectSignal(app.onResume, () => resumes++);
119
+ attachAppLifecycle(app);
120
+ backend.state = 'active';
121
+ backend.fire();
122
+ expect(resumes).toBe(1);
123
+ });
124
+
125
+ it('is idempotent — re-attaching tears down the prior subscription', () => {
126
+ const backend = fakeBackend();
127
+ setLifecycleBackend(backend);
128
+ const app = createAppLifecycle();
129
+ let changes = 0;
130
+ connectSignal(app.onStateChange, () => changes++);
131
+ attachAppLifecycle(app);
132
+ attachAppLifecycle(app);
133
+ backend.state = 'background';
134
+ backend.fire();
135
+ // Only one subscription should be active.
136
+ expect(changes).toBe(1);
137
+ });
138
+
139
+ it('emits onSaveState when leaving active', () => {
140
+ const backend = fakeBackend();
141
+ setLifecycleBackend(backend);
142
+ const app = createAppLifecycle();
143
+ let saved = false;
144
+ connectSignal(app.onSaveState, (bag) => {
145
+ bag['key'] = 'value';
146
+ saved = true;
147
+ });
148
+ attachAppLifecycle(app);
149
+ backend.state = 'background';
150
+ backend.fire();
151
+ expect(saved).toBe(true);
152
+ });
153
+
154
+ it('emits onRestoreState with saved bag when returning to active', () => {
155
+ const backend = fakeBackend();
156
+ setLifecycleBackend(backend);
157
+ const app = createAppLifecycle();
158
+ connectSignal(app.onSaveState, (bag) => {
159
+ bag['x'] = 42;
160
+ });
161
+ let restored: Readonly<Record<string, unknown>> | null = null;
162
+ connectSignal(app.onRestoreState, (state) => {
163
+ restored = state;
164
+ });
165
+ attachAppLifecycle(app);
166
+ // Transition away to trigger save.
167
+ backend.state = 'background';
168
+ backend.fire();
169
+ // Transition back to trigger restore.
170
+ backend.state = 'active';
171
+ backend.fire();
172
+ expect(restored).not.toBeNull();
173
+ expect(restored?.['x']).toBe(42);
174
+ });
175
+
176
+ it('subscribes to memory warnings when backend supports it', () => {
177
+ const backend = fakeBackend();
178
+ setLifecycleBackend(backend);
179
+ const app = createAppLifecycle();
180
+ const levels: AppMemoryPressure[] = [];
181
+ connectSignal(app.onMemoryWarning, (level) => levels.push(level));
182
+ attachAppLifecycle(app);
183
+ backend.fireMemory('critical');
184
+ expect(levels).toEqual(['critical']);
185
+ });
186
+
187
+ // Property/fuzz coverage over transition storms. These validate the already-documented edge
188
+ // contract — onStateChange is raw (one emit per backend notification), while onResume/onPause are
189
+ // deduped 'active'↔non-'active' boundary edges — against random sequences of the three states,
190
+ // rather than a single hand-picked path. The invariants are derived independently from the same
191
+ // model the implementation describes, so a regression in the dedup logic surfaces as a mismatch.
192
+ it('property: onStateChange fires exactly once per backend notification across random storms', () => {
193
+ const states: AppLifecycleState[] = ['active', 'inactive', 'background'];
194
+ for (let trial = 0; trial < 200; trial++) {
195
+ const backend = fakeBackend();
196
+ setLifecycleBackend(backend);
197
+ const app = createAppLifecycle();
198
+ let changes = 0;
199
+ connectSignal(app.onStateChange, () => changes++);
200
+ attachAppLifecycle(app);
201
+ const fireCount = 1 + Math.floor(Math.random() * 30);
202
+ for (let i = 0; i < fireCount; i++) {
203
+ backend.state = states[Math.floor(Math.random() * states.length)];
204
+ backend.fire();
205
+ }
206
+ // Raw, not deduped: every notification emits, even when the derived state is unchanged.
207
+ expect(changes).toBe(fireCount);
208
+ detachAppLifecycle(app);
209
+ }
210
+ });
211
+
212
+ it('property: onResume/onPause collapse a random storm to the minimal active-boundary edge set', () => {
213
+ const states: AppLifecycleState[] = ['active', 'inactive', 'background'];
214
+ for (let trial = 0; trial < 200; trial++) {
215
+ const backend = fakeBackend();
216
+ const start = states[Math.floor(Math.random() * states.length)];
217
+ backend.state = start;
218
+ setLifecycleBackend(backend);
219
+ const app = createAppLifecycle();
220
+ let pauses = 0;
221
+ let resumes = 0;
222
+ connectSignal(app.onPause, () => pauses++);
223
+ connectSignal(app.onResume, () => resumes++);
224
+ attachAppLifecycle(app);
225
+
226
+ // Drive a random storm while computing the expected edge counts from an independent model:
227
+ // a pause edge is every 'active' → non-'active' transition, a resume edge every
228
+ // non-'active' → 'active' transition. inactive↔background never fires either.
229
+ let previous = start;
230
+ let expectedPauses = 0;
231
+ let expectedResumes = 0;
232
+ const fireCount = 1 + Math.floor(Math.random() * 30);
233
+ for (let i = 0; i < fireCount; i++) {
234
+ const next = states[Math.floor(Math.random() * states.length)];
235
+ backend.state = next;
236
+ backend.fire();
237
+ if (previous === 'active' && next !== 'active') expectedPauses++;
238
+ else if (previous !== 'active' && next === 'active') expectedResumes++;
239
+ previous = next;
240
+ }
241
+ expect(pauses).toBe(expectedPauses);
242
+ expect(resumes).toBe(expectedResumes);
243
+ detachAppLifecycle(app);
244
+ }
245
+ });
246
+
247
+ it('property: a focus/blur flutter that never leaves active emits no pause or resume', () => {
248
+ // A common real storm: rapid blur→focus while the document stays visible. Every notification
249
+ // re-reads 'active' (the fake holds 'active'), so onStateChange fires each time but the deduped
250
+ // edges stay silent — there is no active-boundary crossing.
251
+ for (let trial = 0; trial < 100; trial++) {
252
+ const backend = fakeBackend();
253
+ backend.state = 'active';
254
+ setLifecycleBackend(backend);
255
+ const app = createAppLifecycle();
256
+ let changes = 0;
257
+ let pauses = 0;
258
+ let resumes = 0;
259
+ connectSignal(app.onStateChange, () => changes++);
260
+ connectSignal(app.onPause, () => pauses++);
261
+ connectSignal(app.onResume, () => resumes++);
262
+ attachAppLifecycle(app);
263
+ const fireCount = 1 + Math.floor(Math.random() * 30);
264
+ for (let i = 0; i < fireCount; i++) backend.fire();
265
+ expect(changes).toBe(fireCount);
266
+ expect(pauses).toBe(0);
267
+ expect(resumes).toBe(0);
268
+ detachAppLifecycle(app);
269
+ }
270
+ });
271
+
272
+ it('property: pause and resume counts stay balanced within one across any storm', () => {
273
+ // Across a full storm the deduped edges alternate (pause then resume then pause …) starting
274
+ // from whichever side of the active boundary the storm began on, so the running counts can
275
+ // never differ by more than one — the structural guarantee a state machine of one boolean
276
+ // ('was active') provides.
277
+ const states: AppLifecycleState[] = ['active', 'inactive', 'background'];
278
+ for (let trial = 0; trial < 200; trial++) {
279
+ const backend = fakeBackend();
280
+ backend.state = states[Math.floor(Math.random() * states.length)];
281
+ setLifecycleBackend(backend);
282
+ const app = createAppLifecycle();
283
+ let pauses = 0;
284
+ let resumes = 0;
285
+ connectSignal(app.onPause, () => pauses++);
286
+ connectSignal(app.onResume, () => resumes++);
287
+ attachAppLifecycle(app);
288
+ const fireCount = 1 + Math.floor(Math.random() * 30);
289
+ for (let i = 0; i < fireCount; i++) {
290
+ backend.state = states[Math.floor(Math.random() * states.length)];
291
+ backend.fire();
292
+ }
293
+ expect(Math.abs(pauses - resumes)).toBeLessThanOrEqual(1);
294
+ detachAppLifecycle(app);
295
+ }
296
+ });
297
+ });
298
+
299
+ describe('createAppLifecycle', () => {
300
+ it('creates an entity with all seven signals', () => {
301
+ const app = createAppLifecycle();
302
+ expect(app.onStateChange).toBeDefined();
303
+ expect(app.onResume).toBeDefined();
304
+ expect(app.onPause).toBeDefined();
305
+ expect(app.onBackButton).toBeDefined();
306
+ expect(app.onMemoryWarning).toBeDefined();
307
+ expect(app.onSaveState).toBeDefined();
308
+ expect(app.onRestoreState).toBeDefined();
309
+ });
310
+ });
311
+
312
+ describe('createWebLifecycleBackend', () => {
313
+ it("getLaunchKind returns 'cold' when no performance navigation entries", () => {
314
+ vi.spyOn(performance, 'getEntriesByType').mockReturnValue([]);
315
+ const backend = createWebLifecycleBackend();
316
+ expect(backend.getLaunchKind?.()).toBe('cold');
317
+ vi.restoreAllMocks();
318
+ });
319
+
320
+ it("getLaunchKind returns 'warm' for back_forward navigation type", () => {
321
+ vi.spyOn(performance, 'getEntriesByType').mockReturnValue([
322
+ { type: 'back_forward' } as PerformanceNavigationTiming,
323
+ ]);
324
+ const backend = createWebLifecycleBackend();
325
+ expect(backend.getLaunchKind?.()).toBe('warm');
326
+ vi.restoreAllMocks();
327
+ });
328
+
329
+ it("getLaunchKind returns 'cold' for reload navigation type", () => {
330
+ vi.spyOn(performance, 'getEntriesByType').mockReturnValue([{ type: 'reload' } as PerformanceNavigationTiming]);
331
+ const backend = createWebLifecycleBackend();
332
+ expect(backend.getLaunchKind?.()).toBe('cold');
333
+ vi.restoreAllMocks();
334
+ });
335
+
336
+ it("getLaunchKind returns 'cold' for navigate navigation type", () => {
337
+ vi.spyOn(performance, 'getEntriesByType').mockReturnValue([{ type: 'navigate' } as PerformanceNavigationTiming]);
338
+ const backend = createWebLifecycleBackend();
339
+ expect(backend.getLaunchKind?.()).toBe('cold');
340
+ vi.restoreAllMocks();
341
+ });
342
+
343
+ it('reads a state without throwing', () => {
344
+ expect(typeof createWebLifecycleBackend().getState()).toBe('string');
345
+ });
346
+
347
+ it('subscribeMemoryWarning fires critical when memory-pressure event fires', () => {
348
+ const backend = createWebLifecycleBackend();
349
+ const levels: AppMemoryPressure[] = [];
350
+ const unsubscribe = backend.subscribeMemoryWarning?.((level) => levels.push(level));
351
+ // Simulate a memory-pressure event with 'critical' pressure detail.
352
+ const event = new CustomEvent('memory-pressure', { detail: { pressure: 'critical' } });
353
+ window.dispatchEvent(event);
354
+ expect(levels).toEqual(['critical']);
355
+ unsubscribe?.();
356
+ });
357
+
358
+ it('subscribeMemoryWarning fires moderate when memory-pressure event fires with moderate pressure', () => {
359
+ const backend = createWebLifecycleBackend();
360
+ const levels: AppMemoryPressure[] = [];
361
+ const unsubscribe = backend.subscribeMemoryWarning?.((level) => levels.push(level));
362
+ const event = new CustomEvent('memory-pressure', { detail: { pressure: 'moderate' } });
363
+ window.dispatchEvent(event);
364
+ expect(levels).toEqual(['moderate']);
365
+ unsubscribe?.();
366
+ });
367
+
368
+ it('subscribeMemoryWarning fires normal when memory-pressure-relieved event fires', () => {
369
+ const backend = createWebLifecycleBackend();
370
+ const levels: AppMemoryPressure[] = [];
371
+ const unsubscribe = backend.subscribeMemoryWarning?.((level) => levels.push(level));
372
+ const event = new CustomEvent('memory-pressure-relieved');
373
+ window.dispatchEvent(event);
374
+ expect(levels).toEqual(['normal']);
375
+ unsubscribe?.();
376
+ });
377
+
378
+ it('subscribeMemoryWarning stops delivering after unsubscribe', () => {
379
+ const backend = createWebLifecycleBackend();
380
+ const levels: AppMemoryPressure[] = [];
381
+ const unsubscribe = backend.subscribeMemoryWarning?.((level) => levels.push(level));
382
+ unsubscribe?.();
383
+ const event = new CustomEvent('memory-pressure', { detail: { pressure: 'critical' } });
384
+ window.dispatchEvent(event);
385
+ expect(levels).toHaveLength(0);
386
+ });
387
+
388
+ it('subscribes and unsubscribes without throwing', () => {
389
+ const unsubscribe = createWebLifecycleBackend().subscribe(() => {});
390
+ expect(() => unsubscribe()).not.toThrow();
391
+ });
392
+ });
393
+
394
+ describe('detachAppLifecycle', () => {
395
+ it('stops further delivery', () => {
396
+ const backend = fakeBackend();
397
+ setLifecycleBackend(backend);
398
+ const app = createAppLifecycle();
399
+ let changes = 0;
400
+ connectSignal(app.onStateChange, () => changes++);
401
+ attachAppLifecycle(app);
402
+ detachAppLifecycle(app);
403
+ backend.fire();
404
+ expect(changes).toBe(0);
405
+ });
406
+
407
+ it('is safe to call when not attached', () => {
408
+ const app = createAppLifecycle();
409
+ expect(() => detachAppLifecycle(app)).not.toThrow();
410
+ });
411
+ });
412
+
413
+ describe('disposeAppLifecycle', () => {
414
+ it('detaches the subscription', () => {
415
+ const backend = fakeBackend();
416
+ setLifecycleBackend(backend);
417
+ const app = createAppLifecycle();
418
+ attachAppLifecycle(app);
419
+ expect(() => disposeAppLifecycle(app)).not.toThrow();
420
+ });
421
+
422
+ it('stops delivery after dispose', () => {
423
+ const backend = fakeBackend();
424
+ setLifecycleBackend(backend);
425
+ const app = createAppLifecycle();
426
+ let changes = 0;
427
+ connectSignal(app.onStateChange, () => changes++);
428
+ attachAppLifecycle(app);
429
+ disposeAppLifecycle(app);
430
+ backend.fire();
431
+ expect(changes).toBe(0);
432
+ });
433
+ });
434
+
435
+ describe('getAppLaunchKind', () => {
436
+ it("returns 'warm' when backend does not implement getLaunchKind", () => {
437
+ const backend = fakeBackend();
438
+ setLifecycleBackend(backend);
439
+ expect(getAppLaunchKind()).toBe('warm');
440
+ });
441
+
442
+ it('delegates to backend.getLaunchKind when present', () => {
443
+ const backend: LifecycleBackend = {
444
+ getState: () => 'active',
445
+ subscribe: () => () => {},
446
+ getLaunchKind: () => 'cold',
447
+ };
448
+ setLifecycleBackend(backend);
449
+ expect(getAppLaunchKind()).toBe('cold');
450
+ });
451
+ });
452
+
453
+ describe('getAppLifecycleState', () => {
454
+ it('reads from the active backend', () => {
455
+ const backend = fakeBackend();
456
+ backend.state = 'inactive';
457
+ setLifecycleBackend(backend);
458
+ expect(getAppLifecycleState()).toBe('inactive');
459
+ });
460
+ });
461
+
462
+ describe('getLifecycleBackend', () => {
463
+ it('falls back to a web backend', () => {
464
+ expect(getLifecycleBackend()).not.toBeNull();
465
+ });
466
+ });
467
+
468
+ describe('isAppActive', () => {
469
+ it("returns true when state is 'active'", () => {
470
+ const backend = fakeBackend();
471
+ backend.state = 'active';
472
+ setLifecycleBackend(backend);
473
+ expect(isAppActive()).toBe(true);
474
+ });
475
+
476
+ it("returns false when state is 'background'", () => {
477
+ const backend = fakeBackend();
478
+ backend.state = 'background';
479
+ setLifecycleBackend(backend);
480
+ expect(isAppActive()).toBe(false);
481
+ });
482
+
483
+ it("returns false when state is 'inactive'", () => {
484
+ const backend = fakeBackend();
485
+ backend.state = 'inactive';
486
+ setLifecycleBackend(backend);
487
+ expect(isAppActive()).toBe(false);
488
+ });
489
+ });
490
+
491
+ describe('isAppBackground', () => {
492
+ it("returns true when state is 'background'", () => {
493
+ const backend = fakeBackend();
494
+ backend.state = 'background';
495
+ setLifecycleBackend(backend);
496
+ expect(isAppBackground()).toBe(true);
497
+ });
498
+
499
+ it("returns false when state is 'active'", () => {
500
+ const backend = fakeBackend();
501
+ backend.state = 'active';
502
+ setLifecycleBackend(backend);
503
+ expect(isAppBackground()).toBe(false);
504
+ });
505
+ });
506
+
507
+ describe('isAppInactive', () => {
508
+ it("returns true when state is 'inactive'", () => {
509
+ const backend = fakeBackend();
510
+ backend.state = 'inactive';
511
+ setLifecycleBackend(backend);
512
+ expect(isAppInactive()).toBe(true);
513
+ });
514
+
515
+ it("returns false when state is 'active'", () => {
516
+ const backend = fakeBackend();
517
+ backend.state = 'active';
518
+ setLifecycleBackend(backend);
519
+ expect(isAppInactive()).toBe(false);
520
+ });
521
+ });
522
+
523
+ describe('requestAppBack', () => {
524
+ it('returns true when no listener vetoes', () => {
525
+ const app = createAppLifecycle();
526
+ expect(requestAppBack(app)).toBe(true);
527
+ });
528
+
529
+ it('returns false when a listener calls cancelSignal', () => {
530
+ const app = createAppLifecycle();
531
+ connectSignal(app.onBackButton, () => cancelSignal(app.onBackButton));
532
+ expect(requestAppBack(app)).toBe(false);
533
+ });
534
+
535
+ it('emits onBackButton', () => {
536
+ const app = createAppLifecycle();
537
+ let fired = 0;
538
+ connectSignal(app.onBackButton, () => fired++);
539
+ requestAppBack(app);
540
+ expect(fired).toBe(1);
541
+ });
542
+ });
543
+
544
+ describe('setLifecycleBackend', () => {
545
+ it('clears back to the web fallback when passed null', () => {
546
+ setLifecycleBackend(fakeBackend());
547
+ setLifecycleBackend(null);
548
+ expect(getLifecycleBackend()).not.toBeNull();
549
+ });
550
+ });