Skip to content

feat(native): @decocms/native — app nativo a partir de um site TanStack - #502

Open
JonasJesus42 wants to merge 19 commits into
mainfrom
native-apps-from-tanstack-sites
Open

feat(native): @decocms/native — app nativo a partir de um site TanStack#502
JonasJesus42 wants to merge 19 commits into
mainfrom
native-apps-from-tanstack-sites

Conversation

@JonasJesus42

@JonasJesus42 JonasJesus42 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

O que é

@decocms/native: um site TanStack ganha um app nativo editado pelo mesmo Studio.

As páginas nascem do CMS via ?renderJson, então conteúdo novo e página nova
aparecem no app sem release
. O que exige build é tipo de seção novo, porque
precisa do renderer nativo.

Provado ponta a ponta num dev build no simulador, contra um storefront TanStack
limpo: home e PLP nativas, filtros e ordenação, carrinho nativo, e checkout no
WebView com os itens certos.

O desenho

Híbrido, não tudo-nativo. Uma política de rota decide o que abre nativo; o
resto abre num SiteView (WebView) com ponte de volta — tocar num link cuja
rota foi promovida cancela a navegação e empurra a tela nativa. Por isso o site
inteiro funciona no dia zero, e promover uma rota é uma linha.

cmsRoutes é gerado de .deco/blocks e é snapshot, não whitelist: página
publicada depois do binário abre pelo catch-all. Tratar como whitelist quebraria
página nova até o próximo release de loja, e mataria o valor todo de ser
CMS-dirigido.

Os quatro achados que custaram caro

Cada um falha sem erro em lugar nenhum, e é por isso que estão codificados
no pacote e no init em vez de num README.

1. A plataforma já compartilha a sessão — o jar é que quebrava. No iOS o
fetch e o <WebView sharedCookiesEnabled> usam o mesmo NSHTTPCookieStorage
(no Android, o mesmo CookieManager). Um cookie jar próprio manda o Cookie
dele, o servidor ecoa no Set-Cookie, a camada nativa regrava, e o valor cresce
a cada ida e volta — medido de 1190 para 2390 bytes com ,cart= repetido, até o
backend não reconhecer mais o id e abrir carrinho novo. Sintoma: "adicionou ao
carrinho, mas a sacola está vazia", com 200 em toda requisição.
createNativeSession() decide isso uma vez.

2. Classe DaisyUI não é inerte em RN — ela derruba a tela. btn compila
para var(--border), var(--size), var(--radius-selector), que o DaisyUI
declara em :where(:root), :root:has(...). :has() não existe em RN, o
NativeWind descarta o bloco, e o runtime estoura em .length.
@decocms/native/daisy cobre as 13 famílias traduzindo a string de classe para
utilitários comuns — Modal/Drawer/Collapse/Tabs trocam o truque do checkbox por
useState, que é o que de fato não portava.

3. React nativo duplicado mata o app no primeiro import. Um checkout
linkado resolve react/react-native do node_modules DELE. Duas cópias são
dois registries nativos e "Maximum call stack size exceeded" ao avaliar o
import — sem erro de bundling, porque bundlar as duas é possível. O
metro.config.js scaffoldado força singleton.

4. Imagem do CMS crua é 17× a 106× maior que o necessário. 1,34 MB por foto
de produto num card de 160 dp; 1,62 MB num banner. Contra 79 KB e 15 KB
redimensionados. DecoImage usa o getOptimizedMediaUrl do próprio framework —
e notavelmente ?width= no decoims não faz nada, devolve o original com
200.

Superfície

entrada o que é
@decocms/native cmsScreenConfig, DecoSections, SiteView, createNativeSession, createNativeInvoke, política de rota
@decocms/native/daisy 13 famílias DaisyUI como componentes nativos com estado real
@decocms/native/image DecoImage, prefetchImages, backend injetado
@decocms/native/push campanhas de push como bloco de CMS com matcher de audiência
deco-native init metro config, wiring e as telas que fazem o site abrir
blocks-cli --platform native routes.gen.ts + invoke.native.gen.ts tipado

Zero dependência de Expo em runtime. As menções vivem em templates do
init, que viram código do app. Peers: react, react-native, e nativewind
opcional (só o /daisy e o /image precisam). Backends nativos — WebView,
imagem — são injetados, pelo mesmo motivo.

Fora do pacote

Correções em @decocms/blocks que o consumo real expôs: NestedFromFlat nunca
funcionou (o invoke tipado dava never), getSiteBlock lia a chave errada do
decofile, schema.ts não compilava sob strict de consumidor, e o
requestContextStorage caía na implementação com node:async_hooks em RN.

Verificação

  • 144 testes em packages/native; bun run check limpo (as falhas de
    blocks-cli são pré-existentes, de e38fc15).
  • Dev build iOS real: carrinho adicionado numa página dentro do WebView aparece
    na sacola nativa, com subtotal certo e checkout.
  • Filtro e ordenação da PLP verificados contra o servidor, sem tocar no
    @decocms/apps-shopify.

O que sei que não está pronto

  • Paginação não foi verificada em execução — a loja de teste tem 4 produtos
    e 12 por página, então a segunda página nunca aparece. O contrato está certo
    (usa pageInfo.nextPage, não ?page=N, que quebra o loader do Shopify
    porque ele pagina por cursor), mas não roda ainda.
  • VTEX não foi testado. O checkout do Shopify carrega o id do carrinho na
    própria URL; o da VTEX não, então a conclusão sobre sessão precisa ser
    revalidada lá.
  • Auth no /deco/invoke continua inexistente — decisão de design
    pré-existente, mais urgente com um app publicado.
  • Login e wishlist nativos ainda faltam; é o mesmo tratamento mecânico que o
    carrinho levou.
  • Rolagem infinita pede trocar o ScrollView do DecoScreen por FlatList.

🤖 Generated with Claude Code


Summary by cubic

Ships @decocms/native to render TanStack CMS pages in React Native/Expo via ?renderJson, with hybrid routing, typed invoke, platform-safe session handling, DaisyUI ports, and optimized images. Also fixes site-block casing in renderJson, exports mergeSections, and makes requestContextStorage RN-safe.

  • Codegen and runtime
    • blocks-cli --platform native now emits .deco/routes.gen.ts (compiled CMS paths for device) and .deco/invoke.native.gen.ts (types for /deco/invoke/*).
    • Native render: DecoSections, cmsScreenConfig (TanStack Query options), and SiteView for WebView fallbacks; device-safe root export.
    • Sessions: createNativeSession defers to the platform cookie store by default; createNativeInvoke calls /deco/invoke/* with cookie continuity.
    • UI: @decocms/native/daisy translates DaisyUI classes to RN components; @decocms/native/image builds CDN-sized URLs and supports prefetch.
  • Framework fixes and adjustments
    • renderJson reads the site block via getSiteBlock() (works with Site.json and site.json); mergeSections is now exported from @decocms/blocks/cms.
    • @decocms/blocks/sdk/requestContextStorage adds a react-native condition to avoid node:async_hooks in RN.
    • Typed invoke types (NestedFromFlat) fixed so deep keys no longer collapse to never.

Adoption

  • In the site repo: run bun run blocks-cli generate --platform native to produce .deco/routes.gen.ts and .deco/invoke.native.gen.ts.
  • In the Expo app: run npx deco-native init to add Metro config and wiring; wire the generated files into your registry and route policy.
    • If you use WebView fallbacks, install react-native-webview and pass it to SiteView.
    • If you use @decocms/native/daisy, import @decocms/native/daisy.css in your Tailwind entry.

Written for commit 7a191f0. Summary will update on new commits.

Review in cubic

JonasJesus42 and others added 19 commits August 21, 2026 20:54
…almente usa

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…/cms

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) <noreply@anthropic.com>
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 <div>.

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) <noreply@anthropic.com>
O site guarda sessão inteiramente em cookie: nada é espelhado no corpo da
resposta, nenhum invoke devolve handle de sessão. O browser esconde isso; o RN
não tem jar, e o `Headers` do whatwg-fetch que ele usa:

  - não tem getSetCookie()
  - colapsa repetidos:  this.map[name] = oldValue + ', ' + value

Um round-trip de carrinho VTEX seta 5 cookies, então chegam como uma string só.
Split ingênuo corrompe todos que carregam data, porque `Expires=Wed, 21 Oct`
tem vírgula. É o gêmeo cliente do bug que o forwardCtxHeadersTo resolve no
servidor, e o comentário de lá descreve o fim: o usuário chega no checkout com
o carrinho vazio.

  createCookieJar   parse/serialização, Max-Age antes de Expires (RFC 6265),
                    cookie expirado = deleção (é assim que logout funciona),
                    persistência opcional via KV async-ou-sync
  withCookieJar     embrulha fetch — é o mesmo seam que o createRenderJsonClient
                    e o createAppInvokeWith aceitam, então um jar só cobre
                    página e invoke. Zero mudança de servidor.
  createNativeInvoke  createAppInvokeWith com origem absoluta + jar

Um bug achado pelo teste ponta a ponta, não por raciocínio: `getSetCookie()`
devolve o que o header store tem, e um store populado com valor já juntado
devolve UM elemento com os 5 cookies dentro — indistinguível de um cookie real.
Confiar na forma guardava um cookie de lixo e perdia o carrinho em silêncio.
Agora ambas as fontes passam pelo split, que é idempotente para cookie único.

61 testes. Bundle Expo real com jar e invoke no grafo: iOS 3,9 MB.

Nota: /deco/invoke não tem auth nenhuma — documentado no README, é decisão de
servidor e não cabe neste pacote. `headers` é o seam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A árvore de páginas já é dado — .deco/blocks/pages-*.json, os mesmos arquivos
que o Studio escreve. Então "declarar rota uma vez" é problema de codegen, não
motivo para shipar um segundo router no app.

  generate-routes.ts   lê .deco/blocks/, compila cada `path` do decofile e
                       emite .deco/routes.gen.ts
  createRoutePolicy    (@decocms/native) resolve href -> tela nativa ou WebView

Gerado e não lido em runtime por um motivo concreto: os paths do CMS são
URLPattern, e o matchPath do @decocms/blocks LANÇA num runtime sem a API —
Hermes não tem. O gerador roda em Node, que tem, e emite regex simples.

A tabela é SNAPSHOT, nunca whitelist. O app é binário publicado; página
publicada amanhã não está nela. Path sem match cai no WebView em vez de 404 —
tratar como whitelist quebraria no app toda página nova até o próximo release,
e o valor de ser CMS-dirigido morre junto.

--platform native no orquestrador: liga `routes`, desliga `manifest` (só Next)
e `invoke` (emite imports de @tanstack/react-start que um app RN não usa).
Verificado que o caminho padrão e o --platform eitri não mudaram.

Ordenação por especificidade é load-bearing: sem ela o catch-all `/*` — que é a
Category Page num storefront real, e a razão de aquele site nunca dar 404 —
engoliria /products/:slug e toda landing page.

Padrão que não compila (grupo opcional `{/70-off}?`, segmento misto) é recusado
com aviso em vez de compilado errado; a página cai no WebView, que é o default
correto. 21 testes no gerador, 78 no pacote native.

Nota: os 6 erros de typecheck em scripts/migrate/*.test.ts já existiam antes
(verificado com stash).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
O "createServerFn dos apps". createServerFn é inalcançável de fora por
construção — transporte /_serverFn/<id-gerado-no-build> e a metade servidora
precisa do grafo de módulos do Start; não existe compilador de RPC no Metro.
Então não é runtime novo: é gerador que TIPA o transporte que já existe,
/deco/invoke/<key>, cujas chaves o generate-loaders já registra.

Mesmo slot `invoke` no orquestrador, emissor diferente por plataforma. Web e
eitri seguem idênticos (verificado).

Declaração, não ativação: o arquivo é TYPES ONLY. Não cria endpoint, não
registra handler, não entra em bundle nenhum — importar é o opt-in. Site que só
bumpa versão e nunca importa fica intacto. Isso é deliberado: cada entrada
descreve um endpoint público, e upgrade de framework não pode alargar
superfície de rede em silêncio. A deny-list PRIVILEGED do generate-invoke vem
junto.

Dois bugs achados rodando contra o storefront real, não por raciocínio:

1. Eu presumia que o handler exporta todo tipo que menciona. Falso: `Person`
   vem de @decocms/apps-commerce/types e `AddressBookState` de um módulo irmão.
   Gerava TS2614 em 7 imports. Agora resolve a origem de verdade via ts-morph —
   import do handler, re-emissão do specifier original, ou degradação para
   `unknown` com aviso quando o tipo é local e não exportado (aí não dá para
   nomeá-lo de fora, ponto).
2. Nomes colidiam (`Props` e `WishlistState` de dois módulos cada) = identificador
   duplicado. Resolver a origem eliminou as colisões falsas; o alias por chave
   ficou para as reais.

Verificado com tsc de verdade sobre o arquivo gerado, incluindo um
@ts-expect-error que prova que o tipo de entrada é real e não `any`.

13 testes no gerador. 1668 na suíte dos três pacotes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
O pacote roda com `strictNullChecks` apenas, mas exporta `.ts` cru — então o
tsc de quem consome type-checa este fonte, e `skipLibCheck` não vale para `.ts`.
Um parâmetro implícito aqui quebrava o build de qualquer consumidor com
`strict: true`, não só de app RN.

Medido antes de corrigir: 8 erros TS7006 sob --strict, dos quais 7 estão em
arquivo de teste (que consumidor nunca importa). Só este é shippable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…caffolding

Tudo que aprendi migrando a POC vivia só no FINDINGS.md; sem encodar, o próximo
redescobre os três. O `init` liga um app Expo EXISTENTE a um site deco.

Não scaffolda o app de propósito: create-expo-app faz isso melhor, e um init
que é dono do seu app é um init contra o qual você briga depois. Precedente:
deco-eitri init, que também só escreve a cola.

Três arquivos, nenhum sobrescrito (idempotente), cada um prevenindo uma falha
silenciosa:

  metro.config.js   watchFolders — o Metro observa só o diretório do projeto,
                    então importar <site>/.deco/* falha com "Unable to resolve
                    module" COM o arquivo no lugar e o tsc satisfeito
  tsconfig.json     @types/node + skipLibCheck — o framework exporta .ts cru,
                    então o tsc do consumidor type-checa o fonte dele
  lib/deco.ts       um jar para ?renderJson e invoke

Bug pego escrevendo: o `site` é relativo à raiz do app, mas o módulo de wiring
vai dentro de lib/, um nível mais fundo. Escrever o specifier relativo à raiz
resolvia para app/.deco/ — errado, e silencioso até o Metro falhar. Agora é
calculado a partir do diretório do arquivo, com teste pinando o caso e outro
para libDir aninhado.

O patch do tsconfig preserva as opções existentes e faz merge no array `types`;
tsconfig com comentário (JSONC válido e comum) fica intocado, e o init explica
o que adicionar em vez de mutilar o arquivo.

88 testes no pacote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Push é a parte de "push/EAS/lojas" que É framework — e a peça já existe: uma
campanha de push é um bloco do decofile com uma regra de matcher. O
generate-schema transforma a interface PushCampaign num formulário, audiências
compõem com multi/negate, e matcher salvo vira bloco reutilizável entre
campanhas. Nada novo do lado de autoria; o Studio consegue editar isso hoje.

  registerPushMatchers()                     uma vez, no boot
  selectCampaigns(campaigns, devices)        puro — sem relógio próprio, sem I/O

Matchers de audiência, registrados pelo mesmo registerMatcher dos built-ins:
lastOpen (dias desde a última abertura — o "sentimos sua falta"), cartAge
(carrinho com itens, parado há N horas), platform, signedIn, tag.

Uma adição em @decocms/blocks: MatcherContext.state, para matcher que NÃO
avalia contra um request HTTP. Campanha não tem request — avalia contra um
snapshot de device, numa varredura agendada. Em vez de forkar o sistema de
matcher, o estado entra por aí, e composição, blocos salvos e o schema do
Studio seguem funcionando iguais.

cooldownHours é obrigatório, não opcional: campanha sem teto notifica o mesmo
device a cada varredura, que é como app é desinstalado. selectCampaigns não
devolve campanha dentro da janela e pula campanha com teto zero.

registerPushMatchers chama registerBuiltinMatchers por dentro. Sem isso,
multi/negate não existem e a audiência falha em SILÊNCIO — nunca casa, campanha
nunca dispara, nada loga. Descoberto porque dois testes de composição falharam.

Entrega fica fora: APNs/FCM/Expo Push é escolha de provedor e ruim de assar no
framework. selectCampaigns devolve quem recebe o quê e para aí.

24 testes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
O push.ts importa o barrel server-only (@decocms/blocks/cms) para reusar o
evaluateMatcher, e o index.ts re-exportava. Resultado: importar @decocms/native
arrastava node:async_hooks e NENHUM bundle nativo subia. Commitei o push sem
re-bundlar o app; o `expo export` pegou depois.

Faz sentido que seja server-only: uma varredura de campanha roda contra um
registro de devices, agendada — não há request nem device envolvido. Então vira
subpath `@decocms/native/push`, e o barrel raiz volta a ser device-safe.

Teste de fonte guardando o barrel. Nem o tsc nem o vitest pegam isso — só o
`expo export` pega, e aí já é tarde. O teste falhou de cara e pegou um segundo
bug meu: o splice que removeu o push levou junto os exports de renderJson e
routes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ever`

O tipo por trás do proxy de invoke shipou quebrado e nada pegou: o runtime
funciona, o typecheck do pacote passa, e a falha só aparece quando alguém
escreve `invoke.site.actions.newsletter.subscribe(...)` e recebe
"Property 'subscribe' does not exist on type 'never'". Foi assim que apareceu —
trocando um fetch na mão pelo invoke tipado num app real.

Dois bugs independentes, ambos invisíveis para teste de valor:

1. BuildNested terminava em `T extends string`. `never extends string` é TRUE,
   então o caso terminal nunca disparava e toda chave colapsava para never.
   Corrigido com `[T] extends [never]`, que não distribui.
2. DeepMerge recursava em funções. Função É object em TypeScript, então a folha
   virava um record das próprias propriedades e deixava de ser chamável.

Teste de tipo novo (invokeTypes.test-d.ts) compilado pelo tsc, não rodado pelo
vitest — cobre chave profunda, chave sem barra, irmãs sob o mesmo prefixo, e um
@ts-expect-error provando que o tipo de entrada é real e não any. `bun run
typecheck` falha se qualquer um regredir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dois achados de campo, testando num storefront TanStack limpo.

**Classe DaisyUI não é inerte em React Native — ela derruba a tela.**
`btn` compila para `var(--border)`, `var(--size)`, `var(--radius-selector)` e
`var(--color-primary-content)`, que o DaisyUI declara em
`:where(:root), :root:has(input.theme-controller[value=light]:checked),
[data-theme="light"]`. `:has()` e o seletor de atributo não existem em RN, então
o NativeWind descarta o bloco inteiro e o runtime recebe `undefined` — um throw
em `.length` que leva a tela junto. Logo não dá para "deixar a classe lá".

`@decocms/native/daisy` cobre as 13 famílias que a home do storefront alcança.
Cada componente aceita a string de classe como API de variante — código portado
continua lendo `className="btn btn-primary"` — mas traduz para utilitários
Tailwind comuns, que o NativeWind resolve contra o `@theme` do site. Mesma cor
da web, por construção; a classe DaisyUI para nesta fronteira.

O que não porta nunca foi o estilo, foi o seletor: `.modal-toggle:checked +
.modal` e `.drawer-toggle:checked ~ .drawer-side` são máquina de estado escrita
em CSS. Modal/Drawer/Collapse/Tabs trocam o checkbox escondido por `useState`.

`nativewind` e `react-native` entram como peers OPCIONAIS: só `./daisy` precisa
deles, o resto do pacote (renderJson, invoke, jar, rotas) não tem a ver com
estilo.

**Um app híbrido tem DOIS cookie stores e nada os unia.** O `fetch` nativo usa
este jar; o `<WebView>` usa o store da plataforma. Um item adicionado numa
página dentro do WebView caía num carrinho DIFERENTE do que a tela nativa lê —
sem erro em lugar nenhum, o badge simplesmente não mexia. `createCookieJar`
agora aceita um `SystemCookieStore` injetado (`@react-native-cookies/cookies`
encaixa), e `withCookieJar` sincroniza antes de cada request, porque o WebView
pode mexer na sessão a qualquer momento.

Injetado em vez de dependência: o pacote tem que instalar num app sem WebView.

Um bug que o teste pegou: `void Promise.resolve(system.set(...))` avalia
`set()` ANTES de embrulhar, então um módulo nativo que lança de forma síncrona
quebrava toda resposta que já tinha dado certo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Antes, "importar o @decocms/native" dava o cliente de renderJson, o invoke
tipado, o jar e a política de rota — mas nenhuma tela. A política já
classificava um destino como `webview` e nada renderizava um, então o app abria
vazio, o que se lê como "o pacote não funciona".

`SiteView` sai do repo de teste e entra no pacote. O `react-native-webview`
continua sendo peer opcional: o componente é passado por prop, então o import
fica no consumidor, onde o bundler enxerga. Um app que só tem telas nativas não
paga por ele.

Ele também instrumenta o WebView, e as duas coisas custaram debug real:

- **Erro dentro do WebView não chega em log nenhum** — nem Metro, nem servidor,
  que só vê request que deu certo. O `window.onerror` sozinho reporta
  "Script error." sem arquivo nem linha.
- **Mutação não avisa ninguém.** O nativo não tem como saber que uma página
  mexeu no carrinho: não dispara evento, e a query só revalida quando o
  staleTime vence — o badge fica parado mesmo com a sessão compartilhada. O
  alvo do serverFn vem em base64 no path, então dá para identificar o que foi
  chamado sem acoplar a nome de rota nem a hash de build.

`init` passa a escrever `app/_layout.tsx` e o catch-all `app/[...path].tsx`. O
catch-all é catch-all de propósito e precisa continuar sendo: `cmsRoutes` é um
SNAPSHOT do build, não um whitelist, e página publicada no Studio depois do
binário tem que abrir. Tratar a tabela gerada como whitelist quebraria página
nova até o próximo release de loja.

Dois bugs no template da ponte de cookie, achados rodando um dev build de
verdade no simulador:

1. `@react-native-cookies/cookies` é CommonJS puro — `module.exports = {...}`,
   sem `default`. Ler `.default` devolvia undefined e a ponte se desligava em
   silêncio, com a mensagem culpando o Expo Go.
2. Sem `useWebKit = true`, o iOS lê o NSHTTPCookieStorage — o store que o fetch
   nativo já usa, exatamente a metade que NÃO precisa ser lida. O do WebView é
   o do WKWebView.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverte o desenho da ponte de cookie do commit anterior. Ele estava construído
sobre uma premissa errada, e a premissa errada era a causa do bug.

**O que eu supunha:** app híbrido tem dois cookie stores (o do `fetch` nativo e
o do `<WebView>`) e falta uma ponte entre eles.

**O que é:** no iOS o `fetch` passa por `NSURLSession`, que persiste em
`NSHTTPCookieStorage` — e `<WebView sharedCookiesEnabled>` usa esse MESMO store.
No Android o networking do RN usa `ForwardingCookieHandler`, que embrulha
justamente o `android.webkit.CookieManager` do WebView. Nos dois, telas nativas
e páginas embutidas já são uma sessão só. Não faltava ponte.

**E o jar não era só redundante, era destrutivo:**

1. o jar manda o `Cookie: cart=<valor>` dele;
2. o servidor lê, e ecoa o mesmo valor de volta num `Set-Cookie`;
3. a camada nativa grava esse cookie da resposta;
4. no request seguinte o jar manda o valor guardado — agora com o anterior
   concatenado, porque os dois `Set-Cookie` foram colados numa vírgula.

O valor cresce a cada ida e volta. Medido num dev build real subindo de 1190
para 2390 bytes com `,cart=` repetido dentro; a partir daí o Shopify não
reconhece mais o id e abre carrinho novo a cada chamada. O sintoma é "adicionou
ao carrinho, mas a sacola está vazia" — com `200` em toda requisição e nada em
log nenhum.

`createNativeSession()` decide isso uma vez: em ios/android devolve o `fetch`
cru, e o jar só entra onde nada persiste cookie por nós (Expo web, fetch
custom). `createNativeInvoke` passa a aceitar `jar: false` pelo mesmo motivo.

Some junto o `SystemCookieStore`/`syncFromSystem`: era a API da premissa errada.
E some a dependência de `@react-native-cookies/cookies`, que só servia de sonda
— exigir um módulo nativo obrigaria todo consumidor a um dev build para algo que
o SO faz de graça.

Provado ponta a ponta num dev build no simulador: item adicionado na PDP dentro
do WebView aparece na sacola nativa, com subtotal certo e checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dois bugs de empacotamento, os dois achados usando `@decocms/native/daisy` num
app de verdade pela primeira vez. Nenhum dos dois falha o bundling.

**1. React nativo duplicado derruba o app no primeiro import.**

Um módulo é resolvido de onde ele mora. Quando `@decocms/native` é symlink para
um checkout do framework, ele pega o `react`/`react-native` do node_modules
DAQUELE repo. Duas cópias de react-native são dois registries de módulo nativo,
e o app morre com "Maximum call stack size exceeded (native stack depth)" ao
avaliar o import — sem erro nenhum de bundling, porque bundlar as duas é
perfeitamente possível.

Na prática foram react-native 0.81.5 contra 0.81.6 e react 19.1.0 contra
19.2.7 — e a versão veio de eu ter adicionado react-native como devDependency
do pacote só para o typecheck. Dependência npm normal não cai nisso; checkout
linkado cai sempre.

O `metro.config.js` scaffoldado agora resolve uma lista de pacotes como se o
import tivesse partido da raiz do app.

**2. Os tipos do NativeWind não viajavam com o import.**

O framework exporta `.ts` cru, então o `tsc` do consumidor compila essa fonte
com o tsconfig DELE — e um `.d.ts` solto que só o tsconfig do pacote inclui não
vai junto. Resultado: todo consumidor via "Property 'className' does not exist"
em cima do próprio pacote. A referência foi para dentro do `daisy/index.ts`,
que é o arquivo que o consumidor de fato importa.

Fica também um aviso no `init` sobre o `@source` do Tailwind apontando para o
pacote: o JIT só emite classe que ele VÊ e não olha node_modules, então
`bg-primary` dentro de um `<Button>` empacotado não existiria no CSS — botão
sem cor, sem erro em lugar nenhum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`@decocms/native/daisy` renderizava sem estilo nenhum onde a classe era usada
só por ele: modal com backdrop transparente por cima da página, botão sem cor.
Nada quebrava, nada logava.

O JIT do Tailwind só emite classe que ele VÊ, e ele não olha `node_modules`.
Então `bg-base-100` e `bg-black/50`, que existem apenas dentro dos componentes
do pacote, não chegavam ao CSS do app — enquanto `bg-primary` e
`border-gray-300` apareciam, porque o app também as usa. É o pior formato de
falha: parcial e sem sinal.

A correção óbvia (`@source "../node_modules/@decocms/native/src"` no app) não
funciona: num checkout linkado esse caminho é um symlink, e o Tailwind não
percorre diretório symlinkado. Testado com glob explícito também — mesmo
resultado.

Então o pacote passa a exportar `@decocms/native/daisy.css`, que fica AO LADO
dos componentes e declara `@source "./**/*.{ts,tsx}"`. O import resolve pelo
node_modules e o `@source` relativo cai no caminho real, linkado ou instalado.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Aplicar um filtro na PLP fazia a lista inteira desmontar e voltar como uma
folha nova, em vez de atualizar no lugar.

A query key carrega os search params, então filtrar é uma chave NOVA sem dado
em cache — a tela cai no estado de loading e todas as seções remontam. E o
mesmo vale seção a seção: um filtro novo dá `lazyUrl` novo para TODA seção
deferred, então mesmo segurando o envelope cada uma piscaria sozinha.

`cmsScreenConfig` e `deferredSectionConfig` passam a devolver
`placeholderData: (previous) => previous` — identidade em vez de importar o
`keepPreviousData`, mesmo comportamento e sem trazer @tanstack/react-query para
as dependências do pacote.

É o equivalente nativo do `export const eager = true` que a SearchResult do
site usa: manter a seção montada entre mudanças de URL, para filtro e ordenação
não sumirem da tela e só a grade trocar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iantável

Um `<Image source={{uri}}>` com a URL crua do CMS baixa o ORIGINAL. Medido num
storefront real: 1,34 MB por foto de produto para desenhar um card de 160 dp, e
1,62 MB no banner da home — ~8 MB só para pintar uma prateleira de seis. Com o
resize do CDN viram 79 KB e 15 KB. 17× e 106×.

Nenhum cache resolve isso: o primeiro carregamento já é o problema, e é o único
que o usuário julga.

O app não deve montar essa URL. Cada CDN tem sintaxe própria — Shopify é
`&width=&height=&crop=center`, VTEX tem `/arquivos/ids/`, decoims é
`/image?fit=&width=&src=`. E notavelmente `?width=` no decoims NÃO FAZ NADA:
devolve o original inteiro, com 200 e sem erro. `getOptimizedMediaUrl`
(@decocms/blocks/hooks) é o mesmo builder do site e cobre os três.

O backend de imagem é INJETADO. `expo-image` traz cache em disco, `transition`
e `recyclingKey` — e é o que um app deveria usar — mas o pacote não depende de
Expo, e nada aqui deve obrigar um app React Native puro a adotá-lo. Sem
registro cai no `Image` do react-native e tudo funciona, só sem cache em disco
nem prefetch. Mesmo padrão do `SiteView` com o WebView.

`prefetchImages` usa a MESMA função de URL do render. Se calculasse o tamanho
por conta própria, aqueceria uma entrada de cache que ninguém pediria — e
pareceria estar funcionando.

`react-native` entra por require preguiçoso: ele é distribuído com tipos Flow,
que nenhum runner em Node parseia, e um import de topo tornaria este módulo (e
qualquer um que o importe) impossível de testar fora de um device. A parte pura
é justamente a que tem regra.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JonasJesus42
JonasJesus42 requested a review from a team August 24, 2026 13:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant