ruact 0.0.15 → 0.0.17
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 +42 -1
- data/lib/generators/ruact/install/install_generator.rb +2 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +2 -2
- data/lib/ruact/client_manifest.rb +11 -2
- data/lib/ruact/component_attributes.rb +91 -0
- data/lib/ruact/configuration.rb +28 -0
- data/lib/ruact/controller.rb +3 -1
- data/lib/ruact/doctor.rb +64 -4
- data/lib/ruact/erb_preprocessor.rb +1 -47
- data/lib/ruact/flight/renderer.rb +23 -9
- data/lib/ruact/flight/request.rb +31 -2
- data/lib/ruact/flight/row_emitter.rb +9 -0
- data/lib/ruact/flight/serializer.rb +22 -7
- data/lib/ruact/render_pipeline.rb +10 -3
- data/lib/ruact/server.rb +11 -7
- data/lib/ruact/server_functions/action_assigns.rb +52 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +1 -1
- data/lib/ruact/testing/component_query.rb +11 -4
- data/lib/ruact/testing/flight_wire_parser.rb +2 -2
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact.rb +1 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +44 -2
- data/vendor/javascript/vite-plugin-ruact/flight-conformance.test.mjs +286 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +89 -14
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +804 -3
- data/vendor/javascript/vite-plugin-ruact/package.json +5 -1
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +53 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +46 -17
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-modules.js +40 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-webpack-globals.js +15 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/root-boundary.js +41 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +75 -88
- data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +83 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/transport.js +41 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/LICENSE +21 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/VERSION.json +5 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.development.js +5473 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.production.js +2126 -0
- data/vendor/javascript/vite-plugin-ruact/scripts/vendor-flight-client.mjs +124 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +14 -0
- metadata +15 -4
- data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +0 -452
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +0 -408
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 78c8550850bcded6e5585918c33d4520131a019520f4def020e9e5701a47c7ae
|
|
4
|
+
data.tar.gz: ab49c696c75137128062c6f9c7fa8b2569b8f918818bcf50f209d3939675af59
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a4dda8e8c229d941a6d8e852704837259a45653002b2c7465764ae219c58fbaa4188d4490745efe88327ceb736094ccc265b6e38f2cc38d008388bf7cb5d1e88
|
|
7
|
+
data.tar.gz: 976492c9e23c34d777071ec0fa313ae146d942ba1ade21d939de380e012a0cedc4028c9e997dabe96518ad9b7b00bbd48a4c162eef9270969ba9e2bf5ce4e3a6
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.0.17] - 2026-10-06
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **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.
|
|
15
|
+
|
|
16
|
+
- **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:
|
|
17
|
+
- import rows are `[id, chunks, name]`, with `chunks` always `[]` (the runtime registers every component up front);
|
|
18
|
+
- a string of 1024 bytes or more is referenced as `"$<hex>"`, not `"$T<hex>"`;
|
|
19
|
+
- 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`;
|
|
20
|
+
- 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;
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
23
|
+
`Ruact::Testing`'s matchers read the new wire, and `have_ruact_component` ignores the runtime's own boundary.
|
|
24
|
+
|
|
25
|
+
- **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.
|
|
26
|
+
|
|
27
|
+
- **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.
|
|
28
|
+
|
|
29
|
+
## [0.0.16] - 2026-10-03
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **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}`.
|
|
34
|
+
|
|
35
|
+
- **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.
|
|
36
|
+
|
|
37
|
+
- **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.
|
|
38
|
+
|
|
39
|
+
- **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.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **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:
|
|
44
|
+
- **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).
|
|
45
|
+
- **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"]`.
|
|
46
|
+
|
|
47
|
+
A page render's view still sees every instance variable.
|
|
48
|
+
|
|
10
49
|
## [0.0.15] - 2026-10-03
|
|
11
50
|
|
|
12
51
|
### Security
|
|
@@ -416,7 +455,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
416
455
|
|
|
417
456
|
- **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
|
|
418
457
|
|
|
419
|
-
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.
|
|
458
|
+
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.17...HEAD
|
|
459
|
+
[0.0.17]: https://github.com/luizcg/ruact/releases/tag/v0.0.17
|
|
460
|
+
[0.0.16]: https://github.com/luizcg/ruact/releases/tag/v0.0.16
|
|
420
461
|
[0.0.15]: https://github.com/luizcg/ruact/releases/tag/v0.0.15
|
|
421
462
|
[0.0.14]: https://github.com/luizcg/ruact/releases/tag/v0.0.14
|
|
422
463
|
[0.0.13]: https://github.com/luizcg/ruact/releases/tag/v0.0.13
|
|
@@ -425,7 +425,8 @@ module Ruact
|
|
|
425
425
|
|
|
426
426
|
show_shadcn_next_steps if shadcn?
|
|
427
427
|
|
|
428
|
-
say "\nThen add <MyComponent /> to
|
|
428
|
+
say "\nThen add <MyComponent /> to an ERB view of a ruact page:" unless whole_app?
|
|
429
|
+
say "\nThen add <MyComponent /> to any ERB view." if whole_app?
|
|
429
430
|
say layout_summary
|
|
430
431
|
say ""
|
|
431
432
|
say "Note: re-run this generator after updating the ruact gem to refresh"
|
|
@@ -84,8 +84,8 @@ READ IT instead of simulating the name generation. Regenerate after routes/queri
|
|
|
84
84
|
`<Suspense fallback="...">...</Suspense>` pair.
|
|
85
85
|
2. **`{}` props are Ruby, not JavaScript.** `<Badge label={@post.title} />`
|
|
86
86
|
evaluates `@post.title` as a Ruby expression in the ERB. No JS ternaries,
|
|
87
|
-
no `{...spread}`, no JSX children.
|
|
88
|
-
|
|
87
|
+
no `{...spread}`, no JSX children. A quoted value (`title="Hi"`) is a
|
|
88
|
+
plain string and a bare attribute is `true`, as in JSX.
|
|
89
89
|
3. **One action, two response shapes.** A `Ruact::Server` non-GET action
|
|
90
90
|
answers JSON — its instance variables, or `204`, or `{"$redirect": path}` —
|
|
91
91
|
when the request's `Accept` header is exactly `application/json`, which is
|
|
@@ -38,13 +38,22 @@ module Ruact
|
|
|
38
38
|
# Story 13.5 preprocess-time call-site validator; +nil+ means "no contract →
|
|
39
39
|
# no validation" (fail open).
|
|
40
40
|
class ClientManifest
|
|
41
|
+
# The +chunks+ of every import row (see {#resolve}). One shared frozen
|
|
42
|
+
# array: a render resolves each component once, and the allocation guard
|
|
43
|
+
# counts every array.
|
|
44
|
+
NO_CHUNKS = [].freeze
|
|
45
|
+
|
|
41
46
|
# Used by Flight::Serializer to produce I rows.
|
|
42
|
-
# Returns the metadata array
|
|
47
|
+
# Returns the metadata array React's Flight client reads: [id, chunks, name]
|
|
48
|
+
# (Story 18-1). +chunks+ is always empty: the browser runtime registers
|
|
49
|
+
# every client component eagerly (`virtual:ruact/registry`), so there is
|
|
50
|
+
# nothing to load — and React reads +chunks+ as `[chunkId, filename]` pairs,
|
|
51
|
+
# which the manifest's one-URL list is not.
|
|
43
52
|
def resolve(module_id, _export_name)
|
|
44
53
|
entry = by_module_id(module_id)
|
|
45
54
|
raise "ClientManifest: no entry for module_id=#{module_id.inspect}" unless entry
|
|
46
55
|
|
|
47
|
-
[entry["id"],
|
|
56
|
+
[entry["id"], NO_CHUNKS, entry["name"]]
|
|
48
57
|
end
|
|
49
58
|
|
|
50
59
|
# Returns true if +name+ is a top-level key in the manifest data.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# The attributes of a PascalCase component tag in ERB, read the way JSX
|
|
5
|
+
# reads them (see {ErbPreprocessor}). Returns ordered
|
|
6
|
+
# +[name, ruby_expr]+ pairs for the props Hash the template renders.
|
|
7
|
+
module ComponentAttributes
|
|
8
|
+
ATTR_NAME_RE = /\A[a-zA-Z_][\w-]*/
|
|
9
|
+
|
|
10
|
+
# Parses the attributes string of a component tag into ordered
|
|
11
|
+
# +[name, ruby_expr]+ pairs, e.g. [["postId", "@post.id"], ["title", "\"Hi\""]].
|
|
12
|
+
# The names feed the Story 13.5 contract check; the pairs render the props
|
|
13
|
+
# Hash. The three JSX attribute forms:
|
|
14
|
+
#
|
|
15
|
+
# name={ruby} — the Ruby expression (nested braces honored)
|
|
16
|
+
# name="text" — the string, as JSX passes it (single quotes too)
|
|
17
|
+
# name — +true+, as JSX passes a bare attribute
|
|
18
|
+
#
|
|
19
|
+
# Anything else raises instead of being dropped: an unbraced attribute used
|
|
20
|
+
# to vanish without a word, and it is the first thing a JSX hand writes.
|
|
21
|
+
def self.parse(attrs_string)
|
|
22
|
+
attrs = attrs_string.sub(%r{\s*/\z}, "")
|
|
23
|
+
pairs = []
|
|
24
|
+
i = 0
|
|
25
|
+
while i < attrs.length
|
|
26
|
+
if attrs[i].match?(/\s/)
|
|
27
|
+
i += 1
|
|
28
|
+
next
|
|
29
|
+
end
|
|
30
|
+
name = attrs[i..][ATTR_NAME_RE] ||
|
|
31
|
+
raise(PreprocessorError, "unexpected #{attrs[i, 20].inspect} in the component's attributes")
|
|
32
|
+
i += name.length
|
|
33
|
+
i += 1 while attrs[i]&.match?(/\s/)
|
|
34
|
+
if attrs[i] == "="
|
|
35
|
+
i += 1
|
|
36
|
+
i += 1 while attrs[i]&.match?(/\s/)
|
|
37
|
+
value, i = parse_value(attrs, i, name)
|
|
38
|
+
pairs << [name, value]
|
|
39
|
+
else
|
|
40
|
+
pairs << [name, "true"]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
pairs
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# One attribute value at +attrs[i]+ → [ruby_expr, index after it].
|
|
47
|
+
def self.parse_value(attrs, pos, name)
|
|
48
|
+
case attrs[pos]
|
|
49
|
+
when "{"
|
|
50
|
+
expr = extract_braced_expr(attrs, pos + 1)
|
|
51
|
+
raise PreprocessorError, "#{name}={} is empty: put a Ruby expression in the braces" if expr.strip.empty?
|
|
52
|
+
|
|
53
|
+
[expr, pos + expr.length + 2]
|
|
54
|
+
when '"', "'"
|
|
55
|
+
close = attrs.index(attrs[pos], pos + 1)
|
|
56
|
+
text = attrs[(pos + 1)...(close || attrs.length)]
|
|
57
|
+
if text.include?("<%")
|
|
58
|
+
raise PreprocessorError, "#{name}= holds ERB, which a component attribute cannot run. " \
|
|
59
|
+
"Pass the Ruby in braces instead: #{name}={...}"
|
|
60
|
+
end
|
|
61
|
+
unless close
|
|
62
|
+
raise PreprocessorError, "unclosed quote in #{name}= (a component tag cannot contain `>`, " \
|
|
63
|
+
"even inside a quoted value: pass the text from Ruby instead)"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
[text.inspect, close + 1]
|
|
67
|
+
else
|
|
68
|
+
raise PreprocessorError, "#{name}= needs a value: #{name}={ruby} or #{name}=\"text\""
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Given a string and a start position (just after the opening '{'),
|
|
73
|
+
# returns the content up to the matching '}'.
|
|
74
|
+
def self.extract_braced_expr(str, start)
|
|
75
|
+
depth = 1
|
|
76
|
+
i = start
|
|
77
|
+
while i < str.length && depth.positive?
|
|
78
|
+
case str[i]
|
|
79
|
+
when "{" then depth += 1
|
|
80
|
+
when "}" then depth -= 1
|
|
81
|
+
end
|
|
82
|
+
i += 1
|
|
83
|
+
end
|
|
84
|
+
raise PreprocessorError, "unclosed brace in prop expression" if depth.positive?
|
|
85
|
+
|
|
86
|
+
str[start...(i - 1)]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private_class_method :parse_value, :extract_braced_expr
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/ruact/configuration.rb
CHANGED
|
@@ -26,6 +26,7 @@ module Ruact
|
|
|
26
26
|
shadcn_compatible_versions
|
|
27
27
|
layout
|
|
28
28
|
layout_stylesheets
|
|
29
|
+
server_function_hidden_ivars
|
|
29
30
|
].freeze
|
|
30
31
|
|
|
31
32
|
# @!attribute [r] manifest_path
|
|
@@ -125,6 +126,19 @@ module Ruact
|
|
|
125
126
|
# @example Set an app-wide default expiry
|
|
126
127
|
# Ruact.configure { |c| c.signed_global_id_default_expires_in = 1.hour }
|
|
127
128
|
#
|
|
129
|
+
# @!attribute [r] server_function_hidden_ivars
|
|
130
|
+
# @return [Array<String>] Instance-variable names (no `@`) a server
|
|
131
|
+
# function's JSON never returns, even when the action set them: the
|
|
132
|
+
# memoization ivars of authentication and authorization libraries,
|
|
133
|
+
# which an action fills just by calling `current_user` or `authorize`.
|
|
134
|
+
# Names starting with `_` are always hidden. Default
|
|
135
|
+
# `["current_user", "current_ability", "pundit"]` (Devise's `:user`
|
|
136
|
+
# scope, CanCanCan, Pundit); another Devise scope memoizes its own
|
|
137
|
+
# `@current_<scope>` — add it. Assign to replace the list; to keep the
|
|
138
|
+
# defaults, add to them.
|
|
139
|
+
# @example Also hide an app's own memoized account
|
|
140
|
+
# Ruact.configure { |c| c.server_function_hidden_ivars += ["current_account"] }
|
|
141
|
+
#
|
|
128
142
|
# @!attribute [r] shadcn_compatible_versions
|
|
129
143
|
# @return [Array<Integer>] Story 10.5 — the shadcn/ui MAJOR versions the
|
|
130
144
|
# `ruact:scaffold` generator is regression-tested against. When the
|
|
@@ -255,6 +269,7 @@ module Ruact
|
|
|
255
269
|
@shadcn_compatible_versions = [1, 2, 4]
|
|
256
270
|
@layout = false
|
|
257
271
|
@layout_stylesheets = [:app]
|
|
272
|
+
@server_function_hidden_ivars = %w[current_user current_ability pundit]
|
|
258
273
|
end
|
|
259
274
|
end
|
|
260
275
|
|
|
@@ -278,6 +293,9 @@ module Ruact
|
|
|
278
293
|
# @api private
|
|
279
294
|
# @return [Ruact::Configuration] self, frozen
|
|
280
295
|
def seal!
|
|
296
|
+
# An Array attribute mutated in place inside the block (`list << x`)
|
|
297
|
+
# never passed through its writer; validate it here, before publication.
|
|
298
|
+
validate_attribute_value!(:server_function_hidden_ivars, server_function_hidden_ivars)
|
|
281
299
|
ATTRIBUTES.each do |attr|
|
|
282
300
|
value = public_send(attr)
|
|
283
301
|
next if value.nil? || value.frozen?
|
|
@@ -322,6 +340,7 @@ module Ruact
|
|
|
322
340
|
when :shadcn_compatible_versions then validate_shadcn_compatible_versions!(value)
|
|
323
341
|
when :layout then validate_layout!(value)
|
|
324
342
|
when :layout_stylesheets then validate_layout_stylesheets!(value)
|
|
343
|
+
when :server_function_hidden_ivars then validate_server_function_hidden_ivars!(value)
|
|
325
344
|
end
|
|
326
345
|
end
|
|
327
346
|
|
|
@@ -440,6 +459,15 @@ module Ruact
|
|
|
440
459
|
"major versions (e.g. [1, 2]); got #{value.inspect}."
|
|
441
460
|
end
|
|
442
461
|
|
|
462
|
+
# An Array of ivar names; a leading `@` is a typo that would hide nothing.
|
|
463
|
+
def validate_server_function_hidden_ivars!(value)
|
|
464
|
+
return if value.is_a?(Array) && value.all? { |name| name.is_a?(String) && !name.start_with?("@") }
|
|
465
|
+
|
|
466
|
+
raise Ruact::ConfigurationError,
|
|
467
|
+
"Ruact::Configuration#server_function_hidden_ivars must be an Array of instance-variable " \
|
|
468
|
+
"names without the @ (e.g. [\"current_user\"]); got #{value.inspect}."
|
|
469
|
+
end
|
|
470
|
+
|
|
443
471
|
def build_error_message(attr, location)
|
|
444
472
|
<<~MSG.strip
|
|
445
473
|
ruact: cannot mutate Ruact::Configuration##{attr} after initialization.
|
data/lib/ruact/controller.rb
CHANGED
|
@@ -168,7 +168,9 @@ module Ruact
|
|
|
168
168
|
# its headers on the first row.
|
|
169
169
|
self.status = status if status
|
|
170
170
|
|
|
171
|
-
pipeline = RenderPipeline.new(ruact_manifest,
|
|
171
|
+
pipeline = RenderPipeline.new(ruact_manifest,
|
|
172
|
+
controller_path: controller_path, logger: logger,
|
|
173
|
+
development: Flight::Renderer.development_default)
|
|
172
174
|
streaming = ruact_request? && self.class.ancestors.include?(ActionController::Live)
|
|
173
175
|
|
|
174
176
|
# Allocate a per-render context and expose it to the view via a normal
|
data/lib/ruact/doctor.rb
CHANGED
|
@@ -8,8 +8,8 @@ module Ruact
|
|
|
8
8
|
# Runs a suite of installation health checks and prints ✓/✗ per check.
|
|
9
9
|
# Extracted from the ruact:doctor Rake task for direct testability (FR27).
|
|
10
10
|
class Doctor # rubocop:disable Metrics/ClassLength
|
|
11
|
-
CHECKS = %i[manifest vite controller layout head_assets streaming legacy_constant
|
|
12
|
-
flight_middleware].freeze
|
|
11
|
+
CHECKS = %i[manifest vite flight_client controller layout head_assets streaming legacy_constant
|
|
12
|
+
serialize_only flight_middleware].freeze
|
|
13
13
|
# Built via Array#join so the gem-CI `name-propagation` guard does not
|
|
14
14
|
# match these literals against itself (Story 5.1 review F4 — the doctor
|
|
15
15
|
# file participates in the guard with no exclusion).
|
|
@@ -42,8 +42,8 @@ module Ruact
|
|
|
42
42
|
/\b#{%w[from flight].join('_')}\b/,
|
|
43
43
|
/\b#{%w[parse flight].join('_')}\b/,
|
|
44
44
|
/\b#{%w[decode flight].join('_')}\b/,
|
|
45
|
-
# React Flight reader entry points invoked from Ruby (NOT
|
|
46
|
-
#
|
|
45
|
+
# React Flight reader entry points invoked from Ruby (NOT the browser
|
|
46
|
+
# runtime's own calls, which read the server's own trusted payload)
|
|
47
47
|
/\b#{%w[create From].join}(?:NodeStream|ReadableStream|Fetch)\b/
|
|
48
48
|
].freeze
|
|
49
49
|
DESERIALIZE_SIGNAL_RE = Regexp.union(DESERIALIZE_SIGNALS)
|
|
@@ -163,6 +163,66 @@ module Ruact
|
|
|
163
163
|
"Run npm run dev (or bin/dev) to start the Vite dev server."]
|
|
164
164
|
end
|
|
165
165
|
|
|
166
|
+
# Story 18-1 — which copy of React's Flight client the browser runtime
|
|
167
|
+
# uses, decided as the Vite plugin decides it (`resolveFlightClient`): the
|
|
168
|
+
# app's `react-server-dom-webpack` when the app's package.json declares it
|
|
169
|
+
# and Node finds it from the app (a workspace may hoist it to a parent
|
|
170
|
+
# directory), otherwise the one vendored in the gem. The plugin prints the
|
|
171
|
+
# same line when Vite starts. The client is built for one React minor; the
|
|
172
|
+
# app's copy is checked against the app's `react`.
|
|
173
|
+
def check_flight_client
|
|
174
|
+
vendored = [:pass, "Flight client: react-server-dom-webpack #{vendored_flight_client_version} vendored in ruact"]
|
|
175
|
+
return vendored unless app_declares?("react-server-dom-webpack")
|
|
176
|
+
|
|
177
|
+
app_package = node_package_json("react-server-dom-webpack")
|
|
178
|
+
return vendored unless app_package
|
|
179
|
+
|
|
180
|
+
version = JSON.parse(app_package.read)["version"]
|
|
181
|
+
react_package = node_package_json("react")
|
|
182
|
+
react = react_package && JSON.parse(react_package.read)["version"]
|
|
183
|
+
message = "Flight client: react-server-dom-webpack #{version} from the app (replaces the copy in ruact)"
|
|
184
|
+
return [:pass, message] if react.nil? || minor(react) == minor(version)
|
|
185
|
+
|
|
186
|
+
[:warn, "#{message}, but the app's react is #{react}",
|
|
187
|
+
"Install the react-server-dom-webpack matching your react (npm install react-server-dom-webpack@#{react}), " \
|
|
188
|
+
"or uninstall it to use the copy vendored in ruact."]
|
|
189
|
+
rescue JSON::ParserError => e
|
|
190
|
+
[:warn, "Flight client: a package.json under node_modules is not valid JSON (#{e.message})",
|
|
191
|
+
"Reinstall it (npm install), or remove react-server-dom-webpack to use the copy vendored in ruact."]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# As the Vite plugin reads it: a non-empty entry in dependencies or
|
|
195
|
+
# devDependencies; an unreadable package.json declares nothing.
|
|
196
|
+
def app_declares?(name)
|
|
197
|
+
manifest = Rails.root.join("package.json")
|
|
198
|
+
return false unless manifest.exist?
|
|
199
|
+
|
|
200
|
+
json = JSON.parse(manifest.read)
|
|
201
|
+
[json["dependencies"], json["devDependencies"]].any? { |deps| deps.is_a?(Hash) && !deps[name].to_s.empty? }
|
|
202
|
+
rescue JSON::ParserError
|
|
203
|
+
false
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The first `node_modules/<name>/package.json` from Rails.root upward, the
|
|
207
|
+
# way Node resolves a package.
|
|
208
|
+
def node_package_json(name)
|
|
209
|
+
Pathname(Rails.root).expand_path.ascend do |dir|
|
|
210
|
+
candidate = dir.join("node_modules", name, "package.json")
|
|
211
|
+
return candidate if candidate.exist?
|
|
212
|
+
end
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def minor(version)
|
|
217
|
+
version.to_s.split(".").first(2).join(".")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def vendored_flight_client_version
|
|
221
|
+
path = File.join(File.dirname(Ruact.vite_plugin_path), "runtime", "vendor", "react-server-dom-webpack",
|
|
222
|
+
"VERSION.json")
|
|
223
|
+
JSON.parse(File.read(path))["version"]
|
|
224
|
+
end
|
|
225
|
+
|
|
166
226
|
# Story 17.0g (FR116) — reports the ADOPTION MODE instead of demanding one.
|
|
167
227
|
#
|
|
168
228
|
# Whole-app (`ruact:install --app`): ApplicationController includes the
|
|
@@ -51,11 +51,6 @@ module Ruact
|
|
|
51
51
|
# unrelated `<% "</Dialog>" %>` appears later).
|
|
52
52
|
ERB_ISLAND_RE = /<%.*?%>/m
|
|
53
53
|
|
|
54
|
-
# Matches a +{ruby_expr}+ attribute value — captures everything between the braces.
|
|
55
|
-
# We use a simple bracket-depth counter approach during scanning instead of regex
|
|
56
|
-
# because expressions can contain nested braces: {foo.bar({ a: 1 })}.
|
|
57
|
-
PROP_RE = /\b([a-zA-Z_][a-zA-Z0-9_]*)=\{/
|
|
58
|
-
|
|
59
54
|
# Transform ERB source, replacing component tags with ERB placeholders.
|
|
60
55
|
# Returns the transformed source string.
|
|
61
56
|
#
|
|
@@ -114,7 +109,7 @@ module Ruact
|
|
|
114
109
|
# the render path surfaces the clear error). In prod this is the
|
|
115
110
|
# boot-loaded Ruact.manifest, unchanged.
|
|
116
111
|
registry = ManifestResolver.resolve_soft if registry == :default
|
|
117
|
-
pairs =
|
|
112
|
+
pairs = ComponentAttributes.parse(attrs_string)
|
|
118
113
|
validate_contract(registry, component_name, pairs.map(&:first),
|
|
119
114
|
at: { file: identifier, line: line, snippet: match.strip })
|
|
120
115
|
props_ruby = pairs.map { |name, expr| "#{name.inspect} => #{expr}" }.join(", ")
|
|
@@ -247,46 +242,5 @@ module Ruact
|
|
|
247
242
|
m = identifier.to_s.match(%r{app/views/(.+)/[^/]+\z})
|
|
248
243
|
m && m[1]
|
|
249
244
|
end
|
|
250
|
-
|
|
251
|
-
# Parses the attributes string of a component tag into ordered
|
|
252
|
-
# +[name, ruby_expr]+ pairs, e.g. [["postId", "@post.id"], ["count", "5"]].
|
|
253
|
-
# Honors nested braces in values (via {#extract_braced_expr}). The names
|
|
254
|
-
# feed the Story 13.5 contract check; the pairs render the props Hash.
|
|
255
|
-
def parse_prop_pairs(attrs_string)
|
|
256
|
-
return [] if attrs_string.empty?
|
|
257
|
-
|
|
258
|
-
pairs = []
|
|
259
|
-
remaining = attrs_string.dup
|
|
260
|
-
|
|
261
|
-
while (m = PROP_RE.match(remaining))
|
|
262
|
-
prop_name = m[1]
|
|
263
|
-
# Find the matching closing brace, respecting nesting
|
|
264
|
-
value_start = m.end(0)
|
|
265
|
-
value_expr = extract_braced_expr(remaining, value_start)
|
|
266
|
-
pairs << [prop_name, value_expr]
|
|
267
|
-
# Advance past this prop
|
|
268
|
-
remaining = remaining[(value_start + value_expr.length + 1)..] # +1 for closing }
|
|
269
|
-
break if remaining.nil?
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
pairs
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
# Given a string and a start position (just after the opening '{'),
|
|
276
|
-
# returns the content up to the matching '}'.
|
|
277
|
-
def extract_braced_expr(str, start)
|
|
278
|
-
depth = 1
|
|
279
|
-
i = start
|
|
280
|
-
while i < str.length && depth.positive?
|
|
281
|
-
case str[i]
|
|
282
|
-
when "{" then depth += 1
|
|
283
|
-
when "}" then depth -= 1
|
|
284
|
-
end
|
|
285
|
-
i += 1
|
|
286
|
-
end
|
|
287
|
-
raise PreprocessorError, "unclosed brace in prop expression" if depth.positive?
|
|
288
|
-
|
|
289
|
-
str[start...(i - 1)]
|
|
290
|
-
end
|
|
291
245
|
end
|
|
292
246
|
end
|
|
@@ -14,17 +14,30 @@ module Ruact
|
|
|
14
14
|
each(model, bundler_config, streaming: false, **).to_a.join
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
on_as_json_warning: on_as_json_warning).each(streaming: streaming, &)
|
|
17
|
+
# Keyword options other than +streaming:+ go to {#initialize}
|
|
18
|
+
# (strict_serialization:, on_as_json_warning:, development:).
|
|
19
|
+
def self.each(model, bundler_config, streaming: true, **, &)
|
|
20
|
+
new(model, bundler_config, **).each(streaming: streaming, &)
|
|
22
21
|
end
|
|
23
22
|
|
|
24
|
-
|
|
23
|
+
# Whether rows carry the slots React's development client reads (see
|
|
24
|
+
# Serializer#element_tuple): in Rails' development environment only. The
|
|
25
|
+
# test environment keeps the production wire, so what a request spec
|
|
26
|
+
# asserts is what production sends.
|
|
27
|
+
def self.development_default
|
|
28
|
+
return false unless defined?(Rails) && Rails.respond_to?(:env)
|
|
29
|
+
|
|
30
|
+
Rails.env.development?
|
|
31
|
+
rescue StandardError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil,
|
|
36
|
+
development: false)
|
|
25
37
|
@request = Request.new(model, bundler_config,
|
|
26
38
|
strict_serialization: strict_serialization,
|
|
27
|
-
on_as_json_warning: on_as_json_warning
|
|
39
|
+
on_as_json_warning: on_as_json_warning,
|
|
40
|
+
development: development)
|
|
28
41
|
end
|
|
29
42
|
|
|
30
43
|
# Yields Flight rows one at a time.
|
|
@@ -50,7 +63,8 @@ module Ruact
|
|
|
50
63
|
if streaming && deferred[:delay]&.positive?
|
|
51
64
|
timeout = Ruact.config.suspense_timeout
|
|
52
65
|
if timeout&.positive? && deferred[:delay] > timeout
|
|
53
|
-
|
|
66
|
+
error = RowEmitter.error_payload("Suspense timeout exceeded", digest: "ruact:suspense-timeout")
|
|
67
|
+
yield RowEmitter.error(deferred[:id], JSON.generate(error))
|
|
54
68
|
next
|
|
55
69
|
end
|
|
56
70
|
sleep(deferred[:delay])
|
|
@@ -58,7 +72,7 @@ module Ruact
|
|
|
58
72
|
|
|
59
73
|
# Serialize deferred content — may produce new import rows, and new
|
|
60
74
|
# regular rows (a string of 1024+ bytes becomes a `T` row the deferred
|
|
61
|
-
# model row references as
|
|
75
|
+
# model row references as `$<id>`).
|
|
62
76
|
import_count_before = @request.completed_import_chunks.length
|
|
63
77
|
regular_count_before = @request.completed_regular_chunks.length
|
|
64
78
|
deferred_value = serializer.serialize_model(deferred[:element])
|
data/lib/ruact/flight/request.rb
CHANGED
|
@@ -16,11 +16,15 @@ module Ruact
|
|
|
16
16
|
# object_id => "$L<hex>" reference (dedup)
|
|
17
17
|
attr_reader :written_objects
|
|
18
18
|
attr_reader :next_chunk_id, :pending_chunks, :bundler_config, :root_model,
|
|
19
|
-
:strict_serialization, :on_as_json_warning
|
|
19
|
+
:strict_serialization, :on_as_json_warning, :development
|
|
20
20
|
|
|
21
|
-
def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil
|
|
21
|
+
def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil,
|
|
22
|
+
development: false)
|
|
22
23
|
@strict_serialization = strict_serialization
|
|
23
24
|
@on_as_json_warning = on_as_json_warning
|
|
25
|
+
@development = development
|
|
26
|
+
@suspense_symbol_ref = nil
|
|
27
|
+
@boundary_ref = nil
|
|
24
28
|
@next_chunk_id = 0
|
|
25
29
|
@pending_chunks = 0
|
|
26
30
|
@bundler_config = bundler_config
|
|
@@ -42,6 +46,31 @@ module Ruact
|
|
|
42
46
|
id
|
|
43
47
|
end
|
|
44
48
|
|
|
49
|
+
# The `"$<hex>"` reference to this response's `"$Sreact.suspense"` symbol
|
|
50
|
+
# row, emitted once, before the first row that uses it.
|
|
51
|
+
def suspense_symbol_ref
|
|
52
|
+
@suspense_symbol_ref ||= begin
|
|
53
|
+
id = allocate_id
|
|
54
|
+
@completed_regular_chunks << RowEmitter.model(id, JSON.generate("$Sreact.suspense"))
|
|
55
|
+
"$#{id.to_s(16)}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The runtime's own client component that catches an error in a Suspense
|
|
60
|
+
# child (runtime/suspense-boundary.js). Not in the app's manifest: the
|
|
61
|
+
# runtime registers it under this id.
|
|
62
|
+
BOUNDARY_METADATA = ["ruact:boundary", [], "SuspenseBoundary"].freeze
|
|
63
|
+
|
|
64
|
+
# The `"$L<hex>"` reference to {BOUNDARY_METADATA}'s import row, emitted
|
|
65
|
+
# once per response.
|
|
66
|
+
def boundary_ref
|
|
67
|
+
@boundary_ref ||= begin
|
|
68
|
+
id = allocate_id
|
|
69
|
+
@completed_import_chunks << RowEmitter.import(id, JSON.generate(BOUNDARY_METADATA))
|
|
70
|
+
"$L#{id.to_s(16)}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
45
74
|
def increment_pending
|
|
46
75
|
@pending_chunks += 1
|
|
47
76
|
end
|
|
@@ -22,6 +22,15 @@ module Ruact
|
|
|
22
22
|
"#{id.to_s(16)}:E#{error_json}\n"
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# The error object React's Flight client reads from an E row. Its
|
|
26
|
+
# development build reads `stack` and `env`; a bare string crashes it.
|
|
27
|
+
# Its production build drops the message and keeps `digest`, so the
|
|
28
|
+
# digest is a stable code the runtime turns back into the message
|
|
29
|
+
# (runtime/suspense-boundary.js, DIGEST_MESSAGES).
|
|
30
|
+
def self.error_payload(message, digest:)
|
|
31
|
+
{ "digest" => digest, "name" => "Error", "message" => message, "stack" => [], "env" => "Server" }
|
|
32
|
+
end
|
|
33
|
+
|
|
25
34
|
# A large text row (binary framing, no trailing newline)
|
|
26
35
|
def self.text(id, text)
|
|
27
36
|
byte_length = text.bytesize
|
|
@@ -64,7 +64,9 @@ module Ruact
|
|
|
64
64
|
@request.increment_pending
|
|
65
65
|
row = RowEmitter.text(id, value)
|
|
66
66
|
@request.completed_regular_chunks << row
|
|
67
|
-
|
|
67
|
+
# A plain "$<hex>" reference: React reads "$T" as a temporary
|
|
68
|
+
# reference (Story 18-1).
|
|
69
|
+
return "$#{id.to_s(16)}"
|
|
68
70
|
end
|
|
69
71
|
|
|
70
72
|
# Escape leading $ so the client doesn't misinterpret it.
|
|
@@ -131,7 +133,16 @@ module Ruact
|
|
|
131
133
|
key = element.key
|
|
132
134
|
props = serialize_hash(element.props)
|
|
133
135
|
|
|
134
|
-
|
|
136
|
+
element_tuple(resolved_type, key, props)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# In development, React's client reads three more slots — owner, debug
|
|
140
|
+
# stack, and whether the children were validated. ERB has no `key`, so
|
|
141
|
+
# without the last one every element with sibling children or rendered in
|
|
142
|
+
# a loop logs React's "unique key" warning. Production reads four slots.
|
|
143
|
+
def element_tuple(type, key, props)
|
|
144
|
+
tuple = ["$", type, key, props]
|
|
145
|
+
@request.development ? tuple.push(nil, nil, 1) : tuple
|
|
135
146
|
end
|
|
136
147
|
|
|
137
148
|
# --- Suspense Boundary ---
|
|
@@ -143,11 +154,15 @@ module Ruact
|
|
|
143
154
|
|
|
144
155
|
fallback_value = element.fallback ? serialize_model(element.fallback) : nil
|
|
145
156
|
|
|
146
|
-
#
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
157
|
+
# React's shape (Story 18-1): the type is a reference to a
|
|
158
|
+
# `"$Sreact.suspense"` symbol row, and the deferred content is a lazy
|
|
159
|
+
# child the client resolves when its row arrives. The lazy child sits
|
|
160
|
+
# inside the runtime's error boundary: an error row for it (a Suspense
|
|
161
|
+
# timeout) keeps the fallback on screen and reaches the router's
|
|
162
|
+
# `onError`, where an unhandled render error would unmount the page.
|
|
163
|
+
boundary = element_tuple(@request.boundary_ref, nil,
|
|
164
|
+
{ "fallback" => fallback_value, "children" => "$L#{deferred_id.to_s(16)}" })
|
|
165
|
+
element_tuple(@request.suspense_symbol_ref, nil, { "fallback" => fallback_value, "children" => boundary })
|
|
151
166
|
end
|
|
152
167
|
|
|
153
168
|
# --- Unknown type fallback ---
|