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.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +86 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1680 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +89 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +87 -6
  127. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
  128. data/lib/ruact/component_registry.rb +0 -31
  129. data/lib/tasks/rsc.rake +0 -9
@@ -38,19 +38,62 @@ Expected output (`client_reference.txt`):
38
38
 
39
39
  ---
40
40
 
41
- ## How `match_flight_fixture` Works
41
+ ## How to assert on Flight output
42
42
 
43
- The custom RSpec matcher is defined in `spec/support/matchers/flight_fixture_matcher.rb`:
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 match_flight_fixture("nil")
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
- This reads `spec/fixtures/flight/nil.txt` and performs an **exact string comparison** against `output`. There is no normalisation whitespace, newlines, and ordering must match exactly.
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
- ### Failure output
94
+ ### When a fixture spec fails after a serializer change
52
95
 
53
- When a fixture does not match, the failure message shows both the expected (fixture file content) and actual (serializer output) as inspected strings, making it easy to spot differences in whitespace or character escaping.
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 `rsc_props` serializes only the declared props |
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"
@@ -0,0 +1,3 @@
1
+ <div>
2
+ <DemoButton label={"hello"} />
3
+ </div>
@@ -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