feat(native): @decocms/native — app nativo a partir de um site TanStack - #502
Open
JonasJesus42 wants to merge 19 commits into
Open
feat(native): @decocms/native — app nativo a partir de um site TanStack#502JonasJesus42 wants to merge 19 commits into
JonasJesus42 wants to merge 19 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 novaaparecem 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 cujarota 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/blockse é snapshot, não whitelist: páginapublicada 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
initem vez de num README.1. A plataforma já compartilha a sessão — o jar é que quebrava. No iOS o
fetche o<WebView sharedCookiesEnabled>usam o mesmoNSHTTPCookieStorage(no Android, o mesmo
CookieManager). Um cookie jar próprio manda oCookiedele, o servidor ecoa no
Set-Cookie, a camada nativa regrava, e o valor crescea cada ida e volta — medido de 1190 para 2390 bytes com
,cart=repetido, até obackend não reconhecer mais o id e abrir carrinho novo. Sintoma: "adicionou ao
carrinho, mas a sacola está vazia", com
200em toda requisição.createNativeSession()decide isso uma vez.2. Classe DaisyUI não é inerte em RN — ela derruba a tela.
btncompilapara
var(--border),var(--size),var(--radius-selector), que o DaisyUIdeclara em
:where(:root), :root:has(...).:has()não existe em RN, oNativeWind descarta o bloco, e o runtime estoura em
.length.@decocms/native/daisycobre as 13 famílias traduzindo a string de classe parautilitá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-nativedo node_modules DELE. Duas cópias sãodois 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.jsscaffoldado 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.
DecoImageusa ogetOptimizedMediaUrldo próprio framework —e notavelmente
?width=no decoims não faz nada, devolve o original com200.
Superfície
@decocms/nativecmsScreenConfig,DecoSections,SiteView,createNativeSession,createNativeInvoke, política de rota@decocms/native/daisy@decocms/native/imageDecoImage,prefetchImages, backend injetado@decocms/native/pushdeco-native initblocks-cli --platform nativeroutes.gen.ts+invoke.native.gen.tstipadoZero dependência de Expo em runtime. As menções vivem em templates do
init, que viram código do app. Peers:react,react-native, enativewindopcional (só o
/daisye o/imageprecisam). Backends nativos — WebView,imagem — são injetados, pelo mesmo motivo.
Fora do pacote
Correções em
@decocms/blocksque o consumo real expôs:NestedFromFlatnuncafuncionou (o invoke tipado dava
never),getSiteBlocklia a chave errada dodecofile,
schema.tsnão compilava sobstrictde consumidor, e orequestContextStoragecaía na implementação comnode:async_hooksem RN.Verificação
packages/native;bun run checklimpo (as falhas deblocks-clisão pré-existentes, dee38fc15).na sacola nativa, com subtotal certo e checkout.
@decocms/apps-shopify.O que sei que não está pronto
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 Shopifyporque ele pagina por cursor), mas não roda ainda.
própria URL; o da VTEX não, então a conclusão sobre sessão precisa ser
revalidada lá.
/deco/invokecontinua inexistente — decisão de designpré-existente, mais urgente com um app publicado.
carrinho levou.
ScrollViewdoDecoScreenporFlatList.🤖 Generated with Claude Code
Summary by cubic
Ships
@decocms/nativeto 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 inrenderJson, exportsmergeSections, and makesrequestContextStorageRN-safe.blocks-cli --platform nativenow emits.deco/routes.gen.ts(compiled CMS paths for device) and.deco/invoke.native.gen.ts(types for/deco/invoke/*).DecoSections,cmsScreenConfig(TanStack Query options), andSiteViewfor WebView fallbacks; device-safe root export.createNativeSessiondefers to the platform cookie store by default;createNativeInvokecalls/deco/invoke/*with cookie continuity.@decocms/native/daisytranslates DaisyUI classes to RN components;@decocms/native/imagebuilds CDN-sized URLs and supports prefetch.renderJsonreads the site block viagetSiteBlock()(works withSite.jsonandsite.json);mergeSectionsis now exported from@decocms/blocks/cms.@decocms/blocks/sdk/requestContextStorageadds areact-nativecondition to avoidnode:async_hooksin RN.NestedFromFlat) fixed so deep keys no longer collapse tonever.Adoption
bun run blocks-cli generate --platform nativeto produce.deco/routes.gen.tsand.deco/invoke.native.gen.ts.npx deco-native initto add Metro config and wiring; wire the generated files into your registry and route policy.react-native-webviewand pass it toSiteView.@decocms/native/daisy, import@decocms/native/daisy.cssin your Tailwind entry.Written for commit 7a191f0. Summary will update on new commits.