@decocms/start 0.28.3 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/hydration-and-ssr-migration.md +616 -0
- package/docs/next-steps-tanstack-native.md +333 -0
- package/package.json +8 -2
- package/src/cms/index.ts +1 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +7 -0
- package/src/cms/resolve.test.ts +67 -0
- package/src/cms/resolve.ts +29 -1
- package/src/hooks/DecoPageRenderer.tsx +125 -14
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +7 -0
- package/src/middleware/validateSection.test.ts +117 -0
- package/src/middleware/validateSection.ts +91 -0
- package/src/routes/cmsRoute.ts +134 -4
- package/src/routes/components.tsx +21 -3
- package/src/sdk/index.ts +2 -1
- package/src/sdk/useDevice.test.ts +104 -0
- package/src/sdk/useDevice.ts +28 -6
- package/src/sdk/useHydrated.ts +19 -0
- package/src/sdk/useScript.test.ts +53 -0
- package/src/sdk/useScript.ts +49 -1
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# Proximos Passos — TanStack Native Patterns
|
|
2
|
+
|
|
3
|
+
> O que falta implementar no `@decocms/start` para usar o potencial completo do TanStack Router/Start.
|
|
4
|
+
> Ordenado por impacto e prioridade.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Status Atual
|
|
9
|
+
|
|
10
|
+
| Pattern | Status | Onde |
|
|
11
|
+
|---------|--------|------|
|
|
12
|
+
| `<Await>` + deferred streaming | ✅ Feito | `DecoPageRenderer.tsx`, `cmsRoute.ts` |
|
|
13
|
+
| `createServerFn()` | ✅ Feito | `cmsRoute.ts` |
|
|
14
|
+
| `pendingComponent` | ✅ Feito | `cmsRouteConfig` |
|
|
15
|
+
| `ClientOnly` | ❌ Nao usado | — |
|
|
16
|
+
| `useHydrated` | ❌ Nao usado | — |
|
|
17
|
+
| `createIsomorphicFn` | ❌ Nao usado | — |
|
|
18
|
+
| `pendingMs` / `pendingMinMs` | ❌ Nao configurado | — |
|
|
19
|
+
| Preload/Prefetch strategies | ❌ Nao configurado | — |
|
|
20
|
+
| `ssr: 'data-only'` | ❌ Nao usado | — |
|
|
21
|
+
| `useScript(fn)` deprecation | ⚠️ Parcial | `sdk/useScript.ts` |
|
|
22
|
+
| `clientOnly` sections | ❌ Nao implementado | — |
|
|
23
|
+
| Dev warnings | ⚠️ Parcial | `DecoPageRenderer.tsx` |
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## P0 — Alta Prioridade
|
|
28
|
+
|
|
29
|
+
### 1. `<ClientOnly>` para sections analytics/third-party
|
|
30
|
+
|
|
31
|
+
**Problema**: Scripts de analytics (GTM, Emarsys, Sourei) injetados no `<head>` quebram hydration (P4 do doc original). `useScript(fn)` gera hydration mismatch (P3).
|
|
32
|
+
|
|
33
|
+
**Solucao**: Usar `<ClientOnly>` do TanStack Router para wrapping automatico.
|
|
34
|
+
|
|
35
|
+
**Arquivos**:
|
|
36
|
+
- `src/cms/registry.ts` — adicionar opcao `clientOnly` em `registerSection()` e `registerSectionsSync()`
|
|
37
|
+
- `src/hooks/DecoPageRenderer.tsx` — wrapping automatico com `<ClientOnly>` quando section marcada
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
// registry.ts
|
|
41
|
+
registerSection("site/sections/Sourei/Sourei.tsx", SoureiModule, {
|
|
42
|
+
clientOnly: true,
|
|
43
|
+
loadingFallback: () => null,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// DecoPageRenderer.tsx — render path
|
|
47
|
+
import { ClientOnly } from "@tanstack/react-router";
|
|
48
|
+
|
|
49
|
+
if (sectionOptions?.clientOnly) {
|
|
50
|
+
return (
|
|
51
|
+
<ClientOnly fallback={sectionOptions.loadingFallback?.() ?? null}>
|
|
52
|
+
<LazyComponent {...section.props} />
|
|
53
|
+
</ClientOnly>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Impacto**: Elimina P3 e P4 do doc de migracao sem mudanca no site.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
### 2. `inlineScript(str)` helper — substituir `useScript(fn)`
|
|
63
|
+
|
|
64
|
+
**Problema**: `useScript(fn)` usa `fn.toString()` que gera output diferente no SSR vs client (Vite compila separado). Causa hydration mismatch no `dangerouslySetInnerHTML.__html`.
|
|
65
|
+
|
|
66
|
+
**Solucao**: Deprecar `useScript(fn)`, adicionar `inlineScript(str)` que aceita string constante.
|
|
67
|
+
|
|
68
|
+
**Arquivo**: `src/sdk/useScript.ts`
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
/** @deprecated fn.toString() differs SSR vs client. Use inlineScript(str) instead. */
|
|
72
|
+
export function useScript(fn: Function, ...args: unknown[]): string {
|
|
73
|
+
if (import.meta.env?.DEV) {
|
|
74
|
+
console.warn(
|
|
75
|
+
`[useScript] fn.toString() for "${fn.name || 'anonymous'}" may cause hydration mismatch. ` +
|
|
76
|
+
`Use inlineScript() with a plain string constant instead.`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
// ... existing implementation
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Safe inline script — returns props for <script> element. */
|
|
83
|
+
export function inlineScript(js: string) {
|
|
84
|
+
return { dangerouslySetInnerHTML: { __html: js } } as const;
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Impacto**: Resolve P3 para novos usos. Warning guia migracao de usos existentes.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
### 3. `useHydrated()` para substituir `typeof document === "undefined"`
|
|
93
|
+
|
|
94
|
+
**Problema**: Varios locais usam `typeof document === "undefined"` para detectar SSR. Isso e fragil e nao e reativo.
|
|
95
|
+
|
|
96
|
+
**Solucao**: Re-exportar `useHydrated` do TanStack Router como parte do SDK.
|
|
97
|
+
|
|
98
|
+
**Arquivo**: `src/sdk/useHydrated.ts` (novo)
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
export { useHydrated } from "@tanstack/react-router";
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Arquivos afetados**:
|
|
105
|
+
- `src/hooks/DecoPageRenderer.tsx:248` — `const isSSR = typeof document === "undefined"` → `const hydrated = useHydrated()`
|
|
106
|
+
- `src/hooks/DecoPageRenderer.tsx:242` — useState initializer
|
|
107
|
+
|
|
108
|
+
**Impacto**: Pattern mais robusto e alinhado com TanStack.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## P1 — Media Prioridade
|
|
113
|
+
|
|
114
|
+
### 4. `pendingMs` e `pendingMinMs` em `CmsRouteOptions`
|
|
115
|
+
|
|
116
|
+
**Problema**: Sem configuracao de delay, o `pendingComponent` (skeleton) aparece imediatamente em toda navegacao SPA, mesmo quando o cache hit e instantaneo. Causa flash desnecessario.
|
|
117
|
+
|
|
118
|
+
**Solucao**: Expor `pendingMs` e `pendingMinMs` no route config.
|
|
119
|
+
|
|
120
|
+
**Arquivo**: `src/routes/cmsRoute.ts`
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
export interface CmsRouteOptions {
|
|
124
|
+
// ... existing
|
|
125
|
+
/** Delay (ms) before showing pendingComponent. Default: 200 */
|
|
126
|
+
pendingMs?: number;
|
|
127
|
+
/** Minimum display time (ms) for pendingComponent once shown. Default: 300 */
|
|
128
|
+
pendingMinMs?: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// No return do cmsRouteConfig:
|
|
132
|
+
return {
|
|
133
|
+
// ...
|
|
134
|
+
pendingMs: options.pendingMs ?? 200,
|
|
135
|
+
pendingMinMs: options.pendingMinMs ?? 300,
|
|
136
|
+
};
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Impacto**: UX mais suave — skeleton so aparece em loads lentos.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
### 5. Preload/Prefetch strategy no route config
|
|
144
|
+
|
|
145
|
+
**Problema**: Nao ha prefetching configurado. Navegacao SPA sempre espera o loader rodar do zero.
|
|
146
|
+
|
|
147
|
+
**Solucao**: Documentar e configurar `defaultPreload: 'intent'` como recomendacao.
|
|
148
|
+
|
|
149
|
+
**Onde**: Documentacao + exemplo no site consumer.
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
// No createRouter() do site:
|
|
153
|
+
const router = createRouter({
|
|
154
|
+
defaultPreload: "intent", // prefetch on hover
|
|
155
|
+
defaultPreloadDelay: 50, // 50ms antes de iniciar
|
|
156
|
+
defaultPreloadStaleTime: 5 * 60 * 1000, // 5min = alinhado com staleTime do cmsRouteConfig
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**Impacto**: Navegacao SPA fica instantanea quando usuario hovera links. Alinhado com staleTime do cache.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
### 6. `createIsomorphicFn` para device detection
|
|
165
|
+
|
|
166
|
+
**Problema**: `useDevice()` e um hook React — nao pode ser usado em loaders. Device detection no loader usa header parsing manual.
|
|
167
|
+
|
|
168
|
+
**Solucao**: Criar versao isomorfica com `createIsomorphicFn`.
|
|
169
|
+
|
|
170
|
+
**Arquivo**: `src/sdk/useDevice.ts`
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
import { createIsomorphicFn } from "@tanstack/react-start";
|
|
174
|
+
|
|
175
|
+
export const getDevice = createIsomorphicFn()
|
|
176
|
+
.server(() => {
|
|
177
|
+
const ua = getRequestHeader("user-agent") ?? "";
|
|
178
|
+
return detectDevice(ua);
|
|
179
|
+
})
|
|
180
|
+
.client(() => {
|
|
181
|
+
return window.innerWidth < 768 ? "mobile" : "desktop";
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Impacto**: Device detection consistente em qualquer contexto (loader, component, middleware).
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## P2 — Baixa Prioridade (mas valioso)
|
|
190
|
+
|
|
191
|
+
### 7. `ssr: 'data-only'` para rotas interativas
|
|
192
|
+
|
|
193
|
+
**Problema**: PDPs com zoom de imagem, seletor de variante, e muitos useEffects podem ter hydration lenta. O servidor renderiza HTML que sera descartado pelo client de qualquer forma.
|
|
194
|
+
|
|
195
|
+
**Solucao**: Permitir `ssr: 'data-only'` por rota no `cmsRouteConfig`.
|
|
196
|
+
|
|
197
|
+
**Arquivo**: `src/routes/cmsRoute.ts`
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
export interface CmsRouteOptions {
|
|
201
|
+
// ... existing
|
|
202
|
+
/** SSR mode: true (default), 'data-only', or false */
|
|
203
|
+
ssr?: boolean | 'data-only';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// No return:
|
|
207
|
+
return {
|
|
208
|
+
ssr: options.ssr ?? true,
|
|
209
|
+
// ...
|
|
210
|
+
};
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**Caso de uso**: PDP carrega dados no server (precos, estoque), mas renderiza no client:
|
|
214
|
+
```tsx
|
|
215
|
+
export const Route = createFileRoute("/product/$slug")({
|
|
216
|
+
...cmsRouteConfig({ siteName: "Store", defaultTitle: "Product", ssr: "data-only" }),
|
|
217
|
+
pendingComponent: PDPSkeleton,
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Impacto**: TTFB mais rapido para rotas complexas. Skeleton aparece imediatamente, dados ja carregados.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
### 8. Dev warnings para misconfiguracao
|
|
226
|
+
|
|
227
|
+
**Problema**: Erros silenciosos — section eager sem `registerSectionsSync` renderiza em branco, section deferred sem LoadingFallback mostra skeleton generico.
|
|
228
|
+
|
|
229
|
+
**O que ja existe**: `DevMissingFallbackWarning` em `DecoPageRenderer.tsx`.
|
|
230
|
+
|
|
231
|
+
**O que falta**:
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
// Em DecoPageRenderer.tsx — warn eager section sem sync registration
|
|
235
|
+
if (import.meta.env.DEV && section.type === "eager" && !getSyncComponent(section.component)) {
|
|
236
|
+
console.warn(
|
|
237
|
+
`[DecoPageRenderer] Section "${section.component}" is eager but not in registerSectionsSync(). ` +
|
|
238
|
+
`This may cause blank content during hydration. Add it to registerSectionsSync() in setup.ts.`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Em useScript.ts — warn fn.toString() risk (ja descrito acima)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**Impacto**: DX melhor — erros aparecem cedo no dev ao inves de bugs sutis em prod.
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
### 9. Server function middleware para validacao
|
|
250
|
+
|
|
251
|
+
**Problema**: `loadDeferredSection` aceita `rawProps` sem validacao. Requests malformados podem causar erros inesperados nos section loaders.
|
|
252
|
+
|
|
253
|
+
**Solucao**: Usar `createMiddleware({ type: 'function' })` do TanStack Start para validar input.
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
import { createMiddleware } from "@tanstack/react-start";
|
|
257
|
+
|
|
258
|
+
const validateSectionInput = createMiddleware({ type: "function" })
|
|
259
|
+
.server(async ({ next, data }) => {
|
|
260
|
+
if (!data?.component || typeof data.component !== "string") {
|
|
261
|
+
throw new Error("Invalid section component");
|
|
262
|
+
}
|
|
263
|
+
return next();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
export const loadDeferredSection = createServerFn({ method: "POST" })
|
|
267
|
+
.middleware([validateSectionInput])
|
|
268
|
+
.handler(async (ctx) => { ... });
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**Impacto**: Seguranca e robustez — previne erros obscuros de runtime.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
### 10. Hydration context via middleware
|
|
276
|
+
|
|
277
|
+
**Problema**: Locale, timezone, e feature flags precisam ser consistentes entre SSR e client. Atualmente nao ha pattern padrao.
|
|
278
|
+
|
|
279
|
+
**Solucao**: Middleware que injeta contexto de hydration.
|
|
280
|
+
|
|
281
|
+
```tsx
|
|
282
|
+
// Site-level middleware
|
|
283
|
+
const hydrationContext = createMiddleware().server(async ({ request, next }) => {
|
|
284
|
+
const locale = getCookie("locale") || request.headers.get("accept-language")?.split(",")[0] || "en-US";
|
|
285
|
+
const tz = getCookie("tz") || "UTC";
|
|
286
|
+
return next({ context: { locale, timeZone: tz } });
|
|
287
|
+
});
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**Impacto**: Previne hydration mismatches de locale/timezone sem workarounds manuais.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Ordem de Implementacao Recomendada
|
|
295
|
+
|
|
296
|
+
```
|
|
297
|
+
P0 (resolver problemas existentes):
|
|
298
|
+
1. ClientOnly para sections analytics → elimina P3/P4
|
|
299
|
+
2. inlineScript(str) helper → substitui useScript(fn)
|
|
300
|
+
3. Re-export useHydrated → pattern moderno
|
|
301
|
+
|
|
302
|
+
P1 (melhorar UX):
|
|
303
|
+
4. pendingMs/pendingMinMs → sem flash em loads rapidos
|
|
304
|
+
5. Documentar preload: 'intent' → navegacao instantanea
|
|
305
|
+
6. createIsomorphicFn para device → device detection universal
|
|
306
|
+
|
|
307
|
+
P2 (refinamento):
|
|
308
|
+
7. ssr: 'data-only' para PDPs → TTFB rapido para rotas pesadas
|
|
309
|
+
8. Dev warnings expandidos → DX melhor
|
|
310
|
+
9. Middleware de validacao → seguranca
|
|
311
|
+
10. Hydration context middleware → locale/timezone consistente
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Referencia — Comandos TanStack CLI
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
# Pesquisar patterns
|
|
320
|
+
tanstack search-docs "ClientOnly" --library router --json
|
|
321
|
+
tanstack search-docs "useHydrated" --library router --json
|
|
322
|
+
tanstack search-docs "createIsomorphicFn" --library start --json
|
|
323
|
+
tanstack search-docs "pendingMs pendingMinMs" --library router --json
|
|
324
|
+
tanstack search-docs "preload intent viewport" --library router --json
|
|
325
|
+
tanstack search-docs "ssr data-only selective" --library start --json
|
|
326
|
+
|
|
327
|
+
# Ler docs especificos
|
|
328
|
+
tanstack doc router api/router/clientOnlyComponent --json
|
|
329
|
+
tanstack doc start framework/react/guide/selective-ssr --json
|
|
330
|
+
tanstack doc start framework/react/guide/execution-model --json
|
|
331
|
+
tanstack doc start framework/react/guide/middleware --json
|
|
332
|
+
tanstack doc router guide/preloading --json
|
|
333
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/start",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -27,8 +27,11 @@
|
|
|
27
27
|
"./sdk/redirects": "./src/sdk/redirects.ts",
|
|
28
28
|
"./sdk/sitemap": "./src/sdk/sitemap.ts",
|
|
29
29
|
"./sdk/useDevice": "./src/sdk/useDevice.ts",
|
|
30
|
+
"./sdk/useHydrated": "./src/sdk/useHydrated.ts",
|
|
30
31
|
"./middleware": "./src/middleware/index.ts",
|
|
31
32
|
"./middleware/healthMetrics": "./src/middleware/healthMetrics.ts",
|
|
33
|
+
"./middleware/hydrationContext": "./src/middleware/hydrationContext.ts",
|
|
34
|
+
"./middleware/validateSection": "./src/middleware/validateSection.ts",
|
|
32
35
|
"./sdk/wrapCaughtErrors": "./src/sdk/wrapCaughtErrors.ts",
|
|
33
36
|
"./sdk/csp": "./src/sdk/csp.ts",
|
|
34
37
|
"./sdk/urlUtils": "./src/sdk/urlUtils.ts",
|
|
@@ -47,6 +50,7 @@
|
|
|
47
50
|
},
|
|
48
51
|
"scripts": {
|
|
49
52
|
"build": "tsc",
|
|
53
|
+
"test": "vitest run",
|
|
50
54
|
"typecheck": "tsc --noEmit",
|
|
51
55
|
"lint": "biome check src/ scripts/",
|
|
52
56
|
"lint:fix": "biome check --write src/ scripts/",
|
|
@@ -85,9 +89,11 @@
|
|
|
85
89
|
"@tanstack/store": "^0.9.1",
|
|
86
90
|
"@types/react": "^19.0.0",
|
|
87
91
|
"@types/react-dom": "^19.0.0",
|
|
92
|
+
"jsdom": "^29.0.0",
|
|
88
93
|
"knip": "^5.86.0",
|
|
89
94
|
"ts-morph": "^27.0.0",
|
|
90
95
|
"typescript": "^5.9.0",
|
|
91
|
-
"vite": ">=6.0.0"
|
|
96
|
+
"vite": ">=6.0.0",
|
|
97
|
+
"vitest": "^4.1.0"
|
|
92
98
|
}
|
|
93
99
|
}
|
package/src/cms/index.ts
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
registerSection,
|
|
4
|
+
registerSectionsSync,
|
|
5
|
+
getSection,
|
|
6
|
+
getSectionOptions,
|
|
7
|
+
getSyncComponent,
|
|
8
|
+
getResolvedComponent,
|
|
9
|
+
} from "./registry";
|
|
10
|
+
|
|
11
|
+
// Reset globalThis.__deco between tests to avoid cross-test pollution
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
const G = globalThis as any;
|
|
14
|
+
G.__deco.sectionRegistry = {};
|
|
15
|
+
G.__deco.sectionOptions = {};
|
|
16
|
+
G.__deco.resolvedComponents = {};
|
|
17
|
+
G.__deco.syncComponents = {};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("registerSection + getSection", () => {
|
|
21
|
+
it("registers and retrieves a section loader", () => {
|
|
22
|
+
const loader = async () => ({ default: () => null });
|
|
23
|
+
registerSection("site/sections/Hero.tsx", loader);
|
|
24
|
+
|
|
25
|
+
const retrieved = getSection("site/sections/Hero.tsx");
|
|
26
|
+
expect(retrieved).toBe(loader);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns undefined for unregistered section", () => {
|
|
30
|
+
expect(getSection("nonexistent")).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("registerSection with options", () => {
|
|
35
|
+
it("stores loadingFallback in section options", () => {
|
|
36
|
+
const fallback = () => null;
|
|
37
|
+
registerSection(
|
|
38
|
+
"site/sections/Shelf.tsx",
|
|
39
|
+
async () => ({ default: () => null }),
|
|
40
|
+
{ loadingFallback: fallback },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const opts = getSectionOptions("site/sections/Shelf.tsx");
|
|
44
|
+
expect(opts?.loadingFallback).toBe(fallback);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("stores clientOnly flag in section options", () => {
|
|
48
|
+
registerSection(
|
|
49
|
+
"site/sections/Analytics.tsx",
|
|
50
|
+
async () => ({ default: () => null }),
|
|
51
|
+
{ clientOnly: true },
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const opts = getSectionOptions("site/sections/Analytics.tsx");
|
|
55
|
+
expect(opts?.clientOnly).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("clientOnly defaults to undefined when not set", () => {
|
|
59
|
+
registerSection(
|
|
60
|
+
"site/sections/Normal.tsx",
|
|
61
|
+
async () => ({ default: () => null }),
|
|
62
|
+
{ loadingFallback: () => null },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const opts = getSectionOptions("site/sections/Normal.tsx");
|
|
66
|
+
expect(opts?.clientOnly).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("clientOnly false is preserved", () => {
|
|
70
|
+
registerSection(
|
|
71
|
+
"site/sections/Explicit.tsx",
|
|
72
|
+
async () => ({ default: () => null }),
|
|
73
|
+
{ clientOnly: false },
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const opts = getSectionOptions("site/sections/Explicit.tsx");
|
|
77
|
+
expect(opts?.clientOnly).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns undefined options for section without options", () => {
|
|
81
|
+
registerSection("site/sections/Plain.tsx", async () => ({ default: () => null }));
|
|
82
|
+
expect(getSectionOptions("site/sections/Plain.tsx")).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("registerSectionsSync", () => {
|
|
87
|
+
it("registers component as sync and resolved", () => {
|
|
88
|
+
const MyComponent = () => null;
|
|
89
|
+
registerSectionsSync({ "site/sections/Header.tsx": MyComponent });
|
|
90
|
+
|
|
91
|
+
expect(getSyncComponent("site/sections/Header.tsx")).toBe(MyComponent);
|
|
92
|
+
expect(getResolvedComponent("site/sections/Header.tsx")).toBe(MyComponent);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("accepts module objects with LoadingFallback", () => {
|
|
96
|
+
const MyComponent = () => null;
|
|
97
|
+
const MyFallback = () => null;
|
|
98
|
+
|
|
99
|
+
registerSectionsSync({
|
|
100
|
+
"site/sections/Footer.tsx": {
|
|
101
|
+
default: MyComponent,
|
|
102
|
+
LoadingFallback: MyFallback,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(getSyncComponent("site/sections/Footer.tsx")).toBe(MyComponent);
|
|
107
|
+
const opts = getSectionOptions("site/sections/Footer.tsx");
|
|
108
|
+
expect(opts?.loadingFallback).toBe(MyFallback);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("skips entries without callable default export", () => {
|
|
112
|
+
registerSectionsSync({
|
|
113
|
+
"site/sections/Bad.tsx": { default: "not a function" } as any,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(getSyncComponent("site/sections/Bad.tsx")).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
});
|
package/src/cms/registry.ts
CHANGED
|
@@ -14,6 +14,13 @@ export interface SectionOptions {
|
|
|
14
14
|
loadingFallback?: ComponentType<any>;
|
|
15
15
|
/** Custom error fallback component for this section. */
|
|
16
16
|
errorFallback?: ComponentType<{ error: Error }>;
|
|
17
|
+
/**
|
|
18
|
+
* When true, the section is wrapped in `<ClientOnly>` from TanStack Router.
|
|
19
|
+
* It renders only on the client — no SSR, no hydration mismatch.
|
|
20
|
+
* Use for analytics scripts, GTM, third-party widgets, and other
|
|
21
|
+
* browser-dependent components.
|
|
22
|
+
*/
|
|
23
|
+
clientOnly?: boolean;
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
// globalThis-backed: server function split modules need access to the registry
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock dependencies before importing the module under test
|
|
4
|
+
vi.mock("./sectionLoaders", () => ({
|
|
5
|
+
isLayoutSection: () => false,
|
|
6
|
+
runSingleSectionLoader: vi.fn(async (section: any) => section),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("../sdk/normalizeUrls", () => ({
|
|
10
|
+
normalizeUrlsInObject: vi.fn(<T>(x: T) => x),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("./loader", () => ({
|
|
14
|
+
findPageByPath: vi.fn(),
|
|
15
|
+
loadBlocks: vi.fn(() => ({})),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("./registry", () => ({
|
|
19
|
+
getSection: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { resolveDeferredSectionFull } from "./resolve";
|
|
23
|
+
import { runSingleSectionLoader } from "./sectionLoaders";
|
|
24
|
+
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
25
|
+
import type { DeferredSection } from "./resolve";
|
|
26
|
+
|
|
27
|
+
describe("resolveDeferredSectionFull", () => {
|
|
28
|
+
it("resolves a deferred section and preserves index", async () => {
|
|
29
|
+
const ds: DeferredSection = {
|
|
30
|
+
component: "site/sections/ProductShelf.tsx",
|
|
31
|
+
key: "site/sections/ProductShelf.tsx",
|
|
32
|
+
index: 5,
|
|
33
|
+
rawProps: { title: "Best Sellers" },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const request = new Request("https://store.com/");
|
|
37
|
+
|
|
38
|
+
// resolveDeferredSection depends on ensureInitialized() and CMS internals.
|
|
39
|
+
// Since we can't easily mock the full resolution pipeline, we test that
|
|
40
|
+
// the function composes correctly by verifying it calls the right deps.
|
|
41
|
+
// A full integration test would require a running CMS context.
|
|
42
|
+
|
|
43
|
+
// For now, verify the function signature is correct and types align
|
|
44
|
+
expect(typeof resolveDeferredSectionFull).toBe("function");
|
|
45
|
+
expect(resolveDeferredSectionFull.length).toBe(4); // ds, pagePath, request, matcherCtx?
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("runSingleSectionLoader is called with enriched section", async () => {
|
|
49
|
+
// Verify the mock is correctly set up
|
|
50
|
+
const mockSection = {
|
|
51
|
+
component: "test",
|
|
52
|
+
props: { title: "hi" },
|
|
53
|
+
key: "test",
|
|
54
|
+
index: 3,
|
|
55
|
+
};
|
|
56
|
+
const request = new Request("https://store.com/");
|
|
57
|
+
|
|
58
|
+
const result = await (runSingleSectionLoader as any)(mockSection, request);
|
|
59
|
+
expect(result).toEqual(mockSection);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("normalizeUrlsInObject is used for output normalization", () => {
|
|
63
|
+
const input = { url: "https://store.com/image.jpg" };
|
|
64
|
+
const result = (normalizeUrlsInObject as any)(input);
|
|
65
|
+
expect(result).toEqual(input); // mock passes through
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/cms/resolve.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { findPageByPath, loadBlocks } from "./loader";
|
|
2
2
|
import { getSection } from "./registry";
|
|
3
|
-
import { isLayoutSection } from "./sectionLoaders";
|
|
3
|
+
import { isLayoutSection, runSingleSectionLoader } from "./sectionLoaders";
|
|
4
|
+
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
4
5
|
|
|
5
6
|
// globalThis-backed: share state across Vite server function split modules
|
|
6
7
|
const G = globalThis as any;
|
|
@@ -1375,3 +1376,30 @@ export async function resolveDeferredSection(
|
|
|
1375
1376
|
key: component,
|
|
1376
1377
|
};
|
|
1377
1378
|
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* Resolve AND enrich a deferred section in one call.
|
|
1382
|
+
* Combines CMS prop resolution (resolveDeferredSection) with section loader
|
|
1383
|
+
* enrichment (runSingleSectionLoader) and URL normalization.
|
|
1384
|
+
*
|
|
1385
|
+
* Used by the route loader to create streaming promises for TanStack's
|
|
1386
|
+
* native deferred data pattern — each deferred section becomes an unawaited
|
|
1387
|
+
* promise that TanStack streams via SSR.
|
|
1388
|
+
*/
|
|
1389
|
+
export async function resolveDeferredSectionFull(
|
|
1390
|
+
ds: DeferredSection,
|
|
1391
|
+
pagePath: string,
|
|
1392
|
+
request: Request,
|
|
1393
|
+
matcherCtx?: MatcherContext,
|
|
1394
|
+
): Promise<ResolvedSection | null> {
|
|
1395
|
+
const section = await resolveDeferredSection(
|
|
1396
|
+
ds.component,
|
|
1397
|
+
ds.rawProps,
|
|
1398
|
+
pagePath,
|
|
1399
|
+
matcherCtx,
|
|
1400
|
+
);
|
|
1401
|
+
if (!section) return null;
|
|
1402
|
+
section.index = ds.index;
|
|
1403
|
+
const enriched = await runSingleSectionLoader(section, request);
|
|
1404
|
+
return normalizeUrlsInObject(enriched);
|
|
1405
|
+
}
|