> ## Documentation Index
> Fetch the complete documentation index at: https://docs.superun.com/llms.txt
> Use this file to discover all available pages before exploring further.

# CSR 项目改造为请求期 SSR（React Router 7 data-mode · 标准 Vite SSR）

> 用于：CSR 项目里某个公开、要被搜索引擎索引的页需要服务端渲染时。把项目整体 flip 到标准 React Router 7 data-mode 的 Vite SSR（自洽、能独立跑）；私有/登录态页保持 CSR。平台只做 CDN 上传、edge 部署、请求期门控。

# CSR 项目改造为请求期 SSR（React Router 7 data-mode）

你是代码迁移 Agent。任务：把当前 CSR 项目**项目级 flip** 到一个**标准、自洽、能独立跑**的 React Router 7 data-mode Vite SSR 项目。

## 能力与边界（先读这一节）

**这个 skill 让你做到**：把 CSR 项目整体翻成标准 Vite SSR——单一 `routes.tsx` + `entry-server`/`entry-client` 双入口 + IIFE server bundle + 项目自带的 server（部署成本站 Supabase 的 edge function，请求期直接产出完整 HTML）。产物是**标准 Vite SSR 项目**，不引 Next/Remix/vite-react-ssg、不自造数据层。方案依据：React Router 官方 `ssr-data-router` 示例 + Vite 官方 SSR（`server.js` 注模板）。

**何时用**：某页「公开 + 要 SEO/被索引」→ SSR（**无论纯静态还是带真数据**：静态页就是没有 loader 的 SSR，首屏照样预渲染）。私有/登录态页保持 CSR。**SSG（vite-react-ssg）已退役**（钉死 react-router v6、不再投入）：**禁止新用**；已做过 SSG 的项目**一律升级**到 SSR（含纯静态页，别以"静态就留 SSG"为由保留），见 [SSG→SSR 迁移](/superun/skills/seo/ssg-to-ssr)。

**心智模型：整体 flip → 局部深化**

* **默认 = CSR**。**切 SSR = 项目级一次 flip**。flip 后每条路由都被服务端渲染——但**多数页此刻仍是壳**（渲染外壳 + 客户端水合后取数），标准 React SSR 行为，无需特殊处理。
* **局部深化（按需逐页）**：要 SEO/首屏数据的页，给那条 route 加 `loader`（服务端取数），组件 `useLoaderData()` 读。

**你写什么 vs 平台做什么（三层架构）**

* **你的项目自带一个 server**——标准 Vite SSR 里的 `server.js`。它 load server bundle + index.html 模板，`render(url)` 出 app HTML、注进模板 → 吐完整 HTML 文档。这个 server 以 **edge function** 部署到**本站自己的 Supabase**（Deno）：每站独立 = 强隔离、能用 service\_role 直连本站库取数。**它是你写的项目代码**（见「模板·supabase fn」）。
* **平台的 container 只是多租户门控**：转发请求给你的 server、拿回整页、注平台脚本（monitor/付费墙）、在真站点 URL 吐给访客。**它不碰你的渲染逻辑。**
* **核心原则**：「模板」节的文件**全由你写进项目源码**，不依赖平台注入。平台只负责 CDN 上传、edge **部署**、请求期门控（见 「平台负责的」一节）。

## 你要做的决策

flip 是机械的（照「模板」节写文件）。真正要你判断的就这几条：

| 决策                                    | 怎么判断                                                                                         | 落地                                    |
| ------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------- |
| 这页要不要 SSR                             | 公开 + 要 SEO/被索引 → SSR；私有/登录态 → 留 CSR                                                          | flip 后所有路由自动 SSR；私有页无需特殊处理            |
| 这页要不要 `loader`                        | 要 SEO/首屏就带真实数据 → 加 `loader`（服务端取数）；否则是壳（客户端取数）                                               | 「局部深化」                                |
| 哪些库要从 server bundle **`external` 剔除** | import 期跑浏览器初始化的**客户端重库**（图表/可视化/canvas/WebGL/数值精度：recharts/echarts/three/konva/decimal.js…） | 见下「关于 external」+ 「模板·vite.config.ssr」 |
| 要不要**资源路由**（按需）                       | 要请求期动态产出带扩展名的文件（sitemap.xml / robots.txt / feed.json）                                        | 「局部深化·资源路由」                           |

### 关于「external 剔除重库」（最常踩的 P0，必读）

**纯客户端重库**（图表/可视化/canvas/WebGL/数值精度，如 recharts、echarts、decimal.js、three 等）**绝不能 import 进 SSR 渲染树**（`routes.tsx` → 组件模块顶层）。它们常在 import 期就跑浏览器初始化、或用 `typeof self/window` 探测环境；而 edge 是 **Deno（有 `self`）会被误判成浏览器分支直接崩**（线上实测：decimal.js 抛 `[DecimalError] Invalid argument: LN10` → edge 500 → 容器静默降级 CSR）。这类只做客户端展示、无 SEO 价值的能力，必须 `React.lazy`/动态 `import()` 或挂载后渲染，**仅客户端加载**。

⚠️ **但仅靠 `React.lazy`/动态 `import()` 在本指南的 IIFE server bundle 下不充分。** IIFE 无法代码分割，Vite lib 模式会强制 `inlineDynamicImports`，把 lazy 的目标模块**连同它静态依赖的重库一起内联进唯一 bundle**，并在 edge 加载（`new Function` eval）那一刻执行该重库的模块顶层副作用 → 仍然崩，且**连不含该库的页（如首页 `/`）都会一起 500**——这是 **bundle 级 eval 崩**，不是路由级渲染崩。所以这里有**两层、缺一不可**：

1. **客户端懒加载 / 挂载守卫** —— 挡的是**渲染期**崩（组件别在服务端被渲染）。
2. **`vite.config.ssr.ts` 用 `build.rollupOptions.external` 把重库从 server bundle 物理剔除** —— 挡的是 **eval 期**崩。

客户端构建（`vite.config.ts`）**不要** external，图表照常在浏览器加载。这份重库清单同时就是「执行步骤」的预扫描要标出、「模板·vite.config.ssr」 `external` 要剔除的库。

## 硬性规则（护栏，P0 真实踩过）

**MUST**

1. 只用 **Vite + React Router 7 data-mode**，不换栈。路由收敛成单一 `src/routes.tsx` 导出 `RouteObject[]`（server/client 共用），用 `createStaticHandler`/`createStaticRouter`/`StaticRouterProvider` + `loader` + `useLoaderData`；删 `<ScrollRestoration/>`。
2. **CSR 项目零影响**：没 flip 的项目构建/行为跟今天完全一致（仍用 `main.tsx`，没有 `entry-client`/`vite.config.ssr.ts`/edge function）。
3. `entry-server` 的 `render(path,ctx)` 只返回 **app HTML**（不拼完整文档）；由你写的 server（edge function）注进 index.html 模板的 `<!--ssr-outlet-->` 才成完整 HTML。
4. `index.html` 改两处：入口脚本 `main.tsx` → `src/entry-client.tsx`；`<div id="root"></div>` → `<div id="root"><!--ssr-outlet--></div>`（server 据此注入）。
5. 原 `App` 的 providers 收进 `routes.tsx` 根 route 的 `element`（providers 包 `<Outlet/>`）。
6. `vite.config.ssr.ts` 把 `react-router-dom` alias 到 `react-router`（保证 react-router 单份）。
7. 仅客户端能力（Sonner/主题/window）做挂载后渲染/守卫（见「客户端能力约束」）。

**DO NOT**

1. 不引入 Next/Remix/vite-react-ssg/框架式文件路由。
2. 不在模块顶层执行依赖浏览器环境的逻辑：`window`/`document`/`localStorage` → 加 `typeof window==='undefined'` 守卫或惰性化（**SSR bundle import 整棵树时会执行**）。
3. 不保留 `manualChunks` 对 `react`/`react-dom` 的拆分（破坏自包含 IIFE bundle）。
4. 不把客户端重库 import 进 SSR 渲染树（见 「关于 external」一节）。
5. 不做平台职责的事：**不要**往 index.html 注 `<meta name="x-ssr">`、**不要**写 `render-manifest.json`、**不要**做 CDN 上传——这些平台自动处理（见 「平台负责的」一节）。

## 执行步骤

### 1) SSR 安全全量预扫描（编码前必做）

改造前对 `src/` 全目录预扫描，汇总"待修改文件清单"：模块顶层/Hook 初始值/渲染路径里的 `window`/`document`/`localStorage`；非确定性值（时间戳/随机）；client-only 组件（Sonner/useTheme）；`framer-motion` 的 `initial` 隐藏态。**另外标出所有 client-only 重库（图表/可视化/canvas/WebGL/数值精度，如 recharts/echarts/decimal.js）的 import 点**——这些禁止进 SSR 树、必须改成客户端懒加载，否则在 Deno edge 直接崩（见 「关于 external」一节）。这份重库清单同时就是 「模板·vite.config.ssr」 `rollupOptions.external` 要剔除的库。清单收敛后再编码。

### 2) 按「模板」节写这些文件

`src/routes.tsx` · `src/entry-server.tsx` · `src/entry-client.tsx` · `vite.config.ssr.ts` · `supabase/functions/ssr/index.ts`，逐字照抄、按项目实际填空。

### 3) 改 `index.html` 两处

见上「硬性规则 MUST 4」。

### 4) 局部深化

要 SEO/首屏数据的页加 `loader`；要动态文件加资源路由。见 「局部深化」。

## 模板（逐字照抄）

下面是 5 个要新建的文件 + `index.html` 的 2 处改动，全照抄、按项目实际填空。注释里的坑都是 P0 实战，别删。

### 1) `src/routes.tsx` —— 单一路由源

```tsx theme={null}
import type { RouteObject } from 'react-router';
// import Layout from './Layout';   // 原 App 的 providers 收这里 + <Outlet/>
export const routes: RouteObject[] = [
  // {
  //   path: '/',
  //   element: <Layout />,
  //   children: [
  //     { index: true, element: <Home /> },                       // 壳（无 loader）
  //     {
  //       path: 'product/:id',
  //       element: <Product />,
  //       loader: async ({ params, context }) => {                // 局部深化（见下文「局部深化」节）
  //         const { data } = await context.supabase.from('products').select('*').eq('id', params.id).single();
  //         return { product: data };
  //       },
  //     },
  //     // 动态文件路由（资源路由）：有 loader、【无 element/Component】→ loader 返回 Response，server 原样吐。见 局部深化·资源路由。
  //     {
  //       path: 'sitemap.xml',
  //       loader: async ({ context }) => {
  //         const { data } = await context.supabase.from('posts').select('slug,updated_at');
  //         const body = renderSitemapXml(data ?? []);             // 你的拼接函数：产出 <urlset>…</urlset>
  //         return new Response(body, { headers: { 'content-type': 'application/xml; charset=utf-8' } });
  //       },
  //     },
  //     { path: '*', element: <NotFound /> },
  //   ],
  // },
];
```

### 2) `src/entry-server.tsx`（render → app HTML · 固定模板）

```tsx theme={null}
// ⚠️ 必须用显式子路径 `react-dom/server.browser`，不要写 `react-dom/server`。
// `react-dom/server` 是条件解析（export map），会被下面 vite.config 的 resolve.conditions:['node',…] 命中 `node`
// 分支 → server.node.js（React 的 Node streams 渲染器，依赖 Node 的 stream.Readable）。我们的 IIFE bundle 跑在
// Supabase 边缘函数（Deno），没有 Node 的 Readable → 初始化/首次 render 抛 `Cannot read properties of undefined
// (reading 'prototype')` → edge 500 → 容器降级 CSR（SSR 看着没生效）。子路径绕开 export 条件，恒拿 Web Streams 版
// （server.browser.js，用 ReadableStream/TextEncoder，Deno 原生支持）；renderToString 同步、对无 server-Suspense 的树行为一致，Node dev 下也照常工作。
import { renderToString } from 'react-dom/server.browser';
import { createStaticHandler, createStaticRouter, StaticRouterProvider, matchRoutes } from 'react-router';
import { routes } from './routes';

const { query, queryRoute, dataRoutes } = createStaticHandler(routes);

// 资源路由判定（如 /sitemap.xml、/robots.txt）：匹配到的叶子 route 有 loader 但【不渲染组件】（无 element/Component）。
// 这类「文件路由」用 queryRoute 取 loader 的【原始返回】（Response 直接透传）；page 路由才用 query 渲染成 HTML 文档。
// ⚠️ 必须分流：query() 会把 loader 返回的 Response 反序列化成渲染数据（拿不到原始 XML），只有 queryRoute() 返回原始 Response。
function isResourceRoute(path: string): boolean {
  const matches = matchRoutes(routes, new URL(`http://ssr.local${path}`).pathname);
  const leaf: any = matches?.[matches.length - 1]?.route;
  return !!leaf && !leaf.element && !leaf.Component && !!(leaf.loader || leaf.lazy);
}

// ctx 由 server（edge/dev）注入：{ supabase }（service_role）。经 requestContext 传给每个 loader 作为其 context。
// page 路由 → 返回 app HTML（含 RR 自动内联的 hydration script），由 server 注进 index.html 的 <!--ssr-outlet--> 成完整文档；
// 资源路由 → 返回 { body, contentType }，由 server 原样吐（不套模板），实现 /sitemap.xml 等动态文件。
export async function render(
  path: string,
  ctx: { supabase: any }
): Promise<{ html?: string; redirect?: string; status?: number; body?: string; contentType?: string }> {
  const request = new Request(`http://ssr.local${path}`);

  // 资源路由：queryRoute 取 loader 原始返回（Response 透传 / 字符串 / 对象都当文件原样输出）
  if (isResourceRoute(path)) {
    const result = await queryRoute(request, { requestContext: ctx });
    if (result instanceof Response) {
      const location = result.headers.get('Location');
      if (location && result.status >= 300 && result.status < 400) {
        return { redirect: location, status: result.status };
      }
      return {
        body: await result.text(),
        contentType: result.headers.get('Content-Type') ?? 'text/plain; charset=utf-8',
        status: result.status,
      };
    }
    if (typeof result === 'string') return { body: result, contentType: 'text/plain; charset=utf-8', status: 200 };
    // 非 Response/字符串：序列化为 JSON（result 为空也吐空串，确保资源路由绝不回落到 HTML 壳）
    return { body: result == null ? '' : JSON.stringify(result), contentType: 'application/json; charset=utf-8', status: 200 };
  }

  // page 路由：渲染完整 app HTML
  const context = await query(request, { requestContext: ctx });
  if (context instanceof Response) {
    return { redirect: context.headers.get('Location') ?? '/', status: context.status };
  }
  const router = createStaticRouter(dataRoutes, context);
  const html = renderToString(<StaticRouterProvider router={router} context={context} />);
  return { html };
}
```

### 3) `src/entry-client.tsx`（水合 · 固定模板）

```tsx theme={null}
import { createBrowserRouter, matchRoutes, RouterProvider } from 'react-router';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { routes } from './routes';

async function hydrate() {
  const lazyMatches = matchRoutes(routes, window.location)?.filter((m) => m.route.lazy);
  if (lazyMatches && lazyMatches.length > 0) {
    await Promise.all(
      lazyMatches.map(async (m) => {
        const routeModule = await (m.route.lazy as () => Promise<Record<string, unknown>>)();
        Object.assign(m.route, { ...routeModule, lazy: undefined });
      })
    );
  }
  const router = createBrowserRouter(routes); // 自动读 server 注入的 window.__staticRouterHydrationData
  const el = document.getElementById('root')!;
  const tree = <RouterProvider router={router} />;
  if (el.hasChildNodes()) hydrateRoot(el, tree);
  else createRoot(el).render(tree);
}
hydrate();
```

### 4) `vite.config.ssr.ts`（自包含 IIFE + react-router 单份 · 固定模板）

```ts theme={null}
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
  plugins: [react()],
  // edge 跑在 Deno，没有 process；React 等库初始化需要它
  define: { 'process.env.NODE_ENV': JSON.stringify('production') },
  resolve: {
    alias: { '@': path.resolve(__dirname, './src'), 'react-router-dom': 'react-router' },
    // 让依赖按 server 分支解析，避免库用 typeof self/window 走浏览器初始化（Deno 有 self 会误判→崩）。
    // ⚠️ 副作用：'node' 也会把 react-dom/server 解析成 server.node.js（Node stream 版）→ Deno edge 崩；故 entry-server
    //    对 react-dom 用显式子路径 server.browser 绕开（见模板·entry-server）。其它「带 node 分支、又依赖 Node 内建（stream/fs/crypto）」
    //    的库同理：命中后在 Deno 报 `xxx is undefined` / `reading 'prototype'`，改用其显式 web 子路径或给该包单独 alias。
    conditions: ['node', 'module', 'import', 'default'],
  },
  build: {
    outDir: 'dist/server', emptyOutDir: true,
    lib: { entry: 'src/entry-server.tsx', formats: ['iife'], name: '__SSR_BUNDLE__', fileName: () => 'entry-server.js' },
    rollupOptions: {
      // 纯客户端重库（图表/可视化/canvas/WebGL/数值精度）从 server bundle 物理剔除。
      // IIFE 无法代码分割 → Vite 强制 inlineDynamicImports → React.lazy 的目标连同其静态依赖的重库会被
      // 内联进唯一 bundle，并在 edge eval 时跑模块顶层副作用 → 崩（连不含该库的页也一起 500）。只有 external 能在打包期剔掉。
      // 清单 = 预扫描标出的「client-only 重库」，按项目实际用到的增减（未 import 的库列入也只是警告，无害）。
      // ⚠️ 客户端构建 vite.config.ts 千万不要加这些 external，否则浏览器里图表也没了。
      external: [
        'recharts', 'echarts', 'three', 'konva', 'react-konva',
        '@react-three/fiber', '@react-three/drei', '@react-three/cannon',
      ],
      output: {
        // server bundle 自挂全局做兜底；edge 主用「new Function 末尾 return 取回 __SSR_BUNDLE__」（见模板·supabase fn），不依赖这条
        footer: 'globalThis.__SSR_BUNDLE__=__SSR_BUNDLE__;',
        // ⚠️ IIFE 下 external 被当作全局注入：产物形如 `})({}, globalThis.__ext_recharts)`。
        //    每个 external 都要给 globals，且值必须是「undefined-safe 的 globalThis 取值表达式」，不能写裸标识符
        //    （如 recharts），否则 IIFE 调用时 ReferenceError: recharts is not defined。这些库只在客户端渲染时
        //    触达，服务端 eval 永不解引用 → 取到 undefined 无害（缺省时 Rollup 会 guess 成裸名 → 反而会崩）。
        globals: {
          recharts: 'globalThis.__ext_recharts', echarts: 'globalThis.__ext_echarts',
          three: 'globalThis.__ext_three', konva: 'globalThis.__ext_konva',
          'react-konva': 'globalThis.__ext_react_konva',
          '@react-three/fiber': 'globalThis.__ext_r3f',
          '@react-three/drei': 'globalThis.__ext_drei',
          '@react-three/cannon': 'globalThis.__ext_cannon',
        },
      },
    },
  },
});
```

### 5) `index.html`（改 2 处）

* 入口脚本：`<script type="module" src="/src/main.tsx">` → `/src/entry-client.tsx`。
* 注入点：`<div id="root"></div>` → `<div id="root"><!--ssr-outlet--></div>`（server 把 app HTML 注进这个占位）。

### 6) `supabase/functions/ssr/index.ts`（你项目的 SSR server · 固定模板，逐字照抄）

这是标准 Vite SSR 的 `server.js`，以 Deno edge function 形式部署到本站 Supabase。它 load 你的 server bundle + index.html 模板，render 出 app HTML、注进模板 → 返回完整 HTML。平台会用现成 `deploy_edge_function` 把它部署到本站 Supabase——**你只写文件、不管部署**（见 「平台负责的」一节）。

```ts theme={null}
// SSR server（Deno edge function）——本项目自带的 server，部署到本站 Supabase。
// load server bundle + index.html 模板 → render(path) → 注进 <!--ssr-outlet--> → 返回完整 HTML。
// bundle 的 CDN URL 由平台 container 用 header x-ssr-bundle 传进来；index.html 同版本基址（同目录的 /index.html）。
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

type Render = (
  path: string,
  ctx: { supabase: unknown }
) => Promise<{ html?: string; redirect?: string; status?: number; body?: string; contentType?: string }>;

const renderCache = new Map<string, Render>();
const templateCache = new Map<string, string>();

async function loadRender(bundleUrl: string): Promise<Render> {
  const cached = renderCache.get(bundleUrl);
  if (cached) return cached;
  const res = await fetch(bundleUrl);
  if (!res.ok) throw new Error(`fetch bundle failed: ${res.status}`);
  // ⚠️ Rollup 的 IIFE 产物是 `var __SSR_BUNDLE__ = (...)()`——在 new Function 里这个 var 是函数局部变量、
  // 不会挂到 globalThis 上。所以**末尾 return 把它直接取回来**，别去读 globalThis.__SSR_BUNDLE__（对 IIFE 不可靠：
  // 只要 bundle 能正常 eval，读到的 render 永远是空 → 每条路由 500 → 全部降级成同一份模板）。globalThis 只作兜底。
  const code = await res.text();
  const mod = new Function(`${code}\n;return typeof __SSR_BUNDLE__!=="undefined"?__SSR_BUNDLE__:globalThis.__SSR_BUNDLE__;`)() as
    | { render?: Render }
    | undefined;
  const render = mod?.render;
  if (typeof render !== 'function') {
    console.error(`[ssr] bundle did not expose render. bundleUrl=${bundleUrl}`);
    throw new Error('bundle did not expose __SSR_BUNDLE__.render');
  }
  renderCache.set(bundleUrl, render);
  return render;
}

async function loadTemplate(templateUrl: string): Promise<string> {
  const cached = templateCache.get(templateUrl);
  if (cached) return cached;
  const res = await fetch(templateUrl);
  if (!res.ok) throw new Error(`fetch template failed: ${res.status}`);
  const html = await res.text();
  templateCache.set(templateUrl, html);
  return html;
}

// 把 app HTML 注进模板：优先用 <!--ssr-outlet--> 占位；占位缺失（没加 / 被 minify 去注释）则注进 #root 开标签之后；
// 两者都没有才 throw（→ 500 → container 降级 CSR，绝不静默白屏）。
function injectAppHtml(template: string, appHtml: string): string {
  if (template.includes('<!--ssr-outlet-->')) return template.replace('<!--ssr-outlet-->', appHtml);
  const m = template.match(/<div[^>]*\bid=["']?root["']?[^>]*>/i);
  if (!m) throw new Error('SSR template has no <!--ssr-outlet--> nor #root to inject into');
  return template.replace(m[0], m[0] + appHtml);
}

Deno.serve(async (req: Request): Promise<Response> => {
  try {
    const u = new URL(req.url);
    const stripped = u.pathname.replace(/^\/functions\/v1\/ssr/, '').replace(/^\/ssr/, '');
    const sitePath = (stripped === '' ? '/' : stripped) + u.search;
    const bundleUrl = req.headers.get('x-ssr-bundle');
    if (!bundleUrl) return new Response('missing required header: x-ssr-bundle', { status: 400 });
    const templateUrl = bundleUrl.replace('/server/entry-server.js', '/index.html');

    const supabase = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
      { auth: { persistSession: false } }
    );

    const [render, template] = await Promise.all([loadRender(bundleUrl), loadTemplate(templateUrl)]);
    const r = await render(sitePath, { supabase });
    if (r.redirect) {
      return new Response(null, { status: r.status ?? 302, headers: { location: r.redirect } });
    }
    // 资源路由（如 /sitemap.xml /robots.txt）：render 返回原始 body+contentType → 原样吐，不套 index.html 模板。
    if (r.body != null) {
      return new Response(r.body, {
        status: r.status ?? 200,
        headers: { 'content-type': r.contentType ?? 'text/plain; charset=utf-8' },
      });
    }
    const html = injectAppHtml(template, r.html ?? '');
    return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } });
  } catch (e) {
    // 渲染/载入失败 → 非 2xx，让 container 降级回 CSR（永不白屏）。
    // 必须 console.error：否则错误只进响应体、不进 function_logs，线上日志一片空、真因看不见。
    console.error(`[ssr] render failed: ${req.url}`, (e as Error)?.stack ?? e);
    return new Response((e as Error)?.message ?? String(e), { status: 500 });
  }
});
```

## 局部深化：loader 与资源路由

### route `loader`（服务端取数）

```tsx theme={null}
{
  path: 'product/:id',
  element: <Product />,
  loader: async ({ params, context }) => {
    // context = server 注入的 { supabase }（service_role，取浏览器 anon 拿不到的数据）
    const { data } = await context.supabase.from('products').select('*').eq('id', params.id).single();
    return { product: data };
  },
}
```

组件 `const { product } = useLoaderData();` 读回。没 loader 的 route = 壳（客户端取数）。

### 动态文件路由（资源路由）

`/sitemap.xml`、`/robots.txt`、动态 `feed.json` 等。当你要**请求期动态产出一个文件**（生成 sitemap、robots、RSS/Atom、动态 manifest 等），在 `routes.tsx` 里加一条**资源路由**：

* **有 `loader`、但【不写 `element`/`Component`】**——`entry-server` 据此用 `queryRoute` 取 loader 的原始 `Response`（page 路由有 `element`、走 `query` 渲染 HTML）。
* **`loader` 直接 `return new Response(body, { headers: { 'content-type': '…' } })`，且 Content-Type 必须是非 HTML**（`application/xml`、`text/plain`、`application/json`…）——**这是容器判「资源」（原样吐、不注平台脚本）还是「页面」的唯一依据**；漏设 / 设成 `text/html` 会被当页面注脚本。也可只 `return` 字符串（按 `text/plain` 吐）。
* **path 带不带扩展名都行**：容器靠 edge 返回的 `Content-Type` 判定、不再靠扩展名——`/sitemap.xml`、无后缀的 `/sitemap/static` 都可。分片/动态文件名用动态段：`/sitemap/:page.xml`、`/feeds/:slug.xml`。

```tsx theme={null}
// routes.tsx 里，与 page 路由并列：
{
  path: 'sitemap.xml',                              // 无 element + loader 回非 HTML Content-Type = 资源路由（带不带后缀都行）
  loader: async ({ context }) => {
    const { data } = await context.supabase.from('posts').select('slug,updated_at');
    const urls = (data ?? [])
      .map((p) => `<url><loc>https://你的站点域名/posts/${p.slug}</loc><lastmod>${p.updated_at}</lastmod></url>`)
      .join('');
    const xml = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`;
    return new Response(xml, { headers: { 'content-type': 'application/xml; charset=utf-8' } });
  },
}
```

**机制（你只写上面这条 route，其余平台自动）**：

* `entry-server` 对资源路由用 `queryRoute` 取 loader 的**原始 `Response`**（page 路由才用 `query` 渲染 HTML）；你的 `index.ts` server 把它原样吐、不套 index.html 模板——见「模板」节。
* 容器对 SSR 站**不做路由白名单**（已无 `dynamicFileRoutes`）：带扩展名路径先精确查静态产物（命中且**非空**就走文件），**不存在或为空**则连同任意无扩展名路由一起转发给 edge。**页面还是资源由 edge 返回的 `Content-Type` 决定**——`text/html`→当页面（注平台脚本）；其余→资源原样吐（不注入、用 edge 的 `Content-Type`）。代价：带后缀的缺失请求（bot 探 `.php` 等）也会打一次 edge、由项目 router 的 `*` 兜底；真实静态产物因「有内容」已在前一步命中走文件、不碰 edge。
* 不要再去 `public/` 放同名静态文件——静态文件优先级更高（精确命中即原样返回），会盖掉你的动态路由。

## 客户端能力约束

1. **Sonner/toast**：保留在根布局，加 `mounted` 守卫；`toast(...)` 只在事件回调/`useEffect`。
2. **主题**：`useTheme()` 分支挂载后执行，未挂载用稳定 fallback。
3. **浏览器 API**：顶层/Hook 初始值/渲染路径里的 `window`/`document`/`localStorage` → `useEffect` 或 `typeof window!=='undefined'` 守卫。
4. **非确定性值**：时间戳/随机改稳定值或移进 `useEffect`。

`useMounted` 参考：

```tsx theme={null}
function useMounted() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  return mounted;
}
```

## 平台负责的（你**不**写）

flip 时**只写「模板」节的项目源码 + loaders（见「局部深化」）**。下面平台自动处理，不要手搓：

* **edge 部署**：平台用 `deploy_edge_function` 把你写的 `supabase/functions/ssr/index.ts` 部署到本站 Supabase。**代码你写、部署平台管。**
* **serving 标记 + bundle 指针**：构建自动往 index.html 注 `<meta name="x-ssr">`、写 `render-manifest.json`（存 server bundle 的 CDN URL），供 container 门控判 SSR + 把 bundle URL 用 header 传给你的 server。**你不写这些。**
* **CDN 上传 / 请求期门控**：平台上传 server bundle + index.html 到 CDN；container 转发请求给你的 server、拿回完整 HTML、注平台脚本、在真站点 URL 吐给访客。

## dev 预览（可选）

* 文件进了源码后，项目 `dev`（`vite`）跑的就是真 SSR-shaped app（data router 客户端跑），结构与 prod 一致。
* 想 dev **真·服务端渲染**：加官方 `server.js`（Node，`vite.ssrLoadModule('src/entry-server')` + 同样注模板）——和「模板·supabase fn」的 edge 是同一套逻辑、不同 runtime。**也是项目源码。** 不加则 SSR 校验落在发布后 snapshot 预览（走平台 container→你的 edge = 真 SSR）。

## 交付验收（必须全部通过）

1. `pnpm build` 成功 + 产出 `dist/server/entry-server.js`（IIFE，暴露 `__SSR_BUNDLE__.render`；edge 按模板「末尾 return 取回」加载，不依赖 globalThis）。
2. 客户端构建产出正常（index.html 入口指向 `entry-client`，#root 含 `<!--ssr-outlet-->`）。
3. `supabase/functions/ssr/index.ts` 已写（你的 SSR server）。
4. 运行后无 `window/document is not defined`；首屏 hydration 无显著 mismatch。
5. 路由是单一 `src/routes.tsx` 的 `RouteObject[]`。
6. 深化过的页 SSR 首屏带 loader 数据；未深化的页正常出壳 + 客户端接管。
7. （若加了资源路由）发布后访问 `/sitemap.xml`、`/sitemap/static` 等：返回 loader 设的 Content-Type（如 `application/xml`）、内容是动态产出而非 HTML 壳，且**响应里没被注入平台 `<script>`**（被注入 = 容器把它当成了页面，去查 loader Response 的 Content-Type 是否非 HTML）。
8. 对一个**不含任何客户端重库**的公开页（通常是首页 `/`）单独发一次 SSR 请求，确认不出现任何重库的初始化错（如 `[DecimalError]`）。若这种页都报重库错，说明该库进了 eager bundle（IIFE 内联），需按 「关于 external」一节 用 `external` 物理剔除，而非仅 lazy。

## 失败排查（按顺序）

1. **edge 500 且报错栈来自 `node_modules`** → 某个包被打进 SSR bundle、在 Deno 上跑了不兼容的分支。两种典型，**修法不同**：
   * 栈是 `Cannot read properties of undefined (reading 'prototype')`（来自 react-dom）→ `react-dom/server` 被 `resolve.conditions:['node',…]` 解析成了 `server.node.js`（Node stream 版），Deno 没有 Node `Readable`。**改 entry-server 的 import 为显式子路径 `react-dom/server.browser`**（见「模板·entry-server」；别动 conditions）。其它「带 node 分支又依赖 Node 内建」的库同理（显式 web 子路径 / 单独 alias）。
   * 栈是 `[DecimalError]`、某图表/数值库 → 该库 import 期跑了浏览器初始化分支（`typeof self/window` 把 Deno 误判成浏览器）。**注意仅靠 React.lazy/动态 import 在 IIFE bundle 下排不掉**（IIFE 强制内联，库的顶层副作用仍在 bundle eval 时执行）。正确修法是 `external` + `output.globals` 把它从 server bundle 物理剔除（见 「关于 external」一节），客户端继续用 lazy 加载。**自检**：若连一个不含该库的公开页（如首页 `/`）都报这个错，就是 bundle 级 eval 崩、必须 external，而不是某条路由的渲染问题。
2. **edge 500 报 `bundle did not expose __SSR_BUNDLE__.render`，或现象是「每条路由返回同一份模板、`/` 和子路由内容一模一样」** → bundle 能正常 eval 但服务端取不到 render 函数。根因：Rollup 的 IIFE 产物 `var __SSR_BUNDLE__=(...)()` 在 `new Function` 里是局部变量、不挂 globalThis；必须用「末尾 `return __SSR_BUNDLE__` 取回」的载入方式（见「模板·supabase fn」），别去读 `globalThis.__SSR_BUNDLE__`。**注意第 1 条会遮住这条**——bundle 一 eval 崩就走不到取 render，所以先解第 1 条、再回来看这条是否仍在。
3. 先查 `vite.config.ssr.ts` 是否把 `react-router-dom` alias 到 `react-router`、是否按模板加了 `resolve.conditions`/`footer`/`define`。
4. 再查 `dist/server/entry-server.js` 是否产出。
5. 再查 edge：`render` 返回的是 `{html}`、server 注进 `<!--ssr-outlet-->`（占位是否在 index.html 里）、`x-ssr-bundle`/模板 URL 是否能 fetch。
6. 再查 SSR bundle import 期崩：定位模块顶层浏览器 API（回「执行步骤」的预扫描补齐）。
7. 再查 hydration mismatch：主题/Sonner 挂载守卫、非确定性值。
8. **资源路由（`/sitemap.xml`、`/sitemap/static` 等）返回 HTML 壳 / 被注入脚本 / 404 而非动态内容**：① 该 route 是否**误带了 `element`/`Component`**（带了 edge 就当 page 渲染、走不到 `queryRoute`）；② loader 的 Response 是否设了**非 HTML 的 `Content-Type`**（漏设 / 设成 `text/html` → 容器当页面、会注平台脚本）；③ `public/` 下是否放了**同名非空静态文件**（带后缀路径精确命中优先、会盖掉动态路由，删掉它）。

> **看日志**：edge 现在会 `console.error` 出失败路径 + 栈（见「模板·supabase fn」），到本站 Supabase 的 `function_logs` 直接看真因——不再是「只进响应体、日志一片空」。
>
> **已知可接受噪音**：`function_logs` 里的 `Warning: useLayoutEffect does nothing on the server`（Radix 等组件在服务端调 `useLayoutEffect` 触发）不影响 200 与内容产出，属预期，不必处理。
>
> **`function_logs` 为空 ≠ 没出错**：edge 实例回收后日志有延迟、或查询时间窗没覆盖到失败请求时都会「空」。此时应 ① 主动发一次请求触发渲染再查；② 确认时间窗覆盖最近的 500；③ 务必拿到真实栈再下结论。切忌在拿不到栈时仅凭「先 200 后 500、未重部署」就推断成冷启动/远程依赖问题——确定性的 bundle eval 崩（同一路由稳定 500）和冷启动（时对时错）现象不同，靠状态码区分不了，必须看栈。
