---
name: astro
description: Astro+React island projects. Enforces hydration directive selection, provider context across island boundaries, SSR-safe react-query, SEO-preserving patterns. Triggers on *.astro, astro.config.*, React islands, @astrojs/react config edits.
---

# Astro + React Islands — Production Rules

Battle-tested rules. No empty HTML to crawlers. No SSR crash. No silent context break across island boundaries.

For broad build/test and deploy, MUST follow `/home/user/Projects/0 DOCS/GIT_FATIGUE.md` §12. Valid current-tree receipt/log = proof; NEVER rebuild to verify. Delivery controller owns publication.

## The single biggest gotcha

**Astro slots serialize React `children` as HTML before parent renders.** React context (`Context.Provider`, `QueryClientProvider`, `ThemeProvider`, etc.) in parent island does NOT reach slot children. Isolated React tree. No provider access.

```astro
<!-- BROKEN: providers in <ProviderWrapper> never reach <Inner /> -->
<ProviderWrapper client:load>
  <Inner />
</ProviderWrapper>
```

### Primary fix: self-wrap providers inside island component

Universal, version-agnostic. Each top-level island wraps own providers internally. Eliminates slot boundary. Single React tree per page.

```tsx
// features/home/HomePage.tsx
import { HydratedIsland } from '@/components/HydratedIsland';
import type { DehydratedState } from '@tanstack/react-query';

function Inner(props: HomePageProps) { /* ...real content... */ }

export function HomePage(props: HomePageProps & { dehydratedState?: DehydratedState }) {
  const { dehydratedState, ...rest } = props;
  return (
    <HydratedIsland dehydratedState={dehydratedState}>
      <Inner {...rest} />
    </HydratedIsland>
  );
}
```

```astro
---
// pages/index.astro — direct island, no Astro slot
import { HomePage } from '@/features/home/HomePage';
---
<HomePage client:load dehydratedState={dehydratedState} {...props} />
```

Apply to every public page top-level feature. `<HydratedIsland>` wrapper is React-internal — Astro never sees it as slot parent.

Deep export indirection (e.g. `features/stores/index.ts`): convert index to `index.tsx`, compose wrapper there:

```tsx
// features/stores/index.tsx
import { Stores as StoresInner, type StoresProps } from './Stores';
import { HydratedIsland } from '@/components/HydratedIsland';
import type { DehydratedState } from '@tanstack/react-query';

export type { StoresProps } from './Stores';

export function Stores(props: StoresProps & { dehydratedState?: DehydratedState }) {
  const { dehydratedState, ...rest } = props;
  return (
    <HydratedIsland dehydratedState={dehydratedState}>
      <StoresInner {...rest} />
    </HydratedIsland>
  );
}
```

### Legacy/optional: `experimentalReactChildren: true`

Older `@astrojs/react` v3/early v4 had flag for VDOM propagation through slots. **In `@astrojs/react` v5 + Astro 6 flag is silent no-op** — children still serialize as HTML. Empirically: slot pattern + flag crashed with "No QueryClient set" until refactored to self-wrap.

Older stack + flag works: fine. **Don't rely on it for new projects.** Self-wrap works every version, never silently breaks.

```ts
// astro.config.ts — keep this for older stacks ONLY; harmless no-op on v5+
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';

export default defineConfig({
  integrations: [react({ experimentalReactChildren: true })],
});
```

## Hard rules

1. **Self-wrap providers inside island component** (see "Primary fix"). Never pass children through Astro slot expecting context flow — slot serializes as HTML. Self-wrap = universal, version-agnostic.
2. **Never `client:only="react"` on public/SEO pages.** Skips SSR → empty crawler HTML → SEO dead. Reserve for auth-only routes (admin, dashboards).
3. **One root provider island per page.** All page-level providers in single `<HydratedIsland>`. Never nest provider islands.
4. **One `QueryClient` singleton in browser, fresh per request on server.** Server reuse leaks data on Cloudflare/Edge.
5. **Never browser-only APIs at module top level in server-rendered components.** `window`, `document`, `localStorage`, `navigator` — gate behind `typeof window !== 'undefined'` or `useEffect`.
6. **Data-driven pages: react-query SSR dehydrate pattern** — server prefetch, dehydrate, hydrate on client. No waterfall, full SEO HTML.
7. **CI must fail if public page uses `client:only="react"`.** Add audit script.

## Hydration directive decision table

| Directive | When to use | SSR HTML? |
|---|---|---|
| `client:load` | Above-fold, LCP-critical, immediate interact | yes |
| `client:visible` | Below-fold, defer until visible | yes |
| `client:idle` | Non-critical interactive | yes |
| `client:media="(...)"` | Conditional on media query | yes |
| `client:only="react"` | Private routes only — no SSR, no SEO | NO |

**Rule:** crawler-visible content must SSR. Only `client:only` skips SSR.

## Provider island pattern

Canonical wrapper, all page-level providers in one React tree. Place at `src/components/HydratedIsland.tsx`:

```tsx
import {
  QueryClientProvider,
  HydrationBoundary,
  type DehydratedState,
} from '@tanstack/react-query';
import { type ReactNode } from 'react';
import { getBrowserQueryClient, createBrowserQueryClient } from '@/lib/query/client';

export interface HydratedIslandProps {
  dehydratedState?: DehydratedState;
  children: ReactNode;
}

export function HydratedIsland({ dehydratedState, children }: HydratedIslandProps) {
  return (
    <QueryClientProvider
      client={typeof window === 'undefined' ? createBrowserQueryClient() : getBrowserQueryClient()}
    >
      <HydrationBoundary state={dehydratedState}>
        {children}
      </HydrationBoundary>
    </QueryClientProvider>
  );
}
```

**Critical:** `typeof window === 'undefined'` branch. `getBrowserQueryClient` throws on server. Without SSR fallback: page crash → empty body → broken SEO + hydration.

## QueryClient singleton + factory

```ts
// src/lib/query/client.ts
import { QueryClient } from '@tanstack/react-query';

export function createBrowserQueryClient(): QueryClient {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60_000,
        gcTime: 30 * 60_000,
        retry: 1,
        refetchOnWindowFocus: false,
      },
      mutations: { retry: 0 },
    },
  });
}

let _client: QueryClient | undefined;

export function getBrowserQueryClient(): QueryClient {
  if (typeof window === 'undefined') {
    throw new Error('getBrowserQueryClient: server context — use createBrowserQueryClient');
  }
  return (_client ??= createBrowserQueryClient());
}
```

**Edge runtime:** never module-scope QueryClient on server. Cloudflare Workers reuse module instances across requests — singleton leaks cached data between users. Always per-request.

## SSR dehydrate pattern (full SEO + zero waterfall)

Server prefetch → dehydrate → ship in HTML → client `HydrationBoundary` rehydrates instantly. No spinner, no waterfall, full crawler HTML.

```astro
---
// src/pages/index.astro
import { dehydrate } from '@tanstack/react-query';
import { createBrowserQueryClient } from '@/lib/query/client';
import { HydratedIsland } from '@/components/HydratedIsland';
import { HomePage } from '@/features/home/HomePage';
import { fetchUser, fetchFeed } from '@/lib/api';

// Per-request QueryClient — never module-scope on server
const queryClient = createBrowserQueryClient();
await Promise.all([
  queryClient.prefetchQuery({ queryKey: ['user'], queryFn: () => fetchUser(Astro) }),
  queryClient.prefetchQuery({ queryKey: ['feed'], queryFn: () => fetchFeed(Astro) }),
]);
const dehydratedState = dehydrate(queryClient);
---

<HydratedIsland dehydratedState={dehydratedState} client:load>
  <HomePage />
</HydratedIsland>
```

Inside `<HomePage>`:

```tsx
import { useQuery } from '@tanstack/react-query';
export function HomePage() {
  // Data is instantly available from dehydrated state — no spinner.
  const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
  const { data: feed } = useQuery({ queryKey: ['feed'], queryFn: fetchFeed });
  return <Feed user={user} items={feed} />;
}
```

Helper for repeated use:

```ts
// src/lib/query/ssr.ts
import { dehydrate, type DehydratedState } from '@tanstack/react-query';
import { createBrowserQueryClient } from './client';

export async function dehydrateForIsland(
  prefetchers: ((qc: ReturnType<typeof createBrowserQueryClient>) => Promise<unknown>)[],
): Promise<{ dehydratedState: DehydratedState }> {
  // CPU diet: static pages call this with [] — skip QueryClient construction
  // + dehydrate() entirely. Matters on CF Workers Free (10ms CPU/request).
  if (prefetchers.length === 0) {
    return { dehydratedState: { mutations: [], queries: [] } };
  }
  const qc = createBrowserQueryClient();
  await Promise.all(prefetchers.map((p) => p(qc)));
  return { dehydratedState: dehydrate(qc) };
}
```

## Common pitfalls (anti-pattern → fix)

| Pitfall | Symptom | Fix |
|---|---|---|
| Wrap island around `useQuery` children without `experimentalReactChildren` | "No QueryClient set" in prod, empty SSR body | Enable flag |
| Use `client:only` to dodge SSR crash | SEO dead, blank paint | Fix SSR error (usually browser-only API at module top) |
| `window`/`document` at module top | SSR `ReferenceError` | Gate `typeof window !== 'undefined'` or `useEffect` |
| Nested `<HydratedIsland>` | Multiple QueryClients, broken cache, double providers | One provider island per page |
| Module-scope QueryClient on server | Data leaks on Edge | Per-request `new QueryClient()` in handler |
| `getBrowserQueryClient()` during SSR | Silent prod crash, blank page | Branch `typeof window`; `createBrowserQueryClient()` on server |
| Forget await before `dehydrate()` | Empty state, client refetches | `await Promise.all([...])` before dehydrate |

## CI hardening

### Audit script — fail build if `client:only` on public page

```bash
#!/usr/bin/env bash
# scripts/audit-islands.sh
set -e
BAD=$(grep -rln 'client:only="react"' src/pages/ \
  --exclude-dir=admin --exclude-dir=vendor --exclude-dir=account 2>/dev/null || true)
if [ -n "$BAD" ]; then
  echo "ERROR: client:only on public SEO pages:"
  echo "$BAD"
  exit 1
fi
echo "OK: no client:only on public pages"
```

Wire into `package.json`:
```json
{
  "scripts": {
    "audit:islands": "bash scripts/audit-islands.sh",
    "prebuild": "pnpm audit:islands"
  }
}
```

### ESLint guard (optional, custom rule)

Forbid `useQuery` not reachable from `<HydratedIsland>`. Easier: grep pre-commit check.

### E2E SEO test

Test public routes return non-empty SSR HTML:

```ts
test('homepage SSR is non-empty', async ({ request }) => {
  const res = await request.get('/');
  const body = await res.text();
  expect(body.length).toBeGreaterThan(10_000);
  expect(body).toContain('<!DOCTYPE html>');
  expect(body).toMatch(/<h1[^>]*>/);
});
```

## Cloudflare Workers / Edge specific

- **Version pinning — stay on Astro 6 line; do NOT jump to Astro 7 on Cloudflare.** Astro 7 ships Vite 8 + Rolldown (15-61% faster builds), but `@astrojs/cloudflare` is NOT Vite-8-compatible — adapter forces `"overrides": {"vite": "^7"}` to avoid `require_dist is not a function`. On CF you get all the v7 breaking-change risk and NONE of the Vite-8 speed. Revisit only when adapter CHANGELOG declares Vite 8 support + Astro ≥7.2.
- **Adapter major = Astro major. Match them.** `@astrojs/cloudflare` 13.x → peer `astro@^6.3.0`; 14.x → peer `astro@^7.0.0-alpha`. NEVER install adapter 14 on an Astro 6 app. Latest safe v6 stack (verified 2026-06-27): `astro@6.4.8` + `@astrojs/cloudflare@13.7.0` + `@astrojs/react@5.0.7`.
- **Bump wrangler in lockstep with the adapter.** `@astrojs/cloudflare` 13.6+ pulls `@cloudflare/vite-plugin@1.39+` (≥1.42 hard-asserts `wrangler@^4.105.0`). Wrangler too old → `astro check`/build dies at config load: "installed version of Wrangler (X) does not satisfy the peer dependency". Fix = bump `wrangler` everywhere it's declared (every app that builds: web + the DO host worker), not just the main app.
- Per-request QueryClient mandatory (module scope reused = data leak).
- `cloudflare:workers` env access only inside request handlers, never module-top.
- Astro `@astrojs/cloudflare` adapter: `output: 'server'` (or `'hybrid'`), `runtime.mode: 'local'` for dev.
- `export const prerender = true` = cheapest for marketing pages — no per-user data.
- **Custom worker entry** (own `scheduled()`, Durable Objects, queue consumers — same Worker as Astro): wrangler.toml `main = "src/worker.ts"`; entry re-exports adapter handler: `import { handle } from '@astrojs/cloudflare/handler'` then `export default { fetch: (req, env, ctx) => handle(manifest, app, req, env, ctx) }` + your DO classes/`scheduled`. Verified Astro 6.4 + @astrojs/cloudflare 13.7. Without this, adapter owns `_worker.js` and silently discards your exports.
- **Adapter regenerates `dist/server/wrangler.json` each build** — DO `migrations` come out `[]`, custom fields dropped. Designated executor MUST re-patch post-build + gate (`grep -q "YourDO" dist/server/$MAIN || exit 1`); delivery controller invokes deploy primitive.
- **`ctx.waitUntil` passed into frontmatter loaders must be bound** — `waitUntil: cfCtx.waitUntil` (bare ref) → workerd "Illegal invocation" at runtime, clean at typecheck. Use `cfCtx.waitUntil.bind(cfCtx)` or arrow wrap. Audit: grep `\.waitUntil[^(.]`.
- **Astro/Vite ships SSR bundle UNMINIFIED — client build minified, server NOT.** Astro 7 hardcodes `build.minify=false` for the SSR environment (createViteBuildConfig); Astro 6 same net result. On Workers Free 3072 KiB gzip limit this silently eats ~800 KiB. Measured (multideal Astro 7, 2026-07-09): unminified 3112.86 KiB gzip → deploy REJECTED; esbuild-minified 2288.48 KiB → fits. Fix = post-build esbuild minify pass over `dist/server` chunks (after adapter's "Rearranging server assets"). MUST `--keep-names`; NEVER `--mangle-props`; server sourcemaps become load-bearing for Sentry — inject+upload from the FINAL minified dist. Fail-closed + idempotent; esbuild pinned in lockfile. DO NOT force minify inside Vite config instead: `environments.ssr.build.minify: 'oxc'` shrinks bundle but deployed worker crashes at boot ("Uncaught TypeError: Invalid URL string" in entry.mjs — caught by `wrangler dev` smoke, NOT by build success). Measure: `npx wrangler deploy --dry-run --config dist/server/wrangler.json` → "Total Upload gzip".
- **Workers Free = 10ms CPU/request; SSR miss path is mostly DB, not React** — measured profile: ~70% of cold-render CPU = DB client init + queries + ORM init; `renderToString` ~2%. Component diet won't save a heavy page; fix = edge cache + render offload to a SQLite Durable Object (30s CPU on free plan). Full pattern in `/cloudflareops` skill → "Durable Object render offload".

## Decision flow

```
Building a new page?
├─ Public + SEO matters?
│  ├─ Pure static content? → no React island, plain Astro + `prerender = true`
│  ├─ Above-fold interactive? → <HydratedIsland client:load> + SSR dehydrate
│  └─ Below-fold interactive? → <HydratedIsland client:visible> + SSR dehydrate
└─ Private (admin / dashboard)?
   └─ <HydratedIsland client:only="react"> (skip SSR, save server work)
```

## Performance + SSR strategy (apply in this order)

Rules compound. Each level: less server CPU, better LCP.

### 1. Prerender static pages

Pages with no per-user data, no auth rendering, no per-request `cf` lookups, no query-param personalization:

```astro
---
export const prerender = true;
---
```

Candidates: `/about`, `/contact`, `/privacy`, `/terms`, `/faq`, `/help`, `/legal/*`, marketing, 404. Skip if reads `Astro.locals.user`, `Astro.cookies`, `Astro.request.cf`, or per-request fetch.

Wins: zero CPU, edge-cached HTML, instant TTFB.

### 2. Dashboards: `client:only="react"`

Auth-gated routes (admin, vendor, profile, settings, dashboards): top-level island → `client:only="react"`. Noindex — SSR wastes CPU.

```astro
<DashboardIsland client:only="react" {...props} />
```

Trade-off: blank flash before JS boot. Acceptable for logged-in users.

Caveats:
- Self-wrap providers must be in place (client mount needs QueryClient).
- Props must be JSON-serializable (no functions, no class instances). `Date` OK; use `Astro.props.date.toISOString()` if unsure.
- Auth check + redirect in Astro frontmatter, not React island.

### 3. Below-fold islands: `client:visible` (or `client:idle`)

Multiple islands on public page: LCP/above-fold → `client:load`. Below fold → `client:visible`. Non-critical (settings, accessibility, command palettes) → `client:idle`.

```astro
<HeroIsland client:load {...heroProps} />
<RecommendationsIsland client:visible {...recProps} />
<CommandPalette client:idle />
```

Wins: smaller initial JS, faster TTI, better CWV.

### 4. Client-fetch → frontmatter prefetch (SSR dehydrate)

`useQuery` on public page must NOT browser-fetch if data needed for SEO/above-fold. Migrate to server-side prefetch + dehydrate.

**Before** (client waterfall, empty SSR HTML):

```tsx
// Feed.tsx — runs only after JS boots
const { data } = useQuery({ queryKey: ['feed'], queryFn: fetchFeed });
```

**After** (server prefetch, full HTML, instant cache hit):

```astro
---
// pages/index.astro
import { dehydrate } from '@tanstack/react-query';
import { createBrowserQueryClient } from '@/lib/query/client';
import { fetchFeed } from '@/lib/api';

const qc = createBrowserQueryClient();
await qc.prefetchQuery({ queryKey: ['feed'], queryFn: () => fetchFeed(Astro) });
const dehydratedState = dehydrate(qc);
---

<HomePage client:load dehydratedState={dehydratedState} />
```

```tsx
// HomePage.tsx — same hook, instant data from cache
const { data } = useQuery({ queryKey: ['feed'], queryFn: fetchFeed });
```

**Don't migrate:** auth-specific (cart, profile, wishlist), admin/vendor, search/filter (URL-driven), `client:only` islands.

**Do migrate:** homepage feed, deal detail, store list, business page detail, public loyalty/rewards.

### Routing matrix

| Route group | Directive | Prerender? | Prefetch? |
|---|---|---|---|
| Static informational (`/about`, `/contact`, `/legal/*`) | `client:idle` (if any island) | yes | n/a |
| Marketing landing | `client:load` hero, `client:visible` below | yes | optional |
| Public content (`/`, `/deals/*`, `/stores/*`) | `client:load` hero, `client:visible` below | no | yes (frontmatter) |
| Search / filter (`/search?q=`) | `client:load` | no | optional (URL-driven) |
| Auth-gated (`/loyalty`, `/wishlist`) | `client:visible` or `client:only` | no | n/a |
| Dashboards (`/admin/**`, `/vendor/**`, `/profile/**`, `/settings/**`) | `client:only="react"` | no | n/a |

## TL;DR

1. **Self-wrap providers inside island** — never rely on Astro slots for context propagation.
2. SSR-safe QueryClient (`typeof window === 'undefined'` fallback in wrapper).
3. Never `client:only` on public/SEO pages.
4. Per-request QueryClient on server, singleton in browser.
5. Dehydrate pattern for data-driven pages.
6. Audit script in CI to enforce.
7. `experimentalReactChildren: true` legacy/optional — no-op on modern stacks. Don't depend on it.

## Learned Rules

### astro-flag-empirical-verify | fired:1 | 2026-04-26
Recommended `experimentalReactChildren: true` without testing on `@astrojs/react` v5 → no-op, shipped broken homepage. Flag semantics change silently across versions.
Prevent: smoke test any Astro/@astrojs/react flag on target version, curl body bytes. Default to self-wrap.

### ssr-verify-body-bytes | fired:1 | 2026-04-26
Verification used HTTP 200 as success → 0-byte body shipped to prod. Cloudflare returns 200 even when worker throws after headers sent.

### astro-i18n-shared-component-all-strings-via-t-lang | fired:1 | 2026-05-24
Shared components (HeroSlider, PhotographerPortrait, Footer, BookingProgressBar, CategoryCards, FeaturedWork) received `lang` prop but hardcoded English for aria-labels, alt text, CTA button, labels, meta descriptions. Required 3 fix rounds (13 issues total).
Prevent: when writing any shared Astro component that accepts `lang`, import `t` and call `const tr = t(lang)`. Every user-visible string — including aria-labels, alt text, button text, section labels, and meta descriptions — must use `tr.*`. Never hardcode English strings in shared components.

### astro-i18n-created-key-must-be-used | fired:1 | 2026-05-24
BookingProgressBar caused `contact.bookingSteps` to be added to en.ts/he.ts, then ignored that key and used an inline `lang === 'he' ? ... : ...` ternary instead. Silent inconsistency — key drift on future edits.
Prevent: when a component causes a new i18n key to be created, immediately wire `t(lang)` in that component and reference the new key. Never bypass a key you just created.
Prevent: SSR smoke test = `curl -s URL | wc -c` > threshold (50KB+ content pages). 200 alone not success.

### astro-rtl-template-literal-in-jsx-ternary-silent-fail | fired:1 | 2026-05-24
`{lang === 'he' ? \`הכירו את ${name}\` : \`Meet ${name}\`}` in Astro 5 SSG JSX template silently rendered English branch for Hebrew route — built output showed "Meet Roy" on `/he/about` despite `lang === 'he'` being true at SSG time.
Prevent: never use RTL/Hebrew strings inside backtick template literals directly in JSX ternary expressions. Compute locale-conditional strings in the frontmatter using string concatenation (`const h = lang === 'he' ? 'הכירו את ' + firstName : 'Meet ' + firstName`) and reference the variable in template (`{h}`).

### tailwindv4-dist-verify-before-deploy | fired:1 | 2026-05-25
Deployed Astro+Tailwind v4 dist with base CSS only (17KB, 0 utility classes) → entire site unstyled. Tailwind ran but scanner found no classes.
Prevent: before `wrangler pages deploy`, run `grep -c "display:flex\|\.flex{" dist/_astro/*.css` — result must be >0. CSS <30KB with 0 utility matches = broken build, do not deploy.

### tailwindv4-multiple-vite-conflict | fired:1 | 2026-05-25
Added `@tailwindcss/vite` to Astro 6 project while two Vite versions coexisted (`node_modules/vite@8` + `astro/node_modules/vite@7`) → `Missing field 'tsconfigPaths' on BindingViteResolvePluginConfig` runtime error.
Prevent: before adding `@tailwindcss/vite` to Astro project, check `ls node_modules/vite 2>/dev/null && ls node_modules/astro/node_modules/vite 2>/dev/null` — if both exist, add `"overrides": {"vite": "X.Y.Z"}` in package.json and `npm install` to unify before wiring the plugin.

### tailwindv4-oxide-doublestar-glob-bug | fired:1 | 2026-05-25
`@source "../**/*.astro"` in global.css returned 0 utility classes — `@tailwindcss/oxide@4.3.0` `**` glob patterns silently return 0 candidates for `.astro` files. Single-star and specific paths work fine.
Prevent: when Tailwind PostCSS/Vite plugin runs (canary `.foo{display:flex}` appears) but 0 utility classes generated, test scanner directly: `node -e "const {Scanner}=require('@tailwindcss/oxide');console.log(new Scanner({sources:[{base:require('path').resolve('.'),pattern:'src/**/*.astro',negated:false}]}).scan().length)"`. If 0, use explicit per-dir single-star sources: `@source "../components/shared/*.astro"` etc. per subdirectory.

### cf-adapter-bump-requires-wrangler-lockstep | fired:1 | 2026-06-27
Bumped `@astrojs/cloudflare` 13.5.1→13.7.0 alone; it pulled `@cloudflare/vite-plugin@1.42.3` which hard-asserts `wrangler@^4.105.0`, but app still had `wrangler@^4.95.0`. `astro check` died at config load ("installed version of Wrangler does not satisfy the peer dependency") before any TS ran — looked like a broken adapter, was a stale wrangler.
Prevent: when bumping the CF adapter minor, check `npm view @astrojs/cloudflare@<v> dependencies.@cloudflare/vite-plugin` then its required wrangler, and bump `wrangler` in EVERY package that builds (main app + DO host worker) in the same change. Verify with `./node_modules/.bin/astro check` (config must load) then a full `pnpm --filter web build`.

### astro7-on-cloudflare-no-vite8 | fired:1 | 2026-06-27
Considered Astro 7 upgrade; its headline win (Vite 8 + Rolldown) is unusable on Cloudflare — `@astrojs/cloudflare` pins `vite@^7` via auto-override. All breaking-change risk, zero speed payoff.
Prevent: on CF Workers stay on Astro 6 until adapter CHANGELOG declares Vite 8 support. Adapter major must equal Astro major (13.x=6, 14.x=7) — never cross them.

### tailwindv4-vite-cache-same-hash | fired:1 | 2026-05-25
Spent 5 attempts changing `@source` paths while dist CSS hash stayed identical (`Heading.CxXlcdz7.css`) across all rebuilds — Vite cache served stale CSS, config changes had no effect.
Prevent: after any CSS config change, check if dist CSS filename hash changed. Same hash = cache hit = changes ignored. Run `rm -rf node_modules/.vite .astro` before next rebuild attempt.
