Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions .agents/skills/deco-migrate-script/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ packages/blocks-cli/scripts/migrate/
│ ├── imports.ts ← 70+ import rewriting rules
│ ├── jsx.ts ← JSX attribute fixes
│ ├── fresh-apis.ts ← Fresh framework API removal
│ ├── ctx-compat.ts ← Optional-chain section-loader ctx.* reads (#305)
│ ├── deno-isms.ts ← Deno-specific cleanup
│ ├── dead-code.ts ← Old cache/loader system removal
│ └── tailwind.ts ← Tailwind v3→v4 + DaisyUI v4→v5
Expand Down Expand Up @@ -152,7 +153,7 @@ Generates 14+ configuration and infrastructure files:

### Phase 3: Transform

Applies 6 transforms in sequence to every source file:
Applies 7 transforms in sequence to every source file:

#### 1. `imports.ts` — Import Rewriting (70+ rules)

Expand Down Expand Up @@ -194,21 +195,35 @@ setTimeout → window.setTimeout (type safety)
- `IS_BROWSER` → `typeof window !== "undefined"`
- `Context.active()` → removed

#### 4. `dead-code.ts` — Old Deco Patterns
#### 4. `ctx-compat.ts` — Section-loader `ctx` compatibility (#305)

Runs only on files that export a `loader`. deco.cx section loaders use a
3-arg `(props, req, ctx)` signature; the framework now supplies a real compat
`ctx` (device, `invoke`, per-app state, `response.headers`) as the 3rd arg. But
an app that isn't configured yields `undefined`, so a non-optional deep read
(`ctx.salesforce.cartExtension[0]`) still throws and `withSectionLoader`'s
try/catch silently drops the section's props (blank render).

- Optional-chains every `ctx.*` read → `ctx?.salesforce?.cartExtension?.[0]`.
- Leaves already-optional chains and assignment targets alone.
- No-op on files without a `loader` export (so an unrelated `ctx`, e.g. a
canvas 2D context, is untouched).

#### 5. `dead-code.ts` — Old Deco Patterns

- Removes: `export const cache`, `export const cacheKey`, `export const loader` (old caching system)
- Handles: `crypto.subtle.digestSync` (Deno-only → async)
- Preserves: `invoke.*` calls (runtime.ts proxy)

#### 5. `deno-isms.ts` — Deno Cleanup
#### 6. `deno-isms.ts` — Deno Cleanup

- `deno-lint-ignore` comments → removed
- `npm:` prefix → removed
- `@ts-ignore` → `@ts-expect-error`
- `Deno.*` API usage → flagged
- `/// <reference>` directives → removed

#### 6. `tailwind.ts` — Tailwind v3→v4 + DaisyUI v4→v5
#### 7. `tailwind.ts` — Tailwind v3→v4 + DaisyUI v4→v5

**23 Tailwind class renames:**
```
Expand Down Expand Up @@ -477,7 +492,7 @@ This script handles **Phases 0-6** of the [migration playbook](../deco-to-tansta
- Phase 0 (Scaffold) → `phase-scaffold.ts`
- Phase 1 (Imports) → `transforms/imports.ts`
- Phase 2 (Signals) → `transforms/imports.ts` (bulk only — manual `useSignal` → `useState` still needed)
- Phase 3 (Deco Framework) → `transforms/fresh-apis.ts` + `transforms/deno-isms.ts`
- Phase 3 (Deco Framework) → `transforms/fresh-apis.ts` + `transforms/ctx-compat.ts` + `transforms/deno-isms.ts`
- Phase 4 (Commerce) → `transforms/imports.ts`
- Phase 6 (Islands) → `phase-cleanup.ts` (deletes directory, repoints imports)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -517,16 +517,18 @@ function Header({ device, ...props }: Props) {

The `device` prop comes from the section loader:
```typescript
export async function loader(props: Props, req: Request, ctx: AppContext) {
export async function loader(props: Props, req: Request, ctx?: AppContext) {
return {
...props,
device: ctx.device as "mobile" | "desktop" | "tablet",
device: ctx?.device as "mobile" | "desktop" | "tablet",
};
}
```

`ctx.device` is detected from the request `User-Agent` header server-side. TanStack Start serializes loaderData and sends it to the client, so both server and client always use the same value. No mismatch.

> **The 3rd-arg `ctx` is real (#305).** The framework passes a compat `ctx` (device, `invoke`, per-app state, `response.headers`) as the loader's 3rd argument — see vtex-commerce.md §32. `ctx.device` here is genuinely populated. Keep app-state reads optional-chained (`ctx?.vtex?.…`) since an unconfigured app is `undefined`.

Also fix the section's `LoadingFallback` to use `props.device` instead of `useDevice()`:
```tsx
// Before
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ During migration, section loaders (e.g., `sections/Header/Header.tsx`) may have

**Fix**: Keep all section loader logic intact. The loader signature `(props, req, ctx) => {...}` and the `ctx.invoke` calls should be preserved as-is.

**The 3rd-arg `ctx` is real now (#305).** The framework builds a compat `ctx` and passes it as the loader's 3rd argument (`@decocms/blocks`'s `buildSectionLoaderContext`, wired through `withSectionLoader`/`withPageContext`). What it provides:

- `ctx.device` — `"mobile" | "tablet" | "desktop"` from the request User-Agent (works in worker, dev, and SPA-nav paths — it's derived from `req`, not the ambient `RequestContext`).
- `ctx.invoke.*` — a nested invoke proxy bound to this request's origin + AbortSignal. `ctx.invoke.vtex.loaders.categories.tree()` works server-side via a self-fetch to `/deco/invoke`.
- `ctx.<appName>` (e.g. `ctx.vtex`, `ctx.salesforce`) — the app's request-scoped state via `RequestContext.getAppState(name)`, or `undefined` if the app isn't configured.
- `ctx.response.headers` — maps to `RequestContext.responseHeaders` (Set-Cookie forwarding) inside the worker request scope; writes are dropped on the dev/SPA serverFn path.

**Still optional-chain app-state reads.** An unconfigured app yields `undefined`, so a non-optional deep read like `ctx.salesforce.cartExtension[0]` throws — and `withSectionLoader`'s try/catch swallows it, dropping the section's props (blank render). The migrator's `ctx-compat` transform auto-rewrites `ctx.*` reads to optional chains (`ctx?.salesforce?.cartExtension?.[0]`); write new/hand-fixed loaders the same way.


## 34. Commerce Loaders Are Blind to the URL

Expand Down
6 changes: 5 additions & 1 deletion packages/blocks-cli/scripts/migrate/phase-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { log, logPhase } from "./types";
import { transformImports } from "./transforms/imports";
import { transformJsx } from "./transforms/jsx";
import { transformFreshApis } from "./transforms/fresh-apis";
import { transformCtxCompat } from "./transforms/ctx-compat";
import { transformDenoIsms } from "./transforms/deno-isms";
import { transformTailwind } from "./transforms/tailwind";
import { transformDeadCode } from "./transforms/dead-code";
Expand Down Expand Up @@ -55,16 +56,19 @@ function applyTransforms(content: string, filePath: string, ctx?: MigrationConte
return { content, changed: false, notes: [] };
}

// Pipeline: imports → jsx → htmx-on-events → fresh-apis → dead-code → deno-isms → tailwind
// Pipeline: imports → jsx → htmx-on-events → fresh-apis → ctx-compat → dead-code → deno-isms → tailwind
// htmx-on-events runs after jsx (which renames class/onChange) and
// before fresh-apis (which removes useScript imports the htmx
// codemod's TODO might still reference). The codemod is a no-op on
// files without hx-on, so it never adds latency to non-htmx sites.
// ctx-compat runs after fresh-apis so it sees the settled loader body; it's
// a no-op on files without a `loader` export (#305).
const pipeline: Array<{ name: string; fn: (content: string) => TransformResult }> = [
{ name: "imports", fn: (c) => transformImports(c, ctx?.islandWrapperTargets) },
{ name: "jsx", fn: transformJsx },
{ name: "htmx-on-events", fn: transformHtmxOnEvents },
{ name: "fresh-apis", fn: transformFreshApis },
{ name: "ctx-compat", fn: transformCtxCompat },
{ name: "dead-code", fn: (c) => transformDeadCode(c, ctx?.platform) },
{ name: "deno-isms", fn: transformDenoIsms },
{ name: "tailwind", fn: transformTailwind },
Expand Down
10 changes: 6 additions & 4 deletions packages/blocks-cli/scripts/migrate/templates/section-loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,9 +314,11 @@ export function generateSectionLoaders(ctx: MigrationContext): string {
// ---------- SEO + analytics delegation ----------
if (hasSEOPDP) {
entries.push(``);
entries.push(` "site/sections/SEOPDP.tsx": async (props: any, _req) => {`);
// The framework supplies the real compat ctx as the 3rd arg (#305) —
// forward it instead of faking `{ seo: {} }` (which violated policy D3).
entries.push(` "site/sections/SEOPDP.tsx": async (props: any, _req, ctx) => {`);
entries.push(` const mod = await import("../sections/SEOPDP");`);
entries.push(` const result = mod.loader(props, _req, { seo: {} } as any);`);
entries.push(` const result = mod.loader(props, _req, ctx);`);
entries.push(` return result ?? props;`);
entries.push(` },`);
}
Expand All @@ -329,9 +331,9 @@ export function generateSectionLoaders(ctx: MigrationContext): string {
}

if (hasIsEvents) {
entries.push(` "site/sections/Analytics/IsEvents.tsx": async (props: any, req) => {`);
entries.push(` "site/sections/Analytics/IsEvents.tsx": async (props: any, req, ctx) => {`);
entries.push(` const mod = await import("../sections/Analytics/IsEvents");`);
entries.push(` return mod.loader(props, req) as unknown as Record<string, unknown>;`);
entries.push(` return mod.loader(props, req, ctx) as unknown as Record<string, unknown>;`);
entries.push(` },`);
}

Expand Down
66 changes: 66 additions & 0 deletions packages/blocks-cli/scripts/migrate/transforms/ctx-compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { transformCtxCompat } from "./ctx-compat";

const withLoader = (body: string) =>
`export const loader = (props: Props, req: Request, ctx?: AppContext) => {\n${body}\n};`;

describe("transformCtxCompat", () => {
it("optional-chains a deep app-state read", () => {
const src = withLoader(" const ext = ctx.salesforce.cartExtension[0];");
const r = transformCtxCompat(src);
expect(r.changed).toBe(true);
expect(r.content).toContain("ctx?.salesforce?.cartExtension?.[0]");
});

it("optional-chains ctx.device and ctx.invoke calls (harmless no-op safety)", () => {
const src = withLoader(
" const isMobile = ctx.device !== 'desktop';\n const page = await ctx.invoke.vtex.loaders.product.detailsPageGQL({ slug });",
);
const r = transformCtxCompat(src);
expect(r.content).toContain("ctx?.device !== 'desktop'");
expect(r.content).toContain("ctx?.invoke?.vtex?.loaders?.product?.detailsPageGQL({ slug })");
});

it("leaves already-optional chains intact (no double ??.)", () => {
const src = withLoader(" const x = ctx?.salesforce?.cartExtension?.[0];");
const r = transformCtxCompat(src);
// No change needed → not flagged as changed.
expect(r.content).toContain("ctx?.salesforce?.cartExtension?.[0]");
expect(r.content).not.toContain("??.");
});

it("does not optional-chain an assignment target (would be a syntax error)", () => {
const src = withLoader(" ctx.state.count = 1;");
const r = transformCtxCompat(src);
expect(r.content).toContain("ctx.state.count = 1;");
expect(r.content).not.toContain("ctx?.state?.count =");
});

it("still rewrites reads while leaving assignment targets alone", () => {
const src = withLoader(" ctx.state.count = ctx.state.count + 1;");
const r = transformCtxCompat(src);
// LHS untouched, RHS optional-chained.
expect(r.content).toContain("ctx.state.count = ctx?.state?.count + 1;");
});

it("is a no-op on files without a loader export", () => {
const src = "const ctx = canvas.getContext('2d');\nctx.fillRect(0, 0, 1, 1);";
const r = transformCtxCompat(src);
expect(r.changed).toBe(false);
expect(r.content).toBe(src);
});

it("does not touch the ctx parameter declaration", () => {
const src = withLoader(" return props;");
const r = transformCtxCompat(src);
expect(r.changed).toBe(false);
expect(r.content).toContain("ctx?: AppContext");
});

it("does not match identifiers that merely contain ctx", () => {
const src = withLoader(" const c = canvasCtx.foo;\n const d = a.ctx.bar;");
const r = transformCtxCompat(src);
expect(r.content).toContain("canvasCtx.foo");
expect(r.content).toContain("a.ctx.bar");
});
});
151 changes: 151 additions & 0 deletions packages/blocks-cli/scripts/migrate/transforms/ctx-compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type { TransformResult } from "../types";

/**
* Make ported deco.cx section loaders defensive about the compat `ctx`
* (issue #305).
*
* The framework now hands section loaders a real 3rd-arg `ctx`
* (`@decocms/blocks`'s `buildSectionLoaderContext`) with `device`, `invoke`,
* `response` and per-app state (`ctx.vtex`, `ctx.salesforce`, …). But an app
* that isn't configured on the target site yields `undefined`, so a
* *non-optional* deep read like `ctx.salesforce.cartExtension[0]` still throws
* — and `withSectionLoader`'s try/catch would swallow it, dropping the
* section's props (blank render). The hand-fixed reference migration
* (`granadobr-tanstack`) solves this by optional-chaining every `ctx` read.
*
* This codemod reproduces that: every `ctx.` member-access chain becomes an
* optional chain (`ctx?.a?.b?.[0]`). Optional chaining short-circuits to
* `undefined` instead of throwing, which is exactly the defensive behavior the
* working migration relies on. `ctx.device`/`ctx.invoke` are always present so
* the extra `?.` is a harmless no-op there.
*
* Scope: only files that export a `loader` (section/loader files), so an
* unrelated `ctx` (e.g. a canvas 2D context) elsewhere isn't touched.
* Assignment targets (`ctx.x = …`, invalid as an optional chain) are skipped.
*/

const IDENT = /[A-Za-z0-9_$]/;

const LOADER_EXPORT_RE =
/export\s+(?:const|(?:async\s+)?function)\s+loader\b|export\s*\{\s*[^}]*\bloader\b/;

function isIdentChar(c: string | undefined): boolean {
return c !== undefined && IDENT.test(c);
}

/**
* Scan a single `ctx` member-access chain starting at `start` (which must
* point at the `c` of `ctx`). Returns the rewritten (optional) chain, the
* original text, the index just past the chain, and whether the chain is the
* target of an assignment (in which case it must NOT be optional-chained).
*/
function scanChain(
code: string,
start: number,
): { rewritten: string; original: string; end: number; isAssignTarget: boolean } {
let i = start + 3; // past "ctx"
const pieces: string[] = ["ctx"];

while (i < code.length) {
if (code.startsWith("?.", i)) {
// already optional — keep it, then consume the identifier OR, for an
// optional computed access (`?.[expr]`), the balanced bracket group.
i += 2;
if (code[i] === "[") {
let depth = 1;
let j = i + 1;
while (j < code.length && depth > 0) {
if (code[j] === "[") depth++;
else if (code[j] === "]") depth--;
j++;
}
pieces.push("?.[", code.slice(i + 1, j));
i = j;
} else {
let j = i;
while (j < code.length && isIdentChar(code[j])) j++;
pieces.push("?.", code.slice(i, j));
i = j;
}
} else if (code[i] === ".") {
i += 1;
let j = i;
while (j < code.length && isIdentChar(code[j])) j++;
pieces.push("?.", code.slice(i, j));
i = j;
} else if (code[i] === "[") {
let depth = 1;
let j = i + 1;
while (j < code.length && depth > 0) {
if (code[j] === "[") depth++;
else if (code[j] === "]") depth--;
j++;
}
pieces.push("?.[", code.slice(i + 1, j)); // slice includes closing "]"
i = j;
} else {
break;
}
}

// Peek past trailing whitespace to detect an assignment target.
let k = i;
while (k < code.length && (code[k] === " " || code[k] === "\t")) k++;
const isAssignTarget = code[k] === "=" && code[k + 1] !== "=" && code[k + 1] !== ">";

return {
rewritten: pieces.join(""),
original: code.slice(start, i),
end: i,
isAssignTarget,
};
}

export function transformCtxCompat(content: string): TransformResult {
// Only touch files that actually export a loader — avoids rewriting an
// unrelated `ctx` variable (canvas context, etc.) in components.
if (!LOADER_EXPORT_RE.test(content)) {
return { content, changed: false, notes: [] };
}

let out = "";
let i = 0;
let count = 0;

while (i < content.length) {
const isCtxToken =
content.startsWith("ctx", i) &&
!isIdentChar(content[i - 1]) &&
content[i - 1] !== "." &&
(content[i + 3] === "." || content[i + 3] === "[" || content.startsWith("?.", i + 3));

if (isCtxToken) {
const { rewritten, original, end, isAssignTarget } = scanChain(content, i);
if (!isAssignTarget && rewritten !== original) {
out += rewritten;
count++;
} else {
out += original;
}
i = end;
continue;
}

out += content[i];
i++;
}

if (count === 0) {
return { content, changed: false, notes: [] };
}

return {
content: out,
changed: true,
notes: [
`Optional-chained ${count} ctx.* read(s) so unconfigured app state degrades to undefined instead of throwing (#305)`,
],
};
}

export const _internals = { scanChain, LOADER_EXPORT_RE };
6 changes: 4 additions & 2 deletions packages/blocks-cli/scripts/migrate/transforms/fresh-apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,11 @@ export function transformFreshApis(content: string): TransformResult {
notes.push("Removed allowCorsFor (not needed in TanStack)");
}

// ctx.response.headers → not available, flag
// ctx.response.headers → supported by the compat ctx (#305): section loaders
// receive a 3rd-arg ctx whose `response.headers` maps to
// RequestContext.responseHeaders. Just an INFO note (no manual work).
if (result.includes("ctx.response")) {
notes.push("MANUAL: ctx.response usage found — FnContext in @decocms/blocks does not have response object");
notes.push("INFO: ctx.response.headers is provided by the compat ctx (RequestContext.responseHeaders) inside the worker request scope; writes are dropped on the dev/SPA serverFn path");
}

// { crypto } from "@std/crypto" → use globalThis.crypto (Web Crypto API)
Expand Down
2 changes: 2 additions & 0 deletions packages/blocks/src/cms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export {
WELL_KNOWN_TYPES,
} from "./resolve";
export type { SectionLoaderFn } from "./sectionLoaders";
export type { SectionLoaderContext } from "./sectionLoaderContext";
export { buildSectionLoaderContext } from "./sectionLoaderContext";
export {
isLayoutSection,
registerCacheableSections,
Expand Down
Loading