ruact 0.0.11 → 0.0.12
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -48
- data/CONTRIBUTING.md +233 -0
- data/README.md +14 -4
- data/RELEASING.md +125 -139
- data/lib/ruact/packaging.rb +68 -0
- data/lib/ruact/version.rb +1 -1
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
- metadata +6 -107
- data/.codecov.yml +0 -31
- data/.github/workflows/ci.yml +0 -284
- data/.github/workflows/server-functions-bench.yml +0 -54
- data/.rubocop.yml +0 -107
- data/.rubocop_todo.yml +0 -63
- data/Rakefile +0 -10
- data/bench/server_functions_dispatch_bench.rb +0 -276
- data/bench/server_functions_dispatch_bench.results.md +0 -150
- data/docs/internal/README.md +0 -9
- data/docs/internal/decisions/server-functions-api.md +0 -2236
- data/spec/benchmarks/baseline.json +0 -12
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
- data/spec/fixtures/flight/README.md +0 -136
- data/spec/fixtures/flight/array.txt +0 -1
- data/spec/fixtures/flight/as_json_object.txt +0 -2
- data/spec/fixtures/flight/bigint.txt +0 -1
- data/spec/fixtures/flight/boolean_false.txt +0 -1
- data/spec/fixtures/flight/boolean_true.txt +0 -1
- data/spec/fixtures/flight/client_component_with_props.txt +0 -2
- data/spec/fixtures/flight/client_reference.txt +0 -2
- data/spec/fixtures/flight/hash.txt +0 -1
- data/spec/fixtures/flight/infinity.txt +0 -1
- data/spec/fixtures/flight/nan.txt +0 -1
- data/spec/fixtures/flight/negative_infinity.txt +0 -1
- data/spec/fixtures/flight/nil.txt +0 -1
- data/spec/fixtures/flight/number_float.txt +0 -1
- data/spec/fixtures/flight/number_integer.txt +0 -1
- data/spec/fixtures/flight/react_element_no_props.txt +0 -1
- data/spec/fixtures/flight/redirect_row.txt +0 -1
- data/spec/fixtures/flight/serializable_object.txt +0 -2
- data/spec/fixtures/flight/string_basic.txt +0 -1
- data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
- data/spec/fixtures/flight/undefined.txt +0 -1
- data/spec/fixtures/readme/children-error.html.erb +0 -3
- data/spec/fixtures/readme/children-error.txt +0 -1
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
- data/spec/readme_demo_message_spec.rb +0 -67
- data/spec/readme_spec.rb +0 -282
- data/spec/ruact/client_manifest_spec.rb +0 -270
- data/spec/ruact/component_contract_spec.rb +0 -119
- data/spec/ruact/configuration_spec.rb +0 -518
- data/spec/ruact/controller_request_spec.rb +0 -671
- data/spec/ruact/controller_spec.rb +0 -343
- data/spec/ruact/doctor_spec.rb +0 -769
- data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
- data/spec/ruact/erb_preprocessor_spec.rb +0 -361
- data/spec/ruact/errors_spec.rb +0 -93
- data/spec/ruact/flight/renderer_spec.rb +0 -133
- data/spec/ruact/flight/serializer_spec.rb +0 -494
- data/spec/ruact/html_converter_spec.rb +0 -375
- data/spec/ruact/install_generator_spec.rb +0 -1549
- data/spec/ruact/layout_source_spec.rb +0 -108
- data/spec/ruact/manifest_resolver_spec.rb +0 -174
- data/spec/ruact/query_request_spec.rb +0 -706
- data/spec/ruact/query_spec.rb +0 -105
- data/spec/ruact/railtie_spec.rb +0 -155
- data/spec/ruact/render_context_spec.rb +0 -58
- data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
- data/spec/ruact/render_pipeline_spec.rb +0 -928
- data/spec/ruact/scaffold_generator_spec.rb +0 -1849
- data/spec/ruact/serializable_spec.rb +0 -179
- data/spec/ruact/server_bucket_request_spec.rb +0 -785
- data/spec/ruact/server_function_name_spec.rb +0 -53
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
- data/spec/ruact/server_functions/codegen_spec.rb +0 -397
- data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
- data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
- data/spec/ruact/server_functions/introspection_spec.rb +0 -135
- data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
- data/spec/ruact/server_functions/query_context_spec.rb +0 -72
- data/spec/ruact/server_functions/query_source_spec.rb +0 -193
- data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
- data/spec/ruact/server_functions/rake_spec.rb +0 -86
- data/spec/ruact/server_functions/route_source_spec.rb +0 -202
- data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
- data/spec/ruact/server_rescue_request_spec.rb +0 -416
- data/spec/ruact/server_spec.rb +0 -179
- data/spec/ruact/server_upload_request_spec.rb +0 -311
- data/spec/ruact/signed_references_spec.rb +0 -164
- data/spec/ruact/string_distance_spec.rb +0 -38
- data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
- data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
- data/spec/ruact/testing/no_production_load_spec.rb +0 -41
- data/spec/ruact/validation_errors_spec.rb +0 -116
- data/spec/ruact/view_helper_spec.rb +0 -131
- data/spec/spec_helper.rb +0 -77
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +0 -21
- data/spec/support/flight_wire_parser_spec.rb +0 -93
- data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
- data/spec/support/rails_stub.rb +0 -115
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.0.12] - 2026-09-09
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **The release is the pull request.** Publishing used to be turned on by hand — a repository variable a maintainer set before merging and had to remember to delete afterwards — and the version number was then computed by CI, written into `lib/ruact/version.rb` after the merge, and committed back to `main` with `[skip ci]`. Three things followed from that, and all three are gone. The number was *predicted* when the CHANGELOG heading was stamped and *decided* afterwards, so the first minor would have been published under a heading naming a patch. The commit every published gem was built from was the one commit no check had ever seen. And the switch's dangerous state was the one you had to remember to undo.
|
|
15
|
+
|
|
16
|
+
Now the pull request owns both: you write the version and stamp the record in the same diff, and merging it is what publishes. The `release` job asks one question on every push to `main` — does `lib/ruact/version.rb` name a version with no tag? — and builds, uploads and tags if the answer is yes. That is a question about state rather than about what a push changed, which buys three properties the old trigger could not have: it is idempotent, so a second run does nothing; it is self-healing, so a release lost because no run was created for a push is published by the next green push instead of being lost forever; and it commits nothing to `main`, only a tag.
|
|
17
|
+
|
|
18
|
+
- **Two gates on every pull request, and the same two runnable locally.** `bin/release-gate` asks whether this branch changed anything that goes inside the published gem and, if it did, requires `CHANGELOG.md` to have *gained* something — a bullet or a heading, with touching the file not being enough. Separately, and only when `lib/ruact/version.rb` moved, it requires the new version to be exactly one semver step, to carry no tag already, and to be matched by `Gemfile.lock`. Nothing forces a bump: a merge without one accumulates under `[Unreleased]` until somebody decides to release.
|
|
19
|
+
|
|
20
|
+
- **`bin/release X.Y.Z` prepares a release, and generates its own checklist.** It refuses a dirty checkout, a checkout that is not `main` exactly as the remote has it, and a version that is not one legal step, then writes the version, re-resolves the lock, moves `[Unreleased]` under a dated heading, rewrites both link references, proves the result against the gates, and opens the pull request — whose body is the checklist, with the version already substituted. There is no checklist document, deliberately: a checklist is a second copy of the process in the imperative, and this one is generated by the thing that performs it.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
- **`CONTRIBUTING.md` — this repository now tells a contributor how to work in it.** Until now the README's Contributing section pointed at the Issues URL and nothing else, because there was no guide to link: the only contributing document lived in a repository outside contributors can't see, and it had gone false anyway (it named a class deleted two epics ago and prescribed `Thread.current`, which a custom cop rejects — following it made CI fail). The new file is executable with this repository alone: what the three directories are and why the Vite plugin ships bundled rather than published, the prerequisites, setup, the checks CI runs on every push and every pull request, with their local equivalents, and a `path:` block that puts your working tree into a throwaway Rails app — which is also the block that makes the vendoring pay off, because pointing an app at your gem checkout points it at your plugin checkout too. It then names the four gates that go red for reasons a diff doesn't show (the byte-pinned README specs, the allocation baseline, the three custom cops, and the YARD cache that hides warnings CI still reports), and routes each kind of change to a destination the reader can actually reach, naming the superseded `vite-plugin-ruact` npm package so nobody mistakes it for one.
|
|
24
|
+
|
|
25
|
+
`spec/contributing_spec.rb` keeps it honest, in the shape `spec/readme_spec.rb` arrived at: every repo-relative link resolves on disk, no private-side vocabulary leaks, exactly one repository is cloned, the command spine is pinned byte for byte — and the list of CI jobs is **parsed out of `.github/workflows/ci.yml`** rather than maintained by hand, so renaming a job reddens the document by construction. It also forbids the document from stating what a check *decides* at merge time — a claim nothing inside this repository can verify, and therefore one that would rot without ever going red.
|
|
26
|
+
|
|
27
|
+
- **A "Where ruact fits" section — including where it does not.** The README described what ruact does without once naming what it cannot do. It now says plainly that pages needing to render without JavaScript, or content search engines must read out of the HTML, are not a fit: ruact renders client-side from an inlined payload and there is no server-rendered HTML for the React tree. The honest alternative is named rather than implied — keep that page in ERB, which coexists per-view in the same app, or use something that server-renders.
|
|
28
|
+
|
|
29
|
+
- **`RELEASING.md` now describes the release that actually happens.** The file was written for a world with two published artifacts, and every step in it was a step a human performed: create a release branch, hand-edit `lib/ruact/version.rb`, hand-edit a second package version, tag the merge commit, build and push the gem with an OTP, publish to npm, open a GitHub Release. None of that is what a release is any more — what a release *is* is the entry below, **The release is the pull request**. A maintainer following the old file would have hand-edited a file CI rewrites and republished a version CI had already published — and would have looked for a GitHub Release step that nobody has performed across every tag in this repository, which is why that step is gone rather than corrected.
|
|
30
|
+
|
|
31
|
+
- **Two gates keep both documents honest.** `spec/releasing_spec.rb` reads the release trigger **out of `.github/workflows/ci.yml`** rather than from a list somebody maintains, so changing the release mechanism reddens the document that describes it, by construction, instead of leaving it to be remembered. It also refuses the steps nobody performs, refuses a concrete version number anywhere in a file that is republished with every release, pins the command blocks a maintainer copies, and — sharing its lists with `spec/contributing_spec.rb` through a new `spec/support/markdown_gate.rb` — refuses private-side paths and the claim that any check gates merging.
|
|
32
|
+
|
|
33
|
+
`spec/changelog_spec.rb` is the first check this file has ever had. It asserts the record's shape against itself and against `Ruact::VERSION`, which is enough to have caught the defects listed below.
|
|
34
|
+
|
|
35
|
+
- **The changelog stopped linking into a repository its readers do not have.** Fifty-four links pointed at story files by relative path outside this repository — dead for anyone reading the file on GitHub, dead inside the packaged gem, and dead on the documentation site the page is generated from. The labels stay as plain text, so the record still says which story did what; only the targets are gone. The gems already on RubyGems keep the text they shipped with; this file is corrected from here on.
|
|
36
|
+
|
|
37
|
+
- **`## [0.1.0]` was a release that never happened.** It had no tag, no link reference, nothing on RubyGems, sat below the `0.0.x` entries, and was dated a month before the first publish — while `0.1.0` is the version this project has reserved for a milestone it has not reached. It is now a plainly-labelled section for the work that predates the first published version, content unchanged.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **Arrays keep their length, including `children`.** ⚠️ **Behaviour change.** The collapse described under Fixed was applied by a walker that sees values without seeing where they sit, so removing it reaches one more path than the reported bug: `children` passed as an *explicit prop* on a self-closing tag — `<Label children={["hi"]} />` — used to arrive as `"hi"` and now arrives as `["hi"]`.
|
|
42
|
+
|
|
43
|
+
Children written the ordinary way, nested inside a tag, are unaffected: the server collapses a lone child before the wire, and that is still what the client receives. Only the explicit-prop form changes — but it changes **at every depth**, not only when the outer array holds one item: `children={[["hi"], ["bye"]]}` has two entries and still differs, because each inner one-element array is now preserved too. A component that indexed into such a prop, or called a string method on it, will see an array where it saw a value.
|
|
44
|
+
|
|
45
|
+
This was taken deliberately rather than compensated for. The alternative was to keep collapsing `children` alone, which preserves the old behaviour at the price of a permanent inconsistency — the same one-element array arriving as an array in every prop except one. A uniform contract is only free to adopt before there are applications depending on the exception.
|
|
46
|
+
|
|
47
|
+
- **The published gem stopped shipping the repository.** `spec.files` was `git ls-files` minus four entries, so the whole of `spec/` — about two fifths of the tracked tree — plus `.github/`, `bench/`, `docs/`, `Rakefile`, `Gemfile.lock` and the RuboCop configuration travelled inside every `.gem` anybody installed, none of it reachable from an installed copy. What ships is now decided by a predicate, `Ruact::Packaging`, with a single writer that both the gemspec and the release gate call: `lib/`, `sig/`, `vendor/javascript/` and the top-level documents. Nothing a consumer loads was removed; the vendored Vite plugin and browser runtime still ship, because they are resolved by filesystem path off the installed gem. `spec.bindir` and `spec.executables` are gone with it — this gem has never had an `exe/` directory.
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
- **The README no longer claims props are an allowlist.** It said *"Props are an allowlist — other columns never cross"*, describing the opt-in rather than the default. The default is `as_json`: every attribute of a model prop crosses to the client, and the gem says so in the log (`ALL attributes exposed to client`). A reader who took the bullet at face value would have believed the safe behaviour was already on. The bullet now leads with what actually happens, then names `ruact_props` as the thing that makes it an allowlist and `strict_serialization` as what turns the permissive path into an error in production.
|
|
51
|
+
- **The server-functions bullet no longer calls the whole generated module "typed".** Only queries carry an exact signature (one property per declared keyword, since 0.0.5). An action's accessor is typed to be callable and to satisfy `<form action>`, but its arguments and resolved value are `Record<string, unknown>` and `unknown` — useful, and not the autocomplete the word "typed" promises. Both are now described as what they are.
|
|
52
|
+
- **The mechanism is stated where the reader meets it.** "Capitalized tag means React. Lowercase stays HTML." now sits directly under the ERB/TSX pair, and the paragraph that follows explains the wire in plain terms — the view renders server-side as it always did, the result travels as a React tree in the format React uses for Server Components, the data is inlined so React renders without a fetch, and Node builds the bundle and does nothing else.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **A list with exactly one row rendered as nothing.** A client component given a collection prop — `<PostList posts={rows} />`, the shape `rails generate ruact:scaffold` emits — received the row *itself* instead of a one-row array whenever the collection happened to hold exactly one item. Two or more worked. The payload leaving the server was always correct; the client collapsed it while rebuilding the tree, because the collapse was applied by a walker that sees values without seeing where they sit, and so could not tell a list prop from an element's children.
|
|
57
|
+
|
|
58
|
+
What this looked like is worth stating, because it did not look like a bug: the generated list rendered **neither the table nor the "none yet" message**, with nothing in the console — a blank list and no error. Typing in the search box made it appear, since search results come back as JSON and never pass through that code. Reaching an actual exception took a sequence, because the sort controls live inside the table that had not rendered: search, sort, then clear the search. Seed data and fixtures almost always hold more than one row, so the case that broke was the one every new application reaches first — the record you just created.
|
|
59
|
+
|
|
60
|
+
Arity is now preserved wherever an array appears, at every depth — see **Arrays keep their length, including `children`** under Changed. The generated scaffold had a second surface with the same shape: a relationship `<select>` renders `{authorOptions.map(...)}`, so a resource with exactly one option to choose from threw rather than rendering. That is fixed by the same change. `flight-client.js` was the only runtime module in the bundled Vite plugin without a unit test, which is what let a change to shared deserialization go unnoticed; it has one now, covering empty, one and two elements for both data props and children, asserting the rebuilt content rather than only its arity, and driven in part by wire bytes the Ruby serializer writes.
|
|
61
|
+
|
|
10
62
|
## [0.0.11] - 2026-08-25
|
|
11
63
|
|
|
12
64
|
### Changed
|
|
@@ -78,25 +130,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
78
130
|
|
|
79
131
|
### Changed
|
|
80
132
|
|
|
81
|
-
- **`ManifestError` message is now in English (was Portuguese)** (
|
|
133
|
+
- **`ManifestError` message is now in English (was Portuguese)** (Story 15.0). When the Vite dev server is unreachable AND no `public/react-client-manifest.json` exists on disk, `Ruact::ManifestResolver` now raises `[ruact] Vite dev server unreachable at <url> and no react-client-manifest.json found at <path> — run `bin/dev`.` — same raise site, same `ManifestError` class, same HTTP→file fallback order, same diagnostic content (dev-server URL + manifest path + the `bin/dev` fix); only the language changed. Error messages are prompts — agents (and humans) regex-match them — so the stable tokens (`Vite dev server`, `react-client-manifest.json`, `bin/dev`) are preserved and now spec-pinned.
|
|
82
134
|
|
|
83
135
|
### Added
|
|
84
136
|
|
|
85
|
-
- **`ruact_props` now works directly on an ActiveRecord model — loud check deferred from class-load to first serialize for lazy attributes (Story 13.7)** (
|
|
137
|
+
- **`ruact_props` now works directly on an ActiveRecord model — loud check deferred from class-load to first serialize for lazy attributes (Story 13.7)** (Story 13.7). `include Ruact::Serializable; ruact_props :id, :title` on an `ActiveRecord` model used to raise `ArgumentError: method 'title' is not defined` at **class-load**, because ActiveRecord defines its attribute reader methods **lazily** (on first instance access) — so the eager `method_defined?` check fired before the reader existed, even for a real column. The check is now **hybrid**: a **PORO** is still validated eagerly at class-load (byte-identical — a typo still raises immediately), while for a **lazy-attribute (ActiveRecord) class** the not-yet-defined names are recorded and their loud check is **deferred to the first `ruact_serialize`** (via `respond_to?` on the instance, where the DB is up and the reader exists). **The loud-omission guarantee is preserved** (Epic 13.1/13.2 spine): a bogus/typo'd prop still raises the same clean `ArgumentError` — only the *timing* moved for AR (first render of that model instead of boot). No boot-time DB dependency is introduced (`attribute_names`/`column_names` would need a live connection at class-load — rejected), the gem stays **single-dep `nokogiri`** (the ActiveRecord discriminator `defined?(ActiveRecord::Base) && self < ActiveRecord::Base` references the constant only when the host already defined it), and there is **no new config key, no serializer-pipeline change** (an AR model that includes the module already dispatched to `serialize_serializable` → `ruact_serialize`), and **`strict_serialization` is unaffected** (an AR `Serializable` model serializes identically under strict true/false, as a PORO does). This reverses the doc-only guidance shipped in the AGENTS.md/`llms.txt` serialization-contract fix (gem PR #48): `ruact_props` on an AR model is now a first-class pattern again; the manual row hash (`{ id: p.id, title: p.title }`) remains the equivalent for a no-model / partial shape.
|
|
86
138
|
|
|
87
|
-
- **Auto-revalidate opt-in: one `await` folds a mutation and its in-place refresh into a single call (FR110)** (
|
|
139
|
+
- **Auto-revalidate opt-in: one `await` folds a mutation and its in-place refresh into a single call (FR110)** (Story 15.6). A mutation whose result is shown *elsewhere* on the page has always needed a manual two-step — `await createPost(fd); await revalidate()` — and the second call is easy to forget, leaving a stale page that reads like a silent bug. There are now two runtime-only, opt-in ways to fold the second call in: an app-wide default via `configureRuactRuntime({ autoRevalidate: true })` (mirrors the existing `defaultHeaders` config), and a per-call `withRefresh(accessor)` wrapper (`await withRefresh(createPost)(fd)`) that forces the refresh for one call and **wins over** the global default. When opted in, a **successful, non-redirecting** mutation `await`s an in-place Flight refresh of the current path (the existing `revalidate()` machinery) **before** the promise resolves, then resolves with the action's JSON result. This is **pure client-side composition** of the two requests that already exist (the POST-JSON mutation + the GET-Flight refresh): **no new wire shape and no inbound Flight deserialization — the serialize-only invariant is untouched.** A `$redirect` response **wins**: the redirect is followed and the refresh is skipped (the destination already re-renders; refreshing the old path would be wrong). A **failed** mutation throws before any refresh runs, so no refresh happens. With no router installed, the descriptive `revalidate()` "wire `setupRouter()`" error surfaces (it is not swallowed) — so a mutation that succeeds but whose refresh rejects **rejects the returned promise**, consistent with `revalidate()`'s loud-by-default stance. Opt-out (default) behavior is **byte-identical** to before, and the generated `.ruact/server-functions.ts` is unchanged — `withRefresh` is imported directly from `ruact/server-functions-runtime` (like `configureRuactRuntime`), so the codegen output and the Ruby↔JS byte-parity fixtures are untouched.
|
|
88
140
|
|
|
89
|
-
- **Dev-only `[ruact]` log line naming the negotiated response shape per request (FR109)** (
|
|
141
|
+
- **Dev-only `[ruact]` log line naming the negotiated response shape per request (FR109)** (Story 15.5). The single most confusing thing about a `Ruact::Server` action is that the same action, at the same URL, answers different body shapes depending only on the request's `Accept` header + verb — never on the Ruby. In **development only**, `Ruact::Server` now emits exactly one `[ruact]` line per ruact-negotiated request naming the chosen bucket and the deciding signal — e.g. `[ruact] PostsController#create — Accept: application/json + POST → function-call JSON` — so the dual-bucket negotiation is observable at runtime without a debugger. It covers the function-call-JSON bucket (including its `204` and `$redirect` sub-shapes) and, for a page render on a controller that also includes `Ruact::Controller`, the Flight-page and HTML-shell shapes. Log-only: response body, status, and headers are byte-identical in every environment. It is SILENT outside development and on a plain Rails action on the same controller (a response ruact did not negotiate). Mirrors the F6 warning: a private, dev-gated `after_action` beside it, no new config key. The log vocabulary matches the new canonical caller→shape table in the server-actions docs. Coverage is `Ruact::Server`-only for now — queries (separate `GET /q` dispatch) and page-only controllers are documented in the table but not yet logged (deferred).
|
|
90
142
|
|
|
91
|
-
- **Public server-side render-assertion helpers: `have_ruact_component` (FR108)** (
|
|
143
|
+
- **Public server-side render-assertion helpers: `have_ruact_component` (FR108)** (Story 15.4). A host request/controller spec can now prove a page rendered a given component with given props against the decoded Flight tree — no browser, no brittle string spelunking on the wire bytes. Load it explicitly (`require "ruact/testing"` from your `spec_helper`/`rails_helper` — it is NOT auto-loaded by `require "ruact"`, so a production boot carries no RSpec dependency), then assert `expect(response).to have_ruact_component("PostList")` or `...have_ruact_component("PostList").with_props(a_hash_including("posts" => ...))`. It accepts an ActionDispatch/Rack response (its `.body` is read) or a raw String, in either page shape — a raw `text/x-component` body or the HTML shell embedding `__FLIGHT_DATA`. The component name resolves against the import row the wire actually carries (its export name or module basename); props are asserted in their serialized wire form (string keys, serialized values) via `===`/`hash_including`. Passing a `Ruact::Server` function-call/query response (plain JSON) raises a clear `Ruact::Testing::NotAFlightResponseError` pointing you at `JSON.parse(response.body)` instead. This is a STABLE public matcher that **wraps, not forks**, the internal Story-7.5 structural parser/diff — those were promoted onto the shipped load path as `Ruact::Testing::FlightWireParser` / `Ruact::Testing::FlightStructureDiff`, and the gem's own `Ruact::Spec` matchers now delegate to that single implementation (one source of truth, no drift). New docs "Testing" page covers all three response shapes (pages, function calls, queries).
|
|
92
144
|
|
|
93
|
-
- **Machine-readable introspection: `ruact:doctor -- --json` and `ruact:routes -- --json` (FR107)** (
|
|
145
|
+
- **Machine-readable introspection: `ruact:doctor -- --json` and `ruact:routes -- --json` (FR107)** (Story 15.3). A coding agent (or CI gate) can now read the two ground-truth surfaces as JSON instead of parsing terminal prose. `ruact:doctor -- --json` emits one document — `schema_version`, an overall `status`, and every health check as `name`/`status`/`message`/`remediation` (a separate, nullable machine-readable fix) — reusing the exact same check results the human path prints (no double-run) and preserving the exit code (0 when all checks pass or warn, 1 on any failure). The new `ruact:routes` task emits the accessor/route table — each entry's `accessor` name, `kind` (action or query), `verb`, `path`, `segments`, and declared `params` (queries from their keyword arguments, actions from their required path segments) — derived from the very same `RouteSource` + `QuerySource` collectors codegen consumes (a shared `Ruact::ServerFunctions.introspect` combine point), so the introspection can never drift from what codegen emits; it writes no bridge or TS file (a CI gate can run it without mutating the tree). Bare `ruact:routes` prints a compact human table. The default human `ruact:doctor` output is unchanged. Both JSON documents are marked EXPERIMENTAL and carry `schema_version: 0` — the shape may change without a major bump while the agent-facing surface is iterated, so parsers should gate on it. The flag is passed after a `--` separator (`bin/rails ruact:doctor -- --json`) so neither Rails nor Rake treats it as an option. The emitted `AGENTS.md`/`llms.txt` "Verify your work" section now references both commands, flagged EXPERIMENTAL.
|
|
94
146
|
|
|
95
|
-
- **Children inside a component tag now fail loudly at preprocess time instead of degrading silently (FR106)** (
|
|
147
|
+
- **Children inside a component tag now fail loudly at preprocess time instead of degrading silently (FR106)** (Story 15.2). ruact PascalCase component tags are self-closing only — a component receives a props Hash, never a children element tree — but the #1 predictable JSX-habit mistake, `<Card>Hello</Card>`, used to **degrade silently**: the opening tag became a `__ruact_component__("Card", {})` call with no children while the inner content leaked into the surrounding HTML and the `</Card>` passed through as literal garbage. The ERB preprocessor now raises a dedicated `Ruact::ChildrenNotSupportedError` (a `Ruact::PreprocessorError`, so it flows through the same dev error overlay) naming the component, the template `file:line`, and the exact fix — pass content as a prop, e.g. a self-closing `<Card content={...} />`. Detection keys on a **matching PascalCase closing tag** (`</Card>`), NOT on "opening tag lacks `/>`", so a bare non-self-closing opening with no closing tag (`<Dialog open={true}>`) stays a valid call. It runs **after** Suspense normalization (Step 1), so `<Suspense>...</Suspense>` — the one legitimate paired PascalCase tag, already lowercased to `<ruact-suspense>` — never trips. Output is byte-identical for every valid pattern. The shipped `AGENTS.md`/`llms.txt` trap #1 wording is updated from "fails silently" to "fails loudly" now that the artifact exists.
|
|
96
148
|
|
|
97
|
-
- **Dev-only `[ruact]` warning when an action registers `ruact_errors` and then renders explicitly on a function-call request (F6)** (
|
|
149
|
+
- **Dev-only `[ruact]` warning when an action registers `ruact_errors` and then renders explicitly on a function-call request (F6)** (Story 15.0). The FR98 `errors` injection rides the implicit `default_render`, so an explicit `render json: {...}` on the failed-save branch of a Bucket-2 (function-call) request silently drops the registered validation errors from the JSON body — a legibility trap for agents following the docs literally. In **development only**, `Ruact::Server` now logs exactly one `[ruact]` warning naming the `controller#action`, explaining the opt-out, and pointing at the fix (fall through, bind `errors={ruact_errors}` on a page render, or `redirect_to`). It never fires on the documented-correct patterns: a Bucket-1 `ruact_errors(record); render :new` page render, a `redirect_to` on either bucket, the fall-through itself, or an action that never touches the collector. Log-only — response bodies and statuses are byte-identical in every environment.
|
|
98
150
|
|
|
99
|
-
- **`rails generate ruact:install` now emits an `AGENTS.md` teaching coding agents the ruact conventions, traps, and verification commands (FR105)** (
|
|
151
|
+
- **`rails generate ruact:install` now emits an `AGENTS.md` teaching coding agents the ruact conventions, traps, and verification commands (FR105)** (Story 15.1). ruact is out-of-distribution for every LLM, so a fresh app now ships the agent context in-repo: the mental model (Rails routes as the single source of truth; ERB as the server component; PascalCase self-closing tags), the verb rule, queries/`useQuery`, the codegen ground truth (`app/javascript/.ruact/server-functions.ts` + `bin/rails ruact:server_functions:generate`), the five traps (children, Ruby-in-braces, Accept-header dual shape, `ruact_errors` fall-through, name derivation), the `ruact_props`/`strict_serialization` allowlist, the SGID helpers, and the verification loop (`bin/rails ruact:doctor`). The emitted content is delimited by `<!-- ruact:begin -->`/`<!-- ruact:end -->` markers and the new `create_agents_md` action is **idempotent and append-aware**: no file → create; a user-authored `AGENTS.md` without markers → the marked section is appended with every pre-existing byte preserved; markers present → skip (re-running install is zero-diff); `--force` refreshes **only** the between-marker content, never user bytes outside it. The same compact reference is served by the docs site at `https://ruact.dev/llms.txt` (kept in sync editorially). New `templates/AGENTS.md.tt` + `:story_15_1` specs (including a ≤160-line template-budget tripwire).
|
|
100
152
|
|
|
101
153
|
## [0.0.7] - 2026-06-30
|
|
102
154
|
|
|
@@ -117,53 +169,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
117
169
|
|
|
118
170
|
### Changed
|
|
119
171
|
|
|
120
|
-
- **`ruact:install` emits the launch files so `bin/dev` boots a working app — `package.json` + `Procfile.dev` + foreman `bin/dev` (FR101, Epic 14 DoD)** (
|
|
172
|
+
- **`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). 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`.
|
|
121
173
|
|
|
122
|
-
- **`ruact:scaffold --shadcn` — shadcn/ui is now an explicit OPT-IN, reusing the Epic 10 components + the (now opt-in) dependency pre-flight (FR103b)** (
|
|
174
|
+
- **`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). 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.**
|
|
123
175
|
|
|
124
|
-
- **`ruact:scaffold` default is design-system-AGNOSTIC — no shadcn prerequisite, no mandatory pre-flight/abort (FR103)** (
|
|
176
|
+
- **`ruact:scaffold` default is design-system-AGNOSTIC — no shadcn prerequisite, no mandatory pre-flight/abort (FR103)** (Story 14.4). 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.
|
|
125
177
|
|
|
126
|
-
- **`ruact:scaffold` is self-contained — it generates the model, migration, route and host-framework tests by delegating to Rails `resource` (FR102)** (
|
|
178
|
+
- **`ruact:scaffold` is self-contained — it generates the model, migration, route and host-framework tests by delegating to Rails `resource` (FR102)** (Story 14.3). `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.
|
|
127
179
|
|
|
128
|
-
- **ruact's JS plumbing is hidden — `app/javascript/` is now your code only (FR104)** (
|
|
180
|
+
- **ruact's JS plumbing is hidden — `app/javascript/` is now your code only (FR104)** (Story 14.2). 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).
|
|
129
181
|
|
|
130
|
-
- **`ruact:install` is one command — it now runs `npm install` (FR101)** (
|
|
182
|
+
- **`ruact:install` is one command — it now runs `npm install` (FR101)** (Story 14.1). 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).
|
|
131
183
|
|
|
132
|
-
- **`ruact:scaffold` List → dep-free shadcn `table` primitive (drops `@tanstack/react-table`) (FR81)** (
|
|
184
|
+
- **`ruact:scaffold` List → dep-free shadcn `table` primitive (drops `@tanstack/react-table`) (FR81)** (Story 10.2b). 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`.
|
|
133
185
|
|
|
134
186
|
### Added
|
|
135
187
|
|
|
136
|
-
- **`ruact:scaffold` shadcn dependency pre-flight — detect / guide / version-compat (FR81)** (
|
|
188
|
+
- **`ruact:scaffold` shadcn dependency pre-flight — detect / guide / version-compat (FR81)** (Story 10.5). `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`.
|
|
137
189
|
|
|
138
|
-
- **`ruact:scaffold` DeleteDialog → controlled shadcn AlertDialog + in-list removal (FR81)** (
|
|
190
|
+
- **`ruact:scaffold` DeleteDialog → controlled shadcn AlertDialog + in-list removal (FR81)** (Story 10.4). 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.
|
|
139
191
|
|
|
140
|
-
- **`ruact:scaffold` Form → shadcn controls mapped by attribute type (FR81)** (
|
|
192
|
+
- **`ruact:scaffold` Form → shadcn controls mapped by attribute type (FR81)** (Story 10.3). 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.
|
|
141
193
|
|
|
142
|
-
- **`ruact:scaffold` List → shadcn DataTable + client-driven search query (FR81)** (
|
|
194
|
+
- **`ruact:scaffold` List → shadcn DataTable + client-driven search query (FR81)** (Story 10.2). 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` payload — server-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`.
|
|
143
195
|
|
|
144
|
-
- **Component auto-registry — the build emits `MODULE_REGISTRY`, no hand-maintained registration** (
|
|
196
|
+
- **Component auto-registry — the build emits `MODULE_REGISTRY`, no hand-maintained registration** (Story 10.1b). 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 the scaffold playground onto the shipped path.
|
|
145
197
|
|
|
146
|
-
- **`rails generate ruact:scaffold` — a complete CRUD skeleton on the v2 contract (FR81)** (
|
|
198
|
+
- **`rails generate ruact:scaffold` — a complete CRUD skeleton on the v2 contract (FR81)** (Story 10.1). `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`.
|
|
147
199
|
|
|
148
|
-
- **Compile-time component contract — HEEx-style `attr`/`slot` call-site validation (FR100)** (
|
|
200
|
+
- **Compile-time component contract — HEEx-style `attr`/`slot` call-site validation (FR100)** (Story 13.5). 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).
|
|
149
201
|
|
|
150
|
-
- **Typed query accessors — `codegen_v2` emits real `params` types (FR99)** (
|
|
202
|
+
- **Typed query accessors — `codegen_v2` emits real `params` types (FR99)** (Story 13.4). 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 defaults — so 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 boundary — both 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).
|
|
151
203
|
|
|
152
|
-
- **Inertia-style validation `errors` round-trip — `ruact_errors(record)`** (
|
|
204
|
+
- **Inertia-style validation `errors` round-trip — `ruact_errors(record)`** (Story 13.3). 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).
|
|
153
205
|
|
|
154
|
-
- **Signed, scoped, expiring record references — `Ruact.signed_global_id` / `Ruact.locate_signed`** (
|
|
206
|
+
- **Signed, scoped, expiring record references — `Ruact.signed_global_id` / `Ruact.locate_signed`** (Story 13.2). 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).
|
|
155
207
|
|
|
156
|
-
- **`useQuery` request de-duplication** (
|
|
208
|
+
- **`useQuery` request de-duplication** (Story 9.6). 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`.
|
|
157
209
|
|
|
158
|
-
- **Queries in codegen + `useQuery` hook + FR88 kwargs sanitization** (
|
|
210
|
+
- **Queries in codegen + `useQuery` hook + FR88 kwargs sanitization** (Story 9.5). 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).
|
|
159
211
|
|
|
160
|
-
- **`Ruact::Query` base class + `ruact_queries` route macro** (
|
|
212
|
+
- **`Ruact::Query` base class + `ruact_queries` route macro** (Story 9.4). 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`.
|
|
161
213
|
|
|
162
|
-
- **Route-driven codegen for mutations + runtime re-target** (
|
|
214
|
+
- **Route-driven codegen for mutations + runtime re-target** (Story 9.3). 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.
|
|
163
215
|
|
|
164
|
-
- **Dual-bucket response negotiation on the same controller action** (
|
|
216
|
+
- **Dual-bucket response negotiation on the same controller action** (Story 9.2). 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`).
|
|
165
217
|
|
|
166
|
-
- **`Ruact::Server` concern** (
|
|
218
|
+
- **`Ruact::Server` concern** (Story 9.1). `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`).
|
|
167
219
|
|
|
168
220
|
- **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.
|
|
169
221
|
|
|
@@ -171,19 +223,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
171
223
|
|
|
172
224
|
### Removed
|
|
173
225
|
|
|
174
|
-
- **The v1 server-functions substrate** (
|
|
226
|
+
- **The v1 server-functions substrate** (Story 9.9). 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`.
|
|
175
227
|
|
|
176
228
|
### Security
|
|
177
229
|
|
|
178
|
-
- **Tamper-proof record references — the structural antidote to forged-reference attacks** (
|
|
230
|
+
- **Tamper-proof record references — the structural antidote to forged-reference attacks** (Story 13.2). 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).
|
|
179
231
|
|
|
180
|
-
- **Serialize-only invariant — guarded by `ruact:doctor`** (
|
|
232
|
+
- **Serialize-only invariant — guarded by `ruact:doctor`** (Story 13.1). 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).
|
|
181
233
|
|
|
182
234
|
### Fixed
|
|
183
235
|
|
|
184
|
-
- **`default_render` degrades gracefully on non-HTML `Accept` (no more 500 on `*/*`)** (
|
|
236
|
+
- **`default_render` degrades gracefully on non-HTML `Accept` (no more 500 on `*/*`)** (Story 10.0; 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.
|
|
185
237
|
|
|
186
|
-
- **`Ruact::Controller#ruact_render` now renders successfully under Rails 8** (
|
|
238
|
+
- **`Ruact::Controller#ruact_render` now renders successfully under Rails 8** (Story 7.9; 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 — the underlying fix is mechanically the same.)
|
|
187
239
|
|
|
188
240
|
- **`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).
|
|
189
241
|
|
|
@@ -196,7 +248,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
196
248
|
|
|
197
249
|
### Renamed
|
|
198
250
|
|
|
199
|
-
- **`rsc_*` public API surface fully migrated to `ruact_*`** (
|
|
251
|
+
- **`rsc_*` public API surface fully migrated to `ruact_*`** (Story 5.12; **BREAKING**). Pre-v0.1.0 clean cut to eliminate the residual `rsc_` prefix from Phase 1's pre-rename era so Epic 8 (server actions), Epic 9 (server queries), and Epic 10 (scaffold) inherit a clean substrate. No aliases, no deprecation bridge — host code must rename in lockstep.
|
|
200
252
|
|
|
201
253
|
| Surface | Was | Now |
|
|
202
254
|
| --- | --- | --- |
|
|
@@ -218,27 +270,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
218
270
|
### Internal
|
|
219
271
|
|
|
220
272
|
- 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.
|
|
221
|
-
- **Render context now passed explicitly** (
|
|
222
|
-
- **`RenderPipeline` entry points consolidated** (
|
|
273
|
+
- **Render context now passed explicitly** (Story 7.1). `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 for rationale and contributor guidance.
|
|
274
|
+
- **`RenderPipeline` entry points consolidated** (Story 7.2). `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 for rationale and contributor guidance.
|
|
223
275
|
|
|
224
276
|
_Migration for any external code that may have reached into `Ruact::RenderPipeline`:_ `pipeline.call(erb, binding)` → `pipeline.render({ erb: erb, binding: binding }, mode: :string)`; `pipeline.stream(erb, binding)` → `pipeline.render({ erb: erb, binding: binding }, mode: :stream)`; `pipeline.from_html(html, render_context: ctx)` → `pipeline.render({ html: html, render_context: ctx }, mode: :string)`; `pipeline.from_html(html, render_context: ctx, streaming: true)` → `pipeline.render({ html: html, render_context: ctx }, mode: :stream)`.
|
|
225
277
|
|
|
226
|
-
- **`Ruact::Configuration` is frozen after initialization** (
|
|
278
|
+
- **`Ruact::Configuration` is frozen after initialization** (Story 7.3). The `Ruact::Configuration` instance returned by `Ruact.config` is frozen the moment `Ruact.configure { |c| ... }` returns (or, if no `configure` block is called, on first access). Mutating attributes outside the `configure` block (e.g. `Ruact.config.foo = bar`) now raises `Ruact::ConfigurationError` — a new error class, subclass of `Ruact::Error` — with a message naming the offending attribute, the caller's file:line, and the suggested fix. Calling `Ruact.configure` a second time after boot replaces the configuration atomically (the new draft is fully assembled, frozen, then swapped — partial reconfiguration is impossible) and emits a `[ruact]` warning advising that runtime re-configuration is unusual. **No public API change** — the `Ruact.configure { |c| ... }` DSL is unchanged, every existing reader returns the same value as before, and the `rails generate ruact:install` template still works as-is. Note: the gem's own internal RSpec stubs against `Ruact.config` (`render_pipeline_spec.rb`, `flight/renderer_spec.rb`) were migrated to `Ruact.configure { |c| c.attr = ... }` blocks because RSpec mocks cannot proxy frozen objects in MRI Ruby — this affects only the gem's test suite, not host applications. See decision note for rationale and contributor guidance.
|
|
227
279
|
|
|
228
280
|
_Migration for any external code that mutated `Ruact.config` outside `Ruact.configure`:_ replace any post-boot `Ruact.config.foo = bar` with a `Ruact.configure { |c| c.foo = bar }` block in `config/initializers/ruact.rb`. If the change must be conditional on environment, branch inside the block. For test scenarios that legitimately need to swap config per-example, prefer `Ruact.configure { |c| c.foo = ... }` with a `before`/`around` reset hook (`Ruact.instance_variable_set(:@config, nil)`); RSpec stubs (`allow(Ruact.config).to receive(...)`) no longer work because the underlying object is frozen.
|
|
229
281
|
|
|
230
|
-
- **`Ruact::HtmlConverter.convert` validates inputs at the boundary** (
|
|
282
|
+
- **`Ruact::HtmlConverter.convert` validates inputs at the boundary** (Story 7.4). The class-method entry point now raises `Ruact::HtmlConverterError` (new — subclass of `Ruact::Error`) when its `html` argument is not a `String`. The validation runs before Nokogiri is invoked, so the caller's file:line appears at the top of the backtrace with a clear ruact-named error rather than a `NoMethodError` for `:children` deep in Nokogiri internals. The most common upstream bug — an ERB template, partial, or render path that returned `nil` — now surfaces with a "Most likely cause" hint pointing at the call site. Separately, `Ruact::ClientManifest#reference_for` enhances its existing `Ruact::ManifestError` message with a Damerau-Levenshtein closest-match suggestion (e.g. `Did you mean "LikeButton"?` for a typo'd `LikeButtonn`), with a fallback hint suggesting the file path to add when no entry within distance 2 exists; passing `controller_path:` biases the suggestion toward co-located keys. **No public API change** — the public signature `html, registry = []` is unchanged for valid inputs, every existing spec continues to pass, and the `ManifestError` raised on unknown components is the same class with an enhanced message. See decision note for rationale and contributor guidance.
|
|
231
283
|
|
|
232
284
|
_Migration:_ any application code that previously rescued `NoMethodError` or `Nokogiri::XML::SyntaxError` from a path that flowed through the HTML converter (an unusual pattern; not documented as a public contract) should now rescue `Ruact::HtmlConverterError` for the nil/non-String case. The unresolved-component case continues to raise `Ruact::ManifestError` (same class, enhanced message); rescue patterns matching `/not found in manifest/` continue to work, and rescue patterns matching `/Did you run the Vite build\?/` continue to work. No host application is expected to need changes — the new validation only fires on inputs that already failed in Phase 1, just with worse error messages.
|
|
233
285
|
|
|
234
|
-
- **Flight wire test matchers extended with structural modes** (
|
|
286
|
+
- **Flight wire test matchers extended with structural modes** (Story 7.5). `gem/spec/support/matchers/flight_fixture_matcher.rb` gains two new RSpec matcher modes alongside the existing `match_flight_fixture(name)` snapshot matcher: `match_flight_structure(expected)` parses the actual wire output via a new `Ruact::Spec::FlightWireParser` and compares against an array of row records (hash payloads compare structurally — JSON key reordering does not break the spec); `include_flight_row(predicate)` asserts at least one parsed row satisfies a subset-match predicate (supports `hash_including`, `array_including`, etc. via case-equality). Failure messages are row-indexed and name the differing field path. **Test-only change; no production code touched** — `gem/lib/` is unchanged. The new `Ruact::Spec::*` namespace is established as the canonical home for spec-only utilities; it is **not** part of the public API and may change between minors. Default for new wire tests should be `match_flight_structure`; reserve `match_flight_fixture` for tests where the wire bytes themselves are the contract (e.g. `string_dollar_escape`). See decision note for rationale and contributor guidance. Story 7.6 will migrate ~31 existing string-matching specs in a follow-up.
|
|
287
|
+
|
|
288
|
+
- **Phase 1 Flight wire-asserting specs migrated to structural matchers** (Story 7.6). All Phase 1 specs that asserted on Flight wire output via regex / `include` / string equality have been migrated to the three Story 7.5 matcher modes (`match_flight_fixture`, `match_flight_structure`, `include_flight_row`). The migration covered 4 spec files (`flight/serializer_spec.rb`, `flight/renderer_spec.rb`, `render_pipeline_spec.rb`, `render_pipeline_concurrency_spec.rb`) routed by an explicit decision tree (A: byte-exact contract → fixture; B: multi-row shape → structure; C: presence → row; D: invariant on parsed rows; E: byte-determinism / non-wire-format → preserved). Five new fixtures added under `spec/fixtures/flight/` for previously-uncovered scalars (`bigint`, `nan`, `infinity`, `negative_infinity`, `undefined`). Three sites preserved as Decision E with explanatory comments (byte-determinism positive/negative + Suspense error-message text). Cosmetic JSON changes (key reordering, whitespace) no longer break unrelated specs; semantic regressions surface with row-indexed structural diffs. **Test-only change; no production code touched.** Pre-migration: 347 examples / 0 failures / 0 RuboCop offenses. Post-migration: 339 examples / 0 failures / 0 RuboCop offenses. AC2 grep returns zero brittle wire-fragment assertions outside the 3 documented Decision E sites. See decision note.
|
|
235
289
|
|
|
236
|
-
-
|
|
290
|
+
- Story 7.8: Playground demo architecture-checks page + Rake task verifying Epic 7 invariants (Story 7.8). Added a checks engine, a Rake umbrella with four sub-tasks under `demo:check:*`, and an HTTP twin at `/architecture-checks`, all in the demo playground, and reorganized the playground README's "Architecture verification" section. The four checks gate, from outside the gem: render-context isolation under N=50 parallel renders (Story 7.1), Configuration immutability with the documented `Ruact::ConfigurationError` message shape (Story 7.3), HtmlConverter input validation for nil / non-String / unknown component (Story 7.4), and Phase 1 transparency via structural Flight equivalence on the Counter demo (Stories 7.1–7.7). Playground-only — `gem/lib/` and `gem/spec/` are unchanged. The story surfaced two latent issues escalated for follow-up: (a) the demo's `Gemfile` previously pinned `gem "ruact"` to RubyGems v0.0.2 (Phase 1) instead of the workspace submodule, so prior playground-based verifications including the Story 7.1 soak ran against pre-Epic-7 code — fixed in this story by switching to `path: "../../gem"`; and (b) the controller's render-context ivar was set on `view_context`, but Rails 8's `render_to_string` uses a different `ActionView::Base` instance so the ivar was never visible to the view helper — every demo request 500-ed with the "outside a flow" error; check #4 was marked `:pending` until Story 7.9 fixed the gem-side wiring. (Originally written using the pre-rename `rsc_*` method/helper names; rewritten here in Story 5.12 to match the post-rename surface.)
|
|
291
|
+
- Story 7.7: Codified five code-review edge cases as regression specs (Story 7.7) — render context re-entry on a shared binding receiver (post-7.1 ensure restoration on inner-render-raises, 3-level nesting, sequential receiver reuse), `HtmlConverter.convert(nil)` and non-String input smoke specs cross-referencing the Story 7.4 detailed coverage, ERB rendered against a binding with zero instance variables (3 specs), and the `as_json`-returns-self message-shape contract (offending class name + `Ruact::Serializable` + `ruact_props`). Nine new specs co-located under the `:story_7_7` rspec tag and the `"Story 7.7"` describe substring for one-command suite execution (`bundle exec rspec --tag story_7_7`). Test-only — no production change under `gem/lib/`. `.rubocop.yml` extends `Naming/VariableNumber` with `AllowedPatterns: ['\bstory_\d+_\d+\b']` — the exemption matches only the canonical `:story_X_Y` story-tag symbol shape; any other snake_case-with-numbers under `spec/` continues to trip the cop. p99 wall-clock for the tag: 0.0154 s (gate: < 1 s). See decision note.
|
|
237
292
|
|
|
238
|
-
|
|
239
|
-
- Story 7.7: Codified five code-review edge cases as regression specs ([Story 7.7](../_bmad-output/implementation-artifacts/7-7-edge-case-test-coverage-from-code-review.md)) — render context re-entry on a shared binding receiver (post-7.1 ensure restoration on inner-render-raises, 3-level nesting, sequential receiver reuse), `HtmlConverter.convert(nil)` and non-String input smoke specs cross-referencing the Story 7.4 detailed coverage, ERB rendered against a binding with zero instance variables (3 specs), and the `as_json`-returns-self message-shape contract (offending class name + `Ruact::Serializable` + `ruact_props`). Nine new specs co-located under the `:story_7_7` rspec tag and the `"Story 7.7"` describe substring for one-command suite execution (`bundle exec rspec --tag story_7_7`). Test-only — no production change under `gem/lib/`. `.rubocop.yml` extends `Naming/VariableNumber` with `AllowedPatterns: ['\bstory_\d+_\d+\b']` — the exemption matches only the canonical `:story_X_Y` story-tag symbol shape; any other snake_case-with-numbers under `spec/` continues to trip the cop. p99 wall-clock for the tag: 0.0154 s (gate: < 1 s). See [decision note](../_bmad-output/decisions/2026-05-edge-case-regression-suite.md).
|
|
293
|
+
## Phase 1 — the work that predates the first published version (2026-03-24)
|
|
240
294
|
|
|
241
|
-
|
|
295
|
+
> Not a release, and deliberately not a version heading. This section records what the
|
|
296
|
+
> project could already do before anything of it was published; the first versions on
|
|
297
|
+
> RubyGems were 0.0.1 and 0.0.2, in April 2026.
|
|
298
|
+
>
|
|
299
|
+
> The entries below are left as they were written, so they describe the plan of the time —
|
|
300
|
+
> including a `vite-plugin-ruact` published to npm and co-versioned with the gem. That never
|
|
301
|
+
> became how ruact ships: the plugin is vendored inside the gem and published nowhere. See
|
|
302
|
+
> `RELEASING.md` for the release this project actually performs.
|
|
242
303
|
|
|
243
304
|
### Added
|
|
244
305
|
|
|
@@ -259,7 +320,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
259
320
|
- **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.
|
|
260
321
|
- **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
|
|
261
322
|
|
|
262
|
-
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.
|
|
323
|
+
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.12...HEAD
|
|
324
|
+
[0.0.12]: https://github.com/luizcg/ruact/releases/tag/v0.0.12
|
|
263
325
|
[0.0.11]: https://github.com/luizcg/ruact/releases/tag/v0.0.11
|
|
264
326
|
[0.0.10]: https://github.com/luizcg/ruact/releases/tag/v0.0.10
|
|
265
327
|
[0.0.9]: https://github.com/luizcg/ruact/releases/tag/v0.0.9
|