@decocms/start 0.38.0 → 0.40.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.
Files changed (41) hide show
  1. package/.agents/skills/deco-migrate-script/SKILL.md +434 -0
  2. package/.agents/skills/deco-to-tanstack-migration/SKILL.md +382 -0
  3. package/.agents/skills/deco-to-tanstack-migration/references/admin-cms.md +154 -0
  4. package/{.cursor/skills/deco-async-rendering-site-guide/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/async-rendering.md} +296 -31
  5. package/.agents/skills/deco-to-tanstack-migration/references/codemod-commands.md +174 -0
  6. package/.agents/skills/deco-to-tanstack-migration/references/commerce/README.md +78 -0
  7. package/.agents/skills/deco-to-tanstack-migration/references/css-styling.md +156 -0
  8. package/.agents/skills/deco-to-tanstack-migration/references/deco-framework/README.md +128 -0
  9. package/.agents/skills/deco-to-tanstack-migration/references/gotchas.md +13 -0
  10. package/{.cursor/skills/deco-tanstack-hydration-fixes/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/hydration-fixes.md} +139 -4
  11. package/.agents/skills/deco-to-tanstack-migration/references/imports/README.md +70 -0
  12. package/{.cursor/skills/deco-islands-migration/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/islands.md} +0 -14
  13. package/.agents/skills/deco-to-tanstack-migration/references/jsx-migration.md +80 -0
  14. package/.agents/skills/deco-to-tanstack-migration/references/matchers.md +1064 -0
  15. package/{.cursor/skills/deco-tanstack-navigation/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/navigation.md} +1 -16
  16. package/.agents/skills/deco-to-tanstack-migration/references/platform-hooks/README.md +154 -0
  17. package/.agents/skills/deco-to-tanstack-migration/references/react-hooks-patterns.md +142 -0
  18. package/.agents/skills/deco-to-tanstack-migration/references/react-signals-state.md +72 -0
  19. package/{.cursor/skills/deco-tanstack-search/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/search.md} +1 -13
  20. package/.agents/skills/deco-to-tanstack-migration/references/signals/README.md +220 -0
  21. package/{.cursor/skills/deco-tanstack-storefront-patterns/SKILL.md → .agents/skills/deco-to-tanstack-migration/references/storefront-patterns.md} +1 -137
  22. package/.agents/skills/deco-to-tanstack-migration/references/vite-config/README.md +78 -0
  23. package/.agents/skills/deco-to-tanstack-migration/references/vtex-commerce.md +165 -0
  24. package/.agents/skills/deco-to-tanstack-migration/references/worker-cloudflare.md +209 -0
  25. package/.agents/skills/deco-to-tanstack-migration/templates/package-json.md +55 -0
  26. package/.agents/skills/deco-to-tanstack-migration/templates/root-route.md +110 -0
  27. package/.agents/skills/deco-to-tanstack-migration/templates/router.md +96 -0
  28. package/.agents/skills/deco-to-tanstack-migration/templates/setup-ts.md +167 -0
  29. package/.agents/skills/deco-to-tanstack-migration/templates/vite-config.md +122 -0
  30. package/.agents/skills/deco-to-tanstack-migration/templates/worker-entry.md +67 -0
  31. package/README.md +45 -0
  32. package/package.json +2 -1
  33. package/src/admin/index.ts +2 -0
  34. package/src/admin/invoke.ts +53 -5
  35. package/src/admin/setup.ts +7 -1
  36. package/src/apps/autoconfig.ts +50 -72
  37. package/src/sdk/invoke.ts +123 -12
  38. package/src/sdk/requestContext.ts +42 -0
  39. package/src/sdk/setupApps.ts +211 -0
  40. package/src/sdk/workerEntry.ts +6 -0
  41. package/.cursor/skills/deco-async-rendering-architecture/SKILL.md +0 -270
@@ -0,0 +1,1064 @@
1
+ # Matchers: Architecture & Migration
2
+
3
+ ## Part 1: Architecture
4
+
5
+ Internal documentation for the matcher system in `@decocms/start` + `deco-cx/apps`.
6
+
7
+ ### When to Use This Reference
8
+
9
+ - Creating a new custom matcher (server-side or client-side)
10
+ - Debugging A/B tests or flags not activating
11
+ - Fixing CLS caused by unregistered/unknown matchers
12
+ - Understanding how flags evaluate variants (first-match wins)
13
+ - Implementing geo-targeted content with Cloudflare Workers
14
+ - Adding location, device, or session-based content variants
15
+
16
+ ---
17
+
18
+ ### What Is a Matcher?
19
+
20
+ A **matcher** is a serializable function registered in the CMS that evaluates boolean conditions about the current HTTP request. Matchers power:
21
+
22
+ 1. **A/B testing** — show Variant A to 50% of users, Variant B to the rest
23
+ 2. **Feature flags** — enable features by device, region, cookie, or query param
24
+ 3. **Personalization** — different content per location, session, or user segment
25
+
26
+ In the CMS JSON (decofile), a matcher looks like:
27
+
28
+ ```json
29
+ {
30
+ "__resolveType": "website/flags/multivariate.ts",
31
+ "variants": [
32
+ {
33
+ "rule": { "__resolveType": "website/matchers/device.ts", "mobile": true },
34
+ "value": { "__resolveType": "site/sections/HeroMobile.tsx" }
35
+ },
36
+ {
37
+ "rule": null,
38
+ "value": { "__resolveType": "site/sections/HeroDesktop.tsx" }
39
+ }
40
+ ]
41
+ }
42
+ ```
43
+
44
+ The `rule` is evaluated server-side during page resolution. **First variant that matches wins.**
45
+
46
+ ---
47
+
48
+ ### Architecture Overview
49
+
50
+ ```
51
+ Request
52
+ └─ resolveDecoPage()
53
+ └─ For each multivariate flag:
54
+ └─ evaluateMatcher(rule, matcherCtx)
55
+ ├─ Inline matchers: always, never, device, random, date
56
+ ├─ Custom registry: registerMatcher(key, fn) ← setup.ts
57
+ └─ Unknown key → returns false + console.warn ← CLS risk!
58
+ ```
59
+
60
+ **Critical insight**: Unknown matchers return `false`. If `website/matchers/location.ts` is not registered, ALL location-based variants default to `false`, causing every user to see the "no match" variant — which can trigger React Reconnect events and severe CLS.
61
+
62
+ ---
63
+
64
+ ### MatcherContext
65
+
66
+ Defined in `@decocms/start/src/cms/resolve.ts`:
67
+
68
+ ```typescript
69
+ export interface MatcherContext {
70
+ userAgent?: string; // User-Agent header value
71
+ url?: string; // Full URL with query string
72
+ path?: string; // URL pathname only
73
+ cookies?: Record<string, string>; // Parsed cookies (key → value)
74
+ headers?: Record<string, string>; // All request headers
75
+ request?: Request; // Raw Request object
76
+ }
77
+ ```
78
+
79
+ **Important**: `cookies` is the most reliable field for custom matchers. The `request` object is not always populated by `cmsRoute.ts`. When injecting data from Cloudflare Workers, use the **cookie injection pattern** (see custom-matcher-guide.md).
80
+
81
+ ---
82
+
83
+ ### Evaluation Engine: `evaluateMatcher()`
84
+
85
+ Located in `@decocms/start/src/cms/resolve.ts`:
86
+
87
+ 1. If no rule → returns `true` (default variant, always matches)
88
+ 2. Extracts `__resolveType` from the rule object
89
+ 3. **Inline matchers** (hardcoded switch): `always`, `never`, `device`, `random`, `date`
90
+ 4. **Named block references**: recursively resolves the block, then evaluates
91
+ 5. **Custom registry** (`G.__deco.customMatchers`): looks up `customMatchers[resolveType]` and calls it
92
+ 6. **Unknown type**: returns `false` + logs a `console.warn`
93
+
94
+ ```typescript
95
+ // Pseudo-code of evaluateMatcher:
96
+ function evaluateMatcher(rule, ctx) {
97
+ if (!rule) return true; // no rule = default
98
+ const type = rule.__resolveType;
99
+ switch (type) {
100
+ case "website/matchers/always.ts": return true;
101
+ case "website/matchers/never.ts": return false;
102
+ case "website/matchers/device.ts": return evaluateDevice(rule, ctx);
103
+ case "website/matchers/random.ts": return Math.random() < rule.traffic;
104
+ case "website/matchers/date.ts": return evaluateDate(rule);
105
+ default:
106
+ const fn = G.__deco.customMatchers[type];
107
+ if (fn) return fn(rule, ctx);
108
+ console.warn(`Unknown matcher: ${type}`); // → returns false!
109
+ return false;
110
+ }
111
+ }
112
+ ```
113
+
114
+ ---
115
+
116
+ ### Registering Matchers: Setup
117
+
118
+ Call `registerBuiltinMatchers()` + any custom matchers in `src/setup.ts`, before the app serves requests:
119
+
120
+ ```typescript
121
+ // src/setup.ts
122
+ import { registerBuiltinMatchers } from "@decocms/start/matchers/builtins";
123
+ import { registerLocationMatcher } from "./matchers/location";
124
+
125
+ registerBuiltinMatchers(); // registers cookie, cron, host, pathname, queryString
126
+ registerLocationMatcher(); // registers website/matchers/location.ts
127
+ ```
128
+
129
+ **`registerMatcher(key, fn)` API:**
130
+
131
+ ```typescript
132
+ import { registerMatcher } from "@decocms/start/cms";
133
+
134
+ registerMatcher("website/matchers/my-custom.ts", (rule, ctx) => {
135
+ // rule: the CMS config object for this matcher
136
+ // ctx: MatcherContext with { cookies, path, userAgent, url, headers }
137
+ return Boolean(ctx.cookies?.["feature-flag"] === "enabled");
138
+ });
139
+ ```
140
+
141
+ ---
142
+
143
+ ### Built-in Matchers Reference
144
+
145
+ | Matcher | Key | What it checks |
146
+ |---------|-----|----------------|
147
+ | Always | `website/matchers/always.ts` | Always `true` |
148
+ | Never | `website/matchers/never.ts` | Always `false` |
149
+ | Device | `website/matchers/device.ts` | Mobile/desktop detection via User-Agent |
150
+ | Random | `website/matchers/random.ts` | `Math.random() < traffic` (A/B split) |
151
+ | Date | `website/matchers/date.ts` | Current time within start/end window |
152
+ | Cookie | `website/matchers/cookie.ts` | Cookie name === value |
153
+ | Cron | `website/matchers/cron.ts` | Cron schedule window |
154
+ | Host | `website/matchers/host.ts` | Hostname matches |
155
+ | Pathname | `website/matchers/pathname.ts` | Path regex include/exclude lists |
156
+ | QueryString | `website/matchers/queryString.ts` | Query param (equals/greater/includes/exists) |
157
+ | Location | `website/matchers/location.ts` | Country, region, city (**needs registration!**) |
158
+ | UserAgent | `website/matchers/userAgent.ts` | User-Agent regex |
159
+ | Environment | `website/matchers/environment.ts` | Production vs development |
160
+ | Multi | `website/matchers/multi.ts` | AND/OR combinator of other matchers |
161
+ | Negate | `website/matchers/negate.ts` | Inverts another matcher result |
162
+ | PostHog | `posthog/matchers/featureFlag.ts` | PostHog feature flags |
163
+
164
+ ### Device Matcher
165
+
166
+ ```typescript
167
+ // Rule props:
168
+ { mobile?: boolean; desktop?: boolean }
169
+
170
+ // Evaluates via User-Agent detection
171
+ // Example: show section only on mobile
172
+ { "__resolveType": "website/matchers/device.ts", "mobile": true }
173
+ ```
174
+
175
+ ### Random Matcher (A/B Testing)
176
+
177
+ ```typescript
178
+ // Rule props:
179
+ { traffic: number; sticky?: "session" }
180
+
181
+ // traffic: 0.0 to 1.0 (e.g., 0.5 = 50%)
182
+ // sticky: "session" → stores result in cookie for 30 days (consistent UX)
183
+ { "__resolveType": "website/matchers/random.ts", "traffic": 0.5, "sticky": "session" }
184
+ ```
185
+
186
+ ### Cookie Matcher
187
+
188
+ ```typescript
189
+ // Rule props:
190
+ { name: string; value: string }
191
+
192
+ // Checks if ctx.cookies[name] === value
193
+ { "__resolveType": "website/matchers/cookie.ts", "name": "ab-test", "value": "variant-a" }
194
+ ```
195
+
196
+ ### Pathname Matcher
197
+
198
+ ```typescript
199
+ // Rule props:
200
+ { includePatterns?: string[]; excludePatterns?: string[] }
201
+
202
+ // Each pattern is a regex tested against ctx.path
203
+ { "__resolveType": "website/matchers/pathname.ts", "includePatterns": ["^/categoria/"] }
204
+ ```
205
+
206
+ ### QueryString Matcher
207
+
208
+ ```typescript
209
+ // Rule props:
210
+ { name: string; value?: string; operator: "equals" | "greater" | "includes" | "exists" }
211
+
212
+ { "__resolveType": "website/matchers/queryString.ts", "name": "preview", "operator": "exists" }
213
+ ```
214
+
215
+ ### Location Matcher (requires registration)
216
+
217
+ ```typescript
218
+ // Rule props from deco-cx/apps:
219
+ interface LocationRule {
220
+ includeLocations?: Array<{ country?: string; regionCode?: string; city?: string }>;
221
+ excludeLocations?: Array<{ country?: string; regionCode?: string; city?: string }>;
222
+ }
223
+
224
+ // country: full country name (e.g., "Brasil", "Brazil")
225
+ // regionCode: full state/region name from Cloudflare (e.g., "São Paulo", "Paraná")
226
+ // city: city name (case-insensitive)
227
+ ```
228
+
229
+ ---
230
+
231
+ ### Creating Custom Matchers
232
+
233
+ #### Pattern 1: Cookie-based (simplest)
234
+
235
+ ```typescript
236
+ // src/matchers/preview-mode.ts
237
+ import { registerMatcher } from "@decocms/start/cms";
238
+
239
+ export function registerPreviewMatcher(): void {
240
+ registerMatcher("website/matchers/preview-mode.ts", (rule, ctx) => {
241
+ return ctx.cookies?.["preview"] === "true";
242
+ });
243
+ }
244
+ ```
245
+
246
+ #### Pattern 2: Header-based
247
+
248
+ ```typescript
249
+ import { registerMatcher } from "@decocms/start/cms";
250
+
251
+ export function registerInternalMatcher(): void {
252
+ registerMatcher("website/matchers/internal.ts", (rule, ctx) => {
253
+ const ip = ctx.headers?.["cf-connecting-ip"] ?? "";
254
+ const allowedIPs = (rule as { ips?: string[] }).ips ?? [];
255
+ return allowedIPs.includes(ip);
256
+ });
257
+ }
258
+ ```
259
+
260
+ #### Pattern 3: Cloudflare Geo (server-side location)
261
+
262
+ This pattern solves the #1 CLS cause: `website/matchers/location.ts` returning `false` for all users because it isn't registered.
263
+
264
+ **Step 1**: Inject CF geo data as cookies in `worker-entry.ts`:
265
+
266
+ ```typescript
267
+ // src/worker-entry.ts
268
+ function injectGeoCookies(request: Request): Request {
269
+ const cf = (request as unknown as { cf?: Record<string, string> }).cf;
270
+ if (!cf) return request;
271
+ const parts: string[] = [];
272
+ if (cf.region) parts.push(`__cf_geo_region=${encodeURIComponent(cf.region)}`);
273
+ if (cf.country) parts.push(`__cf_geo_country=${encodeURIComponent(cf.country)}`);
274
+ if (cf.city) parts.push(`__cf_geo_city=${encodeURIComponent(cf.city)}`);
275
+ if (!parts.length) return request;
276
+ const existing = request.headers.get("cookie") ?? "";
277
+ const combined = existing ? `${existing}; ${parts.join("; ")}` : parts.join("; ");
278
+ const headers = new Headers(request.headers);
279
+ headers.set("cookie", combined);
280
+ return new Request(request, { headers });
281
+ }
282
+
283
+ // In export default { fetch }:
284
+ return await handler.fetch(injectGeoCookies(request));
285
+ ```
286
+
287
+ **Step 2**: Read cookies in the matcher (`src/matchers/location.ts`):
288
+
289
+ ```typescript
290
+ import { registerMatcher } from "@decocms/start/cms";
291
+
292
+ // Cloudflare cf.country gives ISO code (e.g., "BR")
293
+ // CMS stores full names (e.g., "Brasil") — need mapping
294
+ const COUNTRY_NAME_TO_CODE: Record<string, string> = {
295
+ Brasil: "BR", Brazil: "BR",
296
+ Argentina: "AR", Chile: "CL",
297
+ Colombia: "CO", Mexico: "MX",
298
+ Peru: "PE", Uruguay: "UY",
299
+ "United States": "US", USA: "US",
300
+ };
301
+
302
+ interface LocationRule {
303
+ country?: string;
304
+ regionCode?: string;
305
+ city?: string;
306
+ }
307
+
308
+ function matchesRule(loc: LocationRule, region: string, country: string, city: string): boolean {
309
+ if (loc.country) {
310
+ const code = COUNTRY_NAME_TO_CODE[loc.country] ?? loc.country;
311
+ if (code !== country) return false;
312
+ }
313
+ if (loc.regionCode && loc.regionCode !== region) return false;
314
+ if (loc.city && loc.city.toLowerCase() !== city.toLowerCase()) return false;
315
+ return true;
316
+ }
317
+
318
+ export function registerLocationMatcher(): void {
319
+ registerMatcher("website/matchers/location.ts", (rule, ctx) => {
320
+ const cookies = ctx.cookies ?? {};
321
+ const region = cookies.__cf_geo_region ? decodeURIComponent(cookies.__cf_geo_region) : "";
322
+ const country = cookies.__cf_geo_country ? decodeURIComponent(cookies.__cf_geo_country) : "";
323
+ const city = cookies.__cf_geo_city ? decodeURIComponent(cookies.__cf_geo_city) : "";
324
+
325
+ const r = rule as { includeLocations?: LocationRule[]; excludeLocations?: LocationRule[] };
326
+
327
+ if (r.excludeLocations?.some(loc => matchesRule(loc, region, country, city))) return false;
328
+ if (r.includeLocations?.length) {
329
+ return r.includeLocations.some(loc => matchesRule(loc, region, country, city));
330
+ }
331
+ return true;
332
+ });
333
+ }
334
+ ```
335
+
336
+ **Step 3**: Register in `src/setup.ts`:
337
+
338
+ ```typescript
339
+ import { registerBuiltinMatchers } from "@decocms/start/matchers/builtins";
340
+ import { registerLocationMatcher } from "./matchers/location";
341
+
342
+ registerBuiltinMatchers();
343
+ registerLocationMatcher();
344
+ ```
345
+
346
+ ---
347
+
348
+ ### Flags & Variants: How Resolution Works
349
+
350
+ ```
351
+ resolveDecoPage()
352
+ └─ resolveVariants() for each multivariate block
353
+ └─ for (const variant of variants):
354
+ if (evaluateMatcher(variant.rule, matcherCtx)):
355
+ return variant.value // FIRST MATCH WINS, stops here
356
+ └─ return undefined (no match — section not rendered)
357
+ ```
358
+
359
+ **Variant ordering rules:**
360
+ - More specific rules go first (e.g., device + location combined)
361
+ - Default/fallback variant goes last with `rule: null`
362
+ - If NO variant matches (all rules `false`, no `null` rule), the entire section is omitted
363
+
364
+ **Session stickiness** (Random matcher):
365
+ - `sticky: "session"` stores the matched variant in a cookie (`deco_sticky_<hash>`)
366
+ - Cookie expires in 30 days
367
+ - Ensures the same user always sees the same variant
368
+
369
+ ---
370
+
371
+ ### Debugging Matchers
372
+
373
+ #### Check if matcher is registered
374
+
375
+ Look for console warnings at startup:
376
+ ```
377
+ [deco] Unknown matcher: website/matchers/location.ts
378
+ ```
379
+
380
+ #### Diagnose from Chrome Trace
381
+
382
+ - Open `chrome://tracing` and load a recorded trace
383
+ - Look for **"React Reconnect"** events — each one = a subtree remount
384
+ - 10+ reconnects on the same page → likely an unregistered matcher causing all variants to resolve to `false`, then the client re-renders when JS loads and re-evaluates
385
+
386
+ #### Force a variant via query string
387
+
388
+ Use `?__deco_matcher_override[key]=true` (if supported by the framework version) to test specific variants without real traffic.
389
+
390
+ #### Verify Cloudflare geo data
391
+
392
+ Add a debug endpoint or check cookies in DevTools:
393
+ - `__cf_geo_country` → e.g., `BR`
394
+ - `__cf_geo_region` → e.g., `S%C3%A3o%20Paulo` (URL-encoded "São Paulo")
395
+ - `__cf_geo_city` → e.g., `Curitiba`
396
+
397
+ ---
398
+
399
+ ### Common Pitfalls
400
+
401
+ | Problem | Root cause | Fix |
402
+ |---------|-----------|-----|
403
+ | All location variants show wrong content | `location.ts` not registered → always `false` | Register in `setup.ts` |
404
+ | CLS score spike + React Reconnect events | Unregistered matcher → server renders variant A, client re-renders variant B | Register all matchers used in CMS |
405
+ | Random A/B test inconsistent per page load | Not using `sticky: "session"` | Add `"sticky": "session"` to rule |
406
+ | Geo matcher works locally but not in production | CF geo data not available in local dev | Mock `__cf_geo_*` cookies locally |
407
+ | Pathname matcher regex syntax error | REDOS vulnerability check rejects pattern | Simplify regex, avoid catastrophic backtracking |
408
+ | `request` undefined in MatcherContext | `cmsRoute.ts` doesn't populate it | Use `ctx.cookies` or `ctx.headers` instead |
409
+
410
+ ---
411
+
412
+ ### Key Source Files
413
+
414
+ | File | Location |
415
+ |------|----------|
416
+ | `evaluateMatcher`, `registerMatcher`, `MatcherContext` | `@decocms/start/src/cms/resolve.ts` |
417
+ | `registerBuiltinMatchers` | `@decocms/start/src/matchers/builtins.ts` |
418
+ | Built-in matcher implementations | `deco-cx/apps/website/matchers/*.ts` |
419
+ | PostHog matcher | `@decocms/start/src/matchers/posthog.ts` |
420
+ | Example: server-side location matcher | `src/matchers/location.ts` |
421
+ | Example: CF geo cookie injection | `src/worker-entry.ts` → `injectGeoCookies()` |
422
+ | Matcher registration call site | `src/setup.ts` |
423
+
424
+ ---
425
+
426
+ ## Part 2: Migration Guide
427
+
428
+ Complete guide to migrate ALL matcher types from Fresh/Deno to TanStack Start / Cloudflare Workers.
429
+
430
+ ### When to Use This Reference
431
+
432
+ - Migrating a deco site from Fresh/Deno to TanStack Start
433
+ - Setting up matchers in a new TanStack storefront
434
+ - A/B tests or feature flags stopped working after migration
435
+ - Location matcher returns `false` for everyone (most common post-migration bug)
436
+ - Need to implement a matcher that isn't in `registerBuiltinMatchers()`
437
+
438
+ ---
439
+
440
+ ### Key Architecture Differences
441
+
442
+ | Aspect | Fresh/Deno | TanStack Start |
443
+ |--------|-----------|----------------|
444
+ | Context type | `MatchContext` | `MatcherContext` |
445
+ | Request access | `ctx.request: Request` | `ctx.request?` (unreliable, often undefined) |
446
+ | CF geo data | Read directly from CF headers | Inject as cookies in `worker-entry.ts` |
447
+ | CF headers available | `cf-ipcountry`, `cf-ipcity`, `cf-region-code` | Not in `MatcherContext`, inject via cookies |
448
+ | Device detection | `ctx.device: "mobile"\|"tablet"\|"desktop"` | Parse from `ctx.userAgent` (string) |
449
+ | Environment check | `Deno.env.get("DENO_DEPLOYMENT_ID")` | `process.env.NODE_ENV` or `ctx.headers` |
450
+ | Cron parsing | `https://deno.land/x/croner@6.0.3` | Inline date-range check (no cron lib) |
451
+ | Registration | Automatic via block manifest | Must call `registerMatcher(key, fn)` in setup.ts |
452
+ | Builtins | Included by default | Must call `registerBuiltinMatchers()` in setup.ts |
453
+ | MatchContext fields | `{ request, device, siteId }` | `{ userAgent, url, path, cookies, headers, request? }` |
454
+
455
+ ---
456
+
457
+ ### MatcherContext Interface (TanStack Start)
458
+
459
+ ```typescript
460
+ // @decocms/start/src/cms/resolve.ts
461
+ export interface MatcherContext {
462
+ userAgent?: string; // User-Agent header value
463
+ url?: string; // Full URL with query string (e.g. "https://site.com/path?q=1")
464
+ path?: string; // Pathname only (e.g. "/path")
465
+ cookies?: Record<string, string>; // Parsed cookies
466
+ headers?: Record<string, string>; // All request headers
467
+ request?: Request; // Raw Request (not always populated!)
468
+ }
469
+ ```
470
+
471
+ ---
472
+
473
+ ### Migration Status per Matcher
474
+
475
+ | Matcher | In `registerBuiltinMatchers()`? | Migration needed? | Notes |
476
+ |---------|--------------------------------|-------------------|-------|
477
+ | `always.ts` | No (inline) | No | Hardcoded in `evaluateMatcher` |
478
+ | `never.ts` | No (inline) | No | Hardcoded in `evaluateMatcher` |
479
+ | `device.ts` | No (inline) | No | Hardcoded, uses `ctx.userAgent` |
480
+ | `random.ts` | No (inline) | No | Hardcoded |
481
+ | `date.ts` | Yes (as cron alias) | No | Registered in builtins |
482
+ | `cron.ts` | Yes | No | Registered in builtins |
483
+ | `cookie.ts` | Yes | No | Registered in builtins |
484
+ | `host.ts` | Yes | No | Registered in builtins |
485
+ | `pathname.ts` | Yes | Partial | Props shape changed (see below) |
486
+ | `queryString.ts` | Yes | Partial | Props shape changed (see below) |
487
+ | `location.ts` | **No** | **YES** | Biggest CLS risk — see full guide |
488
+ | `userAgent.ts` | **No** | **YES** | Must register manually |
489
+ | `environment.ts` | **No** | **YES** | Deno API → Node/CF equivalent |
490
+ | `multi.ts` | **No** | **YES** | Must register manually |
491
+ | `negate.ts` | **No** | **YES** | Must register manually |
492
+ | `site.ts` | **No** | **Not needed** | Site-specific, skip or skip if unused |
493
+ | PostHog | Yes (posthog.ts) | Partial | Needs adapter config |
494
+
495
+ ---
496
+
497
+ ### Step 1: Setup File
498
+
499
+ All matchers must be registered before any request is served:
500
+
501
+ ```typescript
502
+ // src/setup.ts
503
+ import { registerBuiltinMatchers } from "@decocms/start/matchers/builtins";
504
+ import { registerLocationMatcher } from "./matchers/location";
505
+ import { registerUserAgentMatcher } from "./matchers/userAgent";
506
+ import { registerEnvironmentMatcher } from "./matchers/environment";
507
+ import { registerMultiMatcher } from "./matchers/multi";
508
+ import { registerNegateMatcher } from "./matchers/negate";
509
+
510
+ // Always call this first — registers cookie, cron/date, host, pathname, queryString
511
+ registerBuiltinMatchers();
512
+
513
+ // Register any matchers used in CMS that aren't in builtins:
514
+ registerLocationMatcher();
515
+ registerUserAgentMatcher();
516
+ registerEnvironmentMatcher();
517
+ registerMultiMatcher();
518
+ registerNegateMatcher();
519
+ ```
520
+
521
+ **Critical**: If any matcher used in the CMS is not registered, `evaluateMatcher` returns `false` + logs a warning. This causes ALL variants using that matcher to show the wrong content and can trigger React Reconnect events (CLS).
522
+
523
+ ---
524
+
525
+ ### Step 2: Cloudflare Geo Cookie Injection
526
+
527
+ The Fresh version of `location.ts` reads CF headers directly from `ctx.request`. In TanStack Start, `MatcherContext` doesn't expose headers reliably — inject CF geo data as cookies in `worker-entry.ts` **before** TanStack Start processes the request.
528
+
529
+ ```typescript
530
+ // src/worker-entry.ts
531
+ function injectGeoCookies(request: Request): Request {
532
+ const cf = (request as unknown as { cf?: Record<string, string> }).cf;
533
+ if (!cf) return request;
534
+ const parts: string[] = [];
535
+ if (cf.region) parts.push(`__cf_geo_region=${encodeURIComponent(cf.region)}`);
536
+ if (cf.country) parts.push(`__cf_geo_country=${encodeURIComponent(cf.country)}`);
537
+ if (cf.city) parts.push(`__cf_geo_city=${encodeURIComponent(cf.city)}`);
538
+ if (cf.latitude) parts.push(`__cf_geo_lat=${encodeURIComponent(cf.latitude)}`);
539
+ if (cf.longitude) parts.push(`__cf_geo_lng=${encodeURIComponent(cf.longitude)}`);
540
+ if (!parts.length) return request;
541
+ const existing = request.headers.get("cookie") ?? "";
542
+ const combined = existing ? `${existing}; ${parts.join("; ")}` : parts.join("; ");
543
+ const headers = new Headers(request.headers);
544
+ headers.set("cookie", combined);
545
+ return new Request(request, { headers });
546
+ }
547
+
548
+ export default {
549
+ async fetch(request: Request, env: Env, ctx: ExecutionContext) {
550
+ return await handler.fetch(injectGeoCookies(request), env, ctx);
551
+ }
552
+ };
553
+ ```
554
+
555
+ **CF data available** (from `request.cf` in Cloudflare Workers):
556
+ - `cf.country` — ISO 2-letter code (`"BR"`, `"US"`)
557
+ - `cf.region` — Full state/region name (`"São Paulo"`, `"Paraná"`)
558
+ - `cf.city` — City name (`"Curitiba"`)
559
+ - `cf.latitude` — Decimal latitude
560
+ - `cf.longitude` — Decimal longitude
561
+ - `cf.regionCode` — Short region code (some regions) (`"SP"`)
562
+
563
+ > Note: Fresh used CF **headers** (`cf-ipcountry`, `cf-ipcity`, etc.). TanStack Start uses `request.cf` object injected as cookies. Both come from Cloudflare but via different mechanisms.
564
+
565
+ ---
566
+
567
+ ### Step 3: Implement Each Missing Matcher
568
+
569
+ #### `location.ts` — Geographic targeting
570
+
571
+ **Fresh behavior**: Reads `cf-ipcity`, `cf-ipcountry`, `cf-region-code`, lat/lng headers. Supports coordinate radius matching.
572
+
573
+ **TanStack behavior**: Reads from injected cookies `__cf_geo_*`. CMS stores country as full names ("Brasil"), CF provides ISO codes ("BR") — needs a mapping table.
574
+
575
+ ```typescript
576
+ // src/matchers/location.ts
577
+ import { registerMatcher } from "@decocms/start/cms";
578
+
579
+ // CF country codes → CMS country name mapping
580
+ const COUNTRY_NAME_TO_CODE: Record<string, string> = {
581
+ Brasil: "BR", Brazil: "BR",
582
+ Argentina: "AR", Chile: "CL",
583
+ Colombia: "CO", Mexico: "MX",
584
+ Peru: "PE", Uruguay: "UY",
585
+ Paraguay: "PY", Bolivia: "BO",
586
+ Ecuador: "EC", Venezuela: "VE",
587
+ "United States": "US", USA: "US",
588
+ Spain: "ES", Portugal: "PT",
589
+ };
590
+
591
+ interface LocationRule {
592
+ country?: string;
593
+ regionCode?: string; // Full region name (e.g., "São Paulo", "Paraná")
594
+ city?: string;
595
+ }
596
+
597
+ function matchesRule(
598
+ loc: LocationRule,
599
+ region: string,
600
+ country: string,
601
+ city: string
602
+ ): boolean {
603
+ if (loc.country) {
604
+ const code = COUNTRY_NAME_TO_CODE[loc.country] ?? loc.country;
605
+ if (code !== country) return false;
606
+ }
607
+ if (loc.regionCode && loc.regionCode !== region) return false;
608
+ if (loc.city && loc.city.toLowerCase() !== city.toLowerCase()) return false;
609
+ return true;
610
+ }
611
+
612
+ export function registerLocationMatcher(): void {
613
+ registerMatcher("website/matchers/location.ts", (rule, ctx) => {
614
+ const cookies = ctx.cookies ?? {};
615
+ const region = cookies.__cf_geo_region ? decodeURIComponent(cookies.__cf_geo_region) : "";
616
+ const country = cookies.__cf_geo_country ? decodeURIComponent(cookies.__cf_geo_country) : "";
617
+ const city = cookies.__cf_geo_city ? decodeURIComponent(cookies.__cf_geo_city) : "";
618
+
619
+ const r = rule as {
620
+ includeLocations?: LocationRule[];
621
+ excludeLocations?: LocationRule[];
622
+ };
623
+
624
+ // Exclude list takes priority
625
+ if (r.excludeLocations?.some(loc => matchesRule(loc, region, country, city))) {
626
+ return false;
627
+ }
628
+ // Include list: must match at least one
629
+ if (r.includeLocations?.length) {
630
+ return r.includeLocations.some(loc => matchesRule(loc, region, country, city));
631
+ }
632
+ // No rules = match all
633
+ return true;
634
+ });
635
+ }
636
+ ```
637
+
638
+ > **Coordinate radius matching** (from Fresh): The Fresh version supports `Map { coordinates: "lat,lng,radius_m" }` using haversine distance. This is rare in practice — skip unless the CMS actually uses coordinate rules.
639
+
640
+ ---
641
+
642
+ #### `userAgent.ts` — Browser/OS targeting
643
+
644
+ **Fresh behavior**: Reads `ctx.request.headers.get("user-agent")`. Supports `includes` (string) and `match` (regex).
645
+
646
+ **TanStack behavior**: Read from `ctx.userAgent`.
647
+
648
+ ```typescript
649
+ // src/matchers/userAgent.ts
650
+ import { registerMatcher } from "@decocms/start/cms";
651
+
652
+ export function registerUserAgentMatcher(): void {
653
+ registerMatcher("website/matchers/userAgent.ts", (rule, ctx) => {
654
+ const ua = ctx.userAgent ?? "";
655
+ const r = rule as { includes?: string; match?: string };
656
+
657
+ if (r.includes && !ua.includes(r.includes)) return false;
658
+ if (r.match) {
659
+ try {
660
+ if (!new RegExp(r.match, "i").test(ua)) return false;
661
+ } catch {
662
+ return false;
663
+ }
664
+ }
665
+ return true;
666
+ });
667
+ }
668
+ ```
669
+
670
+ ---
671
+
672
+ #### `environment.ts` — Production vs development
673
+
674
+ **Fresh behavior**: Checks `Deno.env.get("DENO_DEPLOYMENT_ID")`.
675
+
676
+ **TanStack behavior**: Check `process.env.NODE_ENV` or a custom env var. In Cloudflare Workers, use a binding or `ctx.headers`.
677
+
678
+ ```typescript
679
+ // src/matchers/environment.ts
680
+ import { registerMatcher } from "@decocms/start/cms";
681
+
682
+ function isProduction(): boolean {
683
+ // Cloudflare Workers: check for production deployment
684
+ // Adjust based on how your site sets this
685
+ if (typeof process !== "undefined" && process.env.NODE_ENV) {
686
+ return process.env.NODE_ENV === "production";
687
+ }
688
+ // Fallback: check for a custom header or env var
689
+ return false;
690
+ }
691
+
692
+ export function registerEnvironmentMatcher(): void {
693
+ registerMatcher("website/matchers/environment.ts", (rule, ctx) => {
694
+ const r = rule as { environment: "production" | "development" };
695
+ const prod = isProduction();
696
+ if (r.environment === "production") return prod;
697
+ if (r.environment === "development") return !prod;
698
+ return false;
699
+ });
700
+ }
701
+ ```
702
+
703
+ > Alternatively, inject an `__env` cookie in `worker-entry.ts` with the environment name.
704
+
705
+ ---
706
+
707
+ #### `multi.ts` — AND/OR combinator
708
+
709
+ **Fresh behavior**: Recursively calls each `Matcher` function with `MatchContext`.
710
+
711
+ **TanStack behavior**: Must recursively call `evaluateMatcher` for each sub-matcher. Import it from the framework.
712
+
713
+ ```typescript
714
+ // src/matchers/multi.ts
715
+ import { registerMatcher } from "@decocms/start/cms";
716
+ // Import the internal evaluateMatcher — check if exported by your version
717
+ // If not exported, implement inline evaluation
718
+
719
+ export function registerMultiMatcher(): void {
720
+ registerMatcher("website/matchers/multi.ts", (rule, ctx) => {
721
+ const r = rule as {
722
+ op: "or" | "and";
723
+ matchers: Array<Record<string, unknown>>;
724
+ };
725
+
726
+ if (!r.matchers?.length) return true;
727
+
728
+ // Evaluate each sub-matcher rule using the same ctx
729
+ // We need to call evaluateMatcher recursively — check your @decocms/start version
730
+ // If evaluateMatcher is exported:
731
+ // import { evaluateMatcher } from "@decocms/start/cms";
732
+ // const results = r.matchers.map(m => evaluateMatcher(m, ctx));
733
+
734
+ // Fallback: evaluate inline using registered matchers
735
+ const G = globalThis as unknown as {
736
+ __deco?: { customMatchers?: Record<string, (r: unknown, c: unknown) => boolean> }
737
+ };
738
+ const registry = G.__deco?.customMatchers ?? {};
739
+
740
+ const results = r.matchers.map(m => {
741
+ const subRule = m as Record<string, unknown>;
742
+ const type = subRule.__resolveType as string | undefined;
743
+ if (!type) return true;
744
+ const fn = registry[type];
745
+ if (!fn) return false;
746
+ return fn(subRule, ctx);
747
+ });
748
+
749
+ return r.op === "and" ? results.every(Boolean) : results.some(Boolean);
750
+ });
751
+ }
752
+ ```
753
+
754
+ > Check your `@decocms/start` version — newer versions may export `evaluateMatcher` for reuse.
755
+
756
+ ---
757
+
758
+ #### `negate.ts` — Invert any matcher
759
+
760
+ **Fresh behavior**: Calls inner `Matcher` function and inverts.
761
+
762
+ **TanStack behavior**: Same recursive evaluation as multi.
763
+
764
+ ```typescript
765
+ // src/matchers/negate.ts
766
+ import { registerMatcher } from "@decocms/start/cms";
767
+
768
+ export function registerNegateMatcher(): void {
769
+ registerMatcher("website/matchers/negate.ts", (rule, ctx) => {
770
+ const r = rule as { matcher?: Record<string, unknown> };
771
+ if (!r.matcher) return false;
772
+
773
+ const G = globalThis as unknown as {
774
+ __deco?: { customMatchers?: Record<string, (r: unknown, c: unknown) => boolean> }
775
+ };
776
+ const registry = G.__deco?.customMatchers ?? {};
777
+ const type = r.matcher.__resolveType as string | undefined;
778
+ if (!type) return false;
779
+ const fn = registry[type];
780
+ if (!fn) return false;
781
+ return !fn(r.matcher, ctx);
782
+ });
783
+ }
784
+ ```
785
+
786
+ ---
787
+
788
+ ### Step 4: Props Shape Differences for Builtins
789
+
790
+ Even registered matchers have prop shape differences between Fresh and TanStack Start. Check CMS JSON against the TanStack implementation.
791
+
792
+ #### `pathname.ts` — Props changed
793
+
794
+ **Fresh Props**:
795
+ ```typescript
796
+ {
797
+ case: {
798
+ type: "Equals" | "Includes" | "Template";
799
+ pathname: string;
800
+ negate?: boolean;
801
+ }
802
+ }
803
+ ```
804
+
805
+ **TanStack Props** (builtins.ts):
806
+ ```typescript
807
+ {
808
+ pattern?: string; // Regex pattern
809
+ includes?: string[]; // Exact paths or wildcard (path/* prefix)
810
+ excludes?: string[]; // Exact paths or wildcard
811
+ }
812
+ ```
813
+
814
+ **Migration**: The CMS JSON uses the Fresh props shape. The TanStack builtins handle a different (simpler) shape. If your CMS has the Fresh-style `case` object, the TanStack builtin won't match it — you must register a custom implementation matching the Fresh shape.
815
+
816
+ ```typescript
817
+ // src/matchers/pathname-compat.ts — Fresh-compatible pathname matcher
818
+ import { registerMatcher } from "@decocms/start/cms";
819
+
820
+ function templateToRegex(template: string): RegExp {
821
+ const pattern = template
822
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape
823
+ .replace(/\\:\w+/g, '[^/]+') // :slug → [^/]+
824
+ .replace(/\\*/g, '.*'); // * → .*
825
+ return new RegExp(`^${pattern}$`);
826
+ }
827
+
828
+ export function registerPathnameCompatMatcher(): void {
829
+ registerMatcher("website/matchers/pathname.ts", (rule, ctx) => {
830
+ const r = rule as { case?: { type: string; pathname?: string; negate?: boolean } };
831
+ const path = ctx.path ?? "";
832
+
833
+ if (!r.case?.pathname) return true;
834
+ const { type, pathname, negate } = r.case;
835
+
836
+ let match = false;
837
+ switch (type) {
838
+ case "Equals": match = path === pathname; break;
839
+ case "Includes": match = path.includes(pathname); break;
840
+ case "Template": match = templateToRegex(pathname).test(path); break;
841
+ default: match = false;
842
+ }
843
+
844
+ return negate ? !match : match;
845
+ });
846
+ }
847
+ ```
848
+
849
+ > Only add this compatibility shim if your CMS actually uses the Fresh-style `case` object. Check your decofile JSON first.
850
+
851
+ #### `queryString.ts` — Props changed
852
+
853
+ **Fresh Props**:
854
+ ```typescript
855
+ {
856
+ conditions: Array<{
857
+ param: string;
858
+ case: {
859
+ type: "Equals" | "Greater" | "Lesser" | "GreaterOrEquals" | "LesserOrEquals" | "Includes" | "Exists";
860
+ value?: string;
861
+ }
862
+ }>
863
+ }
864
+ ```
865
+
866
+ **TanStack Props** (builtins.ts):
867
+ ```typescript
868
+ {
869
+ key?: string; // or "param"
870
+ param?: string; // query param name
871
+ value?: string; // optional value to match
872
+ }
873
+ ```
874
+
875
+ If your CMS uses the `conditions` array shape, register a compatibility matcher:
876
+
877
+ ```typescript
878
+ // src/matchers/querystring-compat.ts
879
+ import { registerMatcher } from "@decocms/start/cms";
880
+
881
+ export function registerQueryStringCompatMatcher(): void {
882
+ registerMatcher("website/matchers/queryString.ts", (rule, ctx) => {
883
+ const r = rule as { conditions?: Array<{ param: string; case: { type: string; value?: string } }> };
884
+ if (!r.conditions?.length) return true;
885
+
886
+ const url = ctx.url ? new URL(ctx.url) : null;
887
+ if (!url) return false;
888
+
889
+ return r.conditions.every(({ param, case: c }) => {
890
+ const raw = url.searchParams.get(param);
891
+ const val = raw ?? "";
892
+ const cval = c.value ?? "";
893
+ switch (c.type) {
894
+ case "Exists": return raw !== null;
895
+ case "Equals": return val === cval;
896
+ case "Includes": return val.includes(cval);
897
+ case "Greater": return Number(val) > Number(cval);
898
+ case "Lesser": return Number(val) < Number(cval);
899
+ case "GreaterOrEquals": return Number(val) >= Number(cval);
900
+ case "LesserOrEquals": return Number(val) <= Number(cval);
901
+ default: return false;
902
+ }
903
+ });
904
+ });
905
+ }
906
+ ```
907
+
908
+ ---
909
+
910
+ ### Step 5: PostHog Matcher
911
+
912
+ The TanStack Start framework ships a PostHog bridge in `@decocms/start/src/matchers/posthog.ts`. Configure it with a server-side adapter:
913
+
914
+ ```typescript
915
+ // src/setup.ts
916
+ import { registerMatcher } from "@decocms/start/cms";
917
+ import {
918
+ configurePostHogMatcher,
919
+ createPostHogMatcher,
920
+ createServerPostHogAdapter,
921
+ } from "@decocms/start/matchers/posthog";
922
+ import { PostHog } from "posthog-node";
923
+
924
+ // Create a PostHog Node client (server-side)
925
+ const posthogClient = new PostHog(process.env.POSTHOG_API_KEY!, {
926
+ host: "https://app.posthog.com",
927
+ });
928
+
929
+ export async function setupPostHogMatcher(distinctId: string): Promise<void> {
930
+ const adapter = createServerPostHogAdapter(posthogClient, distinctId);
931
+ configurePostHogMatcher(adapter);
932
+ registerMatcher("posthog/matchers/featureFlag.ts", createPostHogMatcher());
933
+ }
934
+ ```
935
+
936
+ ---
937
+
938
+ ### Complete `src/setup.ts` Example
939
+
940
+ ```typescript
941
+ // src/setup.ts — register ALL matchers before serving requests
942
+ import { setAsyncRenderingConfig } from "@decocms/start/async-rendering";
943
+ import { registerBuiltinMatchers } from "@decocms/start/matchers/builtins";
944
+ import { registerLocationMatcher } from "./matchers/location";
945
+ import { registerUserAgentMatcher } from "./matchers/userAgent";
946
+ import { registerEnvironmentMatcher } from "./matchers/environment";
947
+ import { registerMultiMatcher } from "./matchers/multi";
948
+ import { registerNegateMatcher } from "./matchers/negate";
949
+
950
+ // Register all built-in matchers (cookie, cron/date, host, pathname, queryString)
951
+ registerBuiltinMatchers();
952
+
953
+ // Register matchers not in builtins — add only those used in your CMS
954
+ registerLocationMatcher(); // Critical if CMS uses location-based content
955
+ registerUserAgentMatcher(); // If CMS uses userAgent rules
956
+ registerEnvironmentMatcher(); // If CMS uses production/development flags
957
+ registerMultiMatcher(); // If CMS uses AND/OR combinator rules
958
+ registerNegateMatcher(); // If CMS uses negated matcher rules
959
+
960
+ // Async rendering config
961
+ setAsyncRenderingConfig({
962
+ respectCmsLazy: true,
963
+ alwaysEager: ["Header", "Footer", "Theme"],
964
+ });
965
+ ```
966
+
967
+ ---
968
+
969
+ ### Complete `src/worker-entry.ts` Example
970
+
971
+ ```typescript
972
+ // src/worker-entry.ts — inject CF geo data before TanStack Start processes request
973
+ import { createWorkerEntry } from "@decocms/start";
974
+ import "../src/setup"; // Ensure matchers are registered
975
+
976
+ const handler = createWorkerEntry();
977
+
978
+ function injectGeoCookies(request: Request): Request {
979
+ const cf = (request as unknown as { cf?: Record<string, string> }).cf;
980
+ if (!cf) return request;
981
+ const parts: string[] = [];
982
+ if (cf.region) parts.push(`__cf_geo_region=${encodeURIComponent(cf.region)}`);
983
+ if (cf.country) parts.push(`__cf_geo_country=${encodeURIComponent(cf.country)}`);
984
+ if (cf.city) parts.push(`__cf_geo_city=${encodeURIComponent(cf.city)}`);
985
+ if (cf.latitude) parts.push(`__cf_geo_lat=${encodeURIComponent(cf.latitude)}`);
986
+ if (cf.longitude) parts.push(`__cf_geo_lng=${encodeURIComponent(cf.longitude)}`);
987
+ if (!parts.length) return request;
988
+ const existing = request.headers.get("cookie") ?? "";
989
+ const combined = existing ? `${existing}; ${parts.join("; ")}` : parts.join("; ");
990
+ const headers = new Headers(request.headers);
991
+ headers.set("cookie", combined);
992
+ return new Request(request, { headers });
993
+ }
994
+
995
+ export default {
996
+ async fetch(request: Request, env: Env, ctx: ExecutionContext) {
997
+ return await handler.fetch(injectGeoCookies(request), env, ctx);
998
+ }
999
+ };
1000
+ ```
1001
+
1002
+ ---
1003
+
1004
+ ### Debugging After Migration
1005
+
1006
+ #### 1. Check startup logs for unregistered matchers
1007
+
1008
+ ```
1009
+ console.warn: Unknown matcher type: website/matchers/location.ts
1010
+ ```
1011
+
1012
+ Every warning = one matcher returning `false` for all users = potential CLS.
1013
+
1014
+ #### 2. Check Chrome Trace for React Reconnect
1015
+
1016
+ - Record a trace at `chrome://tracing`
1017
+ - Look for **"React Reconnect"** events — each = a React subtree that remounted
1018
+ - Cause: server renders variant A (matcher returns false) → client JS evaluates differently → remounts
1019
+ - Fix: register all matchers so server and client agree
1020
+
1021
+ #### 3. Verify CF geo cookies
1022
+
1023
+ Open DevTools → Application → Cookies:
1024
+ - `__cf_geo_country` = `BR` (ISO code)
1025
+ - `__cf_geo_region` = `S%C3%A3o%20Paulo` (URL-encoded "São Paulo")
1026
+ - `__cf_geo_city` = `Curitiba`
1027
+
1028
+ If cookies are missing → `injectGeoCookies()` isn't running or CF isn't populating `request.cf` (only available in production Cloudflare Workers, not `wrangler dev`).
1029
+
1030
+ #### 4. Test locally with mock cookies
1031
+
1032
+ For local development, set mock geo cookies in `injectGeoCookies()` when `cf` is undefined:
1033
+
1034
+ ```typescript
1035
+ function injectGeoCookies(request: Request): Request {
1036
+ const cf = (request as unknown as { cf?: Record<string, string> }).cf;
1037
+ // In local dev, simulate a location
1038
+ const geoData = cf ?? {
1039
+ country: "BR",
1040
+ region: "Paraná",
1041
+ city: "Curitiba",
1042
+ };
1043
+ // ... rest of implementation
1044
+ }
1045
+ ```
1046
+
1047
+ ---
1048
+
1049
+ ### Matcher Feature Parity Checklist
1050
+
1051
+ Use this checklist when migrating a site:
1052
+
1053
+ - [ ] `registerBuiltinMatchers()` called in `setup.ts`
1054
+ - [ ] `injectGeoCookies()` added to `worker-entry.ts`
1055
+ - [ ] `registerLocationMatcher()` added to `setup.ts` (if site uses location rules)
1056
+ - [ ] `registerUserAgentMatcher()` added (if site uses userAgent rules)
1057
+ - [ ] `registerEnvironmentMatcher()` added (if site uses env flags)
1058
+ - [ ] `registerMultiMatcher()` added (if site uses AND/OR combinator)
1059
+ - [ ] `registerNegateMatcher()` added (if site uses negate)
1060
+ - [ ] Check CMS decofiles for `pathname.ts` prop shape (Fresh uses `case` object, TanStack uses `pattern`/`includes`)
1061
+ - [ ] Check CMS decofiles for `queryString.ts` prop shape (Fresh uses `conditions[]`, TanStack uses `key`/`value`)
1062
+ - [ ] Verify no "Unknown matcher" warnings in startup logs
1063
+ - [ ] Verify no "React Reconnect" events in Chrome Trace
1064
+ - [ ] Test geo-targeted content from different locations (or mock cookies locally)