@decocms/blocks 7.58.0 → 7.59.1

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/blocks",
3
- "version": "7.58.0",
3
+ "version": "7.59.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
package/src/cms/index.ts CHANGED
@@ -92,6 +92,7 @@ export type {
92
92
  } from "./resolve";
93
93
  export {
94
94
  addSkipResolveType,
95
+ BOT_UA_SUBSTRINGS,
95
96
  cacheDeferredRawProps,
96
97
  clearCommerceLoaders,
97
98
  evaluateMatcher,
@@ -23,14 +23,16 @@ vi.mock("./registry", () => ({
23
23
  import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
24
24
  import { findPageByPath } from "./loader";
25
25
  import { getSection } from "./registry";
26
- import type { AsyncRenderingConfig, DeferredSection } from "./resolve";
26
+ import type { AsyncRenderingConfig, DeferredSection, MatcherContext } from "./resolve";
27
27
  import {
28
28
  clearCommerceLoaders,
29
29
  DEFAULT_FOLD_THRESHOLD,
30
30
  extractSeoFromProps,
31
31
  getAsyncRenderingConfig,
32
32
  isEagerRequest,
33
+ reExtractRawProps,
33
34
  registerCommerceLoader,
35
+ registerMatcher,
34
36
  registerEagerSections,
35
37
  registerAlwaysDeferSections,
36
38
  registerNeverDeferSections,
@@ -736,16 +738,6 @@ describe("resolvePageSeoBlock — per-section ignoreStructuredData drives the fe
736
738
  });
737
739
  });
738
740
 
739
- // ---------------------------------------------------------------------------
740
- // resolveDecoPage — #277 client-side navigation disables deferral
741
- // ---------------------------------------------------------------------------
742
- //
743
- // When isClientNavigation is true, resolveDecoPageImpl sets useAsync = false so
744
- // shouldDeferSection is never called. All sections — including CMS ⚡-wrapped
745
- // ones — are resolved eagerly. This prevents client-nav from returning a
746
- // deferredSections array that loadDeferredSection would then try to resolve
747
- // without the per-request commerce app context.
748
-
749
741
  describe("extractSeoFromProps — commerce jsonLD structured data", () => {
750
742
  const plp = (overrides: Record<string, unknown> = {}) => ({
751
743
  "@type": "ProductListingPage",
@@ -888,22 +880,42 @@ describe("extractSeoFromProps — commerce jsonLD structured data", () => {
888
880
  });
889
881
  });
890
882
 
891
- describe("resolveDecoPage — #277 client-side navigation disables deferral", () => {
883
+ // ---------------------------------------------------------------------------
884
+ // resolveDecoPage — client nav gets the SAME eager/deferred split as SSR
885
+ // ---------------------------------------------------------------------------
886
+ //
887
+ // A TanStack route loader is BLOCKING: the router will not commit the
888
+ // transition until the loader promise settles. Eager-resolving every ⚡
889
+ // below-fold section on client nav therefore freezes the previous page for as
890
+ // long as the slowest upstream takes (measured on a real PDP: 20 awaited
891
+ // sections, 2717ms, 3.41MB — worse than a full reload). So deferral must apply
892
+ // to client nav exactly as it does to SSR.
893
+ //
894
+ // The historical `!isClientNavigation` gate (decocms/blocks#277) was a
895
+ // workaround for deferred loaders that appeared to lose per-request app
896
+ // context. It traded a page-wide latency regression for that symptom. The
897
+ // second hop (`loadDeferredSection`) is the SAME server fn the SSR path has
898
+ // always used and rebuilds MatcherContext from the real request — see the
899
+ // "#277 — per-request context survives the deferred second hop" cases below.
900
+ // ---------------------------------------------------------------------------
901
+
902
+ describe("resolveDecoPage — deferral parity between SSR and client nav", () => {
892
903
  const lazySec = {
893
904
  __resolveType: WELL_KNOWN_TYPES.LAZY,
894
905
  section: { __resolveType: "site/sections/Hero.tsx" },
895
906
  };
907
+ const eagerSec = { __resolveType: "site/sections/Banner.tsx" };
896
908
 
897
909
  beforeEach(() => {
898
- // Enable async rendering so useAsync can be true for SSR requests.
910
+ // Enable async rendering so useAsync can be true.
899
911
  setAsyncRenderingConfig({ foldThreshold: Infinity, respectCmsLazy: true });
900
912
  // resolveSectionShallow unwraps the ⚡ and looks up the inner key via
901
913
  // getSection — return truthy so it produces a DeferredSection rather than
902
914
  // falling back to eager resolution.
903
915
  (getSection as ReturnType<typeof vi.fn>).mockReturnValue({ default: () => null });
904
- // Return a page with one CMS ⚡-wrapped section.
916
+ // A page with one plain section followed by one CMS ⚡-wrapped section.
905
917
  (findPageByPath as ReturnType<typeof vi.fn>).mockReturnValue({
906
- page: { name: "test", sections: [lazySec] },
918
+ page: { name: "test", sections: [eagerSec, lazySec] },
907
919
  params: {},
908
920
  blockKey: "test-page",
909
921
  });
@@ -914,15 +926,178 @@ describe("resolveDecoPage — #277 client-side navigation disables deferral", ()
914
926
  (findPageByPath as ReturnType<typeof vi.fn>).mockReset();
915
927
  });
916
928
 
917
- it("SSR request defers a CMS ⚡ section", async () => {
929
+ it("SSR request defers the CMS ⚡ section and keeps the plain one eager", async () => {
918
930
  const result = await resolveDecoPage("/product/foo", {});
919
931
  expect(result?.deferredSections).toHaveLength(1);
920
932
  expect(result?.deferredSections[0].component).toBe("site/sections/Hero.tsx");
933
+ expect(result?.resolvedSections.map((s) => s.component)).toEqual(["site/sections/Banner.tsx"]);
934
+ });
935
+
936
+ it("client nav produces the IDENTICAL split — deferral is not disabled", async () => {
937
+ const ssr = await resolveDecoPage("/product/foo", {});
938
+ const nav = await resolveDecoPage("/product/foo", { isClientNavigation: true });
939
+
940
+ // The acceptance criterion: client nav returns deferredSections and awaits
941
+ // only the eager set, exactly like SSR.
942
+ expect(nav?.deferredSections).toHaveLength(1);
943
+ expect(nav?.deferredSections.map((d) => d.component)).toEqual(
944
+ ssr?.deferredSections.map((d) => d.component),
945
+ );
946
+ expect(nav?.resolvedSections.map((s) => s.component)).toEqual(
947
+ ssr?.resolvedSections.map((s) => s.component),
948
+ );
921
949
  });
922
950
 
923
- it("client-nav (isClientNavigation: true) resolves the ⚡ section eagerly — empty deferredSections", async () => {
924
- const result = await resolveDecoPage("/product/foo", { isClientNavigation: true });
951
+ it("the ⚡ section's index is preserved on client nav (ordering on the merge)", async () => {
952
+ const nav = await resolveDecoPage("/product/foo", { isClientNavigation: true });
953
+ expect(nav?.deferredSections[0].index).toBe(1);
954
+ });
955
+
956
+ it("bots stay fully eager on a client-nav-flagged request (SEO guarantee)", async () => {
957
+ const result = await resolveDecoPage("/product/foo", {
958
+ isClientNavigation: true,
959
+ userAgent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
960
+ });
961
+ expect(result?.deferredSections).toHaveLength(0);
962
+ expect(result?.resolvedSections).toHaveLength(2);
963
+ });
964
+
965
+ it("?__deco_ssr=1 stays fully eager on a client-nav-flagged request", async () => {
966
+ const result = await resolveDecoPage("/product/foo", {
967
+ isClientNavigation: true,
968
+ url: "https://store.com/product/foo?__deco_ssr=1",
969
+ });
925
970
  expect(result?.deferredSections).toHaveLength(0);
971
+ expect(result?.resolvedSections).toHaveLength(2);
972
+ });
973
+
974
+ it("a genuine programmatic fetch (Sec-Fetch-Dest: empty, no client-nav flag) stays eager", async () => {
975
+ const result = await resolveDecoPage("/product/foo", {
976
+ request: new Request("https://store.com/product/foo", {
977
+ headers: { "sec-fetch-dest": "empty" },
978
+ }),
979
+ });
980
+ expect(result?.deferredSections).toHaveLength(0);
981
+ });
982
+ });
983
+
984
+ // ---------------------------------------------------------------------------
985
+ // #277 — per-request context survives the deferred second hop
986
+ // ---------------------------------------------------------------------------
987
+ //
988
+ // This is the invariant that made it safe to re-enable deferral on client nav.
989
+ // #277 reported deferred sections rendering blank because their loaders "lost"
990
+ // per-request app context. The guarantee is that `resolveDeferredSectionFull`
991
+ // (and `loadDeferredSection`, which wraps it in @decocms/tanstack) threads the
992
+ // caller's MatcherContext — cookies, url, path, userAgent, request — into BOTH
993
+ // the cache-hit and the cache-miss (`reExtractRawProps`) branch. A different
994
+ // isolate misses the in-process rawProps Map, so the miss path is the one that
995
+ // actually runs in production on Cloudflare Workers; if it ever stops receiving
996
+ // matcherCtx, cookie-dependent loaders silently resolve against an anonymous
997
+ // request. Do not relax these assertions.
998
+
999
+ describe("#277 — deferred second hop keeps per-request context", () => {
1000
+ const PROBE = "test/matchers/probe.ts";
1001
+ const CTX: MatcherContext & { request: Request } = {
1002
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Mobile Safari",
1003
+ url: "https://store.com/product/foo?utm=x",
1004
+ path: "/product/foo",
1005
+ cookies: { deco_segment: "abc", VtexIdclientAutCookie: "tok" },
1006
+ request: new Request("https://store.com/product/foo?utm=x"),
1007
+ };
1008
+
1009
+ /** Every MatcherContext the probe matcher was evaluated with. */
1010
+ let seen: MatcherContext[] = [];
1011
+
1012
+ // The ⚡ section sits behind a multivariate flag whose rule is the probe
1013
+ // matcher. Reaching the inner Shelf at all therefore PROVES the resolver
1014
+ // evaluated the rule, and the probe records exactly which context it saw —
1015
+ // an end-to-end assertion rather than a mock of the call site.
1016
+ const gatedLazy = {
1017
+ __resolveType: WELL_KNOWN_TYPES.MULTIVARIATE,
1018
+ variants: [
1019
+ {
1020
+ rule: { __resolveType: PROBE },
1021
+ value: {
1022
+ __resolveType: WELL_KNOWN_TYPES.LAZY,
1023
+ section: { __resolveType: "site/sections/Shelf.tsx", title: "Mais vendidos" },
1024
+ },
1025
+ },
1026
+ ],
1027
+ };
1028
+
1029
+ beforeEach(() => {
1030
+ seen = [];
1031
+ registerMatcher(PROBE, (_rule, ctx) => {
1032
+ seen.push(ctx);
1033
+ return true;
1034
+ });
1035
+ setAsyncRenderingConfig({ foldThreshold: Infinity, respectCmsLazy: true });
1036
+ (getSection as ReturnType<typeof vi.fn>).mockReturnValue({ default: () => null });
1037
+ (findPageByPath as ReturnType<typeof vi.fn>).mockReturnValue({
1038
+ page: { name: "test", sections: [gatedLazy] },
1039
+ params: {},
1040
+ blockKey: "test-page",
1041
+ });
1042
+ (runSingleSectionLoader as ReturnType<typeof vi.fn>).mockImplementation(
1043
+ async (s: unknown) => s,
1044
+ );
1045
+ });
1046
+
1047
+ afterEach(() => {
1048
+ (getSection as ReturnType<typeof vi.fn>).mockReset();
1049
+ (findPageByPath as ReturnType<typeof vi.fn>).mockReset();
1050
+ (runSingleSectionLoader as ReturnType<typeof vi.fn>).mockReset();
1051
+ });
1052
+
1053
+ it("reExtractRawProps (cross-isolate cache miss) resolves with the caller's cookies/UA/url", async () => {
1054
+ // A cold isolate never populated the in-process rawProps Map, so this is
1055
+ // the branch that actually runs in production on Cloudflare Workers.
1056
+ const rawProps = await reExtractRawProps("/product/foo", "site/sections/Shelf.tsx", 0, CTX);
1057
+
1058
+ expect(rawProps).toMatchObject({ title: "Mais vendidos" });
1059
+ expect(seen).not.toHaveLength(0);
1060
+ for (const ctx of seen) {
1061
+ expect(ctx.cookies).toEqual(CTX.cookies);
1062
+ expect(ctx.userAgent).toBe(CTX.userAgent);
1063
+ expect(ctx.url).toBe(CTX.url);
1064
+ expect(ctx.request).toBe(CTX.request);
1065
+ }
1066
+ });
1067
+
1068
+ it("resolveDeferredSectionFull on a cold cache still resolves + enriches the section", async () => {
1069
+ const ds = {
1070
+ component: "site/sections/Shelf.tsx",
1071
+ index: 0,
1072
+ props: {},
1073
+ } as unknown as DeferredSection;
1074
+
1075
+ const section = await resolveDeferredSectionFull(ds, "/product/foo", CTX.request, CTX);
1076
+
1077
+ expect(section).not.toBeNull();
1078
+ expect(section?.component).toBe("site/sections/Shelf.tsx");
1079
+ expect(section?.index).toBe(0);
1080
+ // The deferred hop must run the section's own loader — this is what #277
1081
+ // reported as missing, and it is what makes the second hop equivalent to
1082
+ // eager resolution. Scope note: this asserts the request THIS function was
1083
+ // handed reaches the loader. `loadDeferredSection` in @decocms/tanstack
1084
+ // constructs its own `new Request(pageUrl || serverUrl, { headers })` before
1085
+ // calling in, so the fidelity of that reconstruction is a separate concern
1086
+ // and is not covered here.
1087
+ expect(runSingleSectionLoader).toHaveBeenCalled();
1088
+ const [, passedRequest] = (runSingleSectionLoader as ReturnType<typeof vi.fn>).mock
1089
+ .calls[0] as [unknown, Request];
1090
+ expect(passedRequest).toBe(CTX.request);
1091
+ });
1092
+
1093
+ it("a context-free second hop is observably different — guards against dropping matcherCtx", async () => {
1094
+ // If reExtractRawProps ever stops threading matcherCtx, cookie/UA-gated
1095
+ // variants silently resolve against an anonymous request. Assert the probe
1096
+ // can actually tell the two apart, so the test above is not vacuous.
1097
+ await reExtractRawProps("/product/foo", "site/sections/Shelf.tsx", 0, undefined);
1098
+ expect(seen).not.toHaveLength(0);
1099
+ expect(seen[0].cookies).toBeUndefined();
1100
+ expect(seen[0].userAgent).toBeUndefined();
926
1101
  });
927
1102
  });
928
1103
 
@@ -310,9 +310,35 @@ export function getDeferredRawProps(
310
310
  // Bot detection — bots always receive fully eager pages for SEO
311
311
  // ---------------------------------------------------------------------------
312
312
 
313
- const botPatterns: RegExp[] = [
314
- /bot|crawl|spider|slurp|facebookexternalhit|mediapartners|google|bing|yandex|baidu|duckduck|teoma|ia_archiver|semrush|ahrefs|lighthouse/i,
315
- ];
313
+ /**
314
+ * User-Agent substrings that classify a request as a bot.
315
+ *
316
+ * Exported as data, not just baked into the regex, because the CDN cache rules
317
+ * have to bypass exactly the same requests this list makes eager — see
318
+ * `@decocms/blocks-cli/scripts/cdn-rules.ts`. Two hand-maintained copies would
319
+ * drift, and the failure mode is a crawler's eager HTML (~10x larger) being
320
+ * served to humans from the CDN, or the deferred one being served to Google.
321
+ */
322
+ export const BOT_UA_SUBSTRINGS = [
323
+ "bot",
324
+ "crawl",
325
+ "spider",
326
+ "slurp",
327
+ "facebookexternalhit",
328
+ "mediapartners",
329
+ "google",
330
+ "bing",
331
+ "yandex",
332
+ "baidu",
333
+ "duckduck",
334
+ "teoma",
335
+ "ia_archiver",
336
+ "semrush",
337
+ "ahrefs",
338
+ "lighthouse",
339
+ ] as const;
340
+
341
+ const botPatterns: RegExp[] = [new RegExp(BOT_UA_SUBSTRINGS.join("|"), "i")];
316
342
 
317
343
  /**
318
344
  * Add a custom bot detection regex.
@@ -362,9 +388,10 @@ function hasForceEagerParam(ctx?: MatcherContext): boolean {
362
388
  * (`image`/`script`/`style`/`font`) do not — navigations stay deferred because
363
389
  * they CAN hydrate and resolve deferred sections. SPA navigations (TanStack
364
390
  * `<Link>` → `/_serverFn`) also send `empty` but set `isClientNavigation`, and
365
- * are excluded here so page-SEO commerce loaders stay off for humans
366
- * (decocms/blocks#286); their sections already render eagerly via the
367
- * `!isClientNav` branch of the `useAsync` gate.
391
+ * are excluded here on both counts: page-SEO commerce loaders stay off for
392
+ * humans (decocms/blocks#286), and their sections stay deferred, because a
393
+ * client nav renders through `DecoPageRenderer` and therefore CAN resolve a
394
+ * deferred section on scroll — unlike a real AJAX consumer.
368
395
  *
369
396
  * Like {@link hasForceEagerParam}, this must stay in lock-step with the edge
370
397
  * cache key: `workerEntry` keys `Sec-Fetch-Dest: empty` page requests into a
@@ -421,11 +448,15 @@ export interface MatcherContext {
421
448
  */
422
449
  flags?: StoredFlag[];
423
450
  /**
424
- * Client-side (SPA) navigation via TanStack `<Link>`. Disables section
425
- * deferral: deferral is a streaming-SSR optimization, but a client nav
426
- * receives the server-fn JSON in one shot, so deferral adds a round-trip +
427
- * skeleton with no benefit (and breaks loaders that need per-request app
428
- * context — see decocms/blocks#277). Set by the route loaders.
451
+ * Client-side (SPA) navigation via TanStack `<Link>`. Set by the route
452
+ * loaders, where the incoming request URL is the `/_serverFn/...` endpoint
453
+ * rather than the page being navigated to.
454
+ *
455
+ * Consumed by `derivePageUrl` (rebuilding the real page URL, #280) and by
456
+ * {@link isProgrammaticFetch} (a SPA nav sends `Sec-Fetch-Dest: empty` but is
457
+ * NOT an AJAX consumer). It deliberately does NOT affect section deferral —
458
+ * a TanStack route loader is blocking, so eager-resolving below-fold sections
459
+ * on client nav stalls the whole transition. See `resolveDecoPageImpl`.
429
460
  */
430
461
  isClientNavigation?: boolean;
431
462
  }
@@ -1497,6 +1528,15 @@ function isCmsDeferralWrapped(section: unknown, matcherCtx?: MatcherContext): bo
1497
1528
  * then they can only force a NON-⚡ section eager — they never override the
1498
1529
  * editor's ⚡ choice.
1499
1530
  *
1531
+ * Because deferral now applies to client (SPA) navigations too, a ⚡ section is
1532
+ * resolved on a SECOND server hop (`loadDeferredSection`) in both cases. That
1533
+ * hop rebuilds MatcherContext from the real request, but it cannot reconstruct
1534
+ * "which branch of the page renders at all" — so a *gate* section (one whose
1535
+ * loader picks between `children`/`fallback`, e.g. a combined PDP/PLP route)
1536
+ * must be left un-⚡ in the admin. There is deliberately no code-level override
1537
+ * for this: `neverDefer`/`alwaysEager` sit below the admin check and cannot win
1538
+ * against an explicit editorial ⚡ (see decocms/blocks#277).
1539
+ *
1500
1540
  * Exported for unit testing.
1501
1541
  */
1502
1542
  export function shouldDeferSection(
@@ -1982,13 +2022,32 @@ async function resolveDecoPageImpl(
1982
2022
  }
1983
2023
 
1984
2024
  const isBotReq = isEagerRequest(matcherCtx);
1985
- // SPA navigation (TanStack <Link>) receives the server-fn JSON in one shot —
1986
- // there is no HTTP streaming, so deferral adds a round-trip + skeleton with
1987
- // no benefit (and breaks loaders that need per-request app context, #277).
1988
- // Resolve everything eagerly on client nav; SSR/bots keep deferral.
1989
- const isClientNav = matcherCtx?.isClientNavigation ?? false;
2025
+ // Deferral applies identically to SSR documents and SPA navigations. A
2026
+ // TanStack route loader is BLOCKING: the router does not commit the
2027
+ // transition until the loader promise settles, so resolving every
2028
+ // below-the-fold section eagerly on client nav freezes the previous page for
2029
+ // as long as the slowest shelf/upstream takes (measured: 2.7s and a 3.4MB
2030
+ // payload on a real PDP — worse than a full reload). Deferral's job is
2031
+ // decoupling first paint from below-fold data, which matters MORE here, not
2032
+ // less.
2033
+ //
2034
+ // #277 (deferred loaders missing per-request app context) is not a reason to
2035
+ // disable deferral on client nav: the deferred second hop is the same
2036
+ // `loadDeferredSection` server fn the SSR path has always used, and it runs
2037
+ // server-side with matcherCtx rebuilt from the real request
2038
+ // (url/path/cookies/request). It may well land in a DIFFERENT isolate — that
2039
+ // is exactly why `reExtractRawProps` exists as the rawProps cache-miss path —
2040
+ // but per-request state is reconstructed from the request either way, so a
2041
+ // client nav is no more exposed than an SSR document already was. A section
2042
+ // whose loader genuinely cannot be resolved on a second hop (e.g. a PDP/PLP
2043
+ // gate that decides what renders at all) must not be marked ⚡ in the admin;
2044
+ // see `shouldDeferSection`.
2045
+ //
2046
+ // `isClientNavigation` is still load-bearing for `derivePageUrl` (duplicate
2047
+ // query params) and for `isProgrammaticFetch` (SPA `Sec-Fetch-Dest: empty`
2048
+ // must not be mistaken for an AJAX call) — it just no longer gates deferral.
1990
2049
  const currentAsyncConfig = getAsyncConfig();
1991
- const useAsync = currentAsyncConfig !== null && !isBotReq && !isClientNav;
2050
+ const useAsync = currentAsyncConfig !== null && !isBotReq;
1992
2051
 
1993
2052
  const eagerResults: (ResolvedSection[] | Promise<ResolvedSection[]>)[] = [];
1994
2053
  const deferredSections: DeferredSection[] = [];