@tokenoftrust/storefront-runner 2.1.9 → 2.1.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.
@@ -64,6 +64,10 @@ body { overflow-x: clip; }
64
64
  caught as a real color-contrast failure once accent is a deep brand hue). */
65
65
  .announce-bar { background: var(--color-accent); color: var(--color-primary-contrast); }
66
66
  .announce-bar[hidden] { display: none; }
67
+ /* A returning visitor who already dismissed the bar gets `.announce-dismissed`
68
+ stamped on <html> pre-paint (see rawChrome.ts's head script) so it never
69
+ flashes in before CHROME_BEHAVIOR_JS's end-of-body script sets [hidden]. */
70
+ .announce-dismissed .announce-bar { display: none; }
67
71
  .announce-bar .container { display: flex; align-items: center; justify-content: center; gap: 14px; padding-block: 9px; position: relative; }
68
72
  .announce-bar__msg { font-size: .875rem; font-weight: 700; letter-spacing: .01em; text-align: center; }
69
73
  .announce-bar__msg a { color: var(--color-primary-contrast); text-decoration: underline; }
@@ -23,7 +23,7 @@ import { referenceTheme } from "@/themes/reference";
23
23
  import { mergeTheme } from "@/themes/schema";
24
24
  import { organizationLd, websiteLd } from "@/lib/jsonld";
25
25
  import { nonceInlineTags, CHROME_BODY_MARKER } from "@/lib/rawMarketingHtml";
26
- import { CHROME_BEHAVIOR_JS, sanitizeSharedChromeFragment } from "@/lib/rawChrome";
26
+ import { CHROME_BEHAVIOR_JS, CHROME_HEAD_JS, sanitizeSharedChromeFragment } from "@/lib/rawChrome";
27
27
  import { ASYNC_CSS_SWAP_JS } from "@/lib/asyncCss";
28
28
  import { reportTrackingChainHealth } from "@/lib/tracking/interactionHealth";
29
29
  import { resolveHeaderVariant, resolveFooterVariant } from "@/lib/chrome/variant";
@@ -264,6 +264,11 @@ const footerWidgets = await renderWidgetSlot(
264
264
  <meta charset="utf-8" />
265
265
  <meta name="viewport" content="width=device-width, initial-scale=1" />
266
266
 
267
+ {/* MUST run before body content paints — see CHROME_HEAD_JS's doc comment
268
+ in rawChrome.ts (the same statement CHROME_BEHAVIOR_JS used to run at
269
+ end of body, which shifted the mobile nav's layout post-paint). */}
270
+ <script is:inline nonce={cspNonce} set:html={CHROME_HEAD_JS}></script>
271
+
267
272
  {/* Renderer-owned head phases (platform.environment → consent.defaults →
268
273
  head.early), emitted before any tenant/app code — the Layout half of "one
269
274
  engine, two mounts" (rawHtmlResponse is the other). Same engine output, so
@@ -19,7 +19,7 @@ export const STATIC_SCRIPT_HASHES: readonly string[] = [
19
19
  "'sha256-YicfxUFBkKrQapZ/tzZKlaKTdNHgwVfv7y2Wa0NC6/k='",
20
20
  "'sha256-eIXWvAmxkr251LJZkjniEK5LcPF3NkapbJepohwYRIc='",
21
21
  "'sha256-elMLh/6Oubj8u9FRLwZLyRa6TqKikGs75mf0jfWcoxI='",
22
- "'sha256-yxhGRh5KeGL1P3NgVN7YoKE4N5zBl1XGEycWPbX0TWo='",
22
+ "'sha256-Rl3sizqw4AmEkfQgkgajKo4uJC7JkyVCYiPJY/DJbIE='",
23
23
  ];
24
24
 
25
25
  export const STATIC_STYLE_HASHES: readonly string[] = [
@@ -531,6 +531,45 @@ function renderFooter(footer: ChromeFooter): string {
531
531
  );
532
532
  }
533
533
 
534
+ /**
535
+ * The ONE canonical statement that marks the document JS-enabled so
536
+ * commerce-chrome.css can turn the always-reachable stacked mobile nav into an
537
+ * off-canvas drawer (`.js-nav` gate, commerce-chrome.css's max-width:860px
538
+ * block) — progressive enhancement, not a requirement. Shared verbatim by both
539
+ * document mounts that emit this chrome (the raw-HTML wrapper below and
540
+ * Layout.astro's Astro-rendered mount) so it is one source, never copy-pasted.
541
+ *
542
+ * MUST run from `<head>`, before any body content paints: run from end of body
543
+ * (as CHROME_BEHAVIOR_JS used to), the browser paints the stacked in-flow nav
544
+ * first and then reflows it into the fixed drawer once this line runs,
545
+ * producing a mobile-only layout shift (measured CLS 0.232 on
546
+ * `body > main#main`). Run from `<head>`, the drawer layout is what the
547
+ * browser paints first — no reflow, no shift.
548
+ */
549
+ export const CHROME_HEAD_JS = `document.documentElement.classList.add('js-nav');`;
550
+
551
+ /**
552
+ * The wrapper's `<head>` behavior script: {@link CHROME_HEAD_JS}, plus — only when
553
+ * the announcement is a dismissible "bar" — a pre-paint check of the SAME
554
+ * localStorage key CHROME_BEHAVIOR_JS's dismiss handler reads, so a returning
555
+ * visitor who already dismissed it never sees it flash in before being hidden
556
+ * (the same before/after-paint shape as `.js-nav`, for a returning visitor
557
+ * rather than every visitor). Marks `<html>` with `.announce-dismissed`, which
558
+ * commerce-chrome.css hides the bar behind; CHROME_BEHAVIOR_JS's own
559
+ * `bar.hidden` logic (which also gates the message-rotation interval) is
560
+ * unchanged and still runs.
561
+ */
562
+ function renderChromeHeadScript(chrome: ChromeConfig): string {
563
+ const announcement = chrome.header.announcement;
564
+ const announceKey = announcement?.variant === "bar" && announcement.dismissible
565
+ ? announcement.storageKey || "tot_announce_dismissed"
566
+ : null;
567
+ const announceCheck = announceKey
568
+ ? ` try { if (localStorage.getItem(${JSON.stringify(announceKey)}) === '1') document.documentElement.classList.add('announce-dismissed'); } catch (e) {}`
569
+ : "";
570
+ return `<script>(function () { ${CHROME_HEAD_JS}${announceCheck} })();</script>`;
571
+ }
572
+
534
573
  /**
535
574
  * The ONE shared, nonce-safe chrome behavior script. Inlined into the wrapper (the
536
575
  * rawMarketingHtml pipeline stamps it with the page nonce, so a strict CSP admits it)
@@ -542,11 +581,6 @@ function renderFooter(footer: ChromeFooter): string {
542
581
  * defaults open only when JS enables it).
543
582
  */
544
583
  export const CHROME_BEHAVIOR_JS = `(function () {
545
- // Marks the document as JS-enabled so commerce-chrome.css can turn the
546
- // always-reachable stacked mobile nav into an off-canvas drawer (progressive
547
- // enhancement, not a requirement — see the .js-nav rules there).
548
- document.documentElement.classList.add('js-nav');
549
-
550
584
  // Mobile nav drawer
551
585
  var burger = document.getElementById('hamburger');
552
586
  var nav = document.getElementById('primaryNav');
@@ -808,6 +842,7 @@ export function renderRawChrome(
808
842
  `<head>\n` +
809
843
  `<meta charset="UTF-8">\n` +
810
844
  `<meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
845
+ `${renderChromeHeadScript(chrome)}\n` +
811
846
  `${headLinks}\n` +
812
847
  `${CHROME_HEAD_MARKER}\n` +
813
848
  `</head>\n` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.1.9",
3
+ "version": "2.1.10",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -46,7 +46,9 @@ import { resolveNativeGa4SandboxUrl, type NativeGa4Transport } from "./native-ga
46
46
  import {
47
47
  createBrowserNativeGa4SandboxHost,
48
48
  createNativeGa4SandboxTransport,
49
+ type NativeGa4SandboxScheduler,
49
50
  } from "./native-ga4-sandbox.js";
51
+ import { schedulePostPaint, type PostPaintWindow } from "./post-paint.js";
50
52
  import { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js";
51
53
  import { minimizePageAttribution, type ManagedPageViewInput } from "./managed-analytics.js";
52
54
  import { managedAnalyticsOpaqueRevision } from "./managed-analytics-value-safety.js";
@@ -121,6 +123,15 @@ export interface BootLifecycleOptions {
121
123
  importModule?: (url: string) => Promise<unknown>;
122
124
  /** Reviewed isolated-origin GA4 transport; injectable for DOM-free tests. */
123
125
  nativeGa4Transport?: NativeGa4Transport;
126
+ /**
127
+ * Defers non-essential platform-adapter work (the GA4 sandbox iframe mount and
128
+ * the Amplitude HTTP capability) until after first paint and idle — see
129
+ * `post-paint.ts`. Defaults to the real post-paint scheduler over `window`
130
+ * (a guarded synchronous no-op off the browser). Injectable so a test can
131
+ * assert nothing runs before the scheduler fires with a fake, controllable
132
+ * scheduler instead of real paint/idle timing.
133
+ */
134
+ schedulePlatformAdapterStart?: NativeGa4SandboxScheduler;
124
135
  }
125
136
 
126
137
  /**
@@ -314,6 +325,58 @@ function defaultConsentGate(
314
325
  return (integration) => integrationConsentEligible(integration, readConsentSnapshot(win));
315
326
  }
316
327
 
328
+ /**
329
+ * The default `schedulePlatformAdapterStart`: the real post-paint scheduler over the
330
+ * global `window`, or a synchronous immediate start off the browser (SSR, tests that
331
+ * import this module without a DOM) — never a hang.
332
+ */
333
+ function defaultPlatformAdapterScheduler(start: () => void): { cancel(): void } {
334
+ if (typeof window === "undefined") {
335
+ start();
336
+ return { cancel() {} };
337
+ }
338
+ return schedulePostPaint(window as unknown as PostPaintWindow, start);
339
+ }
340
+
341
+ /**
342
+ * Wrap a `fetch` capability so calls queue instead of running immediately, and are
343
+ * all issued together once `schedule` fires. This is what keeps the Amplitude
344
+ * adapter's HTTP collection off the pre-paint critical path exactly like the GA4
345
+ * sandbox's deferred iframe mount, without touching `native-amplitude.ts` (which
346
+ * stays paint-timing-agnostic — it just calls the `fetch` capability it was given).
347
+ */
348
+ function deferFetchCapability(
349
+ fetchCapability: typeof fetch,
350
+ schedule: (start: () => void) => { cancel(): void },
351
+ ): typeof fetch {
352
+ interface Pending {
353
+ args: Parameters<typeof fetch>;
354
+ resolve: (value: Response) => void;
355
+ reject: (reason: unknown) => void;
356
+ }
357
+ let queue: Pending[] = [];
358
+ let scheduled = false;
359
+ const flush = (): void => {
360
+ scheduled = false;
361
+ const pending = queue;
362
+ queue = [];
363
+ for (const { args, resolve, reject } of pending) {
364
+ fetchCapability(...args).then(resolve, reject);
365
+ }
366
+ };
367
+ return ((...args: Parameters<typeof fetch>) =>
368
+ new Promise<Response>((resolve, reject) => {
369
+ queue.push({ args, resolve, reject });
370
+ // Re-arm on every fresh burst: a flush disarms itself so a LATER call (e.g. a
371
+ // same-document route commit long after boot) still gets scheduled, not just
372
+ // the first ever call through this capability.
373
+ if (!scheduled) {
374
+ scheduled = true;
375
+ schedule(flush);
376
+ }
377
+ })) as typeof fetch;
378
+ }
379
+
317
380
  /**
318
381
  * Boot the lifecycle for `win` exactly once. A second call returns the same instance
319
382
  * (nested-layout safety). Returns `null` when there is no boot descriptor to act on.
@@ -330,6 +393,8 @@ export function bootIntegrationLifecycle(
330
393
 
331
394
  const hostFetch = options.fetchCapability ??
332
395
  (typeof win.fetch === "function" ? win.fetch.bind(win) : undefined);
396
+ const schedulePlatformAdapterStart =
397
+ options.schedulePlatformAdapterStart ?? defaultPlatformAdapterScheduler;
333
398
  const fallbackLoader = options.importModule
334
399
  ? makeSameOriginEntryLoader(options.importModule)
335
400
  : sameOriginEntryLoader;
@@ -340,7 +405,10 @@ export function bootIntegrationLifecycle(
340
405
  // Platform authority is resolved before any caller/window override: exact registry
341
406
  // matches run; unknown/mismatched platform claims fail closed without delegation.
342
407
  const loadModule = makePlatformAdapterRuntimeLoader({
343
- fetchCapability: hostFetch,
408
+ // Deferred so the Amplitude adapter's HTTP collection never starts pre-paint
409
+ // (F4/CLASS 1). The subscribe/mapping logic in native-amplitude.ts is unchanged
410
+ // and unaware of this — it just calls whatever `fetch` capability it was given.
411
+ fetchCapability: hostFetch ? deferFetchCapability(hostFetch, schedulePlatformAdapterStart) : hostFetch,
344
412
  importModule: options.importModule,
345
413
  fallback: ordinaryLoader,
346
414
  nativeGa4Transport: () => {
@@ -351,6 +419,7 @@ export function bootIntegrationLifecycle(
351
419
  return createNativeGa4SandboxTransport(
352
420
  createBrowserNativeGa4SandboxHost(document, MessageChannel),
353
421
  resolveNativeGa4SandboxUrl(win.location.href),
422
+ schedulePlatformAdapterStart,
354
423
  );
355
424
  },
356
425
  });
@@ -92,15 +92,34 @@ export function createBrowserNativeGa4SandboxHost(
92
92
  };
93
93
  }
94
94
 
95
+ /**
96
+ * Starts (or cancels) the deferred mount of the sandbox iframe. Defaults to
97
+ * running `start` synchronously, so a caller that does not care about paint
98
+ * timing (every existing test, and any future non-browser host) gets the
99
+ * original immediate-mount behavior unchanged.
100
+ */
101
+ export type NativeGa4SandboxScheduler = (start: () => void) => { cancel(): void };
102
+
103
+ const IMMEDIATE_SCHEDULER: NativeGa4SandboxScheduler = (start) => {
104
+ start();
105
+ return { cancel() {} };
106
+ };
107
+
95
108
  /**
96
109
  * Mount one hidden same-site, cross-origin iframe and transfer exactly one MessagePort
97
110
  * after load. gtag suppresses collection in an opaque third-party frame, so the receiver
98
111
  * needs a real same-site origin. The exact origin, iframe window, and one-shot transfer
99
112
  * are the authority boundary. No general parent `message` listener is installed.
113
+ *
114
+ * The frame is not created or appended until `schedule` fires (default: immediately,
115
+ * i.e. unchanged from before) — that append is what starts gtag.js loading, so gating
116
+ * it behind a paint-aware scheduler (see `post-paint.ts`) keeps it off the LCP path.
117
+ * `configure`/`send` calls before the frame mounts queue exactly as they always have.
100
118
  */
101
119
  export function createNativeGa4SandboxTransport(
102
120
  host: NativeGa4SandboxHost,
103
121
  sandboxUrl: string,
122
+ schedule: NativeGa4SandboxScheduler = IMMEDIATE_SCHEDULER,
104
123
  ): NativeGa4SandboxTransport {
105
124
  const sandboxOrigin = new URL(sandboxUrl, host.parentOrigin).origin;
106
125
  if (!host.parentOrigin || sandboxOrigin === host.parentOrigin) {
@@ -110,15 +129,8 @@ export function createNativeGa4SandboxTransport(
110
129
  if (sandboxOrigin !== expectedSandboxOrigin) {
111
130
  throw new Error("ga4_sandbox_origin_not_same_site");
112
131
  }
113
- const frame = host.createFrame();
114
- frame.setAttribute("sandbox", "allow-scripts allow-same-origin");
115
- frame.setAttribute("referrerpolicy", "no-referrer");
116
- frame.setAttribute("aria-hidden", "true");
117
- frame.setAttribute("tabindex", "-1");
118
- frame.setAttribute("title", "Analytics transport");
119
- frame.hidden = true;
120
- frame.src = sandboxUrl;
121
132
 
133
+ let frame: NativeGa4SandboxFrame | null = null;
122
134
  let port: NativeGa4MessagePort | null = null;
123
135
  let disposed = false;
124
136
  let unavailable = false;
@@ -129,34 +141,49 @@ export function createNativeGa4SandboxTransport(
129
141
  unavailable = true;
130
142
  queue = [];
131
143
  host.clearTimer(timer);
132
- frame.remove();
144
+ frame?.remove();
133
145
  };
134
146
 
135
- frame.addEventListener("load", () => {
136
- if (disposed || port) return;
137
- const target = frame.contentWindow;
138
- if (!target) return reject();
139
- const channel = host.createChannel();
140
- port = channel.port1;
141
- try {
142
- target.postMessage(
143
- Object.freeze({ protocol: NATIVE_GA4_SANDBOX_PROTOCOL, type: "bind", parentOrigin: host.parentOrigin }),
144
- sandboxOrigin,
145
- [channel.port2],
146
- );
147
- } catch {
148
- port.close();
149
- port = null;
150
- return reject();
151
- }
152
- host.clearTimer(timer);
153
- const pending = queue;
154
- queue = [];
155
- for (const message of pending) port.postMessage(Object.freeze(message));
156
- }, { once: true });
157
- frame.addEventListener("error", reject, { once: true });
158
- timer = host.setTimer(reject, 5_000);
159
- host.appendFrame(frame);
147
+ const mount = (): void => {
148
+ if (disposed || unavailable || frame) return;
149
+ const mounted = host.createFrame();
150
+ frame = mounted;
151
+ mounted.setAttribute("sandbox", "allow-scripts allow-same-origin");
152
+ mounted.setAttribute("referrerpolicy", "no-referrer");
153
+ mounted.setAttribute("aria-hidden", "true");
154
+ mounted.setAttribute("tabindex", "-1");
155
+ mounted.setAttribute("title", "Analytics transport");
156
+ mounted.hidden = true;
157
+ mounted.src = sandboxUrl;
158
+
159
+ mounted.addEventListener("load", () => {
160
+ if (disposed || port) return;
161
+ const target = mounted.contentWindow;
162
+ if (!target) return reject();
163
+ const channel = host.createChannel();
164
+ port = channel.port1;
165
+ try {
166
+ target.postMessage(
167
+ Object.freeze({ protocol: NATIVE_GA4_SANDBOX_PROTOCOL, type: "bind", parentOrigin: host.parentOrigin }),
168
+ sandboxOrigin,
169
+ [channel.port2],
170
+ );
171
+ } catch {
172
+ port.close();
173
+ port = null;
174
+ return reject();
175
+ }
176
+ host.clearTimer(timer);
177
+ const pending = queue;
178
+ queue = [];
179
+ for (const message of pending) port.postMessage(Object.freeze(message));
180
+ }, { once: true });
181
+ mounted.addEventListener("error", reject, { once: true });
182
+ timer = host.setTimer(reject, 5_000);
183
+ host.appendFrame(mounted);
184
+ };
185
+
186
+ const scheduled = schedule(mount);
160
187
 
161
188
  const post = (message: NativeGa4SandboxPortMessage): void => {
162
189
  if (disposed || unavailable) throw new Error("ga4_sandbox_unavailable");
@@ -174,6 +201,7 @@ export function createNativeGa4SandboxTransport(
174
201
  },
175
202
  dispose() {
176
203
  if (disposed) return;
204
+ scheduled.cancel();
177
205
  if (port) {
178
206
  post({ schemaVersion: 1, type: "dispose" });
179
207
  port.close();
@@ -183,7 +211,7 @@ export function createNativeGa4SandboxTransport(
183
211
  port = null;
184
212
  disposed = true;
185
213
  host.clearTimer(timer);
186
- frame.remove();
214
+ frame?.remove();
187
215
  },
188
216
  };
189
217
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Paint-aware scheduling for non-essential platform work (ext-kernel-05 follow-on).
3
+ *
4
+ * Nothing in the lifecycle boot chain waited for a real paint before starting
5
+ * heavy adapter transports (GA4's isolated-origin iframe, Amplitude's HTTP
6
+ * collection), so Lighthouse's simulated dependency graph charged their bytes
7
+ * and main-thread time straight to LCP. This scheduler defers a callback until
8
+ * AFTER the document has painted and the main thread is otherwise idle:
9
+ *
10
+ * - if the document has already finished loading, schedule on idle immediately;
11
+ * - otherwise wait for `load`, then schedule on idle;
12
+ * - "idle" is `requestIdleCallback` with a bounded timeout, falling back to a
13
+ * macrotask (`setTimeout(0)`) where `requestIdleCallback` does not exist.
14
+ *
15
+ * Cancellable at every stage (before `load`, and while idle-queued), so a
16
+ * document torn down before the deferred work starts never runs it.
17
+ */
18
+
19
+ /** The narrow slice of `window` this scheduler depends on — structural, edge-safe. */
20
+ export interface PostPaintWindow {
21
+ document?: { readyState?: string };
22
+ addEventListener(type: "load", listener: () => void, options?: { once?: boolean }): void;
23
+ removeEventListener?(type: "load", listener: () => void): void;
24
+ requestIdleCallback?(callback: (deadline: unknown) => void, options?: { timeout: number }): number;
25
+ cancelIdleCallback?(handle: number): void;
26
+ setTimeout(callback: () => void, ms: number): unknown;
27
+ clearTimeout(handle: unknown): void;
28
+ }
29
+
30
+ export interface PostPaintScheduleOptions {
31
+ /** Bound on how long `requestIdleCallback` may wait before firing anyway. */
32
+ idleTimeoutMs?: number;
33
+ }
34
+
35
+ export interface PostPaintHandle {
36
+ /** Idempotent. Prevents the callback from running if it has not run yet. */
37
+ cancel(): void;
38
+ }
39
+
40
+ /** Default bound so a busy main thread cannot indefinitely starve deferred work. */
41
+ export const DEFAULT_POST_PAINT_IDLE_TIMEOUT_MS = 4_000;
42
+
43
+ /**
44
+ * Schedule `callback` to run after first paint and idle. Never runs synchronously,
45
+ * even when the document is already `complete` — always at least one idle/macrotask
46
+ * turn later — so a caller can rely on "nothing runs before this returns".
47
+ */
48
+ export function schedulePostPaint(
49
+ win: PostPaintWindow,
50
+ callback: () => void,
51
+ options: PostPaintScheduleOptions = {},
52
+ ): PostPaintHandle {
53
+ let cancelled = false;
54
+ let ran = false;
55
+ let idleHandle: number | undefined;
56
+ let timeoutHandle: unknown;
57
+ let loadListener: (() => void) | undefined;
58
+
59
+ const runIdle = (): void => {
60
+ if (cancelled) return;
61
+ if (typeof win.requestIdleCallback === "function") {
62
+ idleHandle = win.requestIdleCallback(
63
+ () => {
64
+ if (cancelled || ran) return;
65
+ ran = true;
66
+ callback();
67
+ },
68
+ { timeout: options.idleTimeoutMs ?? DEFAULT_POST_PAINT_IDLE_TIMEOUT_MS },
69
+ );
70
+ } else {
71
+ timeoutHandle = win.setTimeout(() => {
72
+ if (cancelled || ran) return;
73
+ ran = true;
74
+ callback();
75
+ }, 0);
76
+ }
77
+ };
78
+
79
+ if (win.document?.readyState === "complete") {
80
+ runIdle();
81
+ } else {
82
+ loadListener = runIdle;
83
+ win.addEventListener("load", loadListener, { once: true });
84
+ }
85
+
86
+ return {
87
+ cancel() {
88
+ if (cancelled) return;
89
+ cancelled = true;
90
+ if (loadListener) win.removeEventListener?.("load", loadListener);
91
+ if (idleHandle !== undefined) win.cancelIdleCallback?.(idleHandle);
92
+ if (timeoutHandle !== undefined) win.clearTimeout(timeoutHandle);
93
+ },
94
+ };
95
+ }