From 6c5d25559c4b31a81a9af76c438cab5b4a6a9ee7 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Fri, 21 Aug 2026 20:54:06 -0300 Subject: [PATCH 01/19] =?UTF-8?q?fix(blocks):=20renderJson=20l=C3=AA=20o?= =?UTF-8?q?=20site=20block=20com=20a=20chave=20que=20o=20decofile=20realme?= =?UTF-8?q?nte=20usa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chave do site block vem do nome do arquivo em `.deco/blocks/`: `site.json` gera `"site"`, `Site.json` gera `"Site"`. Ler `blocks["Site"]` direto devolve undefined nos primeiros — sem erro, sem warning, a feature só não faz nada. A #479 corrigiu `getSiteSeo` e o merge de SEO do `?asJson`, mas passou pelo terceiro call site: o `sectionsToIgnore` do `?renderJson` (workerEntry:1873). Ele continuou morto. Não é hipotético — um site em produção tem `renderJson.sectionsToIgnore` com três entradas no `site.json` e nenhuma aplica; as seções seguem entrando no payload do app. Em vez de espalhar um quarto `?? blocks["site"]`, os três call sites passam a usar um acessor único, `getSiteBlock()`, exportado de `@decocms/blocks/cms`. Assim um quinto leitor não consegue reintroduzir o bug. Testes cobrindo as duas grafias, a precedência quando o decofile tem as duas, a ausência do bloco, e especificamente o shape de `renderJson.sectionsToIgnore` que a #479 deixou passar. Nota: os 2 testes de draft preview em workerEntry.test.ts já falhavam antes desta mudança (verificado com stash). Co-Authored-By: Claude Opus 5 (1M context) --- packages/blocks/src/cms/index.ts | 1 + packages/blocks/src/cms/loader.test.ts | 54 ++++++++++++++++++++++++ packages/blocks/src/cms/loader.ts | 21 ++++++++- packages/tanstack/src/sdk/workerEntry.ts | 7 ++- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/packages/blocks/src/cms/index.ts b/packages/blocks/src/cms/index.ts index 0960fe53..18c39877 100644 --- a/packages/blocks/src/cms/index.ts +++ b/packages/blocks/src/cms/index.ts @@ -41,6 +41,7 @@ export { findPageByPath, getAllPages, getRevision, + getSiteBlock, getSiteSeo, loadBlocks, onChange, diff --git a/packages/blocks/src/cms/loader.test.ts b/packages/blocks/src/cms/loader.test.ts index ff800d7a..29e57e9b 100644 --- a/packages/blocks/src/cms/loader.test.ts +++ b/packages/blocks/src/cms/loader.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { setDraftOverrideGetter } from "./draftSource"; import { findPageByPath, + getSiteBlock, + getSiteSeo, loadBlocks, matchPath, setBlocks, @@ -375,3 +377,55 @@ describe("loadBlocks draft snapshot semantics", () => { ); }); }); + +// --------------------------------------------------------------------------- +// Site block key casing +// --------------------------------------------------------------------------- + +/** + * The site block's key comes from its filename in `.deco/blocks/`, so real + * sites ship both spellings: `site.json` -> "site", `Site.json` -> "Site". + * + * Reading `blocks["Site"]` directly returns `undefined` on the lowercase ones — + * no error, no warning, the feature just silently does nothing. That shipped + * twice: PR #479 fixed `getSiteSeo` and the `?asJson` SEO merge but missed the + * `?renderJson` `sectionsToIgnore` lookup, which stayed dead in production + * (a live site had three entries configured and none applied). + * + * If this fails, do not "fix" it by special-casing one caller — every site-block + * read must go through `getSiteBlock()`. + */ +describe("getSiteBlock — key casing", () => { + afterEach(() => setBlocks({})); + + it('finds the block under the capitalized "Site" key', () => { + setBlocks({ Site: { seo: { title: "Capitalized" } } }); + expect(getSiteBlock()).toEqual({ seo: { title: "Capitalized" } }); + expect(getSiteSeo().title).toBe("Capitalized"); + }); + + it('finds the block under the lowercase "site" key', () => { + setBlocks({ site: { seo: { title: "Lowercase" } } }); + expect(getSiteBlock()).toEqual({ seo: { title: "Lowercase" } }); + expect(getSiteSeo().title).toBe("Lowercase"); + }); + + it("prefers the capitalized key when a decofile somehow has both", () => { + setBlocks({ Site: { seo: { title: "Capitalized" } }, site: { seo: { title: "Lowercase" } } }); + expect(getSiteSeo().title).toBe("Capitalized"); + }); + + it("returns undefined when there is no site block at all", () => { + setBlocks({ "pages-home": { name: "Home", sections: [] } }); + expect(getSiteBlock()).toBeUndefined(); + expect(getSiteSeo()).toEqual({}); + }); + + it("exposes renderJson.sectionsToIgnore from a lowercase site block", () => { + // The exact shape `workerEntry`'s ?renderJson path reads — the call site + // that PR #479 missed. + setBlocks({ site: { renderJson: { sectionsToIgnore: ["SeoV2.tsx"] } } }); + const rj = getSiteBlock()?.renderJson as { sectionsToIgnore?: string[] }; + expect(rj?.sectionsToIgnore).toEqual(["SeoV2.tsx"]); + }); +}); diff --git a/packages/blocks/src/cms/loader.ts b/packages/blocks/src/cms/loader.ts index 40368134..a1cadd27 100644 --- a/packages/blocks/src/cms/loader.ts +++ b/packages/blocks/src/cms/loader.ts @@ -392,6 +392,24 @@ export function matchPath( * SEO configuration that provides fallback title, description, and templates * when page-level seo blocks don't supply them. */ +/** + * The site-level block, however the decofile happens to spell its key. + * + * The key comes from the filename in `.deco/blocks/`, so a site with + * `site.json` yields `"site"` while one with `Site.json` yields `"Site"`. + * Reading `blocks["Site"]` directly silently returns `undefined` on the former + * — no error, no warning, the feature just does nothing. + * + * That has bitten twice now (PR #479 fixed `getSiteSeo` and the `?asJson` SEO + * merge; the `?renderJson` `sectionsToIgnore` lookup was missed and stayed + * broken in production). Route every site-block read through here so there is + * one place to get it right. + */ +export function getSiteBlock(): Record | undefined { + const blocks = loadBlocks(); + return (blocks["Site"] ?? blocks["site"]) as Record | undefined; +} + export function getSiteSeo(): { title?: string; description?: string; @@ -402,8 +420,7 @@ export function getSiteSeo(): { themeColor?: string; noIndexing?: boolean; } { - const blocks = loadBlocks(); - const site = (blocks["Site"] ?? blocks["site"]) as Record | undefined; + const site = getSiteBlock(); if (!site) return {}; const seo = site.seo as Record | undefined; if (!seo) return {}; diff --git a/packages/tanstack/src/sdk/workerEntry.ts b/packages/tanstack/src/sdk/workerEntry.ts index 7a5a78c3..8c9ffdc9 100644 --- a/packages/tanstack/src/sdk/workerEntry.ts +++ b/packages/tanstack/src/sdk/workerEntry.ts @@ -29,6 +29,7 @@ import { getRevision, getSectionOptions, isBot, + getSiteBlock, loadBlocks, type MatcherContext, onChange, @@ -1869,8 +1870,7 @@ export function createDecoWorkerEntry( // dropped section never triggers its (expensive) VTEX call. Dual-sourced: // - the section's own `export const renderJson = false` // - the website app's renderJson.sectionsToIgnore (resolveType suffix) - const rjBlocks = loadBlocks(); - const rjSite = rjBlocks["Site"] as Record | undefined; + const rjSite = getSiteBlock(); const rawIgnore = (rjSite?.renderJson as { sectionsToIgnore?: unknown })?.sectionsToIgnore; const ignoreSuffixes = (Array.isArray(rawIgnore) ? rawIgnore : []) .filter((s): s is string => typeof s === "string") @@ -1995,8 +1995,7 @@ export function createDecoWorkerEntry( } // Merge site-wide SEO defaults into seo props - const blocks = loadBlocks(); - const site = (blocks["Site"] ?? blocks["site"]) as Record | undefined; + const site = getSiteBlock(); const fullSiteSeo = (site?.seo as Record) ?? {}; // When SeoV2 loader ran, use its output as base (preserves key order) From 0e8d0792acb3031ff1284f7eeb00135c1acdb11f Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Fri, 21 Aug 2026 21:32:50 -0300 Subject: [PATCH 02/19] fix(blocks): resolver o stub de requestContextStorage no React Native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O Metro ativa a condição `react-native` em ios/android (unstable_conditionsByPlatform). O exports map declarava workerd/node/browser/ default — sem `react-native` — então RN caía no `default`, que é a implementação com node:async_hooks. Qualquer bundle nativo que alcançasse RequestContext quebraria. Adicionada entre `node` e `browser`, apontando para o mesmo stub no-op. Ordem verificada em clean-room do PACKAGE_TARGET_RESOLVE, não no olho: Workers continuam no real (workerd antes de browser — o footgun do CLAUDE.md), Node SSR no real, Metro ios/android/web e Vite client no stub. Teste novo cobre a ordem para os seis conjuntos de condição reais. Ele existe porque `node --conditions=...` NÃO consegue checar isso: o Node sempre ativa `node` e a flag só soma às padrão, então todo probe resolve para o módulo real independentemente do map. Daí o resolvedor clean-room. Co-Authored-By: Claude Opus 5 (1M context) --- packages/blocks/package.json | 1 + .../sdk/requestContextStorage.exports.test.ts | 90 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 packages/blocks/src/sdk/requestContextStorage.exports.test.ts diff --git a/packages/blocks/package.json b/packages/blocks/package.json index ae9821ce..3ab9f60b 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -76,6 +76,7 @@ "./sdk/requestContextStorage": { "workerd": "./src/sdk/requestContextStorage.ts", "node": "./src/sdk/requestContextStorage.ts", + "react-native": "./src/sdk/requestContextStorage.browser.ts", "browser": "./src/sdk/requestContextStorage.browser.ts", "default": "./src/sdk/requestContextStorage.ts" }, diff --git a/packages/blocks/src/sdk/requestContextStorage.exports.test.ts b/packages/blocks/src/sdk/requestContextStorage.exports.test.ts new file mode 100644 index 00000000..9e018dd5 --- /dev/null +++ b/packages/blocks/src/sdk/requestContextStorage.exports.test.ts @@ -0,0 +1,90 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +/** + * Guards the CONDITION ORDER of the `./sdk/requestContextStorage` exports map. + * + * The map picks between an `AsyncLocalStorage`-backed implementation and a + * no-op stub. Getting it wrong is silent in both directions: a bundle that + * wrongly gets the real module fails on `node:async_hooks`; a runtime that + * wrongly gets the stub loses cookies, abort signals and device detection with + * **no build error at all**. + * + * Two facts make this untestable by inspection, which is why this file exists: + * + * 1. Condition matching is *first match in insertion order* (Node's + * PACKAGE_TARGET_RESOLVE), so reordering keys silently changes behavior. + * 2. Cloudflare Workers activate `browser` **alongside** `workerd`. If + * `browser` were listed first, a real production deploy would get the no-op + * stub. That is the footgun documented in CLAUDE.md. + * + * `node --conditions=...` cannot check this: Node always activates `node` + * itself and `--conditions` only *adds* to the defaults, so every probe + * resolves to the real module regardless of the map. Hence the clean-room + * resolver below. + */ + +// Read from cwd, not `import.meta.url`: the jsdom environment this suite runs +// in does not give `import.meta.url` a `file:` scheme. Vitest's root is the +// monorepo root (see vitest.config.ts, and each package's +// `vitest run --root ../..` test script), so this path is stable. +const pkg = JSON.parse(readFileSync("packages/blocks/package.json", "utf8")) as { + exports: Record; +}; + +const target = pkg.exports["./sdk/requestContextStorage"]; + +/** Minimal PACKAGE_TARGET_RESOLVE: first key whose condition is active wins. */ +function resolveTarget(t: unknown, conditions: Set): string | null { + if (typeof t === "string") return t; + if (!t || typeof t !== "object") return null; + for (const [key, value] of Object.entries(t as Record)) { + if (key === "default" || conditions.has(key)) return resolveTarget(value, conditions); + } + return null; +} + +const backendFor = (conditions: string[]): "real" | "stub" => { + const resolved = resolveTarget(target, new Set(conditions)); + expect(resolved, `no export matched ${conditions.join(",")}`).toBeTruthy(); + return resolved!.endsWith(".browser.ts") ? "stub" : "real"; +}; + +describe("requestContextStorage exports map — condition order", () => { + // Real backend: runtimes that have node:async_hooks AND serve requests. + it.each([ + // The load-bearing case: `browser` is active here too. If it were listed + // before `workerd`, production Workers would silently get the no-op stub. + ["Cloudflare Workers", ["workerd", "worker", "browser", "import", "default"]], + ["Node SSR", ["node", "import", "default"]], + ["no conditions at all (default)", ["import", "default"]], + ])("%s gets the real AsyncLocalStorage backend", (_name, conditions) => { + expect(backendFor(conditions)).toBe("real"); + }); + + // Stub: bundles with no per-request async context. + it.each([ + // Metro's unstable_conditionsByPlatform: ios/android/tvos/macos. + ["Metro (React Native)", ["react-native", "import", "require", "default"]], + // Metro's web platform, and Vite/webpack client builds. + ["Metro web / Expo web", ["browser", "import", "require", "default"]], + ["Vite client build", ["browser", "import", "default"]], + // Some bundlers activate both; either key resolves to the stub, so order + // between them is not load-bearing — but assert it so a future edit that + // points one of them at the real module fails here. + ["bundler activating react-native + browser", ["react-native", "browser", "import", "default"]], + ])("%s gets the no-op stub", (_name, conditions) => { + expect(backendFor(conditions)).toBe("stub"); + }); + + it("lists workerd and node before browser", () => { + const keys = Object.keys(target as Record); + expect(keys.indexOf("workerd")).toBeLessThan(keys.indexOf("browser")); + expect(keys.indexOf("node")).toBeLessThan(keys.indexOf("browser")); + }); + + it("ends with a default so no runtime resolves to nothing", () => { + const keys = Object.keys(target as Record); + expect(keys.at(-1)).toBe("default"); + }); +}); From c627d903353c74cdef1f18db307b6931b3364665 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Fri, 21 Aug 2026 21:45:08 -0300 Subject: [PATCH 03/19] =?UTF-8?q?refactor(blocks):=20mergeSections=20vira?= =?UTF-8?q?=20parte=20p=C3=BAblica=20de=20@decocms/blocks/cms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Era module-private no DecoPageRenderer.tsx. É lógica pura de array — reordena eager + deferred pelo `index` que o resolveDecoPage carimba, para a seção deferred voltar à posição em que foi autorada no CMS. Todo renderer precisa dela, e um binding que reimplementa vai divergir em silêncio: o bug aparece como "a ordem do CMS está errada", não como bug de render. Exportado dos dois barrels. O client também, porque o DecoPageRenderer é client e o /cms é server-only (node:async_hooks) — mergeSections importa ResolvedSection/DeferredSection type-only, então resolve.ts nunca entra no bundle. O teste de bundle esbuild real (client.browserBundle.test.ts) continua passando. Um achado no caminho: a implementação vazava a chave interna `_sort` nos itens retornados. Não aparecia no tipo `PageItem`, mas estava lá para qualquer um que iterasse as chaves do objeto. Como agora é API pública, a chave de ordenação passou a viver ao lado do item, não nele. 10 testes novos — não havia nenhum. Cobrem a home real (shelf deferred entre banner e newsletter), a PDP real (página 100% deferred), empate entre índice ausente e deferred (fixando a estabilidade do sort), imutabilidade e o não-vazamento do `_sort`. Os 2 testes de draft preview em workerEntry.test.ts seguem falhando como antes desta mudança. Co-Authored-By: Claude Opus 5 (1M context) --- packages/blocks/src/cms/client.ts | 6 ++ packages/blocks/src/cms/index.ts | 2 + packages/blocks/src/cms/mergeSections.test.ts | 99 +++++++++++++++++++ packages/blocks/src/cms/mergeSections.ts | 64 ++++++++++++ .../tanstack/src/hooks/DecoPageRenderer.tsx | 38 +------ 5 files changed, 173 insertions(+), 36 deletions(-) create mode 100644 packages/blocks/src/cms/mergeSections.test.ts create mode 100644 packages/blocks/src/cms/mergeSections.ts diff --git a/packages/blocks/src/cms/client.ts b/packages/blocks/src/cms/client.ts index 716cdb7c..27dd7b67 100644 --- a/packages/blocks/src/cms/client.ts +++ b/packages/blocks/src/cms/client.ts @@ -25,6 +25,10 @@ * `sdk/requestContextStorage.browser.ts`), so it's already safe for a * browser bundle. * - `schema.ts` has no imports at all. + * - `mergeSections.ts` imports `ResolvedSection`/`DeferredSection` from + * `resolve.ts` **type-only**, so the import is erased at compile time and + * `resolve.ts` never enters the bundle. The function itself is pure array + * logic. * * Deliberately NOT re-exported here: `loader.ts`, `resolve.ts`, * `sectionLoaders.ts`, `loadDecofileDirectory.ts`, `blockSource.ts`, and @@ -33,6 +37,8 @@ * storage concerns that only make sense server-side — import them from * `@decocms/blocks/cms` instead. */ +export type { PageItem } from "./mergeSections"; +export { mergeSections } from "./mergeSections"; export type { OnBeforeResolveProps, SectionModule, SectionOptions } from "./registry"; export { getResolvedComponent, diff --git a/packages/blocks/src/cms/index.ts b/packages/blocks/src/cms/index.ts index 18c39877..9b8b211b 100644 --- a/packages/blocks/src/cms/index.ts +++ b/packages/blocks/src/cms/index.ts @@ -37,6 +37,8 @@ export { setDraftPreviewHosts, } from "./draftSource"; export type { DecoPage, Resolvable } from "./loader"; +export type { PageItem } from "./mergeSections"; +export { mergeSections } from "./mergeSections"; export { findPageByPath, getAllPages, diff --git a/packages/blocks/src/cms/mergeSections.test.ts b/packages/blocks/src/cms/mergeSections.test.ts new file mode 100644 index 00000000..4474befe --- /dev/null +++ b/packages/blocks/src/cms/mergeSections.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { mergeSections } from "./mergeSections"; +import type { DeferredSection, ResolvedSection } from "./resolve"; + +const eager = (component: string, index?: number): ResolvedSection => ({ + component, + props: {}, + key: `k-${component}`, + ...(index === undefined ? {} : { index }), +}); + +const deferred = (component: string, index: number): DeferredSection => ({ + component, + key: `k-${component}`, + index, + propsHash: "h", +}); + +/** The rendered order, as component names — what a reader actually cares about. */ +const order = (items: ReturnType) => + items.map((i) => (i.type === "eager" ? i.section.component : i.deferred.component)); + +describe("mergeSections", () => { + it("interleaves deferred sections back into their authored position", () => { + // The real shape from a storefront home: a deferred shelf authored between + // a banner and the newsletter. Rendering the two arrays separately would + // push the shelf to the bottom. + const items = mergeSections( + [eager("Carousel", 0), eager("Banner", 1), eager("Newsletter", 3)], + [deferred("ShelfTabbed", 2)], + ); + expect(order(items)).toEqual(["Carousel", "Banner", "ShelfTabbed", "Newsletter"]); + }); + + it("keeps input order when nothing is deferred", () => { + const items = mergeSections([eager("A"), eager("B"), eager("C")], []); + expect(order(items)).toEqual(["A", "B", "C"]); + expect(items.every((i) => i.type === "eager")).toBe(true); + }); + + it("stamps originalIndex by array position, not by the CMS index", () => { + const items = mergeSections([eager("A", 5), eager("B", 9)], [deferred("D", 7)]); + const eagers = items.filter((i) => i.type === "eager") as Extract< + (typeof items)[number], + { type: "eager" } + >[]; + expect(eagers.map((i) => i.originalIndex)).toEqual([0, 1]); + }); + + it("falls back to array position when an eager section carries no index", () => { + // Pre-deferral behavior: sections resolved without an `index` stamp still + // render in the order they arrived. Here B (position 1) ties with D + // (index 1); the sort is stable, so eager stays ahead of deferred. Pinning + // the tie so a future refactor to an unstable sort is caught. + const items = mergeSections([eager("A"), eager("B")], [deferred("D", 1)]); + expect(order(items)).toEqual(["A", "B", "D"]); + }); + + it("handles a page that is entirely deferred", () => { + // The real PDP shape: every section wrapped in Rendering/Lazy. + const items = mergeSections( + [], + [deferred("Details", 0), deferred("Shelf", 1), deferred("Newsletter", 2)], + ); + expect(order(items)).toEqual(["Details", "Shelf", "Newsletter"]); + expect(items.every((i) => i.type === "deferred")).toBe(true); + }); + + it("sorts deferred sections that arrive out of order", () => { + const items = mergeSections([eager("A", 0)], [deferred("Z", 3), deferred("M", 1)]); + expect(order(items)).toEqual(["A", "M", "Z"]); + }); + + it("returns empty for an empty page", () => { + expect(mergeSections([], [])).toEqual([]); + }); + + it("tolerates null/undefined arrays", () => { + expect(mergeSections(null as never, undefined as never)).toEqual([]); + expect(order(mergeSections([eager("A")], null as never))).toEqual(["A"]); + }); + + it("does not mutate its inputs", () => { + const resolved = [eager("B", 1), eager("A", 0)]; + const def = [deferred("D", 2)]; + const snapshot = JSON.stringify({ resolved, def }); + mergeSections(resolved, def); + expect(JSON.stringify({ resolved, def })).toBe(snapshot); + }); + + it("does not leak the internal _sort key into the result", () => { + // The sort key is an implementation detail; a binding that iterated + // Object.keys on a PageItem would otherwise see it. + const items = mergeSections([eager("A", 0)], [deferred("D", 1)]); + for (const item of items) { + expect(Object.keys(item)).not.toContain("_sort"); + } + }); +}); diff --git a/packages/blocks/src/cms/mergeSections.ts b/packages/blocks/src/cms/mergeSections.ts new file mode 100644 index 00000000..b79fed33 --- /dev/null +++ b/packages/blocks/src/cms/mergeSections.ts @@ -0,0 +1,64 @@ +/** + * Interleaves a page's eager and deferred sections back into the order the CMS + * authored them. + * + * `resolveDecoPage` splits a page into two arrays — sections it resolved + * eagerly and sections it deferred — but both carry the `index` they had in the + * original flat section list. Rendering either array on its own would put a + * deferred shelf at the bottom of the page instead of between the two banners + * it was authored between; this puts them back. + * + * Pure array logic — no React, no DOM. It lives here rather than inside a + * binding so every renderer (TanStack's `DecoPageRenderer`, the React Native + * one, anything later) orders sections identically. A binding that reimplements + * this drifts silently: the bug looks like "the CMS order is wrong", not like a + * rendering bug. + */ + +import type { DeferredSection, ResolvedSection } from "./resolve"; + +/** One entry in the merged page list — either resolved or still to be fetched. */ +export type PageItem = + | { type: "eager"; section: ResolvedSection; originalIndex: number } + | { type: "deferred"; deferred: DeferredSection }; + +export function mergeSections( + resolved: ResolvedSection[], + deferred: DeferredSection[], +): PageItem[] { + if (!resolved?.length && !deferred?.length) return []; + const safeResolved = resolved ?? []; + const safeDeferred = deferred ?? []; + + // Nothing deferred → input order is already the CMS order. + if (!safeDeferred.length) { + return safeResolved.map((s, i) => ({ type: "eager", section: s, originalIndex: i })); + } + + // Sort by the `index` stamped by resolveDecoPage. An eager section missing + // one falls back to its array position, which is the pre-deferral behavior. + // + // The sort key is kept beside the item rather than on it: `PageItem` is a + // public type, and a stray `_sort` key would show up for anything iterating + // an item's own keys. + const keyed: { sort: number; item: PageItem }[] = []; + + for (let i = 0; i < safeResolved.length; i++) { + const s = safeResolved[i]; + keyed.push({ + sort: s.index ?? i, + item: { type: "eager", section: s, originalIndex: i }, + }); + } + + for (const d of safeDeferred) { + keyed.push({ sort: d.index, item: { type: "deferred", deferred: d } }); + } + + // Array.prototype.sort is stable, so a tie (an eager section with no `index` + // colliding with a deferred one) keeps eager-before-deferred — the order the + // arrays were pushed in. + keyed.sort((a, b) => a.sort - b.sort); + + return keyed.map((k) => k.item); +} diff --git a/packages/tanstack/src/hooks/DecoPageRenderer.tsx b/packages/tanstack/src/hooks/DecoPageRenderer.tsx index a5b1c7f1..8966bc9c 100644 --- a/packages/tanstack/src/hooks/DecoPageRenderer.tsx +++ b/packages/tanstack/src/hooks/DecoPageRenderer.tsx @@ -9,7 +9,8 @@ import { useState, } from "react"; import { Await, ClientOnly } from "@tanstack/react-router"; -import type { SectionOptions } from "@decocms/blocks/cms/client"; +import type { PageItem, SectionOptions } from "@decocms/blocks/cms/client"; +import { mergeSections } from "@decocms/blocks/cms/client"; import { getResolvedComponent, getSectionOptions, @@ -448,41 +449,6 @@ function DeferredSectionSkeleton({ return ; } -// --------------------------------------------------------------------------- -// Merge helper — combines eager and deferred sections in original order -// --------------------------------------------------------------------------- - -type PageItem = - | { type: "eager"; section: ResolvedSection; originalIndex: number } - | { type: "deferred"; deferred: DeferredSection }; - -function mergeSections(resolved: ResolvedSection[], deferred: DeferredSection[]): PageItem[] { - if (!resolved?.length && !deferred?.length) return []; - const safeResolved = resolved ?? []; - const safeDeferred = deferred ?? []; - - if (!safeDeferred.length) { - return safeResolved.map((s, i) => ({ type: "eager", section: s, originalIndex: i })); - } - - // Use the `index` property stamped by resolveDecoPage to sort all - // sections (eager + deferred) back into their original CMS order. - const items: (PageItem & { _sort: number })[] = []; - - for (let i = 0; i < safeResolved.length; i++) { - const s = safeResolved[i]; - items.push({ type: "eager", section: s, originalIndex: i, _sort: s.index ?? i }); - } - - for (const d of safeDeferred) { - items.push({ type: "deferred", deferred: d, _sort: d.index } as PageItem & { _sort: number }); - } - - items.sort((a, b) => a._sort - b._sort); - - return items; -} - // --------------------------------------------------------------------------- // DecoPageRenderer — renders top-level resolved sections from a CMS page // --------------------------------------------------------------------------- From 381b5656c63eeeca9f486026cf59535f6803358d Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Fri, 21 Aug 2026 21:56:02 -0300 Subject: [PATCH 04/19] =?UTF-8?q?feat(native):=20@decocms/native=20?= =?UTF-8?q?=E2=80=94=20binding=20de=20app=20para=20React=20Native=20/=20Ex?= =?UTF-8?q?po?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Metade device do contrato que o site já serve: busca o envelope do ?renderJson, mapeia __resolveType -> componente nativo, renderiza. Sem resolver, sem decofile, sem worker entry no aparelho — isso é do servidor e continua lá. createRenderJsonClient cliente do ?renderJson, com memo de ETag por path (304 no lugar de rebaixar a página inteira a cada foco de tela) e erro tipado com `notFound` cmsScreenConfig irmão do cmsRouteConfig: mesmos nomes de opção (ignoreSearchParams default ["skuId"]) e o mesmo routeCacheDefaults, mas devolvendo opções de TanStack Query — Router não existe no device DecoSections renderer dirigido por registry; devolve Fragment, não ScrollView, porque o app é quem tem o container createNativeSetup registra componentes nativos no registry compartilhado Reusa o registry do @decocms/blocks verbatim (o ComponentType é type-only e o check Symbol.for(react.memo|forward_ref|lazy) vale idêntico em RN) e o SectionErrorBoundary — sempre com fallback explícito, porque o default dele renderiza
. Não usa mergeSections: o serializeRenderJson já interleava eager e deferred por índice no worker, então page.sections chega na ordem autorada. 36 testes. Validado com bundle Expo real importando o pacote por symlink: iOS 3,9 MB. Isso prova de uma vez que exports para .ts cru funcionam no Metro, que /cms/client e /hooks são seguros em RN, e que a condição react-native do 0e8d079 é load-bearing — removendo-a, o mesmo bundle falha com "Unable to resolve module node:async_hooks from requestContextStorage.ts". Co-Authored-By: Claude Opus 5 (1M context) --- bun.lock | 17 ++ packages/native/README.md | 93 +++++++++ packages/native/package.json | 38 ++++ packages/native/src/DecoSections.test.tsx | 198 ++++++++++++++++++++ packages/native/src/DecoSections.tsx | 118 ++++++++++++ packages/native/src/cmsScreenConfig.test.ts | 80 ++++++++ packages/native/src/cmsScreenConfig.ts | 84 +++++++++ packages/native/src/index.ts | 42 +++++ packages/native/src/renderJson.test.ts | 157 ++++++++++++++++ packages/native/src/renderJson.ts | 124 ++++++++++++ packages/native/src/setup.ts | 60 ++++++ packages/native/tsconfig.json | 7 + 12 files changed, 1018 insertions(+) create mode 100644 packages/native/README.md create mode 100644 packages/native/package.json create mode 100644 packages/native/src/DecoSections.test.tsx create mode 100644 packages/native/src/DecoSections.tsx create mode 100644 packages/native/src/cmsScreenConfig.test.ts create mode 100644 packages/native/src/cmsScreenConfig.ts create mode 100644 packages/native/src/index.ts create mode 100644 packages/native/src/renderJson.test.ts create mode 100644 packages/native/src/renderJson.ts create mode 100644 packages/native/src/setup.ts create mode 100644 packages/native/tsconfig.json diff --git a/bun.lock b/bun.lock index 1783d8de..eb948541 100644 --- a/bun.lock +++ b/bun.lock @@ -305,6 +305,21 @@ "typescript": "^5.9.0", }, }, + "packages/native": { + "name": "@decocms/native", + "version": "0.0.0", + "dependencies": { + "@decocms/blocks": "workspace:*", + }, + "devDependencies": { + "@types/react": "^19.0.0", + "knip": "^5.86.0", + "typescript": "^5.9.0", + }, + "peerDependencies": { + "react": "^19.0.0", + }, + }, "packages/nextjs": { "name": "@decocms/nextjs", "version": "0.0.0", @@ -501,6 +516,8 @@ "@decocms/eitri": ["@decocms/eitri@workspace:packages/eitri"], + "@decocms/native": ["@decocms/native@workspace:packages/native"], + "@decocms/nextjs": ["@decocms/nextjs@workspace:packages/nextjs"], "@decocms/tanstack": ["@decocms/tanstack@workspace:packages/tanstack"], diff --git a/packages/native/README.md b/packages/native/README.md new file mode 100644 index 00000000..065dafa0 --- /dev/null +++ b/packages/native/README.md @@ -0,0 +1,93 @@ +# `@decocms/native` + +Deco binding for **React Native / Expo**. A site already on `@decocms/tanstack` +serves its CMS pages as JSON through `?renderJson`; this package is the +device-side half — fetch that envelope, map each section's `__resolveType` to a +native component, render. + +It is deliberately **not** a second copy of the framework. There is no resolver, +no decofile, no worker entry on the device: + +| Concern | Owner | +|---|---| +| Resolve the CMS page, run section loaders | the site's worker (`?renderJson`) | +| Author content | Studio, writing `.deco/blocks/` | +| Map `__resolveType` → component, render | **this package** | +| Navigation, scrolling, viewport detection | your app | + +Bundling `blocks.gen.json` into an app would be actively wrong: megabytes of +content that goes stale the moment someone publishes. The whole point of +`?renderJson` is that content updates without a store release. + +## Usage + +```tsx +import { + cmsScreenConfig, + createNativeSetup, + createRenderJsonClient, + DecoSections, +} from "@decocms/native"; +import { useQuery } from "@tanstack/react-query"; +import { ScrollView } from "react-native"; + +const client = createRenderJsonClient({ baseUrl: "https://loja.example.com" }); + +createNativeSetup({ + sections: { "site/sections/Images/Banner.tsx": Banner }, +}); + +export function HomeScreen() { + const { data } = useQuery(cmsScreenConfig({ client, path: "/" })); + return ( + + + + ); +} +``` + +## Why TanStack Query and not TanStack Router + +`cmsRouteConfig` (`@decocms/tanstack`) returns a **Router** route object whose +`loader` calls a server function. Neither exists on a device, so mirroring its +literal type would produce a config nobody can spread into anything. + +`cmsScreenConfig` mirrors its *ergonomics* instead — same option names +(`ignoreSearchParams`, defaulting to `["skuId"]`), same `staleTime`/`gcTime` +from the same `routeCacheDefaults` — and returns TanStack **Query** options. +Query is the part of the stack that runs natively, and the site already depends +on it. Navigation stays with Expo Router, which owns stack/tabs/gesture/deep +links. + +Dropped because they only exist to feed `buildHead`: `siteName`, +`defaultTitle`, `head`, `headers`, `validateSearch`, `ssr`. + +## Deferred sections + +Sections the CMS marks deferred arrive as `{ component, lazyUrl }`. This package +does not fetch them for you, because *when* to fetch is a scrolling decision and +your app owns the scroll container. Resolve them with `deferredSectionConfig` — +typically from `onViewableItemsChanged` — and feed them back: + +```tsx + } +/> +``` + +## `DecoSections` returns a Fragment + +Not a `ScrollView`. Your app owns scrolling because it also owns +pull-to-refresh, tab bars, sticky headers and viewport detection. Wrapping here +would take that away. + +## Requirements + +- `@decocms/blocks` ≥ the release carrying the `react-native` export condition + on `./sdk/requestContextStorage`. Without it, Metro resolves the + `node:async_hooks`-backed implementation and the bundle fails with + `Unable to resolve module node:async_hooks`. +- The site must serve `?renderJson` (on by default in `createDecoWorkerEntry`). diff --git a/packages/native/package.json b/packages/native/package.json new file mode 100644 index 00000000..a0ffc04b --- /dev/null +++ b/packages/native/package.json @@ -0,0 +1,38 @@ +{ + "name": "@decocms/native", + "version": "0.0.0", + "type": "module", + "description": "Deco binding for React Native / Expo \u2014 renders CMS pages from the ?renderJson page-as-JSON endpoint served by the site's worker.", + "repository": { + "type": "git", + "url": "https://github.com/decocms/blocks.git", + "directory": "packages/native" + }, + "license": "MIT", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./setup": "./src/setup.ts" + }, + "scripts": { + "build": "tsc", + "test": "vitest run --root ../.. packages/native/", + "typecheck": "tsc --noEmit", + "lint:unused": "knip" + }, + "dependencies": { + "@decocms/blocks": "workspace:*" + }, + "peerDependencies": { + "react": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "knip": "^5.86.0", + "typescript": "^5.9.0" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public" + } +} diff --git a/packages/native/src/DecoSections.test.tsx b/packages/native/src/DecoSections.test.tsx new file mode 100644 index 00000000..c960d7d1 --- /dev/null +++ b/packages/native/src/DecoSections.test.tsx @@ -0,0 +1,198 @@ +import { renderToString } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { DecoSections, type NativeRegistry } from "./DecoSections"; +import type { SerializedSection } from "./renderJson"; + +// Sections render plain DOM here because the assertions run through +// `renderToString`. On a device these would be ``/``; nothing in +// DecoSections cares which — it only calls `createElement`. +const Banner = ({ title }: { title?: string }) =>

{`banner:${title ?? "-"}`}

; +const Shelf = ({ count }: { count?: number }) =>

{`shelf:${count ?? 0}`}

; +const Boom = () => { + throw new Error("section blew up"); +}; + +const eager = (component: string, props: Record = {}): SerializedSection => ({ + component, + props, +}); +const lazy = (component: string, lazyUrl: string): SerializedSection => ({ component, lazyUrl }); + +const html = (node: React.ReactElement) => renderToString(node); + +describe("DecoSections — component lookup", () => { + const registry: NativeRegistry = { + "site/sections/Images/Banner.tsx": Banner, + "Product/ProductShelf.tsx": Shelf, + }; + + it("renders a section by its exact resolveType", () => { + const out = html( + , + ); + expect(out).toContain("banner:hi"); + }); + + it("matches by suffix, so keys can skip the site namespace", () => { + // The CMS always sends the full `site/sections/...` path; letting a site + // register just the tail is the ergonomic half of the registry. + const out = html( + , + ); + expect(out).toContain("shelf:6"); + }); + + it("renders nothing for an unregistered section instead of crashing the page", () => { + const out = html( + , + ); + expect(out).toBe(""); + }); + + it("surfaces an unregistered section when the app asks for it", () => { + const out = html( + {`missing:${c}`}} + />, + ); + expect(out).toContain("missing:site/sections/Nope.tsx"); + }); + + it("renders the same component twice on one page", () => { + // Two shelves on a home page is normal; the key must not collide. + const out = html( + , + ); + expect(out).toContain("shelf:1"); + expect(out).toContain("shelf:2"); + }); +}); + +describe("DecoSections — order", () => { + it("preserves the envelope order, including deferred placeholders", () => { + // The worker already interleaved eager and deferred by CMS index inside + // serializeRenderJson, so array order IS authored order. + const out = html( + pending} + />, + ); + expect(out.indexOf("banner:one")).toBeLessThan(out.indexOf("pending")); + expect(out.indexOf("pending")).toBeLessThan(out.indexOf("banner:three")); + }); +}); + +describe("DecoSections — deferred sections", () => { + const registry: NativeRegistry = { "Shelf.tsx": Shelf }; + + it("shows the pending placeholder until the app resolves it", () => { + const out = html( + {`pending:${s.lazyUrl}`}} + />, + ); + expect(out).toContain("pending:/?__section=5"); + }); + + it("renders the real section once the app hands it back", () => { + const out = html( + pending} + />, + ); + expect(out).toContain("shelf:12"); + expect(out).not.toContain("pending"); + }); + + it("keys resolved sections by lazyUrl, not by component", () => { + // A PDP has two deferred shelves of the same type; matching on component + // would render the first one's data twice. + const out = html( + , + ); + expect(out).toContain("shelf:11"); + expect(out).toContain("shelf:22"); + }); + + it("renders nothing for a pending section when no placeholder is supplied", () => { + const out = html(); + expect(out).toBe(""); + }); +}); + +describe("DecoSections — error isolation", () => { + // React error boundaries do not run during renderToString — + // getDerivedStateFromError is client-only — so catching cannot be asserted + // here. What CAN be asserted, and is the actual risk, is structural: every + // section is wrapped in SectionErrorBoundary and always receives an explicit + // fallback. Without one, the boundary falls back to its own default, which + // renders a `
` — fine on the web, a crash on a device. + const tree = DecoSections({ + sections: [eager("Ok.tsx", { title: "a" }), lazy("L.tsx", "/?x=1")], + registry: { "Ok.tsx": Banner }, + resolved: { "/?x=1": { component: "Ok.tsx", props: { title: "b" } } }, + }) as React.ReactElement<{ children: React.ReactElement[] }>; + + const rendered = tree.props.children.filter( + (child) => typeof child?.type !== "symbol" && child?.type !== undefined, + ); + + it("wraps every rendered section in an error boundary", () => { + expect(rendered).toHaveLength(2); + for (const child of rendered) { + expect((child.type as { name?: string })?.name).toBe("SectionErrorBoundary"); + } + }); + + it("always passes an explicit fallback, so the boundary's DOM default never runs", () => { + for (const child of rendered) { + const props = child.props as { fallback?: unknown; sectionKey?: string }; + expect(props.fallback).toBeDefined(); + expect(props.sectionKey).toBe("Ok.tsx"); + } + }); + + it("still renders the surviving sections around a broken one", () => { + // Boom is registered but never invoked here — the assertion is that a + // page with a bad section still emits the good ones. + const out = html( + , + ); + expect(out).toContain("banner:survived"); + }); +}); diff --git a/packages/native/src/DecoSections.tsx b/packages/native/src/DecoSections.tsx new file mode 100644 index 00000000..ee2cd18a --- /dev/null +++ b/packages/native/src/DecoSections.tsx @@ -0,0 +1,118 @@ +/** + * Renders a `?renderJson` page by mapping each section's `__resolveType` to a + * registered native component. + * + * This is the native counterpart of `DecoPageRenderer` (`@decocms/tanstack`), + * and it is a fraction of its size. Almost everything that file does exists to + * survive server-side rendering, which does not happen on a device: + * + * - the sync/lazy bifurcation and the pre-fulfilled-thenable trick dodge + * React 19 SSR-streaming hydration bugs — no SSR, no bug; + * - `` streaming has no device equivalent (deferred sections are + * fetched, not streamed); + * - `` is meaningless when everything is client; + * - `DeviceProvider` seeds a server-resolved device so SSR and hydration agree; + * the device already knows what it is; + * - the `
` wrappers and the fade-in `