@decocms/blocks-cli 7.14.0 → 7.15.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/blocks-cli",
3
- "version": "7.14.0",
3
+ "version": "7.15.0",
4
4
  "type": "module",
5
5
  "description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "lint:unused": "knip"
31
31
  },
32
32
  "dependencies": {
33
- "@decocms/blocks": "7.14.0",
33
+ "@decocms/blocks": "7.15.0",
34
34
  "ts-morph": "^27.0.0",
35
35
  "tsx": "^4.22.5"
36
36
  },
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Verifies the generated `.deco/loaders.gen.ts` shape: loaders route through
3
+ * `createLoaderEntry` (so their cache/cacheKey exports drive dedup) while
4
+ * actions stay plain pass-throughs (never cached/deduped).
5
+ *
6
+ * The script is spawned as a subprocess (`npx tsx generate-loaders.ts`) exactly
7
+ * how sites invoke it.
8
+ */
9
+ import * as cp from "node:child_process";
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
14
+
15
+ const SCRIPT = path.resolve(__dirname, "generate-loaders.ts");
16
+
17
+ function run(cwd: string): { stdout: string; stderr: string; code: number } {
18
+ const r = cp.spawnSync("npx", ["tsx", SCRIPT], { encoding: "utf8", cwd });
19
+ return { stdout: r.stdout || "", stderr: r.stderr || "", code: r.status ?? -1 };
20
+ }
21
+
22
+ describe("generate-loaders — loader vs action emit", () => {
23
+ let dir: string;
24
+ let out: string;
25
+
26
+ beforeAll(() => {
27
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-loaders-"));
28
+ const write = (rel: string, content: string) => {
29
+ const abs = path.join(dir, rel);
30
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
31
+ fs.writeFileSync(abs, content);
32
+ };
33
+ write("src/loaders/related.ts", "export default async () => [];\n");
34
+ write("src/actions/addToCart.ts", "export default async () => ({});\n");
35
+
36
+ const r = run(dir);
37
+ expect(r.code).toBe(0);
38
+ out = fs.readFileSync(path.join(dir, ".deco/loaders.gen.ts"), "utf8");
39
+ });
40
+
41
+ afterAll(() => {
42
+ fs.rmSync(dir, { recursive: true, force: true });
43
+ });
44
+
45
+ it("imports createLoaderEntry when any loader is present", () => {
46
+ expect(out).toContain(
47
+ 'import { createLoaderEntry } from "@decocms/blocks/sdk/cachedLoader";',
48
+ );
49
+ });
50
+
51
+ it("wraps loaders (both alias keys) with createLoaderEntry under the non-.ts name", () => {
52
+ expect(out).toContain(
53
+ '"site/loaders/related": createLoaderEntry("site/loaders/related", () => import(',
54
+ );
55
+ expect(out).toContain(
56
+ '"site/loaders/related.ts": createLoaderEntry("site/loaders/related", () => import(',
57
+ );
58
+ });
59
+
60
+ it("keeps actions as plain pass-throughs — never routed through createLoaderEntry", () => {
61
+ expect(out).toContain('"site/actions/addToCart": async (props: any, request?: Request) => {');
62
+ expect(out).not.toContain('createLoaderEntry("site/actions/addToCart"');
63
+ });
64
+ });
@@ -158,6 +158,12 @@ function isReferenced(key: string): boolean {
158
158
  interface LoaderEntry {
159
159
  key: string;
160
160
  importPath: string;
161
+ /**
162
+ * Loaders route through `createLoaderEntry` so their `cache`/`cacheKey`
163
+ * exports drive single-flight dedup. Actions MUST NOT be cached/deduped
164
+ * (they mutate) — they stay plain pass-throughs.
165
+ */
166
+ kind: "loader" | "action";
161
167
  }
162
168
 
163
169
  const entries: LoaderEntry[] = [];
@@ -175,6 +181,7 @@ for (const filePath of walkDir(loadersDir)) {
175
181
  entries.push({
176
182
  key,
177
183
  importPath: relativeImportPath(outFile, filePath),
184
+ kind: "loader",
178
185
  });
179
186
  }
180
187
 
@@ -190,33 +197,56 @@ for (const filePath of walkDir(actionsDir)) {
190
197
  entries.push({
191
198
  key,
192
199
  importPath: relativeImportPath(outFile, filePath),
200
+ kind: "action",
193
201
  });
194
202
  }
195
203
 
196
204
  entries.sort((a, b) => a.key.localeCompare(b.key));
197
205
 
206
+ const hasLoaderEntries = entries.some((e) => e.kind === "loader");
207
+
198
208
  const lines: string[] = [
199
209
  "// Auto-generated by @decocms/blocks-cli/scripts/generate-loaders.ts",
200
210
  "// Do not edit manually. Run `npm run generate:loaders` to update.",
201
211
  "//",
202
- "// Pass-through loader/action entries for COMMERCE_LOADERS.",
203
- "// Custom-wired entries should be excluded via --exclude and added manually in setup.ts.",
212
+ "// Loader entries route through createLoaderEntry so their cache/cacheKey",
213
+ "// exports drive single-flight dedup (concurrent identical calls in one",
214
+ "// render collapse to one upstream call). Actions stay plain pass-throughs",
215
+ "// (never cached/deduped). Custom-wired entries should be excluded via",
216
+ "// --exclude and added manually in setup.ts.",
204
217
  "",
205
- "export const siteLoaders: Record<string, (props: any, request?: Request) => Promise<any>> = {",
206
218
  ];
219
+ if (hasLoaderEntries) {
220
+ lines.push('import { createLoaderEntry } from "@decocms/blocks/sdk/cachedLoader";');
221
+ lines.push("");
222
+ }
223
+ lines.push(
224
+ "export const siteLoaders: Record<string, (props: any, request?: Request) => Promise<any>> = {",
225
+ );
207
226
 
208
227
  // Cast the dynamic-import default to `any` so legacy 3-arg
209
228
  // `(props, req, ctx)` Fresh/Deno loaders still type-check. Any ctx-dependent
210
229
  // path in the loader body throws at runtime and must be refactored.
211
230
  for (const entry of entries) {
212
- lines.push(` "${entry.key}": async (props: any, request?: Request) => {`);
213
- lines.push(` const mod = await import("${entry.importPath}");`);
214
- lines.push(" return (mod.default as any)(props, request);");
215
- lines.push(" },");
216
- lines.push(` "${entry.key}.ts": async (props: any, request?: Request) => {`);
217
- lines.push(` const mod = await import("${entry.importPath}");`);
218
- lines.push(" return (mod.default as any)(props, request);");
219
- lines.push(" },");
231
+ if (entry.kind === "loader") {
232
+ // Both alias keys share the same dedup namespace (the non-.ts name) so a
233
+ // render that references either collapses onto one in-flight call.
234
+ lines.push(
235
+ ` "${entry.key}": createLoaderEntry("${entry.key}", () => import("${entry.importPath}")),`,
236
+ );
237
+ lines.push(
238
+ ` "${entry.key}.ts": createLoaderEntry("${entry.key}", () => import("${entry.importPath}")),`,
239
+ );
240
+ } else {
241
+ lines.push(` "${entry.key}": async (props: any, request?: Request) => {`);
242
+ lines.push(` const mod = await import("${entry.importPath}");`);
243
+ lines.push(" return (mod.default as any)(props, request);");
244
+ lines.push(" },");
245
+ lines.push(` "${entry.key}.ts": async (props: any, request?: Request) => {`);
246
+ lines.push(` const mod = await import("${entry.importPath}");`);
247
+ lines.push(" return (mod.default as any)(props, request);");
248
+ lines.push(" },");
249
+ }
220
250
  }
221
251
 
222
252
  lines.push("};");
@@ -4,59 +4,19 @@ import type { TransformResult } from "../types";
4
4
  * Removes dead code patterns from the old Deco stack that don't work
5
5
  * in TanStack Start:
6
6
  *
7
- * - `export const cache = "stale-while-revalidate"` (old cache system)
8
- * - `export const cacheKey = ...` (old cache key generation)
9
7
  * - `crypto.subtle.digestSync(...)` (Deno-only sync API)
8
+ * - non-active platform branches (see `stripNonPlatformCode`)
10
9
  *
10
+ * NOTE: `export const cache` / `export const cacheKey` are KEPT — they are no
11
+ * longer dead. `@decocms/blocks` wires them via `createLoaderEntry` so
12
+ * `cache: "stale-while-revalidate"` drives single-flight dedup keyed by
13
+ * `cacheKey(props, req)` (the #339 N+1 fix). Only flagged in the notes so the
14
+ * author verifies the `(props, req)` signature.
11
15
  * NOTE: `export const loader` is kept — it's a server-side function the CMS calls.
12
16
  * NOTE: invoke.* calls are NOT migrated — they are RPC calls to the server
13
17
  * where the CMS config (API keys, etc.) is available. The runtime.ts invoke
14
18
  * proxy handles routing them to /deco/invoke/*.
15
19
  */
16
- /**
17
- * Remove an `export const <name> = ...` block using brace-counting
18
- * so nested `{}` (for loops, if/else) don't cause premature truncation.
19
- */
20
- function removeExportConstBlock(src: string, name: string): string {
21
- const pattern = new RegExp(`^export\\s+const\\s+${name}\\s*=`, "m");
22
- const match = pattern.exec(src);
23
- if (!match) return src;
24
-
25
- // Find the arrow `=>` first, then the opening `{` of the body.
26
- // This avoids matching destructuring braces in parameters like
27
- // `export const loader = ({ groups }: Props) => { ... }`
28
- let pos = match.index + match[0].length;
29
- // Look for `=>`
30
- const arrowIdx = src.indexOf("=>", pos);
31
- if (arrowIdx === -1) {
32
- // No arrow function — try simple brace from current position
33
- while (pos < src.length && src[pos] !== "{") pos++;
34
- } else {
35
- // Start searching for `{` after the arrow
36
- pos = arrowIdx + 2;
37
- while (pos < src.length && src[pos] !== "{") pos++;
38
- }
39
- if (pos >= src.length) return src; // no brace body, skip
40
-
41
- // Count braces to find the matching closing brace
42
- let depth = 0;
43
- const start = match.index;
44
- for (; pos < src.length; pos++) {
45
- if (src[pos] === "{") depth++;
46
- else if (src[pos] === "}") {
47
- depth--;
48
- if (depth === 0) {
49
- // Skip optional semicolon and trailing newline
50
- let end = pos + 1;
51
- if (end < src.length && src[end] === ";") end++;
52
- if (end < src.length && src[end] === "\n") end++;
53
- return src.slice(0, start) + src.slice(end);
54
- }
55
- }
56
- }
57
- return src; // unbalanced braces, don't touch
58
- }
59
-
60
20
  const ALL_PLATFORMS = ["vtex", "shopify", "linx", "vnda", "wake", "nuvemshop"];
61
21
 
62
22
  /**
@@ -169,36 +129,22 @@ export function transformDeadCode(content: string, platform?: string): Transform
169
129
  let changed = false;
170
130
  let result = content;
171
131
 
172
- // Remove old cache export: export const cache = "stale-while-revalidate" or { maxAge: ... }
132
+ // KEEP `export const cache` / `export const cacheKey`. These are NO LONGER
133
+ // dead: `@decocms/blocks` wires them via `createLoaderEntry` (generated in
134
+ // `.deco/loaders.gen.ts`) so `cache: "stale-while-revalidate"` opts the loader
135
+ // into single-flight dedup keyed by `cacheKey(props, req)`. Stripping them
136
+ // would silently reintroduce the #339 N+1 (same loader run once per section).
137
+ // Flag them so the author verifies the `(props, req)` signature — old
138
+ // `deco-cx/apps` `cacheKey(props, req, ctx)` loses its 3rd `ctx` arg here.
173
139
  if (/^export\s+const\s+cache\s*=/m.test(result)) {
174
- // String form: export const cache = "stale-while-revalidate";
175
- result = result.replace(
176
- /^export\s+const\s+cache\s*=\s*["'][^"']*["'];?\s*\n?/gm,
177
- "",
178
- );
179
- // Object form: export const cache = { maxAge: 60 * 10 };
180
- result = result.replace(
181
- /^export\s+const\s+cache\s*=\s*\{[^}]*\};?\s*\n?/gm,
182
- "",
140
+ notes.push(
141
+ "KEPT `export const cache` — now drives single-flight dedup via createLoaderEntry (run `generate` to wire it).",
183
142
  );
184
- // Multiline object form (use brace-counting)
185
- if (/^export\s+const\s+cache\s*=/m.test(result)) {
186
- result = removeExportConstBlock(result, "cache");
187
- }
188
- changed = true;
189
- notes.push("Removed dead `export const cache` (old caching system)");
190
143
  }
191
-
192
- // Remove old cacheKey export (can be multiline with brace-counting)
193
144
  if (/^export\s+const\s+cacheKey\s*=/m.test(result)) {
194
- result = removeExportConstBlock(result, "cacheKey");
195
- // Also handle simpler inline forms
196
- result = result.replace(
197
- /^export\s+const\s+cacheKey\s*=[^;]*;\s*\n?/gm,
198
- "",
145
+ notes.push(
146
+ "MANUAL: `export const cacheKey` is kept and now active — verify its signature is `(props, req)`; the old `ctx` 3rd arg is no longer passed.",
199
147
  );
200
- changed = true;
201
- notes.push("Removed dead `export const cacheKey` (old caching system)");
202
148
  }
203
149
 
204
150
  // NOTE: `export const loader` is kept — these are server-side functions