Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.0.12 - 2026-09-09
Section titled “0.0.12 - 2026-09-09”-
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.rbafter the merge, and committed back tomainwith[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.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
releasejob asks one question on every push tomain— doeslib/ruact/version.rbname 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 tomain, only a tag. -
Two gates on every pull request, and the same two runnable locally.
bin/release-gateasks whether this branch changed anything that goes inside the published gem and, if it did, requiresCHANGELOG.mdto have gained something — a bullet or a heading, with touching the file not being enough. Separately, and only whenlib/ruact/version.rbmoved, it requires the new version to be exactly one semver step, to carry no tag already, and to be matched byGemfile.lock. Nothing forces a bump: a merge without one accumulates under[Unreleased]until somebody decides to release. -
bin/release X.Y.Zprepares a release, and generates its own checklist. It refuses a dirty checkout, a checkout that is notmainexactly 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. -
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 prescribedThread.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 apath: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 supersededvite-plugin-ruactnpm package so nobody mistakes it for one.spec/contributing_spec.rbkeeps it honest, in the shapespec/readme_spec.rbarrived 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.ymlrather 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. -
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.
-
RELEASING.mdnow 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-editlib/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. -
Two gates keep both documents honest.
spec/releasing_spec.rbreads the release trigger out of.github/workflows/ci.ymlrather 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 withspec/contributing_spec.rbthrough a newspec/support/markdown_gate.rb— refuses private-side paths and the claim that any check gates merging.spec/changelog_spec.rbis the first check this file has ever had. It asserts the record’s shape against itself and againstRuact::VERSION, which is enough to have caught the defects listed below. -
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.
-
## [0.1.0]was a release that never happened. It had no tag, no link reference, nothing on RubyGems, sat below the0.0.xentries, and was dated a month before the first publish — while0.1.0is 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.
Changed
Section titled “Changed”-
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:childrenpassed as an explicit prop on a self-closing tag —<Label children={["hi"]} />— used to arrive as"hi"and now arrives as["hi"].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.This was taken deliberately rather than compensated for. The alternative was to keep collapsing
childrenalone, 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. -
The published gem stopped shipping the repository.
spec.fileswasgit ls-filesminus four entries, so the whole ofspec/— about two fifths of the tracked tree — plus.github/,bench/,docs/,Rakefile,Gemfile.lockand the RuboCop configuration travelled inside every.gemanybody 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.bindirandspec.executablesare gone with it — this gem has never had anexe/directory. -
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 namesruact_propsas the thing that makes it an allowlist andstrict_serializationas what turns the permissive path into an error in production. -
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 areRecord<string, unknown>andunknown— useful, and not the autocomplete the word “typed” promises. Both are now described as what they are. -
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.
-
A list with exactly one row rendered as nothing. A client component given a collection prop —
<PostList posts={rows} />, the shaperails generate ruact:scaffoldemits — 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.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.
Arity is now preserved wherever an array appears, at every depth — see Arrays keep their length, including
childrenunder 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.jswas 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.
0.0.11 - 2026-08-25
Section titled “0.0.11 - 2026-08-25”Changed
Section titled “Changed”-
The README’s first sentence shows the tag instead of naming its casing convention. It read “Write a PascalCase tag in ERB, pass
@postas a prop” — which put a mechanism in the slot where the reader is still asking what they get. It also worked against the thing it was selling: for anyone arriving from React, a capitalized component tag is not a new rule, it is exactly how JSX already behaves, so naming the convention turned the familiar into a ritual to comply with. The line now shows<LikeButton likes={@likes} />and lets the capital letter speak for itself, with the Ruby value passed in demonstrating the no-serializer claim the sentence used to only assert. TheERB as server componentsbullet states the same mechanism one beat later, where it lands as reassurance rather than a requirement: capitalized is React, lowercase stays HTML.The Concepts pages and the API reference are deliberately unchanged — a reader there is asking exactly how resolution works, and naming the convention is the right answer in that position.
- The render-pipeline allocation baseline was still the one written on the first commit.
spec/benchmarks/baseline.jsonhad held April’s numbers (1623 typical / 7764 heavy) since the initial commit, so the ×1.20 tolerance was being measured against a pipeline that predates route-driven codegen, contract validation and error suggestion. Four months of that work grew the typical render by 10.4% — legitimate growth, not a regression from any one change — and it finally crossed the limit. Because a single global baseline also has to absorb the ~9% spread between matrix cells (Ruby 3.4 / Rails 8.1 measures 1792 where Ruby 3.3 / Rails 7.1 measures ~1958), the overshoot surfaced as one intermittently red cell while twelve stayed green — which reads as flakiness and is not: rerunning did not fix it. Rebaselined to today’s measurement, with April’s numbers kept in a_historykey rather than overwritten, and the spec now documents how to tell a regression from drift and how to regenerate the file.
0.0.10 - 2026-08-25
Section titled “0.0.10 - 2026-08-25”Changed
Section titled “Changed”-
README.md— the gem’s public face is no longerbundle gemboilerplate. The file GitHub renders ongithub.com/luizcg/ruact, the filespec.filespackages inside the built.gem, and the destination ofsource_code_uristill said “TODO: Delete this and the text below”, told the reader tobundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG, and pointed contributors athttps://github.com/[USERNAME]/ruact. It is now a first-contact README for v0.0.9: the four-command greenfield quick start (the same sequence the site’s landing puts on the clipboard), the ERB-tag/"use client"pair,include Ruact::Serverwith its generated typed accessor, a list where every bullet names a shipped artifact, a compatibility table sourced from the CI matrix rather than from optimism, and links out toruact.devinstead of second copies of the guides.It also carries the AI-tools section deferred here from the docs work — the one-requirement rule (
"use client"at the top; a PascalCase named export is the thing to check rather than add), the four-step walkthrough, and a pointer to the canonical page — plus one line naming the shipped agent context: theAGENTS.mdrails generate ruact:installwrites,ruact.dev/llms.txt, and the experimental-- --jsonoutput ofruact:doctor/ruact:routes.Two gates now watch it, because nothing did before:
spec/readme_spec.rbruns inside this repo’s ownrspecjob (no boilerplate literals, every relative link resolves on disk, nopath:gem source a reader could copy, and the quick-start block pinned literally), and the monorepo’s command-spine check treatsREADME.mdas a declared artifact of the one canonical greenfield sequence.
-
A demo at the top of
README.md— the write→verify loop, recorded rather than described. The README could argue the mechanism in prose and code but could not show what happens when the boundary is used wrong, which is the part a reader of “React rendered from Rails templates” actually doubts. The recording is one loop: an ERB template with<LikeButton likes={@likes} />beside the"use client"component it resolves to; the component rendering in a browser, its count changing on click; children put inside the tag — the JSX habit — and the next request stopping server-side, while the template is being processed, before anything reaches the browser, withRuact::ChildrenNotSupportedErrornaming the component, the template file and line, and the fix; then the children removed and the page rendering again. Every frame is a capture of the gem running; the loud error is the one shipped in 0.0.9.It is referenced by absolute URL and nothing binary is committed here — so neither
gem install ruactnor a clone of this repository carries a multi-megabyte file, permanently, for a picture. Two things keep it from aging into a lie: the URL belongs to the site’s URL contract, andspec/readme_demo_message_spec.rbpins the message the recording shows against a fixture the gem itself produced. If the wording changes, that spec goes red and names the recording as the thing to redo. -
LICENSE.txt— the gemspec has declaredspec.license = "MIT"since the first commit while the repository contained no licence file, so both GitHub and the packaged gem shipped a promise with nothing behind it. -
spec.descriptionandmetadata["documentation_uri"]in the gemspec — RubyGems rendered the one-linesummaryas the whole description and fell through to rubydoc.info for documentation.
SECURITY.mdnamed a version that has never existed. Its supported-versions table listed0.1.x; the released gem is0.0.9. Corrected to0.0.x.
0.0.9 - 2026-08-12
Section titled “0.0.9 - 2026-08-12”-
rails generate ruact:install --shadcn— the--shadcnscaffold path now has a working setup.ruact:scaffold --shadcnemits components dressed in Tailwind classes, but nothing in ruact ever wired Tailwind, so the generated CRUD rendered unstyled: the classes had nothing to resolve against. Worse, the documented next step did not work either — shadcn’s own CLI refuses to initialize in a ruact app, aborting with “No Tailwind CSS configuration found” and “Could not find valid path aliases”, because a fresh ruact app ships neither Tailwind nor atsconfig.json.The new flag emits exactly those prerequisites, each verified against the real shadcn CLI:
app/javascript/styles/globals.css(the Tailwind entry shadcn appends its design tokens to, and pointscomponents.jsonat), atsconfig.jsoncarrying the@/*→app/javascript/*alias (the bundled Vite plugin already registers the same alias for the bundler; this is what makes it resolve for TypeScript, and therefore for shadcn’s probe and your editor),app/assets/builds/with the compiled stylesheet gitignored, thebuild:cssscript, and acssprocess inProcfile.devsobin/devrebuilds the stylesheet alongside Rails and Vite.It then prints the two
npx shadcncommands instead of running them — they hit the network, andshadcn initis interactive, so automating them is neither safe (the scaffold generator’s “never auto-run npx/npm” rule) nor possible. The printedinitline pins--base radix, which is the part nobody would guess: current shadcn defaults to Base UI, while the componentsruact:scaffold --shadcngenerates import Radix primitives — accepting the default gets you a component library the scaffold cannot use. The printedaddlist is the complete primitive superset, pinned by a spec as a superset of whatever a given resource narrows to, so the two generators cannot drift.The default (agnostic) path is untouched — no Tailwind, no
tsconfig.json, and a byte-identicalpackage.jsonandProcfile.dev, all pinned by specs.Verified end to end twice from
rails new: the generated CRUD renders as a styled shadcn table with theuseQuerysearch filtering rows live. Note that shadcn’s CLI is currently 4.x whileshadcn_compatible_versionsdefaults to[1, 2], so the scaffold’s version pre-flight emits its (non-blocking) warning; the generated components do work against 4.x.
-
A ruact page can now carry your app’s CSS — the Rails layout owns the document.
ruact_renderrendered the view withlayout: falseand then wrapped the Flight payload in a hardcoded HTML shell whose<head>holds onlycharset,viewport, the CSRF meta tag and the titleRails RSC. That shell has no stylesheet slot and never uses your layout, sostylesheet_link_tag— and with it favicons, fonts, analytics and every<head>-writing gem — could not reach a ruact-rendered page at all. The visible consequence:rails generate ruact:scaffold --shadcnemitted components whose Tailwind classes had nothing to resolve against, so the generated CRUD was unstyled by construction; the docs’ instruction to put shadcn theme variables inapp/assets/stylesheets/application.csspointed at a file that provably never reached the browser; and Epic 12 (ruact_meta→ tags in<head>) had no surface to write into. The default agnostic scaffold was affected too — it renders plain semantic HTML and only looked acceptable because browsers style a bare<table>.A non-Flight HTML response is now rendered through the host app’s own layout, with the React root’s bootstrap tags supplied by the layout’s
<%= ruact_js_assets %>call (which, called with no argument, picks up the render’s Flight payload). The Flight wire shape (text/x-component) is untouched — this only changes the full document a browser gets on a normal navigation.Controlled by the new
Ruact.config.layout—falseby default,true(or a layout name) to opt in.rails generate ruact:installnow writes both halves of that opt-in in the same run:config.layout = truein the generated initializer, and<%= ruact_js_assets %>next to the React root in your layout.The default is
false, so an existing app is untouched until it opts in — not because ruact detects anything, but because it does not look. An earlier cut of this change tried to infer whether your layout was ready by inspecting it; three review rounds each found another template shape that fooled the inference (a mention in a comment, a commented-out call, a trim-mode comment), and each wrong answer decided how every page in the app rendered. “Does this template call this method?” is not a question pattern-matching can answer reliably, so it is no longer asked. One explicit line beats a clever guess for a setting this load-bearing. (:autois rejected with a message naming its replacement, so an initializer carrying it forward cannot be silently reinterpreted.)Once opted in, a layout that cannot mount the app is a configuration error rather than a silent blank page: ruact checks the rendered document for both the assets block and a
<div id="root">, raising in development/test and logging-and-degrading to the shell in production. A controller with no resolvable layout (API-shaped, orlayout false) degrades quietly instead — that is a normal Rails choice, not a mistake.To migrate an existing app: add
<%= ruact_js_assets %>next to the<div id="root"></div>inapp/views/layouts/application.html.erb, or re-runrails generate ruact:install(which now injects both, and adds only the missing call to a layout that already has the root — tolerating single quotes, extra attributes and a same-line marker, and telling you loudly if it cannot find the root div rather than reporting a success it did not perform).rails ruact:doctorreports the half-migrated state as a warning naming the one-line fix — where it previously reported a root-only layout as a pass.Known limitation: a ruact view is rendered in its own pass (it produces the component tree), so
content_fordeclared inside the view does not reach the layout. Set document metadata from the controller.rails ruact:doctorreports the two halves separately, because they are different fixes: a layout missing the root or the helper fails, and a ready layout withconfig.layout = falsewarns and names the setting.
0.0.8 - 2026-07-11
Section titled “0.0.8 - 2026-07-11”Changed
Section titled “Changed”ManifestErrormessage is now in English (was Portuguese) (Story 15.0). When the Vite dev server is unreachable AND nopublic/react-client-manifest.jsonexists on disk,Ruact::ManifestResolvernow raises[ruact] Vite dev server unreachable at <url> and no react-client-manifest.json found at <path> — runbin/dev.— same raise site, sameManifestErrorclass, same HTTP→file fallback order, same diagnostic content (dev-server URL + manifest path + thebin/devfix); 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.
-
ruact_propsnow 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, :titleon anActiveRecordmodel used to raiseArgumentError: method 'title' is not definedat class-load, because ActiveRecord defines its attribute reader methods lazily (on first instance access) — so the eagermethod_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 firstruact_serialize(viarespond_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 cleanArgumentError— only the timing moved for AR (first render of that model instead of boot). No boot-time DB dependency is introduced (attribute_names/column_nameswould need a live connection at class-load — rejected), the gem stays single-depnokogiri(the ActiveRecord discriminatordefined?(ActiveRecord::Base) && self < ActiveRecord::Basereferences 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 toserialize_serializable→ruact_serialize), andstrict_serializationis unaffected (an ARSerializablemodel serializes identically under strict true/false, as a PORO does). This reverses the doc-only guidance shipped in the AGENTS.md/llms.txtserialization-contract fix (gem PR #48):ruact_propson 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. -
Auto-revalidate opt-in: one
awaitfolds 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 viaconfigureRuactRuntime({ autoRevalidate: true })(mirrors the existingdefaultHeadersconfig), and a per-callwithRefresh(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 mutationawaits an in-place Flight refresh of the current path (the existingrevalidate()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$redirectresponse 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 descriptiverevalidate()“wiresetupRouter()” error surfaces (it is not swallowed) — so a mutation that succeeds but whose refresh rejects rejects the returned promise, consistent withrevalidate()’s loud-by-default stance. Opt-out (default) behavior is byte-identical to before, and the generated.ruact/server-functions.tsis unchanged —withRefreshis imported directly fromruact/server-functions-runtime(likeconfigureRuactRuntime), so the codegen output and the Ruby↔JS byte-parity fixtures are untouched. -
Dev-only
[ruact]log line naming the negotiated response shape per request (FR109) (Story 15.5). The single most confusing thing about aRuact::Serveraction is that the same action, at the same URL, answers different body shapes depending only on the request’sAcceptheader + verb — never on the Ruby. In development only,Ruact::Servernow 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 its204and$redirectsub-shapes) and, for a page render on a controller that also includesRuact::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-gatedafter_actionbeside it, no new config key. The log vocabulary matches the new canonical caller→shape table in the server-actions docs. Coverage isRuact::Server-only for now — queries (separateGET /qdispatch) and page-only controllers are documented in the table but not yet logged (deferred). -
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 yourspec_helper/rails_helper— it is NOT auto-loaded byrequire "ruact", so a production boot carries no RSpec dependency), then assertexpect(response).to have_ruact_component("PostList")or...have_ruact_component("PostList").with_props(a_hash_including("posts" => ...)). It accepts an ActionDispatch/Rack response (its.bodyis read) or a raw String, in either page shape — a rawtext/x-componentbody 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 aRuact::Serverfunction-call/query response (plain JSON) raises a clearRuact::Testing::NotAFlightResponseErrorpointing you atJSON.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 asRuact::Testing::FlightWireParser/Ruact::Testing::FlightStructureDiff, and the gem’s ownRuact::Specmatchers 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). -
Machine-readable introspection:
ruact:doctor -- --jsonandruact: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 -- --jsonemits one document —schema_version, an overallstatus, and every health check asname/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 newruact:routestask emits the accessor/route table — each entry’saccessorname,kind(action or query),verb,path,segments, and declaredparams(queries from their keyword arguments, actions from their required path segments) — derived from the very sameRouteSource+QuerySourcecollectors codegen consumes (a sharedRuact::ServerFunctions.introspectcombine 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). Bareruact:routesprints a compact human table. The default humanruact:doctoroutput is unchanged. Both JSON documents are marked EXPERIMENTAL and carryschema_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 emittedAGENTS.md/llms.txt“Verify your work” section now references both commands, flagged EXPERIMENTAL. -
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 dedicatedRuact::ChildrenNotSupportedError(aRuact::PreprocessorError, so it flows through the same dev error overlay) naming the component, the templatefile: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 shippedAGENTS.md/llms.txttrap #1 wording is updated from “fails silently” to “fails loudly” now that the artifact exists. -
Dev-only
[ruact]warning when an action registersruact_errorsand then renders explicitly on a function-call request (F6) (Story 15.0). The FR98errorsinjection rides the implicitdefault_render, so an explicitrender 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::Servernow logs exactly one[ruact]warning naming thecontroller#action, explaining the opt-out, and pointing at the fix (fall through, binderrors={ruact_errors}on a page render, orredirect_to). It never fires on the documented-correct patterns: a Bucket-1ruact_errors(record); render :newpage render, aredirect_toon 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. -
rails generate ruact:installnow emits anAGENTS.mdteaching 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_errorsfall-through, name derivation), theruact_props/strict_serializationallowlist, the SGID helpers, and the verification loop (bin/rails ruact:doctor). The emitted content is delimited by<!-- ruact:begin -->/<!-- ruact:end -->markers and the newcreate_agents_mdaction is idempotent and append-aware: no file → create; a user-authoredAGENTS.mdwithout markers → the marked section is appended with every pre-existing byte preserved; markers present → skip (re-running install is zero-diff);--forcerefreshes only the between-marker content, never user bytes outside it. The same compact reference is served by the docs site athttps://ruact.dev/llms.txt(kept in sync editorially). Newtemplates/AGENTS.md.tt+:story_15_1specs (including a ≤160-line template-budget tripwire).
0.0.7 - 2026-06-30
Section titled “0.0.7 - 2026-06-30”- Boot-race that 500’d the first request in development — the manifest is now resolved from the Vite dev server over HTTP. On a fresh app the Railtie’s
config.to_preparereadpublic/react-client-manifest.jsononce at boot, but Rails frequently booted (and read the still-missing file) before the Vite dev server wrote it — leavingRuact.manifestnil.public/is not watched, soto_preparenever re-fired, and the first request to a view containing a component hitnil.reference_for→ a cryptic 500. The bundled Vite plugin now serves the live in-memory manifest atGET <vite_dev_server>/__ruact/manifest(always fresh, reflects HMR rebuilds, internal_sourceFilefield stripped), and in development the gem resolves the manifest through a newRuact::ManifestResolver: it fetches that endpoint overNet::HTTP(~1s timeout, once per render/preprocess — not per component), falls back topublic/react-client-manifest.jsonon disk when the dev server is down, and otherwise raises a clear, actionable error (Vite dev server inacessível … rode bin/dev) instead of aNoMethodError. Production is untouched — it still uses the boot-loadedRuact.manifest(raising at boot if the build is missing); the HTTP fetch is dev-only. The Vite plugin continues to writepublic/react-client-manifest.json(prod build + the dev fallback). Both render and the FR100 contract validator resolve through the same path (the validator fails open to no-validation when the manifest is unreachable). The three hardcodedhttp://localhost:5173references inruact_js_assets(the react-refresh preamble,@vite/client, and the bootstrap<script src>) now honorRuact.config.vite_dev_serverfor consistency.
0.0.6 - 2026-06-30
Section titled “0.0.6 - 2026-06-30”Server functions are route-driven. A server mutation is a normal non-GET controller action on a controller that does
include Ruact::Server— the action IS the function, reached at its real Rails route (POST /posts), visible inrails routes. A server query is a public method on aRuact::Queryclass mounted withruact_queries→ a namedGET /q/<jsId>route. React imports both from the codegen-emitted module (import { createPost, useQuery } from "@/.ruact/server-functions"), derived from the route table. There is no per-action DSL and no synthetic gem-managed endpoint.
Changed
Section titled “Changed”-
ruact:installemits the launch files sobin/devboots a working app —package.json+Procfile.dev+ foremanbin/dev(FR101, Epic 14 DoD) (Story 14.6). The clean-room capstone surfaced that a freshruact:installwrote nopackage.json(so Story 14.1’snpm installhad nothing to resolve) and noProcfile.dev/bin/devthat 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:installnow writes all three: apackage.jsondeclaringreact/react-dom+ thevite/@vitejs/plugin-reactdev deps and adev: vitescript (the bundled ruact Vite plugin is not a dep —vite.configimports it by the absoluteRuact.vite_plugin_pathand it uses onlynode:builtins); aProcfile.dev(web: bin/rails server -p 3000+vite: npm run dev); and an executablebin/devthatexecsforeman start -f Procfile.dev(installing foreman if absent — the Rails-idiomatic launcher). So the literalbin/devfrom the Epic 14 DoD now boots the full stack with no manual launch wiring. Every file is guarded and idempotent — an existingpackage.json/Procfile.dev/bin/devis left untouched (a skip notice prints) and--forceoverwrites — so re-running the generator never clobbers a developer’s launcher, andbin/devis always left executable. Newtemplates/{package.json.tt,Procfile.dev.tt,dev.tt}+create_package_json/create_launch_files(ordered before thenpm installstep) ininstall_generator.rb(+:story_14_6specs). This closes the getting-started doc’s long-standing promise thatruact:installproducespackage.json+Procfile.dev. -
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--shadcnflag re-activates the byte-preserved Epic 10 design system:rails generate ruact:scaffold Post title:string body:text published:boolean --shadcnemitsPostList(shadcnTableprimitive + client-side sort),PostForm(shadcnInput/Textarea/Switch/Selectcontrols mapped per attribute type), andPostDeleteDialog(controlled shadcnAlertDialog) by rendering the byte-unchangedtemplates/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_setupearly-returns unless--shadcn): under--shadcnon an app where shadcn/ui is not set up, it surfaces the copy-pasteablenpx 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 --javascriptcomposes to untyped.jsxshadcn 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 —--shadcnflips only the three component templates + the pre-flight. The shadcn templates,ShadcnPreflight,--skip-shadcn-check,FormHelpers#form_uses_*?, and the Epic 10type-tests/scaffold/Post*.tsxfixtures +tsconfig.scaffold.jsonare byte-unchanged (the only runtime edits are+class_option :shadcn, thecreate_componentsbranch, and thecheck_shadcn_setupre-registration + guard inscaffold_generator.rb). The Epic 10 default-output shadcn specs neutralized in 14.4 are un-skipped and re-pointed at the--shadcnpath (zero remaining skips); a new:story_14_5block proves the flag matrix (the@/components/uigrep in both directions + the pre-flight abort vs no-op), and the shadcntsc --noEmitfixtures rejoinnpm run typecheck. This completes the FR103 split: agnostic default for a frictionless first run,--shadcnfor the proven Epic 10 styled output when shadcn is already in the app. -
ruact:scaffolddefault 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:booleannow completes without aborting and emitsPostList/PostForm/PostDeleteDialogas 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 previouslyraised/aborted on a fresh app is removed from the default path (check_shadcn_setupis de-registered as a Thor command viaremove_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 controlleduseState, submits viacreate<Model>/update<Model>, and surfaces the FR98 attribute-keyederrorsinline; the DeleteDialog confirms before destroying (controlledopen/onConfirm,{ ok, error? }contract, inline error on failure). FR99 (type <Model>Row) + FR100 (__ruactContract) are preserved in the agnostic.tsx;--javascriptstill strips them and emits untyped.jsxwith the “forfeits FR99/FR100” banner. shadcn is NOT deleted — it becomes the opt-in path: the shadcntemplates/components/List.tsx.tt/Form.tsx.tt/DeleteDialog.tsx.tt, theShadcnPreflightmodule, the--skip-shadcn-checkoption, and theFormHelpers#form_uses_*?predicates are preserved byte-unchanged and dormant for a follow-up--shadcnflag (Story 14.5, which re-pointscreate_componentsat 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. Newtemplates/components/agnostic/templates +FormHelpers#native_input_type+ thecreate_componentsre-point + theremove_commandinscaffold_generator.rb; the CI gate is a:story_14_4spec (no-@/components/uigrep on the default output + the agnostictsc --noEmitfixtures undertype-tests/scaffold/agnostic/against an ambient stub that declares no@/components/ui/*module). The Epic 10 default-output shadcn specs (and the:story_10_5pre-flight block) are neutralized (skip+ aStory 14.5 re-points this at the --shadcn pathmarker), never deleted. -
ruact:scaffoldis self-contained — it generates the model, migration, route and host-framework tests by delegating to Railsresource(FR102) (Story 14.3).rails generate ruact:scaffold Post title:string body:text published:booleannow invokes Rails’ own publicresourcegenerator first — producingapp/models/post.rb, adb/migrate/*_create_posts.rbmigration, theresources :postsroute, 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-runningrails generate model … ; rails db:migratebefore scaffolding (the Epic 10 dogfooding friction): after the scaffold andrails db:migrate, the resource is ready with no manual model step. Honors your test framework (parity): because the delegation goes throughresource, the delegated model/controller stubs land RSpec-under-spec/or Minitest-under-test/per yourconfig.generators test_framework— ruact hard-codes none for the delegated stubs. Overlay reconciliation: the v2 controller force-overwritesresource’s bare 7-action controller (silently — no Thor “conflict?” prompt); the route stays a singleresources :postsline (resourcedraws it, ruact’s existing guard no-ops); ruact’s own v2-contract request spec is the single authoritative request spec — itforce-wins the RSpec-path collision withresource’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 brokenrails_helper-requiring file). The generatedrequest_spec.rbPREREQUISITES comment drops the obsolete manualrails generate model … ; rails db:migratestep. The delegation uses only the publicresourcegenerator surface (Thorinvoke), so it survives Rails-version drift (exercised across the Rails 7.0/7.1/7.2/8.0 CI matrix); the rawfield:typestrings pass through verbatim so Rails parses the correct migration columns. The sub-generator call sits behind a stubbableinvoke_rails_resourceseam (mirroring Story 14.1’srun_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(+ therequest_spec.rb.ttcomment +:story_14_3specs). The CSS-agnostic default /--shadcnopt-in split is Story 14.4/14.5;check_shadcn_setupis unchanged here. -
ruact’s JS plumbing is hidden —
app/javascript/is now your code only (FR104) (Story 14.2). After a freshruact:install,app/javascript/contains only yourcomponents/(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 modulevirtual:ruact/bootstrapserved by the bundled Vite plugin from gem-shipped source (vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx), mirroring the existingvirtual:ruact/registry— the install generator no longer writesapp/javascript/application.jsx, and the generatedvite.configinput isvirtual:ruact/bootstrap; (2)flight-client.js+ruact-router.jsmoved 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 privatevite_tagsis promoted to a publicRuact::ViewHelper#ruact_js_assetshelper (the dev/prod entry<script>tags + the__FLIGHT_DATAinline script) thatRuact::Controller#ruact_html_shelldelegates 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.tsis unchanged — it stays the lone visible, typed generated file atapp/javascript/.ruact/server-functions.ts, and the locked@/.ruact/server-functionsaccessor import is untouched (NOT virtualized). Migration: an earlier-layout app deletesapp/javascript/{application.jsx,flight-client.js,ruact-router.js}, points itsvite.configinput atvirtual:ruact/bootstrap, and usesruact_js_assetsin its layout (re-runningruact:install --forceregenerates thevite.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_2specs + a bootstrap vitest). -
ruact:installis one command — it now runsnpm 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:installnow runsnpm installin the app root so a fresh app goes straight tobin/devwith no separate JS-dependency step. The npm step runs last (a failure leaves the generated files in place and reported — no rollback).--skip-npmopts out (CI, or a non-npm package manager — install JS deps yourself, thenbin/dev). Whennpmis not onPATHthe 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), thenbin/dev” — and never claims deps are installed when they are not. Idempotent on re-run (npm’s own idempotency over a populatednode_modules). The shell-out lives behind a stubbablerun_npm_installseam so generator specs assert invoke-by-default / skip-under---skip-npmwith no real npm or network call. Package-manager auto-detection (yarn/pnpm/bun) is deferred — default-npm is the supported path,--skip-npmthe documented escape hatch. Supersedes the old v1 behavior of merely printing annpm install …suggestion. Single runtime file:lib/generators/ruact/install/install_generator.rb(+:story_14_1specs). -
ruact:scaffoldList → dep-free shadcntableprimitive (drops@tanstack/react-table) (FR81) (Story 10.2b). The generated<Model>Listis reworked off the shadcnDataTablerecipe (which is built on@tanstack/react-table) onto the plain shadcntableprimitive (Table/TableHeader/TableBody/TableRow/TableHead/TableCellfrom@/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) soruact:scaffolddoes not silently commit the host app to@tanstack/react-table. The sort is a singleuseState<{ key; dir } | null>+ a generatedcompareRowscomparator: numbers numerically, dates by epoch time (off a generatedDATE_KEYSset), booleans/strings sensibly, andnull/undefinedvalues always last regardless of direction (a blank cell never jumps to the top); a clickable headertoggleSort(key)sets a new key ascending and flips asc↔desc on the active key, always sorting a copy of the rows (never mutating thepostsprop). The markup is fully unrolled (one<TableHead>/<TableCell>per attribute), readingrow.<attr>directly (norow.getValue/row.original). Every other Story 10.2 / 10.4 behaviour is preserved exactly: the per-attribute type-aware cells (text /Badgeboolean / right-alignedtabular-numsnumeric / locale-formatted date), the per-row Edit link + controlled<Model>DeleteDialogdelete (RowActions,destroy<Model>({ id }), in-listremovedIdstombstone removal, narrow-viewportDropdownMenuoverflow), the server-renderedpostsprops path (noas_json), theuseQuery(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), andruact_queriesroute injection are untouched. Verified by content-marker generator specs (incl. negative asserts: no@tanstack/@/components/ui/data-table/ColumnDef/<DataTable/row.getValue/toggleSorting) + an isolatedtsc --noEmitof the regeneratedtype-tests/scaffold/PostList.tsxfixture against a new ambient@/components/ui/tablestub (the@tanstack/react-table+@/components/ui/data-tableambient modules dropped). Reworkedtemplates/components/List.tsx.tt; addedScaffoldAttribute#date?; updatedscaffold_generator_spec.rb+type-tests/scaffold/{ambient.d.ts,PostList.tsx}. Story 10.5 note: the List now needs onlynpx shadcn add table— not thedata-tablerecipe or@tanstack/react-table.
-
ruact:scaffoldshadcn dependency pre-flight — detect / guide / version-compat (FR81) (Story 10.5).ruact:scaffoldnow 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-pasteablenpx shadcncommand sequence and aborts before writing anything (zero partial state) when the setup is missing or partial. Missing →npx shadcn@latest init+ a singlenpx shadcn@latest add <full list>; partial → the exact missing component names + a targetednpx shadcn@latest add <missing>. The generator never auto-runsnpx/npm(no surprising network/install behavior — critical for CI). The abort is araise Thor::Error(clean CLI message, non-zero exit, no Ruby backtrace), mirroring the existing unknown-attribute-type guard.--skip-shadcn-checkbypasses 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 ashadcn_missing?predicate → the 10.1–10.4/10.2btscbyte-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 plainnpx shadcn addprimitive: there is NOdata-tablerecipe and NO@tanstack/react-tabledependency (Story 10.2b removed the engine; the List is a plaintable), locked in by a negative spec assertion (the printed guidance never contains@tanstack/data-table). Version-compat (AC6): the newRuact.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 frompackage.jsonshadcn/legacyshadcn-ui;nilwhen run vianpx→ 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 toshadcn_compatible_versionsonce verified). CI exercises ≥2 shadcn majors at the detection/validation level via committedcomponents.json/components/ui//package.jsonfixtures (no network — the realnpx shadcninstall + 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. Newlib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb+check_shadcn_setuptask +--skip-shadcn-checkoption inscaffold_generator.rb+ theshadcn_compatible_versionsconfig attribute; the threetemplates/components/*.tsx.ttgain the conditional banner; extendedscaffold_generator_spec.rb+configuration_spec.rb. -
ruact:scaffoldDeleteDialog → controlled shadcn AlertDialog + in-list removal (FR81) (Story 10.4). The generated<Model>DeleteDialogis upgraded from a nativewindow.confirmbutton to a controlled shadcnAlertDialog(open/onOpenChange/<singular>/onConfirmprops): it readsDelete "<title>"?(the model’s first string display attribute — falling back to atextcolumn, thenid; never a hard-codedtitle, via the newdisplay_attributehelper) with aThis action cannot be undone.body and a Cancel (default focus) / Delete (destructive) footer. Confirm calls the generateddestroy<Model>({ id })accessor (DELETE/<plural>/:id). Two server-owned success shapes: the generated default is delete-in-list —#destroyreturns{ ok: true }(no redirect, aligned to the golden), and the rewired<Model>Listremoves that row from local state in place (no reload, no client-built URL); a commented delete-from-showredirect_to <plural>_urlalternative is carried one line below (the accessor follows the emitted{ "$redirect" }automatically). There is no Railsmethod=deletefallback — thedestroy<Model>accessor is the only path. On failure (e.g.restrict_with_exceptionon a foreign key)#destroydoes 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 thepostsprop) so a delete can drop one; the per-row actions are driven by a small in-fileRowActionscomponent (its ownopenuseState) across both the inline (≥ md) and overflow-menu (< mdDropdownMenu) layouts. Thecolumnsconfig moves inside the component (design B —useMemo) so the actions cell closes over component state, leaving theDataTable’s{ columns, data }interface unchanged (no new Story 10.5 coupling). The List preserves itsuseQuerysearch, the__ruactContract = { props: { posts: "required" } }(FR100),type <Model>Row(FR99), the sortable columns, and the empty/searching states. The.tsxDeleteDialogis typed (FR99) and retains an inert__ruactContract(it is referenced only fromList.tsxJSX, never an ERB tag, so the 13.5 call-site validator never fires);--javascriptforfeits 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 isolatedtsc --noEmitof the generated.tsxagainst an ambient@/components/ui/alert-dialogstub (thetype-tests/scaffold/PostDeleteDialog.tsx+ rewiredPostList.tsxfixtures kept byte-identical to the live render by an rspec equality gate). Upgradedtemplates/components/DeleteDialog.tsx.tt(rewrite) +templates/components/List.tsx.tt(rewire) +templates/controller.rb.tt(#destroy) +ScaffoldGenerator#display_attribute; extendedscaffold_generator_spec.rb+ the typecheck harness. -
ruact:scaffoldForm → shadcn controls mapped by attribute type (FR81) (Story 10.3). The generated<Model>Formis 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-keyederrorsFor(attr)map (plus the top-levelbaseblock) — 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 controlleduseState+ shadcn input primitives (Radix-based — noreact-hook-form/zodruntime dep), with a documented opt-in trail for swapping toreact-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) — noPopover+Calendarrecipe, nodate-fnsdep (the rich picker is a documented follow-up). One component for new + edit via<<Model>Form initial={@record} />(initialnull fornew, the serialized row foredit— noas_json), calling the generatedcreate<Model>/update<Model>accessors; success navigation stays server-driven (redirect_to→$redirectthe accessor follows — the component builds no URL, nowindow.location.assign).referencesoptions (AC6): the generatednew/editcontroller 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.tsxis typed (FR99type <Model>Row+ the options prop) and carries__ruactContract = { props: { initial: "optional" } }(FR100);--javascriptforfeits the TS-only markers but keeps every control + the server-driven behaviour. Scope boundary with Story 10.5: the Form importsinput/textarea/switch/select/label/buttonfrom@/components/ui/*but does not install shadcn or emit anycomponents/ui/*file (10.5 owns that; native date inputs mean nocalendar/popoverneeded; the live demo is 10.7). Verified by content-marker generator specs + an isolatedtsc --noEmitof the generated.tsxagainst ambient stubs (thetype-tests/scaffold/PostForm.tsxfixture kept byte-identical to the live render by an rspec equality gate). Upgradedtemplates/components/Form.tsx.tt+templates/controller.rb.tt(references options) +templates/views/{new,edit}.html.erb.tt; newlib/generators/ruact/scaffold/scaffold_form_helpers.rb+ScaffoldAttribute#shadcn_control; extendedscaffold_generator_spec.rb+ the typecheck harness. -
ruact:scaffoldList → shadcn DataTable + client-driven search query (FR81) (Story 10.2). The generated<Model>Listis upgraded from a plain<table>to a shadcnDataTable: a typedcolumns: 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’sindexpayload — server-side sort/pagination is Phase 3), and a per-row actions cell (Edit link + delegation to<Model>DeleteDialog) that collapses into a…DropdownMenuunder themdbreakpoint so it never pushes data columns out of layout on narrow viewports. The component keeps its FR99type <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} />, noas_json— AC4); only the client-driven search box becomes a query (AC5): the generator now also emitsapp/queries/<plural>_query.rb(class <Plural>Query < ApplicationQuerywith asearch(q:)method — FR88 kwargs — a case-insensitiveLIKEover the model’s string/text columns, returning the SAME row shape the index serializes), createsapp/queries/application_query.rb(< Ruact::Query) idempotently (ruact:installdoes not ship it; a second scaffold never clobbers a customized base), injectsruact_queries <Plural>Queryintoconfig/routes.rb(idempotent on re-run), and wires the List’s search box touseQuery(search<Plural>, { q })(typed FR99 accessor, aliased from the codegen’s genericsearchexport). The query.rbis language-agnostic (emitted in both modes);--javascriptforfeits the TS-onlyColumnDef/type/__ruactContractmarkers but keeps the DataTable + search wiring. Scope boundary with Story 10.5: 10.2 importsDataTablefrom@/components/ui/data-tableandBadge/Button/DropdownMenufrom@/components/ui/*but does not install shadcn or emit anycomponents/ui/*file — 10.5 owns that (incl. thedata-table.tsxrecipe + thetableprimitive its current add-list omits; the live end-to-end demo is 10.7). Verified by content-marker generator specs + an isolatedtsc --noEmitof the generated.tsxagainst ambient@/components/ui/*/@tanstack/react-table/ server-functions stubs (a newtsconfig.scaffold.json+type-tests/scaffold/fixture kept byte-identical to the live render by an rspec equality gate, run in the existingjsCI job). New templatestemplates/queries/{query,application_query}.rb.tt; newlib/generators/ruact/scaffold/scaffold_attribute.rb; upgradedtemplates/components/List.tsx.tt+ generator tasks/helpers +scaffold_generator_spec.rb. -
Component auto-registry — the build emits
MODULE_REGISTRY, no hand-maintained registration (Story 10.1b). A freshruact:installapp no longer ships an emptyconst MODULE_REGISTRY = {}that the developer must hand-edit (import + register) for every"use client"component.vite-plugin-ruactnow exposes a virtual modulevirtual:ruact/registry— its default export is the{ [manifest id]: moduleExports }map, derived from the same scan that writespublic/react-client-manifest.json. Adding or removing a component underapp/javascript/components/is zero edits toapplication.jsx; a freshlyruact: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 manifestidverbatim (the value the gem serializes as the FlightmoduleId,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’schunks) — so a component never becomes a standalone facade chunk and itsidstays the source-relative path in prod exactly as in dev; a realvite buildtest asserts the built registry’s keys equal the emitted manifest ids. Opt-out is by placement (a file outsideapp/javascript/components/, or without a"use client"directive, is not registered). The install template (application.jsx.tt) now doesimport MODULE_REGISTRY from 'virtual:ruact/registry'instead of carrying a hand-maintained literal. Plugin-only change (no gem Ruby / wire-contract change); new vitestregistry.test.mjs(incl. the load-bearing prod-build id-match) + reconciled the scaffold playground onto the shipped path. -
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:booleanproduces a working CRUD at parity withrails 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 (implicitdefault_render, made safe on anyAcceptby Story 10.0);create/update/destroyare the route-derived server functions — Bucket-2 JSON, server-drivenredirect_toon success ($redirect, the runtime follows it — no client URL building), and the FR98ruact_errors(@post)keyed-errors channel on validation failure; strong-params read each attribute explicitly withActiveModel::Type::Booleancoercion. Components are.tsxby default — typed against the server boundary (FR99: a generatedtype PostRow+ the codegen-typedcreatePost/updatePost/destroyPostaccessors) and carrying an opt-in__ruactContract(FR100) so call sites validate at preprocess time;PostFormimports only the action accessors (no query — that is Story 10.2). A--javascriptflag emits untyped.jsx(documented: it forfeits FR99/FR100). References default to the RESTful raw:id; every FR96 SignedGlobalID surface (Ruact.locate_signedfinder swap,Ruact.signed_global_idmint, apublish-by-signed-token action) ships commented — an opt-in the developer flips on per endpoint, never imposed, never closing off the raw-:idpath. 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 generatorlib/generators/ruact/scaffold/(scaffold_generator.rb+ templates); new specspec/ruact/scaffold_generator_spec.rb. -
Compile-time component contract — HEEx-style
attr/slotcall-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 (postIDvspostId), 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 silentundefinedin the browser. This is the consumer-side mirror ofruact_props(which validates the producer at class-load). Contract source (design A). A component opts in by exporting__ruactContractfrom 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, noeval) into an optional, byte-additivecontractfield on the manifest entry; a component without the export emits nocontractfield, 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 existingCOMPONENT_TAG_REtransform loop, with the template path threaded through astemplate.identifierand the contract registry an injectable seam defaulting to the frozenRuact.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 aRuact::ComponentContractError(aRuact::PreprocessorErrorsubclass, so it rides the dev error overlay / NFR30 lineage). The Damerau-Levenshtein closest-match (Story 7.4) is factored out ofClientManifestinto a sharedRuact::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 fileslib/ruact/component_contract.rb,lib/ruact/string_distance.rb; new specscomponent_contract_spec.rb,string_distance_spec.rb+:story_13_5cases inerb_preprocessor_spec.rb/client_manifest_spec.rb+ abuildManifestcontract-extraction vitest. ADR addendum (2026-06-26). -
Typed query accessors —
codegen_v2emits realparamstypes (FR99) (Story 13.4). The generatedserver-functions.tsnow types each query accessor’sparamsfrom the query method’s declared keyword arguments instead of an opaqueRecord<string, unknown>. A querydef search_users(term:, limit: 10)emitssearchUsers: (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/accessoranygap (a BLOCKING gate for the Epic 10 scaffold to be typed by construction). Reflection-honest value type. Ruby’sMethod#parametersexposes 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 unionstring | number | boolean | nullthe 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 readparamsdynamically, no reflectable declared-input source; alreadyFormData | Record<string, unknown>, not bareany); return types stayPromise<unknown>(useQuery<T>remains the return-typing lever). A**keyrestquery fails open ({ named } & Record<string, unknown>). Per-kwarg metadata is derived inQuerySource.build_entry(params+params_rest,accepts_paramsretained), carried verbatim through the snapshot JSON, and emitted byte-identically by both the RubyCodegen::V2and the JSrenderQueryExportV2(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. Atsc --noEmittype-level test (newtypescriptbuild-only devDependency +type-tests/in the vite-plugin suite, wired into a newjsCI job) proves the emitted types are enforced. No runtime.d.tschange, no client-runtime behavior change. ADR addendum (2026-06-26). -
Inertia-style validation
errorsround-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 structurederrorsobject —{ [attribute]: string[] }, full messages keyed by attribute (abase-level error keys under"base"),{}on success — without hand-rolling an error channel per action. Opt-in and explicit (design (B), theruact_props/allowlist grain that 13.1/13.2 reinforced — NO auto-injection, NO globally reserved prop): the developer callsruact_errors(@post)once after the save attempt. Because the shape derives fromrecord.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 normalizerRuact::ServerFunctions::ValidationErrors.normalize(no Rails/Ruact.config/request reads) accepts an ActiveModel-ish record, a rawActiveModel::Errors, or a pre-shaped Hash and is idempotent on canonical input. Dual-bucket. Bucket 2 (imperativeawait): when the collector was touched,Ruact::Server#default_renderinjects it under the reserved JSON keyerrorsalongside the serialized ivars (soresult.errorsalways surfaces) — an untouched collector preserves the Story 9.2204 No Contentempty contract unchanged. Bucket 1 (native form / navigation): on a Flightredirect_tothe errors are stashed inflash[:ruact_errors](single-use, session-backed) and arrive as anerrorsprop 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-exceptionif record.save … else …happy-failure path. No client-runtime change (theerrorskey ridesparseResponseverbatim); no codegen change. New fileslib/ruact/server_functions/validation_errors.rb,lib/ruact/validation_errors_collector.rb; new specvalidation_errors_spec.rb+ request-level:story_13_3cases inserver_bucket_request_spec.rb/controller_request_spec.rb. ADR addendum (2026-06-26). -
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 afor:purpose and anexpires_in:lifetime (returns a plainString, so it serializes as a prop with no serializer change);Ruact.locate_signed(token, for: :post_edit)resolves it back, raisingRuact::InvalidSignedGlobalIDError— mapped to a clean HTTP 400 by the Story 8.4 structured-error chain (noActiveRecord::RecordNotFoundleak, no raw-id trust) — on a tampered, expired, or wrong-purpose token. Opt-in and explicit (it follows theruact_propsallowlist 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 explicitexpires_in: nilis honored as a deliberate non-expiring choice). Two new config keys document app-wide defaults:Ruact.config.signed_global_id_default_purposeandRuact.config.signed_global_id_default_expires_in(both defaultnil).globalid(which ships with Rails) is lazily required only when a helper is called, preserving the single hard runtime dependency. New filelib/ruact/signed_references.rb; new specsigned_references_spec.rb+ request-level:story_13_2cases inquery_request_spec.rb. ADR addendum (2026-06-26). -
useQueryrequest de-duplication (Story 9.6). Identical concurrentuseQuerycalls now share ONE network request: when three components mountuseQuery(categories)with the same params while a request is in flight, exactly oneGET /q/categoriesis issued — all of them receive the same resolveddata, 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 (useQueryhook); the_makeQueryGET accessor, codegen, and the Ruby query dispatch are unchanged. Runtime package version0.3.0→0.4.0; coverage inusequery.test.mjs. -
Queries in codegen +
useQueryhook + FR88 kwargs sanitization (Story 9.5). The read-side of the route-driven model is complete:import { categories, useQuery } from "@/.ruact/server-functions"anduseQuery(categories)/useQuery(searchUsers, { q: input })work againstRuact::Queryclasses — same mental model as mutations, zero new concepts. Codegen.Ruact::ServerFunctions::QuerySourcederives query entries from the drawn route table (the GET routesruact_queriesmounted under the generated query-dispatch namespace) — route-truth-consistent with dispatch, so only classes actually mounted inroutes.rbare exposed (no over-exposure). Query entries join the same merged JS namespace as mutations; collisions fail loudly at boot naming both origins (theruact_function_namerename 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-exportsuseQueryfrom 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; structuredRuactActionErrorintoerror; superseded in-flight responses dropped; refetch on value-changed params);_makeQueryGET helper issuesGET /q/<jsId>with params in the query string — no body, no CSRF (reads are CSRF-free). The runtime gainsreactas a peerDependency (its first React import; the mutation path stays React-free); package version0.2.0→0.3.0. FR88 sanitization.query_dispatch.rb#__ruact_query_kwargsenforces the kwargs allowlist onrequest.query_parameters: onlystring | 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 raiseRuact::BadRequestError→ HTTP 400 via the structured error payload. New filelib/ruact/server_functions/query_source.rb; new specquery_source_spec.rb+:story_9_5cases across the codegen / railtie-integration / query-request specs; new runtime vitestusequery.test.mjs+ query parity cases. ADR addendum (2026-06-10). -
Ruact::Querybase class +ruact_queriesroute macro (Story 9.4). Server QUERIES are plain classes underapp/queries/(class CatalogQuery < ApplicationQuery,ApplicationQuery < Ruact::Query) — each public method is one query — mounted with one line inroutes.rb:ruact_queries CatalogQuerydraws one named GET route per public method (def search_users→GET /q/searchUsers, namedruact_query_searchUsers), all visible inrails routes. The prefix is configurable viaRuact.config.query_route_prefix(default"/q"). Dispatch goes through an internal gem controller — one generated subclass per query class — inheritingRuact.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/sessiondelegate to the dispatching controller, andCatalogQuery.new(fake_context).categoriesis unit-testable with no Rails boot. Per-query callback opt-out viaruact_skip_before_action(mirrors Rails’skip_before_actionsignature incl.only:/except:/raise: false). Queries are GET — no CSRF; the return value serializes through the sameruact_props/Ruact::Serializable/strict_serializationpolicy as a Bucket-2 mutation response (nil→ JSONnullwith 200), and a query raise renders the structured-error payload with the 422/403/413/500 mapping. New fileslib/ruact/query.rb,lib/ruact/routing.rb,lib/ruact/server_functions/query_dispatch.rb,lib/ruact/server_functions/query_context.rb. New specsquery_spec.rb,query_context_spec.rb,query_request_spec.rb+ extensions toconfiguration_spec.rb/bucket_two_payload_spec.rb, all tagged:story_9_4. -
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::Serverbecomes a callable server function —resources :postsis the only declaration, noroutes.rbadditions, no synthetic endpoint.Ruact::ServerFunctions::RouteSourcecollects 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; theruact_function_name :action, as: "jsId"macro onRuact::Serveris 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 (viaglobalThis.__ruact_navigate,window.location.assignfallback). FormData branching, CSRF meta injection, text-first parsing,RuactActionError,redirect: "error", the intersection action signature, andrevalidate()are all carried by a sharedruactInvokecore. 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 incodegen_spec.rb/snapshot_spec.rb/railtie_integration_spec.rb(tagged:story_9_3); +runtime vitest characterization tests; +codegen parity vitest cases. -
Dual-bucket response negotiation on the same controller action (Story 9.2). One non-GET
Ruact::Serveraction serves both form/navigation submits and imperativeawait fn()calls, discriminated by how it was called — norespond_toblocks. Bucket 1 (form/navigation,Accept: text/x-componentor 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 — Railsview_assigns, keyed by ivar name without@(@post→{ "post": {...} }), each value through theruact_props/Ruact::Serializable/strict_serializationrules (a single ivar stays keyed, no unwrap); aredirect_tosurfaces as{ "$redirect": "<path>" }; an action that sets no exposed ivars and does not redirect returns204 No Content(the generated ref resolvesnull); a serialization failure raisesRuact::SerializationError→ structured 500.Vary: Acceptis set on every non-GET response shape. CSRF on Bucket 2 is the host’s ownprotect_from_forgery(missing/invalid → 403; API-mode accepts). New pure serializerRuact::ServerFunctions::BucketTwoPayload. New specsbucket_two_payload_spec.rb,server_bucket_request_spec.rb(tagged:story_9_2). -
Ruact::Serverconcern (Story 9.1).include Ruact::Serverin a controller installs the server-functions infrastructure on the host’s own callback chain: the structured-error renderer (rescue_from StandardError+ explicitActionController::InvalidAuthenticityTokenregistration — uncaught exceptions on function-call requests render the_ruact_server_action_error: trueJSON payload with the 422/403/413/500 status mapping, dev/prod payload split viaRuact.config.dev_error_payload_enabled, hostrescue_fromprecedence preserved) and themax_upload_bytesupload guard (prependedbefore_action; rejects oversizedmultipart/form-data/application/x-www-form-urlencodedbodies with a structured 413 BEFORE CSRF verification; skips GET/HEAD; carve-outs:nillimit, non-form content types, absentContent-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 itsAcceptheader. The concern assumes hosts includeRuact::Serverafterprotect_from_forgery; no runtime callback-order verifier runs. The shared implementation lives inRuact::ServerFunctions::ErrorRendering. New specs:server_spec.rb,server_rescue_request_spec.rb,server_upload_request_spec.rb(tagged:story_9_1). -
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), andrecord.errors.full_messagesfor 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 viaRuact.config.dev_error_payload_enabled(Boolean, defaultnil→ resolves toRails.env.development? || Rails.env.test?). Status mapping:RecordInvalid → 422,InvalidAuthenticityToken → 403,UploadTooLargeError → 413, everything else → 500; the host’s ownrescue_fromchain wins for owned exception classes. Pure modules undergem/lib/ruact/server_functions/:ErrorPayload.build,BacktraceCleaner.split(anchored onRuact.gem_path),ErrorSuggestion.for. Server-sideRails.logger.erroralways logs the failure with a[ruact]prefix + the full backtrace; the dev-mode gate governs only the wire body. -
File uploads +
max_upload_bytespre-parse guard (salvaged from Epic 8, re-anchored onRuact::Server).<form action={fn}>with<input type="file">deliversparams[:file]asActionDispatch::Http::UploadedFile; the runtime’s FormData branch sendsmultipart/form-datawith the browser-managed boundary, and Rails’ standard multipart parser unwraps each part —@post.cover.attach(params[:cover])(Active Storage) works unchanged.Ruact.config.max_upload_bytes(Integer, default 10 MB;nildisables the gem-side guard) gates a prepended guard on theRuact::Serverconcern that checksrequest.content_lengthformultipart/form-data/application/x-www-form-urlencodedbodies BEFORE CSRF verification; oversized requests raiseRuact::UploadTooLargeError→ 413 + the structured body with a dev-onlyupload_limit: { received_bytes, limit_bytes }block. Carve-outs: JSON bodies, chunked-transfer requests (noContent-Length),max_upload_bytes = nil. GET/HEAD requests are never guarded.
Removed
Section titled “Removed”- 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 theapp/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 ofapp/javascript/.ruact/server-functions.ts(the parallel.nextinspection target ceases to exist; the railtie writes the v2 snapshot to the real bridge onconfig.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-functionsnever 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 ADRgem/docs/internal/decisions/server-functions-api.md.
Security
Section titled “Security”-
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 — swapid: 7forid: 8and an action that trustsparams[: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). -
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:doctornow 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 RubycreateFromNodeStream/createFromReadableStream/createFromFetchreader) that is not annotated# ruact:allow-flight-deserialization <reason>; a clean tree passes silently. The same pass introduces a non-failing:warndoctor status (rendered⚠) that fires when a response-transforming middleware (Rack::Deflater) is mounted, since recompressing a streamedtext/x-componentbody breaks the Flight wire contract. The client-sidecreateFromFlightPayload(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).
-
default_renderdegrades gracefully on non-HTMLAccept(no more 500 on*/*) (Story 10.0; Epic 10 gem prerequisite). An ivar-only GET page action relying on implicitdefault_renderreturned an HTTP 500 (__ruact_component__ called outside a ruact_render flow) the moment a non-browser client (curl, bots, uptime/health-checks sendingAccept: */*) hit it: the activation predicate keyed offrequest.format.html?, which isfalsefor the*/*wildcard, so the request fell through tosuper, which rendered the.html.erboutside aruact_renderflow. 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/absentAccept) — 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 tosupersorespond_toblocks and explicitrendercalls are unaffected,text/x-component/Ruact-Request: 1navigations are unchanged, and thetext/htmlpath is byte-identical. This makes the Epic 10 scaffold’s implicit-default_renderstyle safe by construction. -
Ruact::Controller#ruact_rendernow renders successfully under Rails 8 (Story 7.9; resolves Bug 7.8-B). Previously every PascalCase component in a Rails 8 app raisedRuact::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 byrender_to_string. (Story 7.9 originally landed under the pre-rename namesrsc_render/__rsc_component__; the message shape and method name moved toruact_*in Story 5.12 — the underlying fix is mechanically the same.) -
rails generate ruact:installno longer crashes on a freshrails new(Rails 8.1 / Thor) (Sprint Change Proposal 2026-06-16 §4.5; surfaced building theepic9-scaffoldplayground). The generator calleddestination_root.join(...)at eight sites, but Thor returnsdestination_rootas aString(File.expand_path), which has no path-style#join— so the installer aborted with aNoMethodErrorbefore writing a single file. The paths are now wrapped inPathname(destination_root).join(...). A new integration spec runs the real generator against aStringdestination_root (the prior “generator action helpers” tests reimplemented the file logic withFile.join, so they executed Thor’s path handling never and masked the bug). -
<Suspense delay="2.5">in ERB now reachesFlight::SuspenseElement#delay(Sprint Change Proposal 2026-06-16 §4.5).SuspenseElementalready accepted adelay:(the server-side wait, in seconds, before the deferred chunk streams), but the ERB had no way to set it —<Suspense delay="2.5">was silently ignored and every boundary used the default. The preprocessor now extracts the optionaldelayattribute intodata-ruact-delay, andHtmlConverterparses it as aFloatand forwards it toSuspenseElement(absent, blank, or unparseable → the element’s default delay).ActionController::Liveis still required for the wait to stream on a soft navigation.
Tooling
Section titled “Tooling”- Code coverage instrumentation (Story 6.7). Added
simplecovandsimplecov-lcovas development dependencies; gem CI uploads coverage from the canonical matrix cell (Ruby 3.3 × Rails 7.2) to Codecov on every push and PR with thegemflag. Baseline at merge: 88.30% line (581/658), 72.25% branch (239 specs). (Corrects the earlier 87.89% figure recorded at story-merge time, which was a typo of the SimpleCov output for 567/644 = 88.04%; the current numbers reflect the post-review state after Story 6.7 review F1 refactoredhtml_converter.rb#convert_elementinto helpers, which added a few lines and one new spec.) Coverage is informativo (not a CI gate); see Codecov PR comments for diff coverage on individual changes. Diff coverage target per project DoD: ≥ 90% line / ≥ 80% branch on new code. - Spec
rails_stub.rbfix. The previous$LOADED_FEATURES.any? { |f| f.end_with?("/rails.rb") }heuristic for skipping theLOADED_FEATURESinsertion was unreliable — unrelated gems ship files at*/rails.rb(e.g. SimpleCov’ssimplecov/profiles/rails.rb). Replaced with an unconditional insertion guarded only by the existingreturn if defined?(Rails)early-exit, which is the correct invariant.
Renamed
Section titled “Renamed”-
rsc_*public API surface fully migrated toruact_*(Story 5.12; BREAKING). Pre-v0.1.0 clean cut to eliminate the residualrsc_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.Surface Was Now Controller methods rsc_render,rsc_request?,rsc_manifest,rsc_template_exists?,rsc_html_shellruact_render,ruact_request?,ruact_manifest,ruact_template_exists?,ruact_html_shellHTTP header RSC-Request: 1Ruact-Request: 1(the React-Flight-standardAccept: text/x-componentcheck is unchanged)Serializable DSL rsc_props :id, :title·obj.rsc_serialize·Klass.rsc_props_listruact_props :id, :title·obj.ruact_serialize·Klass.ruact_props_listRake task rails rsc:doctorrails ruact:doctorView helper (internal — only visible in stack traces / error messages) __rsc_component____ruact_component__Error message substring (when called outside the flow) "__rsc_component__ called outside an rsc_render flow""__ruact_component__ called outside a ruact_render flow"(classRuact::Errorpreserved)Internal token format (
__RSC_N__→__RUACT_N__), the synthetic Suspense tag (<rsc-suspense data-rsc-fallback="…">→<ruact-suspense data-ruact-fallback="…">), the playground/e2ersc-router.jsJavaScript file →ruact-router.js, and the[rsc-router]log prefixes →[ruact-router]also moved in lockstep. TheRuact::*Ruby module namespace and the Flight wire-protocol identifiers ("$","$L","$SS",text/x-component) are unchanged — those are React-Flight-protocol externalities.Mechanical, not behavioural. Bug 7.8-B (closed by Story 7.9) does NOT regress under Story 5.12: the renamed
__ruact_component__continues to read from the@ruact_render_contextivar Story 7.9 plumbed through_assigns_for_view_context; only the method name and message substring moved. Gem CI’s name-propagation guard gains a second step that fails the build on any residualrsc_*API reference (complementary to Story 5.1’srails_rsc/RailsRscguard, which is preserved unchanged).Migration for host apps that experimented with
ruactv0.0.x: change eachrsc_*identifier listed above to itsruact_*equivalent (e.g.def show; rsc_render; end→def show; ruact_render; end;class Post; include Ruact::Serializable; rsc_props :id; end→ruact_props :id; deployment scripts runningbundle exec rails rsc:doctor→bundle exec rails ruact:doctor). Any host-sideimport { setupRouter } from "./rsc-router.js"adjusts tofrom "./ruact-router.js". -
The gem and its top-level constant were renamed from
rails_rsc/RailsRsctoruact/Ruactbetween v0.0.2 and v0.0.3. Host apps must update theirGemfile(gem "rails_rsc"→gem "ruact") and any code referencingRailsRsc::*(replace withRuact::*). Therails ruact:doctortask (renamed fromrails rsc:doctorin Story 5.12) detects and reports legacy constant usage inconfig/initializers/andapp/.
Internal
Section titled “Internal”-
Rake task descriptions and internal
requirestatements migrated fromrails_rsctoruact. Public API is unchanged; this is a documentation and tooling rename only. -
Render context now passed explicitly (Story 7.1).
Ruact::ComponentRegistry(which usedThread.current) has been removed; the per-render component list is now an instance ofRuact::RenderContextpassed explicitly throughController#ruact_render → RenderPipeline → HtmlConverter. TheRuact/NoSharedStatecop now passes with no exceptions inlib/ruact/. No public API change. Note:Ruact::Flight::*,Ruact::Internal::*, andRuact::RenderContextare 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. -
RenderPipelineentry points consolidated (Story 7.2).RenderPipeline#call,#stream, and#from_htmlhave been removed and replaced with a single#render(input, mode:)entry point.inputselects 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 —:stringreturns aString(deferred chunks inlined eagerly),:streamreturns anEnumeratorof Flight rows (deferred chunks delay). Conflicting input keys, missing siblings, and unknown modes raiseArgumentErrorwith the offending input named. No public API change —Ruact::Controller#ruact_renderis unchanged. Note:Ruact::Flight::*,Ruact::Internal::*, andRuact::RenderPipelineare not part of the public API and may change between minors. See decision note for rationale and contributor guidance.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). -
Ruact::Configurationis frozen after initialization (Story 7.3). TheRuact::Configurationinstance returned byRuact.configis frozen the momentRuact.configure { |c| ... }returns (or, if noconfigureblock is called, on first access). Mutating attributes outside theconfigureblock (e.g.Ruact.config.foo = bar) now raisesRuact::ConfigurationError— a new error class, subclass ofRuact::Error— with a message naming the offending attribute, the caller’s file:line, and the suggested fix. CallingRuact.configurea 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 — theRuact.configure { |c| ... }DSL is unchanged, every existing reader returns the same value as before, and therails generate ruact:installtemplate still works as-is. Note: the gem’s own internal RSpec stubs againstRuact.config(render_pipeline_spec.rb,flight/renderer_spec.rb) were migrated toRuact.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.Migration for any external code that mutated
Ruact.configoutsideRuact.configure: replace any post-bootRuact.config.foo = barwith aRuact.configure { |c| c.foo = bar }block inconfig/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, preferRuact.configure { |c| c.foo = ... }with abefore/aroundreset hook (Ruact.instance_variable_set(:@config, nil)); RSpec stubs (allow(Ruact.config).to receive(...)) no longer work because the underlying object is frozen. -
Ruact::HtmlConverter.convertvalidates inputs at the boundary (Story 7.4). The class-method entry point now raisesRuact::HtmlConverterError(new — subclass ofRuact::Error) when itshtmlargument is not aString. 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 aNoMethodErrorfor:childrendeep in Nokogiri internals. The most common upstream bug — an ERB template, partial, or render path that returnednil— now surfaces with a “Most likely cause” hint pointing at the call site. Separately,Ruact::ClientManifest#reference_forenhances its existingRuact::ManifestErrormessage with a Damerau-Levenshtein closest-match suggestion (e.g.Did you mean "LikeButton"?for a typo’dLikeButtonn), with a fallback hint suggesting the file path to add when no entry within distance 2 exists; passingcontroller_path:biases the suggestion toward co-located keys. No public API change — the public signaturehtml, registry = []is unchanged for valid inputs, every existing spec continues to pass, and theManifestErrorraised on unknown components is the same class with an enhanced message. See decision note for rationale and contributor guidance.Migration: any application code that previously rescued
NoMethodErrororNokogiri::XML::SyntaxErrorfrom a path that flowed through the HTML converter (an unusual pattern; not documented as a public contract) should now rescueRuact::HtmlConverterErrorfor the nil/non-String case. The unresolved-component case continues to raiseRuact::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. -
Flight wire test matchers extended with structural modes (Story 7.5).
gem/spec/support/matchers/flight_fixture_matcher.rbgains two new RSpec matcher modes alongside the existingmatch_flight_fixture(name)snapshot matcher:match_flight_structure(expected)parses the actual wire output via a newRuact::Spec::FlightWireParserand 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 (supportshash_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 newRuact::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 bematch_flight_structure; reservematch_flight_fixturefor 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. -
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 underspec/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. -
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 documentedRuact::ConfigurationErrormessage 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/andgem/spec/are unchanged. The story surfaced two latent issues escalated for follow-up: (a) the demo’sGemfilepreviously pinnedgem "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 topath: "../../gem"; and (b) the controller’s render-context ivar was set onview_context, but Rails 8’srender_to_stringuses a differentActionView::Baseinstance 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:pendinguntil Story 7.9 fixed the gem-side wiring. (Originally written using the pre-renamersc_*method/helper names; rewritten here in Story 5.12 to match the post-rename surface.) -
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 theas_json-returns-self message-shape contract (offending class name +Ruact::Serializable+ruact_props). Nine new specs co-located under the:story_7_7rspec 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 undergem/lib/..rubocop.ymlextendsNaming/VariableNumberwithAllowedPatterns: ['\bstory_\d+_\d+\b']— the exemption matches only the canonical:story_X_Ystory-tag symbol shape; any other snake_case-with-numbers underspec/continues to trip the cop. p99 wall-clock for the tag: 0.0154 s (gate: < 1 s). See decision note.
Phase 1 — the work that predates the first published version (2026-03-24)
Section titled “Phase 1 — the work that predates the first published version (2026-03-24)”Not a release, and deliberately not a version heading. This section records what the project could already do before anything of it was published; the first versions on RubyGems were 0.0.1 and 0.0.2, in April 2026.
The entries below are left as they were written, so they describe the plan of the time — including a
vite-plugin-ruactpublished to npm and co-versioned with the gem. That never became how ruact ships: the plugin is vendored inside the gem and published nowhere. SeeRELEASING.mdfor the release this project actually performs.
- ERB preprocessor — PascalCase RSC component tags (
<Button />,<LikeButton postId={@post.id} />) are transformed to Flight placeholders in ERB templates before Ruby evaluation. <Suspense>support —<Suspense fallback="Loading...">in ERB templates maps to React Suspense boundaries in the Flight payload.- React Flight wire format serializer — Full Ruby-to-Flight protocol implementation covering: nil, booleans, integers, floats (NaN/Infinity/-0), strings (with
$escaping), arrays, hashes,Time/DateTime, large strings (T rows),ReactElement,SuspenseElement, andClientReference. Ruact::Controllerconcern — Include inApplicationControllerto enable RSC rendering. Providesruact_render, RSC request detection (text/x-component/Ruact-Request: 1header), HTML shell generation with inline__FLIGHT_DATA, and Flight-awareredirect_to.- Streaming mode — When
ActionController::Liveis included, Flight rows are streamed to the client as they are produced (Suspense-aware). - Client component resolution —
Ruact::ClientManifestreadspublic/react-client-manifest.json(generated by the Vite plugin) and resolves component names toClientReferenceobjects via a dual-path resolver. Ruact::Serializablemixin —ruact_propsDSL for declaring safe prop attributes on Ruby model objects.- Install generator —
rails generate ruact:installscaffolds the initializer, Vite config patch, and JavaScript entry point. ruact:doctorRake task — Checks manifest presence, Vite server accessibility, controller setup, and streaming mode configuration.vite-plugin-ruact— Vite plugin (npm package, co-versioned) that scans"use client"components and emitspublic/react-client-manifest.json.- Client-side navigation — JavaScript
ruact-router.jsintercepts same-origin link clicks and form submissions, fetches Flight payloads, and updates the React tree without full-page reloads. - Error overlay — Development-mode React error boundary with dismissible overlay for Flight parse and rendering errors.
- RSpec test suite — 223 examples covering all modules: Flight serializer, ERB preprocessor, HTML converter, render pipeline, controller, client manifest, serializable, install generator, and
ruact:doctor. - Memory benchmark —
rake benchmark:memoryenforces a 120% allocation regression gate againstspec/benchmarks/baseline.json. - 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.
- E2E test app —
e2e/Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.