ruact 0.0.1 → 0.0.3
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/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +86 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1680 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +89 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +75 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +446 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +429 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +87 -6
- data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -38,19 +38,62 @@ Expected output (`client_reference.txt`):
|
|
|
38
38
|
|
|
39
39
|
---
|
|
40
40
|
|
|
41
|
-
## How
|
|
41
|
+
## How to assert on Flight output
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
Three RSpec matcher modes live in `spec/support/matchers/flight_fixture_matcher.rb`. Pick the mode that names what you actually care about — defaulting to `match_flight_structure` for new specs avoids the cosmetic-change brittleness that bit Phase 1 (a `JSON.generate` tweak broke 30+ specs at once).
|
|
44
|
+
|
|
45
|
+
| Mode | When to use | What it tolerates | Failure shape |
|
|
46
|
+
|---|---|---|---|
|
|
47
|
+
| `match_flight_fixture(name)` | The wire format itself is the contract — bytes-for-bytes guard. Use for serializer escape rules, ordering invariants, or any test where "the output looks exactly like this" is the assertion. | Nothing — exact `==` against the fixture file. | Inspected expected vs. actual strings (whitespace and escapes visible). |
|
|
48
|
+
| `match_flight_structure(expected)` | The parsed semantics are the contract — change-tolerant. Use for round-tripping tests, scaffolding, end-to-end render tests, and any case where JSON key reordering or whitespace tweaks should not break the spec. | JSON key insertion order inside payload hashes; whitespace inside JSON; relative ordering among `:import` rows (which Flight treats as an unordered set). Cross-class row order (imports → models → deferred → errors) is **preserved** because the wire protocol depends on it. | Row-indexed diff naming the differing field path (e.g. `Row 0 (model) differs at .payload[0]:`). |
|
|
49
|
+
| `include_flight_row(predicate)` | A specific shape must appear regardless of order or siblings. Use for concurrency tests, partial assertions in multi-row outputs, or "this row exists somewhere" checks. | Sibling rows; ordering. Predicate is subset-matched (only the keys you list are compared). | Predicate inspect + a list of every parsed row with id/class/payload preview. |
|
|
50
|
+
|
|
51
|
+
### Worked examples
|
|
52
|
+
|
|
53
|
+
Each example is a complete RSpec assertion you can paste into a spec file (above `RSpec.describe` blocks where the matchers are autoloaded by `spec_helper`).
|
|
54
|
+
|
|
55
|
+
**`match_flight_fixture` — wire-format contract guard:**
|
|
56
|
+
```ruby
|
|
57
|
+
serialized = "0:\"$$danger\"\n"
|
|
58
|
+
expect(serialized).to match_flight_fixture("string_dollar_escape")
|
|
59
|
+
# Asserts byte-for-byte against `string_dollar_escape.txt` — proves the `$` → `$$` escape rule.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**`match_flight_structure` — parsed-semantics assertion:**
|
|
63
|
+
```ruby
|
|
64
|
+
wire = %(1:I["/LikeButton.jsx","LikeButton",["/LikeButton.jsx"]]\n) +
|
|
65
|
+
%(0:["$","$L1",null,{"postId":42}]\n)
|
|
66
|
+
|
|
67
|
+
expect(wire).to match_flight_structure([
|
|
68
|
+
{ id: 1, class: :import, payload: ["/LikeButton.jsx", "LikeButton", ["/LikeButton.jsx"]] },
|
|
69
|
+
{ id: 0, class: :model, payload: ["$", "$L1", nil, { "postId" => 42 }] }
|
|
70
|
+
])
|
|
71
|
+
# Passes even if `JSON.generate` re-orders props later — what matters is that postId=42 round-trips.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**`include_flight_row` — ordering-independent presence check:**
|
|
75
|
+
```ruby
|
|
76
|
+
wire = %(0:["$","CounterButton",null,{"initialCount":0}]\n)
|
|
77
|
+
|
|
78
|
+
expect(wire).to include_flight_row(
|
|
79
|
+
class: :model,
|
|
80
|
+
payload: hash_including("initialCount" => 0)
|
|
81
|
+
)
|
|
82
|
+
# Used in concurrency specs where multiple threads emit interleaved rows; only the row's shape matters.
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### ❌ Avoid / ✅ Prefer
|
|
44
86
|
|
|
45
87
|
```ruby
|
|
46
|
-
expect(output).to
|
|
88
|
+
❌ expect(output).to include('"postId":42') # cosmetic-change brittle
|
|
89
|
+
✅ expect(output).to include_flight_row(class: :model, payload: hash_including("postId" => 42))
|
|
47
90
|
```
|
|
48
91
|
|
|
49
|
-
|
|
92
|
+
A future `JSON.generate` change that emits keys in different insertion order (`{"label":"x","postId":42}` instead of `{"postId":42,"label":"x"}`) breaks the literal substring match. The structural form parses the row and compares hashes by key-set, so it survives the cosmetic change.
|
|
50
93
|
|
|
51
|
-
###
|
|
94
|
+
### When a fixture spec fails after a serializer change
|
|
52
95
|
|
|
53
|
-
|
|
96
|
+
If `match_flight_fixture` fails after touching `Ruact::Flight::Serializer`, that's by design — the wire format **is** the contract for those fixtures. Either the change is intentional (update the fixture file and the CHANGELOG) or it isn't (revert). Don't switch the spec to `match_flight_structure` to "make it green"; that hides the regression that fixture mode is designed to catch.
|
|
54
97
|
|
|
55
98
|
---
|
|
56
99
|
|
|
@@ -63,6 +106,11 @@ When a fixture does not match, the failure message shows both the expected (fixt
|
|
|
63
106
|
| `boolean_false.txt` | Ruby `false` serializes to the JSON `false` literal |
|
|
64
107
|
| `number_integer.txt` | Ruby integer (e.g. `42`) serializes to a bare JSON number |
|
|
65
108
|
| `number_float.txt` | Ruby float (e.g. `3.14`) serializes to a bare JSON float |
|
|
109
|
+
| `bigint.txt` | Ruby integer above `MAX_SAFE_INTEGER` (`2**53`) serializes to `"$n<decimal>"` so the JS client can rebuild a `BigInt` |
|
|
110
|
+
| `nan.txt` | `Float::NAN` serializes to `"$NaN"` (JSON has no NaN literal; Flight uses the `$`-prefix sentinel) |
|
|
111
|
+
| `infinity.txt` | `Float::INFINITY` serializes to `"$Infinity"` |
|
|
112
|
+
| `negative_infinity.txt` | `-Float::INFINITY` serializes to `"$-Infinity"` |
|
|
113
|
+
| `undefined.txt` | The `:undefined` symbol sentinel serializes to `"$undefined"` so the JS client decodes it as `undefined` (JSON has no undefined literal) |
|
|
66
114
|
| `string_basic.txt` | Plain Ruby string serializes to a JSON double-quoted string |
|
|
67
115
|
| `string_dollar_escape.txt` | Strings starting with `$` are escaped to `$$…` to avoid collision with Flight's `$L` reference syntax |
|
|
68
116
|
| `array.txt` | Ruby array serializes to a JSON array in row 0 |
|
|
@@ -71,7 +119,7 @@ When a fixture does not match, the failure message shows both the expected (fixt
|
|
|
71
119
|
| `client_component_with_props.txt` | A `ClientReference` with props passes them as the fourth element of the root array |
|
|
72
120
|
| `react_element_no_props.txt` | A `ReactElement` with no props produces `["$","<tag>",null,{}]` in row 0 |
|
|
73
121
|
| `as_json_object.txt` | An object responding to `as_json` is serialized via that method; if it resolves to a `ClientReference`, import + root rows are emitted |
|
|
74
|
-
| `serializable_object.txt` | An object including `Ruact::Serializable` and declaring `
|
|
122
|
+
| `serializable_object.txt` | An object including `Ruact::Serializable` and declaring `ruact_props` serializes only the declared props |
|
|
75
123
|
| `redirect_row.txt` | A redirect instruction serializes to a JSON object with `redirectUrl` and `redirectType` keys in row 0 |
|
|
76
124
|
|
|
77
125
|
---
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:"$n9007199254740992"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:"$Infinity"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:"$NaN"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:"$-Infinity"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0:"$undefined"
|
|
@@ -122,5 +122,113 @@ module Ruact
|
|
|
122
122
|
expect(manifest).not_to be_frozen
|
|
123
123
|
end
|
|
124
124
|
end
|
|
125
|
+
|
|
126
|
+
describe "#reference_for closest-match suggestion (Story 7.4)" do
|
|
127
|
+
let(:shared_only_manifest) { described_class.from_hash(manifest_data) }
|
|
128
|
+
let(:dual_manifest) { described_class.from_hash(dual_manifest_data) }
|
|
129
|
+
|
|
130
|
+
it "suggests the shared key for a one-character typo" do
|
|
131
|
+
expect { shared_only_manifest.reference_for("LikeButtonn") }
|
|
132
|
+
.to raise_error(Ruact::ManifestError, /Did you mean "LikeButton"\?/)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "suggests the shared key for a two-character typo (boundary)" do
|
|
136
|
+
# "LiekButtoon" is the AC7 spec 13 boundary case (transposition + one
|
|
137
|
+
# insertion); under Damerau-Levenshtein its distance from "LikeButton"
|
|
138
|
+
# is 2, so the suggestion fires.
|
|
139
|
+
expect { shared_only_manifest.reference_for("LiekButtoon") }
|
|
140
|
+
.to raise_error(Ruact::ManifestError, /Did you mean "LikeButton"\?/)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "falls back to the file-path hint when the typo is over distance 2" do
|
|
144
|
+
expect { shared_only_manifest.reference_for("LkkButttn") }
|
|
145
|
+
.to raise_error(Ruact::ManifestError) do |error|
|
|
146
|
+
expect(error.message)
|
|
147
|
+
.to match(%r{Did you mean to add app/javascript/components/LkkButttn\.jsx and rebuild Vite\?})
|
|
148
|
+
expect(error.message).not_to match(/Did you mean "LikeButton"\?/)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it "falls back to the file-path hint for a totally unrelated name" do
|
|
153
|
+
expect { shared_only_manifest.reference_for("Whatever") }
|
|
154
|
+
.to raise_error(Ruact::ManifestError,
|
|
155
|
+
%r{Did you mean to add app/javascript/components/Whatever\.jsx and rebuild Vite\?})
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "suggests a co-located key (with original key shown) when it is the closest match" do
|
|
159
|
+
co_located_only = described_class.from_hash(
|
|
160
|
+
"posts/_like_button" => {
|
|
161
|
+
"id" => "/posts/_like_button.jsx",
|
|
162
|
+
"name" => "default",
|
|
163
|
+
"chunks" => ["/posts/_like_button.jsx"]
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
expect { co_located_only.reference_for("LikeButtoon") }
|
|
167
|
+
.to raise_error(Ruact::ManifestError, %r{Did you mean "posts/_like_button"\?})
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it "biases the suggestion toward co-located keys when controller_path is given" do
|
|
171
|
+
# Both "LikeButton" (shared) and "posts/_like_button" (co-located) tie
|
|
172
|
+
# at distance 1 from "LikeButtoon". Without controller_path the first
|
|
173
|
+
# one encountered wins (hash-iteration order); with controller_path:"posts"
|
|
174
|
+
# the co-located key is preferred so the suggestion is contextual.
|
|
175
|
+
expect { dual_manifest.reference_for("LikeButtoon", controller_path: "posts") }
|
|
176
|
+
.to raise_error(Ruact::ManifestError, %r{Did you mean "posts/_like_button"\?})
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it "preserves the existing 'Did you run the Vite build?' hint in every variant" do
|
|
180
|
+
vite_hint = /Did you run the Vite build\? Run 'npm run build' or start the Vite dev server\./
|
|
181
|
+
expect { shared_only_manifest.reference_for("LikeButtonn") }
|
|
182
|
+
.to raise_error(Ruact::ManifestError, vite_hint)
|
|
183
|
+
expect { shared_only_manifest.reference_for("Whatever") }
|
|
184
|
+
.to raise_error(Ruact::ManifestError, vite_hint)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it "uses the AC3 verbatim multi-line 'ruact:' message shape" do
|
|
188
|
+
expect { shared_only_manifest.reference_for("LikeButtonn") }
|
|
189
|
+
.to raise_error(Ruact::ManifestError) do |error|
|
|
190
|
+
expect(error.message).to eq(<<~MSG.strip)
|
|
191
|
+
ruact: Component "LikeButtonn" not found in manifest.
|
|
192
|
+
Did you mean "LikeButton"?
|
|
193
|
+
Did you run the Vite build? Run 'npm run build' or start the Vite dev server.
|
|
194
|
+
MSG
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
describe "edge cases (Story 7.4)" do
|
|
200
|
+
it "does not raise FrozenError when an empty loaded manifest looks up an unknown component" do
|
|
201
|
+
empty_manifest = Tempfile.create(["empty_manifest", ".json"]) do |f|
|
|
202
|
+
f.write("{}")
|
|
203
|
+
f.flush
|
|
204
|
+
described_class.load(f.path)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
expect(empty_manifest).to be_frozen
|
|
208
|
+
expect { empty_manifest.reference_for("LikeButton") }
|
|
209
|
+
.to raise_error(Ruact::ManifestError, /not found in manifest/)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it "uses entry['name'] (not the lookup key) for ClientReference#export_name" do
|
|
213
|
+
manifest = described_class.from_hash(
|
|
214
|
+
"LikeButton" => {
|
|
215
|
+
"id" => "/LikeButton.jsx",
|
|
216
|
+
"name" => "LikeButton",
|
|
217
|
+
"chunks" => ["/LikeButton.jsx"]
|
|
218
|
+
},
|
|
219
|
+
"posts/_like_button" => {
|
|
220
|
+
"id" => "/posts/_like_button.jsx",
|
|
221
|
+
"name" => "default",
|
|
222
|
+
"chunks" => ["/posts/_like_button.jsx"]
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
shared_ref = manifest.reference_for("LikeButton")
|
|
227
|
+
co_located_ref = manifest.reference_for("LikeButton", controller_path: "posts")
|
|
228
|
+
|
|
229
|
+
expect(shared_ref.export_name).to eq("LikeButton")
|
|
230
|
+
expect(co_located_ref.export_name).to eq("default")
|
|
231
|
+
end
|
|
232
|
+
end
|
|
125
233
|
end
|
|
126
234
|
end
|