@decocms/tanstack 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.
Files changed (49) hide show
  1. package/package.json +12 -5
  2. package/src/__conformance__/observability.test.ts +221 -0
  3. package/src/hooks/CdnSegmentMarker.tsx +38 -0
  4. package/src/hooks/DecoPageRenderer.tsx +91 -29
  5. package/src/hooks/DecoRootLayout.tsx +139 -58
  6. package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
  7. package/src/hooks/DraftPreviewIndicator.tsx +64 -0
  8. package/src/hooks/NavigationProgress.test.tsx +58 -0
  9. package/src/hooks/NavigationProgress.tsx +45 -3
  10. package/src/hooks/PreviewProviders.test.tsx +42 -0
  11. package/src/hooks/PreviewProviders.tsx +21 -2
  12. package/src/hooks/deferredObserverTiming.test.tsx +91 -0
  13. package/src/hooks/index.ts +1 -0
  14. package/src/index.ts +30 -13
  15. package/src/routes/cmsRoute.test.ts +168 -1
  16. package/src/routes/cmsRoute.ts +148 -39
  17. package/src/routes/pageUrl.test.ts +6 -6
  18. package/src/routes/pageUrl.ts +5 -4
  19. package/src/routes/withSiteGlobals.test.ts +37 -0
  20. package/src/routes/withSiteGlobals.ts +45 -24
  21. package/src/sdk/backgroundTasks.test.ts +68 -0
  22. package/src/sdk/backgroundTasks.ts +38 -0
  23. package/src/sdk/cdnSegment.test.ts +67 -0
  24. package/src/sdk/cdnSegment.ts +71 -0
  25. package/src/sdk/deferredSectionLoader.test.ts +1 -1
  26. package/src/sdk/draft.test.ts +341 -0
  27. package/src/sdk/draft.ts +277 -0
  28. package/src/sdk/draftShared.ts +16 -0
  29. package/src/sdk/kvHydration.test.ts +156 -0
  30. package/src/sdk/kvHydration.ts +82 -12
  31. package/src/sdk/kvRedirects.test.ts +132 -0
  32. package/src/sdk/kvRedirects.ts +125 -0
  33. package/src/sdk/router.ts +70 -61
  34. package/src/sdk/serverFnFetch.ts +59 -0
  35. package/src/sdk/speculationRules.test.ts +82 -0
  36. package/src/sdk/speculationRules.ts +136 -0
  37. package/src/sdk/startEntry.ts +31 -0
  38. package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
  39. package/src/sdk/workerEntry.test.ts +554 -16
  40. package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
  41. package/src/sdk/workerEntry.ts +915 -68
  42. package/src/v8/__conformance__/delivery.test.ts +100 -0
  43. package/src/v8/binding.ts +70 -0
  44. package/src/v8/kvLoader.bundle.test.ts +42 -0
  45. package/src/v8/kvLoader.test.ts +74 -0
  46. package/src/v8/kvLoader.ts +43 -0
  47. package/src/vite/plugin.js +180 -8
  48. package/src/vite/plugin.test.js +126 -1
  49. package/src/vite/plugin.test.ts +125 -1
@@ -17,16 +17,17 @@
17
17
  *
18
18
  * export const Route = createFileRoute("/$")({
19
19
  * ...withSiteGlobals(cmsRouteConfig({
20
- * siteName: "Bagaggio",
21
- * defaultTitle: "Bagaggio",
20
+ * siteName: "Acme",
21
+ * defaultTitle: "Acme",
22
22
  * })),
23
23
  * component: ...,
24
24
  * });
25
25
  * ```
26
26
  */
27
27
 
28
- import type { ResolvedSection } from "@decocms/blocks/cms";
28
+ import type { MatcherContext, ResolvedSection } from "@decocms/blocks/cms";
29
29
  import { loadBlocks, onChange, resolvePageSections } from "@decocms/blocks/cms";
30
+ import { createCacheStore, getCacheStorageContext } from "@decocms/blocks/sdk/cacheStorage";
30
31
 
31
32
  // ---------------------------------------------------------------------------
32
33
  // Types
@@ -73,12 +74,18 @@ interface CacheEntry {
73
74
  const DEFAULT_CACHE_TTL_MS = 5 * 60_000;
74
75
  const cacheTtlMs = DEFAULT_CACHE_TTL_MS;
75
76
 
76
- let cache: CacheEntry | null = null;
77
- let inflight: Promise<CacheEntry> | null = null;
77
+ // Keyed by request path — site.global/site.pageSections can contain
78
+ // path-dependent matchers (pathname, multi(ETC Segment), date, etc.), so a
79
+ // single process-wide cache entry would leak one route's matched variant onto
80
+ // every other route (e.g. a /section-only alert winning on "/"). Caching a
81
+ // resolvedSections array per path preserves the original 5-minute SWR behavior
82
+ // while keeping each route's matcher evaluation correct.
83
+ const cache = createCacheStore<CacheEntry>("site-globals");
84
+ const inflight = new Map<string, Promise<CacheEntry>>();
78
85
 
79
86
  onChange(() => {
80
- cache = null;
81
- inflight = null;
87
+ cache.clear();
88
+ inflight.clear();
82
89
  });
83
90
 
84
91
  function readSiteBlock(): SiteBlock | null {
@@ -99,7 +106,7 @@ function gatherSectionRefs(site: SiteBlock): SiteGlobalRef[] {
99
106
  const EMPTY_ENTRY: CacheEntry = {
100
107
  resolvedSections: [],
101
108
  rawRefs: [],
102
- expiresAt: Number.POSITIVE_INFINITY, // empty entries don't need refresh
109
+ expiresAt: 0,
103
110
  };
104
111
 
105
112
  /**
@@ -112,49 +119,60 @@ const EMPTY_ENTRY: CacheEntry = {
112
119
  * Exposed as a util so sites can call it directly if they need globals
113
120
  * outside the route loader path (rare).
114
121
  */
115
- export async function resolveSiteGlobals(): Promise<{
122
+ export async function resolveSiteGlobals(matcherCtx?: MatcherContext): Promise<{
116
123
  resolvedSections: ResolvedSection[];
117
124
  rawRefs: SiteGlobalRef[];
118
125
  }> {
126
+ // Path/date/segment matchers inside site-global sections (e.g. a multivariate
127
+ // `Alert` gated by a pathname matcher) must evaluate against the actual
128
+ // request, and the result is cached per path so routes don't share variants.
129
+ const cacheKey = cache.key(matcherCtx?.path ?? "");
119
130
  const now = Date.now();
120
- if (cache && cache.expiresAt > now) return cache;
121
- if (inflight) return inflight;
131
+ const cached = getCacheStorageContext()?.storage
132
+ ? await cache.read(cacheKey)
133
+ : cache.get(cacheKey);
134
+ if (cached && cached.expiresAt > now) return cached;
135
+ const inflightEntry = inflight.get(cacheKey);
136
+ if (inflightEntry) return inflightEntry;
122
137
 
123
138
  const site = readSiteBlock();
124
139
  if (!site) {
125
140
  // Cache the empty result so subsequent requests don't re-walk the
126
141
  // block registry. `onChange` invalidation still applies — if a Site
127
- // block appears later, the listener nulls `cache` and we re-check.
128
- cache = EMPTY_ENTRY;
129
- return EMPTY_ENTRY;
142
+ // block appears later, the listener clears `cache` and we re-check.
143
+ const empty = { ...EMPTY_ENTRY, expiresAt: Date.now() + cacheTtlMs };
144
+ cache.set(cacheKey, empty, empty.expiresAt);
145
+ return empty;
130
146
  }
131
147
 
132
148
  const rawRefs = gatherSectionRefs(site);
133
149
  if (rawRefs.length === 0) {
134
- cache = EMPTY_ENTRY;
135
- return EMPTY_ENTRY;
150
+ const empty = { ...EMPTY_ENTRY, expiresAt: Date.now() + cacheTtlMs };
151
+ cache.set(cacheKey, empty, empty.expiresAt);
152
+ return empty;
136
153
  }
137
154
 
138
- inflight = (async () => {
155
+ const p = (async () => {
139
156
  try {
140
- const resolvedSections = await resolvePageSections(rawRefs);
157
+ const resolvedSections = await resolvePageSections(rawRefs, matcherCtx);
141
158
  const entry: CacheEntry = {
142
159
  resolvedSections,
143
160
  rawRefs,
144
161
  expiresAt: Date.now() + cacheTtlMs,
145
162
  };
146
- cache = entry;
163
+ cache.set(cacheKey, entry, entry.expiresAt);
147
164
  return entry;
148
165
  } catch (err) {
149
166
  console.error("[site-globals] failed to resolve:", err);
150
167
  // Don't cache failures — let the next request retry.
151
168
  return { resolvedSections: [], rawRefs, expiresAt: 0 };
152
169
  } finally {
153
- inflight = null;
170
+ inflight.delete(cacheKey);
154
171
  }
155
172
  })();
156
173
 
157
- return inflight;
174
+ inflight.set(cacheKey, p);
175
+ return p;
158
176
  }
159
177
 
160
178
  // ---------------------------------------------------------------------------
@@ -170,7 +188,10 @@ export async function resolveSiteGlobals(): Promise<{
170
188
  * both in `site.global` and in a page's section list, which would otherwise
171
189
  * render twice.
172
190
  */
173
- export function dedupeGlobals(globals: ResolvedSection[], existing: ResolvedSection[]): ResolvedSection[] {
191
+ export function dedupeGlobals(
192
+ globals: ResolvedSection[],
193
+ existing: ResolvedSection[],
194
+ ): ResolvedSection[] {
174
195
  if (globals.length === 0) return [];
175
196
  const seenComponents = new Set<string>();
176
197
  for (const s of existing) {
@@ -217,6 +238,6 @@ export function withSiteGlobals<T extends { loader: AnyLoader }>(routeConfig: T)
217
238
 
218
239
  /** @internal */
219
240
  export function __resetSiteGlobalsCache() {
220
- cache = null;
221
- inflight = null;
241
+ cache.clear();
242
+ inflight.clear();
222
243
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Workers run no timers between requests: core background work (release
3
+ * checks, telemetry batches) runs after the response, inside ctx.waitUntil
4
+ * (hosted-publishing.mdx, hosted-releases-internals.mdx, telemetry-internals.mdx).
5
+ */
6
+ import { createCMS, type Loader, resetForTests } from "@decocms/blocks";
7
+ import { setBlocks } from "@decocms/blocks/cms";
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+ import { createDecoWorkerEntry } from "./workerEntry";
10
+
11
+ const HOOK = Symbol.for("decocms.blocks.background");
12
+
13
+ beforeEach(() => {
14
+ resetForTests();
15
+ setBlocks({}); // the v7 path this entry still runs reads redirects from it
16
+ });
17
+ afterEach(() => {
18
+ delete (globalThis as Record<symbol, unknown>)[HOOK];
19
+ resetForTests();
20
+ });
21
+
22
+ function context() {
23
+ const waited: Promise<unknown>[] = [];
24
+ return {
25
+ waited,
26
+ ctx: { waitUntil: (p: Promise<unknown>) => void waited.push(p), passThroughOnException() {} },
27
+ };
28
+ }
29
+
30
+ describe("createDecoWorkerEntry: background work", () => {
31
+ it("runs a due release check after the response, inside ctx.waitUntil", async () => {
32
+ const update = vi.fn(async () => ({ updated: false }));
33
+ const loader: Loader = {
34
+ load: async () => ({ revision: "r1", blocks: {} }),
35
+ update,
36
+ };
37
+ const cms = createCMS({ blocks: {}, content: loader });
38
+ let checkedBeforeResponse = true;
39
+ const worker = createDecoWorkerEntry(
40
+ {
41
+ fetch: async () => {
42
+ cms.forRelease();
43
+ await Promise.resolve();
44
+ checkedBeforeResponse = update.mock.calls.length > 0;
45
+ return new Response("ok");
46
+ },
47
+ },
48
+ { observability: false },
49
+ );
50
+ const { waited, ctx } = context();
51
+ const response = await worker.fetch(new Request("https://example.com/"), {}, ctx);
52
+ expect(await response.text()).toBe("ok");
53
+ expect(checkedBeforeResponse).toBe(false);
54
+ expect(waited).toHaveLength(1);
55
+ await Promise.all(waited);
56
+ expect(update).toHaveBeenCalledTimes(1);
57
+ });
58
+
59
+ it("calls waitUntil only when there's something queued", async () => {
60
+ const worker = createDecoWorkerEntry(
61
+ { fetch: async () => new Response("ok") },
62
+ { observability: false },
63
+ );
64
+ const { waited, ctx } = context();
65
+ await worker.fetch(new Request("https://example.com/"), {}, ctx);
66
+ expect(waited).toHaveLength(0);
67
+ });
68
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Runs `@decocms/blocks` background work (release checks, telemetry batches)
3
+ * after each response, inside `ctx.waitUntil`. Workers run no timers between
4
+ * requests, so the core's timer fallback would never fire or would be bound
5
+ * to a finished request. See /next/hosted-releases-internals and
6
+ * /next/telemetry-internals#sending-in-the-background.
7
+ *
8
+ * The hook is installed once per isolate under the `Symbol.for` key the core
9
+ * looks for; its queue lives on the hook itself, so two copies of this module
10
+ * share one queue.
11
+ */
12
+
13
+ const HOOK = Symbol.for("decocms.blocks.background");
14
+
15
+ type Task = () => Promise<void>;
16
+ type Hook = ((task: Task) => void) & { queue: Task[] };
17
+
18
+ function hook(): Hook {
19
+ const g = globalThis as unknown as Record<symbol, Hook | undefined>;
20
+ const existing = g[HOOK];
21
+ if (typeof existing === "function" && Array.isArray(existing.queue)) return existing;
22
+ const queue: Task[] = [];
23
+ const installed = Object.assign((task: Task) => void queue.push(task), { queue });
24
+ g[HOOK] = installed;
25
+ return installed;
26
+ }
27
+
28
+ /** Installs the hook (idempotent): from now on, core background work queues here. */
29
+ export function installBackgroundHook(): void {
30
+ hook();
31
+ }
32
+
33
+ /** Runs everything queued so far inside `ctx.waitUntil`. */
34
+ export function runBackgroundTasks(ctx: { waitUntil(promise: Promise<unknown>): void }): void {
35
+ const tasks = hook().queue.splice(0);
36
+ if (tasks.length === 0) return;
37
+ ctx.waitUntil(Promise.all(tasks.map((task) => task().catch(() => {}))));
38
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { segmentToken } from "./cdnSegment";
3
+
4
+ const BUILD = "abc123";
5
+
6
+ describe("segmentToken", () => {
7
+ it("produces a token for an anonymous visitor", () => {
8
+ expect(segmentToken("desktop", false, BUILD)).toBeTruthy();
9
+ });
10
+
11
+ it("gives different segments different tokens", () => {
12
+ // The whole point: the URL has to distinguish what the Worker's key
13
+ // distinguishes, or a cache in front serves one visitor another's response.
14
+ const tokens = ["desktop", "mobile", "desktop|r=RJ", "desktop|r=SP", "desktop|sc=3|r=RJ"].map(
15
+ (s) => segmentToken(s, false, BUILD),
16
+ );
17
+ expect(new Set(tokens).size).toBe(tokens.length);
18
+ });
19
+
20
+ it("regionalized and non-regionalized visitors never share a token", () => {
21
+ // This is the case that made the first version inert on real stores: region
22
+ // is resolved server-side from cf.regionCode, so it MUST be in the token.
23
+ expect(segmentToken("desktop", false, BUILD)).not.toBe(
24
+ segmentToken("desktop|r=RJ", false, BUILD),
25
+ );
26
+ });
27
+
28
+ it("is stable for the same segment and build", () => {
29
+ // The worker verifies by recomputing, so the same input must always give
30
+ // the same token — otherwise nothing would ever match.
31
+ expect(segmentToken("desktop|r=RJ", false, BUILD)).toBe(
32
+ segmentToken("desktop|r=RJ", false, BUILD),
33
+ );
34
+ });
35
+
36
+ it("refuses a logged-in visitor regardless of segment precision", () => {
37
+ // Personalized responses never belong in a shared entry, however exact the
38
+ // key is.
39
+ expect(segmentToken("desktop|auth|r=RJ", true, BUILD)).toBeNull();
40
+ });
41
+
42
+ it("refuses a missing or dev build hash", () => {
43
+ // Without a build hash there is no way to invalidate on deploy — the cache
44
+ // in front would keep serving stale code.
45
+ expect(segmentToken("desktop", false, undefined)).toBeNull();
46
+ expect(segmentToken("desktop", false, "")).toBeNull();
47
+ expect(segmentToken("desktop", false, "dev")).toBeNull();
48
+ });
49
+
50
+ it("changes with the build, so a deploy invalidates it", () => {
51
+ expect(segmentToken("desktop", false, "buildA")).not.toBe(
52
+ segmentToken("desktop", false, "buildB"),
53
+ );
54
+ });
55
+
56
+ it("refuses an empty segment descriptor", () => {
57
+ expect(segmentToken("", false, BUILD)).toBeNull();
58
+ });
59
+
60
+ it("is URL-safe even when the segment carries a VTEX region id", () => {
61
+ // `v2.XXXX` contains the character an earlier format used as a separator;
62
+ // hashing sidesteps escaping entirely.
63
+ const t = segmentToken("desktop|r=v2.ABC-123|sc=3", false, BUILD);
64
+ expect(t).toMatch(/^[a-z0-9]+$/);
65
+ expect(encodeURIComponent(t as string)).toBe(t);
66
+ });
67
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Segment marker on `/_serverFn` URLs, so a cache in front of the Worker can
3
+ * serve the response without invoking it.
4
+ *
5
+ * The problem: whatever sits in front (Workers Cache, a CDN rule) keys on the
6
+ * raw URL. The Worker keys on a SYNTHETIC Request carrying
7
+ * `__seg`/`__cf_device`/`__bot`/`__fetch`/`__abf` (`buildCacheKey` in
8
+ * `./workerEntry`) — params the URL never shows. That mismatch is why the
9
+ * framework stamps `CDN-Cache-Control: no-store` on public responses.
10
+ *
11
+ * The fix: put the segment in the URL itself, so the two keys become
12
+ * equivalent.
13
+ *
14
+ * ## Why the SERVER issues the token
15
+ *
16
+ * The first version had the client derive the token from what it could see
17
+ * (`navigator.userAgent`). That only ever worked on sites whose segment
18
+ * reduces to device alone. A regionalized VTEX store — most of them — segments
19
+ * on region too, and region is resolved from `request.cf.regionCode`, which
20
+ * exists **only** on the server. The client cannot see it, so its token never
21
+ * matched and the feature stayed permanently inert.
22
+ *
23
+ * So the server computes the token from the segment it already built, publishes
24
+ * it to the page, and the client only echoes it back. The Worker still verifies
25
+ * by recomputing — the marker is never trusted, so a stale or forged one just
26
+ * keeps the `no-store`.
27
+ *
28
+ * The token is a hash rather than readable fields: `regionId` can contain the
29
+ * separator (`v2.XXXX`), values would need escaping, and there is no reason to
30
+ * publish a visitor's region in a URL that ends up in logs.
31
+ */
32
+
33
+ import { djb2Hex } from "@decocms/blocks/sdk/djb2";
34
+
35
+ /** `__d` is reserved: `workerEntry` uses `?__d=` as an OTel debug flag. */
36
+ export const CSEG_PARAM = "__cseg";
37
+
38
+ /** Global the SSR publishes the token on, read back by `decoServerFnFetch`. */
39
+ export const CSEG_GLOBAL = "__DECO_CSEG";
40
+
41
+ /** RequestContext bag key the worker entry fills before rendering. */
42
+ export const CSEG_BAG_KEY = "deco.cdn.segmentToken";
43
+
44
+ /**
45
+ * Build the token for a segment, or `null` when this request must not be
46
+ * cached in front of the Worker.
47
+ *
48
+ * `null` — i.e. keep `no-store` — when:
49
+ *
50
+ * - the visitor is logged in. Personalized responses never belong in a shared
51
+ * entry, no matter how precise the key is.
52
+ * - there is no build hash, or it is `"dev"`. The build is part of the token
53
+ * because deploying does not purge the cache in front, so the URL has to
54
+ * change on its own when the bundle does.
55
+ *
56
+ * Everything else in the segment — device, sales channel, region, a site's own
57
+ * custom dimensions — is folded INTO the token rather than rejected. That is
58
+ * the difference from the first version: those are what the key needs to
59
+ * distinguish, not reasons to give up on caching.
60
+ */
61
+ export function segmentToken(
62
+ segmentDescriptor: string,
63
+ loggedIn: boolean,
64
+ buildHash: string | undefined,
65
+ ): string | null {
66
+ if (loggedIn) return null;
67
+ if (!buildHash || buildHash === "dev") return null;
68
+ if (!segmentDescriptor) return null;
69
+
70
+ return djb2Hex(`${segmentDescriptor}|${buildHash}`);
71
+ }
@@ -3,7 +3,7 @@
3
3
  * (`@decocms/tanstack/sdk/deferredSectionLoader`).
4
4
  *
5
5
  * Before 7.7 this wrapper was unreachable from any public subpath, so
6
- * migrated sites (lebiscuit, miess, granadobr, casaevideo) each carried a
6
+ * migrated production storefronts each carried a
7
7
  * byte-identical local shim wrapping the public `loadDeferredSection`
8
8
  * export. The cases below are derived from how those sites call it: it is
9
9
  * passed verbatim as `<DecoPageRenderer loadDeferredSectionFn={...} />`,