ruact 0.0.13 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -1
- data/lib/generators/ruact/install/install_generator.rb +113 -2
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +1 -1
- data/lib/generators/ruact/install/templates/package.json.tt +4 -4
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +39 -12
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +4 -3
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +8 -8
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +8 -8
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +2 -2
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +1 -1
- data/lib/ruact/configuration.rb +6 -4
- data/lib/ruact/controller/format_negotiation.rb +83 -0
- data/lib/ruact/controller/page_rendering.rb +1 -1
- data/lib/ruact/controller.rb +7 -0
- data/lib/ruact/flight/js_date.rb +22 -0
- data/lib/ruact/flight/renderer.rb +8 -3
- data/lib/ruact/flight/serializer.rb +10 -4
- data/lib/ruact/flight/utf8.rb +36 -0
- data/lib/ruact/flight.rb +2 -0
- data/lib/ruact/redirect_path.rb +16 -0
- data/lib/ruact/routing.rb +24 -6
- data/lib/ruact/server.rb +6 -0
- data/lib/ruact/testing/component_query.rb +23 -1
- data/lib/ruact/testing/flight_extractor.rb +6 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +43 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.js +19 -10
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +7 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +12 -0
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +131 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +84 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +167 -5
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +10 -19
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +3 -3
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +3 -3
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +4 -4
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +4 -4
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ab61a580bf64e964d0356fae822cc80a83d31eaaa38fdfb5398e6534158772f0
|
|
4
|
+
data.tar.gz: c66021ed18cba60a650a6f1f4e77fc60b848079e2c125aa4f34f2868f6af2101
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 053037bfb91205f625613499e5b98c14da4e886be7a94ef60239a1da934d44a35dbf104208494cc951eb7048c9bedbf1b8792c46ed9428e2a455f18d6141f729
|
|
7
|
+
data.tar.gz: '0800f0c8d28ea88c91b7d40b488ac6931b7548811cbfa0bd56458dbc9d0d4a09ab8f2db5a0884217045072b9a3889fa168cd83d969cb9f778bf22aab0ddc1cc8'
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.0.15] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- **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.
|
|
15
|
+
|
|
16
|
+
- **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.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **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.
|
|
21
|
+
|
|
22
|
+
- **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.
|
|
23
|
+
|
|
24
|
+
- **`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.
|
|
25
|
+
|
|
26
|
+
- **`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.
|
|
27
|
+
|
|
28
|
+
- **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.
|
|
29
|
+
|
|
30
|
+
## [0.0.14] - 2026-10-02
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **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.
|
|
35
|
+
|
|
36
|
+
- **`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.
|
|
37
|
+
|
|
38
|
+
- **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`.
|
|
39
|
+
|
|
40
|
+
- **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.
|
|
41
|
+
|
|
42
|
+
- **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.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **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.
|
|
47
|
+
|
|
48
|
+
- **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.
|
|
49
|
+
|
|
10
50
|
## [0.0.13] - 2026-09-27
|
|
11
51
|
|
|
12
52
|
### Added
|
|
@@ -84,7 +124,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
84
124
|
|
|
85
125
|
|
|
86
126
|
- **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.
|
|
127
|
+
|
|
87
128
|
- **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.
|
|
129
|
+
|
|
88
130
|
- **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.
|
|
89
131
|
|
|
90
132
|
### Fixed
|
|
@@ -124,6 +166,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
124
166
|
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.
|
|
125
167
|
|
|
126
168
|
- **`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.
|
|
169
|
+
|
|
127
170
|
- **`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.
|
|
128
171
|
|
|
129
172
|
### Fixed
|
|
@@ -280,6 +323,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
280
323
|
### Tooling
|
|
281
324
|
|
|
282
325
|
- **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.
|
|
326
|
+
|
|
283
327
|
- **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.
|
|
284
328
|
|
|
285
329
|
### Renamed
|
|
@@ -307,6 +351,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
307
351
|
|
|
308
352
|
- 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.
|
|
309
353
|
- **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.
|
|
354
|
+
|
|
310
355
|
- **`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.
|
|
311
356
|
|
|
312
357
|
_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)`.
|
|
@@ -340,23 +385,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
340
385
|
### Added
|
|
341
386
|
|
|
342
387
|
- **ERB preprocessor** — PascalCase RSC component tags (`<Button />`, `<LikeButton postId={@post.id} />`) are transformed to Flight placeholders in ERB templates before Ruby evaluation.
|
|
388
|
+
|
|
343
389
|
- **`<Suspense>` support** — `<Suspense fallback="Loading...">` in ERB templates maps to React Suspense boundaries in the Flight payload.
|
|
390
|
+
|
|
344
391
|
- **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`.
|
|
392
|
+
|
|
345
393
|
- **`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`.
|
|
394
|
+
|
|
346
395
|
- **Streaming mode** — When `ActionController::Live` is included, Flight rows are streamed to the client as they are produced (Suspense-aware).
|
|
396
|
+
|
|
347
397
|
- **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.
|
|
398
|
+
|
|
348
399
|
- **`Ruact::Serializable` mixin** — `ruact_props` DSL for declaring safe prop attributes on Ruby model objects.
|
|
400
|
+
|
|
349
401
|
- **Install generator** — `rails generate ruact:install` scaffolds the initializer, Vite config patch, and JavaScript entry point.
|
|
402
|
+
|
|
350
403
|
- **`ruact:doctor` Rake task** — Checks manifest presence, Vite server accessibility, controller setup, and streaming mode configuration.
|
|
404
|
+
|
|
351
405
|
- **`vite-plugin-ruact`** — Vite plugin (npm package, co-versioned) that scans `"use client"` components and emits `public/react-client-manifest.json`.
|
|
406
|
+
|
|
352
407
|
- **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.
|
|
408
|
+
|
|
353
409
|
- **Error overlay** — Development-mode React error boundary with dismissible overlay for Flight parse and rendering errors.
|
|
410
|
+
|
|
354
411
|
- **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`.
|
|
412
|
+
|
|
355
413
|
- **Memory benchmark** — `rake benchmark:memory` enforces a 120% allocation regression gate against `spec/benchmarks/baseline.json`.
|
|
414
|
+
|
|
356
415
|
- **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.
|
|
416
|
+
|
|
357
417
|
- **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
|
|
358
418
|
|
|
359
|
-
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.
|
|
419
|
+
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.15...HEAD
|
|
420
|
+
[0.0.15]: https://github.com/luizcg/ruact/releases/tag/v0.0.15
|
|
421
|
+
[0.0.14]: https://github.com/luizcg/ruact/releases/tag/v0.0.14
|
|
360
422
|
[0.0.13]: https://github.com/luizcg/ruact/releases/tag/v0.0.13
|
|
361
423
|
[0.0.12]: https://github.com/luizcg/ruact/releases/tag/v0.0.12
|
|
362
424
|
[0.0.11]: https://github.com/luizcg/ruact/releases/tag/v0.0.11
|
|
@@ -152,6 +152,24 @@ module Ruact
|
|
|
152
152
|
say ""
|
|
153
153
|
end
|
|
154
154
|
|
|
155
|
+
# What `--shadcn` adds to package.json and Procfile.dev. One source for the
|
|
156
|
+
# templates (fresh install) and the merge below (an app that already has
|
|
157
|
+
# both files), so the two paths cannot drift.
|
|
158
|
+
SHADCN_DEV_DEPENDENCIES = {
|
|
159
|
+
"@tailwindcss/cli" => "^4.0.0",
|
|
160
|
+
"tailwindcss" => "^4.0.0",
|
|
161
|
+
"tw-animate-css" => "^1.0.0"
|
|
162
|
+
}.freeze
|
|
163
|
+
SHADCN_BUILD_CSS_SCRIPT =
|
|
164
|
+
"@tailwindcss/cli -i app/javascript/styles/globals.css -o app/assets/builds/tailwind.css --minify"
|
|
165
|
+
# `--watch=always`, not `--watch`: Tailwind stops watching when stdin
|
|
166
|
+
# closes, and it does exit 0, so foreman then stops Rails and Vite with it.
|
|
167
|
+
# stdin is closed whenever bin/dev runs without a terminal (a coding agent,
|
|
168
|
+
# CI, Docker, an IDE task runner), which made the whole app go down in silence.
|
|
169
|
+
SHADCN_CSS_PROCESS =
|
|
170
|
+
"css: npx @tailwindcss/cli -i app/javascript/styles/globals.css " \
|
|
171
|
+
"-o app/assets/builds/tailwind.css --watch=always"
|
|
172
|
+
|
|
155
173
|
# `--shadcn` only. Two files, both of them things shadcn's CLI checks for
|
|
156
174
|
# and refuses to proceed without ("No Tailwind CSS configuration found" /
|
|
157
175
|
# "Could not find valid path aliases"), verified against shadcn 4.x:
|
|
@@ -256,10 +274,18 @@ module Ruact
|
|
|
256
274
|
# imports it by the absolute `Ruact.vite_plugin_path` and it uses only
|
|
257
275
|
# `node:` builtins. Guarded like vite.config.js: an existing package.json
|
|
258
276
|
# is left untouched (the app may already have one) unless --force.
|
|
277
|
+
#
|
|
278
|
+
# Under `--shadcn` an existing package.json is not skipped but COMPLETED:
|
|
279
|
+
# adding shadcn to an app that already ran the install is the documented
|
|
280
|
+
# path (Getting Started step 7 onward), and skipping here left Tailwind
|
|
281
|
+
# undeclared, so `shadcn init` aborted with "No Tailwind CSS configuration
|
|
282
|
+
# found" right after this generator said the prerequisites were in place.
|
|
259
283
|
def create_package_json
|
|
260
284
|
package_json_file = Pathname(destination_root).join("package.json")
|
|
261
285
|
|
|
262
286
|
if package_json_file.exist? && !options[:force]
|
|
287
|
+
return merge_shadcn_package_json(package_json_file) if shadcn?
|
|
288
|
+
|
|
263
289
|
say_status "skip", "package.json already exists — ensure it has react, react-dom, " \
|
|
264
290
|
"vite and @vitejs/plugin-react (re-run with --force to overwrite)", :yellow
|
|
265
291
|
return
|
|
@@ -279,7 +305,12 @@ module Ruact
|
|
|
279
305
|
# OWNED by ruact: see `install_foreman_launcher`. `bin/dev` is made
|
|
280
306
|
# executable.
|
|
281
307
|
def create_launch_files
|
|
282
|
-
|
|
308
|
+
procfile = Pathname(destination_root).join("Procfile.dev")
|
|
309
|
+
if procfile.exist? && !options[:force] && shadcn?
|
|
310
|
+
append_shadcn_css_process(procfile)
|
|
311
|
+
else
|
|
312
|
+
create_guarded_file "Procfile.dev", "Procfile.dev.tt"
|
|
313
|
+
end
|
|
283
314
|
install_foreman_launcher
|
|
284
315
|
# Ensure bin/dev is executable whether we just wrote it or it pre-existed
|
|
285
316
|
# (a skipped, already-foreman launcher should still be runnable).
|
|
@@ -612,7 +643,13 @@ module Ruact
|
|
|
612
643
|
# templates' imports, so the two generators cannot drift.
|
|
613
644
|
def show_shadcn_next_steps
|
|
614
645
|
say ""
|
|
615
|
-
|
|
646
|
+
if shadcn_gaps.empty?
|
|
647
|
+
say "shadcn prerequisites are in place (Tailwind entry, tsconfig alias, css process)."
|
|
648
|
+
else
|
|
649
|
+
say_status "attention", "shadcn prerequisites are NOT all in place:", :red
|
|
650
|
+
shadcn_gaps.each { |gap| say " - #{gap}" }
|
|
651
|
+
say "Fix these first, then run the two commands below."
|
|
652
|
+
end
|
|
616
653
|
say "Two commands remain — they are interactive and hit the network, so run them yourself:"
|
|
617
654
|
say ""
|
|
618
655
|
say " npx shadcn@latest init --base radix"
|
|
@@ -890,6 +927,12 @@ module Ruact
|
|
|
890
927
|
options[:shadcn]
|
|
891
928
|
end
|
|
892
929
|
|
|
930
|
+
# Prerequisites `--shadcn` could not put in place. Non-empty means
|
|
931
|
+
# show_shadcn_next_steps must not say "in place".
|
|
932
|
+
def shadcn_gaps
|
|
933
|
+
@shadcn_gaps ||= []
|
|
934
|
+
end
|
|
935
|
+
|
|
893
936
|
# The superset the scaffold generator narrows per resource. Loaded lazily
|
|
894
937
|
# (and only under `--shadcn`) so a plain install never pays for the
|
|
895
938
|
# scaffold generator's load, and so a failure to reach it degrades to the
|
|
@@ -901,6 +944,74 @@ module Ruact
|
|
|
901
944
|
"button input textarea switch select label badge table alert-dialog dropdown-menu"
|
|
902
945
|
end
|
|
903
946
|
|
|
947
|
+
# Adds the shadcn devDependencies and the build:css script to an existing
|
|
948
|
+
# package.json. Never overwrites a key the app already has (its own
|
|
949
|
+
# Tailwind version wins). The file is re-serialized with 2-space JSON
|
|
950
|
+
# when something is added. An unparseable file is left alone, loudly.
|
|
951
|
+
def merge_shadcn_package_json(path)
|
|
952
|
+
# `create_file … force: true` would DELETE the whole file under
|
|
953
|
+
# `rails destroy`; this merge has nothing to undo.
|
|
954
|
+
return if behavior == :revoke
|
|
955
|
+
|
|
956
|
+
pkg = JSON.parse(path.read.delete_prefix("\uFEFF"))
|
|
957
|
+
raise JSON::ParserError, "top level is #{pkg.class}, not an object" unless pkg.is_a?(Hash)
|
|
958
|
+
|
|
959
|
+
dev = (pkg["devDependencies"] ||= {})
|
|
960
|
+
scripts = (pkg["scripts"] ||= {})
|
|
961
|
+
flag_tailwind_below_v4(dev["tailwindcss"] || pkg.dig("dependencies", "tailwindcss"))
|
|
962
|
+
added = SHADCN_DEV_DEPENDENCIES.reject { |name, _| dev.key?(name) || pkg.dig("dependencies", name) }
|
|
963
|
+
dev.merge!(added)
|
|
964
|
+
add_script = !scripts.key?("build:css")
|
|
965
|
+
scripts["build:css"] = SHADCN_BUILD_CSS_SCRIPT if add_script
|
|
966
|
+
|
|
967
|
+
if added.empty? && !add_script
|
|
968
|
+
say_status "identical", "package.json (Tailwind already declared)", :blue
|
|
969
|
+
return
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
create_file "package.json", "#{JSON.pretty_generate(pkg)}\n", force: true, verbose: false
|
|
973
|
+
say_status "update", "package.json (+ #{(added.keys + (add_script ? ['build:css'] : [])).join(', ')})", :green
|
|
974
|
+
rescue JSON::ParserError => e
|
|
975
|
+
shadcn_gaps << "package.json could not be parsed (#{e.message.lines.first.strip}) — add " \
|
|
976
|
+
"#{SHADCN_DEV_DEPENDENCIES.keys.join(', ')} to devDependencies and a " \
|
|
977
|
+
"\"build:css\" script (#{SHADCN_BUILD_CSS_SCRIPT}) yourself"
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# globals.css is written for Tailwind 4 (`@import "tailwindcss"`). An app
|
|
981
|
+
# pinned to an older major keeps its version, and the setup is not in place.
|
|
982
|
+
def flag_tailwind_below_v4(version)
|
|
983
|
+
major = version.to_s[/\d+/]
|
|
984
|
+
return if major.nil? || major.to_i >= 4
|
|
985
|
+
|
|
986
|
+
shadcn_gaps << "package.json pins tailwindcss #{version}; app/javascript/styles/globals.css " \
|
|
987
|
+
"and shadcn's current components need Tailwind 4"
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
# Appends the Tailwind watch process to an existing Procfile.dev, unless a
|
|
991
|
+
# process already builds globals.css. A different `css:` process, or
|
|
992
|
+
# another Tailwind watcher (tailwindcss-rails' compiles its own entry into
|
|
993
|
+
# the same app/assets/builds/tailwind.css), cannot be appended next to: it
|
|
994
|
+
# is reported as a gap, never silently accepted. Comment lines are ignored.
|
|
995
|
+
def append_shadcn_css_process(path)
|
|
996
|
+
content = path.read
|
|
997
|
+
code = content.lines.reject { |line| line.lstrip.start_with?("#") }
|
|
998
|
+
|
|
999
|
+
if code.any? { |line| line.include?("app/javascript/styles/globals.css") }
|
|
1000
|
+
say_status "identical", "Procfile.dev (already builds globals.css)", :blue
|
|
1001
|
+
return
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
if (clash = code.find { |line| line.match?(/\A\s*css\s*:/) || line.include?("tailwind") })
|
|
1005
|
+
shadcn_gaps << "Procfile.dev already runs `#{clash.strip}`, which does not build " \
|
|
1006
|
+
"app/javascript/styles/globals.css — replace it with: #{SHADCN_CSS_PROCESS}"
|
|
1007
|
+
return
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
separator = content.empty? || content.end_with?("\n") ? "" : "\n"
|
|
1011
|
+
append_to_file "Procfile.dev", "#{separator}#{SHADCN_CSS_PROCESS}\n", verbose: false
|
|
1012
|
+
say_status "update", "Procfile.dev (+ css process)", :green
|
|
1013
|
+
end
|
|
1014
|
+
|
|
904
1015
|
def app_package_name
|
|
905
1016
|
base = File.basename(File.expand_path(destination_root))
|
|
906
1017
|
sanitized = base.downcase.gsub(/[^a-z0-9._-]/, "-").squeeze("-").gsub(/\A-+|-+\z/, "")
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "vite",
|
|
7
7
|
"build": "vite build"<% if shadcn? %>,
|
|
8
|
-
"build:css": "
|
|
8
|
+
"build:css": "<%= SHADCN_BUILD_CSS_SCRIPT %>"<% end %>
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"react": "^19.0.0",
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@vitejs/plugin-react": "^4.3.4",
|
|
16
16
|
<% if shadcn? -%>
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
17
|
+
<% SHADCN_DEV_DEPENDENCIES.each do |name, version| -%>
|
|
18
|
+
"<%= name %>": "<%= version %>",
|
|
19
|
+
<% end -%>
|
|
20
20
|
<% end -%>
|
|
21
21
|
"vite": "^6.0.7"
|
|
22
22
|
}
|
|
@@ -107,7 +107,7 @@ module Ruact
|
|
|
107
107
|
|
|
108
108
|
SUPPORTED_TYPES = TYPE_MAP.keys.freeze
|
|
109
109
|
|
|
110
|
-
# Column types the
|
|
110
|
+
# Column types the search query's case-insensitive LIKE scope spans —
|
|
111
111
|
# matching numeric/date/boolean columns by substring is meaningless.
|
|
112
112
|
SEARCHABLE_COLUMN_TYPES = %w[string text].freeze
|
|
113
113
|
|
|
@@ -121,7 +121,7 @@ module Ruact
|
|
|
121
121
|
REFERENCE_OPTIONS_LIMIT = 100
|
|
122
122
|
|
|
123
123
|
# Documentation anchor referenced by the unknown-type error message (AC4).
|
|
124
|
-
DOCS_POINTER = "https://
|
|
124
|
+
DOCS_POINTER = "https://ruact.dev/docs/api/scaffold.html#attribute-types"
|
|
125
125
|
|
|
126
126
|
# Story 10.5 (AC1, AC2, AC4) — the shadcn/ui dependency PRE-FLIGHT: detect
|
|
127
127
|
# the host's shadcn state (complete / missing / partial) and either proceed
|
|
@@ -191,7 +191,7 @@ module Ruact
|
|
|
191
191
|
end
|
|
192
192
|
|
|
193
193
|
# AC5 — the client-driven read path. Emits the resource query
|
|
194
|
-
# (`<Plural>Query < ApplicationQuery` with a `
|
|
194
|
+
# (`<Plural>Query < ApplicationQuery` with a `search_<plural>(q:)` method) in BOTH
|
|
195
195
|
# `.tsx` and `.jsx` modes (the query is server-side Ruby; the language flag
|
|
196
196
|
# only governs the React component). The `ApplicationQuery` base is created
|
|
197
197
|
# idempotently — `ruact:install` does NOT ship it, and a second scaffold in
|
|
@@ -205,13 +205,13 @@ module Ruact
|
|
|
205
205
|
template "queries/application_query.rb.tt", application_query
|
|
206
206
|
end
|
|
207
207
|
|
|
208
|
-
# AC5 — mount the resource query so its `
|
|
209
|
-
# GET route the codegen exports as `search
|
|
208
|
+
# AC5 — mount the resource query so its `search_<plural>` method becomes the
|
|
209
|
+
# named GET route the codegen exports as `search<Plural>` (consumed by `useQuery`).
|
|
210
210
|
# Idempotent on re-run: guard on the drawn `ruact_queries <Plural>Query`
|
|
211
211
|
# line first (sibling of {#add_resource_route}'s `resources :posts` guard).
|
|
212
212
|
def add_query_route
|
|
213
213
|
routes_file = Pathname(destination_root).join("config/routes.rb")
|
|
214
|
-
if routes_file.exist? && routes_file.read
|
|
214
|
+
if routes_file.exist? && query_already_mounted?(routes_file.read)
|
|
215
215
|
say_status "skip", "ruact_queries #{query_class_name} already routed", :yellow
|
|
216
216
|
return
|
|
217
217
|
end
|
|
@@ -406,17 +406,44 @@ module Ruact
|
|
|
406
406
|
|
|
407
407
|
# The read-side query class — PLURAL, mirroring the golden `PostsQuery`
|
|
408
408
|
# (file `posts_query.rb`) and Zeitwerk's path↔constant rule. Mounted via
|
|
409
|
-
# `ruact_queries <Plural>Query`; its
|
|
409
|
+
# `ruact_queries <Plural>Query`; its search method becomes `GET /q/searchPosts`.
|
|
410
410
|
def query_class_name
|
|
411
411
|
"#{class_name.pluralize}Query"
|
|
412
412
|
end
|
|
413
413
|
|
|
414
|
-
#
|
|
415
|
-
#
|
|
416
|
-
#
|
|
417
|
-
|
|
414
|
+
# True when a `ruact_queries` line already lists this exact class —
|
|
415
|
+
# alone or among others, ignoring a trailing comment and not mistaking
|
|
416
|
+
# `Legacy::PostsQuery` or `BlogPostsQuery` for `PostsQuery`.
|
|
417
|
+
def query_already_mounted?(routes)
|
|
418
|
+
class_ref = /(?<![:\w])#{Regexp.escape(query_class_name)}\b/
|
|
419
|
+
routes.each_line.any? do |line|
|
|
420
|
+
code = line.sub(/#.*/, "")
|
|
421
|
+
code.match?(/\A\s*ruact_queries\b/) && code.match?(class_ref)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# The query's search method, named after the resource (`search_posts`).
|
|
426
|
+
# Query names share ONE namespace — one `GET /q/<name>` route and one
|
|
427
|
+
# export of `@/.ruact/server-functions` per name — so a bare `search`
|
|
428
|
+
# was free for the first resource and broke the boot on the second
|
|
429
|
+
# (`ruact_query_search` drawn twice).
|
|
430
|
+
def query_search_method
|
|
431
|
+
"search_#{plural_table_name}"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# The accessor the codegen exports for {#query_search_method}
|
|
435
|
+
# (`searchPosts`); the List imports it under this name, unaliased.
|
|
418
436
|
def js_search_alias
|
|
419
|
-
|
|
437
|
+
Ruact::ServerFunctions::NameBridge.to_js_identifier(query_search_method)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# The List's collection prop, named after the resource (`posts`,
|
|
441
|
+
# `comments`, `blogPosts`) rather than fixed to the golden's `posts`.
|
|
442
|
+
# The List binds it to a fixed local (`initialRows`), so a model whose
|
|
443
|
+
# plural matches one of the List's own names (`Row` → `rows`) or one a
|
|
444
|
+
# module cannot declare (`Argument` → `arguments`) still compiles.
|
|
445
|
+
def js_collection_prop
|
|
446
|
+
plural_table_name.camelize(:lower)
|
|
420
447
|
end
|
|
421
448
|
|
|
422
449
|
# The columns the search `LIKE` scope spans — string/text only (a
|
|
@@ -20,7 +20,7 @@ module Ruact
|
|
|
20
20
|
# Documentation anchor for the shadcn dependency pre-flight: how to set
|
|
21
21
|
# up shadcn, and how to override the version-compat warning.
|
|
22
22
|
SHADCN_DOCS_POINTER =
|
|
23
|
-
"https://
|
|
23
|
+
"https://ruact.dev/docs/guides/shadcn-ui.html#shadcn-versions"
|
|
24
24
|
|
|
25
25
|
# The pre-flight body (the {ScaffoldGenerator#check_shadcn_setup} Thor
|
|
26
26
|
# command delegates here). Detect the host's shadcn state, surface the
|
|
@@ -141,9 +141,10 @@ module Ruact
|
|
|
141
141
|
<<~MSG.chomp
|
|
142
142
|
ruact:scaffold — shadcn/ui is not set up in this app yet.
|
|
143
143
|
The generated components import from @/components/ui/*, which does not exist.
|
|
144
|
-
Set up shadcn/ui first
|
|
144
|
+
Set up shadcn/ui first (--base radix: the components import Radix primitives,
|
|
145
|
+
and shadcn now defaults to Base UI), then re-run this generator:
|
|
145
146
|
|
|
146
|
-
npx shadcn@latest init
|
|
147
|
+
npx shadcn@latest init --base radix
|
|
147
148
|
#{shadcn_add_command(required_shadcn_components)}
|
|
148
149
|
|
|
149
150
|
No files were written (no partial state). Advanced: pass --skip-shadcn-check
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// small GENERATED client-side sort. There is NO table-engine dependency: the
|
|
17
17
|
// scaffold stays dep-free (no react-table runtime), matching the dep-free
|
|
18
18
|
// Form and the native date inputs. The collection arrives as a SERVER-RENDERED
|
|
19
|
-
// prop (
|
|
19
|
+
// prop (`<%= js_collection_prop %>`); there is no client query for the initial render. A query only
|
|
20
20
|
// enters when the *client* drives the read: the search box calls
|
|
21
21
|
// useQuery(<%= js_search_alias %>, { q }) and swaps in filtered rows as you type.
|
|
22
22
|
// Per-row delete drives a controlled <%= class_name %>DeleteDialog (DELETE
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
// freshly scaffolded app will not resolve these until 10.5 lands; that is
|
|
31
31
|
// expected (the end-to-end live demo is Story 10.7).
|
|
32
32
|
import { useState } from "react";
|
|
33
|
-
import {
|
|
33
|
+
import { <%= js_search_alias %>, destroy<%= class_name %>, useQuery } from "@/.ruact/server-functions";
|
|
34
34
|
import { <%= class_name %>DeleteDialog } from "./<%= class_name %>DeleteDialog";
|
|
35
35
|
import { Badge } from "@/components/ui/badge";
|
|
36
36
|
import { Button } from "@/components/ui/button";
|
|
@@ -52,11 +52,11 @@ import {
|
|
|
52
52
|
|
|
53
53
|
type <%= class_name %>Row = { <%= ts_row_fields %> };
|
|
54
54
|
|
|
55
|
-
// FR100 — opt-in call-site contract:
|
|
56
|
-
// `<<%= class_name %>List
|
|
55
|
+
// FR100 — opt-in call-site contract: `<%= js_collection_prop %>` is required. The index view passes
|
|
56
|
+
// `<<%= class_name %>List <%= js_collection_prop %>={rows} />` (satisfied); a call site that omits it
|
|
57
57
|
// fails at preprocess time, not as a silent `undefined` in the browser.
|
|
58
58
|
export const __ruactContract = {
|
|
59
|
-
props: {
|
|
59
|
+
props: { <%= js_collection_prop %>: "required" },
|
|
60
60
|
};
|
|
61
61
|
<% end -%>
|
|
62
62
|
|
|
@@ -208,9 +208,9 @@ function RowActions({ record, onDeleted }<% if typescript? %>: {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
export function <%= class_name %>List({
|
|
211
|
-
|
|
211
|
+
<%= js_collection_prop %>: initialRows = [],
|
|
212
212
|
emptyLabel = "No <%= plural_name %> yet — create one.",
|
|
213
|
-
}<% if typescript? %>: {
|
|
213
|
+
}<% if typescript? %>: { <%= js_collection_prop %>?: <%= class_name %>Row[]; emptyLabel?: string }<% end %>) {
|
|
214
214
|
const [q, setQ] = useState("");
|
|
215
215
|
const searching = q.trim().length > 0;
|
|
216
216
|
|
|
@@ -229,7 +229,7 @@ export function <%= class_name %>List({
|
|
|
229
229
|
// the box is idle and we fall back to the server-rendered rows.
|
|
230
230
|
const { data: searchData, loading: searchLoading } = useQuery<% if typescript? %><<%= class_name %>Row[]><% end %>(<%= js_search_alias %>, { q: q.trim() });
|
|
231
231
|
|
|
232
|
-
const source = searching ? searchData ?? [] :
|
|
232
|
+
const source = searching ? searchData ?? [] : initialRows;
|
|
233
233
|
const rows = removedIds.length === 0 ? source : source.filter((row) => !removedIds.includes(row.id));
|
|
234
234
|
// Always sort a COPY — never mutate the prop/source array.
|
|
235
235
|
const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// <%= class_name %> list — a DESIGN-SYSTEM-AGNOSTIC table (Story 14.4 / FR103). The
|
|
9
9
|
// default scaffold ships plain, native HTML elements styled by the browser /
|
|
10
10
|
// Rails-default CSS — NO shadcn/ui, NO Tailwind, NO table-engine dependency. The
|
|
11
|
-
// collection arrives as a SERVER-RENDERED prop (
|
|
11
|
+
// collection arrives as a SERVER-RENDERED prop (`<%= js_collection_prop %>`); there is no client query
|
|
12
12
|
// for the initial render. A query only enters when the *client* drives the read:
|
|
13
13
|
// the search box calls useQuery(<%= js_search_alias %>, { q }) and swaps in filtered rows as
|
|
14
14
|
// you type. Per-row delete drives a controlled <%= class_name %>DeleteDialog (DELETE
|
|
@@ -18,17 +18,17 @@
|
|
|
18
18
|
// sort/pagination is Phase-3 territory. (The richer shadcn DataTable styling is
|
|
19
19
|
// the opt-in `--shadcn` path — Story 14.5.)
|
|
20
20
|
import { useState } from "react";
|
|
21
|
-
import {
|
|
21
|
+
import { <%= js_search_alias %>, destroy<%= class_name %>, useQuery } from "@/.ruact/server-functions";
|
|
22
22
|
import { <%= class_name %>DeleteDialog } from "./<%= class_name %>DeleteDialog";
|
|
23
23
|
<% if typescript? -%>
|
|
24
24
|
|
|
25
25
|
type <%= class_name %>Row = { <%= ts_row_fields %> };
|
|
26
26
|
|
|
27
|
-
// FR100 — opt-in call-site contract:
|
|
28
|
-
// `<<%= class_name %>List
|
|
27
|
+
// FR100 — opt-in call-site contract: `<%= js_collection_prop %>` is required. The index view passes
|
|
28
|
+
// `<<%= class_name %>List <%= js_collection_prop %>={rows} />` (satisfied); a call site that omits it
|
|
29
29
|
// fails at preprocess time, not as a silent `undefined` in the browser.
|
|
30
30
|
export const __ruactContract = {
|
|
31
|
-
props: {
|
|
31
|
+
props: { <%= js_collection_prop %>: "required" },
|
|
32
32
|
};
|
|
33
33
|
<% end -%>
|
|
34
34
|
|
|
@@ -146,9 +146,9 @@ function RowActions({ record, onDeleted }<% if typescript? %>: {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
export function <%= class_name %>List({
|
|
149
|
-
|
|
149
|
+
<%= js_collection_prop %>: initialRows = [],
|
|
150
150
|
emptyLabel = "No <%= plural_name %> yet — create one.",
|
|
151
|
-
}<% if typescript? %>: {
|
|
151
|
+
}<% if typescript? %>: { <%= js_collection_prop %>?: <%= class_name %>Row[]; emptyLabel?: string }<% end %>) {
|
|
152
152
|
const [q, setQ] = useState("");
|
|
153
153
|
const searching = q.trim().length > 0;
|
|
154
154
|
|
|
@@ -167,7 +167,7 @@ export function <%= class_name %>List({
|
|
|
167
167
|
// the box is idle and we fall back to the server-rendered rows.
|
|
168
168
|
const { data: searchData, loading: searchLoading } = useQuery<% if typescript? %><<%= class_name %>Row[]><% end %>(<%= js_search_alias %>, { q: q.trim() });
|
|
169
169
|
|
|
170
|
-
const source = searching ? searchData ?? [] :
|
|
170
|
+
const source = searching ? searchData ?? [] : initialRows;
|
|
171
171
|
const rows = removedIds.length === 0 ? source : source.filter((row) => !removedIds.includes(row.id));
|
|
172
172
|
// Always sort a COPY — never mutate the prop/source array.
|
|
173
173
|
const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# Read side of the CRUD. Each public instance method becomes one named GET
|
|
4
4
|
# route when mounted with `ruact_queries <%= query_class_name %>`:
|
|
5
5
|
#
|
|
6
|
-
# GET /q
|
|
6
|
+
# GET /q/<%= js_search_alias %> → <%= query_class_name %>#<%= query_search_method %>(q:) → useQuery(<%= js_search_alias %>, { q }) (list search box)
|
|
7
7
|
#
|
|
8
8
|
# NOTE: there is deliberately NO whole-list query — the index list is
|
|
9
9
|
# server-rendered as props (see <%= controller_class_name %>Controller#index). A query is only the
|
|
@@ -14,7 +14,7 @@ class <%= query_class_name %> < ApplicationQuery
|
|
|
14
14
|
# is what justifies a query — the result depends on live client input, not on
|
|
15
15
|
# what the server already had. Rows are the SAME shape the index serializes, so
|
|
16
16
|
# the search results and the server-rendered props are interchangeable.
|
|
17
|
-
def
|
|
17
|
+
def <%= query_search_method %>(q:)
|
|
18
18
|
term = q.to_s.strip
|
|
19
19
|
scope =
|
|
20
20
|
<% if searchable_attributes.empty? -%>
|
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
Flight payload: no client query, no loading flash. The ivar is serialized to
|
|
4
4
|
plain row hashes here in the view (no as_json on the model). %>
|
|
5
5
|
<%% rows = @<%= plural_name %>.map { |<%= singular_name %>| <%= serialized_row(singular_name) %> } %>
|
|
6
|
-
<<%= class_name %>List
|
|
6
|
+
<<%= class_name %>List <%= js_collection_prop %>={rows} />
|
data/lib/ruact/configuration.rb
CHANGED
|
@@ -73,7 +73,7 @@ module Ruact
|
|
|
73
73
|
# a stream-safety guarantee — Rack's multipart parser will still buffer
|
|
74
74
|
# bodies up to its own limits before the guard rejects. For very large
|
|
75
75
|
# uploads route through Active Storage Direct Upload or a presigned S3
|
|
76
|
-
# URL; see
|
|
76
|
+
# URL; see https://ruact.dev/docs/api/server-actions.html, "File uploads".
|
|
77
77
|
# @example Raise the limit to 25 MB
|
|
78
78
|
# Ruact.configure { |c| c.max_upload_bytes = 25 * 1024 * 1024 }
|
|
79
79
|
# @example Disable the gem-side guard (reverse proxy owns the cap)
|
|
@@ -132,11 +132,13 @@ module Ruact
|
|
|
132
132
|
# host `package.json`) that is NOT in this list, it emits a warning
|
|
133
133
|
# (never a hard stop) that the generated components may import from
|
|
134
134
|
# outdated `@/components/ui/*` paths. Must be a non-empty Array of
|
|
135
|
-
# Integers. Default `[1, 2]` (the majors tested at gem-release time
|
|
135
|
+
# Integers. Default `[1, 2, 4]` (the majors tested at gem-release time;
|
|
136
|
+
# 4 was run end to end on 2026-10-01: init --base radix, the full add
|
|
137
|
+
# list, scaffold --shadcn, form/table/dialog in a browser).
|
|
136
138
|
# A dev who has manually verified a newer major adds it here to
|
|
137
139
|
# suppress the warning — the documented "override" path.
|
|
138
140
|
# @example Allow shadcn v3 once you have verified it
|
|
139
|
-
# Ruact.configure { |c| c.shadcn_compatible_versions = [1, 2, 3] }
|
|
141
|
+
# Ruact.configure { |c| c.shadcn_compatible_versions = [1, 2, 3, 4] }
|
|
140
142
|
#
|
|
141
143
|
# @!attribute [r] layout
|
|
142
144
|
# @return [Boolean, String] Which document wrapper a ruact page's HTML
|
|
@@ -250,7 +252,7 @@ module Ruact
|
|
|
250
252
|
@query_parent_controller = "ApplicationController"
|
|
251
253
|
@signed_global_id_default_purpose = nil
|
|
252
254
|
@signed_global_id_default_expires_in = nil
|
|
253
|
-
@shadcn_compatible_versions = [1, 2]
|
|
255
|
+
@shadcn_compatible_versions = [1, 2, 4]
|
|
254
256
|
@layout = false
|
|
255
257
|
@layout_stylesheets = [:app]
|
|
256
258
|
end
|