# Version 2.3.0

> Two new interceptor layers (network + element-level setter patches) plus a timing fix that lands interceptors before any defer/async tracker can fire

Source: https://zest.freshjuice.dev/changelog/v2.3.0/
Date: 2026-05-19

`@freshjuice/zest@2.3.0` closes three real gaps surfaced while auditing HubSpot CMS, Cloudflare Zaraz, and server-side GTM sites. See the [full release on GitHub](https://github.com/freshjuice-dev/zest/releases/tag/v2.3.0).

> **Zero runtime change for v2.2 consumers.** Every default is preserved. Interceptors now install slightly earlier (script eval instead of `DOMContentLoaded`), which is strictly more protective.

## Highlights

### Fixed: interceptors install on script eval, not on `DOMContentLoaded`

The previous full-build entry waited for `DOMContentLoaded` before calling `Zest.init()`. That meant **all** interceptors (cookies, storage, scripts) installed AFTER `defer` and `async` tracker scripts had already executed. So HubSpot, GTM, and similar `defer`-loaded trackers got to fire their pre-consent calls before our hooks were in place.

`init()` now runs synchronously when the bundle is evaluated. Only the UI mount (banner / widget, which needs `<body>`) is deferred until the DOM is ready.

**No config change required.** Sites that inline or load Zest at the top of `<head>` (the documented setup) immediately get the interceptor coverage they always thought they had.

### Added: network interceptor

Patches `window.fetch`, `XMLHttpRequest`, and `navigator.sendBeacon`. Matches every outbound URL against the same `blockedDomains` + mode-based tracker list that the script blocker already uses. Blocked requests resolve as:

| API | Blocked behavior |
|-----|------------------|
| `fetch(url)` | Resolves to `Response` with status `204` and empty body |
| `XMLHttpRequest` | Fires `error` event with `readyState=4`, `status=0` |
| `navigator.sendBeacon(url, data)` | Returns `false` (spec's "not queued" signal) |

```javascript
Zest.init({
  blockedDomains: [
    { domain: 'track-eu1.hubspot.com', category: 'analytics' }
  ]
});
```

The HubSpot scriptloader at `/hs/scriptloader/{portalId}.js` still loads (first-party path, not blockable by hostname) — but its runtime beacons to `track-eu1.hubspot.com` are now caught. Same pattern applies to Cloudflare Zaraz (`/cdn-cgi/zaraz/`), server-side GTM custom subdomains, Shopify, Webflow.

**No replay.** Network requests are one-shot and time-sensitive — replaying a stale beacon after consent would create duplicate data. Blocked requests are dropped, not queued.

### Added: element interceptor (synchronous setter patches)

The `MutationObserver`-based script blocker fires asynchronously. By the time the observer runs, the browser has already kicked off the fetch for any `<script src>` / `<link href>` / `<img src>` that just got added to the DOM. The script may not execute (we flip `type` to `text/plain`), but the network request already went out — and to a privacy auditor that fetch IS a pre-consent leak.

Zest now also installs **synchronous prototype-setter patches**:

| Patched | Catches |
|---------|---------|
| `HTMLScriptElement.src` setter | `script.src = "..."` |
| `HTMLLinkElement.href` setter | `link.href = "..."` (stylesheets, preload, prefetch) |
| `HTMLImageElement.src` setter | `img.src = "..."` (tracking pixels) |
| `HTMLIFrameElement.src` setter | `iframe.src = "..."` (tracking iframes) |
| `Element.prototype.setAttribute` | `el.setAttribute("src", "...")` for the same four element types |
| `window.Image` constructor | `new Image()` flow |

```javascript
const s = document.createElement('script');
s.src = 'https://tracker.example/track.js';   // ← intercepted HERE,
                                              //   URL never lands on the element
document.head.appendChild(s);                 // ← appendChild fires nothing
```

**Replay supported.** Blocked element writes are queued with their category. When consent arrives, `replayElements()` re-applies the URL via the ORIGINAL setter for every element still connected to the DOM, so previously-blocked scripts / stylesheets / images execute without a page reload — same UX as the existing cookie / storage / script replay path.

**What it does NOT catch:** inline HTML `<script src="...">` and `<link rel="stylesheet" href="...">` tags parsed from the original document response. The browser fetches those during HTML parsing, BEFORE any JavaScript runs. The only complete fix for that class is server-side CSP or template-time removal (e.g. self-host Google Fonts instead of using `fonts.googleapis.com`).

Element interception is gated by the same `intercept.scripts` toggle as the `MutationObserver` blocker — they're both element-level defences and ship together.

### Added: `intercept.network` toggle

Defaults to `true`. Headless integrations that route their own network calls through the consent layer can opt out:

```javascript
Zest.init({
  intercept: { network: false }
});
```

## Why this release exists

CMSes increasingly proxy tracker scripts through the site's own origin to defeat ad-blockers (HubSpot CMS `/hs/scriptloader/`, Cloudflare Zaraz `/cdn-cgi/zaraz/`, server-side GTM, Shopify, Webflow). A hostname-based script blocker cannot match those `<script>` tags because the hostname is first-party. But at runtime the proxied script still phones home to the vendor's analytics endpoint via fetch / XHR / beacon — and that URL IS third-party. The network and element interceptors close those gaps from two complementary angles.

## TypeScript types

`InterceptToggles` now exposes `network` as a first-class typed field in both `zest.d.ts` and `zest.headless.d.ts`.

```typescript
import Zest, { InitOptions } from '@freshjuice/zest/headless';

const config: InitOptions = {
  respectDNT: true,
  intercept: { network: false }
};

Zest.init(config);
```

## Migration from v2.2

None required. Every existing config keeps working exactly as before. The timing fix is strictly more protective, the two new interceptors default to on, and the new `intercept.network` toggle defaults to `true` (matches the implicit prior behaviour of "no network interception" by simply not having had it — there's nothing to migrate).

## Install

```bash
npm install @freshjuice/zest
```

```javascript
// Full build (banner + modal + widget, auto-init)
import Zest from '@freshjuice/zest';

// Headless (BYO UI, manual init)
import Zest from '@freshjuice/zest/headless';
Zest.init({
  intercept: { network: false }
});
```

Or via CDN:

```html
<script src="https://cdn.jsdelivr.net/npm/@freshjuice/zest@2"></script>
```

