Diff v2 → v3

v2: bot legacy · 2026-08-07T09:32:06Z
v3: bot legacy · 2026-08-13T07:10:22Z
  # React Production Playbook (Inertia + Rails)
  
  > **When to use it:** building or reviewing a React frontend on an Inertia Rails
  > backend. Companion to the [Rails Production Playbook](https://wiki.joexbayer.dev/wiki/rails-production-playbook)
  > — that one covers the server; this one covers everything Inertia hands to React,
  > and marks which playbook items Inertia already solved. Assumes `@inertiajs/react`
  > 3.x / `inertia_rails` 3.x, React 19.2.x — note the two version lines move
  > independently, so check the adapter, not the gem, before relying on anything
  > marked *(Inertia 3)*. Default posture: *the server decides, the view renders,
  > one transport per datum.*
  
  **The organizing idea: the entire React tree is `app/views/`.**
  
  Inertia kept routing, controllers, services, models, authorization and
  presentation in Ruby. What it handed to React is the view layer. Every structural
  question therefore has a Rails answer already, and most of the SPA architecture
  canon — client routing, a fetch layer, a cache, an invalidation map — is not
  simplified here, it is *deleted*. Do not port it back in out of habit.
  
  The exception is large datasets, which get a real JSON API. That seam is the only
  genuinely new thing in this document; the rest is Rails discipline in TSX.
  
  ---
  
  ## Architecture & code design
  
  ### The spine
  
  Sandi Metz' rules now apply **literally, in Ruby**, all the way to the view.
  Rule 4 — *"a controller instantiates one object; a view knows one instance
  variable"* — becomes enforceable end-to-end for the first time:
  
  | Rails | Inertia React |
  | --- | --- |
  | Controller | **the Rails controller** |
  | Service object | **Ruby PORO** |
  | Presenter | **Ruby page presenter + serializer** |
  | Model | ActiveRecord |
  | View (ERB) | **page component** `pages/articles/show.tsx` |
  | Partial | feature component |
  | Helper | `lib/` |
  | Form object | Rails form object + `useForm` |
  
  ```ruby
  class ArticlesController < ApplicationController
    def show
      article = Article.includes(:author, :tags).friendly.find(params[:id])
      authorize article
      render inertia: "articles/show", props: ArticleShowPage.new(article, current_user).to_props
    end
  end
  ```
  
  One object per action. `ArticleShowPage` owns the whole prop shape — the same
  extraction you'd do the moment a controller action grew a second `@ivar`.
  
  - **Use when:** always. If an action assembles props inline and it isn't a
    one-liner, extract the page presenter.
  
  ### Project structure
  
  ```
  app/
  ├─ controllers/articles_controller.rb
  ├─ presenters/article_show_page.rb        # owns the prop shape
  ├─ serializers/article_serializer.rb      # Alba — explicit, shared with the API
  ├─ policies/article_policy.rb             # feeds the `can:` props
  └─ frontend/
     ├─ entrypoints/{application,ssr}.tsx
     ├─ pages/                    # ≈ app/views/ — one folder per controller
     │  ├─ articles/              #   the slice IS the controller's view folder
     │  │  ├─ index.tsx           #     ≈ app/views/articles/index.html.erb
     │  │  ├─ show.tsx
     │  │  ├─ new.tsx
     │  │  ├─ shared.ts           #     public surface of the slice (NOT index.ts)
     │  │  ├─ ui/                 #     the partials
     │  │  ├─ model/              #     view logic Ruby *can't* do
     │  │  ├─ hooks/              #     ephemeral UI state only
     │  │  └─ api/                #     ONLY in slices with a tier-3 dataset
     │  └─ errors/error.tsx
     ├─ layouts/{app,auth}-layout.tsx        # ≈ app/views/layouts/
     ├─ components/ui/            # ≈ app/views/shared/ — shadcn
     ├─ lib/                      # ≈ app/helpers/ + lib/
     └─ types/serializers.d.ts    # generated by Typelizer. Never hand-edited.
  ```
  
  There is no `features/` directory. A domain slice and a controller's view folder
  are the same thing, so they are the same folder: `pages/articles/` holds the pages
  *and* their partials, exactly as `app/views/articles/` holds templates and
  `_partials`. A slice that owns no route doesn't exist — it's a controller you
  haven't written yet, or it belongs in `components/ui/`.
  
  Top-level files in the folder are actions; subdirectories are implementation.
  That single rule is the whole layout.
  
  Most slices have no `api/`. That asymmetry is informative: it shows at a glance
  which domains carry a real dataset.
  
  **Two mechanical consequences of colocating — fix both on day one.**
  
  **1. The resolver glob is now a bundle boundary.** Inertia's resolver globs the
  page tree, so once implementation lives under `pages/`, every `.tsx` in there
  becomes an addressable page name — and under an eager glob, an unconditional
  import into the client bundle. Exclude the implementation dirs and the tests:
  
  ```ts
  // entrypoints/application.tsx
  const pages = import.meta.glob<{ default: ComponentType }>(
    [
      '../pages/**/*.tsx',
      '!../pages/**/{ui,model,hooks,api}/**',
      '!../pages/**/*.{test,stories}.tsx',
    ],
    { eager: true },   // pages resolve synchronously — no dynamic import between click and screen
  );
  
  resolve: (name: string) => {
    const page = pages[`../pages/${name}.tsx`];
    if (!page) {
      const known = Object.keys(pages).map((p) =>
        p.replace('../pages/', '').replace(/\.tsx$/, ''));
      throw new Error(
        `Inertia page "${name}" not found — expected pages/${name}.tsx. Known: ${known.join(', ')}`,
      );
    }
    return page;
  },
  ```
  
  Two failures this prevents, both seen in the wild:
  
  - Without the negative patterns, `articles/ui/article-header` is addressable as a
    page — a partial rendered as a full response with none of its props — and a
    colocated `Search.test.tsx` is eagerly imported, shipping Testing Library and
    fixtures to production. **Check this one first: it's silent, and it predates
    colocation.** Any project that colocates tests under `pages/` already has it.
  - Without the explicit `throw`, a missing or renamed page resolves to `undefined`
    and dies several frames deep inside React — a blank screen, not an error. The
    controller's `render inertia:` string and the file path are exactly the two
    things worth printing when they disagree. Keep the `known` list: it turns a
    five-minute bisect into a glance. (If you support both `.jsx` and `.tsx` during
    a TS migration, widen the glob **and** the lookup key — widening only the glob
    makes every page resolve and then miss.)
  
  **2. The slice barrel cannot be `index.ts`** — `pages/articles/index.tsx` is
  already the `index` action. Name it `shared.ts` and let the collision stay
  impossible rather than resolution-order-dependent.
  
  ### When do the slice subdirectories earn their keep?
  
  Not immediately. Below roughly a dozen pages, a flat `components/` next to a flat
  `pages/` is the right amount of structure, and the `ui/ model/ hooks/ api/` split
  inside each slice is ceremony — you can see the whole tree at once, so nothing is
  lost by not naming it.
  
  Promote a slice to subdirectories when its partials outnumber its actions, or the
  first `api/` appears. Promote per slice, not repo-wide: one domain carrying a
  tier-3 dataset while the rest stay flat is the correct end state, not an
  inconsistency to tidy up.
  
  ### Page components are ERB templates
  
  ```tsx
  export default function ArticlesShow({ article, comments, can }: ArticleShowPageProps) {
    return (
      <>
        <ArticleHeader article={article} canEdit={can.edit} />
        <ArticleBody article={article} />
        <CommentSection comments={comments} articleSlug={article.slug} canComment={can.comment} />
      </>
    );
  }
  
  ArticlesShow.layout = (page: React.ReactNode) => <AppLayout>{page}</AppLayout>;
  ```
  
  Props in, partials composed, layout declared. The `.layout` assignment is
  `layout "application"`, and it's what keeps the layout *persistent* across visits
  so sidebar scroll and open menus survive navigation.
  
  - **If a page component needs a comment to explain it, the logic belongs in the
    presenter.**
  - Page grows past a screenful → extract a partial into `pages/<controller>/ui/`.
  - Props grow unwieldy → that's a presenter problem. Split the presenter or defer
    the expensive branch. The fix lives in Ruby.
  
  ### Layer rules within a slice
  
  ```
  ui/ ──→ hooks/ ──→ api/ ──→ lib/http
   └────────┴────────→ model/   (pure — imports nothing but lib/)
  ```
  
  The action files at the top of the folder sit above all of it: a page imports its
  own `ui/`, nothing imports a page.
  
  Enforce with `eslint-plugin-boundaries`: `ui` may not import `api`; `model` may
  import only `lib`; cross-slice traffic goes through `pages/*/shared.ts` only —
  never a deep import into another controller's `ui/`. This is the Packwerk
  `public/` boundary, and it's the difference between colocation as an aspiration
  and as a constraint.
  
  Colocation makes the common violation louder, which is the point: a partial that
  two controllers both want is now visibly in the wrong folder, and the fix is
  `components/ui/` or a shared parent slice — not a deep import.
  
  ### Where does it go?
  
  | Question | Answer |
  | --- | --- |
  | Renders one controller action? | `pages/<controller>/<action>.tsx` |
  | Reused across actions of one controller? | `pages/<controller>/ui/` |
  | Reused across controllers? | `components/ui/` |
  | Wraps pages? | `layouts/` |
  | Derives a value from props? | **the Ruby presenter** |
  | Needs the browser to compute it? | `pages/<controller>/model/` |
  | Ephemeral UI state (open/closed, hover)? | `pages/<controller>/hooks/` |
  | Fetches page data? | **nowhere. It's a prop.** |
  | Fetches a large collection? | `pages/<controller>/api/` — see tiers below |
  
  `model/` is deliberately narrow: relative timestamps, client-side filtering of an
  already-loaded list, layout measurement. Currency formatting, status labels and
  permission derivation all belong in Ruby. If `model/` is growing, presentation is
  leaking out of the presenter.
  
  ### Shared props are `ApplicationController`
  
  ```ruby
  class ApplicationController < ActionController::Base
    inertia_share do
      {
        auth:    { user: current_user && UserSerializer.new(current_user).to_h },
        flash:   { notice: flash.notice, alert: flash.alert },
        traceId: request.request_id,
      }
    end
  end
  ```
  
  ```ts
  // lib/use-page-props.ts — type it once, never call usePage() raw again
  export const useAuth  = () => usePage<SharedProps>().props.auth;
  export const useFlash = () => usePage<SharedProps>().props.flash;
  ```
  
  Raw `usePage()` in a component is the equivalent of reaching into `session` from
  an ERB template.
  
  ---
  
  ## Data & transport
  
  ### Three tiers — one transport per datum
  
  This is the central operational decision in an Inertia app. Props are **page
  render payload**, not a resource representation: they are re-serialized on every
  visit and retained in history state for back/forward. A 5,000-row table therefore
  costs a serialization per visit and a copy per history entry. Deferred props delay
  that cost; they don't remove it. There is also no cross-page cache.
  
  | Tier | Transport | When |
  | --- | --- | --- |
  | **1 — small** | Inertia props | session, auth, flash, permissions, the record being viewed, form option lists, anything under a few hundred rows |
  | **2 — large, page-bound** | `InertiaRails.defer` + merge props + `WhenVisible` | big list that loads once, scrolls, and never refreshes independently of the page |
  | **3 — large, interactive** | **JSON API + TanStack Query** | typeahead, polled/live tables, cross-page reuse, server-side filter/sort that shouldn't re-run the controller, virtualized windows, exports |
  
  - **Tier 3 is earned by needing a cache with its own lifecycle, not by row count.**
    Reach for tier 2 first — it's a controller change, not a new surface.
  - **Use when:** decide per dataset, write it down (below), don't drift.
  
  ### The manifest
  
  Three lines per slice, in its `shared.ts` or README. This is the artifact that
  keeps the seam honest six months in:
  
  ```
  articles/
    article (record)        → props
    articles (collection)   → API   [first page seeded from props]
    stats                   → props
  comments/
    comments (collection)   → API
    commentCount            → API   ← lives with the list, not in props
  ```
  
  The failure mode of a hybrid is never complexity — it's `commentCount` arriving
  via props while the list arrives via Query, and the two disagreeing after a write.
  
  ### Partial reloads replace invalidation
  
  ```ts
  router.reload({ only: ['comments', 'stats'] });
  ```
  
  `invalidateQueries`, keyed by prop name, declared at the call site, with the
  server deciding freshness. Dot-notation targets nested props.
  
  ### The tier-3 API — one backend, two renderers
  
  ```
  app/controllers/
  ├─ articles_controller.rb           # Inertia — page renders
  └─ api/articles_controller.rb       # JSON — collections
  app/serializers/article_serializer.rb   # ← shared
  app/policies/article_policy.rb          # ← shared
  ```
  
  ```ruby
  module Api
    class ArticlesController < Api::BaseController
      def index
        scope = policy_scope(Article).includes(:author, :tags)
        page  = ArticlesQuery.new(scope, filter_params).cursor_page(params[:cursor])
        render json: {
          items: ArticleSerializer.new(page.records, view: :summary).to_a,
          nextCursor: page.next_cursor,
        }
      end
    end
  end
  ```
  
  Same query object, same serializer, same policy — only the renderer differs.
  `Api::BaseController` differs from `ApplicationController` in exactly three ways:
  it renders `problem+json`, it skips `inertia_share`, and it has its own rate limit.
  
  - **Cookie-authed, same-origin, CSRF-protected.** Internal surface, not a public
    API: no bearer tokens, no `/v1/`, no versioning — same session, same deploy. A
    public API is a *third* surface with its own contract and lifecycle.
  - **Cursor pagination**, not offset. Deep offsets on a large table are the thing
    that made you build tier 3 in the first place.
  
  ### Layering the client side of tier 3
  
  `apiFetch` is the adapter; `useQuery` is the service. Never collapse them — a hook
  can't be called outside a render, and the same `apiFetch` must serve mutations,
  prefetches and exports.
  
  ```ts
  // lib/http.ts — the boundary. Knows HTTP. No React.
  export async function apiFetch<T>(path: string, schema: z.ZodType<T>, init?: RequestInit) {
    const res = await fetch(path, {
      ...init,
      credentials: 'same-origin',
      headers: {
        Accept: 'application/json',
        'X-CSRF-Token': csrfToken(),
        'X-Correlation-ID': correlationId(),
        ...init?.headers,
      },
    });
    if (res.status === 401) { router.visit('/login'); throw new SessionExpired(); }
    if (!res.ok) throw await parseProblem(res);
    return schema.parse(await res.json());
  }
  ```
  
  ```ts
  // pages/articles/api/endpoints.ts — repository
  export const fetchArticles = (params: ArticleQueryParams) =>
    apiFetch(ArticlesApi.index.path(), ArticlePage, { query: params });
  
  // pages/articles/api/queries.ts — query objects
  export const articleKeys = {
    all:  ()           => ['articles'] as const,
    list: (f: Filters) => [...articleKeys.all(), 'list', f] as const,
  };
  
  // pages/articles/hooks/use-article-list.ts — service. useQuery lives here.
  export function useArticleList(filters: Filters, seed?: Page<ArticleSummary>) { /* ... */ }
  ```
  
  The 401 branch matters in a hybrid: a dead session on the API surface must hand
  control back to Inertia, not surface as an error toast.
  
  ### Bridging the seam
  
  Seed Query from props so first paint has no spinner and no duplicate request:
  
  ```ts
  initialData: seed && isDefaultFilters(filters)
    ? { pages: [seed], pageParams: [null] }
    : undefined,
  ```
  
  The `isDefaultFilters` guard is load-bearing — seeding a filtered query with an
  unfiltered first page is a silent correctness bug.
  
  Crossing the boundary should be rare and explicit, one line in the success handler:
  
  ```ts
  form.post(path, { onSuccess: () => queryClient.invalidateQueries({ queryKey: commentKeys.list(slug) }) });
  useMutation({ mutationFn: archive, onSuccess: () => router.reload({ only: ['stats'] }) });
  ```
  
  ---
  
  ## Errors — three channels, one shape
  
  Inertia never receives 422 responses. Validation errors are redirected back and
  flashed into the session, arriving as the `errors` prop; Inertia checks
  `page.props.errors` to decide whether `onError()` or `onSuccess()` fires. Do not
  force `problem+json` through page visits — Inertia's protocol already solved that.
  
  | Channel | Transport | Renders as |
  | --- | --- | --- |
  | Validation | session flash → `errors` prop | `form.errors` |
  | Exception on an Inertia visit | `rescue_from` → Inertia response | `pages/errors/error.tsx` |
  | Non-Inertia JSON (tier 3, `useHttp`, webhooks, agents) | **`problem+json`** | `parseProblem()` |
  
  ```ruby
  # app/errors/error_payload.rb — one shape, two renderings
  class ErrorPayload
    def to_problem_json  = { type:, title:, status:, detail:, trace_id: }
    def to_inertia_props = { status:, title:, detail:, traceId: }
  end
  
  class ApplicationController < ActionController::Base
    rescue_from StandardError, with: :render_error
  
    private
  
    def render_error(exception)
      raise exception if Rails.env.local?
      status  = ActionDispatch::ExceptionWrapper.new(nil, exception).status_code
      payload = ErrorPayload.new(exception, request)
  
      if request.headers["X-Inertia"]
        render inertia: "errors/error", props: payload.to_inertia_props, status: status
      else
        render json: payload.to_problem_json, status: status,
               content_type: "application/problem+json"
      end
    end
  end
  ```
  
  Both carry `trace_id`, so the correlation-ID story survives: the error page shows
  it, the user quotes it, one grep spans click → controller → service → job.
  
  ```ts
  // lib/errors.ts — one client shape, whichever channel it arrived on
  export type AppError = {
    status: number; title: string; detail?: string;
    fields: Record<string, string[]>; traceId?: string;
  };
  export const fromInertia = (errors, traceId): AppError => ...
  export const fromProblem = (body): AppError => ...
  ```
  
  One `<ErrorSummary error={…} />` renders both.
  
  **Fix on day one: Rails and Inertia disagree about `errors` twice**, and the two
  halves have different fixes. This is the single most reliably re-solved-per-page
  problem in the stack, so solve it once, globally, in one file.
  
  *The value type.* Inertia types an error value as `string`; Rails
  `errors.to_hash` gives `string[]` per attribute, one message or many. Don't
  flatten it server-side with `to_sentence` — that discards per-message structure
  you may want to render as a list. Widen the client instead, at the extension
  point Inertia publishes for it:
  
  ```ts
  // lib/rails-errors.ts
  declare module '@inertiajs/core' {
    interface InertiaConfig {
      errorValueType: string[]
    }
  }
  ```
  
  Module augmentation is global no matter which file declares it, so this fixes
  `useForm().errors`, the page-level `errors` prop and `setError` in one line. The
  same `InertiaConfig` interface types your flash payload (`flashDataType`) —
  declare them as the pair they are.
  
  *The key namespace.* This one has no global fix. Inertia keys errors by **data
  path** — form data `{ account: { email } }` yields `'account' | 'account.email'`
  — which is Laravel's convention, where the error key *is* the parameter path.
  Rails keys by the bare **attribute** (`email`). No generic argument describes
  both, and no reshaping of the form data reconciles them; it's a disagreement
  between two frameworks, not a bug in either. So assert the conversion once:
  
  ```ts
  export type RailsErrors<K extends string> = Partial<Record<K, string[]>>
  export const railsErrors = <K extends string>(errors: object) => errors as RailsErrors<K>
  ```
  
  It must be a cast: `RailsErrors<K>` is a weak type, so TypeScript rejects a plain
  annotated assignment from `form.errors` with *"no properties in common"* — the
  two key namespaces genuinely share none. Each page then declares only its
  attribute list.
  
  **Enumerate every attribute the model can error on, not just the ones with an
  input.** A custom validation that adds to `:video_asset` when no asset field
  exists on the form renders "Correct the marked fields below" with nothing
  marked — a dead-end error state, and the reason this belongs on a checklist.
  
  ---
  
  ## Security
  
  ### Authorization
  
  Pundit in the controller; policies ride along as props. No route tree to guard —
  that's a real reduction in attack surface versus an SPA.
  
  ```ruby
  can: { comment: policy.comment?, edit: policy.edit? }
  availableActions: article.aasm.events(permitted: true).map(&:name)
  ```
  
  The UI renders buttons from `availableActions`. The client never re-encodes
  transition rules, so the two state machines can't disagree. Client-side `can`
  checks are UX; the controller re-checks every action.
  
  ### Props are a security boundary
  
  Every instance variable becomes a prop, and a model prop serializes through
  `as_json` — every column, password digest included. **Explicit Alba serializers
  are not stylistic here.** Ban bare model props in review; a lint or a spec that
  asserts serializer usage is better.
  
  ### Config
  
  There are no secrets in a bundle. Two buckets:
  
  | Rails bucket | Frontend equivalent |
  | --- | --- |
  | Encrypted credentials | **does not exist** — anything secret stays server-side, which under Inertia is nearly everything |
  | `.yml` config | build-time `import.meta.env.VITE_*` — **world-readable** |
  | ENV | runtime values arrive as **shared props**, not as a `/config` endpoint |
  
  Inertia makes this easier than an SPA: runtime config is just a shared prop.
  CI-grep `VITE_*` for `*_SECRET|*_KEY|*_TOKEN` and fail the build.
  
  ### Supply chain
  
  `pnpm install --frozen-lockfile`, `pnpm audit --prod`, Semgrep, review gate on
  lockfile diffs. A real CSP — note that Inertia's dev error modal injects inline
  styles, so pass a nonce to `createInertiaApp()` (or enable the native `<dialog>`
  error modal via the `future.useDialogForErrorModal` default).
  
  ### RSC
  
  Not applicable — you aren't running it, which is the correct outcome. For context
  on why that matters: CVE-2025-55182 ("React2Shell", CVSS 10.0, Dec 2025) was an
  unauthenticated RCE in the RSC Flight protocol, near-100% exploitable against
  default configs, CISA KEV, exploited within days. Inertia sidesteps the entire
  category. Keep React itself patched regardless.
  
  ---
  
  ## Reliability & resilience
  
  ### What Inertia already gives you
  
  | Playbook item | Status |
  | --- | --- |
  | **Version skew / stale tabs** | ✅ built in — asset versioning forces a full reload on mismatch |
  | **Cache clear on logout** | ✅ built in — history encryption / `clearHistory` |
  | **Client-side cache leaks between tenants** | ✅ void for tiers 1–2 (no cache). Tier 3 needs the tenant in the query key |
  | **Optimistic updates + rollback** | ✅ built in (Inertia 3) |
  | **Double-submit** | ✅ `form.processing` |
  
  ### What is still yours
  
  - **Idempotency keys.** `form.processing` stops double-clicks, not network
    retries. For money-moving actions, mint `crypto.randomUUID()` once per intent,
    carry it as a hidden field or header, back it with a unique index.
  - **Retry policy (tier 3 only).** 5xx and 429 only, backoff with jitter, respect
    `Retry-After`, `AbortSignal.timeout()` on every request.
  - **Release stamping.** Inject semver + git SHA at build; expose in the footer,
    send as a header, set as the Sentry release. Mirrors
    [Kamal Auto-Versioning](https://wiki.joexbayer.dev/wiki/kamal-auto-version).
  - **SSR sidecar.** The one place Inertia costs an operational dependency. It fails
    gently — a dead sidecar degrades to client rendering, not a 500 — but it belongs
    in your deep `/health` check.
  - **Deferred prop failures.** `InertiaRails.defer(rescue: true)` omits the prop
    and reports via the Rails Error Reporter rather than failing the page. Use it
    for non-critical panels; make sure the component handles the absent prop.
  
  ---
  
  ## Observability
  
  ### Correlation IDs
  
  Easier than an SPA — one `inertia_share` block puts `traceId` on every page.
  
  1. Rails mints or accepts it at the edge (`X-Correlation-ID`).
  2. Shared prop carries it to the client.
  3. Tier-3 requests echo it back in the header (`apiFetch` above).
  4. `problem+json` and the Inertia error page both surface it to the user.
  5. OTel browser SDK propagates `traceparent` so the browser span parents the Rails
     span — the trace starts at the click.
  
  ### The rest
  
  - **Logs:** structured, sampled, stripped from the production build. Never
    `console.log`.
  - **Metrics:** Web Vitals with **INP** as the headline. Inertia visits are not
-   page loads — instrument visit duration explicitly via router events.
+   page loads — instrument visit duration explicitly via router events. Report at
+   p75, never as a mean — [QA and QoE](https://wiki.joexbayer.dev/wiki/qa-and-qoe)
+   covers why the average is the one number guaranteed to hide the regression.
  - **Errors:** Sentry with release, tenant, user, correlation ID. An ErrorBoundary
    per layout so one broken partial doesn't blank the page.
  - **Prop payload size** is a metric worth alerting on. It's the first symptom of a
    tier-1 dataset that should have become tier 2 or 3.
  
  ---
  
  ## Testing
  
  | Rails | Inertia React |
  | --- | --- |
  | `rspec` request specs | **`inertia_rails` matchers** — assert component name + prop shape. This is the highest-value test in the stack |
  | presenter unit specs | plain Ruby specs on the page presenter |
  | `factory_bot` + `faker` | unchanged — test data stays in Ruby |
  | view specs | Vitest + Testing Library, rendering the page component with fixture props |
  | `webmock` / `vcr` | MSW — **tier 3 only** |
- | `capybara` / Playwright | Playwright, critical paths only |
+ | `capybara` / Playwright | Playwright, critical paths only — via MCP the same run also yields QoE numbers ([QA and QoE Testing with Playwright MCP](https://wiki.joexbayer.dev/wiki/qa-and-qoe-testing-with-playwright-mcp)) |
  | `prosopite` (N+1) | unchanged, and *more* important — props are the render path |
  | `brakeman` / `bundler-audit` | plus `pnpm audit --prod`, Semgrep |
  
  Two rules:
  
  - **Test the contract at the Rails boundary.** A request spec asserting the
    component name and prop keys catches more than any frontend test, because that's
    where the two halves actually meet.
  - **Generate page-component fixtures from Typelizer types** so a serializer change
    breaks the frontend test, not production.
  
  ---
  
  ## User feedback
  
  - **Flash as a shared prop → one toast pipeline.** `sonner`, driven by a single
    `useFlash()` effect in the layout. Never per-page toast wiring.
  - **Errors are product.** `detail` says what to do next; the trace ID is visible
    and copyable.
  - **Empty and loading states designed, not defaulted.** With deferred props,
    `<Deferred>` fallbacks are a real design surface — note that in Inertia 3 the
    fallback no longer re-shows during partial reloads; use the `reloading` slot
    prop for an indicator that keeps existing content visible.
  
  ---
  
  ## Async & long-running work
  
  - **Background jobs:** mutation returns a job id; poll with Inertia's polling or
    `router.reload({ only: ['job'] })` until terminal. The job id is the resume
    token — refresh or a new tab rejoins.
  - **Streaming / agentic:** SSE with buffer + throttled flush, `AbortController`
    wired to unmount and to a visible Stop button. Treat the LLM call as a flaky
    dependency; keep the surrounding job idempotent.
  
  ---
  
  ## Packages
  
  **Ruby**
  
  | Gem | Why |
  | --- | --- |
  | `inertia_rails` | the adapter (3.x) |
  | `vite_rails` | asset pipeline |
  | `alba` + `alba-inertia` | explicit prop shapes — a security boundary, not a style choice |
  | `typelizer` | TS types generated from serializers — the contract layer |
  | `js_from_routes` | typed path helpers in TS; a renamed action becomes a compile error |
  | `pundit` / `action_policy` | feeds `can:` props |
  | `oj` | props are the render hot path |
  
  **JS**
  
  | Package | Why |
  | --- | --- |
  | `@inertiajs/react` | the adapter |
  | `@inertiajs/vite` | automatic page resolution, code splitting, SSR setup |
  | `vite-plugin-ruby` | pairs with `vite_rails` |
  | `@tanstack/react-query` | **tier 3 only** |
  | `@tanstack/react-table` + `react-virtual` | the tables that justified tier 3 |
  | `zod` | parse at the tier-3 boundary |
  | `@js-from-routes/client` | `formatUrl` + generated helpers |
  
  Configure `js_from_routes` to emit **paths only** — let `apiFetch` own the
  request, or you have two boundaries.
  
  **Don't install:** `react-router` / TanStack Router (Inertia owns routing),
  `axios` (v3 ships its own XHR client), `react-hook-form` (`useForm` owns the
  Inertia protocol; RHF wants to own submission and you'd lose `processing`,
  `progress` and error wiring), anything Redux-shaped.
  
  **Write yourself** — the entire custom surface:
  
  ```
  lib/
  ├─ http.ts             # apiFetch — the tier-3 boundary
  ├─ errors.ts           # parseProblem + fromInertia → one AppError
  ├─ use-page-props.ts   # typed useAuth() / useFlash()
  ├─ rails-errors.ts     # errorValueType + flashDataType augmentation, railsErrors<K>()
  ├─ query-client.ts     # retry policy, staleTime defaults
  └─ correlation.ts      # mint + read the correlation ID
  ```
  
  **Tooling:** `bin/setup` (clone → running) and `bin/dev` running Rails, Vite and
  Typelizer's watcher from one `Procfile.dev`. Type generation is a build step that
  rots silently if it's manual. Use `inertia_rails` generators for pages and a
  `plop` generator for slices — new features scaffolded, not assembled.
  
  ---
  
  ## Quick reference
  
  | Concern | Reach for | Use when |
  | --- | --- | --- |
  | Prop shape | Ruby page presenter, one per action | always |
  | Serialization | Alba, explicit views | always — bare model props leak columns |
  | Types | Typelizer from serializers | always |
  | Paths | `js_from_routes`, paths only | any URL built in TS |
  | Small data | Inertia props | under a few hundred rows |
  | Large, page-bound | `defer` + merge + `WhenVisible` | loads once, scrolls |
  | Large, interactive | JSON API + Query | needs its own cache lifecycle |
  | Refresh | `router.reload({ only: [...] })` | the invalidation analogue |
  | Forms | `useForm` + redirect-with-errors | always |
  | Validation errors | `errors` prop + `errorValueType` / `railsErrors<K>` | always |
  | API errors | `problem+json` + `parseProblem()` | tier 3 and external consumers |
  | Exceptions | `rescue_from` → `pages/errors/error.tsx` | production |
  | Authorization | Pundit → `can:` / `availableActions` props | always |
  | Idempotency | UUID per intent + unique index | money-moving actions |
  | Correlation ID | `inertia_share` + `X-Correlation-ID` | every operation |
  | Version skew | ✅ Inertia asset versioning | free |
  | Logout | ✅ history encryption / `clearHistory` | free |
  | Boundaries | `eslint-plugin-boundaries` | more than one contributor |
  | Testing | `inertia_rails` matchers first | always |
  | Observability | OTel, Web Vitals (INP), Sentry, prop-size metric | before you need it |
  
  ## Review checklist
  
  - [ ] Controller action assembling props inline? → page presenter.
  - [ ] A bare model passed as a prop? → serializer. This is a leak, not a nit.
  - [ ] Page component containing logic that Ruby could have done?
  - [ ] Any `fetch` outside `lib/http.ts`?
  - [ ] Any `useQuery` outside `pages/*/hooks/`?
  - [ ] A dataset transported two ways? → check the manifest.
  - [ ] Tier-1 props carrying a large collection? → defer, or promote to tier 3.
  - [ ] Query key missing the tenant (tier 3)?
  - [ ] Client re-encoding backend transition rules? → `availableActions`.
  - [ ] Raw `usePage()` in a component? → typed wrapper.
  - [ ] `ui/` importing `api/`, or a cross-slice deep import?
  - [ ] New top-level `.tsx` in a slice that isn't a controller action?
  - [ ] Resolver glob still excluding `{ui,model,hooks,api}` **and `*.test.tsx`**?
  - [ ] Resolver still throws (with the known-page list) on a miss?
  - [ ] A page casting `form.errors` inline instead of `railsErrors<K>`?
  - [ ] `RailsErrors<K>` missing an attribute only a custom validation writes to?
  - [ ] Errors handled anywhere but `useForm` / `parseProblem()`?
  - [ ] Rails error arrays passed straight to `useForm`?
  - [ ] Idempotency key regenerated per retry?
  - [ ] Anything secret-shaped in `VITE_*`?
  - [ ] Bundle carries no version/SHA?
  - [ ] Request spec asserting component name + prop shape?
  - [ ] Any rule broken without a one-line justification? → fix or justify.
  
  ## Anti-patterns (reject in review)
  
  - Porting SPA architecture back in: client router, fetch layer, cache, for tier-1
    data.
  - A `features/` directory parallel to `pages/` — two homes for one controller's
    view layer, and a standing argument about which one a file belongs in.
  - Bare model props (`render inertia:` with an AR object).
  - Business rules, permission logic or money arithmetic in TSX.
  - A JSON API endpoint built for a dataset that `defer` would have handled.
  - The same datum available as both a prop and a Query resource.
  - `problem+json` forced through Inertia page visits.
  - React Hook Form fighting `useForm` over submission.
  - Un-virtualized tier-3 tables.
  - A public-API surface grown accidentally out of the internal one — no versioning,
    no contract, external consumers.
  - Typelizer output edited by hand.
  - Test files under `pages/` reaching the client bundle via an unfiltered eager glob.
  - Each page re-solving the Rails-errors shape locally — five copies in three
    disagreeing shapes is the observed steady state, not the worst case.
  
  ## See also
  
  - [Rails Production Playbook](https://wiki.joexbayer.dev/wiki/rails-production-playbook) — the server half of every contract above.
  - [Sandi Metz' Rules](https://wiki.joexbayer.dev/wiki/sandi-metz-rules) — now enforceable end-to-end.
  - [React: Rules & Project Structure (2026)](https://wiki.joexbayer.dev/wiki/react-best-practices) — component guard-rails; Parts 2–4 apply only to tier 3.
  - [Security Baseline: NSM Grunnprinsipper & EU Regulation](https://wiki.joexbayer.dev/wiki/security-baseline-nsm-and-eu) · [Red Team](https://wiki.joexbayer.dev/wiki/red-team)
  - [Kamal Auto-Versioning](https://wiki.joexbayer.dev/wiki/kamal-auto-version) — release stamping.