@decocms/apps-website 8.0.0 → 8.1.0-next.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/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # `@decocms/apps-website` (v7 only)
2
+
3
+ This package is the v7 website app. It has no next-major surface: the next
4
+ major ships no website package, and nothing new is added here.
5
+
6
+ Website features move to the platform templates (starter sites you copy and
7
+ own) or to built-ins:
8
+
9
+ | v7 (code in this package) | Next major |
10
+ |---|---|
11
+ | SEO components and sections (`Seo`, `SeoV2`) | SEO helpers in the platform templates. |
12
+ | `Analytics` section (Google Tag Manager, GA4) | A tag manager block in the platform template, owned by the site. |
13
+ | `Stats`, `OneDollarStats` | The built-in `analytics` block. |
14
+ | `website/loaders/secret.ts` | The built-in `secret` block; the migration re-encrypts each v7 secret. |
15
+ | Fonts, theme, video and environment loaders | Platform templates and site code. |
16
+
17
+ v7 saved-content keys and features that are not code in this package:
18
+
19
+ | v7 | Next major |
20
+ |---|---|
21
+ | `website/pages/Page.tsx`, `website/flags/multivariate.ts` | The built-in `page` and `multivariate`, through the CLI alias table. |
22
+ | Matchers | The built-in `always`, `never` and `date` matchers; others are site code. |
23
+ | Sitemaps, redirect logic | Platform templates and site code. |
24
+
25
+ See the next-major docs: *Calling APIs* (what is not in a client),
26
+ *Built-in blocks* and *Migrating from v7*.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@decocms/apps-website",
3
- "version": "8.0.0",
3
+ "version": "8.1.0-next.0",
4
4
  "type": "module",
5
- "description": "Deco generic-site app: SEO, analytics, theme, and content utilities shared across every commerce backend",
5
+ "description": "v7 only: Deco website app. The next major ships no website package; website features live in the platform templates.",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/decocms/blocks.git",
@@ -32,8 +32,8 @@
32
32
  "lint:unused": "knip"
33
33
  },
34
34
  "dependencies": {
35
- "@decocms/blocks": "8.0.0",
36
- "@decocms/apps-commerce": "8.0.0"
35
+ "@decocms/blocks": "8.1.0-next.0",
36
+ "@decocms/apps-commerce": "8.1.0-next.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.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Moved to `@decocms/blocks/hooks`, and re-exported here so nothing that already imports
3
+ * `@decocms/apps-website/components/Stats` breaks.
4
+ *
5
+ * It moved because `DecoRootLayout` mounts it now: `@decocms/tanstack` importing from an apps
6
+ * package would add an edge to a dependency graph this repo keeps one-way on purpose, and the
7
+ * component never needed to be here — it is a `<link>` and a `<script>` pointing at our own
8
+ * collector, not a vendor integration like `OneDollarStats`.
9
+ *
10
+ * Sites do not need to import it at all any more. Set `DECO_ANALYTICS_ENABLED=true`.
11
+ */
12
+ export {
13
+ Stats as default,
14
+ STATS_DEFAULT_ORIGIN as DEFAULT_ORIGIN,
15
+ type StatsProps as Props,
16
+ } from "@decocms/blocks/hooks";
@@ -1,5 +1,8 @@
1
+ import { withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout";
1
2
  import type { Font } from "../../types";
2
3
 
4
+ const timeoutFetch = withFetchTimeout();
5
+
3
6
  interface Props {
4
7
  fonts: GoogleFont[];
5
8
  }
@@ -94,13 +97,13 @@ const loader = async (props: Props): Promise<Font> => {
94
97
  };
95
98
 
96
99
  const sheets = await Promise.all([
97
- fetch(url, { headers: OLD_BROWSER_KEY })
100
+ timeoutFetch(url, { headers: OLD_BROWSER_KEY })
98
101
  .then((res) => res.text())
99
102
  .catch((e) => {
100
103
  logFontError("OLD_UA", url, e);
101
104
  return "";
102
105
  }),
103
- fetch(url, { headers: NEW_BROWSER_KEY })
106
+ timeoutFetch(url, { headers: NEW_BROWSER_KEY })
104
107
  .then((res) => res.text())
105
108
  .catch((e) => {
106
109
  logFontError("NEW_UA", url, e);
package/src/mod.ts CHANGED
@@ -108,6 +108,18 @@ export interface Props {
108
108
 
109
109
  /** @title Whilelist URL Patterns */
110
110
  whilelistURLs?: string[];
111
+
112
+ /**
113
+ * @title renderJson
114
+ * @description Structured JSON rendering of pages for the mobile app (?renderJson).
115
+ */
116
+ renderJson?: {
117
+ /**
118
+ * @title Ignored Sections
119
+ * @description App-owned sections excluded from the ?renderJson response, matched by resolveType suffix (e.g. "SeoV2.tsx"). Site-owned sections should prefer `export const renderJson = false` in their own file.
120
+ */
121
+ sectionsToIgnore?: string[];
122
+ };
111
123
  }
112
124
 
113
125
  /** Alias for site app bridges that extend website Props. */