ruact 0.0.5 → 0.0.6

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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +67 -18
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  93. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  94. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  95. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  96. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  97. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  98. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  99. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  102. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  104. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  107. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  118. metadata +55 -15
  119. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  120. data/lib/ruact/server_action.rb +0 -131
  121. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  122. data/lib/ruact/server_functions/registry.rb +0 -148
  123. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  124. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  125. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  126. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  127. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  128. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  129. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  130. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  131. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
data/CHANGELOG.md CHANGED
@@ -7,41 +7,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.6] - 2026-06-30
11
+
12
+ > **Server functions are route-driven.** A server **mutation** is a normal non-GET
13
+ > controller action on a controller that does `include Ruact::Server` — the action
14
+ > IS the function, reached at its real Rails route (`POST /posts`), visible in
15
+ > `rails routes`. A server **query** is a public method on a `Ruact::Query` class
16
+ > mounted with `ruact_queries` → a named `GET /q/<jsId>` route. React imports both
17
+ > from the codegen-emitted module (`import { createPost, useQuery } from
18
+ > "@/.ruact/server-functions"`), derived from the route table. There is no per-action
19
+ > DSL and no synthetic gem-managed endpoint.
20
+
21
+ ### Changed
22
+
23
+ - **`ruact:install` emits the launch files so `bin/dev` boots a working app — `package.json` + `Procfile.dev` + foreman `bin/dev` (FR101, Epic 14 DoD)** ([Story 14.6](../_bmad-output/implementation-artifacts/14-6-capstone-three-step-frictionless-greenfield-clean-room-docker-e2e.md)). The clean-room capstone surfaced that a fresh `ruact:install` wrote **no `package.json`** (so Story 14.1's `npm install` had nothing to resolve) and **no `Procfile.dev`/`bin/dev`** that boots **both** processes a ruact app needs — Rails (HTML shell + Flight + server functions) **and** the Vite dev server (React/HMR + the bundled ruact plugin). `ruact:install` now writes all three: a **`package.json`** declaring `react`/`react-dom` + the `vite`/`@vitejs/plugin-react` dev deps and a `dev: vite` script (the bundled ruact Vite plugin is **not** a dep — `vite.config` imports it by the absolute `Ruact.vite_plugin_path` and it uses only `node:` builtins); a **`Procfile.dev`** (`web: bin/rails server -p 3000` + `vite: npm run dev`); and an executable **`bin/dev`** that `exec`s `foreman start -f Procfile.dev` (installing foreman if absent — the Rails-idiomatic launcher). So the literal `bin/dev` from the Epic 14 DoD now boots the full stack with no manual launch wiring. Every file is **guarded and idempotent** — an existing `package.json`/`Procfile.dev`/`bin/dev` is left untouched (a skip notice prints) and `--force` overwrites — so re-running the generator never clobbers a developer's launcher, and `bin/dev` is always left executable. New `templates/{package.json.tt,Procfile.dev.tt,dev.tt}` + `create_package_json`/`create_launch_files` (ordered **before** the `npm install` step) in `install_generator.rb` (+ `:story_14_6` specs). This closes the getting-started doc's long-standing promise that `ruact:install` produces `package.json` + `Procfile.dev`.
24
+
25
+ - **`ruact:scaffold --shadcn` — shadcn/ui is now an explicit OPT-IN, reusing the Epic 10 components + the (now opt-in) dependency pre-flight (FR103b)** ([Story 14.5](../_bmad-output/implementation-artifacts/14-5-shadcn-as-opt-in-design-system.md)). The new `--shadcn` flag re-activates the byte-preserved Epic 10 design system: `rails generate ruact:scaffold Post title:string body:text published:boolean --shadcn` emits `PostList` (shadcn `Table` primitive + client-side sort), `PostForm` (shadcn `Input`/`Textarea`/`Switch`/`Select` controls mapped per attribute type), and `PostDeleteDialog` (controlled shadcn `AlertDialog`) by rendering the **byte-unchanged** `templates/components/{List,Form,DeleteDialog}.tsx.tt` — *restored from behind the flag, not rewritten*. **The default (no-flag) path stays design-system-AGNOSTIC** (Story 14.4): no `@/components/ui/*` imports, no Tailwind, native `<table>`/`<input>`/`<dialog>`. The shadcn **dependency pre-flight is re-promoted to a `--shadcn`-gated Thor command** (`check_shadcn_setup` early-returns unless `--shadcn`): under `--shadcn` on an app where shadcn/ui is not set up, it surfaces the copy-pasteable `npx shadcn@latest init` + `add <list>` guidance and **aborts before any write** (`raise Thor::Error`, zero partial state) — *no silent agnostic fallback under `--shadcn`*; `--skip-shadcn-check` (meaningful only under `--shadcn`) bypasses the abort and writes the shadcn components with the in-file banner. **The default path never invokes the pre-flight and never aborts.** **FR99 (`type <Model>Row`) + FR100 (`__ruactContract`) are preserved** in the `--shadcn` `.tsx`; `--shadcn --javascript` composes to untyped `.jsx` shadcn output with the "forfeits FR99/FR100" banner. **The controller, route, query, ERB views, model/migration/test delegation (14.3) and smoke spec are identical on both paths** — `--shadcn` flips only the three component templates + the pre-flight. The shadcn templates, `ShadcnPreflight`, `--skip-shadcn-check`, `FormHelpers#form_uses_*?`, and the Epic 10 `type-tests/scaffold/Post*.tsx` fixtures + `tsconfig.scaffold.json` are **byte-unchanged** (the only runtime edits are `+class_option :shadcn`, the `create_components` branch, and the `check_shadcn_setup` re-registration + guard in `scaffold_generator.rb`). The Epic 10 default-output shadcn specs neutralized in 14.4 are **un-skipped and re-pointed at the `--shadcn` path** (zero remaining skips); a new `:story_14_5` block proves the flag matrix (the `@/components/ui` grep in both directions + the pre-flight abort vs no-op), and the shadcn `tsc --noEmit` fixtures rejoin `npm run typecheck`. **This completes the FR103 split: agnostic default for a frictionless first run, `--shadcn` for the proven Epic 10 styled output when shadcn is already in the app.**
26
+
27
+ - **`ruact:scaffold` default is design-system-AGNOSTIC — no shadcn prerequisite, no mandatory pre-flight/abort (FR103)** ([Story 14.4](../_bmad-output/implementation-artifacts/14-4-agnostic-default-components-drop-mandatory-shadcn-preflight.md)). On a fresh app with **no** shadcn/ui installed, `rails generate ruact:scaffold Post title:string body:text published:boolean` now **completes without aborting** and emits `PostList`/`PostForm`/`PostDeleteDialog` as **plain, design-system-agnostic React components** — native `<table>`/`<input>`/`<textarea>`/`<select>`/`<input type=checkbox>`/`<button>` and a native `<dialog>` for the delete confirm — styled by the **Rails-default (browser) CSS** with **no `@/components/ui/*` imports, no Tailwind utility classes, and no shipped design-system stylesheet**. The mandatory shadcn dependency **pre-flight that previously `raise`d/aborted on a fresh app is removed from the default path** (`check_shadcn_setup` is de-registered as a Thor command via `remove_command`, kept defined but **dormant**). **CRUD feature parity is preserved — only the visual primitive changes:** the List keeps per-row Edit/Delete + live-search (`useQuery`) + client-side sort + in-place delete; the Form binds every attribute type (text→input, text→`<textarea>`, integer/float/decimal→`number`, boolean→**checkbox**, date→`date`, datetime→`datetime-local`, references→native `<select>` of controller-provided options) with controlled `useState`, submits via `create<Model>`/`update<Model>`, and surfaces the FR98 attribute-keyed `errors` inline; the DeleteDialog confirms before destroying (controlled `open`/`onConfirm`, `{ ok, error? }` contract, inline error on failure). **FR99 (`type <Model>Row`) + FR100 (`__ruactContract`) are preserved** in the agnostic `.tsx`; `--javascript` still strips them and emits untyped `.jsx` with the "forfeits FR99/FR100" banner. **shadcn is NOT deleted — it becomes the opt-in path:** the shadcn `templates/components/List.tsx.tt` / `Form.tsx.tt` / `DeleteDialog.tsx.tt`, the `ShadcnPreflight` module, the `--skip-shadcn-check` option, and the `FormHelpers#form_uses_*?` predicates are preserved **byte-unchanged and dormant** for a follow-up `--shadcn` flag (Story 14.5, which re-points `create_components` at them and re-gates the pre-flight). This repositions Epic 10's shadcn work as the proof it always was (shadcn drops into a Rails app cleanly) — the right proof, the wrong *default*. New `templates/components/agnostic/` templates + `FormHelpers#native_input_type` + the `create_components` re-point + the `remove_command` in `scaffold_generator.rb`; the CI gate is a `:story_14_4` spec (no-`@/components/ui` grep on the default output + the agnostic `tsc --noEmit` fixtures under `type-tests/scaffold/agnostic/` against an ambient stub that declares **no** `@/components/ui/*` module). The Epic 10 default-output shadcn specs (and the `:story_10_5` pre-flight block) are **neutralized** (`skip` + a `Story 14.5 re-points this at the --shadcn path` marker), never deleted.
28
+
29
+ - **`ruact:scaffold` is self-contained — it generates the model, migration, route and host-framework tests by delegating to Rails `resource` (FR102)** ([Story 14.3](../_bmad-output/implementation-artifacts/14-3-scaffold-delegates-to-rails-resource-model-migration-tests.md)). `rails generate ruact:scaffold Post title:string body:text published:boolean` now **invokes Rails' own public `resource` generator first** — producing `app/models/post.rb`, a `db/migrate/*_create_posts.rb` migration, the `resources :posts` route, and host-framework model/controller **test stubs** — and **then overlays** the ruact v2 controller, ERB views, React components and query on top. No more hand-running `rails generate model … ; rails db:migrate` before scaffolding (the Epic 10 dogfooding friction): after the scaffold and `rails db:migrate`, the resource is ready with no manual model step. **Honors your test framework** (parity): because the delegation goes through `resource`, the delegated model/controller stubs land RSpec-under-`spec/` or Minitest-under-`test/` per your `config.generators test_framework` — ruact hard-codes none for the delegated stubs. **Overlay reconciliation:** the v2 controller **force-overwrites** `resource`'s bare 7-action controller (silently — no Thor "conflict?" prompt); the route stays a single `resources :posts` line (`resource` draws it, ruact's existing guard no-ops); ruact's own v2-contract request spec is the **single** authoritative request spec — it `force`-wins the RSpec-path collision with `resource`'s empty stub **and** is **framework-gated** so it is emitted only when the host uses RSpec (a Minitest app gets the delegated Minitest tests instead — never a broken `rails_helper`-requiring file). The generated `request_spec.rb` PREREQUISITES comment **drops** the obsolete manual `rails generate model … ; rails db:migrate` step. The delegation uses only the **public** `resource` generator surface (Thor `invoke`), so it survives Rails-version drift (exercised across the Rails 7.0/7.1/7.2/8.0 CI matrix); the raw `field:type` strings pass through verbatim so Rails parses the correct migration columns. The sub-generator call sits behind a stubbable `invoke_rails_resource` seam (mirroring Story 14.1's `run_npm_install`) — unit specs (`:story_14_3`) assert invoke-once-with-args + before-the-overlay ordering + controller-force + framework-gated smoke spec with **no booted app**; the live end-to-end delegation (real model/migration on disk → `rails db:migrate` → CRUD round-trip) is proven by Story 14.6's clean-room Docker E2E. Single runtime file: `lib/generators/ruact/scaffold/scaffold_generator.rb` (+ the `request_spec.rb.tt` comment + `:story_14_3` specs). The CSS-agnostic default / `--shadcn` opt-in split is Story 14.4/14.5; `check_shadcn_setup` is unchanged here.
30
+
31
+ - **ruact's JS plumbing is hidden — `app/javascript/` is now your code only (FR104)** ([Story 14.2](../_bmad-output/implementation-artifacts/14-2-ruact-asset-and-plumbing-consolidation.md)). After a fresh `ruact:install`, `app/javascript/` contains only your `components/` (plus the gitignored, typed `.ruact/server-functions.ts`) — the framework's bootstrap entry and runtime wiring no longer sit interleaved with your components, the way Rails hides its own plumbing. Three moves: (1) the React **bootstrap entry is a virtual module `virtual:ruact/bootstrap`** served by the bundled Vite plugin from gem-shipped source (`vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx`), mirroring the existing `virtual:ruact/registry` — the install generator **no longer writes `app/javascript/application.jsx`**, and the generated `vite.config` input is `virtual:ruact/bootstrap`; (2) **`flight-client.js` + `ruact-router.js` moved into the gem** runtime dir (they used to be copied into every app) and the virtual bootstrap imports them via absolute specifiers (Vite-compiled into your bundle against your React — single React instance — but invisible to you); (3) the controller's private `vite_tags` is promoted to a **public `Ruact::ViewHelper#ruact_js_assets`** helper (the dev/prod entry `<script>` tags + the `__FLIGHT_DATA` inline script) that `Ruact::Controller#ruact_html_shell` delegates to (one implementation, controller↔helper parity). The four entry-reference points — Vite input, dev `<script src>` (`/@id/__x00__virtual:ruact/bootstrap`), prod Vite-manifest key, and the gem source the plugin loads — all read one source of truth (`Ruact.bootstrap_virtual_id`) so they can never drift. **`server-functions.ts` is unchanged** — it stays the lone visible, typed generated file at `app/javascript/.ruact/server-functions.ts`, and the locked `@/.ruact/server-functions` accessor import is untouched (NOT virtualized). **Migration:** an earlier-layout app deletes `app/javascript/{application.jsx,flight-client.js,ruact-router.js}`, points its `vite.config` input at `virtual:ruact/bootstrap`, and uses `ruact_js_assets` in its layout (re-running `ruact:install --force` regenerates the `vite.config`). Gem files: `vendor/javascript/vite-plugin-ruact/{index.js,runtime/}`, `lib/ruact/{view_helper,controller}.rb`, `lib/ruact.rb` (`Ruact.bootstrap_virtual_id`), `lib/generators/ruact/install/` (+ `:story_14_2` specs + a bootstrap vitest).
32
+
33
+ - **`ruact:install` is one command — it now runs `npm install` (FR101)** ([Story 14.1](../_bmad-output/implementation-artifacts/14-1-one-command-ruact-install-runs-npm-install.md)). After writing every file (initializer / controller concern / layout root / `app/javascript/.ruact/` scaffold / vite config / bootstrap entry), `rails generate ruact:install` now runs **`npm install`** in the app root so a fresh app goes straight to `bin/dev` with no separate JS-dependency step. The npm step runs **last** (a failure leaves the generated files in place and reported — no rollback). **`--skip-npm`** opts out (CI, or a non-npm package manager — install JS deps yourself, then `bin/dev`). When `npm` is **not on `PATH`** the generator does not crash with a stack trace: it prints a clear, actionable message (install Node ≥ 20, or re-run with `--skip-npm`) and finishes with the files written. The post-install message now branches on the real outcome — npm-ran → "JavaScript dependencies are installed. … `bin/dev`"; skipped/unavailable → "install JS dependencies (`npm install`), then `bin/dev`" — and never claims deps are installed when they are not. Idempotent on re-run (npm's own idempotency over a populated `node_modules`). The shell-out lives behind a stubbable `run_npm_install` seam so generator specs assert invoke-by-default / skip-under-`--skip-npm` with **no real npm or network call**. Package-manager auto-detection (yarn/pnpm/bun) is deferred — default-npm is the supported path, `--skip-npm` the documented escape hatch. Supersedes the old v1 behavior of merely *printing* an `npm install …` suggestion. Single runtime file: `lib/generators/ruact/install/install_generator.rb` (+ `:story_14_1` specs).
34
+
35
+ - **`ruact:scaffold` List → dep-free shadcn `table` primitive (drops `@tanstack/react-table`) (FR81)** ([Story 10.2b](../_bmad-output/implementation-artifacts/10-2b-generated-list-uses-shadcn-table-primitive-no-tanstack.md)). The generated `<Model>List` is reworked off the shadcn `DataTable` recipe (which is built on `@tanstack/react-table`) onto the plain shadcn [`table`](https://ui.shadcn.com/docs/components/table) primitive (`Table` / `TableHeader` / `TableBody` / `TableRow` / `TableHead` / `TableCell` from `@/components/ui/table` — a styled HTML table, **no runtime engine**) plus a **small generated client-side sort**. This keeps the scaffold dep-free (matching the dep-free Form + native date inputs) so `ruact:scaffold` does not silently commit the host app to `@tanstack/react-table`. The sort is a single `useState<{ key; dir } | null>` + a generated `compareRows` comparator: numbers numerically, dates by epoch time (off a generated `DATE_KEYS` set), booleans/strings sensibly, and `null`/`undefined` values **always last** regardless of direction (a blank cell never jumps to the top); a clickable header `toggleSort(key)` sets a new key ascending and flips asc↔desc on the active key, always sorting a **copy** of the rows (never mutating the `posts` prop). The markup is fully unrolled (one `<TableHead>` / `<TableCell>` per attribute), reading `row.<attr>` directly (no `row.getValue` / `row.original`). **Every other Story 10.2 / 10.4 behaviour is preserved exactly:** the per-attribute type-aware cells (text / `Badge` boolean / right-aligned `tabular-nums` numeric / locale-formatted date), the per-row Edit link + controlled `<Model>DeleteDialog` delete (`RowActions`, `destroy<Model>({ id })`, in-list `removedIds` tombstone removal, narrow-viewport `DropdownMenu` overflow), the server-rendered `posts` props path (no `as_json`), the `useQuery(search<Plural>, { q })` search (FR99), `type <Model>Row` (FR99), `__ruactContract = { props: { posts: "required" } }` (FR100), the empty / searching / no-match states, and the `--javascript` (`.jsx`) forfeit. The controller, ERB views, query templates (`<plural>_query.rb`, `application_query.rb`), and `ruact_queries` route injection are **untouched**. Verified by content-marker generator specs (incl. negative asserts: no `@tanstack` / `@/components/ui/data-table` / `ColumnDef` / `<DataTable` / `row.getValue` / `toggleSorting`) + an **isolated `tsc --noEmit`** of the regenerated `type-tests/scaffold/PostList.tsx` fixture against a new ambient `@/components/ui/table` stub (the `@tanstack/react-table` + `@/components/ui/data-table` ambient modules dropped). Reworked `templates/components/List.tsx.tt`; added `ScaffoldAttribute#date?`; updated `scaffold_generator_spec.rb` + `type-tests/scaffold/{ambient.d.ts,PostList.tsx}`. **Story 10.5 note:** the List now needs only `npx shadcn add table` — not the `data-table` recipe or `@tanstack/react-table`.
36
+
10
37
  ### Added
11
38
 
12
- - **`useQuery` request de-duplication** ([Story 9.6](../_bmad-output/implementation-artifacts/9-6-automatic-request-deduplication-for-usequery.md)). Identical concurrent `useQuery` calls now share ONE network request: when three components mount `useQuery(categories)` with the same params while a request is in flight, exactly one `GET /q/categories` is issued all of them receive the same resolved `data`, and an error propagates to every sharer. The dedup key derives from the **query reference identity + the serialized params**, with order-independent param serialization (`{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` share a request; different params do not). Scope is **in-flight only** — there is no TTL cache and no stale-while-revalidate: once a request settles its shared entry is dropped, so a fresh mount refetches. Runtime-only change (`useQuery` hook); the `_makeQuery` GET accessor, codegen, and the Ruby query dispatch are unchanged. Runtime package version `0.3.0` `0.4.0`; coverage in `usequery.test.mjs`.
39
+ - **`ruact:scaffold` shadcn dependency pre-flight — detect / guide / version-compat (FR81)** ([Story 10.5](../_bmad-output/implementation-artifacts/10-5-generator-handles-shadcn-dependency-setup.md)). `ruact:scaffold` now runs a shadcn/ui dependency **pre-flight as its FIRST step** (before any file is written): it detects the host's shadcn state (`components.json` + `app/javascript/components/ui/*`) and either **reuses** a complete setup, or prints a **copy-pasteable** `npx shadcn` command sequence and **aborts before writing anything** (zero partial state) when the setup is missing or partial. **Missing** `npx shadcn@latest init` + a single `npx shadcn@latest add <full list>`; **partial** → the **exact** missing component names + a targeted `npx shadcn@latest add <missing>`. The generator **never auto-runs** `npx`/`npm` (no surprising network/install behavior — critical for CI). The abort is a `raise Thor::Error` (clean CLI message, non-zero exit, no Ruby backtrace), mirroring the existing unknown-attribute-type guard. **`--skip-shadcn-check`** bypasses the abort and writes anyway, emitting a prominent **in-file comment banner** at the top of each generated component naming the unresolved `@/components/ui/*` imports + the exact fix command (the Vite build then fails loud on the missing import — never a silent blank render). The default/configured path is **byte-identical** to before (the banner is gated strictly behind a `shadcn_missing?` predicate the 10.1–10.4/10.2b `tsc` byte-equality fixtures stay valid without regeneration). **Dep-free by construction (AC7):** the add-list is **derived from the templates' own import predicates** (`FormHelpers#form_uses_*?`) so it can never drift from the emitted imports `button input textarea switch select label badge table alert-dialog dropdown-menu`, **every entry a plain `npx shadcn add` primitive**: there is **NO `data-table` recipe and NO `@tanstack/react-table`** dependency (Story 10.2b removed the engine; the List is a plain `table`), locked in by a negative spec assertion (the printed guidance never contains `@tanstack`/`data-table`). **Version-compat (AC6):** the new `Ruact.configuration.shadcn_compatible_versions` (a non-empty Array of major Integers, default `[1, 2]`, with non-empty-Array + Integer-only writer validation) is checked against the installed shadcn major (read best-effort from `package.json` `shadcn`/legacy `shadcn-ui`; `nil` when run via `npx` → a soft note, not a warning). An out-of-range major emits a **warning (never a hard stop)** naming the installed + tested versions and the docs link, with the documented override (add the major to `shadcn_compatible_versions` once verified). **CI exercises ≥2 shadcn majors at the detection/validation level via committed `components.json`/`components/ui/`/`package.json` fixtures** (no network — the real `npx shadcn` install + render is Story 10.7). This story adds a generator pre-flight + one config attribute + a comment-banner conditional only — **no component-template runtime change**, no wire-contract change. New `lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb` + `check_shadcn_setup` task + `--skip-shadcn-check` option in `scaffold_generator.rb` + the `shadcn_compatible_versions` config attribute; the three `templates/components/*.tsx.tt` gain the conditional banner; extended `scaffold_generator_spec.rb` + `configuration_spec.rb`.
13
40
 
14
- - **Queries in codegen + `useQuery` hook + FR88 kwargs sanitization** ([Story 9.5](../_bmad-output/implementation-artifacts/9-5-queries-in-codegen-usequery-re-pointed-kwargs-params.md)). The read-side of the route-driven redesign is complete: `import { categories, useQuery } from "@/.ruact/server-functions"` and `useQuery(categories)` / `useQuery(searchUsers, { q: input })` now work against `Ruact::Query` classes same mental model as mutations, zero new concepts. **Codegen.** A new `Ruact::ServerFunctions::QuerySource` derives v2 query entries from the **drawn route table** (the GET routes the `ruact_queries` macro mounted under the generated query-dispatch namespace) route-truth-consistent with dispatch, so only classes actually mounted in `routes.rb` are exposed (no over-exposure, and no `app/queries` force-load needed). Query entries join the **same merged JS namespace** as mutations; collisions fail loudly at boot naming both origins (route×query is caught at the codegen combine point; `query×query` by the source; the `ruact_function_name` rename macro on the mutation controller or renaming the query method is the escape hatch). The emitted module binds each query to `_makeQuery({ path, kind: "query" })` with a per-query TS signature (`() => Promise<unknown>` when the method declares no kwargs, `(params: Record<string, unknown>) => Promise<unknown>` when it does) and re-exports `useQuery` from the runtime (only when ≥1 query exists, so action-only/empty v2 modules stay byte-identical to Story 9.3). The Ruby↔JS byte-equality parity test covers query entries. **Runtime.** New `useQuery(reference, params?)` React hook `{ data, loading, error }` (loading until first resolution; structured `RuactActionError` into `error`; superseded in-flight responses dropped; refetch on value-changed params); new `_makeQuery` GET helper issues `GET /q/<jsId>` with params in the query string**no body, no CSRF** (reads are CSRF-free; the stale `POST /__ruact/fn/:id` query mechanism is voided). The runtime gains `react` as a **peerDependency** (its first React import; the mutation path stays React-free); package version `0.2.0` `0.3.0`. **FR88 sanitization.** `query_dispatch.rb#__ruact_query_kwargs` now enforces the authoritative kwargs allowlist on `request.query_parameters`: only `string | number | boolean | null` are accepted arrays (`?q[]=`) and objects (`?q[k]=`) are **rejected with a descriptive error naming the key and the allowlist**; a **missing required kwarg 400** naming it; an **unknown param 400** (rejected, not silently dropped). All raise the new `Ruact::BadRequestError` HTTP 400 via the Story 8.4 structured payload. 9.5 writes ONLY the parallel `.next` codegen target (ownership flip is 9.8); `useQuery` request de-duplication is 9.6; the v1 substrate is untouched (demolition is 9.9). New files `lib/ruact/server_functions/query_source.rb`; new specs `query_source_spec.rb`, `:story_9_5` cases in `codegen_spec.rb` / `railtie_integration_spec.rb` / `query_request_spec.rb`; new runtime vitest `usequery.test.mjs` + query parity cases. ADR addendum (2026-06-10).
41
+ - **`ruact:scaffold` DeleteDialog controlled shadcn AlertDialog + in-list removal (FR81)** ([Story 10.4](../_bmad-output/implementation-artifacts/10-4-generated-modelname-delete-dialog-with-shadcn-alertdialog.md)). The generated `<Model>DeleteDialog` is upgraded from a native `window.confirm` button to a **controlled** shadcn `AlertDialog` (`open` / `onOpenChange` / `<singular>` / `onConfirm` props): it reads `Delete "<title>"?` (the model's first **string** display attribute falling back to a `text` column, then `id`; never a hard-coded `title`, via the new `display_attribute` helper) with a `This action cannot be undone.` body and a **Cancel** (default focus) / **Delete** (destructive) footer. Confirm calls the generated **`destroy<Model>({ id })`** accessor (DELETE `/<plural>/:id`). **Two server-owned success shapes:** the generated default is **delete-in-list** `#destroy` returns `{ ok: true }` (no redirect, aligned to the golden), and the rewired `<Model>List` removes that row from local state **in place** (no reload, no client-built URL); a commented **delete-from-show** `redirect_to <plural>_url` alternative is carried one line below (the accessor follows the emitted `{ "$redirect" }` automatically). **There is no Rails `method=delete` fallback** — the `destroy<Model>` accessor is the only path. **On failure** (e.g. `restrict_with_exception` on a foreign key) `#destroy` does **not** hand-roll a rescue: the gem's existing structured-error middleware shapes the dev/prod response, the accessor throws it, and the dialog **stays open** with the message inline. **List rewiring:** the rendered rows become **local state** (seeded from the `posts` prop) so a delete can drop one; the per-row actions are driven by a small in-file `RowActions` component (its own `open` `useState`) across both the inline (≥ md) and overflow-menu (< md `DropdownMenu`) layouts. The `columns` config moves **inside** the component (design B `useMemo`) so the actions cell closes over component state, leaving the `DataTable`'s `{ columns, data }` interface unchanged (**no new Story 10.5 coupling**). The List preserves its `useQuery` search, the `__ruactContract = { props: { posts: "required" } }` (FR100), `type <Model>Row` (FR99), the sortable columns, and the empty/searching states. The `.tsx` `DeleteDialog` is typed (FR99) and retains an **inert** `__ruactContract` (it is referenced only from `List.tsx` JSX, never an ERB tag, so the 13.5 call-site validator never fires); `--javascript` forfeits the TS-only markers but keeps the controlled-dialog + in-list-removal + server-error behaviour identical. **Scope boundary with Story 10.5:** the dialog **imports** `@/components/ui/alert-dialog` (already on 10.5's add-list adds nothing new to the 10.5-owes tally) but installs nothing; the live demo is Story 10.7. Verified by content-marker generator specs + an **isolated `tsc --noEmit`** of the generated `.tsx` against an ambient `@/components/ui/alert-dialog` stub (the `type-tests/scaffold/PostDeleteDialog.tsx` + rewired `PostList.tsx` fixtures kept byte-identical to the live render by an rspec equality gate). Upgraded `templates/components/DeleteDialog.tsx.tt` (rewrite) + `templates/components/List.tsx.tt` (rewire) + `templates/controller.rb.tt` (`#destroy`) + `ScaffoldGenerator#display_attribute`; extended `scaffold_generator_spec.rb` + the typecheck harness.
15
42
 
16
- - **`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`.
43
+ - **`ruact:scaffold` Form shadcn controls mapped by attribute type (FR81)** ([Story 10.3](../_bmad-output/implementation-artifacts/10-3-generated-modelname-form-with-shadcn-form-components-by-attribute-type.md)). The generated `<Model>Form` is upgraded from bare HTML inputs to **shadcn form primitives mapped per ActiveRecord attribute type**: `string` `Input`; `text` `Textarea`; `boolean` `Switch`; `integer`/`float`/`decimal` `Input type="number"`; `date` `Input type="date"`; `datetime` `Input type="datetime-local"`; `references` `Select`. Each field is a consistent **Label + control + inline-error** layout, the per-field error driven by the FR98 attribute-keyed `errorsFor(attr)` map (plus the top-level `base` block) server-only validation with full round-trip feedback (client-side validation is Story 10.6). **Dep-free controlled state (design B+):** the form keeps the golden's controlled `useState` + shadcn input **primitives** (Radix-based — no `react-hook-form`/`zod` runtime dep), with a documented opt-in trail for swapping to `react-hook-form` + shadcn `<Form>` if rich client validation is wanted. **Native date/datetime controls:** shadcn-styled `<Input type="date"|"datetime-local">` round-trip the exact wire formats the serializer already emits (`YYYY-MM-DD` / `YYYY-MM-DDTHH:MM`) — no `Popover`+`Calendar` recipe, no `date-fns` dep (the rich picker is a documented follow-up). **One component for new + edit** via `<<Model>Form initial={@record} />` (`initial` null for `new`, the serialized row for `edit` no `as_json`), calling the generated `create<Model>`/`update<Model>` accessors; **success navigation stays server-driven** (`redirect_to` `$redirect` the accessor follows — the component builds no URL, no `window.location.assign`). **`references` options (AC6):** the generated `new`/`edit` controller actions load a capped, labelled options ivar (`@author_options = Author.limit(101).map { }`, label = name title `to_s`) passed as a prop the `<Select>` consumes (ivar prop, ≤ `REFERENCE_OPTIONS_LIMIT`); a larger parent set's server-search combobox is a documented opt-in trail. The `.tsx` is typed (FR99 `type <Model>Row` + the options prop) and carries `__ruactContract = { props: { initial: "optional" } }` (FR100); `--javascript` forfeits the TS-only markers but keeps every control + the server-driven behaviour. **Scope boundary with Story 10.5:** the Form **imports** `input`/`textarea`/`switch`/`select`/`label`/`button` from `@/components/ui/*` but does **not** install shadcn or emit any `components/ui/*` file (10.5 owns that; native date inputs mean **no** `calendar`/`popover` needed; the live demo is 10.7). Verified by content-marker generator specs + an **isolated `tsc --noEmit`** of the generated `.tsx` against ambient stubs (the `type-tests/scaffold/PostForm.tsx` fixture kept byte-identical to the live render by an rspec equality gate). Upgraded `templates/components/Form.tsx.tt` + `templates/controller.rb.tt` (references options) + `templates/views/{new,edit}.html.erb.tt`; new `lib/generators/ruact/scaffold/scaffold_form_helpers.rb` + `ScaffoldAttribute#shadcn_control`; extended `scaffold_generator_spec.rb` + the typecheck harness.
17
44
 
18
- - **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.
45
+ - **`ruact:scaffold` List shadcn DataTable + client-driven search query (FR81)** ([Story 10.2](../_bmad-output/implementation-artifacts/10-2-generated-modelname-list-component-with-shadcn-datatable.md)). The generated `<Model>List` is upgraded from a plain `<table>` to a **shadcn `DataTable`**: a typed `columns: ColumnDef<<Model>Row>[]` config with **per-attribute cell renderers** (boolean → `Badge` "Yes"/"No"; integer/float/decimal/references → right-aligned numeric; date/datetime → locale-formatted; string/text → text), **client-side sortable** column headers (the dataset is the controller's `index` payloadserver-side sort/pagination is Phase 3), and a per-row **actions** cell (Edit link + delegation to `<Model>DeleteDialog`) that **collapses into a `…` `DropdownMenu` under the `md` breakpoint** so it never pushes data columns out of layout on narrow viewports. The component keeps its FR99 `type <Model>Row` + the opt-in `__ruactContract = { props: { posts: "required" } }` (FR100), and gains a documented, prop-configurable **empty state** (`emptyLabel`). **Props vs. query, by construction.** The initial list stays **server-rendered props** (`<<Model>List posts={rows} />`, no `as_json` — AC4); only the **client-driven search box** becomes a query (AC5): the generator now also emits `app/queries/<plural>_query.rb` (`class <Plural>Query < ApplicationQuery` with a `search(q:)` method FR88 kwargs — a case-insensitive `LIKE` over the model's string/text columns, returning the SAME row shape the index serializes), creates `app/queries/application_query.rb` (`< Ruact::Query`) **idempotently** (`ruact:install` does not ship it; a second scaffold never clobbers a customized base), injects `ruact_queries <Plural>Query` into `config/routes.rb` (idempotent on re-run), and wires the List's search box to `useQuery(search<Plural>, { q })` (typed FR99 accessor, aliased from the codegen's generic `search` export). The query `.rb` is **language-agnostic** (emitted in both modes); `--javascript` forfeits the TS-only `ColumnDef`/`type`/`__ruactContract` markers but keeps the DataTable + search wiring. **Scope boundary with Story 10.5:** 10.2 **imports** `DataTable` from `@/components/ui/data-table` and `Badge`/`Button`/`DropdownMenu` from `@/components/ui/*` but does **not** install shadcn or emit any `components/ui/*` file 10.5 owns that (incl. the `data-table.tsx` recipe + the `table` primitive its current add-list omits; the live end-to-end demo is 10.7). Verified by content-marker generator specs + an **isolated `tsc --noEmit`** of the generated `.tsx` against ambient `@/components/ui/*` / `@tanstack/react-table` / server-functions stubs (a new `tsconfig.scaffold.json` + `type-tests/scaffold/` fixture kept byte-identical to the live render by an rspec equality gate, run in the existing `js` CI job). New templates `templates/queries/{query,application_query}.rb.tt`; new `lib/generators/ruact/scaffold/scaffold_attribute.rb`; upgraded `templates/components/List.tsx.tt` + generator tasks/helpers + `scaffold_generator_spec.rb`.
19
46
 
20
- - **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.)_
47
+ - **Component auto-registry the build emits `MODULE_REGISTRY`, no hand-maintained registration** ([Story 10.1b](../_bmad-output/implementation-artifacts/10-1b-component-auto-registry-build-emits-module-registry.md)). A fresh `ruact:install` app no longer ships an empty `const MODULE_REGISTRY = {}` that the developer must hand-edit (import + register) for every `"use client"` component. `vite-plugin-ruact` now exposes a virtual module `virtual:ruact/registry` its default export is the `{ [manifest id]: moduleExports }` map, derived from the **same scan** that writes `public/react-client-manifest.json`. Adding or removing a component under `app/javascript/components/` is **zero edits** to `application.jsx`; a freshly `ruact:scaffold`-generated resource hydrates in a fresh app with no manifest-resolution 500 (closes the Story 10.1 fresh-app gap + dogfooding friction #1). **Id-match by construction (NFR16 dev/prod parity):** the registry is keyed on the manifest `id` verbatim (the value the gem serializes as the Flight `moduleId`, `client_manifest.rb`), and the eager registry inlines every component into the app bundle (the Flight client resolves from the registry, not the Import row's `chunks`) — so a component never becomes a standalone facade chunk and its `id` stays the source-relative path in **prod** exactly as in **dev**; a real `vite build` test asserts the built registry's keys equal the emitted manifest ids. **Opt-out** is by placement (a file outside `app/javascript/components/`, or without a `"use client"` directive, is not registered). The install template (`application.jsx.tt`) now does `import MODULE_REGISTRY from 'virtual:ruact/registry'` instead of carrying a hand-maintained literal. Plugin-only change (no gem Ruby / wire-contract change); new vitest `registry.test.mjs` (incl. the load-bearing prod-build id-match) + reconciled `playgrounds/epic9-scaffold` onto the shipped path.
21
48
 
22
- - **`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`).
49
+ - **`rails generate ruact:scaffold` — a complete CRUD skeleton on the v2 contract (FR81)** ([Story 10.1](../_bmad-output/implementation-artifacts/10-1-rails-generate-ruact-scaffold-produces-complete-crud-skeleton.md)). `rails generate ruact:scaffold Post title:string body:text published:boolean` produces a working CRUD at parity with `rails generate scaffold`, with **typed React components** as the differentiator: a controller (`PostsController`, the seven RESTful actions, `include Ruact::Server`), `resources :posts`, four ERB views, three **plain working** React components, and a light controller smoke spec. **v2 controller shape:** GET actions set ivars only (implicit `default_render`, made safe on any `Accept` by Story 10.0); `create`/`update`/`destroy` are the route-derived server functions — Bucket-2 JSON, **server-driven `redirect_to`** on success (`$redirect`, the runtime follows it — no client URL building), and the FR98 `ruact_errors(@post)` keyed-errors channel on validation failure; strong-params read each attribute explicitly with `ActiveModel::Type::Boolean` coercion. **Components are `.tsx` by default** typed against the server boundary (FR99: a generated `type PostRow` + the codegen-typed `createPost`/`updatePost`/`destroyPost` accessors) and carrying an opt-in `__ruactContract` (FR100) so call sites validate at preprocess time; `PostForm` imports only the action accessors (no query — that is Story 10.2). A `--javascript` flag emits untyped `.jsx` (documented: it forfeits FR99/FR100). **References default to the RESTful raw `:id`**; every FR96 SignedGlobalID surface (`Ruact.locate_signed` finder swap, `Ruact.signed_global_id` mint, a `publish`-by-signed-token action) ships **commented** an opt-in the developer flips on per endpoint, never imposed, never closing off the raw-`:id` path. An unknown attribute type fails the generator **before any file is written** with a message listing the supported set + the docs pointer; the route injection and templated files are idempotent on re-run (no silent overwrite). The skeleton's plain components are upgraded to shadcn DataTable/Form/AlertDialog by Stories 10.2/10.3/10.4. New generator `lib/generators/ruact/scaffold/` (`scaffold_generator.rb` + templates); new spec `spec/ruact/scaffold_generator_spec.rb`.
23
50
 
24
- - **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 branchesReact 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 unchangedno 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.
51
+ - **Compile-time component contract HEEx-style `attr`/`slot` call-site validation (FR100)** ([Story 13.5](../_bmad-output/implementation-artifacts/13-5-compile-time-component-contract.md)). A `<Component prop={...} />` call site in an ERB view is now validated against the component's **opt-in** contract at **preprocess time** a missing required prop, a typo'd prop name (`postID` vs `postId`), or a missing required slot raises **before the page renders**, naming the component, the call site **file:line**, the offending prop, and a Damerau-Levenshtein **"did you mean?"** suggestion instead of surfacing as a silent `undefined` in the browser. This is the **consumer-side mirror** of `ruact_props` (which validates the producer at class-load). **Contract source (design A).** A component opts in by exporting `__ruactContract` from its own `.tsx` (HEEx spirit declared next to the component): `export const __ruactContract = { props: { title: "required", subtitle: "optional" }, slots: { header: "optional" }, passthrough: false }`. The Vite plugin's component scanner extracts it **names-only** (a brace-balanced scan + targeted regexes no TS-AST, no `eval`) into an optional, byte-additive `contract` field on the manifest entry; a component without the export emits no `contract` field, and a malformed declaration is warned + skipped (the Ruby side fails open). **Check location is forced to Ruby preprocess-time** ERB `<Component>` tags are invisible to TS/codegen; the validator (`Ruact::ComponentContract`) hooks the existing `COMPONENT_TAG_RE` transform loop, with the template path threaded through as `template.identifier` and the contract registry an **injectable seam** defaulting to the frozen `Ruact.manifest` (no per-call disk read). **NAME-level only** (required / unknown / slot): prop **values** are arbitrary render-time Ruby expressions the preprocessor never evaluates, so value-type checking is out of scope (reflection-honest, mirroring 13.4; component value typing is a later concern). **Slots** are minimal/name-level expressed at the call site as named prop attributes (render-prop style), required-by-presence; a contract with no slots makes slot checking a no-op; a typed slot runtime is deferred. **Opt-in / fail-open / byte-identical**: a contract-less component gets zero validation and byte-identical emitted output, and the no-tag fast path reads no registry (both regression-pinned — the load-bearing non-breaking guard). The error is a `Ruact::ComponentContractError` (a `Ruact::PreprocessorError` subclass, so it rides the dev error overlay / NFR30 lineage). The Damerau-Levenshtein closest-match (Story 7.4) is factored out of `ClientManifest` into a shared `Ruact::StringDistance`. This + FR99 (13.4) are the two BLOCKING gates so the Epic 10 scaffold output is typed AND contract-validated by construction (the playground build-fails proof is Story 13.6). New files `lib/ruact/component_contract.rb`, `lib/ruact/string_distance.rb`; new specs `component_contract_spec.rb`, `string_distance_spec.rb` + `:story_13_5` cases in `erb_preprocessor_spec.rb` / `client_manifest_spec.rb` + a `buildManifest` contract-extraction vitest. ADR addendum (2026-06-26).
25
52
 
26
- - **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>` fallbackthe 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`.
53
+ - **Typed query accessors `codegen_v2` emits real `params` types (FR99)** ([Story 13.4](../_bmad-output/implementation-artifacts/13-4-ts-type-emission-for-actions-and-queries.md)). The generated `server-functions.ts` now types each query accessor's `params` from the query method's **declared keyword arguments** instead of an opaque `Record<string, unknown>`. A query `def search_users(term:, limit: 10)` emits `searchUsers: (params: { term: string | number | boolean | null; limit?: string | number | boolean | null }) => Promise<unknown>` **named keys** (autocomplete), **exact optionality** (`keyreq` required, `key` → optional), so a **missing-required param, an unknown key, and a wrong-typed/wrong-arity call are all compile-time errors**. Closes the parameter/accessor `any` gap (a BLOCKING gate for the Epic 10 scaffold to be typed by construction). **Reflection-honest value type.** Ruby's `Method#parameters` exposes only names + required/optional never types or defaultsso per-param scalar precision (`limit?: number`) is not reflectable; the value type is the exact **FR88 wire union `string | number | boolean | null`** the query-string sanitizer already enforces (keys + optionality ARE exact). True scalar precision is deferred to a future explicit param-type DSL. **Scope.** Queries only **action signatures are byte-identical** (actions read `params` dynamically, no reflectable declared-input source; already `FormData | Record<string, unknown>`, not bare `any`); **return types stay `Promise<unknown>`** (`useQuery<T>` remains the return-typing lever). A `**keyrest` query fails open (`{ named } & Record<string, unknown>`). Per-kwarg metadata is derived in `QuerySource.build_entry` (`params` + `params_rest`, `accepts_params` retained), carried verbatim through the snapshot JSON, and emitted **byte-identically** by both the Ruby `Codegen::V2` and the JS `renderQueryExportV2` (the cross-impl parity test gains a typed-query fixture). The new metadata is a trust boundaryboth renderers reject a malformed snapshot rather than emit injected TS. A `tsc --noEmit` type-level test (new `typescript` build-only devDependency + `type-tests/` in the vite-plugin suite, wired into a new `js` CI job) proves the emitted types are enforced. No runtime `.d.ts` change, no client-runtime behavior change. ADR addendum (2026-06-26).
27
54
 
28
- ### Changed
55
+ - **Inertia-style validation `errors` round-trip — `ruact_errors(record)`** ([Story 13.3](../_bmad-output/implementation-artifacts/13-3-validation-error-round-trip-errors-prop.md)). A server action that saves a model can now round-trip field-level validation failures back to the form as an always-present structured `errors` object — `{ [attribute]: string[] }`, full messages keyed by attribute (a `base`-level error keys under `"base"`), `{}` on success — without hand-rolling an error channel per action. **Opt-in and explicit** (design (B), the `ruact_props`/allowlist grain that 13.1/13.2 reinforced — NO auto-injection, NO globally reserved prop): the developer calls `ruact_errors(@post)` once after the save attempt. Because the shape derives from `record.errors` (empty on a valid record), the SAME call yields `{}` on success and the populated map on failure — one client code path for both. **Pure normalizer** `Ruact::ServerFunctions::ValidationErrors.normalize` (no Rails/`Ruact.config`/request reads) accepts an ActiveModel-ish record, a raw `ActiveModel::Errors`, or a pre-shaped Hash and is idempotent on canonical input. **Dual-bucket.** Bucket 2 (imperative `await`): when the collector was touched, `Ruact::Server#default_render` injects it under the reserved JSON key `errors` alongside the serialized ivars (so `result.errors` always surfaces) — an untouched collector preserves the Story 9.2 `204 No Content` empty contract unchanged. Bucket 1 (native form / navigation): on a Flight `redirect_to` the errors are stashed in `flash[:ruact_errors]` (single-use, session-backed) and arrive as an `errors` prop on the re-rendered page (`<PostForm errors={ruact_errors} />`); a session-less API-only host degrades to the Bucket-2 body path. **Extends — does not replace — the Story 8.4/9.1 structured-error chain**: that chain stays the raised-`ActiveRecord::RecordInvalid` → 422 overlay path; FR98 is the non-exception `if record.save … else …` happy-failure path. No client-runtime change (the `errors` key rides `parseResponse` verbatim); no codegen change. New files `lib/ruact/server_functions/validation_errors.rb`, `lib/ruact/validation_errors_collector.rb`; new spec `validation_errors_spec.rb` + request-level `:story_13_3` cases in `server_bucket_request_spec.rb` / `controller_request_spec.rb`. ADR addendum (2026-06-26).
29
56
 
30
- - **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.
57
+ - **Signed, scoped, expiring record references `Ruact.signed_global_id` / `Ruact.locate_signed`** ([Story 13.2](../_bmad-output/implementation-artifacts/13-2-record-references-as-signed-global-id.md)). The canonical way to pass a model reference across the wire is now a **SignedGlobalID** token, not a raw id or attribute dump. `Ruact.signed_global_id(record, for: :post_edit, expires_in: 1.hour)` mints an HMAC-signed token bound to a `for:` purpose and an `expires_in:` lifetime (returns a plain `String`, so it serializes as a prop with no serializer change); `Ruact.locate_signed(token, for: :post_edit)` resolves it back, raising `Ruact::InvalidSignedGlobalIDError` mapped to a **clean HTTP 400** by the Story 8.4 structured-error chain (no `ActiveRecord::RecordNotFound` leak, no raw-id trust) on a tampered, expired, or wrong-purpose token. **Opt-in and explicit** (it follows the `ruact_props` allowlist grain no auto-detection of records in props, no auto-coercion of params). The purpose and expiry are **required**: omitting both the call argument and the configured default raises loudly rather than mint an unscoped/non-expiring token (an explicit `expires_in: nil` is honored as a deliberate non-expiring choice). Two new config keys document app-wide defaults: `Ruact.config.signed_global_id_default_purpose` and `Ruact.config.signed_global_id_default_expires_in` (both default `nil`). `globalid` (which ships with Rails) is lazily required only when a helper is called, preserving the single hard runtime dependency. New file `lib/ruact/signed_references.rb`; new spec `signed_references_spec.rb` + request-level `:story_13_2` cases in `query_request_spec.rb`. ADR addendum (2026-06-26).
31
58
 
32
- ### Added (pre-Story-8.4)
59
+ - **`useQuery` request de-duplication** ([Story 9.6](../_bmad-output/implementation-artifacts/9-6-automatic-request-deduplication-for-usequery.md)). Identical concurrent `useQuery` calls now share ONE network request: when three components mount `useQuery(categories)` with the same params while a request is in flight, exactly one `GET /q/categories` is issued — all of them receive the same resolved `data`, and an error propagates to every sharer. The dedup key derives from the **query reference identity + the serialized params**, with order-independent param serialization (`{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` share a request; different params do not). Scope is **in-flight only** — there is no TTL cache and no stale-while-revalidate: once a request settles its shared entry is dropped, so a fresh mount refetches. Runtime-only change (`useQuery` hook); the `_makeQuery` GET accessor, codegen, and the Ruby query dispatch are unchanged. Runtime package version `0.3.0` → `0.4.0`; coverage in `usequery.test.mjs`.
60
+
61
+ - **Queries in codegen + `useQuery` hook + FR88 kwargs sanitization** ([Story 9.5](../_bmad-output/implementation-artifacts/9-5-queries-in-codegen-usequery-re-pointed-kwargs-params.md)). The read-side of the route-driven model is complete: `import { categories, useQuery } from "@/.ruact/server-functions"` and `useQuery(categories)` / `useQuery(searchUsers, { q: input })` work against `Ruact::Query` classes — same mental model as mutations, zero new concepts. **Codegen.** `Ruact::ServerFunctions::QuerySource` derives query entries from the **drawn route table** (the GET routes `ruact_queries` mounted under the generated query-dispatch namespace) — route-truth-consistent with dispatch, so only classes actually mounted in `routes.rb` are exposed (no over-exposure). Query entries join the **same merged JS namespace** as mutations; collisions fail loudly at boot naming both origins (the `ruact_function_name` rename macro on the mutation controller — or renaming the query method — is the escape hatch). The emitted module binds each query to `_makeQuery({ path, kind: "query" })` with a per-query TS signature (`() => Promise<unknown>` when the method declares no kwargs, `(params: Record<string, unknown>) => Promise<unknown>` when it does) and re-exports `useQuery` from the runtime (only when ≥1 query exists). The Ruby↔JS byte-equality parity test covers query entries. **Runtime.** `useQuery(reference, params?)` React hook → `{ data, loading, error }` (loading until first resolution; structured `RuactActionError` into `error`; superseded in-flight responses dropped; refetch on value-changed params); `_makeQuery` GET helper issues `GET /q/<jsId>` with params in the query string — **no body, no CSRF** (reads are CSRF-free). The runtime gains `react` as a **peerDependency** (its first React import; the mutation path stays React-free); package version `0.2.0` → `0.3.0`. **FR88 sanitization.** `query_dispatch.rb#__ruact_query_kwargs` enforces the kwargs allowlist on `request.query_parameters`: only `string | number | boolean | null` — arrays (`?q[]=`) and objects (`?q[k]=`) are **rejected naming the key and the allowlist**; a **missing required kwarg → 400**; an **unknown param → 400** (rejected, not silently dropped). All raise `Ruact::BadRequestError` → HTTP 400 via the structured error payload. New file `lib/ruact/server_functions/query_source.rb`; new spec `query_source_spec.rb` + `:story_9_5` cases across the codegen / railtie-integration / query-request specs; new runtime vitest `usequery.test.mjs` + query parity cases. ADR addendum (2026-06-10).
62
+
63
+ - **`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 plain classes under `app/queries/` (`class CatalogQuery < ApplicationQuery`, `ApplicationQuery < Ruact::Query`) — each public method 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`. The prefix is configurable via `Ruact.config.query_route_prefix` (default `"/q"`). Dispatch goes through an internal gem controller — one generated subclass per query class — inheriting `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, and `CatalogQuery.new(fake_context).categories` is unit-testable with no Rails boot. Per-query callback opt-out via `ruact_skip_before_action` (mirrors Rails' `skip_before_action` signature incl. `only:`/`except:`/`raise: false`). Queries are GET — no CSRF; the return value serializes through the same `ruact_props` / `Ruact::Serializable` / `strict_serialization` policy as a Bucket-2 mutation response (`nil` → JSON `null` with 200), and a query raise renders the structured-error payload with the 422/403/413/500 mapping. 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`.
64
+
65
+ - **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 derived from the **Rails route table**: 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. `Ruact::ServerFunctions::RouteSource` collects entries from the route set; the locked naming derivation (recorded in the ADR) covers RESTful writes (`posts#create` → `createPost`), custom member/collection routes (`posts#publish` → `publishPost`; `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 `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's `_makeServerFunction({ method, path, segments })` accessor 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). FormData branching, CSRF meta injection, text-first parsing, `RuactActionError`, `redirect: "error"`, the intersection action signature, and `revalidate()` are all carried by a shared `ruactInvoke` core. 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`); +runtime vitest characterization tests; +codegen parity vitest cases.
66
+
67
+ - **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 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. `Vary: Accept` is set on every non-GET response shape. 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`).
68
+
69
+ - **`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 shape the generated accessors send: `Accept: application/json`. All other shapes — GET pages, browser form submits, Flight navigation, GET/HEAD JSON probes — keep stock Rails behavior. An oversized upload on a non-GET guarded request renders the structured 413 regardless of its `Accept` header. 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`).
70
+
71
+ - **Structured error payload for server-function failures** (salvaged from Epic 8, re-anchored on `Ruact::Server`). A server function that raises responds 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 common failure modes (`ActiveRecord::RecordInvalid`, `ActionController::InvalidAuthenticityToken`, `Ruact::UploadTooLargeError`), 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 render their own UI without backtrace leakage. Toggle via `Ruact.config.dev_error_payload_enabled` (Boolean, default `nil` → resolves to `Rails.env.development? || Rails.env.test?`). Status mapping: `RecordInvalid → 422`, `InvalidAuthenticityToken → 403`, `UploadTooLargeError → 413`, everything else → 500; the host's own `rescue_from` chain wins for owned exception classes. Pure modules under `gem/lib/ruact/server_functions/`: `ErrorPayload.build`, `BacktraceCleaner.split` (anchored on `Ruact.gem_path`), `ErrorSuggestion.for`. Server-side `Rails.logger.error` always logs the failure with a `[ruact]` prefix + the full backtrace; the dev-mode gate governs only the wire body.
33
72
 
34
- - **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`.
73
+ - **File uploads + `max_upload_bytes` pre-parse guard** (salvaged from Epic 8, re-anchored on `Ruact::Server`). `<form action={fn}>` with `<input type="file">` delivers `params[:file]` as `ActionDispatch::Http::UploadedFile`; the runtime's FormData branch sends `multipart/form-data` with the browser-managed boundary, and Rails' standard multipart parser unwraps each part`@post.cover.attach(params[:cover])` (Active Storage) works unchanged. `Ruact.config.max_upload_bytes` (Integer, default 10 MB; `nil` disables the gem-side guard) gates a prepended guard on the `Ruact::Server` concern that checks `request.content_length` for `multipart/form-data` / `application/x-www-form-urlencoded` bodies BEFORE CSRF verification; oversized requests raise `Ruact::UploadTooLargeError` → 413 + the structured body with a dev-only `upload_limit: { received_bytes, limit_bytes }` block. Carve-outs: JSON bodies, chunked-transfer requests (no `Content-Length`), `max_upload_bytes = nil`. GET/HEAD requests are never guarded.
35
74
 
36
- - **`<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.
75
+ ### Removed
37
76
 
38
- - **`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.
39
- - **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.
77
+ - **The v1 server-functions substrate** ([Story 9.9](../_bmad-output/implementation-artifacts/9-9-demolition-gate-v1-substrate-removal-codegen-swap-nfr21-rebench.md)). The Epic-9 route-driven redesign replaced the original server-functions contract, and Story 9.9 demolished the old machinery: the synthetic gem-managed function endpoint and its mounted route; the dual registries + their snapshot/collision layer; the standalone `"use server"` dispatcher (`Ruact::ServerAction` / standalone context / the configurable current-user resolver) and the `app/server_actions/` autoload path (the standalone host shape FR63 was dropped from the MVP); the controller-level block macro; and the v1 codegen render path + its runtime accessor (the registry-symbol ref builder and the synthetic-endpoint POST helper). The route-driven codegen is now the **sole** writer of `app/javascript/.ruact/server-functions.ts` (the parallel `.next` inspection target ceases to exist; the railtie writes the v2 snapshot to the real bridge on `config.to_prepare`, forcing the route table to load first for correct cold-boot ordering). React import statements are unchanged by construction — the module path `@/.ruact/server-functions` never changed. The dispatch benchmark was re-pointed at real REST + query routes and re-validated against NFR21 (< 20ms holds). The v1→v2 transition history lives only in the append-only ADR `gem/docs/internal/decisions/server-functions-api.md`.
78
+
79
+ ### Security
80
+
81
+ - **Tamper-proof record references — the structural antidote to forged-reference attacks** ([Story 13.2](../_bmad-output/implementation-artifacts/13-2-record-references-as-signed-global-id.md)). Passing `@post.id` (or an attribute hash) to the client hands it a forgeable, unscoped, non-expiring reference — swap `id: 7` for `id: 8` and an action that trusts `params[:id]` raw reaches a record it should not. `Ruact.signed_global_id` / `Ruact.locate_signed` (see Added) replace that with a signed, `for:`-scoped, `expires_in:`-bounded token: the client cannot forge or tamper with it, and a bad token is rejected as a clean 400 before any DB lookup. This is the inbound-safety counterpart to the serialize-only invariant (Story 13.1).
82
+
83
+ - **Serialize-only invariant — guarded by `ruact:doctor`** ([Story 13.1](../_bmad-output/implementation-artifacts/13-1-serialize-only-invariant-adr-addendum-doctor-guard.md)). ruact **emits** React Flight (`text/x-component`) but **never deserializes externally-supplied Flight into live Ruby objects** — which keeps it structurally outside the React2Shell / **CVE-2025-55182** class (a Flight-deserialization RCE). `rails ruact:doctor` now **fails** if ruact's own Ruby source introduces an inbound Flight-deserialization entry point (a `*Deserializer`, `parse_flight` / `from_flight` / `deserialize_flight` / `decode_flight`, or a Ruby `createFromNodeStream` / `createFromReadableStream` / `createFromFetch` reader) that is not annotated `# ruact:allow-flight-deserialization <reason>`; a clean tree passes silently. The same pass introduces a non-failing `:warn` doctor status (rendered `⚠`) that fires when a response-transforming middleware (`Rack::Deflater`) is mounted, since recompressing a streamed `text/x-component` body breaks the Flight wire contract. The client-side `createFromFlightPayload` (the browser deserializing the server's own trusted payload) is normal RSC and explicitly out of scope. Invariant recorded in the ADR addendum (`docs/internal/decisions/server-functions-api.md`, 2026-06-25).
40
84
 
41
85
  ### Fixed
42
86
 
87
+ - **`default_render` degrades gracefully on non-HTML `Accept` (no more 500 on `*/*`)** ([Story 10.0](../_bmad-output/implementation-artifacts/10-0-gem-prerequisite-default-render-graceful-degradation-non-html-accept.md); Epic 10 gem prerequisite). An ivar-only GET page action relying on implicit `default_render` returned an HTTP 500 (`__ruact_component__ called outside a ruact_render flow`) the moment a non-browser client (curl, bots, uptime/health-checks sending `Accept: */*`) hit it: the activation predicate keyed off `request.format.html?`, which is `false` for the `*/*` wildcard, so the request fell through to `super`, which rendered the `.html.erb` outside a `ruact_render` flow. The predicate now activates the RSC HTML-shell pipeline whenever the template exists **and HTML is acceptable to the client** (`*/*` wildcard, `text/html`, or a blank/absent `Accept`) — serving the same HTML shell a browser gets (RFC-correct content negotiation for "accept anything"). Concrete non-HTML formats (`application/json`, `application/xml`) still bypass to `super` so `respond_to` blocks and explicit `render` calls are unaffected, `text/x-component` / `Ruact-Request: 1` navigations are unchanged, and the `text/html` path is byte-identical. This makes the Epic 10 scaffold's implicit-`default_render` style safe by construction.
88
+
43
89
  - **`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.)
44
90
 
91
+ - **`rails generate ruact:install` no longer crashes on a fresh `rails new` (Rails 8.1 / Thor)** (Sprint Change Proposal 2026-06-16 §4.5; surfaced building the `epic9-scaffold` playground). The generator called `destination_root.join(...)` at eight sites, but Thor returns `destination_root` as a `String` (`File.expand_path`), which has no path-style `#join` — so the installer aborted with a `NoMethodError` before writing a single file. The paths are now wrapped in `Pathname(destination_root).join(...)`. A new integration spec runs the **real** generator against a `String` destination_root (the prior "generator action helpers" tests reimplemented the file logic with `File.join`, so they executed Thor's path handling never and masked the bug).
92
+
93
+ - **`<Suspense delay="2.5">` in ERB now reaches `Flight::SuspenseElement#delay`** (Sprint Change Proposal 2026-06-16 §4.5). `SuspenseElement` already accepted a `delay:` (the server-side wait, in seconds, before the deferred chunk streams), but the ERB had no way to set it — `<Suspense delay="2.5">` was silently ignored and every boundary used the default. The preprocessor now extracts the optional `delay` attribute into `data-ruact-delay`, and `HtmlConverter` parses it as a `Float` and forwards it to `SuspenseElement` (absent, blank, or unparseable → the element's default delay). `ActionController::Live` is still required for the wait to stream on a soft navigation.
94
+
45
95
  ### Tooling
46
96
 
47
97
  - **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.
@@ -70,7 +120,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
70
120
 
71
121
  ### Internal
72
122
 
73
- - **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).
74
123
  - 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.
75
124
  - **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.
76
125
  - **`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.
@@ -113,5 +162,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
113
162
  - **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.
114
163
  - **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
115
164
 
116
- [Unreleased]: https://github.com/luizcg/ruact/compare/v0.1.0...HEAD
117
- [0.1.0]: https://github.com/luizcg/ruact/releases/tag/v0.1.0
165
+ [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.6...HEAD
166
+ [0.0.6]: https://github.com/luizcg/ruact/releases/tag/v0.0.6