@uniweb/runtime 1.3.9 → 1.3.11

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": "@uniweb/runtime",
3
- "version": "1.3.9",
3
+ "version": "1.3.11",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,7 +36,7 @@
36
36
  "node": ">=22.12"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "~1.6.0",
39
+ "@uniweb/core": "~1.6.1",
40
40
  "@uniweb/theming": "~1.2.1"
41
41
  },
42
42
  "devDependencies": {
@@ -44,7 +44,7 @@
44
44
  "esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0 || ^0.28.0",
45
45
  "vite": "^7.3.1",
46
46
  "vitest": "^4.1.7",
47
- "@uniweb/build": "1.13.11"
47
+ "@uniweb/build": "1.14.1"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -11,8 +11,7 @@ import ThemeProvider from './ThemeProvider.jsx'
11
11
  import { useRememberScroll } from '../hooks/useRememberScroll.js'
12
12
  import { useLinkInterceptor } from '../hooks/useLinkInterceptor.js'
13
13
  import { usePageView } from '../hooks/usePageView.js'
14
- import { useOutboundClicks } from '../hooks/useOutboundClicks.js'
15
- import { useSectionClicks } from '../hooks/useSectionClicks.js'
14
+ import { useDocumentEvents } from '../hooks/useDocumentEvents.js'
16
15
 
17
16
  /**
18
17
  * WebsiteRenderer component
@@ -37,15 +36,12 @@ export default function WebsiteRenderer() {
37
36
  // is disabled and this does nothing at all.
38
37
  usePageView()
39
38
 
40
- // Report where visitors go when they leave. Same unconditional-call contract
41
- // as usePageView, and armed once for the document rather than per route — the
42
- // listener is delegated on `document` and outlives SPA navigation.
43
- useOutboundClicks()
44
-
45
- // Which sections visitors interact with. Same contract and the same arming
46
- // condition, so it shares the chunk — but a separate `arms()` call, because a
47
- // host may consume one of the two and not the other.
48
- useSectionClicks()
39
+ // Report what a visitor does that the document's own listeners can see: where
40
+ // they go when they leave, which sections they interact with, what counts as a
41
+ // conversion. Same unconditional-call contract as usePageView, and armed once for
42
+ // the document rather than per route — the listeners are delegated on `document`
43
+ // and outlive SPA navigation.
44
+ useDocumentEvents()
49
45
 
50
46
  if (!website) {
51
47
  return (
@@ -4,13 +4,13 @@
4
4
  * ⭐ **The grouping rule, which is what this file is really for.** Everything
5
5
  * here needs exactly `tracking.arms(<event>)` with no further condition: a
6
6
  * destination exists, this is a live document, and neither the host nor the site
7
- * narrowed the event away. Today that is `outbound_click` and `section_click`;
8
- * an emitter meeting the same condition belongs in this chunk, and one meeting a
9
- * different condition does not.
7
+ * narrowed the event away. Today that is `outbound_click`, `section_click` and
8
+ * `conversion`; an emitter meeting the same condition belongs in this chunk, and
9
+ * one meeting a different condition does not.
10
10
  *
11
- * ⛔ **Two events, two independent arming calls, two listeners — deliberately.**
12
- * A host may name one and not the other, so they cannot share a registration:
13
- * folding them into one listener would arm both whenever either was selected.
11
+ * ⛔ **Three events, three independent arming calls, three listeners — deliberately.**
12
+ * A host may name one and not the others, so they cannot share a registration:
13
+ * folding them into one listener would arm all of them whenever one was selected.
14
14
  * The grouping is about WHEN THE CHUNK LOADS, never about sharing a callback.
15
15
  *
16
16
  * ⛔ **`section-views.js` stays separate** — its condition is narrower (a page
@@ -70,8 +70,7 @@
70
70
  *
71
71
  * ⛔ `mailto:` and `tel:` are deliberately absent. They are contact intents, not
72
72
  * navigations, and folding them in would put `mail` beside real destinations in
73
- * a chart about where traffic goes. They are worth their own event if anyone
74
- * asks for one; they are not worth corrupting this one.
73
+ * a chart about where traffic goes. They are `conversion` events instead, below.
75
74
  */
76
75
  const REPORTED_PROTOCOLS = new Set(['http:', 'https:'])
77
76
 
@@ -105,6 +104,16 @@ export function outboundHostname(href, here) {
105
104
  return url.hostname || null
106
105
  }
107
106
 
107
+ /**
108
+ * A right click, which fires `auxclick` as a middle click does, and opens a menu rather
109
+ * than the link: "copy link address" is not a visit. Measured 2026-11-01, Chromium and
110
+ * WebKit. ⛔ Until then `outbound_click` and `section_click` counted it.
111
+ *
112
+ * @param {{ button?: number }} event
113
+ * @returns {boolean}
114
+ */
115
+ const opensMenu = (event) => event.button > 1
116
+
108
117
  /**
109
118
  * Listen for clicks that leave the site and report the destination host.
110
119
  *
@@ -115,6 +124,7 @@ export function observeOutboundClicks(tracking) {
115
124
  if (typeof document === 'undefined' || !tracking?.track) return null
116
125
 
117
126
  const onClick = (event) => {
127
+ if (opensMenu(event)) return
118
128
  // `closest` rather than the target itself: the click almost always lands on
119
129
  // a child of the anchor — an icon, a span, the text node's element.
120
130
  const anchor = event.target?.closest?.('a[href]')
@@ -133,7 +143,8 @@ export function observeOutboundClicks(tracking) {
133
143
  document.addEventListener('click', onClick, true)
134
144
  // A middle click fires `auxclick`, not `click`. Opening a link in a new tab
135
145
  // is a real outbound visit, and omitting it would undercount silently and
136
- // unevenly — power users do it far more than average visitors.
146
+ // unevenly — power users do it far more than average visitors. A right click
147
+ // fires it too, which `opensMenu` leaves out.
137
148
  document.addEventListener('auxclick', onClick, true)
138
149
 
139
150
  return () => {
@@ -217,6 +228,7 @@ export function observeSectionClicks(getPage) {
217
228
  let cachedMap = null
218
229
 
219
230
  const onClick = (event) => {
231
+ if (opensMenu(event)) return
220
232
  const page = getPage()
221
233
  if (!page) return
222
234
  if (page !== cachedPage) {
@@ -253,3 +265,139 @@ export function observeSectionClicks(getPage) {
253
265
  document.removeEventListener('auxclick', onClick, true)
254
266
  }
255
267
  }
268
+
269
+ /* ------------------------------------------------------------------ *
270
+ * conversion — what the site counts, with no foundation code.
271
+ *
272
+ * Only the name crosses the wire. A phone number, an email address, a link and a
273
+ * page all stay here, so *"the path and query string never leave the page"* holds
274
+ * for this event as it does for `outbound_click`.
275
+ *
276
+ * ⭐ **Two sources, and the second needs someone to say what counts.** A `tel:` or
277
+ * `mailto:` tap is one by itself. A link or a page counts only when the site lists
278
+ * it (`services.tracking.conversions`), because nothing here can tell that this
279
+ * link is the sign-up and that one is the sign-in.
280
+ *
281
+ * ⚖️ **A listed conversion arms itself, and it still cannot escape the host.** A
282
+ * site with its own endpoint on `emit: standard` gets no automatic conversions, as
283
+ * that preset promises, and writing a list is that site asking for those. `arms`'
284
+ * override replaces the site's answer and never the host's.
285
+ *
286
+ * ⛔ **Once per name per document load.** A double tap is one contact, and so is a
287
+ * visitor coming back to a thank-you page within a visit. A reload is a new document
288
+ * and counts again, as it does for every event here.
289
+ *
290
+ * ⚖️ **`section_click` double-counts with this too, deliberately,** for the reason
291
+ * it does with `outbound_click`: a phone link in a section is both.
292
+ * ------------------------------------------------------------------ */
293
+
294
+ /** The name a tap on these reports. */
295
+ const CONTACT_NAMES = { 'tel:': 'phone', 'mailto:': 'email' }
296
+
297
+ const withoutTrailingSlash = (path) => path.replace(/\/+$/, '')
298
+
299
+ /** The same address, whatever query or fragment either carries. */
300
+ function sameLink(a, b) {
301
+ return a.origin === b.origin && withoutTrailingSlash(a.pathname) === withoutTrailingSlash(b.pathname)
302
+ }
303
+
304
+ /**
305
+ * Listen for what the site counts as a conversion, and report its name.
306
+ *
307
+ * @param {{ arms: Function, track: Function, currentPath?: string|null, onPage?: Function|null }} tracking
308
+ * @param {() => object} getWebsite - reads the active Website: its list, and the rule
309
+ * that makes a page's route the same in every language
310
+ * @returns {(() => void) | null} teardown, or null when there is nothing to report
311
+ */
312
+ export function observeConversions(tracking, getWebsite) {
313
+ if (typeof document === 'undefined') return null
314
+
315
+ const declared = getWebsite()?.config?.tracking?.conversions
316
+ const list = Array.isArray(declared) ? declared : []
317
+ const automatic = tracking.arms('conversion')
318
+ const listed = list.length > 0 && tracking.arms('conversion', true)
319
+ if (!automatic && !listed) return null
320
+
321
+ const seen = new Set()
322
+ const once = (name) => {
323
+ if (seen.has(name)) return
324
+ seen.add(name)
325
+ tracking.track('conversion', { name })
326
+ }
327
+
328
+ const links = []
329
+ const pages = []
330
+ if (listed) {
331
+ for (const { name, link, page } of list) {
332
+ if (page) pages.push({ name, page })
333
+ else if (link) {
334
+ try {
335
+ links.push({ name, url: new URL(link) })
336
+ } catch {
337
+ // The build refuses one of these; a host's copy may still hold it.
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ // `normalizeRoute` takes the locale off and translates a localized route back, so
344
+ // `/thanks` is also `/es/gracias`. A fresh read each time: the language can change.
345
+ const routeOf = (path) => getWebsite()?.normalizeRoute?.(path) ?? path
346
+ const atPage = (path) => {
347
+ if (!path) return
348
+ for (const { name, page } of pages) if (routeOf(page) === routeOf(path)) once(name)
349
+ }
350
+
351
+ const onClick = (event) => {
352
+ if (opensMenu(event)) return
353
+ const href = event.target?.closest?.('a[href]')?.getAttribute('href')
354
+ if (!href) return
355
+ let url
356
+ try {
357
+ url = new URL(href, document.location.href)
358
+ } catch {
359
+ return
360
+ }
361
+ // The site's own name first, so it can tell one phone number from another.
362
+ for (const { name, url: listedUrl } of links) if (sameLink(listedUrl, url)) return once(name)
363
+ if (automatic && CONTACT_NAMES[url.protocol]) once(CONTACT_NAMES[url.protocol])
364
+ }
365
+
366
+ // ⛔ CAPTURE, and `auxclick` alongside `click` — same two reasons as `outbound_click`.
367
+ document.addEventListener('click', onClick, true)
368
+ document.addEventListener('auxclick', onClick, true)
369
+
370
+ if (pages.length) {
371
+ // This chunk arrives after the first page view, which may be the page that counts.
372
+ atPage(tracking.currentPath)
373
+ tracking.onPage = atPage
374
+ }
375
+
376
+ return () => {
377
+ document.removeEventListener('click', onClick, true)
378
+ document.removeEventListener('auxclick', onClick, true)
379
+ if (tracking.onPage === atPage) tracking.onPage = null
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Arm each event this chunk holds, by its own `arms()` question.
385
+ *
386
+ * ⭐ Everything reads the website at the moment it needs it, through the current
387
+ * singleton: one listener spans every navigation, and capturing a page here would
388
+ * attribute every later click to the page the visitor first landed on.
389
+ *
390
+ * @param {object} tracking - the document's Tracker
391
+ * @returns {() => void} teardown
392
+ */
393
+ export function observeDocument(tracking) {
394
+ const website = () => globalThis.uniweb?.activeWebsite
395
+ const stops = [
396
+ tracking.arms('outbound_click') && observeOutboundClicks(tracking),
397
+ tracking.arms('section_click') && observeSectionClicks(() => website()?.activePage),
398
+ observeConversions(tracking, website)
399
+ ]
400
+ return () => {
401
+ for (const stop of stops) if (stop) stop()
402
+ }
403
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * useDocumentEvents — arm what a tracked document reports from its own listeners:
3
+ * where a visitor goes when they leave, which sections they interact with, and what
4
+ * the site counts as a conversion. Each is `document-tracking.js`'s to arm.
5
+ *
6
+ * ⭐ **This file is the only part always loaded.** The listeners live behind a dynamic
7
+ * import, so a site with no tracking destination downloads none of them. It is one hook
8
+ * rather than one per event because every site carries it: two copies of this effect
9
+ * cost every site more than a third event's whole listener costs a tracked one.
10
+ *
11
+ * ⚖️ **The condition is `isEnabled()`, not each event's `arms()`,** which is that same
12
+ * trade: a site narrowed to `emit: minimal` downloads the chunk and arms nothing in it,
13
+ * and no site pays to ask the question three times here.
14
+ *
15
+ * ## Armed ONCE for the document, not per page
16
+ *
17
+ * ⭐ **This is the difference from `useSectionViews` and it decides where the hook is
18
+ * mounted.** Sections are elements, so their observer must re-arm when the page's
19
+ * elements change. These are delegated listeners on `document`, which outlive every SPA
20
+ * navigation — re-arming per route would tear down and rebuild the same listeners for no
21
+ * reason, and a click landing mid-swap would be lost. ⇒ It belongs in `WebsiteRenderer`
22
+ * beside `usePageView`, keyed on nothing.
23
+ *
24
+ * ## SSR
25
+ *
26
+ * There is no SSR twin, and that IS the suppression — the same as `usePageView`. Effects
27
+ * do not run under `renderToString` and a Worker isolate has no `document`. Nothing to
28
+ * remember to switch off.
29
+ */
30
+
31
+ import { useEffect } from 'react'
32
+
33
+ /**
34
+ * Safe to call unconditionally: it decides *when*, never *whether*.
35
+ */
36
+ export function useDocumentEvents() {
37
+ useEffect(() => {
38
+ const tracking = globalThis.uniweb?.tracking
39
+ // Somewhere to send, in a live document — so a framed authoring preview arms nothing.
40
+ if (!tracking?.isEnabled?.()) return
41
+
42
+ // `cancelled` guards the gap between asking for the module and getting it —
43
+ // a fast unmount would otherwise install listeners nothing ever removes.
44
+ let cancelled = false
45
+ let stop = null
46
+
47
+ import('../document-tracking.js')
48
+ .then((m) => {
49
+ if (cancelled) return
50
+ stop = m.observeDocument(tracking)
51
+ })
52
+ .catch(() => {
53
+ // Telemetry must never surface to a visitor. A chunk that fails to load
54
+ // costs some counts; nothing else about the page is affected.
55
+ })
56
+
57
+ return () => {
58
+ cancelled = true
59
+ if (stop) stop()
60
+ }
61
+ }, [])
62
+ }
63
+
64
+ export default useDocumentEvents
@@ -179,7 +179,7 @@ export const ISOLATE_API = Object.freeze({
179
179
  * the publish that creates the version, never in anticipation — the whole
180
180
  * reason `UNRELEASED` exists a few lines up.
181
181
  * 2. **A raise is a CROSS-LANE event.** Backend reads this from the channel index
182
- * and refuses to serve a site below it, so the number moves for them when the
182
+ * and publishes no site below it, so the number moves for them when the
183
183
  * CHANNEL republishes — not when this constant changes.
184
184
  *
185
185
  * ⭐ **Revisited at 1.0 [Diego, 2026-10-11].** This note read: *"Revisit at 1.0: once
@@ -190,7 +190,13 @@ export const ISOLATE_API = Object.freeze({
190
190
  * using 1.0.1+"*. The two mechanical rules above stand.
191
191
  */
192
192
  /*
193
- * ⭐ **1.0.1, raised 2026-10-13 [Diego]** — the first 1.x runtime, a BASELINE raise like the two
193
+ * ⭐ **1.2.3, raised 2026-11-02** — an incompatibility, and a host's request: its render now relies on
194
+ * `render(target, { fetchedData })` and `Website#makeCurrent`, which first shipped in 1.2.3 with core
195
+ * 1.4.7. A runtime below it ignores the option without a word, so a page would render against whatever
196
+ * the graph kept from the last request. Neither is an export, so the map below cannot say it. 1.2.3 is
197
+ * on npm and in the channel, and, like every raise, this reaches backend at the next runtime publish.
198
+ *
199
+ * ⛔ **Previously 1.0.1, raised 2026-10-13 [Diego]** — the first 1.x runtime, a BASELINE raise like the two
194
200
  * below: *"We would also set `minUsable` to tell backend to start using 1.0.1+"* (2026-10-11).
195
201
  * 1.0.1 is the release that took every package from 0.x to 1.x; the runtime's line since 0.29.7
196
202
  * carries `carryQuery`. Raised once npm served 1.0.1 and the channel's `latest` was 1.0.1, and,
@@ -229,7 +235,7 @@ export const ISOLATE_API = Object.freeze({
229
235
  * which the service refuses with a whole-request `400` since it took `narrow`. It also carries
230
236
  * declared-key delivery and `$route`. That break is still real and still below this number.
231
237
  */
232
- export const WIRE_FLOOR = '1.0.1'
238
+ export const WIRE_FLOOR = '1.2.3'
233
239
 
234
240
  /**
235
241
  * ⭐ **THE MINIMUM RUNTIME VERSION A SITE MAY BE PUBLISHED AT.** At or above it,
@@ -302,11 +302,11 @@ export function ensureThemeCss(uniweb, foundation) {
302
302
  * site republishing. A literal list would freeze `all` at the moment the site
303
303
  * was built and quietly stop meaning "all".
304
304
  *
305
- * ⚖️ **`standard` and `all` select the same events today, and that is not a
306
- * reason to drop one.** They diverge the moment a new automatic event ships:
307
- * `standard` is a curated set that a release cannot grow behind an operator's
308
- * back, `all` is the standing yes. The volume surprise is the thing being
309
- * avoided — a site that never changed should not start sending more.
305
+ * ⚖️ **`standard` and `all` differ, and a new automatic event only ever widens
306
+ * the gap.** `standard` is a curated set that a release cannot grow behind an
307
+ * operator's back, `all` is the standing yes: `section_click`, `time_on_page` and
308
+ * `conversion` are `all`'s and not `standard`'s. The volume surprise is the thing
309
+ * being avoided — a site that never changed should not start sending more.
310
310
  *
311
311
  * ⛔ **The curated set is the answer for a site that CONFIGURED ITS OWN
312
312
  * DESTINATION. It is NOT the answer for a site whose host supplies one** — see
@@ -1,64 +0,0 @@
1
- /**
2
- * useOutboundClicks — arm outbound-link reporting for the document.
3
- *
4
- * ⭐ **This file is the only part always loaded.** The listener lives behind a
5
- * dynamic import, so a site with no tracking destination downloads none of it —
6
- * the same arrangement as `useSectionViews`.
7
- *
8
- * ⛔ **One call, three questions.** `tracking.arms('outbound_click')` asks all of
9
- * them: is there somewhere to send in a live document (so a framed authoring
10
- * preview arms nothing), will the host consume this row, and did the site
11
- * select it. Unlike `useSectionViews` there is no per-page override to pass — see the section header
12
- * in `document-tracking.js` for why a page-level opt-in would cost a delivery
13
- * projection and buy nothing.
14
- *
15
- * ## Armed ONCE for the document, not per page
16
- *
17
- * ⭐ **This is the difference from `useSectionViews` and it decides where the
18
- * hook is mounted.** Sections are elements, so their observer must re-arm when
19
- * the page's elements change. This is a single delegated listener on
20
- * `document`, which outlives every SPA navigation — re-arming it per route
21
- * would tear down and rebuild the same listener for no reason, and a click
22
- * landing mid-swap would be lost. ⇒ It belongs in `WebsiteRenderer` beside
23
- * `usePageView`, keyed on nothing.
24
- *
25
- * ## SSR
26
- *
27
- * There is no SSR twin, and that IS the suppression — the same as
28
- * `usePageView`. Effects do not run under `renderToString` and a Worker isolate
29
- * has no `document`. Nothing to remember to switch off.
30
- */
31
-
32
- import { useEffect } from 'react'
33
-
34
- /**
35
- * Safe to call unconditionally: it decides *when*, never *whether*.
36
- */
37
- export function useOutboundClicks() {
38
- useEffect(() => {
39
- const tracking = globalThis.uniweb?.tracking
40
- if (!tracking?.arms?.('outbound_click')) return
41
-
42
- // `cancelled` guards the gap between asking for the module and getting it —
43
- // a fast unmount would otherwise install a listener nothing ever removes.
44
- let cancelled = false
45
- let stop = null
46
-
47
- import('../document-tracking.js')
48
- .then((m) => {
49
- if (cancelled) return
50
- stop = m.observeOutboundClicks(tracking)
51
- })
52
- .catch(() => {
53
- // Telemetry must never surface to a visitor. A chunk that fails to load
54
- // costs some counts; nothing else about the page is affected.
55
- })
56
-
57
- return () => {
58
- cancelled = true
59
- if (stop) stop()
60
- }
61
- }, [])
62
- }
63
-
64
- export default useOutboundClicks
@@ -1,100 +0,0 @@
1
- /**
2
- * useSectionClicks — arm section-click reporting for the document.
3
- *
4
- * ⭐ **This file is the only part always loaded.** The listener lives behind a
5
- * dynamic import into `document-tracking.js` — the same chunk `outbound_click`
6
- * uses, because the two arm on the same condition — so a site with no tracking
7
- * destination downloads none of it.
8
- *
9
- * ⛔ **One call, three questions.** `tracking.arms('section_click')` asks all of
10
- * them: is there somewhere to send in a live document (so a framed authoring
11
- * preview arms nothing), will the host consume this row, and did the site select
12
- * it.
13
- *
14
- * ## ⛔ THIS IS NOT IN THE `standard` PRESET, and that is deliberate
15
- *
16
- * ⭐ **`section_click` is the FIRST event where `standard` and `all` diverge** —
17
- * the divergence `wire-foundation.js`'s `EMIT_PRESETS` was written in
18
- * anticipation of. `standard` is a **curated** set that a framework release must
19
- * not grow behind an operator's back; `all` is the standing yes. So:
20
- *
21
- * ```
22
- * emit: (absent) / standard -> does NOT arm <- the DEFAULT
23
- * emit: all -> arms
24
- * emit: [ ..., section_click] -> arms
25
- * ```
26
- *
27
- * ⛔ **Do NOT "fix" this by adding the name to `standard`.** That would make a
28
- * site which changed nothing start sending more, which is the exact surprise the
29
- * two presets exist to prevent — and it is a promise the docs already make to
30
- * operators.
31
- *
32
- * ⚠️ **The consequence a consumer must be told, because nothing surfaces it:** a
33
- * host may declare this event and a collector may store it, and **a default site
34
- * still emits none of it**. Every gate reads yes and the series is flat. That is
35
- * correct behaviour, not a fault, but it is indistinguishable from a broken
36
- * emitter unless someone says so first.
37
- *
38
- * ⛔ **NO `trackSections` override is passed, and that is deliberate.** That
39
- * flag exists because `section_view` emits **once per section per page view**,
40
- * so its cost scales with page length and had to be capped where the cost
41
- * varies. A click is bounded by what a visitor does, so the same control would
42
- * buy nothing here — and it would cost a delivery projection to carry.
43
- *
44
- * ⚠️ **Consequence worth knowing when reading the numbers:** on a page with
45
- * `trackSections: false` this still reports clicks, so the click population is
46
- * strictly larger than the view population and **a click can exist with no view
47
- * row to divide by.** A per-section CTR is therefore only sound on sections
48
- * present in both. *(Named here because it is the emitter's doing, not the
49
- * collector's.)*
50
- *
51
- * ## Armed ONCE for the document, not per page
52
- *
53
- * ⭐ Same as `useOutboundClicks`, and for the same reason: this is a single
54
- * delegated listener on `document`, which outlives every SPA navigation.
55
- * Re-arming per route would tear down and rebuild the same listener for no
56
- * reason, and a click landing mid-swap would be lost. The *page* it attributes
57
- * to is read at click time instead — see `observeSectionClicks`.
58
- *
59
- * ## SSR
60
- *
61
- * There is no SSR twin, and that IS the suppression — effects do not run under
62
- * `renderToString` and a Worker isolate has no `document`.
63
- */
64
-
65
- import { useEffect } from 'react'
66
-
67
- /**
68
- * Safe to call unconditionally: it decides *when*, never *whether*.
69
- */
70
- export function useSectionClicks() {
71
- useEffect(() => {
72
- const tracking = globalThis.uniweb?.tracking
73
- if (!tracking?.arms?.('section_click')) return
74
-
75
- // `cancelled` guards the gap between asking for the module and getting it —
76
- // a fast unmount would otherwise install a listener nothing ever removes.
77
- let cancelled = false
78
- let stop = null
79
-
80
- import('../document-tracking.js')
81
- .then((m) => {
82
- if (cancelled) return
83
- // Read the active page at CLICK time, not now: one listener spans every
84
- // navigation, and capturing the page here would attribute every later
85
- // click to whichever page the visitor first landed on.
86
- stop = m.observeSectionClicks(() => globalThis.uniweb?.activeWebsite?.activePage)
87
- })
88
- .catch(() => {
89
- // Telemetry must never surface to a visitor. A chunk that fails to load
90
- // costs some counts; nothing else about the page is affected.
91
- })
92
-
93
- return () => {
94
- cancelled = true
95
- if (stop) stop()
96
- }
97
- }, [])
98
- }
99
-
100
- export default useSectionClicks