@pixpilot/chrome-lifecycle 0.10.5 → 0.11.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/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- const e=new Map;let t=!1;function n(){t||(t=!0,chrome.windows.onRemoved.addListener(t=>{let n=e.get(t);n&&(n.forEach(e=>{e()}),e.delete(t))}))}function r(t,r){n();let i=e.get(t);return i||(i=new Set,e.set(t,i)),i.add(r),()=>{let n=e.get(t);n&&(n.delete(r),n.size===0&&e.delete(t))}}const i=250,a=5e3,o=2;let s=!1;function c(e={}){if(s)return console.info(`Side panel state tracker already initialized`),()=>{};s=!0;let{trackDocumentVisibility:t=!0}=e,n,r,i=0,a,o=!1;function c(){r&&=(clearTimeout(r),void 0)}return chrome.windows.getCurrent(e=>{if(o)return;if(e.id==null){console.error(`[side-panel-state-tracker] Could not get window ID`);return}let s=e.id;function l(){return t&&document.hidden?`hidden`:`visible`}function u(){if(o||r)return;n=void 0,console.info(`[side-panel-state-tracker] Connection to background lost, scheduling reconnect...`);let e=Math.min(250*2**i,5e3);i+=1,r=setTimeout(()=>{r=void 0,f(`reconnected`)},e)}function d(e){let t=n;if(!t){u();return}try{t.postMessage({...e,windowId:s,type:`side-panel-state-tracker`,timestamp:Date.now()})}catch(e){console.error(`[side-panel-state-tracker] Failed to send message:`,e),u()}}function f(e){if(!o){c();try{let t=chrome.runtime.connect({name:chrome.runtime.id});n=t,i=0,t.onDisconnect.addListener(()=>{n===t&&(console.info(`[side-panel-state-tracker] Background connection lost.`),u())}),t.onMessage.addListener(e=>{e.type===`close-side-panel`&&(o=!0,c(),window.close())}),d({state:e===`document-load`?`visible`:l(),reason:e})}catch(e){console.error(`[side-panel-state-tracker] Failed to connect:`,e),u()}}}t&&(a=()=>{d({state:l(),reason:`visibility-change`})},document.addEventListener(`visibilitychange`,a)),f(`document-load`)}),()=>{o=!0,c(),a&&document.removeEventListener(`visibilitychange`,a);let e=n;n=void 0,e?.disconnect(),s=!1}}const l=new Map,u=new Map,d=new Set;let f=!1;function p(){f||(f=!0,chrome.action.onClicked.addListener(e=>{let t=l.get(e.windowId);t&&t.state===`visible`?t.port&&(l.delete(e.windowId),t.port.postMessage({type:`close-side-panel`})):chrome.sidePanel.open({windowId:e.windowId}).catch(console.error)}),chrome.runtime.onConnect.addListener(e=>{e.name===chrome.runtime.id&&(e.onMessage.addListener(t=>{t.type===`side-panel-state-tracker`&&t.state&&g({port:e,state:t.state,reason:t.reason??`unknown`,windowId:t.windowId,type:t.type})}),e.onDisconnect.addListener(e=>{Array.from(l.entries()).forEach(([t,n])=>{n.port&&n.port===e&&g({port:void 0,state:`hidden`,reason:`port-disconnected`,windowId:t,type:`side-panel-state-tracker`})})}))}),chrome.windows.onRemoved.addListener(e=>{l.delete(e),u.delete(e)}))}function m(){f||=(p(),!0)}function h(e,t){if(e.type!==`side-panel-state-tracker`)return;let n={state:e.state,reason:e.reason,windowId:e.windowId,previousState:t},r=e.state===t;d.forEach(({listener:e,includeRepeats:t})=>{if(!(r&&!t))try{e(n)}catch(e){console.error(`Error in side panel state listener:`,e)}})}function g(e){let{windowId:t,state:n}=e,r=u.get(t);if(e.type===`side-panel-state-tracker`&&u.set(t,n),n===`hidden`){l.delete(t),h(e,r);return}l.set(t,e),h(e,r)}function _(e){return m(),l.get(e)?.state}function v(e){return m(),_(e)===`visible`}function y(e,t={}){m();let n={listener:e,includeRepeats:t.includeRepeats===!0};return d.add(n),()=>{d.delete(n)}}function b(e){return y(t=>{t.reason!==`reconnected`&&t.state===`visible`&&t.previousState!==`visible`&&e(t)})}function x(e){return y(t=>{t.reason!==`reconnected`&&t.state===`hidden`&&t.previousState===`visible`&&e(t)})}exports.getSidePanelStateForWindow=_,exports.initSidePanelStateManager=p,exports.initializeSidePanelStateTracker=c,exports.isWindowSidePanelVisible=v,exports.onSidePanelHidden=x,exports.onSidePanelShown=b,exports.onSidePanelStateChange=y,exports.onWindowClose=r;
1
+ const e=new Map,t=new Map,n=new Set;let r=!1;function i(){r||(r=!0,chrome.action.onClicked.addListener(t=>{let n=e.get(t.windowId);n&&n.state===`visible`?n.port&&(e.delete(t.windowId),n.port.postMessage({type:`close-side-panel`})):chrome.sidePanel.open({windowId:t.windowId}).catch(console.error)}),chrome.runtime.onConnect.addListener(t=>{t.name===chrome.runtime.id&&(t.onMessage.addListener(e=>{e.type===`side-panel-state-tracker`&&e.state&&s({port:t,state:e.state,reason:e.reason??`unknown`,windowId:e.windowId,type:e.type})}),t.onDisconnect.addListener(t=>{Array.from(e.entries()).forEach(([e,n])=>{n.port&&n.port===t&&s({port:void 0,state:`hidden`,reason:`port-disconnected`,windowId:e,type:`side-panel-state-tracker`})})}))}),chrome.windows.onRemoved.addListener(n=>{e.delete(n),t.delete(n)}))}function a(){r||=(i(),!0)}function o(e,t){if(e.type!==`side-panel-state-tracker`)return;let r={state:e.state,reason:e.reason,windowId:e.windowId,previousState:t},i=e.state===t;n.forEach(({listener:e,includeRepeats:t})=>{if(!(i&&!t))try{e(r)}catch(e){console.error(`Error in side panel state listener:`,e)}})}function s(n){let{windowId:r,state:i}=n,a=t.get(r);if(n.type===`side-panel-state-tracker`&&t.set(r,i),i===`hidden`){e.delete(r),o(n,a);return}e.set(r,n),o(n,a)}function c(t){return a(),e.get(t)?.state}function l(e){return a(),c(e)===`visible`}function u(e,t={}){a();let r={listener:e,includeRepeats:t.includeRepeats===!0};return n.add(r),()=>{n.delete(r)}}function d(e){return u(t=>{t.reason!==`reconnected`&&t.state===`visible`&&t.previousState!==`visible`&&e(t)})}function f(e){return u(t=>{t.reason!==`reconnected`&&t.state===`hidden`&&t.previousState===`visible`&&e(t)})}const p=500;function m(e){let{defaultView:t,debounceMs:n=500}=e,r=new Set,i=t,a,o,s=!1,c;function u(){c&&=(clearTimeout(c),void 0)}async function f(e){await Promise.all([...r].map(async t=>{try{await t(e)}catch(e){console.error(`[browser-change] A change handler failed`,e)}}))}async function p(e,t,n,r={}){if(!s)try{let s=await chrome.tabs.get(e);if(!s.active||n===`page`&&o===e&&a===t)return;let c=o??null;n===`page`&&(o=e,a=t),await f({reason:n,tabId:e,url:t,windowId:s.windowId,tab:s,view:i,previousTabId:c,isPanelReload:r.isPanelReload===!0})}catch(e){console.error(`[browser-change] Failed to read the tab that changed`,e)}}async function m(e,t={}){if(s)return;let r=await chrome.tabs.get(e);if(!r.active||!l(r.windowId))return;let{url:i}=r;if(i!=null){if(u(),t.immediate===!0){await p(e,i,`page`,t);return}c=setTimeout(()=>{c=void 0,p(e,i,`page`,t).catch(console.error)},n)}}function h(e,t={}){chrome.tabs.query({windowId:e,active:!0}).then(async e=>{let n=e[0];n?.id!=null&&await m(n.id,{...t,immediate:!0})}).catch(console.error)}function g(){chrome.tabs.query({active:!0,lastFocusedWindow:!0}).then(async e=>{let t=e[0];t?.id==null||t.url==null||l(t.windowId)&&await p(t.id,t.url,`view`)}).catch(console.error)}function _(e){m(e).catch(console.error)}function v(e){m(e.tabId).catch(()=>{})}chrome.tabs.onUpdated.addListener(_),chrome.tabs.onActivated.addListener(v);let y=d(({reason:e,windowId:n})=>{s||(e===`document-load`&&(i=t),a=void 0,o=void 0,h(n,{isPanelReload:e===`document-load`}))});return{onBrowserChange(e){return r.add(e),()=>{r.delete(e)}},setView(e){s||i===e||(i=e,g())},dispose(){s=!0,u(),chrome.tabs.onUpdated.removeListener(_),chrome.tabs.onActivated.removeListener(v),y(),r.clear()}}}function h(){let e=new Map;return{isDisplaying(t){if(t.isPanelReload)return!1;let n=e.get(t.windowId);return n?.tabId===t.tabId&&n.url===t.url},record(t){e.set(t.windowId,{tabId:t.tabId,url:t.url})},forget(t){e.delete(t)}}}const g=new Map;let _=!1;function v(){_||(_=!0,chrome.windows.onRemoved.addListener(e=>{let t=g.get(e);t&&(t.forEach(e=>{e()}),g.delete(e))}))}function y(e,t){v();let n=g.get(e);return n||(n=new Set,g.set(e,n)),n.add(t),()=>{let n=g.get(e);n&&(n.delete(t),n.size===0&&g.delete(e))}}const b=250,x=5e3,S=2;let C=!1;function w(e={}){if(C)return console.info(`Side panel state tracker already initialized`),()=>{};C=!0;let{trackDocumentVisibility:t=!0}=e,n,r,i=0,a,o=!1;function s(){r&&=(clearTimeout(r),void 0)}return chrome.windows.getCurrent(e=>{if(o)return;if(e.id==null){console.error(`[side-panel-state-tracker] Could not get window ID`);return}let c=e.id;function l(){return t&&document.hidden?`hidden`:`visible`}function u(){if(o||r)return;n=void 0,console.info(`[side-panel-state-tracker] Connection to background lost, scheduling reconnect...`);let e=Math.min(250*2**i,5e3);i+=1,r=setTimeout(()=>{r=void 0,f(`reconnected`)},e)}function d(e){let t=n;if(!t){u();return}try{t.postMessage({...e,windowId:c,type:`side-panel-state-tracker`,timestamp:Date.now()})}catch(e){console.error(`[side-panel-state-tracker] Failed to send message:`,e),u()}}function f(e){if(!o){s();try{let t=chrome.runtime.connect({name:chrome.runtime.id});n=t,i=0,t.onDisconnect.addListener(()=>{n===t&&(console.info(`[side-panel-state-tracker] Background connection lost.`),u())}),t.onMessage.addListener(e=>{e.type===`close-side-panel`&&(o=!0,s(),window.close())}),d({state:e===`document-load`?`visible`:l(),reason:e})}catch(e){console.error(`[side-panel-state-tracker] Failed to connect:`,e),u()}}}t&&(a=()=>{d({state:l(),reason:`visibility-change`})},document.addEventListener(`visibilitychange`,a)),f(`document-load`)}),()=>{o=!0,s(),a&&document.removeEventListener(`visibilitychange`,a);let e=n;n=void 0,e?.disconnect(),C=!1}}exports.createBrowserChangeWatcher=m,exports.createDisplayedPageTracker=h,exports.getSidePanelStateForWindow=c,exports.initSidePanelStateManager=i,exports.initializeSidePanelStateTracker=w,exports.isWindowSidePanelVisible=l,exports.onSidePanelHidden=f,exports.onSidePanelShown=d,exports.onSidePanelStateChange=u,exports.onWindowClose=y;
package/dist/index.d.cts CHANGED
@@ -1,34 +1,96 @@
1
- //#region src/on-window-close.d.ts
1
+ //#region src/types/browser-change.d.ts
2
2
  /**
3
- * Manages callbacks for when specific Chrome windows are closed.
4
- * Useful for triggering alerts, saving data, or cleaning up resources
5
- * when a specific popup or window is removed.
3
+ * What moved under the side panel.
4
+ *
5
+ * - `page` the active tab navigated, the user switched to another tab, or the
6
+ * panel came back on screen and needs to be told what it is looking at.
7
+ * - `view` — the user picked a different view inside the panel; the page is
8
+ * untouched.
6
9
  */
7
- type CloseCallback = () => void;
10
+ type BrowserChangeReason = 'page' | 'view';
8
11
  /**
9
- * Registers a callback to be executed when a specific window is closed.
10
- * @param windowId - The chrome window ID to watch
11
- * @param callback - Function to execute when the window closes
12
- * @returns A function to unsubscribe (remove) this specific listener
13
- * @example
14
- * const unsubscribe = onWindowClose(123, () => {
15
- * console.log("Window 123 was closed!");
16
- * });
12
+ * @typeParam TView - Whatever the extension uses to name the views inside its side
13
+ * panel, usually a union of string literals.
17
14
  */
18
- declare function onWindowClose(windowId: number, callback: CloseCallback): () => void;
19
- //#endregion
20
- //#region src/side-panel-state-tracker.d.ts
21
- interface InitializeSidePanelStateTrackerOptions {
15
+ interface BrowserChangeEvent<TView> {
16
+ reason: BrowserChangeReason;
17
+ /** The active tab the side panel is looking at. */
18
+ tabId: number;
19
+ /** Never empty — a tab with no URL is not announced. */
20
+ url: string;
21
+ windowId: number;
22
+ tab: chrome.tabs.Tab;
23
+ /** The view on screen, so a handler can ignore changes that are not its own. */
24
+ view: TView;
22
25
  /**
23
- * When true, visibilitychange events report document.hidden as hidden state.
24
- * Disable this if you only want open/closed port state.
26
+ * The tab this event moved away from, or `null` for the first event after the
27
+ * panel opens. Handlers that left something behind on a page — markers, injected
28
+ * UI — need it to clean up the tab they are leaving, not the one being arrived
29
+ * at.
25
30
  */
26
- trackDocumentVisibility?: boolean;
31
+ previousTabId: number | null;
32
+ /**
33
+ * True when this is the first event for a side panel document that has just
34
+ * loaded. The panel has rendered nothing yet, so a handler that skips work for a
35
+ * page it believes is already on screen has to treat this as a fresh page — what
36
+ * it drew last time went away with the previous document.
37
+ */
38
+ isPanelReload: boolean;
39
+ }
40
+ type BrowserChangeHandler<TView> = (event: BrowserChangeEvent<TView>) => Promise<void> | void;
41
+ interface BrowserChangeWatcherOptions<TView> {
42
+ /**
43
+ * The view a freshly loaded panel document starts on. A new document has none of
44
+ * the previous one's state, so the watcher resets to this rather than waiting for
45
+ * the panel's first report.
46
+ */
47
+ defaultView: TView;
48
+ /**
49
+ * How long to wait for a navigation to settle before announcing it.
50
+ * `chrome.tabs.onUpdated` fires repeatedly through a single navigation.
51
+ *
52
+ * @default 500
53
+ */
54
+ debounceMs?: number;
55
+ }
56
+ interface BrowserChangeWatcher<TView> {
57
+ /**
58
+ * Registers `handler` for every page or view change under the side panel.
59
+ * Returns a disposer.
60
+ */
61
+ onBrowserChange: (handler: BrowserChangeHandler<TView>) => () => void;
62
+ /**
63
+ * Tells the watcher which view the panel is showing. Call it from wherever the
64
+ * panel reports its view; repeats of the view already recorded are ignored, so it
65
+ * is safe to call on mount as well as on a change.
66
+ */
67
+ setView: (view: TView) => void;
68
+ /** Removes the watcher's browser listeners and drops every handler. */
69
+ dispose: () => void;
70
+ }
71
+ /** The page a window's side panel currently has on screen. */
72
+ interface DisplayedPage {
73
+ tabId: number;
74
+ url: string;
75
+ }
76
+ /** The part of a {@link BrowserChangeEvent} a {@link DisplayedPageTracker} reads. */
77
+ interface DisplayedPageInput {
78
+ windowId: number;
79
+ tabId: number;
80
+ url: string;
81
+ isPanelReload: boolean;
82
+ }
83
+ interface DisplayedPageTracker {
84
+ /**
85
+ * True when the panel in this window already has this exact page on screen, so
86
+ * there is nothing for the user to wait for.
87
+ */
88
+ isDisplaying: (event: DisplayedPageInput) => boolean;
89
+ /** Records the page the panel is now showing. */
90
+ record: (event: DisplayedPageInput) => void;
91
+ /** Drops what is remembered for a window, e.g. when the window closes. */
92
+ forget: (windowId: number) => void;
27
93
  }
28
- /**
29
- * Should only be called inside a Chrome extension side panel page.
30
- */
31
- declare function initializeSidePanelStateTracker(options?: InitializeSidePanelStateTrackerOptions): () => void;
32
94
  //#endregion
33
95
  //#region src/types/side-panel.d.ts
34
96
  type SidePanelState = 'visible' | 'hidden';
@@ -58,6 +120,94 @@ interface SidePanelClientMessage {
58
120
  type: 'close-side-panel';
59
121
  }
60
122
  //#endregion
123
+ //#region src/browser-change-watcher.d.ts
124
+ /**
125
+ * Watches what the side panel is looking at and announces every change.
126
+ *
127
+ * Nothing feature-specific lives here: the browser listeners, the debounce and the
128
+ * "have we already said this" bookkeeping are shared by every consumer, so they are
129
+ * done once and the result handed to whoever registered. Features register a
130
+ * handler instead of wiring their own tab listeners.
131
+ *
132
+ * The watcher does not know how the panel reports its view — call
133
+ * {@link BrowserChangeWatcher.setView} from wherever that arrives.
134
+ *
135
+ * @example
136
+ * ```typescript
137
+ * const { onBrowserChange, setView } = createBrowserChangeWatcher({
138
+ * defaultView: 'insights',
139
+ * });
140
+ *
141
+ * sidePanelTabChange.onMessage(setView);
142
+ * ```
143
+ */
144
+ declare function createBrowserChangeWatcher<TView>(options: BrowserChangeWatcherOptions<TView>): BrowserChangeWatcher<TView>;
145
+ //#endregion
146
+ //#region src/displayed-page-tracker.d.ts
147
+ /**
148
+ * Remembers what each window's side panel currently has on screen, so a handler can
149
+ * tell a real move apart from a page being announced again.
150
+ *
151
+ * The same page reaches a handler more than once — the panel comes back into view,
152
+ * the service worker is recycled, a feature re-reads its data — and putting a
153
+ * loading state up for a page already rendered replaces good content with a spinner,
154
+ * or worse, with an empty state.
155
+ *
156
+ * Keyed by window, not by tab, because a panel shows one page at a time: once it has
157
+ * moved to another tab, coming back is a real change even though that tab had been
158
+ * rendered before. A panel document that has just loaded has drawn nothing, so
159
+ * {@link DisplayedPageTracker.isDisplaying} is always false for it.
160
+ *
161
+ * @example
162
+ * ```typescript
163
+ * const displayedPage = createDisplayedPageTracker();
164
+ *
165
+ * onBrowserChange(async (event) => {
166
+ * const isAlreadyOnScreen = displayedPage.isDisplaying(event);
167
+ * displayedPage.record(event);
168
+ *
169
+ * if (!isAlreadyOnScreen) {
170
+ * await showLoader(event.windowId);
171
+ * }
172
+ *
173
+ * await render(await loadData(event.tabId), event.windowId);
174
+ * });
175
+ * ```
176
+ */
177
+ declare function createDisplayedPageTracker(): DisplayedPageTracker;
178
+ //#endregion
179
+ //#region src/on-window-close.d.ts
180
+ /**
181
+ * Manages callbacks for when specific Chrome windows are closed.
182
+ * Useful for triggering alerts, saving data, or cleaning up resources
183
+ * when a specific popup or window is removed.
184
+ */
185
+ type CloseCallback = () => void;
186
+ /**
187
+ * Registers a callback to be executed when a specific window is closed.
188
+ * @param windowId - The chrome window ID to watch
189
+ * @param callback - Function to execute when the window closes
190
+ * @returns A function to unsubscribe (remove) this specific listener
191
+ * @example
192
+ * const unsubscribe = onWindowClose(123, () => {
193
+ * console.log("Window 123 was closed!");
194
+ * });
195
+ */
196
+ declare function onWindowClose(windowId: number, callback: CloseCallback): () => void;
197
+ //#endregion
198
+ //#region src/side-panel-state-tracker.d.ts
199
+ interface InitializeSidePanelStateTrackerOptions {
200
+ /**
201
+ * When true, visibilitychange events report document.hidden as hidden state.
202
+ * Disable this if you only want open/closed port state.
203
+ */
204
+ trackDocumentVisibility?: boolean;
205
+ }
206
+ /**
207
+ * Should only be called inside a Chrome extension side panel page.
208
+ */
209
+ declare function initializeSidePanelStateTracker(options?: InitializeSidePanelStateTrackerOptions): () => void;
210
+ //#endregion
61
211
  //#region src/sidepanel-state-manager.d.ts
62
212
  type SidePanelStateListener = (data: SidePanelStateChangeData) => void;
63
213
  interface SidePanelStateChangeOptions {
@@ -134,4 +284,4 @@ declare function onSidePanelShown(listener: SidePanelStateListener): () => void;
134
284
  */
135
285
  declare function onSidePanelHidden(listener: SidePanelStateListener): () => void;
136
286
  //#endregion
137
- export { InitializeSidePanelStateTrackerOptions, SidePanelClientMessage, SidePanelState, SidePanelStateChangeData, SidePanelStateChangeOptions, SidePanelStateData, SidePanelStateListener, getSidePanelStateForWindow, initSidePanelStateManager, initializeSidePanelStateTracker, isWindowSidePanelVisible, onSidePanelHidden, onSidePanelShown, onSidePanelStateChange, onWindowClose };
287
+ export { BrowserChangeEvent, BrowserChangeHandler, BrowserChangeReason, BrowserChangeWatcher, BrowserChangeWatcherOptions, DisplayedPage, DisplayedPageInput, DisplayedPageTracker, InitializeSidePanelStateTrackerOptions, SidePanelClientMessage, SidePanelState, SidePanelStateChangeData, SidePanelStateChangeOptions, SidePanelStateData, SidePanelStateListener, createBrowserChangeWatcher, createDisplayedPageTracker, getSidePanelStateForWindow, initSidePanelStateManager, initializeSidePanelStateTracker, isWindowSidePanelVisible, onSidePanelHidden, onSidePanelShown, onSidePanelStateChange, onWindowClose };
package/dist/index.d.ts CHANGED
@@ -1,34 +1,96 @@
1
- //#region src/on-window-close.d.ts
1
+ //#region src/types/browser-change.d.ts
2
2
  /**
3
- * Manages callbacks for when specific Chrome windows are closed.
4
- * Useful for triggering alerts, saving data, or cleaning up resources
5
- * when a specific popup or window is removed.
3
+ * What moved under the side panel.
4
+ *
5
+ * - `page` the active tab navigated, the user switched to another tab, or the
6
+ * panel came back on screen and needs to be told what it is looking at.
7
+ * - `view` — the user picked a different view inside the panel; the page is
8
+ * untouched.
6
9
  */
7
- type CloseCallback = () => void;
10
+ type BrowserChangeReason = 'page' | 'view';
8
11
  /**
9
- * Registers a callback to be executed when a specific window is closed.
10
- * @param windowId - The chrome window ID to watch
11
- * @param callback - Function to execute when the window closes
12
- * @returns A function to unsubscribe (remove) this specific listener
13
- * @example
14
- * const unsubscribe = onWindowClose(123, () => {
15
- * console.log("Window 123 was closed!");
16
- * });
12
+ * @typeParam TView - Whatever the extension uses to name the views inside its side
13
+ * panel, usually a union of string literals.
17
14
  */
18
- declare function onWindowClose(windowId: number, callback: CloseCallback): () => void;
19
- //#endregion
20
- //#region src/side-panel-state-tracker.d.ts
21
- interface InitializeSidePanelStateTrackerOptions {
15
+ interface BrowserChangeEvent<TView> {
16
+ reason: BrowserChangeReason;
17
+ /** The active tab the side panel is looking at. */
18
+ tabId: number;
19
+ /** Never empty — a tab with no URL is not announced. */
20
+ url: string;
21
+ windowId: number;
22
+ tab: chrome.tabs.Tab;
23
+ /** The view on screen, so a handler can ignore changes that are not its own. */
24
+ view: TView;
22
25
  /**
23
- * When true, visibilitychange events report document.hidden as hidden state.
24
- * Disable this if you only want open/closed port state.
26
+ * The tab this event moved away from, or `null` for the first event after the
27
+ * panel opens. Handlers that left something behind on a page — markers, injected
28
+ * UI — need it to clean up the tab they are leaving, not the one being arrived
29
+ * at.
25
30
  */
26
- trackDocumentVisibility?: boolean;
31
+ previousTabId: number | null;
32
+ /**
33
+ * True when this is the first event for a side panel document that has just
34
+ * loaded. The panel has rendered nothing yet, so a handler that skips work for a
35
+ * page it believes is already on screen has to treat this as a fresh page — what
36
+ * it drew last time went away with the previous document.
37
+ */
38
+ isPanelReload: boolean;
39
+ }
40
+ type BrowserChangeHandler<TView> = (event: BrowserChangeEvent<TView>) => Promise<void> | void;
41
+ interface BrowserChangeWatcherOptions<TView> {
42
+ /**
43
+ * The view a freshly loaded panel document starts on. A new document has none of
44
+ * the previous one's state, so the watcher resets to this rather than waiting for
45
+ * the panel's first report.
46
+ */
47
+ defaultView: TView;
48
+ /**
49
+ * How long to wait for a navigation to settle before announcing it.
50
+ * `chrome.tabs.onUpdated` fires repeatedly through a single navigation.
51
+ *
52
+ * @default 500
53
+ */
54
+ debounceMs?: number;
55
+ }
56
+ interface BrowserChangeWatcher<TView> {
57
+ /**
58
+ * Registers `handler` for every page or view change under the side panel.
59
+ * Returns a disposer.
60
+ */
61
+ onBrowserChange: (handler: BrowserChangeHandler<TView>) => () => void;
62
+ /**
63
+ * Tells the watcher which view the panel is showing. Call it from wherever the
64
+ * panel reports its view; repeats of the view already recorded are ignored, so it
65
+ * is safe to call on mount as well as on a change.
66
+ */
67
+ setView: (view: TView) => void;
68
+ /** Removes the watcher's browser listeners and drops every handler. */
69
+ dispose: () => void;
70
+ }
71
+ /** The page a window's side panel currently has on screen. */
72
+ interface DisplayedPage {
73
+ tabId: number;
74
+ url: string;
75
+ }
76
+ /** The part of a {@link BrowserChangeEvent} a {@link DisplayedPageTracker} reads. */
77
+ interface DisplayedPageInput {
78
+ windowId: number;
79
+ tabId: number;
80
+ url: string;
81
+ isPanelReload: boolean;
82
+ }
83
+ interface DisplayedPageTracker {
84
+ /**
85
+ * True when the panel in this window already has this exact page on screen, so
86
+ * there is nothing for the user to wait for.
87
+ */
88
+ isDisplaying: (event: DisplayedPageInput) => boolean;
89
+ /** Records the page the panel is now showing. */
90
+ record: (event: DisplayedPageInput) => void;
91
+ /** Drops what is remembered for a window, e.g. when the window closes. */
92
+ forget: (windowId: number) => void;
27
93
  }
28
- /**
29
- * Should only be called inside a Chrome extension side panel page.
30
- */
31
- declare function initializeSidePanelStateTracker(options?: InitializeSidePanelStateTrackerOptions): () => void;
32
94
  //#endregion
33
95
  //#region src/types/side-panel.d.ts
34
96
  type SidePanelState = 'visible' | 'hidden';
@@ -58,6 +120,94 @@ interface SidePanelClientMessage {
58
120
  type: 'close-side-panel';
59
121
  }
60
122
  //#endregion
123
+ //#region src/browser-change-watcher.d.ts
124
+ /**
125
+ * Watches what the side panel is looking at and announces every change.
126
+ *
127
+ * Nothing feature-specific lives here: the browser listeners, the debounce and the
128
+ * "have we already said this" bookkeeping are shared by every consumer, so they are
129
+ * done once and the result handed to whoever registered. Features register a
130
+ * handler instead of wiring their own tab listeners.
131
+ *
132
+ * The watcher does not know how the panel reports its view — call
133
+ * {@link BrowserChangeWatcher.setView} from wherever that arrives.
134
+ *
135
+ * @example
136
+ * ```typescript
137
+ * const { onBrowserChange, setView } = createBrowserChangeWatcher({
138
+ * defaultView: 'insights',
139
+ * });
140
+ *
141
+ * sidePanelTabChange.onMessage(setView);
142
+ * ```
143
+ */
144
+ declare function createBrowserChangeWatcher<TView>(options: BrowserChangeWatcherOptions<TView>): BrowserChangeWatcher<TView>;
145
+ //#endregion
146
+ //#region src/displayed-page-tracker.d.ts
147
+ /**
148
+ * Remembers what each window's side panel currently has on screen, so a handler can
149
+ * tell a real move apart from a page being announced again.
150
+ *
151
+ * The same page reaches a handler more than once — the panel comes back into view,
152
+ * the service worker is recycled, a feature re-reads its data — and putting a
153
+ * loading state up for a page already rendered replaces good content with a spinner,
154
+ * or worse, with an empty state.
155
+ *
156
+ * Keyed by window, not by tab, because a panel shows one page at a time: once it has
157
+ * moved to another tab, coming back is a real change even though that tab had been
158
+ * rendered before. A panel document that has just loaded has drawn nothing, so
159
+ * {@link DisplayedPageTracker.isDisplaying} is always false for it.
160
+ *
161
+ * @example
162
+ * ```typescript
163
+ * const displayedPage = createDisplayedPageTracker();
164
+ *
165
+ * onBrowserChange(async (event) => {
166
+ * const isAlreadyOnScreen = displayedPage.isDisplaying(event);
167
+ * displayedPage.record(event);
168
+ *
169
+ * if (!isAlreadyOnScreen) {
170
+ * await showLoader(event.windowId);
171
+ * }
172
+ *
173
+ * await render(await loadData(event.tabId), event.windowId);
174
+ * });
175
+ * ```
176
+ */
177
+ declare function createDisplayedPageTracker(): DisplayedPageTracker;
178
+ //#endregion
179
+ //#region src/on-window-close.d.ts
180
+ /**
181
+ * Manages callbacks for when specific Chrome windows are closed.
182
+ * Useful for triggering alerts, saving data, or cleaning up resources
183
+ * when a specific popup or window is removed.
184
+ */
185
+ type CloseCallback = () => void;
186
+ /**
187
+ * Registers a callback to be executed when a specific window is closed.
188
+ * @param windowId - The chrome window ID to watch
189
+ * @param callback - Function to execute when the window closes
190
+ * @returns A function to unsubscribe (remove) this specific listener
191
+ * @example
192
+ * const unsubscribe = onWindowClose(123, () => {
193
+ * console.log("Window 123 was closed!");
194
+ * });
195
+ */
196
+ declare function onWindowClose(windowId: number, callback: CloseCallback): () => void;
197
+ //#endregion
198
+ //#region src/side-panel-state-tracker.d.ts
199
+ interface InitializeSidePanelStateTrackerOptions {
200
+ /**
201
+ * When true, visibilitychange events report document.hidden as hidden state.
202
+ * Disable this if you only want open/closed port state.
203
+ */
204
+ trackDocumentVisibility?: boolean;
205
+ }
206
+ /**
207
+ * Should only be called inside a Chrome extension side panel page.
208
+ */
209
+ declare function initializeSidePanelStateTracker(options?: InitializeSidePanelStateTrackerOptions): () => void;
210
+ //#endregion
61
211
  //#region src/sidepanel-state-manager.d.ts
62
212
  type SidePanelStateListener = (data: SidePanelStateChangeData) => void;
63
213
  interface SidePanelStateChangeOptions {
@@ -134,4 +284,4 @@ declare function onSidePanelShown(listener: SidePanelStateListener): () => void;
134
284
  */
135
285
  declare function onSidePanelHidden(listener: SidePanelStateListener): () => void;
136
286
  //#endregion
137
- export { InitializeSidePanelStateTrackerOptions, SidePanelClientMessage, SidePanelState, SidePanelStateChangeData, SidePanelStateChangeOptions, SidePanelStateData, SidePanelStateListener, getSidePanelStateForWindow, initSidePanelStateManager, initializeSidePanelStateTracker, isWindowSidePanelVisible, onSidePanelHidden, onSidePanelShown, onSidePanelStateChange, onWindowClose };
287
+ export { BrowserChangeEvent, BrowserChangeHandler, BrowserChangeReason, BrowserChangeWatcher, BrowserChangeWatcherOptions, DisplayedPage, DisplayedPageInput, DisplayedPageTracker, InitializeSidePanelStateTrackerOptions, SidePanelClientMessage, SidePanelState, SidePanelStateChangeData, SidePanelStateChangeOptions, SidePanelStateData, SidePanelStateListener, createBrowserChangeWatcher, createDisplayedPageTracker, getSidePanelStateForWindow, initSidePanelStateManager, initializeSidePanelStateTracker, isWindowSidePanelVisible, onSidePanelHidden, onSidePanelShown, onSidePanelStateChange, onWindowClose };
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- const e=new Map;let t=!1;function n(){t||(t=!0,chrome.windows.onRemoved.addListener(t=>{let n=e.get(t);n&&(n.forEach(e=>{e()}),e.delete(t))}))}function r(t,r){n();let i=e.get(t);return i||(i=new Set,e.set(t,i)),i.add(r),()=>{let n=e.get(t);n&&(n.delete(r),n.size===0&&e.delete(t))}}let i=!1;function a(e={}){if(i)return console.info(`Side panel state tracker already initialized`),()=>{};i=!0;let{trackDocumentVisibility:t=!0}=e,n,r,a=0,o,s=!1;function c(){r&&=(clearTimeout(r),void 0)}return chrome.windows.getCurrent(e=>{if(s)return;if(e.id==null){console.error(`[side-panel-state-tracker] Could not get window ID`);return}let i=e.id;function l(){return t&&document.hidden?`hidden`:`visible`}function u(){if(s||r)return;n=void 0,console.info(`[side-panel-state-tracker] Connection to background lost, scheduling reconnect...`);let e=Math.min(250*2**a,5e3);a+=1,r=setTimeout(()=>{r=void 0,f(`reconnected`)},e)}function d(e){let t=n;if(!t){u();return}try{t.postMessage({...e,windowId:i,type:`side-panel-state-tracker`,timestamp:Date.now()})}catch(e){console.error(`[side-panel-state-tracker] Failed to send message:`,e),u()}}function f(e){if(!s){c();try{let t=chrome.runtime.connect({name:chrome.runtime.id});n=t,a=0,t.onDisconnect.addListener(()=>{n===t&&(console.info(`[side-panel-state-tracker] Background connection lost.`),u())}),t.onMessage.addListener(e=>{e.type===`close-side-panel`&&(s=!0,c(),window.close())}),d({state:e===`document-load`?`visible`:l(),reason:e})}catch(e){console.error(`[side-panel-state-tracker] Failed to connect:`,e),u()}}}t&&(o=()=>{d({state:l(),reason:`visibility-change`})},document.addEventListener(`visibilitychange`,o)),f(`document-load`)}),()=>{s=!0,c(),o&&document.removeEventListener(`visibilitychange`,o);let e=n;n=void 0,e?.disconnect(),i=!1}}const o=new Map,s=new Map,c=new Set;let l=!1;function u(){l||(l=!0,chrome.action.onClicked.addListener(e=>{let t=o.get(e.windowId);t&&t.state===`visible`?t.port&&(o.delete(e.windowId),t.port.postMessage({type:`close-side-panel`})):chrome.sidePanel.open({windowId:e.windowId}).catch(console.error)}),chrome.runtime.onConnect.addListener(e=>{e.name===chrome.runtime.id&&(e.onMessage.addListener(t=>{t.type===`side-panel-state-tracker`&&t.state&&p({port:e,state:t.state,reason:t.reason??`unknown`,windowId:t.windowId,type:t.type})}),e.onDisconnect.addListener(e=>{Array.from(o.entries()).forEach(([t,n])=>{n.port&&n.port===e&&p({port:void 0,state:`hidden`,reason:`port-disconnected`,windowId:t,type:`side-panel-state-tracker`})})}))}),chrome.windows.onRemoved.addListener(e=>{o.delete(e),s.delete(e)}))}function d(){l||=(u(),!0)}function f(e,t){if(e.type!==`side-panel-state-tracker`)return;let n={state:e.state,reason:e.reason,windowId:e.windowId,previousState:t},r=e.state===t;c.forEach(({listener:e,includeRepeats:t})=>{if(!(r&&!t))try{e(n)}catch(e){console.error(`Error in side panel state listener:`,e)}})}function p(e){let{windowId:t,state:n}=e,r=s.get(t);if(e.type===`side-panel-state-tracker`&&s.set(t,n),n===`hidden`){o.delete(t),f(e,r);return}o.set(t,e),f(e,r)}function m(e){return d(),o.get(e)?.state}function h(e){return d(),m(e)===`visible`}function g(e,t={}){d();let n={listener:e,includeRepeats:t.includeRepeats===!0};return c.add(n),()=>{c.delete(n)}}function _(e){return g(t=>{t.reason!==`reconnected`&&t.state===`visible`&&t.previousState!==`visible`&&e(t)})}function v(e){return g(t=>{t.reason!==`reconnected`&&t.state===`hidden`&&t.previousState===`visible`&&e(t)})}export{m as getSidePanelStateForWindow,u as initSidePanelStateManager,a as initializeSidePanelStateTracker,h as isWindowSidePanelVisible,v as onSidePanelHidden,_ as onSidePanelShown,g as onSidePanelStateChange,r as onWindowClose};
1
+ const e=new Map,t=new Map,n=new Set;let r=!1;function i(){r||(r=!0,chrome.action.onClicked.addListener(t=>{let n=e.get(t.windowId);n&&n.state===`visible`?n.port&&(e.delete(t.windowId),n.port.postMessage({type:`close-side-panel`})):chrome.sidePanel.open({windowId:t.windowId}).catch(console.error)}),chrome.runtime.onConnect.addListener(t=>{t.name===chrome.runtime.id&&(t.onMessage.addListener(e=>{e.type===`side-panel-state-tracker`&&e.state&&s({port:t,state:e.state,reason:e.reason??`unknown`,windowId:e.windowId,type:e.type})}),t.onDisconnect.addListener(t=>{Array.from(e.entries()).forEach(([e,n])=>{n.port&&n.port===t&&s({port:void 0,state:`hidden`,reason:`port-disconnected`,windowId:e,type:`side-panel-state-tracker`})})}))}),chrome.windows.onRemoved.addListener(n=>{e.delete(n),t.delete(n)}))}function a(){r||=(i(),!0)}function o(e,t){if(e.type!==`side-panel-state-tracker`)return;let r={state:e.state,reason:e.reason,windowId:e.windowId,previousState:t},i=e.state===t;n.forEach(({listener:e,includeRepeats:t})=>{if(!(i&&!t))try{e(r)}catch(e){console.error(`Error in side panel state listener:`,e)}})}function s(n){let{windowId:r,state:i}=n,a=t.get(r);if(n.type===`side-panel-state-tracker`&&t.set(r,i),i===`hidden`){e.delete(r),o(n,a);return}e.set(r,n),o(n,a)}function c(t){return a(),e.get(t)?.state}function l(e){return a(),c(e)===`visible`}function u(e,t={}){a();let r={listener:e,includeRepeats:t.includeRepeats===!0};return n.add(r),()=>{n.delete(r)}}function d(e){return u(t=>{t.reason!==`reconnected`&&t.state===`visible`&&t.previousState!==`visible`&&e(t)})}function f(e){return u(t=>{t.reason!==`reconnected`&&t.state===`hidden`&&t.previousState===`visible`&&e(t)})}function p(e){let{defaultView:t,debounceMs:n=500}=e,r=new Set,i=t,a,o,s=!1,c;function u(){c&&=(clearTimeout(c),void 0)}async function f(e){await Promise.all([...r].map(async t=>{try{await t(e)}catch(e){console.error(`[browser-change] A change handler failed`,e)}}))}async function p(e,t,n,r={}){if(!s)try{let s=await chrome.tabs.get(e);if(!s.active||n===`page`&&o===e&&a===t)return;let c=o??null;n===`page`&&(o=e,a=t),await f({reason:n,tabId:e,url:t,windowId:s.windowId,tab:s,view:i,previousTabId:c,isPanelReload:r.isPanelReload===!0})}catch(e){console.error(`[browser-change] Failed to read the tab that changed`,e)}}async function m(e,t={}){if(s)return;let r=await chrome.tabs.get(e);if(!r.active||!l(r.windowId))return;let{url:i}=r;if(i!=null){if(u(),t.immediate===!0){await p(e,i,`page`,t);return}c=setTimeout(()=>{c=void 0,p(e,i,`page`,t).catch(console.error)},n)}}function h(e,t={}){chrome.tabs.query({windowId:e,active:!0}).then(async e=>{let n=e[0];n?.id!=null&&await m(n.id,{...t,immediate:!0})}).catch(console.error)}function g(){chrome.tabs.query({active:!0,lastFocusedWindow:!0}).then(async e=>{let t=e[0];t?.id==null||t.url==null||l(t.windowId)&&await p(t.id,t.url,`view`)}).catch(console.error)}function _(e){m(e).catch(console.error)}function v(e){m(e.tabId).catch(()=>{})}chrome.tabs.onUpdated.addListener(_),chrome.tabs.onActivated.addListener(v);let y=d(({reason:e,windowId:n})=>{s||(e===`document-load`&&(i=t),a=void 0,o=void 0,h(n,{isPanelReload:e===`document-load`}))});return{onBrowserChange(e){return r.add(e),()=>{r.delete(e)}},setView(e){s||i===e||(i=e,g())},dispose(){s=!0,u(),chrome.tabs.onUpdated.removeListener(_),chrome.tabs.onActivated.removeListener(v),y(),r.clear()}}}function m(){let e=new Map;return{isDisplaying(t){if(t.isPanelReload)return!1;let n=e.get(t.windowId);return n?.tabId===t.tabId&&n.url===t.url},record(t){e.set(t.windowId,{tabId:t.tabId,url:t.url})},forget(t){e.delete(t)}}}const h=new Map;let g=!1;function _(){g||(g=!0,chrome.windows.onRemoved.addListener(e=>{let t=h.get(e);t&&(t.forEach(e=>{e()}),h.delete(e))}))}function v(e,t){_();let n=h.get(e);return n||(n=new Set,h.set(e,n)),n.add(t),()=>{let n=h.get(e);n&&(n.delete(t),n.size===0&&h.delete(e))}}let y=!1;function b(e={}){if(y)return console.info(`Side panel state tracker already initialized`),()=>{};y=!0;let{trackDocumentVisibility:t=!0}=e,n,r,i=0,a,o=!1;function s(){r&&=(clearTimeout(r),void 0)}return chrome.windows.getCurrent(e=>{if(o)return;if(e.id==null){console.error(`[side-panel-state-tracker] Could not get window ID`);return}let c=e.id;function l(){return t&&document.hidden?`hidden`:`visible`}function u(){if(o||r)return;n=void 0,console.info(`[side-panel-state-tracker] Connection to background lost, scheduling reconnect...`);let e=Math.min(250*2**i,5e3);i+=1,r=setTimeout(()=>{r=void 0,f(`reconnected`)},e)}function d(e){let t=n;if(!t){u();return}try{t.postMessage({...e,windowId:c,type:`side-panel-state-tracker`,timestamp:Date.now()})}catch(e){console.error(`[side-panel-state-tracker] Failed to send message:`,e),u()}}function f(e){if(!o){s();try{let t=chrome.runtime.connect({name:chrome.runtime.id});n=t,i=0,t.onDisconnect.addListener(()=>{n===t&&(console.info(`[side-panel-state-tracker] Background connection lost.`),u())}),t.onMessage.addListener(e=>{e.type===`close-side-panel`&&(o=!0,s(),window.close())}),d({state:e===`document-load`?`visible`:l(),reason:e})}catch(e){console.error(`[side-panel-state-tracker] Failed to connect:`,e),u()}}}t&&(a=()=>{d({state:l(),reason:`visibility-change`})},document.addEventListener(`visibilitychange`,a)),f(`document-load`)}),()=>{o=!0,s(),a&&document.removeEventListener(`visibilitychange`,a);let e=n;n=void 0,e?.disconnect(),y=!1}}export{p as createBrowserChangeWatcher,m as createDisplayedPageTracker,c as getSidePanelStateForWindow,i as initSidePanelStateManager,b as initializeSidePanelStateTracker,l as isWindowSidePanelVisible,f as onSidePanelHidden,d as onSidePanelShown,u as onSidePanelStateChange,v as onWindowClose};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pixpilot/chrome-lifecycle",
3
3
  "type": "module",
4
- "version": "0.10.5",
4
+ "version": "0.11.0",
5
5
  "description": "Lifecycle management utilities for Chrome extensions.",
6
6
  "author": "m.doaie <m.doaie@hotmail.com>",
7
7
  "license": "MIT",
@@ -31,9 +31,9 @@
31
31
  "typescript": "^5.9.3",
32
32
  "@internal/eslint-config": "0.3.0",
33
33
  "@internal/prettier-config": "0.1.0",
34
+ "@internal/vitest-config": "0.1.0",
34
35
  "@internal/tsconfig": "0.1.0",
35
- "@internal/tsdown-config": "0.1.0",
36
- "@internal/vitest-config": "0.1.0"
36
+ "@internal/tsdown-config": "0.1.0"
37
37
  },
38
38
  "prettier": "@internal/prettier-config",
39
39
  "scripts": {