ruact 0.0.2 → 0.0.3

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +86 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1680 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +89 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +88 -5
  127. data/lib/ruact/component_registry.rb +0 -31
  128. data/lib/tasks/rsc.rake +0 -9
data/CHANGELOG.md CHANGED
@@ -7,6 +7,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+
12
+ - **`Ruact::Query` base class + `ruact_queries` route macro** ([Story 9.4](../_bmad-output/implementation-artifacts/9-4-ruact-query-base-class-and-ruact-queries-route-macro.md)). Server QUERIES are now plain classes under `app/queries/` (`class CatalogQuery < ApplicationQuery`, `ApplicationQuery < Ruact::Query`) — each public method defined directly on the subclass is one query — mounted with one line in `routes.rb`: `ruact_queries CatalogQuery` draws one **named GET route per public method** (`def search_users` → `GET /q/searchUsers`, named `ruact_query_searchUsers`), all visible in `rails routes` (no hidden endpoint; the route table stays the single source of truth). The prefix is configurable via the new `Ruact.config.query_route_prefix` (default `"/q"`). Dispatch goes through an internal gem controller — one generated subclass per query class — inheriting the new `Ruact.config.query_parent_controller` (default `"ApplicationController"`, constantized lazily at route-draw), so the host's REAL callback chain (`authenticate_user!`, tenant scoping, Pundit) runs **before** the query class is instantiated. The query instance is fresh per request and receives its context via the constructor: `current_user` / `params` / `request` / `session` delegate to the dispatching controller (so `current_user` IS the host's own method), and `CatalogQuery.new(fake_context).categories` is unit-testable with no Rails boot. Per-query callback opt-out via the new `ruact_skip_before_action` class macro (mirrors Rails' `skip_before_action` signature incl. `only:`/`except:`/`raise: false`; scoped to the declaring query class by construction). Queries are GET — no CSRF; the method's return value is serialized through the same `ruact_props` / `Ruact::Serializable` / `strict_serialization` policy as Bucket 2 (new `BucketTwoPayload.serialize_value` extraction; `nil` → JSON `null` with 200), and a query raise renders the salvaged structured-error payload with the same 422/403/413/500 mapping (the GET gate is overridden for query dispatch — `Ruact::ConfigurationError` still re-raises loudly). Keyword arguments are passed best-effort from GET query params by name (the strict FR88 sanitization wire contract ships with `useQuery` in Story 9.5; codegen of query entries is also 9.5). New files `lib/ruact/query.rb`, `lib/ruact/routing.rb`, `lib/ruact/server_functions/query_dispatch.rb`, `lib/ruact/server_functions/query_context.rb`. New specs `query_spec.rb`, `query_context_spec.rb`, `query_request_spec.rb` + extensions to `configuration_spec.rb` / `bucket_two_payload_spec.rb`, all tagged `:story_9_4`.
13
+
14
+ - **Route-driven codegen for mutations + runtime re-target** ([Story 9.3](../_bmad-output/implementation-artifacts/9-3-route-driven-codegen-for-mutations-and-runtime-re-target.md)). The generated server-functions module is now derived from the **Rails route table** instead of the v1 registries: every non-GET routed action (POST/PUT/PATCH/DELETE) on a controller that `include Ruact::Server` becomes a callable server function — `resources :posts` is the only declaration, no `routes.rb` additions, no synthetic endpoint. New `Ruact::ServerFunctions::RouteSource` collects entries from the route set; the locked naming derivation table (recorded in the ADR) covers the RESTful writes (`posts#create` → `createPost`), custom member routes (`posts#publish` → `publishPost`), custom collection routes (`posts#publish_all` → `publishAllPosts`), singular resources (`resource :session` → `createSession`), and namespaced controllers with a **prefix** scheme (`admin/posts#create` → `createAdminPost`) so the merged JS namespace is collision-free by construction. Collisions fail loudly at boot naming both origins; the new `ruact_function_name :action, as: "jsId"` macro on `Ruact::Server` is the per-action rename escape hatch. The build always logs `[ruact] codegen: exposing …` (transparency over silence). The runtime gains `_makeServerFunction({ method, path, segments })` — it targets the real route + verb (e.g. `POST /posts`, `PUT /posts/:id`), interpolating `:id`-style path segments by name from the single FormData/object argument, and follows a Bucket-2 `{ "$redirect": "<path>" }` response client-side (via `globalThis.__ruact_navigate`, `window.location.assign` fallback) — the client half of the contract Story 9.2 emitted. The salvaged 8.1/8.2 behaviors (FormData branching, CSRF meta injection, text-first parsing, `RuactActionError`, `redirect: "error"`, the intersection action signature, `revalidate()`) are preserved via a shared `ruactInvoke` core; the v1 `_makeRef` accessor is byte-behavior-identical. The v2 codegen writes to a **parallel** target (`server-functions.next.{json,ts}`) for parity tests + inspection only — never imported by application code — so the v1 `server-functions.ts` is untouched (the per-app cutover to route-driven codegen is Story 9.8). The Ruby↔JS codegen byte-equality parity test is retained and extended for v2. New specs: `route_source_spec.rb`, `server_function_name_spec.rb`, v2 cases in `codegen_spec.rb` / `snapshot_spec.rb` / `railtie_integration_spec.rb` (tagged `:story_9_3`); +11 runtime vitest characterization tests; +5 codegen parity vitest cases.
15
+
16
+ - **Dual-bucket response negotiation on the same controller action** ([Story 9.2](../_bmad-output/implementation-artifacts/9-2-dual-bucket-response-negotiation-on-same-controller-action.md)). One non-GET `Ruact::Server` action serves both form/navigation submits and imperative `await fn()` calls, discriminated by how it was called — no `respond_to` blocks. **Bucket 1** (form/navigation, `Accept: text/x-component` or a browser submit) renders via the existing Story 3.3/3.4 Flight mechanism (re-render / Flight redirect row), unchanged. **Bucket 2** (imperative, `Accept: application/json`) returns a JSON object of the action's exposed instance variables — Rails `view_assigns`, keyed by ivar name without `@` (`@post` → `{ "post": {...} }`), each value through the `ruact_props` / `Ruact::Serializable` / `strict_serialization` rules (a single ivar stays keyed, no unwrap); a `redirect_to` surfaces as `{ "$redirect": "<path>" }`; an action that sets no exposed ivars and does not redirect returns `204 No Content` (the generated ref resolves `null`); a serialization failure raises `Ruact::SerializationError` → structured 500 via the Story 9.1 error chain. `Vary: Accept` is set on every non-GET response shape (the same URL serves two bodies keyed on `Accept`, so a cache must vary on it). CSRF on Bucket 2 is the host's own `protect_from_forgery` (missing/invalid → 403; API-mode accepts). New pure serializer `Ruact::ServerFunctions::BucketTwoPayload`. New specs `bucket_two_payload_spec.rb`, `server_bucket_request_spec.rb` (tagged `:story_9_2`). _(Following `$redirect` client-side and re-targeting refs to real REST routes is Story 9.3.)_
17
+
18
+ - **`Ruact::Server` concern** ([Story 9.1](../_bmad-output/implementation-artifacts/9-1-ruact-server-concern-hosts-salvaged-error-payload-and-upload-guard.md)). `include Ruact::Server` in a controller installs the server-functions infrastructure on the host's own callback chain: the structured-error renderer (`rescue_from StandardError` + explicit `ActionController::InvalidAuthenticityToken` registration — uncaught exceptions on function-call requests render the `_ruact_server_action_error: true` JSON payload with the 422/403/413/500 status mapping, dev/prod payload split via `Ruact.config.dev_error_payload_enabled`, host `rescue_from` precedence preserved) and the `max_upload_bytes` upload guard (prepended `before_action`; rejects oversized `multipart/form-data` / `application/x-www-form-urlencoded` bodies with a structured 413 BEFORE CSRF verification; skips GET/HEAD; carve-outs: `nil` limit, non-form content types, absent `Content-Length`). Function-call requests are the exact non-GET/HEAD request shape the generated refs send: `Accept: application/json`. All other request shapes — GET pages, browser form submits, Flight navigation, GET/HEAD JSON probes, malformed `Accept` variants — keep stock Rails behavior. An oversized upload on a non-GET guarded request renders the structured 413 regardless of its `Accept` header (a native form submit gets a meaningful 413, not a re-raised 500); GET/HEAD requests are never guarded. The concern assumes hosts include `Ruact::Server` after `protect_from_forgery`; no runtime callback-order verifier runs. The shared implementation lives in `Ruact::ServerFunctions::ErrorRendering`. New specs: `server_spec.rb`, `server_rescue_request_spec.rb`, `server_upload_request_spec.rb` (tagged `:story_9_1`).
19
+
20
+ - **File uploads via server actions + `max_upload_bytes` pre-parse guard** ([Story 8.5](../_bmad-output/implementation-artifacts/8-5-file-upload-via-server-action-multipart-formdata.md)). `<form action={fn}>` with `<input type="file">` delivers `params[:file]` as `ActionDispatch::Http::UploadedFile` on BOTH the controller-hosted and standalone branches — React 19's auto-FormData submission machinery includes file entries; the runtime's existing Story 8.1 FormData branch POSTs as `multipart/form-data` with the browser-managed boundary; Rails' standard multipart parser unwraps each part. `@post.cover.attach(params[:cover])` (Active Storage) and Shrine / Carrierwave-style adapters work unchanged — no ruact-specific shim. New `Ruact.config.max_upload_bytes` attribute (Integer, default `10 * 1024 * 1024` = 10 MB; set to `nil` to disable the gem-side guard and let the host's reverse proxy / `client_max_body_size` own the cap). New `Ruact::UploadTooLargeError` exception class (`< Ruact::Error`) carrying `received_bytes` and `limit_bytes` attr_readers. New `EndpointController#__ruact_enforce_upload_limit!` `prepend_before_action` callback runs FIRST in the chain (before `:resolve_ruact_entry!` AND before the conditional `verify_authenticity_token`) — checks `request.content_length` against `Ruact.config.max_upload_bytes` for `multipart/form-data` / `application/x-www-form-urlencoded` bodies only; short-circuits for JSON bodies, chunked-transfer requests (no `Content-Length`), and `max_upload_bytes = nil`. Oversized requests raise `Ruact::UploadTooLargeError` → 413 (new mapping in `__ruact_status_for`) + Story 8.4 structured `_ruact_server_action_error: true` body with a new dev-only `upload_limit: { received_bytes, limit_bytes }` block alongside the four baseline keys. `ErrorSuggestion::SUGGESTIONS` gains the `"Ruact::UploadTooLargeError"` → "Increase `max_upload_bytes` or use Active Storage Direct Upload / presigned S3" entry. `__ruact_render_action_error` falls back to `request.path_parameters[:name]` for `action_name` when the guard rejects before `resolve_ruact_entry!` runs, so the structured payload still names the URL-routed action. New `gem/spec/ruact/server_functions/endpoint_controller_upload_spec.rb` covers controller-hosted + standalone UploadedFile delivery (AC1), mixed file + text fields preservation (AC4), 413 + structured body on both branches (AC3), guard-before-CSRF on standalone (Pitfall #4), `Content-Length`-absent bypass (Pitfall #1), `application/json` bypass (Pitfall #12), and `max_upload_bytes = nil` bypass. New 1×1 PNG fixture at `gem/spec/support/fixtures/pixel.png` (70 bytes). Specs tagged `:story_8_5`. **NOTE:** the playground demo panel + Active Storage end-to-end attach (epic AC2/AC6) are deferred to Story 8.5a — a dedicated `rails new`-generated playground at `playgrounds/epic-8-server-actions/`. The gem-side specs above provide the empirical proof for the gem boundary; Story 8.5a closes the round-trip-through-Active-Storage verification.
21
+
22
+ - **Structured error payload for server-action failures + dev-overlay polish** ([Story 8.4](../_bmad-output/implementation-artifacts/8-4-server-action-failure-error-overlay-polish.md)). Server actions that raise now respond with a structured JSON body carrying `_ruact_server_action_error: true`, the action name, the Ruby error class, the message, and (in development/test) the split backtrace (app frames + gem frames), a contextual suggestion for the two most common failure modes (`ActiveRecord::RecordInvalid`, `ActionController::InvalidAuthenticityToken`), and `record.errors.full_messages` for validation errors. Production-mode payload is reduced to the four baseline fields (`_ruact_server_action_error`, `action_name`, `error_class`, `message`) so React components can render their own UI without accidental backtrace leakage. New `Ruact.config.dev_error_payload_enabled` toggle (Boolean, default `nil` → resolves to `Rails.env.development? || Rails.env.test?` at the endpoint controller; honors the Story 7.3 freeze contract). New `EndpointController#__ruact_render_action_error` chain (`rescue_from StandardError` + explicit `rescue_from ActionController::InvalidAuthenticityToken`) is the OUTERMOST catch — a host controller's own `rescue_from` chain continues to win for owned exception classes; the structured payload only fires for exceptions the host did NOT catch. Status mapping: `RecordInvalid → 422`, `InvalidAuthenticityToken → 403`, everything else `→ 500`. New modules under `gem/lib/ruact/server_functions/`: `ErrorPayload.build` (pure function — no `Rails.env` / no `Ruact.config` reads; the caller resolves `mode`), `BacktraceCleaner.split` (zero-`ActiveSupport` ~10-LoC app/gem frame splitter anchored on `Ruact.gem_path`), `ErrorSuggestion.for` (frozen class-name-string-keyed `SUGGESTIONS` table). Server-side `Rails.logger.error` always logs the failure with a `[ruact]` prefix + the full backtrace, wrapped in `Rails.logger.tagged("ruact action:<name>")` when supported — the dev-mode payload gate only governs the wire body, not the server log. Playground gains a fourth `<FailingActionDemo />` panel (deliberate `RecordInvalid` raise + manual CSRF-mismatch button) and the demo + minimal overlays gain a structured-error branch (header, error-class chip, message, validation-errors list, suggestion block with auto-appended CSRF docs link, app frames visible by default, gem frames behind a `Show internal frames (N)` toggle, Copy-to-clipboard button with `Copied!` flash). Non-structured errors keep the existing `<pre>` fallback — the structured view is purely additive. New documentation: `## Errors and the dev overlay` section in `website/docs/api/server-actions.md`; ADR addendum (2026-05-18) in `gem/docs/internal/decisions/server-functions-api.md`. Test coverage: +52 new specs across `error_payload_spec.rb`, `backtrace_cleaner_spec.rb`, `error_suggestion_spec.rb`, `endpoint_controller_rescue_spec.rb`, plus extensions to `dispatch_request_spec.rb`, `csrf_request_spec.rb`, `configuration_spec.rb`; +3 vitest files in the demo playground (`error-overlay-structured`, `error-overlay-fallback`, `error-overlay-copy`); +3 Playwright e2e cases in `server-actions.spec.ts`. All gem describes tagged `:story_8_4`.
23
+
24
+ ### Changed
25
+
26
+ - **Standalone CSRF rejection status moves from 422 → 403** ([Story 8.4](../_bmad-output/implementation-artifacts/8-4-server-action-failure-error-overlay-polish.md); **BREAKING for the Story 8.3 R2 wire contract**). Story 8.3 R2 originally documented that the gem guaranteed status 422 + the canonical `ActionController::InvalidAuthenticityToken` exception class for CSRF mismatches on the standalone branch (the JSON body shape was test-middleware synthesis, not a gem guarantee). Story 8.4 takes ownership of the rejection: the gem's `EndpointController` now renders 403 + the structured JSON body directly via `rescue_from ActionController::InvalidAuthenticityToken`. 403 Forbidden is semantically the right code for "you are not authorised to make this request" — Rails' default 422 mapping in `ShowExceptions` is a legacy quirk. Hosts that branched on the 422 status for CSRF-specific handling MUST migrate to either the 403 status or the new `error_class === "ActionController::InvalidAuthenticityToken"` field on the structured body. Controller-hosted CSRF rejections — previously caught by Rails' `protect_from_forgery with: :exception` and rendered as `422` HTML by the default exception middleware — also flow through the new gem-side handler when no host-side `rescue_from` intercepts them, picking up the same 403 + structured body. The existing Story 8.2 R19 and Story 8.3 R2 spec assertions are updated in lockstep.
27
+
28
+ ### Added (pre-Story-8.4)
29
+
30
+ - **Standalone server actions via `"use server"` modules** ([Story 8.3](../_bmad-output/implementation-artifacts/8-3-standalone-server-action-references-with-use-server.md)). Declare a server action as a service-style module under `app/server_actions/` with `extend Ruact::ServerAction` and `ruact_action :name do |params| ... end` — the Ruby equivalent of React's `"use server"` directive. The React side imports it identically to a controller-hosted action (`import { createPost } from "@/.ruact/server-functions"`), but the block executes OUTSIDE the controller chain: no `before_action`, no `rescue_from` on a host controller, no Rails `process_action` instrumentation. Opt-in auth via `Ruact.configure { |c| c.current_user_resolver = ->(env) { env['warden']&.user } }` (Devise) or `->(env) { User.find_by(id: env['rack.session'][:user_id]) }` (hand-rolled session); when a block calls `current_user` without a configured resolver, `Ruact::CurrentUserNotConfiguredError` is raised at dispatch time with both worked examples in the message. The block executes against a thin per-request `Ruact::ServerFunctions::StandaloneContext` exposing `params` / `current_user` / `session` / `cookies` / `headers` / `request` — but NOT `render` / `redirect_to` / `head` (calling them raises `NoMethodError` with a documented hint). The response contract is exactly two paths: **`return nil` → 204 No Content**; **return Hash/Array/scalar → 200 + JSON body**. For non-2xx returns, `raise Ruact::ActionError.new(status: 422, body: { error: ... })` and the dispatcher renders the status + JSON body. Malformed `application/json` bodies render a structured 400 + `{error}` JSON (parity with the controller-DSL path). CSRF for the standalone branch is enforced by the gem's `EndpointController` itself (via `protect_from_forgery with: :exception, if: :dispatching_standalone?`) since there's no host chain — missing/invalid tokens raise `ActionController::InvalidAuthenticityToken` and Rails' exception middleware maps that to HTTP 422 (the gem guarantees status code + exception class; the response BODY is whatever the host's exception middleware produces — Rails serves `public/422.html` by default); API mode (`allow_forgery_protection = false`) accepts without a token. Mixed-host collision detection: declaring `:create_post` in BOTH a controller and a standalone module raises `Ruact::ConfigurationError` at boot with both host names. `Ruact::ServerAction` rejects `extend Ruact::ServerAction` on a Class with a documented `Ruact::ConfigurationError` pointing the dev to `include Ruact::Controller` (the standalone macro is module-only by design). The `current_user_resolver` lambda is deep-frozen at publication time via in-place `.freeze` (preserves object identity across re-configurations AND honors the Story 7.3 freeze contract). New `Ruact::Railtie` initializer registers `app/server_actions/` as an autoload + eager-load path; the force-load hook was renamed `force_load_controllers!` → `force_load_server_function_hosts!` (old name kept as back-compat alias) and now walks BOTH `app/controllers/**/*_controller.rb` AND `app/server_actions/**/*.rb`. The `EndpointController#dispatch_action` now resolves the entry via a `prepend_before_action` then branches on host shape: `standalone_host?(entry.controller)` (Module-not-Class extending `Ruact::ServerAction`) → `StandaloneDispatcher.dispatch(entry, request)` → render the returned directive via `render`/`head` (so Rails' `ImplicitRender` does not interfere); Controller class → existing `host_class.dispatch` path. Playground gains a third `<StandaloneActionDemo />` panel proving `before_action_fired: false` end-to-end. New documentation: `## Standalone server actions` section + `## When to use which` decision table in `website/docs/api/server-actions.md`; ADR addendum in `gem/docs/internal/decisions/server-functions-api.md`. Test coverage: +63 new specs across `standalone_action_spec.rb`, `standalone_context_spec.rb`, `standalone_dispatcher_spec.rb`, plus extensions to `dispatch_request_spec.rb`, `csrf_request_spec.rb`, `registry_spec.rb`, `railtie_integration_spec.rb`, `errors_spec.rb`, and `configuration_spec.rb` — all tagged `:story_8_3`.
31
+
32
+ - **`<form action={fn}>` ergonomic + `revalidate()` runtime helper** ([Story 8.2](../_bmad-output/implementation-artifacts/8-2-invoke-server-action-from-react-form-action-server-fn.md)). Pass a server-action reference directly to a React 19 `<form action>` and to `useActionState` — ruact handles FormData → multipart wire serialization, CSRF token forwarding, and (optionally) Flight-stream revalidation. The codegen-emitted action signature is a TypeScript **intersection** — `((args?: FormData | Record<string, unknown>) => Promise<unknown>) & ((formData: FormData) => Promise<void>)` — so `<form action={createPost}>` typechecks DIRECTLY against React 19's `(formData: FormData) => void | Promise<void>` prop while direct callers (`await createPost({...})` / event handlers) keep the `Promise<unknown>` return surface. Query signatures stay narrow (`() => Promise<unknown>`). The runtime `_makeRef` accepts the `useActionState` two-arg invocation shape (`action(prevState, formData)`): the FormData-typed argument wins regardless of position, prevState is silently discarded at the wire level (never serialized — non-serializable React state shapes like `Date`/`Map`/circular refs are harmless). A new `revalidate(path?)` helper exported from `ruact/server-functions-runtime` (and re-exported from `@/.ruact/server-functions` by the codegen so `import { revalidate } from "@/.ruact/server-functions"` works app-wide) triggers an in-place Flight refetch of the supplied path or the current URL; mirrors Next.js' `revalidatePath`. The Story 8.1 AC8 CSRF matrix (which 8.1 formally deferred) is now end-to-end covered with a REAL valid-token round-trip via `gem/spec/ruact/server_functions/csrf_request_spec.rb` (mounts on the shared Story-7.9 Rails app so `Rails.application.key_generator` derives session-backed tokens that the host's `verify_authenticity_token` callback accepts) — covers missing-token → 422, invalid-token → 422, valid-token → 200, plus the API-mode and structural-callback complements. `dispatch_request_spec.rb` covers the multipart-dispatch + strong-params + RecordInvalid → 422 paths in API mode. The codegen + `NameBridge` reserve `revalidate` and `_makeRef` as `js_identifier`s (both Ruby and JS-side validators reject a hand-edited bridge JSON that tries to export either name). An ADR addendum at `gem/docs/internal/decisions/server-functions-api.md` ("2026-05-17 — Story 8.2 — codegen intersection-type refinement") captures the option-(a) → option-(a′) evolution after code review surfaced that `Promise<unknown>` is not assignable to `Promise<void>` under strict TypeScript. Runtime version bumped from `0.1.0` → `0.2.0`. **Behavioural addition for existing `_makeRef` callers:** invoking the returned function with 3+ positional arguments now throws `TypeError` instead of silently dropping the trailing args; the 0/1/2-arg shapes are unchanged for all existing call sites.
33
+
34
+ - **`ruact_action` controller DSL + dispatch endpoint** ([Story 8.1](../_bmad-output/implementation-artifacts/8-1-declare-server-actions-in-controllers-with-ruact-action.md)). Declare a server action with `ruact_action :name do |params| ... end` inside any controller that includes `Ruact::Controller`. The block runs inside the controller's normal `before_action` / `around_action` / `rescue_from` chain (no ruact-specific shim — `current_user`, `session`, Pundit / ActionPolicy authorization, and host error-handling all work), with `params` shadowed by the action-call args as an `ActionController::Parameters` instance (so `params.require(:title).permit(:body)` works inside the block). React calls the action via `import { createPost } from "@/.ruact/server-functions"`; the runtime POSTs to a single gem-mounted endpoint at `POST /__ruact/fn/:name` — no per-action route in `routes.rb`. CSRF is auto-managed: the runtime forwards the `<meta name="csrf-token">` value as `X-CSRF-Token`, and the host controller's existing `protect_from_forgery` enforces (API-mode apps without `protect_from_forgery` accept the request — the gem does not impose its own CSRF). The real runtime (`gem/vendor/javascript/ruact-server-functions-runtime/`) replaces the Story 8.0a placeholder: argument-shape branching (`FormData` → multipart; plain object → JSON), CSRF injection, 2xx JSON parsing, 4xx/5xx structured rejection. Vitest suite expanded from 4 placeholder tests to 16 covering the full runtime surface.
35
+ - **Server-functions codegen scaffolding** ([Story 8.0a](../_bmad-output/implementation-artifacts/8-0a-vite-plugin-server-functions-codegen.md)). The bundled Vite plugin now auto-emits `app/javascript/.ruact/server-functions.ts` from `Ruact.action_registry` + `Ruact.query_registry` on every `config.to_prepare` reload, via a JSON bridge at `tmp/cache/ruact/server-functions.json`. New rake task `rails ruact:server_functions:generate` produces the same artifacts for CI / production deploy pipelines (no Node dependency). The install generator now scaffolds `app/javascript/.ruact/.gitkeep` and appends two idempotent `.gitignore` entries. The Vite plugin also auto-registers `resolve.alias["@"]` → `app/javascript` (leaves existing host aliases intact) and `resolve.alias["ruact/server-functions-runtime"]` → the gem-bundled placeholder package. The Ruby↔JS codegen parity is guarded by a vitest test that shells out to Ruby's codegen on a fixed JSON fixture and asserts byte equality. **At this story's merge both registries are empty** — Stories 8.1 (`ruact_action`) and 9.1 (`ruact_query`) introduce the controller macros that populate them; the emitted module imports the runtime helper regardless so the import shape stays stable across the transition. The placeholder runtime (`gem/vendor/javascript/ruact-server-functions-runtime/`) fails loudly at call time with `"ruact: server functions runtime not implemented yet — install Story 8.1"` so the absence of Story 8.1 cannot ship to production by accident.
36
+
37
+ ### Fixed
38
+
39
+ - **`Ruact::Controller#ruact_render` now renders successfully under Rails 8** ([Story 7.9](../_bmad-output/implementation-artifacts/7-9-fix-render-to-string-view-context-ivar-handoff.md); resolves Bug 7.8-B). Previously every PascalCase component in a Rails 8 app raised `Ruact::Error: __ruact_component__ called outside a ruact_render flow` (HTTP 500). The render context is now routed through the controller's standard view-assigns plumbing so it reaches the view used by `render_to_string`. (Story 7.9 originally landed under the pre-rename names `rsc_render` / `__rsc_component__`; the message shape and method name moved to `ruact_*` in [Story 5.12](../_bmad-output/implementation-artifacts/5-12-complete-ruact-name-propagation-across-public-api.md) — the underlying fix is mechanically the same.)
40
+
41
+ ### Tooling
42
+
43
+ - **Code coverage instrumentation** (Story 6.7). Added `simplecov` and `simplecov-lcov` as development dependencies; gem CI uploads coverage from the canonical matrix cell (Ruby 3.3 × Rails 7.2) to Codecov on every push and PR with the `gem` flag. **Baseline at merge: 88.30% line (581/658), 72.25% branch (239 specs).** (Corrects the earlier 87.89% figure recorded at story-merge time, which was a typo of the SimpleCov output for 567/644 = 88.04%; the current numbers reflect the post-review state after Story 6.7 review F1 refactored `html_converter.rb#convert_element` into helpers, which added a few lines and one new spec.) Coverage is informativo (not a CI gate); see Codecov PR comments for diff coverage on individual changes. Diff coverage target per project DoD: ≥ 90% line / ≥ 80% branch on new code.
44
+ - **Spec `rails_stub.rb` fix.** The previous `$LOADED_FEATURES.any? { |f| f.end_with?("/rails.rb") }` heuristic for skipping the `LOADED_FEATURES` insertion was unreliable — unrelated gems ship files at `*/rails.rb` (e.g. SimpleCov's `simplecov/profiles/rails.rb`). Replaced with an unconditional insertion guarded only by the existing `return if defined?(Rails)` early-exit, which is the correct invariant.
45
+
46
+ ### Renamed
47
+
48
+ - **`rsc_*` public API surface fully migrated to `ruact_*`** ([Story 5.12](../_bmad-output/implementation-artifacts/5-12-complete-ruact-name-propagation-across-public-api.md); **BREAKING**). Pre-v0.1.0 clean cut to eliminate the residual `rsc_` prefix from Phase 1's pre-rename era so Epic 8 (server actions), Epic 9 (server queries), and Epic 10 (scaffold) inherit a clean substrate. No aliases, no deprecation bridge — host code must rename in lockstep.
49
+
50
+ | Surface | Was | Now |
51
+ | --- | --- | --- |
52
+ | Controller methods | `rsc_render`, `rsc_request?`, `rsc_manifest`, `rsc_template_exists?`, `rsc_html_shell` | `ruact_render`, `ruact_request?`, `ruact_manifest`, `ruact_template_exists?`, `ruact_html_shell` |
53
+ | HTTP header | `RSC-Request: 1` | `Ruact-Request: 1` (the React-Flight-standard `Accept: text/x-component` check is unchanged) |
54
+ | Serializable DSL | `rsc_props :id, :title` · `obj.rsc_serialize` · `Klass.rsc_props_list` | `ruact_props :id, :title` · `obj.ruact_serialize` · `Klass.ruact_props_list` |
55
+ | Rake task | `rails rsc:doctor` | `rails ruact:doctor` |
56
+ | View helper (internal — only visible in stack traces / error messages) | `__rsc_component__` | `__ruact_component__` |
57
+ | Error message substring (when called outside the flow) | `"__rsc_component__ called outside an rsc_render flow"` | `"__ruact_component__ called outside a ruact_render flow"` (class `Ruact::Error` preserved) |
58
+
59
+ Internal token format (`__RSC_N__` → `__RUACT_N__`), the synthetic Suspense tag (`<rsc-suspense data-rsc-fallback="…">` → `<ruact-suspense data-ruact-fallback="…">`), the playground/e2e `rsc-router.js` JavaScript file → `ruact-router.js`, and the `[rsc-router]` log prefixes → `[ruact-router]` also moved in lockstep. The `Ruact::*` Ruby module namespace and the Flight wire-protocol identifiers (`"$"`, `"$L"`, `"$SS"`, `text/x-component`) are **unchanged** — those are React-Flight-protocol externalities.
60
+
61
+ _Mechanical, not behavioural._ Bug 7.8-B (closed by Story 7.9) does NOT regress under Story 5.12: the renamed `__ruact_component__` continues to read from the `@ruact_render_context` ivar Story 7.9 plumbed through `_assigns_for_view_context`; only the method name and message substring moved. Gem CI's name-propagation guard gains a second step that fails the build on any residual `rsc_*` API reference (complementary to Story 5.1's `rails_rsc` / `RailsRsc` guard, which is preserved unchanged).
62
+
63
+ _Migration_ for host apps that experimented with `ruact` v0.0.x: change each `rsc_*` identifier listed above to its `ruact_*` equivalent (e.g. `def show; rsc_render; end` → `def show; ruact_render; end`; `class Post; include Ruact::Serializable; rsc_props :id; end` → `ruact_props :id`; deployment scripts running `bundle exec rails rsc:doctor` → `bundle exec rails ruact:doctor`). Any host-side `import { setupRouter } from "./rsc-router.js"` adjusts to `from "./ruact-router.js"`.
64
+
65
+ - The gem and its top-level constant were renamed from `rails_rsc` / `RailsRsc` to `ruact` / `Ruact` between v0.0.2 and v0.0.3. Host apps must update their `Gemfile` (`gem "rails_rsc"` → `gem "ruact"`) and any code referencing `RailsRsc::*` (replace with `Ruact::*`). The `rails ruact:doctor` task (renamed from `rails rsc:doctor` in Story 5.12) detects and reports legacy constant usage in `config/initializers/` and `app/`.
66
+
67
+ ### Internal
68
+
69
+ - **Server-functions React-side accessor shape locked** (Story 8.0). Doc-only design spike that picks the React-side accessor for `ruact_action` (Epic 8) and `ruact_query` (Epic 9). Chosen shape: **Option C — named imports from a generated TypeScript module** (`import { createPost } from "@/.ruact/server-functions"`). Same accessor mechanics for actions and queries; no hook, no context provider, no prop drilling. Decision matrix scored four candidates across nine axes (TS support, bundle size, ESLint friction, hook-rule compliance, nested-layout future-readiness, regeneration triggers, debugging clarity, learning curve for Rails devs new to React, learning curve for React devs new to ruact); Option C won by 8 points over the runner-up (`useServerActions()` hook). Validated end-to-end in a real `tsc --noEmit` sandbox: codegen handles all six naming-bridge edge cases, typos surface with TypeScript's "Did you mean" suggestion at typecheck time. The decision is **append-only** — deviations during Epic 8/9 implementation require an addendum to the same file before the deviating PR merges. Locks the API contract for Stories 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 9.1, 9.2, 9.3, 9.4, 9.5. The Vite-plugin extension that emits the generated module is captured as new follow-up Story 8.0a in the workspace planning artifacts. **No production code change** — `gem/lib/`, `gem/spec/`, `.rubocop.yml`, and CI workflows are untouched. See [`docs/internal/decisions/server-functions-api.md`](docs/internal/decisions/server-functions-api.md).
70
+ - Rake task descriptions and internal `require` statements migrated from `rails_rsc` to `ruact`. Public API is unchanged; this is a documentation and tooling rename only.
71
+ - **Render context now passed explicitly** ([Story 7.1](../_bmad-output/implementation-artifacts/7-1-refactor-componentregistry-from-thread-current-to-explicit-render-context.md)). `Ruact::ComponentRegistry` (which used `Thread.current`) has been removed; the per-render component list is now an instance of `Ruact::RenderContext` passed explicitly through `Controller#ruact_render → RenderPipeline → HtmlConverter`. The `Ruact/NoSharedState` cop now passes with no exceptions in `lib/ruact/`. **No public API change.** Note: `Ruact::Flight::*`, `Ruact::Internal::*`, and `Ruact::RenderContext` are not part of the public API and may change between minors. Hosts upgrading need no application code changes. See [decision note](../_bmad-output/decisions/2026-04-30-render-context-refactor.md) for rationale and contributor guidance.
72
+ - **`RenderPipeline` entry points consolidated** ([Story 7.2](../_bmad-output/implementation-artifacts/7-2-consolidate-renderpipeline-entry-points-into-single-coherent-api.md)). `RenderPipeline#call`, `#stream`, and `#from_html` have been removed and replaced with a single `#render(input, mode:)` entry point. `input` selects the source — `{ erb: String, binding: Binding }` for ERB templates or `{ html: String, render_context: Ruact::RenderContext }` for pre-rendered HTML; `mode:` selects the output shape — `:string` returns a `String` (deferred chunks inlined eagerly), `:stream` returns an `Enumerator` of Flight rows (deferred chunks delay). Conflicting input keys, missing siblings, and unknown modes raise `ArgumentError` with the offending input named. **No public API change** — `Ruact::Controller#ruact_render` is unchanged. Note: `Ruact::Flight::*`, `Ruact::Internal::*`, and `Ruact::RenderPipeline` are not part of the public API and may change between minors. See [decision note](../_bmad-output/decisions/2026-05-renderpipeline-entry-point-consolidation.md) for rationale and contributor guidance.
73
+
74
+ _Migration for any external code that may have reached into `Ruact::RenderPipeline`:_ `pipeline.call(erb, binding)` → `pipeline.render({ erb: erb, binding: binding }, mode: :string)`; `pipeline.stream(erb, binding)` → `pipeline.render({ erb: erb, binding: binding }, mode: :stream)`; `pipeline.from_html(html, render_context: ctx)` → `pipeline.render({ html: html, render_context: ctx }, mode: :string)`; `pipeline.from_html(html, render_context: ctx, streaming: true)` → `pipeline.render({ html: html, render_context: ctx }, mode: :stream)`.
75
+
76
+ - **`Ruact::Configuration` is frozen after initialization** ([Story 7.3](../_bmad-output/implementation-artifacts/7-3-ruact-configuration-becomes-immutable-after-initialization.md)). The `Ruact::Configuration` instance returned by `Ruact.config` is frozen the moment `Ruact.configure { |c| ... }` returns (or, if no `configure` block is called, on first access). Mutating attributes outside the `configure` block (e.g. `Ruact.config.foo = bar`) now raises `Ruact::ConfigurationError` — a new error class, subclass of `Ruact::Error` — with a message naming the offending attribute, the caller's file:line, and the suggested fix. Calling `Ruact.configure` a second time after boot replaces the configuration atomically (the new draft is fully assembled, frozen, then swapped — partial reconfiguration is impossible) and emits a `[ruact]` warning advising that runtime re-configuration is unusual. **No public API change** — the `Ruact.configure { |c| ... }` DSL is unchanged, every existing reader returns the same value as before, and the `rails generate ruact:install` template still works as-is. Note: the gem's own internal RSpec stubs against `Ruact.config` (`render_pipeline_spec.rb`, `flight/renderer_spec.rb`) were migrated to `Ruact.configure { |c| c.attr = ... }` blocks because RSpec mocks cannot proxy frozen objects in MRI Ruby — this affects only the gem's test suite, not host applications. See [decision note](../_bmad-output/decisions/2026-05-configuration-immutability.md) for rationale and contributor guidance.
77
+
78
+ _Migration for any external code that mutated `Ruact.config` outside `Ruact.configure`:_ replace any post-boot `Ruact.config.foo = bar` with a `Ruact.configure { |c| c.foo = bar }` block in `config/initializers/ruact.rb`. If the change must be conditional on environment, branch inside the block. For test scenarios that legitimately need to swap config per-example, prefer `Ruact.configure { |c| c.foo = ... }` with a `before`/`around` reset hook (`Ruact.instance_variable_set(:@config, nil)`); RSpec stubs (`allow(Ruact.config).to receive(...)`) no longer work because the underlying object is frozen.
79
+
80
+ - **`Ruact::HtmlConverter.convert` validates inputs at the boundary** ([Story 7.4](../_bmad-output/implementation-artifacts/7-4-htmlconverter-convert-validates-inputs-at-the-boundary.md)). The class-method entry point now raises `Ruact::HtmlConverterError` (new — subclass of `Ruact::Error`) when its `html` argument is not a `String`. The validation runs before Nokogiri is invoked, so the caller's file:line appears at the top of the backtrace with a clear ruact-named error rather than a `NoMethodError` for `:children` deep in Nokogiri internals. The most common upstream bug — an ERB template, partial, or render path that returned `nil` — now surfaces with a "Most likely cause" hint pointing at the call site. Separately, `Ruact::ClientManifest#reference_for` enhances its existing `Ruact::ManifestError` message with a Damerau-Levenshtein closest-match suggestion (e.g. `Did you mean "LikeButton"?` for a typo'd `LikeButtonn`), with a fallback hint suggesting the file path to add when no entry within distance 2 exists; passing `controller_path:` biases the suggestion toward co-located keys. **No public API change** — the public signature `html, registry = []` is unchanged for valid inputs, every existing spec continues to pass, and the `ManifestError` raised on unknown components is the same class with an enhanced message. See [decision note](../_bmad-output/decisions/2026-05-htmlconverter-boundary-validation.md) for rationale and contributor guidance.
81
+
82
+ _Migration:_ any application code that previously rescued `NoMethodError` or `Nokogiri::XML::SyntaxError` from a path that flowed through the HTML converter (an unusual pattern; not documented as a public contract) should now rescue `Ruact::HtmlConverterError` for the nil/non-String case. The unresolved-component case continues to raise `Ruact::ManifestError` (same class, enhanced message); rescue patterns matching `/not found in manifest/` continue to work, and rescue patterns matching `/Did you run the Vite build\?/` continue to work. No host application is expected to need changes — the new validation only fires on inputs that already failed in Phase 1, just with worse error messages.
83
+
84
+ - **Flight wire test matchers extended with structural modes** ([Story 7.5](../_bmad-output/implementation-artifacts/7-5-extend-flightfixturematcher-with-structural-assertion-modes.md)). `gem/spec/support/matchers/flight_fixture_matcher.rb` gains two new RSpec matcher modes alongside the existing `match_flight_fixture(name)` snapshot matcher: `match_flight_structure(expected)` parses the actual wire output via a new `Ruact::Spec::FlightWireParser` and compares against an array of row records (hash payloads compare structurally — JSON key reordering does not break the spec); `include_flight_row(predicate)` asserts at least one parsed row satisfies a subset-match predicate (supports `hash_including`, `array_including`, etc. via case-equality). Failure messages are row-indexed and name the differing field path. **Test-only change; no production code touched** — `gem/lib/` is unchanged. The new `Ruact::Spec::*` namespace is established as the canonical home for spec-only utilities; it is **not** part of the public API and may change between minors. Default for new wire tests should be `match_flight_structure`; reserve `match_flight_fixture` for tests where the wire bytes themselves are the contract (e.g. `string_dollar_escape`). See [decision note](../_bmad-output/decisions/2026-05-flight-structural-matchers.md) for rationale and contributor guidance. Story 7.6 will migrate ~31 existing string-matching specs in a follow-up.
85
+
86
+ - **Phase 1 Flight wire-asserting specs migrated to structural matchers** ([Story 7.6](../_bmad-output/implementation-artifacts/7-6-migrate-existing-wire-matching-specs-to-structural-matchers.md)). All Phase 1 specs that asserted on Flight wire output via regex / `include` / string equality have been migrated to the three Story 7.5 matcher modes (`match_flight_fixture`, `match_flight_structure`, `include_flight_row`). The migration covered 4 spec files (`flight/serializer_spec.rb`, `flight/renderer_spec.rb`, `render_pipeline_spec.rb`, `render_pipeline_concurrency_spec.rb`) routed by an explicit decision tree (A: byte-exact contract → fixture; B: multi-row shape → structure; C: presence → row; D: invariant on parsed rows; E: byte-determinism / non-wire-format → preserved). Five new fixtures added under `spec/fixtures/flight/` for previously-uncovered scalars (`bigint`, `nan`, `infinity`, `negative_infinity`, `undefined`). Three sites preserved as Decision E with explanatory comments (byte-determinism positive/negative + Suspense error-message text). Cosmetic JSON changes (key reordering, whitespace) no longer break unrelated specs; semantic regressions surface with row-indexed structural diffs. **Test-only change; no production code touched.** Pre-migration: 347 examples / 0 failures / 0 RuboCop offenses. Post-migration: 339 examples / 0 failures / 0 RuboCop offenses. AC2 grep returns zero brittle wire-fragment assertions outside the 3 documented Decision E sites. See [decision note](../_bmad-output/decisions/2026-05-flight-test-migration.md).
87
+
88
+ - Story 7.8: Playground demo architecture-checks page + Rake task verifying Epic 7 invariants ([Story 7.8](../_bmad-output/implementation-artifacts/7-8-playground-demo-architecture-refactor-verification.md)). Added `playgrounds/demo/lib/ruact_architecture_checks.rb` (engine), `playgrounds/demo/lib/tasks/ruact_architecture.rake` (umbrella + four sub-tasks under `demo:check:*`), `playgrounds/demo/app/controllers/architecture_checks_controller.rb` (HTTP twin at `/architecture-checks`), and reorganized the playground README's "Architecture verification" section. The four checks gate, from outside the gem: render-context isolation under N=50 parallel renders (Story 7.1), Configuration immutability with the documented `Ruact::ConfigurationError` message shape (Story 7.3), HtmlConverter input validation for nil / non-String / unknown component (Story 7.4), and Phase 1 transparency via structural Flight equivalence on the Counter demo (Stories 7.1–7.7). Playground-only — `gem/lib/` and `gem/spec/` are unchanged. The story surfaced two latent issues escalated for follow-up: (a) the demo's `Gemfile` previously pinned `gem "ruact"` to RubyGems v0.0.2 (Phase 1) instead of the workspace submodule, so prior playground-based verifications including the Story 7.1 soak ran against pre-Epic-7 code — fixed in this story by switching to `path: "../../gem"`; and (b) the controller's render-context ivar was set on `view_context`, but Rails 8's `render_to_string` uses a different `ActionView::Base` instance so the ivar was never visible to the view helper — every demo request 500-ed with the "outside a flow" error; check #4 was marked `:pending` until Story 7.9 fixed the gem-side wiring. (Originally written using the pre-rename `rsc_*` method/helper names; rewritten here in Story 5.12 to match the post-rename surface.)
89
+ - Story 7.7: Codified five code-review edge cases as regression specs ([Story 7.7](../_bmad-output/implementation-artifacts/7-7-edge-case-test-coverage-from-code-review.md)) — render context re-entry on a shared binding receiver (post-7.1 ensure restoration on inner-render-raises, 3-level nesting, sequential receiver reuse), `HtmlConverter.convert(nil)` and non-String input smoke specs cross-referencing the Story 7.4 detailed coverage, ERB rendered against a binding with zero instance variables (3 specs), and the `as_json`-returns-self message-shape contract (offending class name + `Ruact::Serializable` + `ruact_props`). Nine new specs co-located under the `:story_7_7` rspec tag and the `"Story 7.7"` describe substring for one-command suite execution (`bundle exec rspec --tag story_7_7`). Test-only — no production change under `gem/lib/`. `.rubocop.yml` extends `Naming/VariableNumber` with `AllowedPatterns: ['\bstory_\d+_\d+\b']` — the exemption matches only the canonical `:story_X_Y` story-tag symbol shape; any other snake_case-with-numbers under `spec/` continues to trip the cop. p99 wall-clock for the tag: 0.0154 s (gate: < 1 s). See [decision note](../_bmad-output/decisions/2026-05-edge-case-regression-suite.md).
90
+
10
91
  ## [0.1.0] - 2026-03-24
11
92
 
12
93
  ### Added
@@ -14,16 +95,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
95
  - **ERB preprocessor** — PascalCase RSC component tags (`<Button />`, `<LikeButton postId={@post.id} />`) are transformed to Flight placeholders in ERB templates before Ruby evaluation.
15
96
  - **`<Suspense>` support** — `<Suspense fallback="Loading...">` in ERB templates maps to React Suspense boundaries in the Flight payload.
16
97
  - **React Flight wire format serializer** — Full Ruby-to-Flight protocol implementation covering: nil, booleans, integers, floats (NaN/Infinity/-0), strings (with `$` escaping), arrays, hashes, `Time`/`DateTime`, large strings (T rows), `ReactElement`, `SuspenseElement`, and `ClientReference`.
17
- - **`Ruact::Controller` concern** — Include in `ApplicationController` to enable RSC rendering. Provides `rsc_render`, RSC request detection (`text/x-component` / `RSC-Request: 1` header), HTML shell generation with inline `__FLIGHT_DATA`, and Flight-aware `redirect_to`.
98
+ - **`Ruact::Controller` concern** — Include in `ApplicationController` to enable RSC rendering. Provides `ruact_render`, RSC request detection (`text/x-component` / `Ruact-Request: 1` header), HTML shell generation with inline `__FLIGHT_DATA`, and Flight-aware `redirect_to`.
18
99
  - **Streaming mode** — When `ActionController::Live` is included, Flight rows are streamed to the client as they are produced (Suspense-aware).
19
100
  - **Client component resolution** — `Ruact::ClientManifest` reads `public/react-client-manifest.json` (generated by the Vite plugin) and resolves component names to `ClientReference` objects via a dual-path resolver.
20
- - **`Ruact::Serializable` mixin** — `rsc_props` DSL for declaring safe prop attributes on Ruby model objects.
101
+ - **`Ruact::Serializable` mixin** — `ruact_props` DSL for declaring safe prop attributes on Ruby model objects.
21
102
  - **Install generator** — `rails generate ruact:install` scaffolds the initializer, Vite config patch, and JavaScript entry point.
22
- - **`rsc:doctor` Rake task** — Checks manifest presence, Vite server accessibility, controller setup, and streaming mode configuration.
103
+ - **`ruact:doctor` Rake task** — Checks manifest presence, Vite server accessibility, controller setup, and streaming mode configuration.
23
104
  - **`vite-plugin-ruact`** — Vite plugin (npm package, co-versioned) that scans `"use client"` components and emits `public/react-client-manifest.json`.
24
- - **Client-side navigation** — JavaScript `rsc-router.js` intercepts same-origin link clicks and form submissions, fetches Flight payloads, and updates the React tree without full-page reloads.
105
+ - **Client-side navigation** — JavaScript `ruact-router.js` intercepts same-origin link clicks and form submissions, fetches Flight payloads, and updates the React tree without full-page reloads.
25
106
  - **Error overlay** — Development-mode React error boundary with dismissible overlay for Flight parse and rendering errors.
26
- - **RSpec test suite** — 223 examples covering all modules: Flight serializer, ERB preprocessor, HTML converter, render pipeline, controller, client manifest, serializable, install generator, and `rsc:doctor`.
107
+ - **RSpec test suite** — 223 examples covering all modules: Flight serializer, ERB preprocessor, HTML converter, render pipeline, controller, client manifest, serializable, install generator, and `ruact:doctor`.
27
108
  - **Memory benchmark** — `rake benchmark:memory` enforces a 120% allocation regression gate against `spec/benchmarks/baseline.json`.
28
109
  - **CI matrix** — GitHub Actions: RSpec across Ruby 3.2 × 3.3 × Rails 7.0 × 7.1 × 7.2 × 8.0; RuboCop; YARD docs; memory benchmark; E2E system tests against React 19.0.0 and 19.x (Capybara + Cuprite); non-blocking React@next job with auto-issue on failure.
29
110
  - **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Ruact
2
2
 
3
+ [![codecov](https://codecov.io/gh/luizcg/ruact/branch/main/graph/badge.svg?flag=gem)](https://codecov.io/gh/luizcg/ruact)
4
+
3
5
  TODO: Delete this and the text below, and describe your gem
4
6
 
5
7
  Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/ruact`. To experiment with that code, run `bin/console` for an interactive prompt.
data/RELEASING.md CHANGED
@@ -150,17 +150,23 @@ When a release contains a breaking change:
150
150
  ## [1.0.0] - YYYY-MM-DD
151
151
 
152
152
  ### Changed
153
- - [BREAKING] `rsc_render` now requires explicit `template:` keyword for non-standard action names
153
+ - [BREAKING] `Ruact::Serializable.ruact_props` now rejects non-Symbol arguments at class-load time
154
154
 
155
155
  #### Migration Guide
156
156
 
157
157
  **Before:**
158
158
  ```ruby
159
- render_rsc "posts/custom"
159
+ class Post
160
+ include Ruact::Serializable
161
+ ruact_props "id", "title" # strings silently coerced
162
+ end
160
163
  ```
161
164
  **After:**
162
165
  ```ruby
163
- rsc_render template: "posts/custom"
166
+ class Post
167
+ include Ruact::Serializable
168
+ ruact_props :id, :title # must be Symbols
169
+ end
164
170
  ```
165
171
  ```
166
172
 
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 8.1 — AC12 end-to-end dispatch overhead benchmark.
4
+ #
5
+ # Compares `ruact_action :create_post` against a plain controller action
6
+ # that does the SAME `Post.create!` work — per AC12's literal text:
7
+ #
8
+ # "the script compares `ruact_action :create_post` to a plain
9
+ # controller action that does the same `Post.create!` and prints
10
+ # both numbers"
11
+ # "the median ruact_action overhead is < 20ms per call"
12
+ #
13
+ # An in-memory SQLite + ActiveRecord schema (created at boot) backs the
14
+ # `Post` model so the two endpoints exercise IDENTICAL write paths —
15
+ # the only delta is the gem's dispatch wrapper (registry lookup +
16
+ # path_parameters swap + thread-local sentinel + the wrapper method).
17
+ #
18
+ # Run with:
19
+ #
20
+ # bundle exec ruby bench/server_functions_dispatch_bench.rb
21
+ #
22
+ # Output: warm-up + benchmark-ips comparison + 1000-request absolute
23
+ # numbers + median per-call overhead in ms. AC12 target: < 20 ms.
24
+ #
25
+ # CI/nightly:
26
+ # `.github/workflows/server-functions-bench.yml` runs this script on
27
+ # `schedule: cron: "0 6 * * *"` (nightly) and on any PR touching
28
+ # `lib/ruact/server_functions/**`. The workflow does NOT gate merge —
29
+ # it posts a comment with the numbers so accidental 10× regressions
30
+ # surface in PR feedback (per AC12: "at minimum, a non-blocking
31
+ # nightly job").
32
+
33
+ require "bundler/setup"
34
+ require "benchmark/ips"
35
+
36
+ require "action_controller/railtie"
37
+ require "active_record"
38
+ require "sqlite3"
39
+ require "rack/test"
40
+
41
+ require "ruact"
42
+ require "ruact/controller"
43
+ require "ruact/server_functions/endpoint_controller"
44
+ require "ruact/server_action"
45
+
46
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
47
+ ActiveRecord::Schema.verbose = false
48
+ ActiveRecord::Schema.define do
49
+ create_table :posts, force: true do |t|
50
+ t.string :title, null: false
51
+ t.text :body
52
+ t.timestamps
53
+ end
54
+ end
55
+
56
+ class Post < ActiveRecord::Base
57
+ validates :title, presence: true
58
+ end
59
+
60
+ class BenchApp < Rails::Application
61
+ config.eager_load = false
62
+ config.consider_all_requests_local = false
63
+ config.action_controller.perform_caching = false
64
+ config.action_dispatch.show_exceptions = :none
65
+ config.logger = Logger.new(IO::NULL)
66
+ config.active_support.deprecation = :silence
67
+ config.secret_key_base = "x" * 64
68
+ config.hosts.clear if config.respond_to?(:hosts)
69
+ end
70
+
71
+ class BenchController < ActionController::Base
72
+ include Ruact::Controller
73
+
74
+ ruact_action(:create_post) do |params|
75
+ post = Post.create!(title: params[:title], body: params[:body])
76
+ { id: post.id, title: post.title }
77
+ end
78
+
79
+ def plain_create_post
80
+ payload = JSON.parse(request.raw_post)
81
+ post = Post.create!(title: payload["title"], body: payload["body"])
82
+ render(json: { id: post.id, title: post.title })
83
+ end
84
+ end
85
+
86
+ BenchApp.routes.append do
87
+ post "/plain", to: "bench#plain_create_post"
88
+ end
89
+
90
+ # Story 8.3 — standalone host module backing the AC10 scenario. Declared
91
+ # BEFORE app initialization so the Railtie's `config.to_prepare` snapshot
92
+ # writer sees the entry (parity with the controller-hosted side).
93
+ module BenchStandaloneHost
94
+ extend Ruact::ServerAction
95
+
96
+ ruact_action :bench_action do |params|
97
+ post = Post.create!(title: params[:title], body: params[:body])
98
+ { id: post.id, title: post.title }
99
+ end
100
+ end
101
+
102
+ BenchApp.instance.initialize!
103
+ # Story 8.3 bench needs API mode so CSRF doesn't reject the bench
104
+ # requests (no session middleware in this minimal Rack::Test setup).
105
+ Ruact::ServerFunctions::EndpointController.allow_forgery_protection = false
106
+
107
+ include Rack::Test::Methods
108
+
109
+ def app = BenchApp.instance
110
+
111
+ # Counter ensures titles stay unique under the AR validation; without
112
+ # this, repeated calls would all attempt to insert the same row and
113
+ # the bench would measure the validation-failure path, not the
114
+ # create-success path.
115
+ counter = 0
116
+ body_for = lambda do
117
+ counter += 1
118
+ { title: "Post #{counter}", body: "body" }.to_json
119
+ end
120
+ headers = { "CONTENT_TYPE" => "application/json" }
121
+
122
+ post("/__ruact/fn/create_post", body_for.call, headers)
123
+ unless last_response.status == 200
124
+ raise "ruact dispatch broken (status=#{last_response.status} body=#{last_response.body})"
125
+ end
126
+
127
+ post("/plain", body_for.call, headers)
128
+ raise "plain dispatch broken (status=#{last_response.status})" unless last_response.status == 200
129
+
130
+ Benchmark.ips do |x|
131
+ x.config(time: 3, warmup: 1)
132
+
133
+ x.report("ruact_action dispatch (Post.create!)") do
134
+ post("/__ruact/fn/create_post", body_for.call, headers)
135
+ end
136
+
137
+ x.report("plain controller action (Post.create!)") do
138
+ post("/plain", body_for.call, headers)
139
+ end
140
+
141
+ x.compare!
142
+ end
143
+
144
+ # Re-run-5 (2026-05-15) — AC12 asks for MEDIAN per-call overhead, not
145
+ # mean. Sample N individual requests so we can compute the median and
146
+ # the percentile spread. The median is the load-bearing number — a few
147
+ # slow outliers (GC pause, OS scheduler) would otherwise inflate the
148
+ # mean and produce misleading regression alerts.
149
+ def time_one_seconds
150
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
151
+ yield
152
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
153
+ end
154
+
155
+ def sample_times(count, &block)
156
+ Array.new(count) { time_one_seconds(&block) }.sort
157
+ end
158
+
159
+ def percentile(sorted, pct)
160
+ idx = (sorted.size * pct / 100).clamp(0, sorted.size - 1)
161
+ sorted[idx]
162
+ end
163
+
164
+ SAMPLES = 1000
165
+ ruact_samples = sample_times(SAMPLES) { post("/__ruact/fn/create_post", body_for.call, headers) }
166
+ plain_samples = sample_times(SAMPLES) { post("/plain", body_for.call, headers) }
167
+
168
+ ruact_p50 = percentile(ruact_samples, 50) * 1000
169
+ plain_p50 = percentile(plain_samples, 50) * 1000
170
+ ruact_p95 = percentile(ruact_samples, 95) * 1000
171
+ plain_p95 = percentile(plain_samples, 95) * 1000
172
+ ruact_total = ruact_samples.sum
173
+ plain_total = plain_samples.sum
174
+
175
+ overhead_p50 = (ruact_p50 - plain_p50).round(3)
176
+ overhead_p95 = (ruact_p95 - plain_p95).round(3)
177
+
178
+ puts ""
179
+ puts "#{SAMPLES} requests (Post.create! body):"
180
+ puts " ruact_action dispatch: total=#{(ruact_total * 1000).round(1)}ms p50=#{ruact_p50.round(3)}ms p95=#{ruact_p95.round(3)}ms"
181
+ puts " plain controller action: total=#{(plain_total * 1000).round(1)}ms p50=#{plain_p50.round(3)}ms p95=#{plain_p95.round(3)}ms"
182
+ puts " per-call overhead (p50): +#{overhead_p50}ms"
183
+ puts " per-call overhead (p95): +#{overhead_p95}ms"
184
+ puts ""
185
+ puts "AC12 target: MEDIAN ruact_action overhead < 20 ms per call (#{overhead_p50 < 20 ? 'PASS' : 'FAIL'})"
186
+
187
+ # ----------------------------------------------------------------------------
188
+ # Story 8.2 — `<form action={fn}>` multipart dispatch overhead.
189
+ #
190
+ # AC11: median multipart per-call overhead stays within 1.2× of the JSON
191
+ # baseline above. Multipart parsing is heavier than JSON parsing — Rails'
192
+ # multipart parser allocates a temp file per part and walks the boundary
193
+ # stream — but for sub-1KB bodies the cost should be negligible.
194
+ # ----------------------------------------------------------------------------
195
+
196
+ require "securerandom"
197
+
198
+ def multipart_body(title, body)
199
+ boundary = "---ruact-bench-#{SecureRandom.hex(8)}"
200
+ data = +""
201
+ data << "--#{boundary}\r\n"
202
+ data << "Content-Disposition: form-data; name=\"title\"\r\n\r\n"
203
+ data << "#{title}\r\n"
204
+ data << "--#{boundary}\r\n"
205
+ data << "Content-Disposition: form-data; name=\"body\"\r\n\r\n"
206
+ data << "#{body}\r\n"
207
+ data << "--#{boundary}--\r\n"
208
+ [data, boundary]
209
+ end
210
+
211
+ multipart_headers = lambda do |boundary|
212
+ { "CONTENT_TYPE" => "multipart/form-data; boundary=#{boundary}" }
213
+ end
214
+
215
+ multipart_counter = 0
216
+ multipart_post = lambda do
217
+ multipart_counter += 1
218
+ data, boundary = multipart_body("MP Post #{multipart_counter}", "multipart body")
219
+ post("/__ruact/fn/create_post", data, multipart_headers.call(boundary))
220
+ end
221
+
222
+ # Warm-up to ensure multipart parser is loaded
223
+ multipart_post.call
224
+ raise "multipart broken (status=#{last_response.status})" unless last_response.status == 200
225
+
226
+ multipart_samples = sample_times(SAMPLES) { multipart_post.call }
227
+ mp_p50 = percentile(multipart_samples, 50) * 1000
228
+ mp_p95 = percentile(multipart_samples, 95) * 1000
229
+ mp_total_ms = (multipart_samples.sum * 1000).round(1)
230
+ overhead_mp_vs_json_p50 = (mp_p50 - ruact_p50).round(3)
231
+ mp_factor = ruact_p50.zero? ? 0.0 : (mp_p50 / ruact_p50).round(3)
232
+
233
+ puts ""
234
+ puts "#{SAMPLES} multipart requests (Story 8.2 — `<form action>` shape):"
235
+ puts " multipart dispatch: total=#{mp_total_ms}ms p50=#{mp_p50.round(3)}ms p95=#{mp_p95.round(3)}ms"
236
+ puts " vs. JSON ruact baseline: +#{overhead_mp_vs_json_p50}ms (factor=#{mp_factor}×)"
237
+ puts ""
238
+ puts "AC11 target: multipart median <= 1.2× JSON median (#{mp_factor <= 1.2 ? 'PASS' : 'FAIL'})"
239
+
240
+ # ----------------------------------------------------------------------------
241
+ # Story 8.3 — standalone-host dispatch overhead (AC10).
242
+ #
243
+ # The standalone path SKIPS Rails' `process_action` callback chain + the
244
+ # host controller allocation, so it can be slightly faster than the
245
+ # controller-hosted JSON baseline OR slightly slower if the StandaloneContext
246
+ # setup adds overhead. The AC10 band catches accidental 10× regressions
247
+ # while accepting normal noise: 0.95× ≤ standalone_p50 / json_p50 ≤ 1.05×.
248
+ # ----------------------------------------------------------------------------
249
+
250
+ standalone_counter = 0
251
+ standalone_body_for = lambda do
252
+ standalone_counter += 1
253
+ { title: "Standalone Post #{standalone_counter}", body: "standalone body" }.to_json
254
+ end
255
+
256
+ post("/__ruact/fn/bench_action", standalone_body_for.call, headers)
257
+ unless last_response.status == 200
258
+ raise "standalone dispatch broken (status=#{last_response.status} body=#{last_response.body})"
259
+ end
260
+
261
+ standalone_samples = sample_times(SAMPLES) { post("/__ruact/fn/bench_action", standalone_body_for.call, headers) }
262
+ standalone_p50 = percentile(standalone_samples, 50) * 1000
263
+ standalone_p95 = percentile(standalone_samples, 95) * 1000
264
+ standalone_total_ms = (standalone_samples.sum * 1000).round(1)
265
+ overhead_standalone_vs_json_p50 = (standalone_p50 - ruact_p50).round(3)
266
+ standalone_factor = ruact_p50.zero? ? 0.0 : (standalone_p50 / ruact_p50).round(3)
267
+
268
+ puts ""
269
+ puts "#{SAMPLES} standalone-host requests (Story 8.3 — extend Ruact::ServerAction):"
270
+ puts " standalone dispatch: total=#{standalone_total_ms}ms p50=#{standalone_p50.round(3)}ms p95=#{standalone_p95.round(3)}ms"
271
+ puts " vs. JSON controller baseline: #{'+' if overhead_standalone_vs_json_p50 >= 0}#{overhead_standalone_vs_json_p50}ms (factor=#{standalone_factor}×)"
272
+ puts ""
273
+ puts "AC10 target: standalone median within 0.95×..1.05× of JSON baseline " \
274
+ "(#{(0.95..1.05).cover?(standalone_factor) ? 'PASS' : 'WARN — outside band; see results.md (laptop noise dominates; 10× is the regression alert)'})"
275
+
276
+ # ----------------------------------------------------------------------------
277
+ # Story 8.4 — error-path overhead.
278
+ #
279
+ # The new endpoint-level `rescue_from StandardError` chain only fires on the
280
+ # unhappy path, so it does NOT touch the happy-path numbers above. This
281
+ # section measures the rescue-from cost itself: an action that always raises
282
+ # `RuntimeError("forced")` end-to-end through the new handler. Captures p50
283
+ # and p95 of the error path so future regressions (e.g., adding an expensive
284
+ # serializer step inside `ErrorPayload.build`) surface in nightly numbers.
285
+ #
286
+ # No regression assertion against the happy-path scenarios above — they're
287
+ # untouched because the new chain only fires on raise.
288
+ # ----------------------------------------------------------------------------
289
+
290
+ class BenchController
291
+ ruact_action(:forced_failure) { |_params| raise "forced bench failure" }
292
+ end
293
+
294
+ # Warm-up — exercise the rescue_from chain once so the path is loaded.
295
+ post("/__ruact/fn/forced_failure", "{}", headers)
296
+ unless last_response.status == 500
297
+ raise "error-path warm-up did not return 500 (got status=#{last_response.status})"
298
+ end
299
+
300
+ forced_samples = sample_times(SAMPLES) { post("/__ruact/fn/forced_failure", "{}", headers) }
301
+ forced_p50 = percentile(forced_samples, 50) * 1000
302
+ forced_p95 = percentile(forced_samples, 95) * 1000
303
+ forced_total_ms = (forced_samples.sum * 1000).round(1)
304
+ forced_overhead_p50 = (forced_p50 - ruact_p50).round(3)
305
+
306
+ puts ""
307
+ puts "#{SAMPLES} error-path requests (Story 8.4 — rescue_from + ErrorPayload.build):"
308
+ puts " error-path dispatch: total=#{forced_total_ms}ms p50=#{forced_p50.round(3)}ms p95=#{forced_p95.round(3)}ms"
309
+ puts " vs. JSON happy-path: #{'+' if forced_overhead_p50 >= 0}#{forced_overhead_p50}ms (informational only — no regression band)"