@decocms/apps-website 7.65.1 → 7.66.0

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": "@decocms/apps-website",
3
- "version": "7.65.1",
3
+ "version": "7.66.0",
4
4
  "type": "module",
5
5
  "description": "Deco generic-site app: SEO, analytics, theme, and content utilities shared across every commerce backend",
6
6
  "repository": {
@@ -32,8 +32,8 @@
32
32
  "lint:unused": "knip"
33
33
  },
34
34
  "dependencies": {
35
- "@decocms/blocks": "7.65.1",
36
- "@decocms/apps-commerce": "7.65.1"
35
+ "@decocms/blocks": "7.66.0",
36
+ "@decocms/apps-commerce": "7.66.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "react": "^19.0.0",
@@ -216,3 +216,44 @@ describe("initOneDollarStats", () => {
216
216
  expect(event).toHaveBeenCalledWith("ev", { keep: "x" });
217
217
  });
218
218
  });
219
+
220
+ describe("async tracker readiness", () => {
221
+ // The tracker <script> is `async`, so it can resolve after the 10s poll
222
+ // window closes. Its `load` event is the second, unbounded trigger.
223
+ let tracker: HTMLScriptElement;
224
+
225
+ beforeEach(() => {
226
+ tracker = document.createElement("script");
227
+ tracker.id = "onedollarstats-tracker";
228
+ document.head.appendChild(tracker);
229
+ });
230
+
231
+ afterEach(() => {
232
+ tracker.remove();
233
+ });
234
+
235
+ it("fires the initial pageview on the tracker's load event after the poll window closes", () => {
236
+ const view = vi.fn();
237
+ initOneDollarStats();
238
+
239
+ // Poll window (200 * 50ms) elapses with no stonks — pageview would be lost.
240
+ vi.advanceTimersByTime(11_000);
241
+ expect(view).not.toHaveBeenCalled();
242
+
243
+ (window as Window & { stonks?: unknown }).stonks = { view };
244
+ tracker.dispatchEvent(new Event("load"));
245
+
246
+ expect(view).toHaveBeenCalledTimes(1);
247
+ });
248
+
249
+ it("does not double-fire when both the poll and the load event resolve", () => {
250
+ const view = vi.fn();
251
+ initOneDollarStats();
252
+
253
+ (window as Window & { stonks?: unknown }).stonks = { view };
254
+ vi.advanceTimersByTime(100); // poll wins
255
+ tracker.dispatchEvent(new Event("load"));
256
+
257
+ expect(view).toHaveBeenCalledTimes(1);
258
+ });
259
+ });
@@ -24,10 +24,11 @@
24
24
  * 2. **`useEffect` for client logic.** All side-effects (initial pageview,
25
25
  * pushState wrap, DECO event subscribe) run inside a `useEffect`,
26
26
  * which fires after hydration. By then `<ScriptOnce>` in
27
- * `DecoRootLayout` has bootstrapped `window.DECO.events`, and the SDK
28
- * `<script>` (rendered as a sibling) has loaded and set
29
- * `window.stonks`. No inline `dangerouslySetInnerHTML` snippet, no
30
- * fragile script-execution-order dependency.
27
+ * `DecoRootLayout` has bootstrapped `window.DECO.events`. The SDK
28
+ * `<script>` (rendered as a sibling) is `async`, so `window.stonks`
29
+ * may or may not exist yet — see (4) and (5). No inline
30
+ * `dangerouslySetInnerHTML` snippet, no fragile script-execution-order
31
+ * dependency.
31
32
  *
32
33
  * 3. **Module-level guards.** `window.DECO.events.subscribe()` returns no
33
34
  * unsubscribe handle, so we cannot clean up on unmount. We use a
@@ -39,6 +40,18 @@
39
40
  * load). We poll every 50 ms for up to 10 s. Production: resolves
40
41
  * within one tick.
41
42
  *
43
+ * 5. **The tracker `<script>` is `async`, never `defer`.** A third-party
44
+ * analytics tag must not be able to hold the host page's lifecycle
45
+ * hostage: with `defer`, an unreachable `s.lilstts.com` (adblock,
46
+ * blocked route, DNS/CDN outage) delays DOMContentLoaded/load until
47
+ * the TCP timeout — a real client saw the browser spin for ~5 min
48
+ * with the page already painted. `async` is non-blocking for both
49
+ * parsing and DOMContentLoaded. Because the tracker can then resolve
50
+ * at any point, the first pageview is fired from whichever comes
51
+ * first: the readiness poll, or the tracker's own `load` event
52
+ * (which also covers a tracker that arrives after the 10 s poll
53
+ * window closes). See `deco-cx/apps@e0af145` for the Fresh-side fix.
54
+ *
42
55
  * ## Behavioural parity vs Fresh `deco-cx/apps`
43
56
  *
44
57
  * Mirrors the Path B snippet (`analytics/loaders/OneDollarScript.ts`):
@@ -69,6 +82,9 @@ export interface Props {
69
82
  staticScriptUrl?: string;
70
83
  }
71
84
 
85
+ /** DOM id of the tracker `<script>`; we listen to its `load` event. */
86
+ const TRACKER_ID = "onedollarstats-tracker";
87
+
72
88
  export const DEFAULT_COLLECTOR_ADDRESS = "https://d.lilstts.com/events";
73
89
  export const DEFAULT_ANALYTICS_SCRIPT_URL = "https://s.lilstts.com/deco.js";
74
90
 
@@ -91,12 +107,12 @@ function OneDollarStats({ collectorAddress, staticScriptUrl }: Props) {
91
107
  <link rel="dns-prefetch" href={collector} />
92
108
  <link rel="preconnect" href={collector} crossOrigin="anonymous" />
93
109
  <script
94
- id="onedollarstats-tracker"
110
+ id={TRACKER_ID}
95
111
  data-autocollect="false"
96
112
  data-hash-routing="true"
97
113
  data-url={collector}
98
114
  src={staticScript}
99
- defer
115
+ async
100
116
  />
101
117
  <OneDollarStatsClient />
102
118
  </>
@@ -199,6 +215,14 @@ function whenReady<T>(
199
215
  }, intervalMs);
200
216
  }
201
217
 
218
+ type StonksView = NonNullable<NonNullable<Window["stonks"]>["view"]>;
219
+
220
+ function getStonksView(): StonksView | undefined {
221
+ return typeof window.stonks?.view === "function"
222
+ ? window.stonks.view.bind(window.stonks)
223
+ : undefined;
224
+ }
225
+
202
226
  /**
203
227
  * Wire up the analytics integration. Idempotent — only the first call has
204
228
  * any effect.
@@ -212,16 +236,29 @@ export function initOneDollarStats(): void {
212
236
  const flags = readFlagsFromCookie();
213
237
 
214
238
  // 1) Initial pageview + SPA nav tracking, with flag enrichment.
215
- whenReady(
216
- () =>
217
- typeof window.stonks?.view === "function"
218
- ? window.stonks.view.bind(window.stonks)
219
- : undefined,
220
- (view) => {
221
- view(flags);
222
- wrapHistoryPushState(() => view(flags));
223
- addEventListener("popstate", () => view(flags));
239
+ //
240
+ // The tracker is `async`, so `window.stonks` may land before hydration,
241
+ // after it, or after the poll window closes. Two independent triggers,
242
+ // first one wins (`started` guard) — otherwise a slow tracker silently
243
+ // drops the landing pageview, since `data-autocollect="false"` means the
244
+ // SDK will never send one for us.
245
+ let started = false;
246
+ const start = (view: StonksView) => {
247
+ if (started) return;
248
+ started = true;
249
+ view(flags);
250
+ wrapHistoryPushState(() => view(flags));
251
+ addEventListener("popstate", () => view(flags));
252
+ };
253
+
254
+ whenReady(getStonksView, start);
255
+ document.getElementById(TRACKER_ID)?.addEventListener(
256
+ "load",
257
+ () => {
258
+ const view = getStonksView();
259
+ if (view) start(view);
224
260
  },
261
+ { once: true },
225
262
  );
226
263
 
227
264
  // 2) Forward DECO events to stonks.event with flag enrichment.