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 browser reads the page with React’s own Flight client. ruact had its own decoder, and most of the transport bugs fixed in 0.0.12–0.0.16 lived in it. The runtime now uses React’s react-server-dom-webpack client (client.browser), which ships inside the gem: version 19.3.0, MIT, regenerated by vendor/javascript/vite-plugin-ruact/scripts/vendor-flight-client.mjs. Nothing is added to the app’s package.json. The npm package would also install webpack, which this avoids. An app that needs a different version adds react-server-dom-webpack to its own package.json, and the Vite plugin uses that copy instead; a copy that is only installed as another package’s dependency is not used. Vite’s startup and build output and rails ruact:doctor say which copy is in use, and the doctor warns when the app’s copy is built for another React minor than the app’s react. Supported React: ^19.2.8, checked in CI against 19.2.8 and 19.3.0. The bundle grows: the nav-islands example went from 74.35 kB to 80.82 kB gzipped (+6.47 kB), and the .gem from 373 KB to 427 KB.

  • The Flight wire changed to the format React’s client reads. The server and the browser runtime change together; a page served by this version needs this version’s runtime. When you deploy it, a tab still open on the previous version cannot read the new responses: its next in-place navigation fails and the user has to reload. Code that parses ruact’s wire itself, rather than through Ruact::Testing, needs updating:

    • import rows are [id, chunks, name], with chunks always [] (the runtime registers every component up front);
    • a string of 1024 bytes or more is referenced as "$<hex>", not "$T<hex>";
    • a <Suspense> boundary’s type is a "$Sreact.suspense" symbol row, and its deferred content is a lazy child, "children":"$L<hex>", inside a boundary the runtime registers as ruact:boundary;
    • an error row is an object, {"digest","name","message","stack","env"}, whose digest is a stable code (ruact:suspense-timeout): React’s production client keeps only the digest, and the runtime turns it back into the message;
    • in development only, elements carry React’s three development slots, so React does not warn about keys on ERB siblings and loops. The test environment keeps the production wire.

    Ruact::Testing’s matchers read the new wire, and have_ruact_component ignores the runtime’s own boundary.

  • An error in a <Suspense> child no longer risks the whole page. React unmounts the page on a render error no component catches. Each Suspense child now sits inside a boundary that keeps the fallback on screen and passes the error to the router’s onError as [ruact] Server error: …, in production too, as before. The same goes for a response that ends before the Suspense row arrives (a server error mid-stream, a dropped connection): [ruact] Server error: the response ended before the whole page arrived. The boundary handles only what the server or the stream produced; a crash in your own component still reaches your error boundaries. The next response for the page (a navigation, revalidate()) renders normally.

  • A render error shows on the page instead of emptying it. React’s client looks a client component up when it renders, so a component missing from the registry (no "use client", a stale build) fails then, and React would remove the whole page. The page now shows [ruact] Error: client component not registered: … in its place, as the first load did before; the next navigation replaces it.

  • A quoted attribute on a component was dropped without a word. <PostCard title="Hello" /> reached the component with no title: only title={…} was read, and the quoted form is the first thing a JSX hand writes. Component tags now take the three JSX forms. name={ruby} is a Ruby expression, as before — except that a hyphenated name keeps its hyphen: aria-label={…} used to arrive as label. name="text" is the string; a quoted value never runs Ruby. A bare name is true. Anything else raises a template error that names the attribute, including ERB inside a quoted value (title="<%= @t %>"), which a component attribute cannot run: write title={@t}.

  • The Getting Started page logged a React error. Server-rendered siblings, such as an <h1> next to a component or the rows of an ERB loop, reached React as an array of elements with no keys, and React logged “Each child in a list should have a unique key” on the guide’s own example. Siblings are now handed to React as separate children, the way JSX compiles them, so React does not check them for keys. That covers the page itself, any element’s children and the content a <Suspense> boundary streams in. No key is invented, and a key the template gives still wins. A data array passed as a children prop is left as it is.

  • A form React handles itself also got a router request. The client router decided on clicks and form submits before the app’s own handlers ran, so a form whose onSubmit calls preventDefault() was also fetched by the router: the scaffold’s form sent a stray GET of its own page on every save. The router now decides after them, and an event the app prevented is left alone. The same holds for a link whose onClick prevents the default. A handler that stops the event’s propagation now also keeps it from the router, which then leaves the browser to follow the link or submit the form, as Turbo does.

  • The install message said a component works in any ERB view. In island mode, the default, it works in the views of a controller that has include Ruact::Controller; the message now says so.

  • A server function no longer returns what callbacks and auth libraries put on the controller, or answers 500 because of it. A function call’s JSON was the controller’s whole view_assigns. An authentication library that memoizes the user in an instance variable (Devise’s current_user sets @current_user on its first call) put that user in every response. Under strict_serialization, on by default in production, a user model without ruact_props then made every function call answer 500. Two rules now decide what the JSON holds:

    • What callbacks set stays out, unless the action assigns it a different object. @post = Post.find(...) in the action is returned. A before_action :set_post whose @post the action only updates in place (@post.update(...)) is not: reassign it (@post = @post.reload) or render json:. This covers plain values too: a callback’s @current_user_id = 5 is not returned, and neither is a value the action sets to the very same object the callback stored (@count = 0 after a callback did the same, or a memoized helper’s result).
    • Authentication and authorization memos are never returned, even when the action fills them by calling current_user or authorize. The names are in the new config.server_function_hidden_ivars, default ["current_user", "current_ability", "pundit"] for Devise’s :user scope, CanCanCan and Pundit. Every name starting with _ is hidden too. Add the memo of any other scope or helper, such as Devise’s @current_admin for devise_for :admins: config.server_function_hidden_ivars += ["current_admin"].

    A page render’s view still sees every instance variable.

  • Text in a prop could run as script on the page (stored XSS). On a plain browser load, the page’s Flight payload is inlined in a <script> element. It was embedded as a Ruby string literal with only a lowercase </script> escaped. A value containing </SCRIPT>, </script > or another casing closed the element, and the rest ran as HTML. A post titled </SCRIPT><img src=x onerror=…> executed its handler for everyone who opened the list. Any string a ruact page passes as a prop, such as user-written record content, could do it. The payload is now embedded as a JSON string literal with <, >, &, U+2028 and U+2029 escaped, so no character of it can end the element. Control characters such as \e, which the Ruby literal also delivered wrongly to JavaScript, now arrive intact. Affects every version up to and including 0.0.14; upgrade. Ruact::Testing’s matchers read both forms.

  • A same-origin redirect could send the browser to another site. redirect_to "#{root_url}#{params[:path]}" with path=/evil.com gives https://your.app//evil.com, which is your own host, so Rails’ open-redirect check accepts it. ruact then shortened every same-origin URL to its path, //evil.com, and a browser reads that as a URL to evil.com. A server function answered {"$redirect":"//evil.com"} and the runtime navigated there. A page redirect produced a Flight row the router refused, so the navigation silently did nothing. A path that a browser would read as another host now keeps its absolute, same-origin URL.

  • Any text of 1 KB or more blanked the page. The server sends every string of 1024 bytes or more as a separate row framed by its byte length, with no newline after it. The browser read the payload line by line, so it lost the row that holds the page and showed nothing: [flight-client] No root row (id=0) found in payload. A scaffolded post whose body was one long paragraph made the whole list disappear. The first load and the router now read the payload with one parser that works on bytes, so text arrives exactly as it left, including multibyte characters and newlines, wherever the network splits it. A truncated or malformed payload, or a reference to text that never arrived, is now an error that says so instead of a blank page. Long text inside Suspense content was also never sent at all; it is now, before the row that uses it. Ruact::Testing’s matchers compare a long prop by its text.

  • Dates and large integers reached React as marker strings. A Ruby Time, DateTime or ActiveSupport::TimeWithZone prop arrived as "$D2026-09-08T12:30:45.123Z", and an integer beyond JavaScript’s safe range (±(2^53 − 1)) as "$n9007199254740993". A component had to know ruact’s transport to use them. They now arrive as a Date, with the same instant and millisecond precision, and as a BigInt; integers inside the safe range stay numbers. This holds on the first load and after a navigation. A BigInt is not a number: JSON.stringify refuses it and some libraries expect numbers, so format it where you use it (String(id)). A Date is not text, so {createdAt} as a child now raises “Objects are not valid as a React child”; format it (createdAt.toLocaleDateString()). Both can go back: a server function sends a BigInt argument as its decimal string, and useQuery takes a BigInt or Date param (the generated query accessors’ TypeScript parameter types do not list them yet). Years outside 0–9999 are written in the form JavaScript reads (+010000). A calendar Date (Date.today) arrives as "YYYY-MM-DD"; it used to raise Ruact::SerializationError in production, where strict_serialization is on. A symbol whose name starts with $ is escaped like a string.

  • respond_to answered 406 to every navigation and form submit of the client router. The router asks for a page with Accept: text/x-component, which is not a Rails MIME type, so the request negotiated no format and respond_to { |format| format.html { … } } raised ActionController::UnknownFormat. That is the controller Rails’ own scaffold writes: with include Ruact::Controller, its create, update and destroy failed whenever the router submitted the form. A router request now negotiates format.html, the same page a browser asks for: the format.html branch runs, and its render, status or redirect reaches the browser as a ruact answer. A URL that names a format (/posts/1.json) and an Accept: application/json request still get format.json. The format is negotiated before every callback, so an authentication before_action that answers with respond_to gets it too. A respond_to with no format.html still answers 406, now with a message naming the missing branch and the data-ruact="false" way out. A partial that exists only in another format (.text.erb) is no longer found by a ruact page during a router request.

  • bin/rails server without Vite showed an empty page and no error. Once Vite has run once, the component manifest is on disk, so a page renders without the dev server, but its JavaScript is not served: the page answered 200 with nothing in it, and the only clue was a 404 in the browser’s network tab. In development, when the Vite dev server is not running and there is no build to serve, the page now shows a banner that says so and to start the app with bin/dev, and the Rails log gets the same [ruact] line. The banner is plain HTML, so it does not need the JavaScript that failed to load. Nothing changes with Vite running, with a build present, or outside development.

  • Text that is not valid UTF-8 broke the page. A string holding Latin-1 bytes, or bytes that are not text at all, made the page answer 500 for every viewer, because JSON refuses invalid UTF-8. A long one, 1024 bytes or more, was sent with a byte count that did not match what the browser decoded. Strings now leave as valid UTF-8: text in another encoding is transcoded, binary-tagged UTF-8 (File.binread, an HTTP body) is read as the text it is, and bytes that are not text become U+FFFD, as a browser shows them.

  • Scaffolding a second resource stopped the app from booting. Every rails generate ruact:scaffold wrote a query with a method named search. Query names share one namespace: each name gets one GET /q/<name> route and one export of @/.ruact/server-functions. So the second resource drew ruact_query_search again, and Rails refused to load the routes with “Invalid route name, already in use: ‘ruact_query_search’”, a route the app never wrote. The scaffold now names the method after the resource: PostsQuery#search_posts, served at GET /q/searchPosts and imported as searchPosts. Two query classes that define the same method — or one query class mounted twice — now raise Ruact::ConfigurationError naming the query, the path and the fix. The new name can meet one thing the bare search could not: a custom collection action named search on the same controller (post :search, on: :collection) also derives searchPosts, and boot stops with ruact’s name-collision error, which points to ruact_function_name to rename one. Apps scaffolded before this release keep their search and work as they are. The first scaffold of a new resource in such an app no longer collides with it.

  • rails generate ruact:install --shadcn on an app that already had ruact installed left Tailwind out, then said the setup was in place. That is the order Getting Started gives: install ruact first, add shadcn later. On the second run package.json and Procfile.dev already existed, so the generator skipped both. Tailwind was never declared and the css process was never added. npx shadcn@latest init then stopped with “No Tailwind CSS configuration found”, right after the generator had printed that the prerequisites were in place. Under --shadcn the generator now adds what is missing to both files: the Tailwind devDependencies and the build:css script in package.json, the css process in Procfile.dev. It keeps every version and script you already declare; package.json is rewritten as 2-space JSON when something is added. When it cannot complete the setup, it says the setup is not in place and names what to do: a package.json it cannot parse, a Tailwind older than 4, or a Procfile.dev that already runs a css process or another Tailwind watcher — such as tailwindcss-rails’ bin/rails tailwindcss:watch, which compiles its own entry rather than the globals.css shadcn writes its theme into. Without --force, rails destroy ruact:install --shadcn leaves an existing package.json in place.

  • Under --shadcn, bin/dev stopped as soon as it started when no terminal was attached. The css process ran @tailwindcss/cli --watch, which stops watching and exits 0 when its input closes. Foreman then stops Rails and Vite as well. A coding agent, CI, Docker or an IDE task runner starts bin/dev without a terminal, so the app was gone before its first request, with no error in the output. The process now runs with --watch=always. To fix an app generated before this release, change --watch to --watch=always on the css: line of Procfile.dev.

  • The ruact:scaffold --shadcn pre-flight printed npx shadcn@latest init without --base radix. The current shadcn defaults to Base UI, but the components the scaffold generates import Radix. An app set up from that message got components whose imports do not resolve. The message now prints --base radix, as the install generator already did.

  • Two generator messages linked to pages that do not exist. The unknown-attribute-type error and the shadcn version warning pointed at a documentation path inside this repository, where no such file exists. They now link to ruact.dev.

  • The scaffold’s list prop is named after the resource. <CommentList posts={rows} /> was a leftover from the reference app the templates came from, and every resource got a posts prop. It is now <CommentList comments={rows} />, <BlogPostList blogPosts={rows} />, and so on. Inside the component the prop is bound to a fixed local, so a model whose plural is one of the List’s own names (Row) or one a module cannot declare (Argument) still compiles. A Post scaffold’s view is unchanged.

  • shadcn 4 is in the default shadcn_compatible_versions (now [1, 2, 4]). It is the current major, so every app that followed the setup steps saw ruact:scaffold --shadcn warn that its shadcn was not tested. shadcn 4 was run end to end with this release: init --base radix, the full add list, ruact:scaffold --shadcn, and the form, table and delete dialog checked in a browser.

  • A development warning when a Turbo form gets a ruact page back. A form on a Turbo page that posts to a ruact action is fetched by Turbo; Turbo does not show the ruact page that answers (a 422 with validation errors, say): it reloads the form’s page, drops the answer or pastes the document into its own, depending on the status. In development and test ruact now logs a [ruact] warning naming the action, the request and the fix: data-turbo="false" on that form, so the browser submits it and shows the answer.
  • render :new, status: :unprocessable_entity answered 500 on a ruact page. The Rails idiom for a failed save rendered the template outside ruact, and its first client component raised __ruact_component__ called outside a ruact_render flow, with no hint of the fix. A render whose template is a ruact page of the same controller — render :new, render "new", render action:, render template: "posts/new" — now goes through ruact, with its status:, locals:, location:, variant:, locale: and assigns: — when that .html.erb (or a locale/variant of it) is the template Rails would pick for the request, so a .json URL or a format.json branch still renders the JSON template. Every other render (other renderers, another folder’s template, an action outside ruact_pages, an option ruact does not take such as wicked_pdf’s pdf: or a stray alert:) is Rails’ own, as before — and when that leaves a client component outside ruact, the error names the option. ruact_render takes status: too. A client component in a template Rails renders on its own now raises an error naming the component, the template and the call that renders it through ruact. The client router renders a Flight 422 in place — the errors are the page, not a failed request — and keeps the form’s URL; any other failing status is still an error.

  • respond_with (the responders gem, Devise’s controllers) raised NoMethodError: private method 'redirect_to' in a controller with Ruact::Controller. ruact’s redirect_to override was private, and responders calls controller.redirect_to from outside. It is public now, as Rails’ own is — and so is the new render override.

  • A server-function call’s validation errors were lost on redirect_to. An action called from React that registers ruact_errors(@post) and then redirect_tos answers {"$redirect": …}; the page the runtime navigated to was documented to receive those errors, and never did, in any include order. They now ride the flash to it (a same-origin redirect; a redirect to another origin carries none).

  • Clicking from a ruact page to an ordinary Rails page did nothing. The ruact router intercepts every same-origin link and asked for a ruact response; an ordinary Rails action answered with HTML the router could not render, so the click went nowhere — no page, no URL change, no error. Now the server tells the router, before running anything, when a route is not a ruact page, and the browser loads it normally. A form that posts to such a page is submitted by the browser the same way, so the action runs once and its response — a validation error included — is what you see. Ruact::NavigationBoundary::Middleware does this; remove it with config.middleware.delete Ruact::NavigationBoundary::Middleware if you need to.

  • Turbo and ruact stopped trampling each other. After one round trip between a Turbo page and a ruact page, Turbo’s links could go dead, a ruact page could render blank, or the address bar could show one page while the screen showed another. Every page ruact renders now carries <meta name="turbo-visit-control" content="reload">, so Turbo loads it in full instead of swapping it into its own document, and each document keeps its own router. It also carries <meta name="turbo-prefetch" content="false">: in a layout that loads Turbo 8, hovering a link on a ruact page no longer makes Turbo fetch — and run — the destination for a click it will never handle.

  • Styling from an npm component worked in development and disappeared in production. A "use client" component that imports CSS — its own, or one a package ships, like a date picker’s — produces a separate stylesheet that Vite builds, digest-stamps and serves. Nothing linked it. The page came back with the script tag and no <link>, so the component rendered unstyled: in the case that surfaced this (#63), a date picker collapsed into a column of overlapping numbers. Development hid it completely, because the Vite dev server injects that CSS through JavaScript.

    A new <head> helper, ruact_head_assets, links those stylesheets — above your own, so your CSS loads last and wins ties. It emits nothing while the Vite dev server is running. The layout ruact now ships calls it for you, and so does the built-in shell (config.layout = false), which still carries none of your stylesheets. rails ruact:doctor fails when a build emits component CSS that the layout rendering your pages never links, and names that layout.

  • The generated --shadcn tsconfig.json had no types for a CSS import. A client component importing a stylesheet for its side effect had nothing to resolve against. It now declares vite/client. If you keep your own tsconfig.json, add "types": ["vite/client"] to it.

  • Installing ruact no longer turns your whole app into ruact pages. rails generate ruact:install used to add include Ruact::Controller to ApplicationController, and from then on every action with an .html.erb rendered through ruact — install it into an existing app to try one screen and the other eighty were converted too, Turbo Frames included. Now the install changes no controller: a page renders through ruact when its controller has include Ruact::Controller, and ruact_pages only: %i[show] (or except:) narrows that to some actions — an action declared there counts even when it renders another action’s template. The navigation boundary reads the same declaration, so a form posting to an action left out of ruact_pages is submitted by the browser like any Rails form — and it is plain Rails in how it shows validation errors, too. A name in ruact_pages only: that is neither a method nor a template of the controller declaring it raises Ruact::ConfigurationError — a typo does not quietly make a page plain Rails. rails generate ruact:scaffold adds the include to the controllers it generates.

    To make every page a ruact page, as before, run rails generate ruact:install --app: it puts the include on ApplicationController and renders through your own layout. Apps already installed keep their include — re-running the install says the app is in whole-app mode and how to leave it, and removes nothing. rails ruact:doctor now reports which mode the app is in, and in whole-app mode how many templates render through ruact.

  • New installs render ruact pages through a layout the gem ships, and no longer edit yours. rails generate ruact:install used to write the React root and ruact_js_assets into app/views/layouts/application.html.erb. It now writes config.layout = "ruact": ruact pages render through layouts/ruact, which comes with the gem and links CSRF, CSP, your client components’ CSS and then the stylesheets you name in the new config.layout_stylesheets ([:app] under Propshaft 0.9+; the install writes ["application"] on Sprockets or an older Propshaft, and [] with neither). Your layouts are not touched.

    Because it is a named layout, it applies to every ruact page whatever a controller declares with layout (layout false included), and its <title> is your app’s name on every page. What that layout does not bring is the rest of your own layout’s <head> — favicons, fonts, analytics, meta tags other gems write — or your app’s JavaScript. If a ruact page needs those, rails generate ruact:layout copies the layout into app/views/layouts/ruact.html.erb, where it is yours to change and wins over the gem’s with no setting. Or set config.layout = true to keep rendering through your own layout, as before.

    Existing installs are not changed: re-running the install leaves a config.layout you already have alone. On an app that renders through its own layout, the install now prints any of the three lines that layout is missing — ruact_head_assets is the new one — instead of writing them.

  • 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.layout — false 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_serializable → ruact_serialize), and strict_serialization is unaffected (an AR Serializable model serializes identically under strict true/false, as a PORO does). This reverses the doc-only guidance shipped in the AGENTS.md/llms.txt serialization-contract fix (gem PR #48): ruact_props on an AR model is now a first-class pattern again; the manual row hash ({ id: p.id, title: p.title }) remains the equivalent for a no-model / partial shape.

  • 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.tt — restored from behind the flag, not rewritten. The default (no-flag) path stays design-system-AGNOSTIC (Story 14.4): no @/components/ui/* imports, no Tailwind, native <table>/<input>/<dialog>. The shadcn dependency pre-flight is re-promoted to a --shadcn-gated Thor command (check_shadcn_setup early-returns unless --shadcn): under --shadcn on an app where shadcn/ui is not set up, it surfaces the copy-pasteable npx shadcn@latest init + add <list> guidance and aborts before any write (raise Thor::Error, zero partial state) — no silent agnostic fallback under --shadcn; --skip-shadcn-check (meaningful only under --shadcn) bypasses the abort and writes the shadcn components with the in-file banner. The default path never invokes the pre-flight and never aborts. FR99 (type <Model>Row) + FR100 (__ruactContract) are preserved in the --shadcn .tsx; --shadcn --javascript composes to untyped .jsx shadcn output with the “forfeits FR99/FR100” banner. The controller, route, query, ERB views, model/migration/test delegation (14.3) and smoke spec are identical on both paths — --shadcn flips only the three component templates + the pre-flight. The shadcn templates, ShadcnPreflight, --skip-shadcn-check, FormHelpers#form_uses_*?, and the Epic 10 type-tests/scaffold/Post*.tsx fixtures + tsconfig.scaffold.json are byte-unchanged (the only runtime edits are +class_option :shadcn, the create_components branch, and the check_shadcn_setup re-registration + guard in scaffold_generator.rb). The Epic 10 default-output shadcn specs neutralized in 14.4 are un-skipped and re-pointed at the --shadcn path (zero remaining skips); a new :story_14_5 block proves the flag matrix (the @/components/ui grep in both directions + the pre-flight abort vs no-op), and the shadcn tsc --noEmit fixtures rejoin npm run typecheck. This completes the FR103 split: agnostic default for a frictionless first run, --shadcn for the proven Epic 10 styled output when shadcn is already in the app.

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

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

  • 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.0 → 0.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.0 → 0.3.0. FR88 sanitization. query_dispatch.rb#__ruact_query_kwargs enforces the kwargs allowlist on request.query_parameters: only string | number | boolean | null — arrays (?q[]=) and objects (?q[k]=) are rejected naming the key and the allowlist; a missing required kwarg → 400; an unknown param → 400 (rejected, not silently dropped). All raise Ruact::BadRequestError → HTTP 400 via the structured error payload. New file lib/ruact/server_functions/query_source.rb; new spec query_source_spec.rb + :story_9_5 cases across the codegen / railtie-integration / query-request specs; new runtime vitest usequery.test.mjs + query parity cases. ADR addendum (2026-06-10).

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

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

  • 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; end → def show; ruact_render; end; class Post; include Ruact::Serializable; rsc_props :id; end → ruact_props :id; deployment scripts running bundle exec rails rsc:doctor → bundle exec rails ruact:doctor). Any host-side import { setupRouter } from "./rsc-router.js" adjusts to from "./ruact-router.js".

  • 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 change — Ruact::Controller#ruact_render is unchanged. Note: Ruact::Flight::*, Ruact::Internal::*, and Ruact::RenderPipeline are not part of the public API and may change between minors. See decision note for rationale and contributor guidance.

    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 touched — gem/lib/ is unchanged. The new Ruact::Spec::* namespace is established as the canonical home for spec-only utilities; it is not part of the public API and may change between minors. Default for new wire tests should be match_flight_structure; reserve match_flight_fixture for tests where the wire bytes themselves are the contract (e.g. string_dollar_escape). See decision note for rationale and contributor guidance. Story 7.6 will migrate ~31 existing string-matching specs in a follow-up.

  • 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 resolution — Ruact::ClientManifest reads public/react-client-manifest.json (generated by the Vite plugin) and resolves component names to ClientReference objects via a dual-path resolver.

  • Ruact::Serializable mixin — ruact_props DSL for declaring safe prop attributes on Ruby model objects.

  • Install generator — rails 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 benchmark — rake 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 app — e2e/ Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.