@uniweb/runtime 1.3.9 → 1.3.10
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/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/components/WebsiteRenderer.jsx +7 -11
- package/src/document-tracking.js +157 -9
- package/src/hooks/useDocumentEvents.js +64 -0
- package/src/wire-foundation.js +5 -5
- package/src/hooks/useOutboundClicks.js +0 -64
- package/src/hooks/useSectionClicks.js +0 -100
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.10",
|
|
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.
|
|
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.
|
|
47
|
+
"@uniweb/build": "1.14.0"
|
|
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 {
|
|
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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 (
|
package/src/document-tracking.js
CHANGED
|
@@ -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
|
|
8
|
-
* an emitter meeting the same condition belongs in this chunk, and
|
|
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
|
-
* ⛔ **
|
|
12
|
-
* A host may name one and not the
|
|
13
|
-
* folding them into one listener would arm
|
|
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
|
|
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
|
package/src/wire-foundation.js
CHANGED
|
@@ -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`
|
|
306
|
-
*
|
|
307
|
-
* `
|
|
308
|
-
*
|
|
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
|