Skip to content

Commit 44edc37

Browse files
committed
Add a "filled in only" sub-option to the form fill events
The form interaction tracker has never looked at field values: every focusin and focusout produced an event, so tabbing through a form without typing emitted a full formElementEnter/formElementLeave pair for every field along the way. That is true all the way back to the jQuery tracker in 1.4.0. The new event-form-move-filled-only option narrows the leave half. gtm4wp.formElementLeave now fires only on an observed transition: the field was empty when the visitor entered it AND holds a value when they leave it. The event therefore marks the moment a field gets filled in. Deliberate consequences of requiring the transition rather than only a non-empty value on leave: * a pre-filled field reports nothing when left unchanged, because the visitor did not fill it in; * clearing a field reports nothing; * a select whose options all carry a value is never empty and so never reports, while a "Choose..." placeholder select does; * a focusout with no matching focusin (autofocus before the deferred bundle attaches) stays silent rather than guessing at an unobserved transition. formElementEnter is never filtered, so "fields touched" stays measurable alongside "fields filled in". Value checked: textarea, select, and input except types checkbox, radio, button, submit, reset and image. button/meter/progress elements are never checked. Whitespace only counts as empty. The option is off by default and marked beta, so upgrading from 1.x changes nothing: Options merges defaults under stored values, and a 1.x options row has no value for the key. The flag reaches the tracker the way the CF7 config does, as a `var` printed by wp_add_inline_script and read as window.gtm4wp_form_move_config. `var` rather than a top-level `const`, which would bind lexically and never appear on window (RI-14). Registers the exempt input-type list as U107 in the upstream registry: it is a snapshot of the HTML standard's control types, and a button-like type added later would land on the checked side and silently stop reporting. Tests: 21 JS cases for the new mode in their own file (the tracker's document listeners and its init guard outlive one jsdom, so a second load in the existing file would measure two trackers at once), a default-mode counterpart asserting an empty field still reports, and 4 PHP cases pinning the emitted config bytes plus the 1.x upgrade path.
1 parent ca8c718 commit 44edc37

10 files changed

Lines changed: 597 additions & 57 deletions

File tree

.upstream/upstream-review-checklist.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ No versions, no changelogs, no feeds. Verified by re-checking the recorded **cla
331331
| ID | Dependency | Coupling site | Source / check | Watch | Failure | Cadence | Last verified |
332332
|---|---|---|---|---|---|---|---|
333333
| U101 | **Browser per-cookie size limit (~4096 bytes) — and *what* the 4096 counts.** The list-attribution writer (#405) evicts by measured encoded size against `GTM4WP_LIST_ATTR_MAX_BYTES = 3900`. Two readings of the same number: Chromium enforces it over **name + value** (`kMaxCookieNamePlusValueSize`), RFC 6265 §6.1 states it as **name + value + attributes**. Our writer emits a fixed 58 attribute bytes, so 3900 clears the stricter reading by 138 and holds either way. Claims to re-verify: (1) no shipping engine has lowered the per-cookie cap below 3900; (2) our attribute string is still ≤ 158 bytes — **a new cookie attribute is what silently eats this margin**, so re-measure `gtm4wp_write_cookie()` if one is added (`Partitioned` alone is 13). Headroom is not free: every byte withheld is a product the visitor loses attribution for, so do not "round down to be safe" without measuring the cost. Failure is silent and self-perpetuating - the browser rejects an oversized assignment whole and keeps the old value, so the next click re-reads the same oversized map and is rejected again, freezing attribution until the 3-day TTL. The PHP reader's `LIST_ATTRIBUTION_COOKIE_MAX_BYTES = 4096` is **not** this contract's other half: it measures the URL-decoded value and is a crafted-cookie guard | `js/frontend/gtm4wp-ecommerce-generic.js` (`GTM4WP_LIST_ATTR_MAX_BYTES`, the eviction loop in `gtm4wp_store_item_list_attribution()`); attribute bytes from `js/frontend/lib/gtm4wp-cookies.js` `gtm4wp_write_cookie()` | RFC 6265 §6.1; Chromium `net/cookies/canonical_cookie.h`; pinned from our side by `ecommerce-generic.test.js` asserting the written cookie stays ≤ 4096 bytes with long list names | — | silent-wrong | yearly | [x] 2026-08-07 measured: attributes = **58 bytes** (`;expires=` + fixed-width UTC date + `;path=/;SameSite=Lax`) → strict ceiling 4038. 20 entries of built-in (16-char) names = 2 636 bytes encoded, all 20 retained; entries retained at 3900 vs the 20-entry cap: 30-char names 20, 45-char 20, 60-char 15, 80-char 15. With the cap disabled, 20 entries of a 76-char widget title reached 5 508 bytes |
334+
| U107 | **HTML input types with no "filled in" state.** The form interaction tracker's filled-only sub-option (`event-form-move-filled-only`) suppresses `gtm4wp.formElementLeave` unless the field went from empty to non-empty during one focus visit. That check is meaningful only for elements that carry a text value, so `input` types **checkbox, radio, button, submit, reset, image** are exempt and always reported, as are `button`, `meter` and `progress` elements. The list is a snapshot of the HTML standard's control types (UD-1) and the tracker reads the DOM `type` property, which normalizes a missing or unrecognized attribute to `text`. **Failure mode is silent and one-directional**: a control type added to the standard later that behaves like a button lands on the checked side by default, its `value` reads empty, and its leave event simply stops being reported on sites that switched the option on — no error, no failing test | `js/frontend/gtm4wp-form-move-tracker.js` `gtm4wp_form_move_unfillable_types` (one definition, read by both handlers) | HTML Living Standard, the `input` element's type keyword table. Claim to re-verify: **no control type added since this row was written belongs on the exempt side** | — | silent-missing | yearly | [x] 2026-08-09 written against the current type list; all six exempt types and the three exempt elements are pinned by the `it.each` case in `js/frontend/test/form-move-tracker-filled-only.test.js`, each with an explicit empty `value` so the assertion fails if the exemption is dropped |
334335
| U63 | Cloudflare `HTTP_CF_IPCOUNTRY` header | `src/Modules/PageVariables/PageVariablesModule.php` | Cloudflare docs || silent-missing | quarterly | [ ] |
335336
| U64 | Proxy client-IP header semantics — `X-Forwarded-For` appends (rightmost), `CF-Connecting-IP`/`True-Client-IP`/`X-Real-IP` replace | `src/Frontend/VisitorIp.php` | proxy vendor docs || silent-wrong | quarterly | [ ] |
336337
| U65 | **8** remote player SDK URLs — YouTube, Vimeo, SoundCloud, Dailymotion, Mixcloud, **Cloudflare Stream `sdk.latest.js` (floating, UD-6)**, Spotify, Twitch. **Dailymotion is the only parameterized one, and the only one still built in PHP**: `https://geo.dailymotion.com/libs/player/<PLAYER_ID>.js` when the site configured a player ID, else the ID-less `https://geo.dailymotion.com/libs/player.js`. Its predecessor `api.dmcdn.net/all.js` was sunset for player integration on **2026-02-03** and is the textbook UD-11 case — the file still returns 200 and only `console.warn`s, so nothing on our side ever observed that every Dailymotion event had stopped firing. Three Dailymotion claims, each weaker than a plain "does the URL resolve": (1) the **ID-less** `libs/player.js` still returns a working library (`"player":{"id":"default",…,"status":"ok"}`) — this is **our own discovery, not a documented entry point**, so it carries a weaker promise than the per-player URL and is what keeps the feature usable without a Dailymotion Studio account; (2) the file **must not be self-hosted or cached** — its own header says *"generated dynamically, do not host it yourself. Statically hosting or caching this file will break your integration"*, recorded here because "self-host the SDK for performance" is exactly the optimization a future contributor reaches for; (3) `createPlayer`'s `player` option still overrides the loaded library's configuration, which is what lets a `/player/<pid>.html` embed keep its own player on a site that configured none. YouTube's `//www.youtube.com/iframe_api` is **protocol-relative and was previously unregistered** — it was the one SDK the tracker already injected itself, so it never appeared in the PHP enqueue list this row used to describe (UD-1: a coupling with no row is invisible, not unverified). **Mixcloud has split its loader from its app** — the URL we load, `https://widget.mixcloud.com/media/js/widgetApi.js`, is still the loader and still the API surface we bind to, while the player *application* now ships from `player-widget.mixcloud.com` as a Vite build (`/assets/index-<hash>.js`). The two hosts can retire independently, so a 200 on the app host says nothing about ours, and the plausible-looking `player-widget.mixcloud.com/widget/js/api.js` is a **404** — do not "modernize" the URL to a host that does not serve it | `js/frontend/gtm4wp-*.js` — the 4th argument to `gtm4wpObserveMedia()` (a bare URL, or `{ src, subscribe }` for YouTube/Spotify, whose SDKs signal readiness through a global callback). **Moved here from `MediaEventsModule.php` when the SDKs stopped being enqueued for every page**: PHP cannot see whether the rendered page has an embed, so all 8 are now fetched by the tracker only after its DOM scan finds one. ⚠️ **Three of the eight are two-hop loaders, not the API** — measured 2026-08-07: YouTube's `iframe_api` is ~1.2 KB that only assigns `YT={loading:0,loaded:0}` and injects `www-widgetapi-script`; Spotify's `/embed/iframe-api/v1` is ~1.1 KB that does **not** itself define `onSpotifyIframeApiReady` or `createController`; Mixcloud's `widgetApi.js` is the loader for an app now shipped from `player-widget.mixcloud.com`. For these three the URL we pin and the code that actually defines the global are **different, independently retireable artifacts**, and the second hop is unversioned and invisible to us — so a 200 on the loader is evidence about the loader and nothing else. Both readiness gates that survive this are the ones testing the *member* (`YT.Player`, the fired callback), never the bare global. **Dailymotion is the half-exception**: `MediaEventsModule::enqueue_scripts()` still *builds* its URL (only PHP has the stored player ID, and only PHP can `rawurlencode()` it where it enters the path) and publishes it as `gtm4wp_dailymotion_config.sdk` via `wp_add_inline_script(..., 'before')` — the tracker fetches it, never assembles it, so the URL still has exactly one definition | reachability + API shape. Claim to re-verify per SDK: **the URL still returns 2xx JavaScript and still assigns the global U66 names** — measured with a request, never inferred from the absence of complaints (UD-11: nothing on our side observes a dead SDK; the tracker simply never wires). Pinned from our side by a per-tracker `SDK loading` describe in `js/frontend/test/<provider>-tracker.test.js`, which asserts the exact URL is requested when an embed is present and that **nothing** is requested when it is not. ⚠️ **Reachability is the weaker half of this row and cannot be the whole check** — see the Watch column: Dailymotion's sunset SDK kept returning 200 for six months after it stopped working | **Vendor deprecation / changelog channels — check these, not just the CDN.** Verified live 2026-08-07: YouTube → the **Revision History** section of `developers.google.com/youtube/iframe_api_reference` (last entry 2025-07-09; already lists `getPlaybackQuality`/`setPlaybackQuality` as no-ops); Vimeo → `github.com/vimeo/player.js/releases` (**the strongest channel here — the only SDK that ships a real version number**, so it can be diffed rather than sniffed); Cloudflare Stream → `developers.cloudflare.com/changelog/` filtered to Stream, RSS at `/changelog/rss/index.xml`; Twitch → `dev.twitch.tv/docs/change-log`; SoundCloud → `github.com/soundcloud/Widget-JS-API` + `developers.soundcloud.com/docs/api/html5-widget` (**weak**: repo is unarchived but near-dormant, README carries no version and no notice, so absence of news is not news); Spotify → `developer.spotify.com/community/news/` (**candidate, not yet verified**) — note the `/v1` in our URL is itself the signal, a `/v2` appearing is the drift event; Mixcloud → **no formal channel exists**, which is why its loader/app split had to be discovered by fetching. Dailymotion → **`reference/migration-guide-new-embed-endpoint` and the `dailymotion/dailymotion-sdk-js` README, NOT `reference/deprecations`**: that page read *"We don't have ongoing deprecation for the moment"* throughout the entire player sunset and still does, so watching the page named "deprecations" would have caught nothing. The lesson generalises — a vendor's *migration guide* carries dated milestones its deprecation page may never mention | silent-missing | quarterly | [x] 2026-08-07 **all 8 fetched, all current, none deprecated.** YouTube `iframe_api` → 200, ~1.2 KB, assigns `YT={loading:0,loaded:0}` and injects `www-widgetapi-script`; Vimeo → 200, banner `@vimeo/player v2.30.4 \| (c) 2026`, assigns `Vimeo.Player` — **matches the latest GitHub release (v2.30.4, 29 Apr), so we are exactly current**; SoundCloud → 200, ~3.8 KB, `window.SC=window.SC\|\|{},window.SC.Widget=i`; Dailymotion → the new `geo.dailymotion.com/libs/player.js`, 200, `"player":{"id":"default",…,"status":"ok"}`; Mixcloud → 200, 194 597 bytes (verified 2026-08-05, unchanged); Cloudflare Stream → 200, ~20 KB, `window.Stream=function(r)`, internal `1.6.7`, and the docs still name this exact host+path; Spotify → 200, ~1.1 KB **loader only**; Twitch → 200, ~19.3 KB UMD assigning `Twitch` with `Embed` and `Player`, changelog current to 2026-07-31 with nothing about embeds. **No vendor body contained "deprecat", "sunset", "no longer supported" or "legacy"** |

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ Major rewrite of the plugin - please read the announcement post on gtm4wp.com be
152152
* Added: "Submitted field values in the data layer" option for the Contact Form 7 integration (`integrate-wpcf7-inputs`). Defaults to "Full" (the existing behavior); can be set to "Field names only" or "None" to keep submitted personal data out of the data layer.
153153
* Added: "Also push GA4 recommended events" option for the Contact Form 7 integration (`integrate-wpcf7-ga4events`, off by default). When enabled, the tracker also pushes the Google Analytics 4 recommended form events - `form_start` (on first field interaction), `form_submit` (on submit) and `generate_lead` (on a successful send) - each with `form_id`, `form_name` and `form_destination`, alongside the existing `gtm4wp.contactForm7*` events. Following the GA4 Enhanced Measurement definition, `form_submit` fires on every submission attempt, including one Contact Form 7 rejects, so `form_submit` and `generate_lead` also carry a `form_status` parameter holding the CF7 status (`mail_sent`, `validation_failed`, `spam` and so on) - use it in your container to separate accepted submissions from rejected ones. Both are pushed from the CF7 `wpcf7submit` event, so `form_submit` always precedes the `generate_lead` it produced.
154154

155+
### User events
156+
157+
* Added: "Only report fields the visitor filled in" option for the form fill events (`event-form-move-filled-only`, off by default, beta). With it enabled, `gtm4wp.formElementLeave` is pushed only for a field that was empty when the visitor entered it and holds a value when they leave it, so tabbing through a form without typing no longer produces a leave event for every field along the way. `gtm4wp.formElementEnter` is never filtered and keeps firing for every field, so "fields touched" stays measurable next to "fields filled in". Text inputs, textareas and dropdowns are checked this way, and a whitespace-only value counts as empty; checkboxes, radio buttons, buttons, `meter` and `progress` are always reported, because they carry no empty state. Two consequences worth knowing before you switch it on: a field that was already filled in when the visitor entered it (one your theme pre-fills, or a dropdown whose options all carry a value) reports no leave event when they leave it unchanged, and neither does a field the visitor clears - the option reports the moment a field gets filled in, not the value it happens to hold.
158+
155159
### AMP
156160

157161
* Fixed: AMP integration now works in the AMP plugin's Standard, Transitional and Reader (theme) modes - previously the GTM amp-analytics tag was only emitted in the AMP plugin's deprecated Legacy Reader mode, so enabling the AMP Container ID produced no tracking on modern AMP setups. Migrated to the AMP plugin's `amp_analytics_entries` API (which also auto-loads the correct `amp-analytics` component script) and to the current `amp_is_request()` function (replacing the deprecated `is_amp_endpoint()`).

compat/constants.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
define( 'GTM4WP_OPTION_INCLUDE_WEATHEROWMAPI', 'weather-openweathermap-apikey' );
103103

104104
define( 'GTM4WP_OPTION_EVENTS_FORMMOVE', 'event-form-move' );
105+
define( 'GTM4WP_OPTION_EVENTS_FORMMOVE_FILLEDONLY', 'event-form-move-filled-only' );
105106
define( 'GTM4WP_OPTION_EVENTS_NEWUSERREG', 'event-new-user-registration' );
106107
define( 'GTM4WP_OPTION_EVENTS_USERLOGIN', 'event-user-logged-in' );
107108

0 commit comments

Comments
 (0)