Skip to content

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.

  • The release is the pull request. Publishing used to be turned on by hand — a repository variable a maintainer set before merging and had to remember to delete afterwards — and the version number was then computed by CI, written into lib/ruact/version.rb after the merge, and committed back to main with [skip ci]. Three things followed from that, and all three are gone. The number was predicted when the CHANGELOG heading was stamped and decided afterwards, so the first minor would have been published under a heading naming a patch. The commit every published gem was built from was the one commit no check had ever seen. And the switch’s dangerous state was the one you had to remember to undo.

    Now the pull request owns both: you write the version and stamp the record in the same diff, and merging it is what publishes. The release job asks one question on every push to main — does lib/ruact/version.rb name a version with no tag? — and builds, uploads and tags if the answer is yes. That is a question about state rather than about what a push changed, which buys three properties the old trigger could not have: it is idempotent, so a second run does nothing; it is self-healing, so a release lost because no run was created for a push is published by the next green push instead of being lost forever; and it commits nothing to main, only a tag.

  • Two gates on every pull request, and the same two runnable locally. bin/release-gate asks whether this branch changed anything that goes inside the published gem and, if it did, requires CHANGELOG.md to have gained something — a bullet or a heading, with touching the file not being enough. Separately, and only when lib/ruact/version.rb moved, it requires the new version to be exactly one semver step, to carry no tag already, and to be matched by Gemfile.lock. Nothing forces a bump: a merge without one accumulates under [Unreleased] until somebody decides to release.

  • bin/release X.Y.Z prepares a release, and generates its own checklist. It refuses a dirty checkout, a checkout that is not main exactly as the remote has it, and a version that is not one legal step, then writes the version, re-resolves the lock, moves [Unreleased] under a dated heading, rewrites both link references, proves the result against the gates, and opens the pull request — whose body is the checklist, with the version already substituted. There is no checklist document, deliberately: a checklist is a second copy of the process in the imperative, and this one is generated by the thing that performs it.

  • CONTRIBUTING.md — this repository now tells a contributor how to work in it. Until now the README’s Contributing section pointed at the Issues URL and nothing else, because there was no guide to link: the only contributing document lived in a repository outside contributors can’t see, and it had gone false anyway (it named a class deleted two epics ago and prescribed Thread.current, which a custom cop rejects — following it made CI fail). The new file is executable with this repository alone: what the three directories are and why the Vite plugin ships bundled rather than published, the prerequisites, setup, the checks CI runs on every push and every pull request, with their local equivalents, and a path: block that puts your working tree into a throwaway Rails app — which is also the block that makes the vendoring pay off, because pointing an app at your gem checkout points it at your plugin checkout too. It then names the four gates that go red for reasons a diff doesn’t show (the byte-pinned README specs, the allocation baseline, the three custom cops, and the YARD cache that hides warnings CI still reports), and routes each kind of change to a destination the reader can actually reach, naming the superseded vite-plugin-ruact npm package so nobody mistakes it for one.

    spec/contributing_spec.rb keeps it honest, in the shape spec/readme_spec.rb arrived at: every repo-relative link resolves on disk, no private-side vocabulary leaks, exactly one repository is cloned, the command spine is pinned byte for byte — and the list of CI jobs is parsed out of .github/workflows/ci.yml rather than maintained by hand, so renaming a job reddens the document by construction. It also forbids the document from stating what a check decides at merge time — a claim nothing inside this repository can verify, and therefore one that would rot without ever going red.

  • 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.md now describes the release that actually happens. The file was written for a world with two published artifacts, and every step in it was a step a human performed: create a release branch, hand-edit lib/ruact/version.rb, hand-edit a second package version, tag the merge commit, build and push the gem with an OTP, publish to npm, open a GitHub Release. None of that is what a release is any more — what a release is is the entry below, The release is the pull request. A maintainer following the old file would have hand-edited a file CI rewrites and republished a version CI had already published — and would have looked for a GitHub Release step that nobody has performed across every tag in this repository, which is why that step is gone rather than corrected.

  • Two gates keep both documents honest. spec/releasing_spec.rb reads the release trigger out of .github/workflows/ci.yml rather than from a list somebody maintains, so changing the release mechanism reddens the document that describes it, by construction, instead of leaving it to be remembered. It also refuses the steps nobody performs, refuses a concrete version number anywhere in a file that is republished with every release, pins the command blocks a maintainer copies, and — sharing its lists with spec/contributing_spec.rb through a new spec/support/markdown_gate.rb — refuses private-side paths and the claim that any check gates merging.

    spec/changelog_spec.rb is the first check this file has ever had. It asserts the record’s shape against itself and against Ruact::VERSION, which is enough to have caught the defects listed below.

  • 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 the 0.0.x entries, and was dated a month before the first publish — while 0.1.0 is the version this project has reserved for a milestone it has not reached. It is now a plainly-labelled section for the work that predates the first published version, content unchanged.

  • Arrays keep their length, including children. ⚠️ Behaviour change. The collapse described under Fixed was applied by a walker that sees values without seeing where they sit, so removing it reaches one more path than the reported bug: children passed as an explicit prop on a self-closing tag — <Label children={["hi"]} /> — used to arrive as "hi" and now arrives as ["hi"].

    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 children alone, which preserves the old behaviour at the price of a permanent inconsistency — the same one-element array arriving as an array in every prop except one. A uniform contract is only free to adopt before there are applications depending on the exception.

  • The published gem stopped shipping the repository. spec.files was git ls-files minus four entries, so the whole of spec/ — about two fifths of the tracked tree — plus .github/, bench/, docs/, Rakefile, Gemfile.lock and the RuboCop configuration travelled inside every .gem anybody installed, none of it reachable from an installed copy. What ships is now decided by a predicate, Ruact::Packaging, with a single writer that both the gemspec and the release gate call: lib/, sig/, vendor/javascript/ and the top-level documents. Nothing a consumer loads was removed; the vendored Vite plugin and browser runtime still ship, because they are resolved by filesystem path off the installed gem. spec.bindir and spec.executables are gone with it — this gem has never had an exe/ directory.

  • The README no longer claims props are an allowlist. It said “Props are an allowlist — other columns never cross”, describing the opt-in rather than the default. The default is as_json: every attribute of a model prop crosses to the client, and the gem says so in the log (ALL attributes exposed to client). A reader who took the bullet at face value would have believed the safe behaviour was already on. The bullet now leads with what actually happens, then names ruact_props as the thing that makes it an allowlist and strict_serialization as what turns the permissive path into an error in production.

  • The server-functions bullet no longer calls the whole generated module “typed”. Only queries carry an exact signature (one property per declared keyword, since 0.0.5). An action’s accessor is typed to be callable and to satisfy <form action>, but its arguments and resolved value are Record<string, unknown> and unknown — useful, and not the autocomplete the word “typed” promises. Both are now described as what they are.

  • 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 shape rails generate ruact:scaffold emits — received the row itself instead of a one-row array whenever the collection happened to hold exactly one item. Two or more worked. The payload leaving the server was always correct; the client collapsed it while rebuilding the tree, because the collapse was applied by a walker that sees values without seeing where they sit, and so could not tell a list prop from an element’s children.

    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 children under Changed. The generated scaffold had a second surface with the same shape: a relationship <select> renders {authorOptions.map(...)}, so a resource with exactly one option to choose from threw rather than rendering. That is fixed by the same change. flight-client.js was the only runtime module in the bundled Vite plugin without a unit test, which is what let a change to shared deserialization go unnoticed; it has one now, covering empty, one and two elements for both data props and children, asserting the rebuilt content rather than only its arity, and driven in part by wire bytes the Ruby serializer writes.

  • The README’s first sentence shows the tag instead of naming its casing convention. It read “Write a PascalCase tag in ERB, pass @post as 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. The ERB as server components bullet 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.json had 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 _history key rather than overwritten, and the spec now documents how to tell a regression from drift and how to regenerate the file.
  • README.md — the gem’s public face is no longer bundle gem boilerplate. The file GitHub renders on github.com/luizcg/ruact, the file spec.files packages inside the built .gem, and the destination of source_code_uri still said “TODO: Delete this and the text below”, told the reader to bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG, and pointed contributors at https://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::Server with 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 to ruact.dev instead 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: the AGENTS.md rails generate ruact:install writes, ruact.dev/llms.txt, and the experimental -- --json output of ruact:doctor / ruact:routes.

    Two gates now watch it, because nothing did before: spec/readme_spec.rb runs inside this repo’s own rspec job (no boilerplate literals, every relative link resolves on disk, no path: gem source a reader could copy, and the quick-start block pinned literally), and the monorepo’s command-spine check treats README.md as 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, with Ruact::ChildrenNotSupportedError naming 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 ruact nor 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, and spec/readme_demo_message_spec.rb pins 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 declared spec.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.description and metadata["documentation_uri"] in the gemspec — RubyGems rendered the one-line summary as the whole description and fell through to rubydoc.info for documentation.

  • SECURITY.md named a version that has never existed. Its supported-versions table listed 0.1.x; the released gem is 0.0.9. Corrected to 0.0.x.
  • rails generate ruact:install --shadcn — the --shadcn scaffold path now has a working setup. ruact:scaffold --shadcn emits 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 a tsconfig.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 points components.json at), a tsconfig.json carrying 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, the build:css script, and a css process in Procfile.dev so bin/dev rebuilds the stylesheet alongside Rails and Vite.

    It then prints the two npx shadcn commands instead of running them — they hit the network, and shadcn init is interactive, so automating them is neither safe (the scaffold generator’s “never auto-run npx/npm” rule) nor possible. The printed init line pins --base radix, which is the part nobody would guess: current shadcn defaults to Base UI, while the components ruact:scaffold --shadcn generates import Radix primitives — accepting the default gets you a component library the scaffold cannot use. The printed add list 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-identical package.json and Procfile.dev, all pinned by specs.

    Verified end to end twice from rails new: the generated CRUD renders as a styled shadcn table with the useQuery search filtering rows live. Note that shadcn’s CLI is currently 4.x while shadcn_compatible_versions defaults 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_render rendered the view with layout: false and then wrapped the Flight payload in a hardcoded HTML shell whose <head> holds only charset, viewport, the CSRF meta tag and the title Rails RSC. That shell has no stylesheet slot and never uses your layout, so stylesheet_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 --shadcn emitted 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 in app/assets/stylesheets/application.css pointed 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.layoutfalse by default, true (or a layout name) to opt in. rails generate ruact:install now writes both halves of that opt-in in the same run: config.layout = true in 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. (:auto is 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, or layout 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> in app/views/layouts/application.html.erb, or re-run rails 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:doctor reports 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_for declared inside the view does not reach the layout. Set document metadata from the controller.

    rails ruact:doctor reports the two halves separately, because they are different fixes: a layout missing the root or the helper fails, and a ready layout with config.layout = false warns and names the setting.

  • ManifestError message is now in English (was Portuguese) (Story 15.0). When the Vite dev server is unreachable AND no public/react-client-manifest.json exists on disk, Ruact::ManifestResolver now raises [ruact] Vite dev server unreachable at <url> and no react-client-manifest.json found at <path> — run bin/dev. — same raise site, same ManifestError class, same HTTP→file fallback order, same diagnostic content (dev-server URL + manifest path + the bin/dev fix); only the language changed. Error messages are prompts — agents (and humans) regex-match them — so the stable tokens (Vite dev server, react-client-manifest.json, bin/dev) are preserved and now spec-pinned.
  • ruact_props now works directly on an ActiveRecord model — loud check deferred from class-load to first serialize for lazy attributes (Story 13.7) (Story 13.7). include Ruact::Serializable; ruact_props :id, :title on an ActiveRecord model used to raise ArgumentError: method 'title' is not defined at class-load, because ActiveRecord defines its attribute reader methods lazily (on first instance access) — so the eager method_defined? check fired before the reader existed, even for a real column. The check is now hybrid: a PORO is still validated eagerly at class-load (byte-identical — a typo still raises immediately), while for a lazy-attribute (ActiveRecord) class the not-yet-defined names are recorded and their loud check is deferred to the first ruact_serialize (via respond_to? on the instance, where the DB is up and the reader exists). The loud-omission guarantee is preserved (Epic 13.1/13.2 spine): a bogus/typo’d prop still raises the same clean ArgumentError — only the timing moved for AR (first render of that model instead of boot). No boot-time DB dependency is introduced (attribute_names/column_names would need a live connection at class-load — rejected), the gem stays single-dep nokogiri (the ActiveRecord discriminator defined?(ActiveRecord::Base) && self < ActiveRecord::Base references the constant only when the host already defined it), and there is no new config key, no serializer-pipeline change (an AR model that includes the module already dispatched to serialize_serializableruact_serialize), and strict_serialization is unaffected (an AR Serializable model serializes identically under strict true/false, as a PORO does). This reverses the doc-only guidance shipped in the AGENTS.md/llms.txt serialization-contract fix (gem PR #48): ruact_props on an AR model is now a first-class pattern again; the manual row hash ({ id: p.id, title: p.title }) remains the equivalent for a no-model / partial shape.

  • Auto-revalidate opt-in: one await folds a mutation and its in-place refresh into a single call (FR110) (Story 15.6). A mutation whose result is shown elsewhere on the page has always needed a manual two-step — await createPost(fd); await revalidate() — and the second call is easy to forget, leaving a stale page that reads like a silent bug. There are now two runtime-only, opt-in ways to fold the second call in: an app-wide default via configureRuactRuntime({ autoRevalidate: true }) (mirrors the existing defaultHeaders config), and a per-call withRefresh(accessor) wrapper (await withRefresh(createPost)(fd)) that forces the refresh for one call and wins over the global default. When opted in, a successful, non-redirecting mutation awaits an in-place Flight refresh of the current path (the existing revalidate() machinery) before the promise resolves, then resolves with the action’s JSON result. This is pure client-side composition of the two requests that already exist (the POST-JSON mutation + the GET-Flight refresh): no new wire shape and no inbound Flight deserialization — the serialize-only invariant is untouched. A $redirect response wins: the redirect is followed and the refresh is skipped (the destination already re-renders; refreshing the old path would be wrong). A failed mutation throws before any refresh runs, so no refresh happens. With no router installed, the descriptive revalidate() “wire setupRouter()” error surfaces (it is not swallowed) — so a mutation that succeeds but whose refresh rejects rejects the returned promise, consistent with revalidate()’s loud-by-default stance. Opt-out (default) behavior is byte-identical to before, and the generated .ruact/server-functions.ts is unchanged — withRefresh is imported directly from ruact/server-functions-runtime (like configureRuactRuntime), so the codegen output and the Ruby↔JS byte-parity fixtures are untouched.

  • Dev-only [ruact] log line naming the negotiated response shape per request (FR109) (Story 15.5). The single most confusing thing about a Ruact::Server action is that the same action, at the same URL, answers different body shapes depending only on the request’s Accept header + verb — never on the Ruby. In development only, Ruact::Server now emits exactly one [ruact] line per ruact-negotiated request naming the chosen bucket and the deciding signal — e.g. [ruact] PostsController#create — Accept: application/json + POST → function-call JSON — so the dual-bucket negotiation is observable at runtime without a debugger. It covers the function-call-JSON bucket (including its 204 and $redirect sub-shapes) and, for a page render on a controller that also includes Ruact::Controller, the Flight-page and HTML-shell shapes. Log-only: response body, status, and headers are byte-identical in every environment. It is SILENT outside development and on a plain Rails action on the same controller (a response ruact did not negotiate). Mirrors the F6 warning: a private, dev-gated after_action beside it, no new config key. The log vocabulary matches the new canonical caller→shape table in the server-actions docs. Coverage is Ruact::Server-only for now — queries (separate GET /q dispatch) and page-only controllers are documented in the table but not yet logged (deferred).

  • Public server-side render-assertion helpers: have_ruact_component (FR108) (Story 15.4). A host request/controller spec can now prove a page rendered a given component with given props against the decoded Flight tree — no browser, no brittle string spelunking on the wire bytes. Load it explicitly (require "ruact/testing" from your spec_helper/rails_helper — it is NOT auto-loaded by require "ruact", so a production boot carries no RSpec dependency), then assert expect(response).to have_ruact_component("PostList") or ...have_ruact_component("PostList").with_props(a_hash_including("posts" => ...)). It accepts an ActionDispatch/Rack response (its .body is read) or a raw String, in either page shape — a raw text/x-component body or the HTML shell embedding __FLIGHT_DATA. The component name resolves against the import row the wire actually carries (its export name or module basename); props are asserted in their serialized wire form (string keys, serialized values) via ===/hash_including. Passing a Ruact::Server function-call/query response (plain JSON) raises a clear Ruact::Testing::NotAFlightResponseError pointing you at JSON.parse(response.body) instead. This is a STABLE public matcher that wraps, not forks, the internal Story-7.5 structural parser/diff — those were promoted onto the shipped load path as Ruact::Testing::FlightWireParser / Ruact::Testing::FlightStructureDiff, and the gem’s own Ruact::Spec matchers now delegate to that single implementation (one source of truth, no drift). New docs “Testing” page covers all three response shapes (pages, function calls, queries).

  • Machine-readable introspection: ruact:doctor -- --json and ruact:routes -- --json (FR107) (Story 15.3). A coding agent (or CI gate) can now read the two ground-truth surfaces as JSON instead of parsing terminal prose. ruact:doctor -- --json emits one document — schema_version, an overall status, and every health check as name/status/message/remediation (a separate, nullable machine-readable fix) — reusing the exact same check results the human path prints (no double-run) and preserving the exit code (0 when all checks pass or warn, 1 on any failure). The new ruact:routes task emits the accessor/route table — each entry’s accessor name, kind (action or query), verb, path, segments, and declared params (queries from their keyword arguments, actions from their required path segments) — derived from the very same RouteSource + QuerySource collectors codegen consumes (a shared Ruact::ServerFunctions.introspect combine point), so the introspection can never drift from what codegen emits; it writes no bridge or TS file (a CI gate can run it without mutating the tree). Bare ruact:routes prints a compact human table. The default human ruact:doctor output is unchanged. Both JSON documents are marked EXPERIMENTAL and carry schema_version: 0 — the shape may change without a major bump while the agent-facing surface is iterated, so parsers should gate on it. The flag is passed after a -- separator (bin/rails ruact:doctor -- --json) so neither Rails nor Rake treats it as an option. The emitted AGENTS.md/llms.txt “Verify your work” section now references both commands, flagged EXPERIMENTAL.

  • Children inside a component tag now fail loudly at preprocess time instead of degrading silently (FR106) (Story 15.2). ruact PascalCase component tags are self-closing only — a component receives a props Hash, never a children element tree — but the #1 predictable JSX-habit mistake, <Card>Hello</Card>, used to degrade silently: the opening tag became a __ruact_component__("Card", {}) call with no children while the inner content leaked into the surrounding HTML and the </Card> passed through as literal garbage. The ERB preprocessor now raises a dedicated Ruact::ChildrenNotSupportedError (a Ruact::PreprocessorError, so it flows through the same dev error overlay) naming the component, the template file:line, and the exact fix — pass content as a prop, e.g. a self-closing <Card content={...} />. Detection keys on a matching PascalCase closing tag (</Card>), NOT on “opening tag lacks />”, so a bare non-self-closing opening with no closing tag (<Dialog open={true}>) stays a valid call. It runs after Suspense normalization (Step 1), so <Suspense>...</Suspense> — the one legitimate paired PascalCase tag, already lowercased to <ruact-suspense> — never trips. Output is byte-identical for every valid pattern. The shipped AGENTS.md/llms.txt trap #1 wording is updated from “fails silently” to “fails loudly” now that the artifact exists.

  • Dev-only [ruact] warning when an action registers ruact_errors and then renders explicitly on a function-call request (F6) (Story 15.0). The FR98 errors injection rides the implicit default_render, so an explicit render json: {...} on the failed-save branch of a Bucket-2 (function-call) request silently drops the registered validation errors from the JSON body — a legibility trap for agents following the docs literally. In development only, Ruact::Server now logs exactly one [ruact] warning naming the controller#action, explaining the opt-out, and pointing at the fix (fall through, bind errors={ruact_errors} on a page render, or redirect_to). It never fires on the documented-correct patterns: a Bucket-1 ruact_errors(record); render :new page render, a redirect_to on either bucket, the fall-through itself, or an action that never touches the collector. Log-only — response bodies and statuses are byte-identical in every environment.

  • rails generate ruact:install now emits an AGENTS.md teaching coding agents the ruact conventions, traps, and verification commands (FR105) (Story 15.1). ruact is out-of-distribution for every LLM, so a fresh app now ships the agent context in-repo: the mental model (Rails routes as the single source of truth; ERB as the server component; PascalCase self-closing tags), the verb rule, queries/useQuery, the codegen ground truth (app/javascript/.ruact/server-functions.ts + bin/rails ruact:server_functions:generate), the five traps (children, Ruby-in-braces, Accept-header dual shape, ruact_errors fall-through, name derivation), the ruact_props/strict_serialization allowlist, the SGID helpers, and the verification loop (bin/rails ruact:doctor). The emitted content is delimited by <!-- ruact:begin -->/<!-- ruact:end --> markers and the new create_agents_md action is idempotent and append-aware: no file → create; a user-authored AGENTS.md without markers → the marked section is appended with every pre-existing byte preserved; markers present → skip (re-running install is zero-diff); --force refreshes only the between-marker content, never user bytes outside it. The same compact reference is served by the docs site at https://ruact.dev/llms.txt (kept in sync editorially). New templates/AGENTS.md.tt + :story_15_1 specs (including a ≤160-line template-budget tripwire).

  • 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_prepare read public/react-client-manifest.json once at boot, but Rails frequently booted (and read the still-missing file) before the Vite dev server wrote it — leaving Ruact.manifest nil. public/ is not watched, so to_prepare never re-fired, and the first request to a view containing a component hit nil.reference_for → a cryptic 500. The bundled Vite plugin now serves the live in-memory manifest at GET <vite_dev_server>/__ruact/manifest (always fresh, reflects HMR rebuilds, internal _sourceFile field stripped), and in development the gem resolves the manifest through a new Ruact::ManifestResolver: it fetches that endpoint over Net::HTTP (~1s timeout, once per render/preprocess — not per component), falls back to public/react-client-manifest.json on disk when the dev server is down, and otherwise raises a clear, actionable error (Vite dev server inacessível … rode bin/dev) instead of a NoMethodError. Production is untouched — it still uses the boot-loaded Ruact.manifest (raising at boot if the build is missing); the HTTP fetch is dev-only. The Vite plugin continues to write public/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 hardcoded http://localhost:5173 references in ruact_js_assets (the react-refresh preamble, @vite/client, and the bootstrap <script src>) now honor Ruact.config.vite_dev_server for consistency.

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 in rails routes. A server query is a public method on a Ruact::Query class mounted with ruact_queries → a named GET /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.

  • ruact:install emits the launch files so bin/dev boots a working app — package.json + Procfile.dev + foreman bin/dev (FR101, Epic 14 DoD) (Story 14.6). The clean-room capstone surfaced that a fresh ruact:install wrote no package.json (so Story 14.1’s npm install had nothing to resolve) and no Procfile.dev/bin/dev that boots both processes a ruact app needs — Rails (HTML shell + Flight + server functions) and the Vite dev server (React/HMR + the bundled ruact plugin). ruact:install now writes all three: a package.json declaring react/react-dom + the vite/@vitejs/plugin-react dev deps and a dev: vite script (the bundled ruact Vite plugin is not a dep — vite.config imports it by the absolute Ruact.vite_plugin_path and it uses only node: builtins); a Procfile.dev (web: bin/rails server -p 3000 + vite: npm run dev); and an executable bin/dev that execs foreman start -f Procfile.dev (installing foreman if absent — the Rails-idiomatic launcher). So the literal bin/dev from the Epic 14 DoD now boots the full stack with no manual launch wiring. Every file is guarded and idempotent — an existing package.json/Procfile.dev/bin/dev is left untouched (a skip notice prints) and --force overwrites — so re-running the generator never clobbers a developer’s launcher, and bin/dev is always left executable. New templates/{package.json.tt,Procfile.dev.tt,dev.tt} + create_package_json/create_launch_files (ordered before the npm install step) in install_generator.rb (+ :story_14_6 specs). This closes the getting-started doc’s long-standing promise that ruact:install produces package.json + Procfile.dev.

  • ruact:scaffold --shadcn — shadcn/ui is now an explicit OPT-IN, reusing the Epic 10 components + the (now opt-in) dependency pre-flight (FR103b) (Story 14.5). The new --shadcn flag re-activates the byte-preserved Epic 10 design system: rails generate ruact:scaffold Post title:string body:text published:boolean --shadcn emits PostList (shadcn Table primitive + client-side sort), PostForm (shadcn Input/Textarea/Switch/Select controls mapped per attribute type), and PostDeleteDialog (controlled shadcn AlertDialog) by rendering the byte-unchanged templates/components/{List,Form,DeleteDialog}.tsx.ttrestored from behind the flag, not rewritten. The default (no-flag) path stays design-system-AGNOSTIC (Story 14.4): no @/components/ui/* imports, no Tailwind, native <table>/<input>/<dialog>. The shadcn dependency pre-flight is re-promoted to a --shadcn-gated Thor command (check_shadcn_setup early-returns unless --shadcn): under --shadcn on an app where shadcn/ui is not set up, it surfaces the copy-pasteable npx shadcn@latest init + add <list> guidance and aborts before any write (raise Thor::Error, zero partial state) — no silent agnostic fallback under --shadcn; --skip-shadcn-check (meaningful only under --shadcn) bypasses the abort and writes the shadcn components with the in-file banner. The default path never invokes the pre-flight and never aborts. FR99 (type <Model>Row) + FR100 (__ruactContract) are preserved in the --shadcn .tsx; --shadcn --javascript composes to untyped .jsx shadcn output with the “forfeits FR99/FR100” banner. The controller, route, query, ERB views, model/migration/test delegation (14.3) and smoke spec are identical on both paths--shadcn flips only the three component templates + the pre-flight. The shadcn templates, ShadcnPreflight, --skip-shadcn-check, FormHelpers#form_uses_*?, and the Epic 10 type-tests/scaffold/Post*.tsx fixtures + tsconfig.scaffold.json are byte-unchanged (the only runtime edits are +class_option :shadcn, the create_components branch, and the check_shadcn_setup re-registration + guard in scaffold_generator.rb). The Epic 10 default-output shadcn specs neutralized in 14.4 are un-skipped and re-pointed at the --shadcn path (zero remaining skips); a new :story_14_5 block proves the flag matrix (the @/components/ui grep in both directions + the pre-flight abort vs no-op), and the shadcn tsc --noEmit fixtures rejoin npm run typecheck. This completes the FR103 split: agnostic default for a frictionless first run, --shadcn for the proven Epic 10 styled output when shadcn is already in the app.

  • ruact:scaffold default is design-system-AGNOSTIC — no shadcn prerequisite, no mandatory pre-flight/abort (FR103) (Story 14.4). On a fresh app with no shadcn/ui installed, rails generate ruact:scaffold Post title:string body:text published:boolean now completes without aborting and emits PostList/PostForm/PostDeleteDialog as plain, design-system-agnostic React components — native <table>/<input>/<textarea>/<select>/<input type=checkbox>/<button> and a native <dialog> for the delete confirm — styled by the Rails-default (browser) CSS with no @/components/ui/* imports, no Tailwind utility classes, and no shipped design-system stylesheet. The mandatory shadcn dependency pre-flight that previously raised/aborted on a fresh app is removed from the default path (check_shadcn_setup is de-registered as a Thor command via remove_command, kept defined but dormant). CRUD feature parity is preserved — only the visual primitive changes: the List keeps per-row Edit/Delete + live-search (useQuery) + client-side sort + in-place delete; the Form binds every attribute type (text→input, text→<textarea>, integer/float/decimal→number, boolean→checkbox, date→date, datetime→datetime-local, references→native <select> of controller-provided options) with controlled useState, submits via create<Model>/update<Model>, and surfaces the FR98 attribute-keyed errors inline; the DeleteDialog confirms before destroying (controlled open/onConfirm, { ok, error? } contract, inline error on failure). FR99 (type <Model>Row) + FR100 (__ruactContract) are preserved in the agnostic .tsx; --javascript still strips them and emits untyped .jsx with the “forfeits FR99/FR100” banner. shadcn is NOT deleted — it becomes the opt-in path: the shadcn templates/components/List.tsx.tt / Form.tsx.tt / DeleteDialog.tsx.tt, the ShadcnPreflight module, the --skip-shadcn-check option, and the FormHelpers#form_uses_*? predicates are preserved byte-unchanged and dormant for a follow-up --shadcn flag (Story 14.5, which re-points create_components at them and re-gates the pre-flight). This repositions Epic 10’s shadcn work as the proof it always was (shadcn drops into a Rails app cleanly) — the right proof, the wrong default. New templates/components/agnostic/ templates + FormHelpers#native_input_type + the create_components re-point + the remove_command in scaffold_generator.rb; the CI gate is a :story_14_4 spec (no-@/components/ui grep on the default output + the agnostic tsc --noEmit fixtures under type-tests/scaffold/agnostic/ against an ambient stub that declares no @/components/ui/* module). The Epic 10 default-output shadcn specs (and the :story_10_5 pre-flight block) are neutralized (skip + a Story 14.5 re-points this at the --shadcn path marker), never deleted.

  • ruact:scaffold is self-contained — it generates the model, migration, route and host-framework tests by delegating to Rails resource (FR102) (Story 14.3). rails generate ruact:scaffold Post title:string body:text published:boolean now invokes Rails’ own public resource generator first — producing app/models/post.rb, a db/migrate/*_create_posts.rb migration, the resources :posts route, and host-framework model/controller test stubs — and then overlays the ruact v2 controller, ERB views, React components and query on top. No more hand-running rails generate model … ; rails db:migrate before scaffolding (the Epic 10 dogfooding friction): after the scaffold and rails db:migrate, the resource is ready with no manual model step. Honors your test framework (parity): because the delegation goes through resource, the delegated model/controller stubs land RSpec-under-spec/ or Minitest-under-test/ per your config.generators test_framework — ruact hard-codes none for the delegated stubs. Overlay reconciliation: the v2 controller force-overwrites resource’s bare 7-action controller (silently — no Thor “conflict?” prompt); the route stays a single resources :posts line (resource draws it, ruact’s existing guard no-ops); ruact’s own v2-contract request spec is the single authoritative request spec — it force-wins the RSpec-path collision with resource’s empty stub and is framework-gated so it is emitted only when the host uses RSpec (a Minitest app gets the delegated Minitest tests instead — never a broken rails_helper-requiring file). The generated request_spec.rb PREREQUISITES comment drops the obsolete manual rails generate model … ; rails db:migrate step. The delegation uses only the public resource generator surface (Thor invoke), so it survives Rails-version drift (exercised across the Rails 7.0/7.1/7.2/8.0 CI matrix); the raw field:type strings pass through verbatim so Rails parses the correct migration columns. The sub-generator call sits behind a stubbable invoke_rails_resource seam (mirroring Story 14.1’s run_npm_install) — unit specs (:story_14_3) assert invoke-once-with-args + before-the-overlay ordering + controller-force + framework-gated smoke spec with no booted app; the live end-to-end delegation (real model/migration on disk → rails db:migrate → CRUD round-trip) is proven by Story 14.6’s clean-room Docker E2E. Single runtime file: lib/generators/ruact/scaffold/scaffold_generator.rb (+ the request_spec.rb.tt comment + :story_14_3 specs). The CSS-agnostic default / --shadcn opt-in split is Story 14.4/14.5; check_shadcn_setup is unchanged here.

  • ruact’s JS plumbing is hidden — app/javascript/ is now your code only (FR104) (Story 14.2). After a fresh ruact:install, app/javascript/ contains only your components/ (plus the gitignored, typed .ruact/server-functions.ts) — the framework’s bootstrap entry and runtime wiring no longer sit interleaved with your components, the way Rails hides its own plumbing. Three moves: (1) the React bootstrap entry is a virtual module virtual:ruact/bootstrap served by the bundled Vite plugin from gem-shipped source (vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx), mirroring the existing virtual:ruact/registry — the install generator no longer writes app/javascript/application.jsx, and the generated vite.config input is virtual:ruact/bootstrap; (2) flight-client.js + ruact-router.js moved into the gem runtime dir (they used to be copied into every app) and the virtual bootstrap imports them via absolute specifiers (Vite-compiled into your bundle against your React — single React instance — but invisible to you); (3) the controller’s private vite_tags is promoted to a public Ruact::ViewHelper#ruact_js_assets helper (the dev/prod entry <script> tags + the __FLIGHT_DATA inline script) that Ruact::Controller#ruact_html_shell delegates to (one implementation, controller↔helper parity). The four entry-reference points — Vite input, dev <script src> (/@id/__x00__virtual:ruact/bootstrap), prod Vite-manifest key, and the gem source the plugin loads — all read one source of truth (Ruact.bootstrap_virtual_id) so they can never drift. server-functions.ts is unchanged — it stays the lone visible, typed generated file at app/javascript/.ruact/server-functions.ts, and the locked @/.ruact/server-functions accessor import is untouched (NOT virtualized). Migration: an earlier-layout app deletes app/javascript/{application.jsx,flight-client.js,ruact-router.js}, points its vite.config input at virtual:ruact/bootstrap, and uses ruact_js_assets in its layout (re-running ruact:install --force regenerates the vite.config). Gem files: vendor/javascript/vite-plugin-ruact/{index.js,runtime/}, lib/ruact/{view_helper,controller}.rb, lib/ruact.rb (Ruact.bootstrap_virtual_id), lib/generators/ruact/install/ (+ :story_14_2 specs + a bootstrap vitest).

  • ruact:install is one command — it now runs npm install (FR101) (Story 14.1). After writing every file (initializer / controller concern / layout root / app/javascript/.ruact/ scaffold / vite config / bootstrap entry), rails generate ruact:install now runs npm install in the app root so a fresh app goes straight to bin/dev with no separate JS-dependency step. The npm step runs last (a failure leaves the generated files in place and reported — no rollback). --skip-npm opts out (CI, or a non-npm package manager — install JS deps yourself, then bin/dev). When npm is not on PATH the generator does not crash with a stack trace: it prints a clear, actionable message (install Node ≥ 20, or re-run with --skip-npm) and finishes with the files written. The post-install message now branches on the real outcome — npm-ran → “JavaScript dependencies are installed. … bin/dev”; skipped/unavailable → “install JS dependencies (npm install), then bin/dev” — and never claims deps are installed when they are not. Idempotent on re-run (npm’s own idempotency over a populated node_modules). The shell-out lives behind a stubbable run_npm_install seam so generator specs assert invoke-by-default / skip-under---skip-npm with no real npm or network call. Package-manager auto-detection (yarn/pnpm/bun) is deferred — default-npm is the supported path, --skip-npm the documented escape hatch. Supersedes the old v1 behavior of merely printing an npm install … suggestion. Single runtime file: lib/generators/ruact/install/install_generator.rb (+ :story_14_1 specs).

  • ruact:scaffold List → dep-free shadcn table primitive (drops @tanstack/react-table) (FR81) (Story 10.2b). The generated <Model>List is reworked off the shadcn DataTable recipe (which is built on @tanstack/react-table) onto the plain shadcn table primitive (Table / TableHeader / TableBody / TableRow / TableHead / TableCell from @/components/ui/table — a styled HTML table, no runtime engine) plus a small generated client-side sort. This keeps the scaffold dep-free (matching the dep-free Form + native date inputs) so ruact:scaffold does not silently commit the host app to @tanstack/react-table. The sort is a single useState<{ key; dir } | null> + a generated compareRows comparator: numbers numerically, dates by epoch time (off a generated DATE_KEYS set), booleans/strings sensibly, and null/undefined values always last regardless of direction (a blank cell never jumps to the top); a clickable header toggleSort(key) sets a new key ascending and flips asc↔desc on the active key, always sorting a copy of the rows (never mutating the posts prop). The markup is fully unrolled (one <TableHead> / <TableCell> per attribute), reading row.<attr> directly (no row.getValue / row.original). Every other Story 10.2 / 10.4 behaviour is preserved exactly: the per-attribute type-aware cells (text / Badge boolean / right-aligned tabular-nums numeric / locale-formatted date), the per-row Edit link + controlled <Model>DeleteDialog delete (RowActions, destroy<Model>({ id }), in-list removedIds tombstone removal, narrow-viewport DropdownMenu overflow), the server-rendered posts props path (no as_json), the useQuery(search<Plural>, { q }) search (FR99), type <Model>Row (FR99), __ruactContract = { props: { posts: "required" } } (FR100), the empty / searching / no-match states, and the --javascript (.jsx) forfeit. The controller, ERB views, query templates (<plural>_query.rb, application_query.rb), and ruact_queries route injection are untouched. Verified by content-marker generator specs (incl. negative asserts: no @tanstack / @/components/ui/data-table / ColumnDef / <DataTable / row.getValue / toggleSorting) + an isolated tsc --noEmit of the regenerated type-tests/scaffold/PostList.tsx fixture against a new ambient @/components/ui/table stub (the @tanstack/react-table + @/components/ui/data-table ambient modules dropped). Reworked templates/components/List.tsx.tt; added ScaffoldAttribute#date?; updated scaffold_generator_spec.rb + type-tests/scaffold/{ambient.d.ts,PostList.tsx}. Story 10.5 note: the List now needs only npx shadcn add table — not the data-table recipe or @tanstack/react-table.

  • ruact:scaffold shadcn dependency pre-flight — detect / guide / version-compat (FR81) (Story 10.5). ruact:scaffold now runs a shadcn/ui dependency pre-flight as its FIRST step (before any file is written): it detects the host’s shadcn state (components.json + app/javascript/components/ui/*) and either reuses a complete setup, or prints a copy-pasteable npx shadcn command sequence and aborts before writing anything (zero partial state) when the setup is missing or partial. Missingnpx shadcn@latest init + a single npx shadcn@latest add <full list>; partial → the exact missing component names + a targeted npx shadcn@latest add <missing>. The generator never auto-runs npx/npm (no surprising network/install behavior — critical for CI). The abort is a raise Thor::Error (clean CLI message, non-zero exit, no Ruby backtrace), mirroring the existing unknown-attribute-type guard. --skip-shadcn-check bypasses the abort and writes anyway, emitting a prominent in-file comment banner at the top of each generated component naming the unresolved @/components/ui/* imports + the exact fix command (the Vite build then fails loud on the missing import — never a silent blank render). The default/configured path is byte-identical to before (the banner is gated strictly behind a shadcn_missing? predicate → the 10.1–10.4/10.2b tsc byte-equality fixtures stay valid without regeneration). Dep-free by construction (AC7): the add-list is derived from the templates’ own import predicates (FormHelpers#form_uses_*?) so it can never drift from the emitted imports — button input textarea switch select label badge table alert-dialog dropdown-menu, every entry a plain npx shadcn add primitive: there is NO data-table recipe and NO @tanstack/react-table dependency (Story 10.2b removed the engine; the List is a plain table), locked in by a negative spec assertion (the printed guidance never contains @tanstack/data-table). Version-compat (AC6): the new Ruact.configuration.shadcn_compatible_versions (a non-empty Array of major Integers, default [1, 2], with non-empty-Array + Integer-only writer validation) is checked against the installed shadcn major (read best-effort from package.json shadcn/legacy shadcn-ui; nil when run via npx → a soft note, not a warning). An out-of-range major emits a warning (never a hard stop) naming the installed + tested versions and the docs link, with the documented override (add the major to shadcn_compatible_versions once verified). CI exercises ≥2 shadcn majors at the detection/validation level via committed components.json/components/ui//package.json fixtures (no network — the real npx shadcn install + render is Story 10.7). This story adds a generator pre-flight + one config attribute + a comment-banner conditional only — no component-template runtime change, no wire-contract change. New lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb + check_shadcn_setup task + --skip-shadcn-check option in scaffold_generator.rb + the shadcn_compatible_versions config attribute; the three templates/components/*.tsx.tt gain the conditional banner; extended scaffold_generator_spec.rb + configuration_spec.rb.

  • ruact:scaffold DeleteDialog → controlled shadcn AlertDialog + in-list removal (FR81) (Story 10.4). The generated <Model>DeleteDialog is upgraded from a native window.confirm button to a controlled shadcn AlertDialog (open / onOpenChange / <singular> / onConfirm props): it reads Delete "<title>"? (the model’s first string display attribute — falling back to a text column, then id; never a hard-coded title, via the new display_attribute helper) with a This action cannot be undone. body and a Cancel (default focus) / Delete (destructive) footer. Confirm calls the generated destroy<Model>({ id }) accessor (DELETE /<plural>/:id). Two server-owned success shapes: the generated default is delete-in-list#destroy returns { ok: true } (no redirect, aligned to the golden), and the rewired <Model>List removes that row from local state in place (no reload, no client-built URL); a commented delete-from-show redirect_to <plural>_url alternative is carried one line below (the accessor follows the emitted { "$redirect" } automatically). There is no Rails method=delete fallback — the destroy<Model> accessor is the only path. On failure (e.g. restrict_with_exception on a foreign key) #destroy does not hand-roll a rescue: the gem’s existing structured-error middleware shapes the dev/prod response, the accessor throws it, and the dialog stays open with the message inline. List rewiring: the rendered rows become local state (seeded from the posts prop) so a delete can drop one; the per-row actions are driven by a small in-file RowActions component (its own open useState) across both the inline (≥ md) and overflow-menu (< md DropdownMenu) layouts. The columns config moves inside the component (design B — useMemo) so the actions cell closes over component state, leaving the DataTable’s { columns, data } interface unchanged (no new Story 10.5 coupling). The List preserves its useQuery search, the __ruactContract = { props: { posts: "required" } } (FR100), type <Model>Row (FR99), the sortable columns, and the empty/searching states. The .tsx DeleteDialog is typed (FR99) and retains an inert __ruactContract (it is referenced only from List.tsx JSX, never an ERB tag, so the 13.5 call-site validator never fires); --javascript forfeits the TS-only markers but keeps the controlled-dialog + in-list-removal + server-error behaviour identical. Scope boundary with Story 10.5: the dialog imports @/components/ui/alert-dialog (already on 10.5’s add-list — adds nothing new to the 10.5-owes tally) but installs nothing; the live demo is Story 10.7. Verified by content-marker generator specs + an isolated tsc --noEmit of the generated .tsx against an ambient @/components/ui/alert-dialog stub (the type-tests/scaffold/PostDeleteDialog.tsx + rewired PostList.tsx fixtures kept byte-identical to the live render by an rspec equality gate). Upgraded templates/components/DeleteDialog.tsx.tt (rewrite) + templates/components/List.tsx.tt (rewire) + templates/controller.rb.tt (#destroy) + ScaffoldGenerator#display_attribute; extended scaffold_generator_spec.rb + the typecheck harness.

  • ruact:scaffold Form → shadcn controls mapped by attribute type (FR81) (Story 10.3). The generated <Model>Form is upgraded from bare HTML inputs to shadcn form primitives mapped per ActiveRecord attribute type: stringInput; textTextarea; booleanSwitch; integer/float/decimalInput type="number"; dateInput type="date"; datetimeInput type="datetime-local"; referencesSelect. Each field is a consistent Label + control + inline-error layout, the per-field error driven by the FR98 attribute-keyed errorsFor(attr) map (plus the top-level base block) — server-only validation with full round-trip feedback (client-side validation is Story 10.6). Dep-free controlled state (design B+): the form keeps the golden’s controlled useState + shadcn input primitives (Radix-based — no react-hook-form/zod runtime dep), with a documented opt-in trail for swapping to react-hook-form + shadcn <Form> if rich client validation is wanted. Native date/datetime controls: shadcn-styled <Input type="date"|"datetime-local"> round-trip the exact wire formats the serializer already emits (YYYY-MM-DD / YYYY-MM-DDTHH:MM) — no Popover+Calendar recipe, no date-fns dep (the rich picker is a documented follow-up). One component for new + edit via <<Model>Form initial={@record} /> (initial null for new, the serialized row for edit — no as_json), calling the generated create<Model>/update<Model> accessors; success navigation stays server-driven (redirect_to$redirect the accessor follows — the component builds no URL, no window.location.assign). references options (AC6): the generated new/edit controller actions load a capped, labelled options ivar (@author_options = Author.limit(101).map { … }, label = name → title → to_s) passed as a prop the <Select> consumes (ivar → prop, ≤ REFERENCE_OPTIONS_LIMIT); a larger parent set’s server-search combobox is a documented opt-in trail. The .tsx is typed (FR99 type <Model>Row + the options prop) and carries __ruactContract = { props: { initial: "optional" } } (FR100); --javascript forfeits the TS-only markers but keeps every control + the server-driven behaviour. Scope boundary with Story 10.5: the Form imports input/textarea/switch/select/label/button from @/components/ui/* but does not install shadcn or emit any components/ui/* file (10.5 owns that; native date inputs mean no calendar/popover needed; the live demo is 10.7). Verified by content-marker generator specs + an isolated tsc --noEmit of the generated .tsx against ambient stubs (the type-tests/scaffold/PostForm.tsx fixture kept byte-identical to the live render by an rspec equality gate). Upgraded templates/components/Form.tsx.tt + templates/controller.rb.tt (references options) + templates/views/{new,edit}.html.erb.tt; new lib/generators/ruact/scaffold/scaffold_form_helpers.rb + ScaffoldAttribute#shadcn_control; extended scaffold_generator_spec.rb + the typecheck harness.

  • ruact:scaffold List → shadcn DataTable + client-driven search query (FR81) (Story 10.2). The generated <Model>List is upgraded from a plain <table> to a shadcn DataTable: a typed columns: ColumnDef<<Model>Row>[] config with per-attribute cell renderers (boolean → Badge “Yes”/“No”; integer/float/decimal/references → right-aligned numeric; date/datetime → locale-formatted; string/text → text), client-side sortable column headers (the dataset is the controller’s index payload — server-side sort/pagination is Phase 3), and a per-row actions cell (Edit link + delegation to <Model>DeleteDialog) that collapses into a DropdownMenu under the md breakpoint so it never pushes data columns out of layout on narrow viewports. The component keeps its FR99 type <Model>Row + the opt-in __ruactContract = { props: { posts: "required" } } (FR100), and gains a documented, prop-configurable empty state (emptyLabel). Props vs. query, by construction. The initial list stays server-rendered props (<<Model>List posts={rows} />, no as_json — AC4); only the client-driven search box becomes a query (AC5): the generator now also emits app/queries/<plural>_query.rb (class <Plural>Query < ApplicationQuery with a search(q:) method — FR88 kwargs — a case-insensitive LIKE over the model’s string/text columns, returning the SAME row shape the index serializes), creates app/queries/application_query.rb (< Ruact::Query) idempotently (ruact:install does not ship it; a second scaffold never clobbers a customized base), injects ruact_queries <Plural>Query into config/routes.rb (idempotent on re-run), and wires the List’s search box to useQuery(search<Plural>, { q }) (typed FR99 accessor, aliased from the codegen’s generic search export). The query .rb is language-agnostic (emitted in both modes); --javascript forfeits the TS-only ColumnDef/type/__ruactContract markers but keeps the DataTable + search wiring. Scope boundary with Story 10.5: 10.2 imports DataTable from @/components/ui/data-table and Badge/Button/DropdownMenu from @/components/ui/* but does not install shadcn or emit any components/ui/* file — 10.5 owns that (incl. the data-table.tsx recipe + the table primitive its current add-list omits; the live end-to-end demo is 10.7). Verified by content-marker generator specs + an isolated tsc --noEmit of the generated .tsx against ambient @/components/ui/* / @tanstack/react-table / server-functions stubs (a new tsconfig.scaffold.json + type-tests/scaffold/ fixture kept byte-identical to the live render by an rspec equality gate, run in the existing js CI job). New templates templates/queries/{query,application_query}.rb.tt; new lib/generators/ruact/scaffold/scaffold_attribute.rb; upgraded templates/components/List.tsx.tt + generator tasks/helpers + scaffold_generator_spec.rb.

  • Component auto-registry — the build emits MODULE_REGISTRY, no hand-maintained registration (Story 10.1b). A fresh ruact:install app no longer ships an empty const MODULE_REGISTRY = {} that the developer must hand-edit (import + register) for every "use client" component. vite-plugin-ruact now exposes a virtual module virtual:ruact/registry — its default export is the { [manifest id]: moduleExports } map, derived from the same scan that writes public/react-client-manifest.json. Adding or removing a component under app/javascript/components/ is zero edits to application.jsx; a freshly ruact:scaffold-generated resource hydrates in a fresh app with no manifest-resolution 500 (closes the Story 10.1 fresh-app gap + dogfooding friction #1). Id-match by construction (NFR16 dev/prod parity): the registry is keyed on the manifest id verbatim (the value the gem serializes as the Flight moduleId, client_manifest.rb), and the eager registry inlines every component into the app bundle (the Flight client resolves from the registry, not the Import row’s chunks) — so a component never becomes a standalone facade chunk and its id stays the source-relative path in prod exactly as in dev; a real vite build test asserts the built registry’s keys equal the emitted manifest ids. Opt-out is by placement (a file outside app/javascript/components/, or without a "use client" directive, is not registered). The install template (application.jsx.tt) now does import MODULE_REGISTRY from 'virtual:ruact/registry' instead of carrying a hand-maintained literal. Plugin-only change (no gem Ruby / wire-contract change); new vitest registry.test.mjs (incl. the load-bearing prod-build id-match) + reconciled the scaffold playground onto the shipped path.

  • rails generate ruact:scaffold — a complete CRUD skeleton on the v2 contract (FR81) (Story 10.1). rails generate ruact:scaffold Post title:string body:text published:boolean produces a working CRUD at parity with rails generate scaffold, with typed React components as the differentiator: a controller (PostsController, the seven RESTful actions, include Ruact::Server), resources :posts, four ERB views, three plain working React components, and a light controller smoke spec. v2 controller shape: GET actions set ivars only (implicit default_render, made safe on any Accept by Story 10.0); create/update/destroy are the route-derived server functions — Bucket-2 JSON, server-driven redirect_to on success ($redirect, the runtime follows it — no client URL building), and the FR98 ruact_errors(@post) keyed-errors channel on validation failure; strong-params read each attribute explicitly with ActiveModel::Type::Boolean coercion. Components are .tsx by default — typed against the server boundary (FR99: a generated type PostRow + the codegen-typed createPost/updatePost/destroyPost accessors) and carrying an opt-in __ruactContract (FR100) so call sites validate at preprocess time; PostForm imports only the action accessors (no query — that is Story 10.2). A --javascript flag emits untyped .jsx (documented: it forfeits FR99/FR100). References default to the RESTful raw :id; every FR96 SignedGlobalID surface (Ruact.locate_signed finder swap, Ruact.signed_global_id mint, a publish-by-signed-token action) ships commented — an opt-in the developer flips on per endpoint, never imposed, never closing off the raw-:id path. An unknown attribute type fails the generator before any file is written with a message listing the supported set + the docs pointer; the route injection and templated files are idempotent on re-run (no silent overwrite). The skeleton’s plain components are upgraded to shadcn DataTable/Form/AlertDialog by Stories 10.2/10.3/10.4. New generator lib/generators/ruact/scaffold/ (scaffold_generator.rb + templates); new spec spec/ruact/scaffold_generator_spec.rb.

  • Compile-time component contract — HEEx-style attr/slot call-site validation (FR100) (Story 13.5). A <Component prop={...} /> call site in an ERB view is now validated against the component’s opt-in contract at preprocess time — a missing required prop, a typo’d prop name (postID vs postId), or a missing required slot raises before the page renders, naming the component, the call site file:line, the offending prop, and a Damerau-Levenshtein “did you mean?” suggestion — instead of surfacing as a silent undefined in the browser. This is the consumer-side mirror of ruact_props (which validates the producer at class-load). Contract source (design A). A component opts in by exporting __ruactContract from its own .tsx (HEEx spirit — declared next to the component): export const __ruactContract = { props: { title: "required", subtitle: "optional" }, slots: { header: "optional" }, passthrough: false }. The Vite plugin’s component scanner extracts it names-only (a brace-balanced scan + targeted regexes — no TS-AST, no eval) into an optional, byte-additive contract field on the manifest entry; a component without the export emits no contract field, and a malformed declaration is warned + skipped (the Ruby side fails open). Check location is forced to Ruby preprocess-time — ERB <Component> tags are invisible to TS/codegen; the validator (Ruact::ComponentContract) hooks the existing COMPONENT_TAG_RE transform loop, with the template path threaded through as template.identifier and the contract registry an injectable seam defaulting to the frozen Ruact.manifest (no per-call disk read). NAME-level only (required / unknown / slot): prop values are arbitrary render-time Ruby expressions the preprocessor never evaluates, so value-type checking is out of scope (reflection-honest, mirroring 13.4; component value typing is a later concern). Slots are minimal/name-level — expressed at the call site as named prop attributes (render-prop style), required-by-presence; a contract with no slots makes slot checking a no-op; a typed slot runtime is deferred. Opt-in / fail-open / byte-identical: a contract-less component gets zero validation and byte-identical emitted output, and the no-tag fast path reads no registry (both regression-pinned — the load-bearing non-breaking guard). The error is a Ruact::ComponentContractError (a Ruact::PreprocessorError subclass, so it rides the dev error overlay / NFR30 lineage). The Damerau-Levenshtein closest-match (Story 7.4) is factored out of ClientManifest into a shared Ruact::StringDistance. This + FR99 (13.4) are the two BLOCKING gates so the Epic 10 scaffold output is typed AND contract-validated by construction (the playground build-fails proof is Story 13.6). New files lib/ruact/component_contract.rb, lib/ruact/string_distance.rb; new specs component_contract_spec.rb, string_distance_spec.rb + :story_13_5 cases in erb_preprocessor_spec.rb / client_manifest_spec.rb + a buildManifest contract-extraction vitest. ADR addendum (2026-06-26).

  • Typed query accessors — codegen_v2 emits real params types (FR99) (Story 13.4). The generated server-functions.ts now types each query accessor’s params from the query method’s declared keyword arguments instead of an opaque Record<string, unknown>. A query def search_users(term:, limit: 10) emits searchUsers: (params: { term: string | number | boolean | null; limit?: string | number | boolean | null }) => Promise<unknown>named keys (autocomplete), exact optionality (keyreq → required, key → optional), so a missing-required param, an unknown key, and a wrong-typed/wrong-arity call are all compile-time errors. Closes the parameter/accessor any gap (a BLOCKING gate for the Epic 10 scaffold to be typed by construction). Reflection-honest value type. Ruby’s Method#parameters exposes only names + required/optional — never types or defaults — so per-param scalar precision (limit?: number) is not reflectable; the value type is the exact FR88 wire union string | number | boolean | null the query-string sanitizer already enforces (keys + optionality ARE exact). True scalar precision is deferred to a future explicit param-type DSL. Scope. Queries only — action signatures are byte-identical (actions read params dynamically, no reflectable declared-input source; already FormData | Record<string, unknown>, not bare any); return types stay Promise<unknown> (useQuery<T> remains the return-typing lever). A **keyrest query fails open ({ named } & Record<string, unknown>). Per-kwarg metadata is derived in QuerySource.build_entry (params + params_rest, accepts_params retained), carried verbatim through the snapshot JSON, and emitted byte-identically by both the Ruby Codegen::V2 and the JS renderQueryExportV2 (the cross-impl parity test gains a typed-query fixture). The new metadata is a trust boundary — both renderers reject a malformed snapshot rather than emit injected TS. A tsc --noEmit type-level test (new typescript build-only devDependency + type-tests/ in the vite-plugin suite, wired into a new js CI job) proves the emitted types are enforced. No runtime .d.ts change, no client-runtime behavior change. ADR addendum (2026-06-26).

  • Inertia-style validation errors round-trip — ruact_errors(record) (Story 13.3). A server action that saves a model can now round-trip field-level validation failures back to the form as an always-present structured errors object — { [attribute]: string[] }, full messages keyed by attribute (a base-level error keys under "base"), {} on success — without hand-rolling an error channel per action. Opt-in and explicit (design (B), the ruact_props/allowlist grain that 13.1/13.2 reinforced — NO auto-injection, NO globally reserved prop): the developer calls ruact_errors(@post) once after the save attempt. Because the shape derives from record.errors (empty on a valid record), the SAME call yields {} on success and the populated map on failure — one client code path for both. Pure normalizer Ruact::ServerFunctions::ValidationErrors.normalize (no Rails/Ruact.config/request reads) accepts an ActiveModel-ish record, a raw ActiveModel::Errors, or a pre-shaped Hash and is idempotent on canonical input. Dual-bucket. Bucket 2 (imperative await): when the collector was touched, Ruact::Server#default_render injects it under the reserved JSON key errors alongside the serialized ivars (so result.errors always surfaces) — an untouched collector preserves the Story 9.2 204 No Content empty contract unchanged. Bucket 1 (native form / navigation): on a Flight redirect_to the errors are stashed in flash[:ruact_errors] (single-use, session-backed) and arrive as an errors prop on the re-rendered page (<PostForm errors={ruact_errors} />); a session-less API-only host degrades to the Bucket-2 body path. Extends — does not replace — the Story 8.4/9.1 structured-error chain: that chain stays the raised-ActiveRecord::RecordInvalid → 422 overlay path; FR98 is the non-exception if record.save … else … happy-failure path. No client-runtime change (the errors key rides parseResponse verbatim); no codegen change. New files lib/ruact/server_functions/validation_errors.rb, lib/ruact/validation_errors_collector.rb; new spec validation_errors_spec.rb + request-level :story_13_3 cases in server_bucket_request_spec.rb / controller_request_spec.rb. ADR addendum (2026-06-26).

  • Signed, scoped, expiring record references — Ruact.signed_global_id / Ruact.locate_signed (Story 13.2). The canonical way to pass a model reference across the wire is now a SignedGlobalID token, not a raw id or attribute dump. Ruact.signed_global_id(record, for: :post_edit, expires_in: 1.hour) mints an HMAC-signed token bound to a for: purpose and an expires_in: lifetime (returns a plain String, so it serializes as a prop with no serializer change); Ruact.locate_signed(token, for: :post_edit) resolves it back, raising Ruact::InvalidSignedGlobalIDError — mapped to a clean HTTP 400 by the Story 8.4 structured-error chain (no ActiveRecord::RecordNotFound leak, no raw-id trust) — on a tampered, expired, or wrong-purpose token. Opt-in and explicit (it follows the ruact_props allowlist grain — no auto-detection of records in props, no auto-coercion of params). The purpose and expiry are required: omitting both the call argument and the configured default raises loudly rather than mint an unscoped/non-expiring token (an explicit expires_in: nil is honored as a deliberate non-expiring choice). Two new config keys document app-wide defaults: Ruact.config.signed_global_id_default_purpose and Ruact.config.signed_global_id_default_expires_in (both default nil). globalid (which ships with Rails) is lazily required only when a helper is called, preserving the single hard runtime dependency. New file lib/ruact/signed_references.rb; new spec signed_references_spec.rb + request-level :story_13_2 cases in query_request_spec.rb. ADR addendum (2026-06-26).

  • useQuery request de-duplication (Story 9.6). Identical concurrent useQuery calls now share ONE network request: when three components mount useQuery(categories) with the same params while a request is in flight, exactly one GET /q/categories is issued — all of them receive the same resolved data, and an error propagates to every sharer. The dedup key derives from the query reference identity + the serialized params, with order-independent param serialization ({ a: 1, b: 2 } and { b: 2, a: 1 } share a request; different params do not). Scope is in-flight only — there is no TTL cache and no stale-while-revalidate: once a request settles its shared entry is dropped, so a fresh mount refetches. Runtime-only change (useQuery hook); the _makeQuery GET accessor, codegen, and the Ruby query dispatch are unchanged. Runtime package version 0.3.00.4.0; coverage in usequery.test.mjs.

  • Queries in codegen + useQuery hook + FR88 kwargs sanitization (Story 9.5). The read-side of the route-driven model is complete: import { categories, useQuery } from "@/.ruact/server-functions" and useQuery(categories) / useQuery(searchUsers, { q: input }) work against Ruact::Query classes — same mental model as mutations, zero new concepts. Codegen. Ruact::ServerFunctions::QuerySource derives query entries from the drawn route table (the GET routes ruact_queries mounted under the generated query-dispatch namespace) — route-truth-consistent with dispatch, so only classes actually mounted in routes.rb are exposed (no over-exposure). Query entries join the same merged JS namespace as mutations; collisions fail loudly at boot naming both origins (the ruact_function_name rename macro on the mutation controller — or renaming the query method — is the escape hatch). The emitted module binds each query to _makeQuery({ path, kind: "query" }) with a per-query TS signature (() => Promise<unknown> when the method declares no kwargs, (params: Record<string, unknown>) => Promise<unknown> when it does) and re-exports useQuery from the runtime (only when ≥1 query exists). The Ruby↔JS byte-equality parity test covers query entries. Runtime. useQuery(reference, params?) React hook → { data, loading, error } (loading until first resolution; structured RuactActionError into error; superseded in-flight responses dropped; refetch on value-changed params); _makeQuery GET helper issues GET /q/<jsId> with params in the query string — no body, no CSRF (reads are CSRF-free). The runtime gains react as a peerDependency (its first React import; the mutation path stays React-free); package version 0.2.00.3.0. FR88 sanitization. query_dispatch.rb#__ruact_query_kwargs enforces the kwargs allowlist on request.query_parameters: only string | number | boolean | null — arrays (?q[]=) and objects (?q[k]=) are rejected naming the key and the allowlist; a missing required kwarg → 400; an unknown param → 400 (rejected, not silently dropped). All raise Ruact::BadRequestError → HTTP 400 via the structured error payload. New file lib/ruact/server_functions/query_source.rb; new spec query_source_spec.rb + :story_9_5 cases across the codegen / railtie-integration / query-request specs; new runtime vitest usequery.test.mjs + query parity cases. ADR addendum (2026-06-10).

  • Ruact::Query base class + ruact_queries route macro (Story 9.4). Server QUERIES are plain classes under app/queries/ (class CatalogQuery < ApplicationQuery, ApplicationQuery < Ruact::Query) — each public method is one query — mounted with one line in routes.rb: ruact_queries CatalogQuery draws one named GET route per public method (def search_usersGET /q/searchUsers, named ruact_query_searchUsers), all visible in rails routes. The prefix is configurable via Ruact.config.query_route_prefix (default "/q"). Dispatch goes through an internal gem controller — one generated subclass per query class — inheriting Ruact.config.query_parent_controller (default "ApplicationController", constantized lazily at route-draw), so the host’s REAL callback chain (authenticate_user!, tenant scoping, Pundit) runs before the query class is instantiated. The query instance is fresh per request and receives its context via the constructor: current_user / params / request / session delegate to the dispatching controller, and CatalogQuery.new(fake_context).categories is unit-testable with no Rails boot. Per-query callback opt-out via ruact_skip_before_action (mirrors Rails’ skip_before_action signature incl. only:/except:/raise: false). Queries are GET — no CSRF; the return value serializes through the same ruact_props / Ruact::Serializable / strict_serialization policy as a Bucket-2 mutation response (nil → JSON null with 200), and a query raise renders the structured-error payload with the 422/403/413/500 mapping. New files lib/ruact/query.rb, lib/ruact/routing.rb, lib/ruact/server_functions/query_dispatch.rb, lib/ruact/server_functions/query_context.rb. New specs query_spec.rb, query_context_spec.rb, query_request_spec.rb + extensions to configuration_spec.rb / bucket_two_payload_spec.rb, all tagged :story_9_4.

  • Route-driven codegen for mutations + runtime re-target (Story 9.3). The generated server-functions module is derived from the Rails route table: every non-GET routed action (POST/PUT/PATCH/DELETE) on a controller that include Ruact::Server becomes a callable server function — resources :posts is the only declaration, no routes.rb additions, no synthetic endpoint. Ruact::ServerFunctions::RouteSource collects entries from the route set; the locked naming derivation (recorded in the ADR) covers RESTful writes (posts#createcreatePost), custom member/collection routes (posts#publishpublishPost; posts#publish_allpublishAllPosts), singular resources (resource :sessioncreateSession), and namespaced controllers with a prefix scheme (admin/posts#createcreateAdminPost) so the merged JS namespace is collision-free by construction. Collisions fail loudly at boot naming both origins; the ruact_function_name :action, as: "jsId" macro on Ruact::Server is the per-action rename escape hatch. The build always logs [ruact] codegen: exposing … (transparency over silence). The runtime’s _makeServerFunction({ method, path, segments }) accessor targets the real route + verb (e.g. POST /posts, PUT /posts/:id), interpolating :id-style path segments by name from the single FormData/object argument, and follows a Bucket-2 { "$redirect": "<path>" } response client-side (via globalThis.__ruact_navigate, window.location.assign fallback). FormData branching, CSRF meta injection, text-first parsing, RuactActionError, redirect: "error", the intersection action signature, and revalidate() are all carried by a shared ruactInvoke core. The Ruby↔JS codegen byte-equality parity test is retained and extended for v2. New specs: route_source_spec.rb, server_function_name_spec.rb, v2 cases in codegen_spec.rb / snapshot_spec.rb / railtie_integration_spec.rb (tagged :story_9_3); +runtime vitest characterization tests; +codegen parity vitest cases.

  • Dual-bucket response negotiation on the same controller action (Story 9.2). One non-GET Ruact::Server action serves both form/navigation submits and imperative await fn() calls, discriminated by how it was called — no respond_to blocks. Bucket 1 (form/navigation, Accept: text/x-component or a browser submit) renders via the existing Flight mechanism (re-render / Flight redirect row), unchanged. Bucket 2 (imperative, Accept: application/json) returns a JSON object of the action’s exposed instance variables — Rails view_assigns, keyed by ivar name without @ (@post{ "post": {...} }), each value through the ruact_props / Ruact::Serializable / strict_serialization rules (a single ivar stays keyed, no unwrap); a redirect_to surfaces as { "$redirect": "<path>" }; an action that sets no exposed ivars and does not redirect returns 204 No Content (the generated ref resolves null); a serialization failure raises Ruact::SerializationError → structured 500. Vary: Accept is set on every non-GET response shape. CSRF on Bucket 2 is the host’s own protect_from_forgery (missing/invalid → 403; API-mode accepts). New pure serializer Ruact::ServerFunctions::BucketTwoPayload. New specs bucket_two_payload_spec.rb, server_bucket_request_spec.rb (tagged :story_9_2).

  • Ruact::Server concern (Story 9.1). include Ruact::Server in a controller installs the server-functions infrastructure on the host’s own callback chain: the structured-error renderer (rescue_from StandardError + explicit ActionController::InvalidAuthenticityToken registration — uncaught exceptions on function-call requests render the _ruact_server_action_error: true JSON payload with the 422/403/413/500 status mapping, dev/prod payload split via Ruact.config.dev_error_payload_enabled, host rescue_from precedence preserved) and the max_upload_bytes upload guard (prepended before_action; rejects oversized multipart/form-data / application/x-www-form-urlencoded bodies with a structured 413 BEFORE CSRF verification; skips GET/HEAD; carve-outs: nil limit, non-form content types, absent Content-Length). Function-call requests are the exact non-GET/HEAD shape the generated accessors send: Accept: application/json. All other shapes — GET pages, browser form submits, Flight navigation, GET/HEAD JSON probes — keep stock Rails behavior. An oversized upload on a non-GET guarded request renders the structured 413 regardless of its Accept header. The concern assumes hosts include Ruact::Server after protect_from_forgery; no runtime callback-order verifier runs. The shared implementation lives in Ruact::ServerFunctions::ErrorRendering. New specs: server_spec.rb, server_rescue_request_spec.rb, server_upload_request_spec.rb (tagged :story_9_1).

  • Structured error payload for server-function failures (salvaged from Epic 8, re-anchored on Ruact::Server). A server function that raises responds with a structured JSON body carrying _ruact_server_action_error: true, the action name, the Ruby error class, the message, and (in development/test) the split backtrace (app frames + gem frames), a contextual suggestion for common failure modes (ActiveRecord::RecordInvalid, ActionController::InvalidAuthenticityToken, Ruact::UploadTooLargeError), and record.errors.full_messages for validation errors. Production-mode payload is reduced to the four baseline fields (_ruact_server_action_error, action_name, error_class, message) so React components render their own UI without backtrace leakage. Toggle via Ruact.config.dev_error_payload_enabled (Boolean, default nil → resolves to Rails.env.development? || Rails.env.test?). Status mapping: RecordInvalid → 422, InvalidAuthenticityToken → 403, UploadTooLargeError → 413, everything else → 500; the host’s own rescue_from chain wins for owned exception classes. Pure modules under gem/lib/ruact/server_functions/: ErrorPayload.build, BacktraceCleaner.split (anchored on Ruact.gem_path), ErrorSuggestion.for. Server-side Rails.logger.error always logs the failure with a [ruact] prefix + the full backtrace; the dev-mode gate governs only the wire body.

  • File uploads + max_upload_bytes pre-parse guard (salvaged from Epic 8, re-anchored on Ruact::Server). <form action={fn}> with <input type="file"> delivers params[:file] as ActionDispatch::Http::UploadedFile; the runtime’s FormData branch sends multipart/form-data with the browser-managed boundary, and Rails’ standard multipart parser unwraps each part — @post.cover.attach(params[:cover]) (Active Storage) works unchanged. Ruact.config.max_upload_bytes (Integer, default 10 MB; nil disables the gem-side guard) gates a prepended guard on the Ruact::Server concern that checks request.content_length for multipart/form-data / application/x-www-form-urlencoded bodies BEFORE CSRF verification; oversized requests raise Ruact::UploadTooLargeError → 413 + the structured body with a dev-only upload_limit: { received_bytes, limit_bytes } block. Carve-outs: JSON bodies, chunked-transfer requests (no Content-Length), max_upload_bytes = nil. GET/HEAD requests are never guarded.

  • The v1 server-functions substrate (Story 9.9). The Epic-9 route-driven redesign replaced the original server-functions contract, and Story 9.9 demolished the old machinery: the synthetic gem-managed function endpoint and its mounted route; the dual registries + their snapshot/collision layer; the standalone "use server" dispatcher (Ruact::ServerAction / standalone context / the configurable current-user resolver) and the app/server_actions/ autoload path (the standalone host shape — FR63 — was dropped from the MVP); the controller-level block macro; and the v1 codegen render path + its runtime accessor (the registry-symbol ref builder and the synthetic-endpoint POST helper). The route-driven codegen is now the sole writer of app/javascript/.ruact/server-functions.ts (the parallel .next inspection target ceases to exist; the railtie writes the v2 snapshot to the real bridge on config.to_prepare, forcing the route table to load first for correct cold-boot ordering). React import statements are unchanged by construction — the module path @/.ruact/server-functions never changed. The dispatch benchmark was re-pointed at real REST + query routes and re-validated against NFR21 (< 20ms holds). The v1→v2 transition history lives only in the append-only ADR gem/docs/internal/decisions/server-functions-api.md.
  • Tamper-proof record references — the structural antidote to forged-reference attacks (Story 13.2). Passing @post.id (or an attribute hash) to the client hands it a forgeable, unscoped, non-expiring reference — swap id: 7 for id: 8 and an action that trusts params[:id] raw reaches a record it should not. Ruact.signed_global_id / Ruact.locate_signed (see Added) replace that with a signed, for:-scoped, expires_in:-bounded token: the client cannot forge or tamper with it, and a bad token is rejected as a clean 400 before any DB lookup. This is the inbound-safety counterpart to the serialize-only invariant (Story 13.1).

  • Serialize-only invariant — guarded by ruact:doctor (Story 13.1). ruact emits React Flight (text/x-component) but never deserializes externally-supplied Flight into live Ruby objects — which keeps it structurally outside the React2Shell / CVE-2025-55182 class (a Flight-deserialization RCE). rails ruact:doctor now fails if ruact’s own Ruby source introduces an inbound Flight-deserialization entry point (a *Deserializer, parse_flight / from_flight / deserialize_flight / decode_flight, or a Ruby createFromNodeStream / createFromReadableStream / createFromFetch reader) that is not annotated # ruact:allow-flight-deserialization <reason>; a clean tree passes silently. The same pass introduces a non-failing :warn doctor status (rendered ) that fires when a response-transforming middleware (Rack::Deflater) is mounted, since recompressing a streamed text/x-component body breaks the Flight wire contract. The client-side createFromFlightPayload (the browser deserializing the server’s own trusted payload) is normal RSC and explicitly out of scope. Invariant recorded in the ADR addendum (docs/internal/decisions/server-functions-api.md, 2026-06-25).

  • default_render degrades gracefully on non-HTML Accept (no more 500 on */*) (Story 10.0; Epic 10 gem prerequisite). An ivar-only GET page action relying on implicit default_render returned an HTTP 500 (__ruact_component__ called outside a ruact_render flow) the moment a non-browser client (curl, bots, uptime/health-checks sending Accept: */*) hit it: the activation predicate keyed off request.format.html?, which is false for the */* wildcard, so the request fell through to super, which rendered the .html.erb outside a ruact_render flow. The predicate now activates the RSC HTML-shell pipeline whenever the template exists and HTML is acceptable to the client (*/* wildcard, text/html, or a blank/absent Accept) — serving the same HTML shell a browser gets (RFC-correct content negotiation for “accept anything”). Concrete non-HTML formats (application/json, application/xml) still bypass to super so respond_to blocks and explicit render calls are unaffected, text/x-component / Ruact-Request: 1 navigations are unchanged, and the text/html path is byte-identical. This makes the Epic 10 scaffold’s implicit-default_render style safe by construction.

  • Ruact::Controller#ruact_render now renders successfully under Rails 8 (Story 7.9; resolves Bug 7.8-B). Previously every PascalCase component in a Rails 8 app raised Ruact::Error: __ruact_component__ called outside a ruact_render flow (HTTP 500). The render context is now routed through the controller’s standard view-assigns plumbing so it reaches the view used by render_to_string. (Story 7.9 originally landed under the pre-rename names rsc_render / __rsc_component__; the message shape and method name moved to ruact_* in Story 5.12 — the underlying fix is mechanically the same.)

  • rails generate ruact:install no longer crashes on a fresh rails new (Rails 8.1 / Thor) (Sprint Change Proposal 2026-06-16 §4.5; surfaced building the epic9-scaffold playground). The generator called destination_root.join(...) at eight sites, but Thor returns destination_root as a String (File.expand_path), which has no path-style #join — so the installer aborted with a NoMethodError before writing a single file. The paths are now wrapped in Pathname(destination_root).join(...). A new integration spec runs the real generator against a String destination_root (the prior “generator action helpers” tests reimplemented the file logic with File.join, so they executed Thor’s path handling never and masked the bug).

  • <Suspense delay="2.5"> in ERB now reaches Flight::SuspenseElement#delay (Sprint Change Proposal 2026-06-16 §4.5). SuspenseElement already accepted a delay: (the server-side wait, in seconds, before the deferred chunk streams), but the ERB had no way to set it — <Suspense delay="2.5"> was silently ignored and every boundary used the default. The preprocessor now extracts the optional delay attribute into data-ruact-delay, and HtmlConverter parses it as a Float and forwards it to SuspenseElement (absent, blank, or unparseable → the element’s default delay). ActionController::Live is still required for the wait to stream on a soft navigation.

  • Code coverage instrumentation (Story 6.7). Added simplecov and simplecov-lcov as development dependencies; gem CI uploads coverage from the canonical matrix cell (Ruby 3.3 × Rails 7.2) to Codecov on every push and PR with the gem flag. Baseline at merge: 88.30% line (581/658), 72.25% branch (239 specs). (Corrects the earlier 87.89% figure recorded at story-merge time, which was a typo of the SimpleCov output for 567/644 = 88.04%; the current numbers reflect the post-review state after Story 6.7 review F1 refactored html_converter.rb#convert_element into helpers, which added a few lines and one new spec.) Coverage is informativo (not a CI gate); see Codecov PR comments for diff coverage on individual changes. Diff coverage target per project DoD: ≥ 90% line / ≥ 80% branch on new code.
  • Spec rails_stub.rb fix. The previous $LOADED_FEATURES.any? { |f| f.end_with?("/rails.rb") } heuristic for skipping the LOADED_FEATURES insertion was unreliable — unrelated gems ship files at */rails.rb (e.g. SimpleCov’s simplecov/profiles/rails.rb). Replaced with an unconditional insertion guarded only by the existing return if defined?(Rails) early-exit, which is the correct invariant.
  • rsc_* public API surface fully migrated to ruact_* (Story 5.12; BREAKING). Pre-v0.1.0 clean cut to eliminate the residual rsc_ prefix from Phase 1’s pre-rename era so Epic 8 (server actions), Epic 9 (server queries), and Epic 10 (scaffold) inherit a clean substrate. No aliases, no deprecation bridge — host code must rename in lockstep.

    Surface Was Now
    Controller methods rsc_render, rsc_request?, rsc_manifest, rsc_template_exists?, rsc_html_shell ruact_render, ruact_request?, ruact_manifest, ruact_template_exists?, ruact_html_shell
    HTTP header RSC-Request: 1 Ruact-Request: 1 (the React-Flight-standard Accept: text/x-component check is unchanged)
    Serializable DSL rsc_props :id, :title · obj.rsc_serialize · Klass.rsc_props_list ruact_props :id, :title · obj.ruact_serialize · Klass.ruact_props_list
    Rake task rails rsc:doctor rails ruact:doctor
    View 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" (class Ruact::Error preserved)

    Internal token format (__RSC_N____RUACT_N__), the synthetic Suspense tag (<rsc-suspense data-rsc-fallback="…"><ruact-suspense data-ruact-fallback="…">), the playground/e2e rsc-router.js JavaScript file → ruact-router.js, and the [rsc-router] log prefixes → [ruact-router] also moved in lockstep. The Ruact::* Ruby module namespace and the Flight wire-protocol identifiers ("$", "$L", "$SS", text/x-component) are unchanged — those are React-Flight-protocol externalities.

    Mechanical, not behavioural. Bug 7.8-B (closed by Story 7.9) does NOT regress under Story 5.12: the renamed __ruact_component__ continues to read from the @ruact_render_context ivar Story 7.9 plumbed through _assigns_for_view_context; only the method name and message substring moved. Gem CI’s name-propagation guard gains a second step that fails the build on any residual rsc_* API reference (complementary to Story 5.1’s rails_rsc / RailsRsc guard, which is preserved unchanged).

    Migration for host apps that experimented with ruact v0.0.x: change each rsc_* identifier listed above to its ruact_* equivalent (e.g. def show; rsc_render; enddef show; ruact_render; end; class Post; include Ruact::Serializable; rsc_props :id; endruact_props :id; deployment scripts running bundle exec rails rsc:doctorbundle exec rails ruact:doctor). Any host-side import { setupRouter } from "./rsc-router.js" adjusts to from "./ruact-router.js".

  • The gem and its top-level constant were renamed from rails_rsc / RailsRsc to ruact / Ruact between v0.0.2 and v0.0.3. Host apps must update their Gemfile (gem "rails_rsc"gem "ruact") and any code referencing RailsRsc::* (replace with Ruact::*). The rails ruact:doctor task (renamed from rails rsc:doctor in Story 5.12) detects and reports legacy constant usage in config/initializers/ and app/.

  • Rake task descriptions and internal require statements migrated from rails_rsc to ruact. Public API is unchanged; this is a documentation and tooling rename only.

  • Render context now passed explicitly (Story 7.1). Ruact::ComponentRegistry (which used Thread.current) has been removed; the per-render component list is now an instance of Ruact::RenderContext passed explicitly through Controller#ruact_render → RenderPipeline → HtmlConverter. The Ruact/NoSharedState cop now passes with no exceptions in lib/ruact/. No public API change. Note: Ruact::Flight::*, Ruact::Internal::*, and Ruact::RenderContext are not part of the public API and may change between minors. Hosts upgrading need no application code changes. See decision note for rationale and contributor guidance.

  • RenderPipeline entry points consolidated (Story 7.2). RenderPipeline#call, #stream, and #from_html have been removed and replaced with a single #render(input, mode:) entry point. input selects the source — { erb: String, binding: Binding } for ERB templates or { html: String, render_context: Ruact::RenderContext } for pre-rendered HTML; mode: selects the output shape — :string returns a String (deferred chunks inlined eagerly), :stream returns an Enumerator of Flight rows (deferred chunks delay). Conflicting input keys, missing siblings, and unknown modes raise ArgumentError with the offending input named. No public API changeRuact::Controller#ruact_render is unchanged. Note: Ruact::Flight::*, Ruact::Internal::*, and Ruact::RenderPipeline are not part of the public API and may change between minors. See decision note for rationale and contributor guidance.

    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::Configuration is frozen after initialization (Story 7.3). The Ruact::Configuration instance returned by Ruact.config is frozen the moment Ruact.configure { |c| ... } returns (or, if no configure block is called, on first access). Mutating attributes outside the configure block (e.g. Ruact.config.foo = bar) now raises Ruact::ConfigurationError — a new error class, subclass of Ruact::Error — with a message naming the offending attribute, the caller’s file:line, and the suggested fix. Calling Ruact.configure a second time after boot replaces the configuration atomically (the new draft is fully assembled, frozen, then swapped — partial reconfiguration is impossible) and emits a [ruact] warning advising that runtime re-configuration is unusual. No public API change — the Ruact.configure { |c| ... } DSL is unchanged, every existing reader returns the same value as before, and the rails generate ruact:install template still works as-is. Note: the gem’s own internal RSpec stubs against Ruact.config (render_pipeline_spec.rb, flight/renderer_spec.rb) were migrated to Ruact.configure { |c| c.attr = ... } blocks because RSpec mocks cannot proxy frozen objects in MRI Ruby — this affects only the gem’s test suite, not host applications. See decision note for rationale and contributor guidance.

    Migration for any external code that mutated Ruact.config outside Ruact.configure: replace any post-boot Ruact.config.foo = bar with a Ruact.configure { |c| c.foo = bar } block in config/initializers/ruact.rb. If the change must be conditional on environment, branch inside the block. For test scenarios that legitimately need to swap config per-example, prefer Ruact.configure { |c| c.foo = ... } with a before/around reset hook (Ruact.instance_variable_set(:@config, nil)); RSpec stubs (allow(Ruact.config).to receive(...)) no longer work because the underlying object is frozen.

  • Ruact::HtmlConverter.convert validates inputs at the boundary (Story 7.4). The class-method entry point now raises Ruact::HtmlConverterError (new — subclass of Ruact::Error) when its html argument is not a String. The validation runs before Nokogiri is invoked, so the caller’s file:line appears at the top of the backtrace with a clear ruact-named error rather than a NoMethodError for :children deep in Nokogiri internals. The most common upstream bug — an ERB template, partial, or render path that returned nil — now surfaces with a “Most likely cause” hint pointing at the call site. Separately, Ruact::ClientManifest#reference_for enhances its existing Ruact::ManifestError message with a Damerau-Levenshtein closest-match suggestion (e.g. Did you mean "LikeButton"? for a typo’d LikeButtonn), with a fallback hint suggesting the file path to add when no entry within distance 2 exists; passing controller_path: biases the suggestion toward co-located keys. No public API change — the public signature html, registry = [] is unchanged for valid inputs, every existing spec continues to pass, and the ManifestError raised on unknown components is the same class with an enhanced message. See decision note for rationale and contributor guidance.

    Migration: any application code that previously rescued NoMethodError or Nokogiri::XML::SyntaxError from a path that flowed through the HTML converter (an unusual pattern; not documented as a public contract) should now rescue Ruact::HtmlConverterError for the nil/non-String case. The unresolved-component case continues to raise Ruact::ManifestError (same class, enhanced message); rescue patterns matching /not found in manifest/ continue to work, and rescue patterns matching /Did you run the Vite build\?/ continue to work. No host application is expected to need changes — the new validation only fires on inputs that already failed in Phase 1, just with worse error messages.

  • Flight wire test matchers extended with structural modes (Story 7.5). gem/spec/support/matchers/flight_fixture_matcher.rb gains two new RSpec matcher modes alongside the existing match_flight_fixture(name) snapshot matcher: match_flight_structure(expected) parses the actual wire output via a new Ruact::Spec::FlightWireParser and compares against an array of row records (hash payloads compare structurally — JSON key reordering does not break the spec); include_flight_row(predicate) asserts at least one parsed row satisfies a subset-match predicate (supports hash_including, array_including, etc. via case-equality). Failure messages are row-indexed and name the differing field path. Test-only change; no production code touchedgem/lib/ is unchanged. The new Ruact::Spec::* namespace is established as the canonical home for spec-only utilities; it is not part of the public API and may change between minors. Default for new wire tests should be match_flight_structure; reserve match_flight_fixture for tests where the wire bytes themselves are the contract (e.g. string_dollar_escape). See decision note for rationale and contributor guidance. Story 7.6 will migrate ~31 existing string-matching specs in a follow-up.

  • Phase 1 Flight wire-asserting specs migrated to structural matchers (Story 7.6). All Phase 1 specs that asserted on Flight wire output via regex / include / string equality have been migrated to the three Story 7.5 matcher modes (match_flight_fixture, match_flight_structure, include_flight_row). The migration covered 4 spec files (flight/serializer_spec.rb, flight/renderer_spec.rb, render_pipeline_spec.rb, render_pipeline_concurrency_spec.rb) routed by an explicit decision tree (A: byte-exact contract → fixture; B: multi-row shape → structure; C: presence → row; D: invariant on parsed rows; E: byte-determinism / non-wire-format → preserved). Five new fixtures added under spec/fixtures/flight/ for previously-uncovered scalars (bigint, nan, infinity, negative_infinity, undefined). Three sites preserved as Decision E with explanatory comments (byte-determinism positive/negative + Suspense error-message text). Cosmetic JSON changes (key reordering, whitespace) no longer break unrelated specs; semantic regressions surface with row-indexed structural diffs. Test-only change; no production code touched. Pre-migration: 347 examples / 0 failures / 0 RuboCop offenses. Post-migration: 339 examples / 0 failures / 0 RuboCop offenses. AC2 grep returns zero brittle wire-fragment assertions outside the 3 documented Decision E sites. See decision note.

  • Story 7.8: Playground demo architecture-checks page + Rake task verifying Epic 7 invariants (Story 7.8). Added a checks engine, a Rake umbrella with four sub-tasks under demo:check:*, and an HTTP twin at /architecture-checks, all in the demo playground, and reorganized the playground README’s “Architecture verification” section. The four checks gate, from outside the gem: render-context isolation under N=50 parallel renders (Story 7.1), Configuration immutability with the documented Ruact::ConfigurationError message shape (Story 7.3), HtmlConverter input validation for nil / non-String / unknown component (Story 7.4), and Phase 1 transparency via structural Flight equivalence on the Counter demo (Stories 7.1–7.7). Playground-only — gem/lib/ and gem/spec/ are unchanged. The story surfaced two latent issues escalated for follow-up: (a) the demo’s Gemfile previously pinned gem "ruact" to RubyGems v0.0.2 (Phase 1) instead of the workspace submodule, so prior playground-based verifications including the Story 7.1 soak ran against pre-Epic-7 code — fixed in this story by switching to path: "../../gem"; and (b) the controller’s render-context ivar was set on view_context, but Rails 8’s render_to_string uses a different ActionView::Base instance so the ivar was never visible to the view helper — every demo request 500-ed with the “outside a flow” error; check #4 was marked :pending until Story 7.9 fixed the gem-side wiring. (Originally written using the pre-rename rsc_* method/helper names; rewritten here in Story 5.12 to match the post-rename surface.)

  • Story 7.7: Codified five code-review edge cases as regression specs (Story 7.7) — render context re-entry on a shared binding receiver (post-7.1 ensure restoration on inner-render-raises, 3-level nesting, sequential receiver reuse), HtmlConverter.convert(nil) and non-String input smoke specs cross-referencing the Story 7.4 detailed coverage, ERB rendered against a binding with zero instance variables (3 specs), and the as_json-returns-self message-shape contract (offending class name + Ruact::Serializable + ruact_props). Nine new specs co-located under the :story_7_7 rspec tag and the "Story 7.7" describe substring for one-command suite execution (bundle exec rspec --tag story_7_7). Test-only — no production change under gem/lib/. .rubocop.yml extends Naming/VariableNumber with AllowedPatterns: ['\bstory_\d+_\d+\b'] — the exemption matches only the canonical :story_X_Y story-tag symbol shape; any other snake_case-with-numbers under spec/ continues to trip the cop. p99 wall-clock for the tag: 0.0154 s (gate: < 1 s). See decision note.

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-ruact published to npm and co-versioned with the gem. That never became how ruact ships: the plugin is vendored inside the gem and published nowhere. See RELEASING.md for 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, and ClientReference.
  • Ruact::Controller concern — Include in ApplicationController to enable RSC rendering. Provides ruact_render, RSC request detection (text/x-component / Ruact-Request: 1 header), HTML shell generation with inline __FLIGHT_DATA, and Flight-aware redirect_to.
  • Streaming mode — When ActionController::Live is included, Flight rows are streamed to the client as they are produced (Suspense-aware).
  • Client component resolutionRuact::ClientManifest reads public/react-client-manifest.json (generated by the Vite plugin) and resolves component names to ClientReference objects via a dual-path resolver.
  • Ruact::Serializable mixinruact_props DSL for declaring safe prop attributes on Ruby model objects.
  • Install generatorrails generate ruact:install scaffolds the initializer, Vite config patch, and JavaScript entry point.
  • ruact:doctor Rake 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 emits public/react-client-manifest.json.
  • Client-side navigation — JavaScript ruact-router.js intercepts same-origin link clicks and form submissions, fetches Flight payloads, and updates the React tree without full-page reloads.
  • 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 benchmarkrake benchmark:memory enforces a 120% allocation regression gate against spec/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 appe2e/ Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.