@decocms/tanstack 7.21.1 → 7.22.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/tanstack",
3
- "version": "7.21.1",
3
+ "version": "7.22.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -24,9 +24,9 @@
24
24
  "lint:unused": "knip"
25
25
  },
26
26
  "dependencies": {
27
- "@decocms/blocks": "7.21.1",
28
- "@decocms/blocks-admin": "7.21.1",
29
- "@decocms/blocks-cli": "7.21.1",
27
+ "@decocms/blocks": "7.22.0",
28
+ "@decocms/blocks-admin": "7.22.0",
29
+ "@decocms/blocks-cli": "7.22.0",
30
30
  "@deco-cx/warp-node": "^0.3.16",
31
31
  "fast-json-patch": "^3.1.0",
32
32
  "ws": "^8.18.0"
@@ -0,0 +1,63 @@
1
+ import { registerCacheableSections, registerSectionLoader } from "@decocms/blocks/cms";
2
+ import type { ResolvedSection } from "@decocms/blocks/cms";
3
+ import { describe, expect, it } from "vitest";
4
+ import { runSectionLoadersWithSeo } from "./cmsRoute";
5
+
6
+ /**
7
+ * Regression guard for #355: body sections and the SEO section used to be
8
+ * resolved sequentially (`runSectionLoaders` for the body, then a separate
9
+ * `runSingleSectionLoader` call for the SEO section inside `buildPageSeo`).
10
+ * On a PDP, both commonly resolve the SAME product via the SAME commerce
11
+ * loader — sequential calls always missed the fetch-cache layer's in-flight
12
+ * dedup (the body call had already completed by the time the SEO call
13
+ * started). Batching them into one `runSectionLoaders` call (this file's
14
+ * `runSectionLoadersWithSeo`) lets identical concurrent requests share one
15
+ * origin round-trip.
16
+ *
17
+ * This test proves the batching by registering a *cacheable* section loader
18
+ * (the same SWR + in-flight-dedup mechanism real commerce loaders use) and
19
+ * asserting it's invoked only once when the body section and SEO section
20
+ * share the same component + props.
21
+ */
22
+ describe("runSectionLoadersWithSeo (#355)", () => {
23
+ it("dedupes a same-component/same-props SEO section against a body section via the cacheable-section in-flight cache", async () => {
24
+ const component = `test/sections/DedupCheck-${Date.now()}.tsx`;
25
+ let calls = 0;
26
+
27
+ registerCacheableSections({ [component]: { maxAge: 60_000 } });
28
+ registerSectionLoader(component, async (props) => {
29
+ calls++;
30
+ await new Promise((r) => setTimeout(r, 5));
31
+ return { ...props, loaded: true };
32
+ });
33
+
34
+ const bodySection: ResolvedSection = { component, props: { sku: "123" }, key: component, index: 0 };
35
+ const seoSection: ResolvedSection = { component, props: { sku: "123" }, key: `${component}-seo`, index: -1 };
36
+
37
+ const request = new Request("https://store.com/produto/123");
38
+ const { enrichedSections, enrichedSeoSection } = await runSectionLoadersWithSeo(
39
+ [bodySection],
40
+ seoSection,
41
+ request,
42
+ );
43
+
44
+ expect(calls).toBe(1);
45
+ expect((enrichedSections[0].props as any).loaded).toBe(true);
46
+ expect((enrichedSeoSection?.props as any).loaded).toBe(true);
47
+ });
48
+
49
+ it("runs plain runSectionLoaders when there is no SEO section", async () => {
50
+ const component = `test/sections/NoSeo-${Date.now()}.tsx`;
51
+ registerSectionLoader(component, async (props) => ({ ...props, loaded: true }));
52
+
53
+ const bodySection: ResolvedSection = { component, props: {}, key: component, index: 0 };
54
+ const { enrichedSections, enrichedSeoSection } = await runSectionLoadersWithSeo(
55
+ [bodySection],
56
+ null,
57
+ new Request("https://store.com/"),
58
+ );
59
+
60
+ expect((enrichedSections[0].props as any).loaded).toBe(true);
61
+ expect(enrichedSeoSection).toBeNull();
62
+ });
63
+ });
@@ -124,6 +124,36 @@ function persistFlags(matcherCtx: MatcherContext): StoredFlag[] {
124
124
  return recorded;
125
125
  }
126
126
 
127
+ /**
128
+ * Run the body sections' loaders and the SEO section's loader in the SAME
129
+ * batch (`Promise.all` via `runSectionLoaders`), instead of sequentially
130
+ * (body first, then `buildPageSeo` separately awaiting the SEO section).
131
+ *
132
+ * On a PDP, the SEO section (e.g. SeoPDP) commonly resolves the same
133
+ * product as a body section via the same commerce loader. Running them
134
+ * sequentially meant the SEO fetch always missed any in-flight request
135
+ * dedup at the fetch-cache layer (apps-vtex's fetchWithCache) — the body
136
+ * fetch had already completed and left the in-flight map by the time the
137
+ * SEO fetch started, so the origin got hit twice per navigation (#355).
138
+ * Batching them gives concurrent identical requests a chance to share one
139
+ * origin round-trip.
140
+ */
141
+ /** @internal exported for tests */
142
+ export async function runSectionLoadersWithSeo(
143
+ resolvedSections: ResolvedSection[],
144
+ seoSection: ResolvedSection | null | undefined,
145
+ request: Request,
146
+ ): Promise<{ enrichedSections: ResolvedSection[]; enrichedSeoSection: ResolvedSection | null | undefined }> {
147
+ if (!seoSection) {
148
+ return { enrichedSections: await runSectionLoaders(resolvedSections, request), enrichedSeoSection: seoSection };
149
+ }
150
+ const enrichedAll = await runSectionLoaders([...resolvedSections, seoSection], request);
151
+ return {
152
+ enrichedSections: enrichedAll.slice(0, -1),
153
+ enrichedSeoSection: enrichedAll[enrichedAll.length - 1],
154
+ };
155
+ }
156
+
127
157
  async function loadCmsPageInternal(fullPath: string) {
128
158
  const [basePath] = fullPath.split("?");
129
159
  // On client-side navigation getRequestUrl() is the /_serverFn/... endpoint,
@@ -150,13 +180,14 @@ async function loadCmsPageInternal(fullPath: string) {
150
180
  headers: originRequest.headers,
151
181
  });
152
182
 
153
- // Resolve page sections and site globals in parallel. Globals are merged
154
- // into the same `resolvedSections` array so the path through this server
155
- // function is identical for SSR (F5) and SPA (<Link>) navigations — see
156
- // #233 for the previous SPA-breakage when `withSiteGlobals` ran client-side
157
- // and saw an empty client-bundled `blocks.gen.ts`.
158
- const [enrichedSections, globals] = await Promise.all([
159
- runSectionLoaders(page.resolvedSections, request),
183
+ // Resolve page sections (+ the SEO section, batched together — see #355)
184
+ // and site globals in parallel. Globals are merged into the same
185
+ // `resolvedSections` array so the path through this server function is
186
+ // identical for SSR (F5) and SPA (<Link>) navigations — see #233 for the
187
+ // previous SPA-breakage when `withSiteGlobals` ran client-side and saw an
188
+ // empty client-bundled `blocks.gen.ts`.
189
+ const [{ enrichedSections, enrichedSeoSection }, globals] = await Promise.all([
190
+ runSectionLoadersWithSeo(page.resolvedSections, page.seoSection, request),
160
191
  resolveSiteGlobals(),
161
192
  ]);
162
193
 
@@ -178,7 +209,7 @@ async function loadCmsPageInternal(fullPath: string) {
178
209
  const device = detectDevice(ua);
179
210
 
180
211
  // Build SEO: merge page-level seo block (primary) with section-contributed SEO (secondary)
181
- const seo = await buildPageSeo(page.seoSection, mergedSections, request);
212
+ const seo = buildPageSeo(page.seoSection, enrichedSeoSection, mergedSections);
182
213
 
183
214
  // Destructure seoSection out — it's an internal artifact, not serialized to client
184
215
  const { seoSection: _seo, ...pageData } = page;
@@ -241,8 +272,8 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async (
241
272
  const page = await resolveDecoPage("/", matcherCtx);
242
273
  if (!page) return null;
243
274
  const flags = persistFlags(matcherCtx);
244
- const [enrichedSections, globals] = await Promise.all([
245
- runSectionLoaders(page.resolvedSections, request),
275
+ const [{ enrichedSections, enrichedSeoSection }, globals] = await Promise.all([
276
+ runSectionLoadersWithSeo(page.resolvedSections, page.seoSection, request),
246
277
  resolveSiteGlobals(),
247
278
  ]);
248
279
 
@@ -255,7 +286,7 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async (
255
286
  await preloadSectionComponents(eagerKeys);
256
287
 
257
288
  const device = detectDevice(ua);
258
- const seo = await buildPageSeo(page.seoSection, mergedSections, request);
289
+ const seo = buildPageSeo(page.seoSection, enrichedSeoSection, mergedSections);
259
290
 
260
291
  const { seoSection: _seo, ...pageData } = page;
261
292
 
@@ -432,15 +463,26 @@ type CmsPageLoaderData = {
432
463
  // ---------------------------------------------------------------------------
433
464
 
434
465
  /**
435
- * Process the resolved SEO section from page.seo, run its section loader
436
- * if registered, apply title/description templates, and merge with
437
- * section-contributed SEO.
466
+ * Process the resolved SEO section from page.seo, apply title/description
467
+ * templates, and merge with section-contributed SEO.
468
+ *
469
+ * `seoSection` is the raw (pre-loader) resolved section — its `props` are
470
+ * used for the CMS-configured `titleTemplate`/`descriptionTemplate` fields,
471
+ * which a loader transforming `{jsonLD} → {title, description}` may not
472
+ * pass through unchanged. `enrichedSeoSection` must already have gone
473
+ * through that registered section loader (e.g., SEOPDP transforms
474
+ * {jsonLD: ProductDetailsPage} → {title, description, ...}) — batched
475
+ * together with the body sections in `runSectionLoadersWithSeo` so a
476
+ * commerce loader shared by both (the same product fetched by both the PDP
477
+ * body section and the SeoPDP section) gets a chance to dedupe at the
478
+ * fetch-cache layer instead of resolving the page twice (#355). This
479
+ * function itself no longer runs any loader.
438
480
  */
439
- async function buildPageSeo(
481
+ function buildPageSeo(
440
482
  seoSection: ResolvedSection | null | undefined,
483
+ enrichedSeoSection: ResolvedSection | null | undefined,
441
484
  enrichedSections: ResolvedSection[],
442
- request: Request,
443
- ): Promise<PageSeo> {
485
+ ): PageSeo {
444
486
  // Secondary source: SEO sections embedded in the sections array
445
487
  const sectionSeo = extractSeoFromSections(enrichedSections);
446
488
 
@@ -472,16 +514,11 @@ async function buildPageSeo(
472
514
  return merged;
473
515
  }
474
516
 
475
- // Run the section loader on the seo section if one is registered
476
- // (e.g., SEOPDP loader transforms {jsonLD: ProductDetailsPage} → {title, description, ...})
477
- let enrichedProps = seoSection.props;
478
- try {
479
- const enriched = await runSingleSectionLoader(seoSection, request);
480
- if (enriched) enrichedProps = enriched.props;
481
- } catch {
482
- // Section loader failed — use the raw resolved props
483
- }
484
-
517
+ // enrichedSeoSection should always be present once seoSection is, but
518
+ // fall back to the raw section's props (pre-loader) if the loader batch
519
+ // somehow dropped it — same "use the raw resolved props" resilience the
520
+ // old inline try/catch had around the loader call.
521
+ const enrichedProps = (enrichedSeoSection ?? seoSection).props;
485
522
  const pageSeo = extractSeoFromProps(enrichedProps);
486
523
 
487
524
  // Replicate original SeoV2 loader logic: `_title ?? appTitle`
@@ -0,0 +1,106 @@
1
+ import { EventEmitter } from "node:events";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ const execCalls = [];
8
+ const execFileSyncCalls = [];
9
+
10
+ vi.mock("node:child_process", () => {
11
+ const exec = (cmd, _opts, cb) => {
12
+ execCalls.push(cmd);
13
+ cb(null, "", "");
14
+ };
15
+ const execFileSync = (cmd, args) => {
16
+ execFileSyncCalls.push([cmd, args]);
17
+ return "";
18
+ };
19
+ return { exec, execFileSync, default: { exec, execFileSync } };
20
+ });
21
+
22
+ /**
23
+ * Regression guard for #371: `vite dev` never re-ran the sections/loaders/
24
+ * invoke generators, only `npm run build` did (via the full `generate`
25
+ * orchestrator) — so editing a section/loader's TS interface left the
26
+ * `.gen` files silently stale until a manual `generate` run. This test
27
+ * exercises `configureServer`'s scaffold-gen hook against a real
28
+ * (empty) tmp dir + a real EventEmitter as `server.watcher`, with
29
+ * `node:child_process` mocked so no real subprocess is spawned.
30
+ */
31
+ describe("decoVitePlugin — sections/loaders/invoke dev regeneration (#371)", () => {
32
+ let tmp;
33
+ let cwdSpy;
34
+ let plugin;
35
+
36
+ beforeEach(async () => {
37
+ vi.resetModules();
38
+ execCalls.length = 0;
39
+ execFileSyncCalls.length = 0;
40
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-scaffold-gen-"));
41
+ cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(tmp);
42
+ vi.useFakeTimers();
43
+ ({ decoVitePlugin: plugin } = await import("./plugin.js"));
44
+ });
45
+
46
+ afterEach(() => {
47
+ vi.useRealTimers();
48
+ cwdSpy.mockRestore();
49
+ fs.rmSync(tmp, { recursive: true, force: true });
50
+ vi.restoreAllMocks();
51
+ });
52
+
53
+ function makeServer() {
54
+ return {
55
+ watcher: new EventEmitter(),
56
+ config: { define: {} },
57
+ environments: undefined,
58
+ httpServer: { once: () => {} },
59
+ };
60
+ }
61
+
62
+ it("runs the sections/loaders/invoke generator on dev startup (debounced)", async () => {
63
+ const p = Array.isArray(plugin()) ? plugin()[0] : plugin();
64
+ const server = makeServer();
65
+ p.configureServer(server);
66
+
67
+ await vi.advanceTimersByTimeAsync(600);
68
+
69
+ const scaffoldCalls = execCalls.filter((c) => c.includes("--only sections,loaders,invoke"));
70
+ expect(scaffoldCalls).toHaveLength(1);
71
+ expect(scaffoldCalls[0]).toContain("generate.ts");
72
+ });
73
+
74
+ it("debounces multiple src/ file-watcher events into a single regen call", async () => {
75
+ const p = Array.isArray(plugin()) ? plugin()[0] : plugin();
76
+ const server = makeServer();
77
+ p.configureServer(server);
78
+
79
+ // Consume the cold-start call first.
80
+ await vi.advanceTimersByTimeAsync(600);
81
+ execCalls.length = 0;
82
+
83
+ const srcFile = path.join(tmp, "src", "sections", "Foo.tsx");
84
+ server.watcher.emit("change", srcFile);
85
+ server.watcher.emit("change", srcFile);
86
+ server.watcher.emit("add", srcFile);
87
+
88
+ await vi.advanceTimersByTimeAsync(600);
89
+
90
+ const scaffoldCalls = execCalls.filter((c) => c.includes("--only sections,loaders,invoke"));
91
+ expect(scaffoldCalls).toHaveLength(1);
92
+ });
93
+
94
+ it("does not trigger for a file outside src/", async () => {
95
+ const p = Array.isArray(plugin()) ? plugin()[0] : plugin();
96
+ const server = makeServer();
97
+ p.configureServer(server);
98
+ await vi.advanceTimersByTimeAsync(600);
99
+ execCalls.length = 0;
100
+
101
+ server.watcher.emit("change", path.join(tmp, "README.md"));
102
+ await vi.advanceTimersByTimeAsync(600);
103
+
104
+ expect(execCalls.filter((c) => c.includes("--only sections,loaders,invoke"))).toHaveLength(0);
105
+ });
106
+ });
@@ -552,6 +552,71 @@ export function decoVitePlugin() {
552
552
  if (isSchemaSource(file)) scheduleSchemaGen();
553
553
  });
554
554
 
555
+ // --- sections.gen.ts / loaders.gen.ts / invoke.gen.ts regeneration (#371) ---
556
+ // Unlike meta.gen.json (regenerated above via generate-schema.ts
557
+ // directly), these three were never re-run in dev — only `npm run build`
558
+ // (which chains the full `generate` orchestrator) touched them. Editing
559
+ // a section/loader's TS interface (new prop, new @title) updated the TS
560
+ // source but left the `.gen` files stale until a manual `generate` run:
561
+ // typecheck passed and the runtime worked, but the studio/admin form
562
+ // silently kept showing the old schema.
563
+ //
564
+ // Reuses the SAME `generate.ts` orchestrator sites already run for
565
+ // `build`, restricted to --only sections,loaders,invoke so the schema
566
+ // regen above isn't duplicated. The orchestrator's own two-tier digest
567
+ // cache (.deco/generate.digests.json + .deco/.cache/stat-memo.json)
568
+ // makes a steady-state run (nothing changed) a cheap no-op — a cache
569
+ // miss only pays the cost for the generator(s) whose actual inputs
570
+ // changed. Best-effort: a failure here only warns (matches the schema
571
+ // regen's own error handling above) — dev keeps running off whatever
572
+ // was last generated, same as before this feature existed.
573
+ let scaffoldTimer = null;
574
+ let scaffoldInFlight = false;
575
+ let scaffoldQueued = false;
576
+ const runScaffoldGen = () => {
577
+ if (scaffoldInFlight) {
578
+ scaffoldQueued = true;
579
+ return;
580
+ }
581
+ scaffoldInFlight = true;
582
+ const start = Date.now();
583
+ const scriptPath = path.resolve(cwd, "node_modules/@decocms/blocks-cli/scripts/generate.ts");
584
+ const cmd = `npx tsx ${JSON.stringify(scriptPath)} --only sections,loaders,invoke --site ${schemaSiteName}`;
585
+ exec(cmd, { cwd }, (err) => {
586
+ scaffoldInFlight = false;
587
+ if (err) {
588
+ console.warn("[deco] sections/loaders/invoke generation failed:", err.message);
589
+ } else {
590
+ console.log(`[deco] sections/loaders/invoke checked (${Date.now() - start}ms)`);
591
+ }
592
+ if (scaffoldQueued) {
593
+ scaffoldQueued = false;
594
+ scheduleScaffoldGen();
595
+ }
596
+ });
597
+ };
598
+ const scheduleScaffoldGen = () => {
599
+ if (scaffoldTimer) clearTimeout(scaffoldTimer);
600
+ scaffoldTimer = setTimeout(() => {
601
+ scaffoldTimer = null;
602
+ runScaffoldGen();
603
+ }, 500);
604
+ };
605
+
606
+ // Cold-start: run once on dev startup. The digest cache makes this a
607
+ // fast no-op when nothing changed since the last `generate` run (e.g.
608
+ // right after a `build`) — fire-and-forget, doesn't block the first request.
609
+ scheduleScaffoldGen();
610
+
611
+ // Reuse the same src/**/*.{ts,tsx} watch predicate as the schema regen
612
+ // above — sections/loaders/actions all live under the same tree.
613
+ server.watcher.on("change", (file) => {
614
+ if (isSchemaSource(file)) scheduleScaffoldGen();
615
+ });
616
+ server.watcher.on("add", (file) => {
617
+ if (isSchemaSource(file)) scheduleScaffoldGen();
618
+ });
619
+
555
620
  // Tunnel + daemon: connect local dev to admin.deco.cx
556
621
  // Activated only when both DECO_SITE_NAME and DECO_ENV_NAME are set.
557
622
  // Omitting DECO_ENV_NAME runs Vite fully local (no tunnel registration),