@decocms/blocks-cli 7.12.1 → 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.12.1",
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.12.1",
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("};");
@@ -1677,6 +1677,93 @@ const ruleVtexProxyHandlerMissing: Rule = {
1677
1677
  },
1678
1678
  };
1679
1679
 
1680
+ /* ------------------------------------------------------------------ */
1681
+ /* Rule 15 — module-level signal .value reads without useStore */
1682
+ /* ------------------------------------------------------------------ */
1683
+
1684
+ /**
1685
+ * Detects the "frozen UI" pattern: a module-level signal whose `.value`
1686
+ * is read inside a React component without a `useStore()` subscription.
1687
+ *
1688
+ * In Preact, `.value` reads are automatically reactive. In React they
1689
+ * are just synchronous property accesses — no subscription is registered,
1690
+ * so the component renders once with the initial value and never updates.
1691
+ *
1692
+ * Two-pass approach:
1693
+ * 1. Find every `const name = signal(...)` at module scope (line starts
1694
+ * with optional `export const` — indented declarations inside
1695
+ * functions are skipped). First declaration wins when the same name
1696
+ * appears in multiple files (Map keyed by name deduplicates).
1697
+ * 2. For each .tsx consumer, check per-signal: does it read `name.value`
1698
+ * AND lack a `useStore(name.store` call? Both conditions must hold for
1699
+ * a finding — a file that subscribes to signal A but reads signal B
1700
+ * raw is still flagged for B.
1701
+ */
1702
+ const MODULE_SIGNAL_RE =
1703
+ /^(?:export\s+)?const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:\w+\.)?signal\s*\(/m;
1704
+
1705
+ const ruleSignalValueReads: Rule = {
1706
+ id: "signal-value-reads",
1707
+ title: "Module-level signal .value reads without useStore()",
1708
+ run({ siteDir, fs }: RuleContext): Finding[] {
1709
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
1710
+
1711
+ // Pass 1: collect module-level signal declarations, first occurrence wins.
1712
+ // Map<signalName, declaredIn> deduplicates names across files so Pass 2
1713
+ // never emits two findings for the same (file, signalName) pair.
1714
+ const moduleSignals = new Map<string, string>();
1715
+ for (const abs of tsFiles) {
1716
+ const content = fs.readText(abs);
1717
+ const re = new RegExp(MODULE_SIGNAL_RE.source, "gm");
1718
+ let match: RegExpExecArray | null;
1719
+ while ((match = re.exec(content)) !== null) {
1720
+ const name = match[1];
1721
+ if (!moduleSignals.has(name)) {
1722
+ moduleSignals.set(name, abs.slice(siteDir.length + 1));
1723
+ }
1724
+ }
1725
+ }
1726
+
1727
+ if (moduleSignals.size === 0) return [];
1728
+
1729
+ // Pre-compile per-signal regexes once — avoids N×M compilations in Pass 2.
1730
+ const valueReMap = new Map<string, RegExp>(
1731
+ [...moduleSignals.keys()].map((name) => [name, new RegExp(`\\b${name}\\.value\\b`)]),
1732
+ );
1733
+
1734
+ // Pass 2: for each .tsx file, check per-signal whether .value is read
1735
+ // without a matching useStore(name.store call in the same file.
1736
+ const tsxFiles = tsFiles.filter((f) => f.endsWith(".tsx"));
1737
+ const findings: Finding[] = [];
1738
+
1739
+ for (const abs of tsxFiles) {
1740
+ const content = fs.readText(abs);
1741
+ const rel = abs.slice(siteDir.length + 1);
1742
+
1743
+ for (const [name, declaredIn] of moduleSignals) {
1744
+ const valueRe = valueReMap.get(name)!;
1745
+ if (!valueRe.test(content)) continue;
1746
+ // Per-signal subscription check: useStore(name.store is the canonical
1747
+ // subscription pattern. A file that subscribes to a different signal
1748
+ // is not considered subscribed to this one.
1749
+ const subscribedRe = new RegExp(`\\buseStore\\s*\\(\\s*${name}\\.store\\b`);
1750
+ if (subscribedRe.test(content)) continue;
1751
+
1752
+ findings.push({
1753
+ rule: "signal-value-reads",
1754
+ severity: "warning",
1755
+ file: rel,
1756
+ message: `'${name}.value' read without useStore() — component will not re-render when the signal changes`,
1757
+ fix: `Use useStore(${name}.store, (s) => s) to subscribe, or replace with useQuery / useMutation`,
1758
+ meta: { signalName: name, declaredIn },
1759
+ });
1760
+ }
1761
+ }
1762
+
1763
+ return findings;
1764
+ },
1765
+ };
1766
+
1680
1767
  export const ALL_RULES: Rule[] = [
1681
1768
  ruleDeadLibShims,
1682
1769
  ruleObsoleteVitePlugins,
@@ -1692,6 +1779,7 @@ export const ALL_RULES: Rule[] = [
1692
1779
  ruleLockfileMissing,
1693
1780
  ruleLockfileDrift,
1694
1781
  rulePackageManagerMissing,
1782
+ ruleSignalValueReads,
1695
1783
  ];
1696
1784
 
1697
1785
  /** Exported for direct unit tests. */
@@ -1715,5 +1803,6 @@ export const _internals = {
1715
1803
  ruleLockfileMissing,
1716
1804
  ruleLockfileDrift,
1717
1805
  rulePackageManagerMissing,
1806
+ ruleSignalValueReads,
1718
1807
  },
1719
1808
  };
@@ -1793,3 +1793,184 @@ describe("rule: package-manager-missing", () => {
1793
1793
  expect(updated.packageManager).toMatch(/^bun@/);
1794
1794
  });
1795
1795
  });
1796
+
1797
+ /* ------------------------------------------------------------------ */
1798
+ /* signal-value-reads rule */
1799
+ /* ------------------------------------------------------------------ */
1800
+
1801
+ describe("rule: signal-value-reads", () => {
1802
+ it("flags a .tsx file that reads a module-level signal .value without useStore()", () => {
1803
+ const fs = makeFs({
1804
+ "/site/src/sdk/useAutocomplete.ts":
1805
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1806
+ "export const loading = signal(false);\n" +
1807
+ "export const suggestions = signal(null);\n",
1808
+ "/site/src/components/Searchbar.tsx":
1809
+ 'import { loading, suggestions } from "~/sdk/useAutocomplete";\n' +
1810
+ "export default function Searchbar() {\n" +
1811
+ " const { products = [] } = suggestions.value ?? {};\n" +
1812
+ " return loading.value ? <Spinner /> : <ul>{products.map(p => <li>{p.name}</li>)}</ul>;\n" +
1813
+ "}\n",
1814
+ });
1815
+ const report = runAudit(SITE, fs);
1816
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1817
+ // Two signals read without useStore — one finding per signal per file.
1818
+ expect(r.findings.length).toBeGreaterThanOrEqual(1);
1819
+ const files = r.findings.map((f) => f.file);
1820
+ expect(files.every((f) => f === "src/components/Searchbar.tsx")).toBe(true);
1821
+ expect(r.findings[0].severity).toBe("warning");
1822
+ expect(r.findings[0].fix).toContain("useStore");
1823
+ });
1824
+
1825
+ it("does NOT flag when the .tsx file also calls useStore()", () => {
1826
+ const fs = makeFs({
1827
+ "/site/src/sdk/useAutocomplete.ts":
1828
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1829
+ "export const loading = signal(false);\n",
1830
+ "/site/src/components/Searchbar.tsx":
1831
+ 'import { useStore } from "@tanstack/react-store";\n' +
1832
+ 'import { loading } from "~/sdk/useAutocomplete";\n' +
1833
+ "export default function Searchbar() {\n" +
1834
+ " const isLoading = useStore(loading.store, (s) => s);\n" +
1835
+ " return isLoading ? <Spinner /> : <div />;\n" +
1836
+ "}\n",
1837
+ });
1838
+ const report = runAudit(SITE, fs);
1839
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1840
+ expect(r.findings).toEqual([]);
1841
+ });
1842
+
1843
+ it("does NOT flag signal declared inside a function body (not module-level)", () => {
1844
+ const fs = makeFs({
1845
+ "/site/src/hooks/useCounter.ts":
1846
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1847
+ "export function useCounter() {\n" +
1848
+ " const count = signal(0);\n" + // indented — not module-level
1849
+ " return count;\n" +
1850
+ "}\n",
1851
+ "/site/src/components/Counter.tsx":
1852
+ 'import { useCounter } from "~/hooks/useCounter";\n' +
1853
+ "export default function Counter() {\n" +
1854
+ " const count = useCounter();\n" +
1855
+ " return <div>{count.value}</div>;\n" +
1856
+ "}\n",
1857
+ });
1858
+ const report = runAudit(SITE, fs);
1859
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1860
+ expect(r.findings).toEqual([]);
1861
+ });
1862
+
1863
+ it("does NOT flag .ts files (non-.tsx), which are not React render bodies", () => {
1864
+ const fs = makeFs({
1865
+ "/site/src/sdk/state.ts":
1866
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1867
+ "export const count = signal(0);\n",
1868
+ // A plain .ts file that reads .value — not a React component, no JSX.
1869
+ "/site/src/utils/helper.ts":
1870
+ 'import { count } from "~/sdk/state";\n' +
1871
+ "export function getCount() { return count.value; }\n",
1872
+ });
1873
+ const report = runAudit(SITE, fs);
1874
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1875
+ expect(r.findings).toEqual([]);
1876
+ });
1877
+
1878
+ it("flags only the unsubscribed signal when two signals coexist, one subscribed and one not", () => {
1879
+ const fs = makeFs({
1880
+ "/site/src/sdk/state.ts":
1881
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1882
+ "export const loading = signal(false);\n" +
1883
+ 'export const query = signal("");\n',
1884
+ // loading is correctly subscribed via useStore(loading.store, ...).
1885
+ // query.value is read raw — no useStore(query.store) call anywhere.
1886
+ // Per-signal check: loading should produce no finding; query should.
1887
+ "/site/src/components/Search.tsx":
1888
+ 'import { useStore } from "@tanstack/react-store";\n' +
1889
+ 'import { loading, query } from "~/sdk/state";\n' +
1890
+ "export default function Search() {\n" +
1891
+ " const isLoading = useStore(loading.store, (s) => s);\n" +
1892
+ " return isLoading ? null : <div>{query.value}</div>;\n" +
1893
+ "}\n",
1894
+ });
1895
+ const report = runAudit(SITE, fs);
1896
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1897
+ // loading is subscribed — no finding for it.
1898
+ // query.value is read without useStore(query.store) — one finding.
1899
+ expect(r.findings).toHaveLength(1);
1900
+ expect(r.findings[0].meta?.signalName).toBe("query");
1901
+ expect(r.findings[0].file).toBe("src/components/Search.tsx");
1902
+ });
1903
+
1904
+ it("does NOT flag when the .tsx file calls useStore with the exact signal's store", () => {
1905
+ const fs = makeFs({
1906
+ "/site/src/sdk/state.ts":
1907
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1908
+ "export const count = signal(0);\n",
1909
+ "/site/src/components/Counter.tsx":
1910
+ 'import { useStore } from "@tanstack/react-store";\n' +
1911
+ 'import { count } from "~/sdk/state";\n' +
1912
+ "export default function Counter() {\n" +
1913
+ " const n = useStore(count.store, (s) => s);\n" +
1914
+ " return <div>{count.value}</div>;\n" + // .value present but store is subscribed
1915
+ "}\n",
1916
+ });
1917
+ const report = runAudit(SITE, fs);
1918
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1919
+ expect(r.findings).toEqual([]);
1920
+ });
1921
+
1922
+ it("does not produce duplicate findings when two source files declare the same signal name", () => {
1923
+ const fs = makeFs({
1924
+ "/site/src/sdk/useAutocomplete.ts":
1925
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1926
+ "export const loading = signal(false);\n",
1927
+ // A re-export file also declares the same name — realistic in partial migrations.
1928
+ "/site/src/re-exports.ts":
1929
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1930
+ "export const loading = signal(false);\n",
1931
+ "/site/src/components/Searchbar.tsx":
1932
+ 'import { loading } from "~/sdk/useAutocomplete";\n' +
1933
+ "export default function Searchbar() {\n" +
1934
+ " return loading.value ? <Spinner /> : <div />;\n" +
1935
+ "}\n",
1936
+ });
1937
+ const report = runAudit(SITE, fs);
1938
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1939
+ // Only one finding despite two source files declaring "loading".
1940
+ expect(r.findings).toHaveLength(1);
1941
+ expect(r.findings[0].meta?.signalName).toBe("loading");
1942
+ });
1943
+
1944
+ it("returns zero findings on a site with no signal declarations", () => {
1945
+ const fs = makeFs({
1946
+ "/site/src/components/Static.tsx":
1947
+ "export default function Static() { return <div>hello</div>; }\n",
1948
+ });
1949
+ const report = runAudit(SITE, fs);
1950
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1951
+ expect(r.findings).toEqual([]);
1952
+ });
1953
+
1954
+ it("does NOT support auto-fix (correct replacement varies per case)", () => {
1955
+ const fs = makeFs({});
1956
+ const report = runAudit(SITE, fs);
1957
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1958
+ expect(r.supportsAutoFix).toBe(false);
1959
+ });
1960
+
1961
+ it("includes signalName and declaredIn in finding meta", () => {
1962
+ const fs = makeFs({
1963
+ "/site/src/sdk/signals.ts":
1964
+ 'import { signal } from "@decocms/blocks/sdk/signal";\n' +
1965
+ "export const mySignal = signal(0);\n",
1966
+ "/site/src/components/Widget.tsx":
1967
+ 'import { mySignal } from "~/sdk/signals";\n' +
1968
+ "export default function Widget() { return <div>{mySignal.value}</div>; }\n",
1969
+ });
1970
+ const report = runAudit(SITE, fs);
1971
+ const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
1972
+ expect(r.findings).toHaveLength(1);
1973
+ expect(r.findings[0].meta?.signalName).toBe("mySignal");
1974
+ expect(r.findings[0].meta?.declaredIn).toBe("src/sdk/signals.ts");
1975
+ });
1976
+ });
@@ -121,9 +121,7 @@ import {
121
121
  } from "@decocms/blocks-admin";
122
122
  import { shouldProxyToVtex, createVtexCheckoutProxy } from "@decocms/apps-vtex/utils/proxy";
123
123
  import { extractVtexContext } from "@decocms/apps-vtex/middleware";
124
- import { loadRedirects, matchRedirect } from "@decocms/blocks/sdk/redirects";
125
124
  import { withABTesting } from "@decocms/blocks/sdk/abTesting";
126
- import { loadBlocks } from "@decocms/blocks/cms";
127
125
 
128
126
  // ---------------------------------------------------------------------------
129
127
  // VTEX checkout proxy — configured via @decocms/apps-vtex factory
@@ -152,11 +150,6 @@ const CSP_DIRECTIVES = [
152
150
 
153
151
  const serverEntry = createServerEntry({ fetch: handler.fetch });
154
152
 
155
- // ---------------------------------------------------------------------------
156
- // CMS Redirects — loaded once at module level from .deco/blocks/
157
- // ---------------------------------------------------------------------------
158
- const cmsRedirects = loadRedirects(loadBlocks());
159
-
160
153
  const MOBILE_RE = /mobile|android|iphone/i;
161
154
 
162
155
  const decoWorker = createDecoWorkerEntry(serverEntry, {
@@ -205,17 +198,6 @@ const decoWorker = createDecoWorkerEntry(serverEntry, {
205
198
 
206
199
  const abTestedWorker = withABTesting(decoWorker, {
207
200
  kvBinding: "SITES_KV",
208
- preHandler: (request, url) => {
209
- const redirect = matchRedirect(url.pathname, cmsRedirects);
210
- if (redirect) {
211
- const target = url.search ? \`\${redirect.to}\${url.search}\` : redirect.to;
212
- return new Response(null, {
213
- status: redirect.status,
214
- headers: { Location: target },
215
- });
216
- }
217
- return null;
218
- },
219
201
  shouldBypassAB: (_request, url) => {
220
202
  if (url.pathname === "/login" || url.pathname === "/login/" ||
221
203
  url.pathname === "/logout" || url.pathname === "/logout/") return false;
@@ -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