@uniweb/runtime 0.12.2 → 0.12.3
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": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
4
4
|
"description": "Minimal runtime for loading Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0",
|
|
45
45
|
"vite": "^7.3.1",
|
|
46
46
|
"vitest": "^4.1.7",
|
|
47
|
-
"@uniweb/build": "0.24.
|
|
47
|
+
"@uniweb/build": "0.24.5"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
|
@@ -11,6 +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'
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* WebsiteRenderer component
|
|
@@ -35,6 +36,11 @@ export default function WebsiteRenderer() {
|
|
|
35
36
|
// is disabled and this does nothing at all.
|
|
36
37
|
usePageView()
|
|
37
38
|
|
|
39
|
+
// Report where visitors go when they leave. Same unconditional-call contract
|
|
40
|
+
// as usePageView, and armed once for the document rather than per route — the
|
|
41
|
+
// listener is delegated on `document` and outlives SPA navigation.
|
|
42
|
+
useOutboundClicks()
|
|
43
|
+
|
|
38
44
|
if (!website) {
|
|
39
45
|
return (
|
|
40
46
|
<div className="website-loading" style={{ padding: '2rem', textAlign: 'center', color: '#64748b' }}>
|
|
@@ -0,0 +1,64 @@
|
|
|
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 gate, not two.** `tracking.isEnabled()` is the whole condition: it
|
|
9
|
+
* says there is somewhere to send *and* that this is a live document, so a
|
|
10
|
+
* framed authoring preview arms nothing (`Tracker.isLiveDocument`). Unlike
|
|
11
|
+
* `useSectionViews` there is no per-page flag to check — see the module header
|
|
12
|
+
* of `outbound-clicks.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?.isEnabled?.()) 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('../outbound-clicks.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
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outbound_click — where a visitor goes when they leave.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ **Reached behind a dynamic import** (`hooks/useOutboundClicks.js`), so a
|
|
5
|
+
* site with no tracking destination never downloads this file. Same arrangement
|
|
6
|
+
* as `section-views.js` and `script-loader.js`.
|
|
7
|
+
*
|
|
8
|
+
* ## ⛔ The HOSTNAME is the event. The URL never leaves the page.
|
|
9
|
+
*
|
|
10
|
+
* A full outbound URL can carry a query string — a search term, a token, a
|
|
11
|
+
* referral id someone pasted — that the site owner has no business collecting
|
|
12
|
+
* merely because a visitor clicked a link. **So the truncation happens HERE, at
|
|
13
|
+
* the emitter, not at the store.** A constraint applied at write time is a
|
|
14
|
+
* promise about someone else's code; applied at emit time the sensitive part
|
|
15
|
+
* never exists as data at all.
|
|
16
|
+
*
|
|
17
|
+
* ⚖️ **This is the one place the constraint can be enforced rather than
|
|
18
|
+
* agreed.** If it lived only in the collector, two producers would hold one
|
|
19
|
+
* rule and could disagree silently — the failure being *a full URL quietly
|
|
20
|
+
* stored*, which nothing surfaces as an error.
|
|
21
|
+
*
|
|
22
|
+
* ## No page-level opt-in, deliberately
|
|
23
|
+
*
|
|
24
|
+
* `section_view` needs one (`trackSections`) because its dimension is unbounded
|
|
25
|
+
* — a site's section types run to the hundreds and hosting capped the cardinality.
|
|
26
|
+
* **An outbound hostname is bounded by how many external sites a page links to**,
|
|
27
|
+
* which is small and does not grow with the site. So the only gate is
|
|
28
|
+
* `tracking.isEnabled()`: declaring a destination is already the operator's
|
|
29
|
+
* decision to measure.
|
|
30
|
+
*
|
|
31
|
+
* ⛔ **And a page flag would not be free — it would need a DELIVERY PROJECTION**
|
|
32
|
+
* (the stored flag → the runtime payload), which is a distinct item owned by a
|
|
33
|
+
* different lane and invisible from this one. That gap shipped once already and
|
|
34
|
+
* cost a day of a working emitter reporting nothing. **Not worth paying for a
|
|
35
|
+
* dimension that was never at risk.**
|
|
36
|
+
*
|
|
37
|
+
* @module @uniweb/runtime/outbound-clicks
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Protocols worth reporting as *traffic leaving the site*.
|
|
42
|
+
*
|
|
43
|
+
* ⛔ `mailto:` and `tel:` are deliberately absent. They are contact intents, not
|
|
44
|
+
* navigations, and folding them in would put `mail` beside real destinations in
|
|
45
|
+
* a chart about where traffic goes. They are worth their own event if anyone
|
|
46
|
+
* asks for one; they are not worth corrupting this one.
|
|
47
|
+
*/
|
|
48
|
+
const REPORTED_PROTOCOLS = new Set(['http:', 'https:'])
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The hostname this click leaves for, or `null` if it does not leave.
|
|
52
|
+
*
|
|
53
|
+
* Exported for the tests: the whole privacy claim of this module is that only a
|
|
54
|
+
* hostname is ever produced, and that is worth asserting directly rather than
|
|
55
|
+
* through a listener.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} href - the link's raw `href`, possibly relative
|
|
58
|
+
* @param {Location|URL} here - the document's own location
|
|
59
|
+
* @returns {string|null}
|
|
60
|
+
*/
|
|
61
|
+
export function outboundHostname(href, here) {
|
|
62
|
+
if (!href) return null
|
|
63
|
+
let url
|
|
64
|
+
try {
|
|
65
|
+
// Resolving against the document is what makes a relative href resolve to
|
|
66
|
+
// OUR host and therefore drop out below. A bare `new URL(href)` would throw
|
|
67
|
+
// on every internal link and turn the common case into the error path.
|
|
68
|
+
url = new URL(href, here.href)
|
|
69
|
+
} catch {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
if (!REPORTED_PROTOCOLS.has(url.protocol)) return null
|
|
73
|
+
if (url.hostname === here.hostname) return null
|
|
74
|
+
// `url.hostname` — never `url.host` (which appends a port), never `url.href`,
|
|
75
|
+
// and nothing derived from `search` or `pathname`. This return is the
|
|
76
|
+
// enforcement point named in the module header.
|
|
77
|
+
return url.hostname || null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Listen for clicks that leave the site and report the destination host.
|
|
82
|
+
*
|
|
83
|
+
* @param {{track: (event: string, data?: object) => void}} tracking
|
|
84
|
+
* @returns {(() => void) | null} teardown, or null when there is no DOM
|
|
85
|
+
*/
|
|
86
|
+
export function observeOutboundClicks(tracking) {
|
|
87
|
+
if (typeof document === 'undefined' || !tracking?.track) return null
|
|
88
|
+
|
|
89
|
+
const onClick = (event) => {
|
|
90
|
+
// `closest` rather than the target itself: the click almost always lands on
|
|
91
|
+
// a child of the anchor — an icon, a span, the text node's element.
|
|
92
|
+
const anchor = event.target?.closest?.('a[href]')
|
|
93
|
+
if (!anchor) return
|
|
94
|
+
const hostname = outboundHostname(anchor.getAttribute('href'), document.location)
|
|
95
|
+
if (!hostname) return
|
|
96
|
+
// Nothing is prevented and nothing is awaited. The navigation proceeds at
|
|
97
|
+
// full speed; the queued event survives it because the tracker beacons on
|
|
98
|
+
// `pagehide` and on `visibilitychange` to hidden.
|
|
99
|
+
tracking.track('outbound_click', { hostname })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ⛔ CAPTURE phase. A foundation's own handler may call `stopPropagation()` on
|
|
103
|
+
// its links — a legitimate thing to do — and in the bubble phase that would
|
|
104
|
+
// silently zero the count for exactly the sites that decorate their links.
|
|
105
|
+
document.addEventListener('click', onClick, true)
|
|
106
|
+
// A middle click fires `auxclick`, not `click`. Opening a link in a new tab
|
|
107
|
+
// is a real outbound visit, and omitting it would undercount silently and
|
|
108
|
+
// unevenly — power users do it far more than average visitors.
|
|
109
|
+
document.addEventListener('auxclick', onClick, true)
|
|
110
|
+
|
|
111
|
+
return () => {
|
|
112
|
+
document.removeEventListener('click', onClick, true)
|
|
113
|
+
document.removeEventListener('auxclick', onClick, true)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default observeOutboundClicks
|