ruact 0.0.2 → 0.0.4

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 (131) 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 +88 -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 +1779 -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 +100 -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 +344 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +212 -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 +190 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +118 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +313 -0
  44. data/lib/ruact/server_functions/query_source.rb +150 -0
  45. data/lib/ruact/server_functions/registry.rb +148 -0
  46. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  47. data/lib/ruact/server_functions/route_source.rb +201 -0
  48. data/lib/ruact/server_functions/snapshot.rb +195 -0
  49. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  50. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  51. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  52. data/lib/ruact/server_functions.rb +111 -0
  53. data/lib/ruact/version.rb +1 -1
  54. data/lib/ruact/view_helper.rb +17 -9
  55. data/lib/ruact.rb +85 -6
  56. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  57. data/lib/tasks/benchmark.rake +15 -11
  58. data/lib/tasks/ruact.rake +81 -0
  59. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  60. data/spec/fixtures/flight/README.md +55 -7
  61. data/spec/fixtures/flight/bigint.txt +1 -0
  62. data/spec/fixtures/flight/infinity.txt +1 -0
  63. data/spec/fixtures/flight/nan.txt +1 -0
  64. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  65. data/spec/fixtures/flight/undefined.txt +1 -0
  66. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  67. data/spec/ruact/client_manifest_spec.rb +108 -0
  68. data/spec/ruact/configuration_spec.rb +501 -0
  69. data/spec/ruact/controller_request_spec.rb +204 -0
  70. data/spec/ruact/controller_spec.rb +427 -39
  71. data/spec/ruact/doctor_spec.rb +118 -0
  72. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  73. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  74. data/spec/ruact/errors_spec.rb +95 -0
  75. data/spec/ruact/flight/renderer_spec.rb +14 -3
  76. data/spec/ruact/flight/serializer_spec.rb +129 -88
  77. data/spec/ruact/html_converter_spec.rb +183 -5
  78. data/spec/ruact/install_generator_spec.rb +93 -0
  79. data/spec/ruact/query_request_spec.rb +598 -0
  80. data/spec/ruact/query_spec.rb +105 -0
  81. data/spec/ruact/railtie_spec.rb +2 -3
  82. data/spec/ruact/render_context_spec.rb +58 -0
  83. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  84. data/spec/ruact/render_pipeline_spec.rb +784 -330
  85. data/spec/ruact/serializable_spec.rb +8 -8
  86. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  87. data/spec/ruact/server_function_name_spec.rb +53 -0
  88. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  89. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  90. data/spec/ruact/server_functions/codegen_spec.rb +508 -0
  91. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  92. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  93. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  94. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  95. data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
  96. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  97. data/spec/ruact/server_functions/query_source_spec.rb +142 -0
  98. data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
  99. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  100. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  101. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  102. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  103. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  104. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  105. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  106. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  107. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  108. data/spec/ruact/server_spec.rb +180 -0
  109. data/spec/ruact/server_upload_request_spec.rb +311 -0
  110. data/spec/ruact/view_helper_spec.rb +23 -17
  111. data/spec/spec_helper.rb +52 -1
  112. data/spec/support/fixtures/pixel.png +0 -0
  113. data/spec/support/flight_wire_parser.rb +135 -0
  114. data/spec/support/flight_wire_parser_spec.rb +93 -0
  115. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  116. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  117. data/spec/support/rails_stub.rb +75 -5
  118. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
  120. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  121. data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
  122. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
  123. data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
  124. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  125. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  126. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
  127. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
  128. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
  129. metadata +91 -5
  130. data/lib/ruact/component_registry.rb +0 -31
  131. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Specs for the three Flight wire matcher modes added in Story 7.5
6
+ # (`match_flight_fixture`, `match_flight_structure`, `include_flight_row`).
7
+ RSpec.describe "Flight wire matchers" do
8
+ # Captures an `ExpectationNotMetError` raised by the inner block so the
9
+ # spec can make multiple assertions on its message without resorting to a
10
+ # multi-line block chained off `raise_error`.
11
+ def capture_failure
12
+ yield
13
+ nil
14
+ rescue RSpec::Expectations::ExpectationNotMetError => e
15
+ e
16
+ end
17
+
18
+ describe "match_flight_fixture (existing snapshot mode)" do
19
+ let(:nil_wire) { "0:null\n" }
20
+
21
+ it "passes against canonical fixture content (regression check)" do
22
+ expect(nil_wire).to match_flight_fixture("nil")
23
+ end
24
+ end
25
+
26
+ describe "match_flight_structure" do
27
+ let(:simple_wire) { %(0:{"className":"box"}\n) }
28
+ let(:two_row_wire) { %(1:I["/L.jsx","L",["/L.jsx"]]\n0:["$","$L1",null,{}]\n) }
29
+
30
+ it "passes when the actual wire matches a single-row expected structure" do
31
+ expect(simple_wire).to match_flight_structure([
32
+ { id: 0, class: :model, payload: { "className" => "box" } }
33
+ ])
34
+ end
35
+
36
+ it "passes for a two-row mixed import + model sequence" do
37
+ expect(two_row_wire).to match_flight_structure([
38
+ { id: 1, class: :import, payload: ["/L.jsx", "L", ["/L.jsx"]] },
39
+ { id: 0, class: :model, payload: ["$", "$L1", nil, {}] }
40
+ ])
41
+ end
42
+
43
+ it "fails with a missing-row message when actual has fewer rows than expected" do
44
+ err = capture_failure do
45
+ expect(simple_wire).to match_flight_structure([
46
+ { id: 0, class: :model, payload: { "className" => "box" } },
47
+ { id: 1, class: :import, payload: [] }
48
+ ])
49
+ end
50
+
51
+ expect(err.message).to include("Expected row 1 (import) was not produced.")
52
+ expect(err.message).to include("expected: {")
53
+ end
54
+
55
+ it "produces the AC3 verbatim row-indexed diff for a single-field semantic regression" do
56
+ broken_wire = %(0:["$X","div",null,{"className":"box","children":"hi"}]\n)
57
+ expected_payload = ["$", "div", nil, { "className" => "box", "children" => "hi" }]
58
+ got_payload = ["$X", "div", nil, { "className" => "box", "children" => "hi" }]
59
+ expected_structure = [{ id: 0, class: :model, payload: expected_payload }]
60
+
61
+ # Hash#inspect changed between Ruby 3.3 (`{"a"=>"b"}`) and Ruby 3.4
62
+ # (`{"a" => "b"}`). The AC3 contract is "values shown via .inspect" —
63
+ # so we render the expected message with the same .inspect the matcher
64
+ # uses at runtime, keeping the spec stable across the CI Ruby matrix.
65
+ expected_message = <<~MSG.strip
66
+ Expected Flight output to match structure.
67
+
68
+ Row 0 (model) differs at .payload[0]:
69
+ expected: "$"
70
+ got: "$X"
71
+
72
+ Row 0 (model) full diff:
73
+ expected: #{expected_payload.inspect}
74
+ got: #{got_payload.inspect}
75
+ MSG
76
+
77
+ expect do
78
+ expect(broken_wire).to match_flight_structure(expected_structure)
79
+ end.to raise_error(RSpec::Expectations::ExpectationNotMetError, expected_message)
80
+ end
81
+
82
+ it "fails with an unexpected-row message when actual has extra rows" do
83
+ err = capture_failure do
84
+ expect(two_row_wire).to match_flight_structure([
85
+ { id: 1, class: :import,
86
+ payload: ["/L.jsx", "L", ["/L.jsx"]] }
87
+ ])
88
+ end
89
+
90
+ expect(err.message).to include("Got unexpected row 1 (model)")
91
+ end
92
+
93
+ it "tolerates cosmetic JSON key reordering in payload hashes (AC4)" do
94
+ canonical = %(0:{"a":1,"b":2}\n)
95
+ perturbed = %(0:{"b":2,"a":1}\n)
96
+ expected_structure = [{ id: 0, class: :model, payload: { "a" => 1, "b" => 2 } }]
97
+
98
+ expect(canonical).to match_flight_structure(expected_structure)
99
+ expect(perturbed).to match_flight_structure(expected_structure)
100
+ end
101
+
102
+ it "passes negation when the structure does not match" do
103
+ expect(simple_wire).not_to match_flight_structure([
104
+ { id: 0, class: :model, payload: { "className" => "circle" } }
105
+ ])
106
+ end
107
+
108
+ # AC1: "multiple I rows are an unordered set". The expected list below
109
+ # reverses the import order vs the wire — the structural matcher must
110
+ # still consider this a match because import-row ordering is not
111
+ # protocol-significant within the import class.
112
+ it "treats import rows as an unordered set (AC1)" do
113
+ wire = %(1:I["/A.jsx","A",["/A.jsx"]]\n2:I["/B.jsx","B",["/B.jsx"]]\n0:["$","$L1",null,{}]\n)
114
+
115
+ expect(wire).to match_flight_structure([
116
+ { id: 2, class: :import, payload: ["/B.jsx", "B", ["/B.jsx"]] },
117
+ { id: 1, class: :import, payload: ["/A.jsx", "A", ["/A.jsx"]] },
118
+ { id: 0, class: :model, payload: ["$", "$L1", nil, {}] }
119
+ ])
120
+ end
121
+
122
+ # Defends against incomplete expected rows silently passing when actual
123
+ # payload happens to be nil (e.g. `{ id: 0, class: :model }` without
124
+ # `:payload` would otherwise satisfy any row whose payload is nil).
125
+ it "raises ArgumentError when an expected row is missing :payload" do
126
+ expect do
127
+ expect(simple_wire).to match_flight_structure([{ id: 0, class: :model }])
128
+ end.to raise_error(ArgumentError, /missing required keys.*:payload/)
129
+ end
130
+
131
+ it "raises ArgumentError when an expected row is missing :class" do
132
+ expect do
133
+ expect(simple_wire).to match_flight_structure([{ id: 0, payload: {} }])
134
+ end.to raise_error(ArgumentError, /missing required keys.*:class/)
135
+ end
136
+
137
+ # Multi-row failure message: the count, AC3 wording for missing/extra,
138
+ # plus a "✓" line for every matching row so the reader can confirm
139
+ # which rows passed (AC3 — "Other rows that match are summarized as
140
+ # `Row N (<class>): ✓`").
141
+ it "shows matching-row checkmarks alongside multi-row diffs" do
142
+ wire = %(1:I["/A.jsx","A",["/A.jsx"]]\n0:["$X","div",null,{}]\n)
143
+
144
+ err = capture_failure do
145
+ expect(wire).to match_flight_structure([
146
+ { id: 1, class: :import, payload: ["/A.jsx", "A", ["/A.jsx"]] },
147
+ { id: 0, class: :model, payload: ["$", "div", nil, {}] },
148
+ { id: 2, class: :model, payload: ["$", "span", nil, {}] }
149
+ ])
150
+ end
151
+
152
+ expect(err.message).to include("Expected Flight output to match structure. 2 rows differ:")
153
+ expect(err.message).to include("Row 0 (import): ✓")
154
+ expect(err.message).to include("Row 1 (model) differs at .payload[0]:")
155
+ expect(err.message).to include("Expected row 2 (model) was not produced.")
156
+ end
157
+ end
158
+
159
+ describe "include_flight_row" do
160
+ let(:wire_with_post_id) do
161
+ %(1:I["/L.jsx","L",["/L.jsx"]]\n0:["$","$L1",null,{"postId":42}]\n)
162
+ end
163
+
164
+ it "matches when at least one row satisfies a hash_including payload predicate" do
165
+ expect(wire_with_post_id).to include_flight_row(
166
+ class: :model,
167
+ payload: include("postId" => 42)
168
+ )
169
+ end
170
+
171
+ it "fails listing parsed rows when no row matches the predicate" do
172
+ err = capture_failure do
173
+ expect(wire_with_post_id).to include_flight_row(
174
+ class: :model,
175
+ payload: include("postId" => 999)
176
+ )
177
+ end
178
+
179
+ expect(err.message).to include("Expected Flight output to include a row matching")
180
+ expect(err.message).to include("[0] id=1, class=import")
181
+ expect(err.message).to include("[1] id=0, class=model")
182
+ end
183
+
184
+ it "supports negation with not_to" do
185
+ expect(wire_with_post_id).not_to include_flight_row(class: :error)
186
+ end
187
+
188
+ it "fails negation when a row matches, naming the offending row index" do
189
+ expect do
190
+ expect(wire_with_post_id).not_to include_flight_row(class: :import)
191
+ end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /but row 0 matched/)
192
+ end
193
+
194
+ it "supports array_including for the payload key" do
195
+ expect(wire_with_post_id).to include_flight_row(
196
+ class: :import,
197
+ payload: include("/L.jsx")
198
+ )
199
+ end
200
+
201
+ # AC4 fixture-mode failure proof. The structural matcher tolerates the
202
+ # cosmetic perturbation; the fixture matcher fails *positively* with the
203
+ # expected-vs-got diff visible — confirming fixture mode is the wire-
204
+ # format contract guard. This spec verifies the failure message rather
205
+ # than relying on a `not_to` shortcut (which would prove only that the
206
+ # matcher returned false, not that the failure is loud and informative).
207
+ it "demonstrates cosmetic-vs-fixture asymmetry — structure tolerates re-ordering, fixture fails loudly (AC4)" do
208
+ canonical_wire = %(0:{"debug":true,"count":5,"label":"x"}\n)
209
+ perturbed_wire = %(0:{"label":"x","count":5,"debug":true}\n)
210
+ expected_structure = [
211
+ { id: 0, class: :model, payload: { "debug" => true, "count" => 5, "label" => "x" } }
212
+ ]
213
+
214
+ err = capture_failure do
215
+ expect(perturbed_wire).to match_flight_fixture("hash")
216
+ end
217
+
218
+ aggregate_failures do
219
+ # Structural mode: both pass — JSON key reordering is cosmetic.
220
+ expect(canonical_wire).to match_flight_structure(expected_structure)
221
+ expect(perturbed_wire).to match_flight_structure(expected_structure)
222
+
223
+ # Fixture mode: canonical passes — the fixture file is the canonical
224
+ # wire bytes.
225
+ expect(canonical_wire).to match_flight_fixture("hash")
226
+
227
+ # Fixture mode against the perturbed wire fails *loudly* with the
228
+ # bytes-for-bytes diff so a human reviewer can see the cosmetic drift.
229
+ expect(err).to be_a(RSpec::Expectations::ExpectationNotMetError)
230
+ expect(err.message).to include("Expected output to match fixture at", "hash.txt", "Expected:", "Got:")
231
+ expect(err.message).to include(perturbed_wire.inspect)
232
+ end
233
+ end
234
+
235
+ # Predicate validation: an unknown key (typo) must raise immediately.
236
+ # Otherwise `row[:payloed]` returns nil and `nil == nil` would silently
237
+ # match every row, hiding broken specs.
238
+ it "raises ArgumentError when the predicate has an unknown key" do
239
+ expect do
240
+ expect(wire_with_post_id).to include_flight_row(payloed: { "postId" => 42 })
241
+ end.to raise_error(ArgumentError, /unknown keys.*:payloed/)
242
+ end
243
+
244
+ it "raises ArgumentError when given an empty predicate" do
245
+ expect do
246
+ expect(wire_with_post_id).to include_flight_row({})
247
+ end.to raise_error(ArgumentError, /predicate cannot be empty/)
248
+ end
249
+ end
250
+ end
@@ -1,11 +1,81 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Minimal Rails stub for specs that need Rails without it being in the bundle.
4
- # Loaded automatically by spec_helper. Does nothing if Rails is already defined.
5
- return if defined?(Rails)
3
+ # Spec-only Rails bootstrap for tests that need a `Rails` constant. Two modes:
4
+ #
5
+ # 1. **Augment** (default since Story 7.9): when the `rails` gem is in the
6
+ # bundle, load its core (`rails.rb` — just `Rails::VERSION`,
7
+ # `Rails::Railtie`, `ActiveSupport::StringInquirer`, etc., *not*
8
+ # `action_controller` / `action_view`) and add test-only writers
9
+ # (`Rails.root=`, `Rails.env=`, `Rails.logger=`) on top. Specs that need
10
+ # the full Rails request cycle (e.g. controller_request_spec.rb) require
11
+ # `action_controller/railtie` and `action_view/railtie` themselves — the
12
+ # rest of the suite never pays that cost.
13
+ #
14
+ # 2. **Full stub** (fallback): when `rails` is not in the bundle (e.g. a
15
+ # matrix run that pruned it), provide a minimal `Rails` module + a
16
+ # `Rails::Railtie` class with no-op class methods so `gem/lib/ruact/railtie.rb`
17
+ # can `class Railtie < Rails::Railtie` without crashing. `$LOADED_FEATURES`
18
+ # is patched so `require "rails"` inside loaded files no-ops.
19
+ #
20
+ # Loaded automatically by spec_helper.
21
+ #
22
+ # Augmentation is idempotent (each `Rails.define_singleton_method` is guarded
23
+ # by `unless Rails.singleton_class.method_defined?(...)`), so we run through
24
+ # this file every time it loads — even when Rails was already required by
25
+ # another spec file (e.g. controller_request_spec.rb pre-loads
26
+ # `action_controller/railtie`). Skipping with `return if defined?(Rails)`
27
+ # would leave doctor_spec / railtie_spec without the test-only writers when
28
+ # the request spec runs first.
6
29
 
7
- # Prevent `require "rails"` inside loaded files from failing.
8
- $LOADED_FEATURES << "rails.rb" unless $LOADED_FEATURES.any? { |f| f.end_with?("/rails.rb") }
30
+ unless defined?(Rails)
31
+ begin
32
+ # Loads Rails core only — not the request-cycle subsystem. Cheap.
33
+ require "rails"
34
+ rescue LoadError
35
+ # Rails not available; fall through to the full stub below.
36
+ end
37
+ end
38
+
39
+ if defined?(Rails) && Rails.respond_to?(:application)
40
+ # Augment-mode: real Rails is present. Add test-only writers that the
41
+ # existing suite (doctor_spec, railtie_spec) relies on, without clobbering
42
+ # real Rails's behaviour outside the test override.
43
+ unless Rails.singleton_class.method_defined?(:root=)
44
+ original_root = Rails.method(:root) if Rails.respond_to?(:root)
45
+
46
+ Rails.define_singleton_method(:root=) { |v| @_test_root = v }
47
+ Rails.define_singleton_method(:root) do
48
+ return @_test_root if defined?(@_test_root) && @_test_root
49
+
50
+ original_root&.call
51
+ end
52
+ end
53
+
54
+ unless Rails.singleton_class.method_defined?(:env=)
55
+ Rails.define_singleton_method(:env=) { |v| @_test_env = v }
56
+ original_env = Rails.method(:env) if Rails.respond_to?(:env)
57
+ Rails.define_singleton_method(:env) do
58
+ return @_test_env if defined?(@_test_env) && @_test_env
59
+
60
+ original_env ? original_env.call : ActiveSupport::StringInquirer.new("test")
61
+ end
62
+ end
63
+
64
+ unless Rails.singleton_class.method_defined?(:logger=)
65
+ Rails.define_singleton_method(:logger=) { |v| @_test_logger = v }
66
+ original_logger = Rails.method(:logger) if Rails.respond_to?(:logger)
67
+ Rails.define_singleton_method(:logger) do
68
+ return @_test_logger if defined?(@_test_logger) && @_test_logger
69
+
70
+ original_logger&.call
71
+ end
72
+ end
73
+
74
+ return
75
+ end
76
+
77
+ # Full-stub fallback: rails gem is not in the bundle.
78
+ $LOADED_FEATURES << "rails.rb"
9
79
 
10
80
  module Rails
11
81
  class Railtie
@@ -0,0 +1,173 @@
1
+ // Story 8.1 — TypeScript declarations for the real server-functions runtime.
2
+ // Mirrors the JS exports in `index.js` so the generated module's
3
+ // `import { _makeRef } from "ruact/server-functions-runtime"` resolves under
4
+ // `tsc --noEmit` (AC10's import guarantee).
5
+ //
6
+ // The generated module's per-export signature is
7
+ // `(args?: Record<string, unknown>) => Promise<unknown>` per the 8.0a
8
+ // codegen contract; the runtime accepts a wider `FormData` argument too
9
+ // (Story 8.2 owns the codegen signature widening if it picks the FormData
10
+ // path). Devs writing call sites against the 8.0a-emitted module continue
11
+ // to see the conservative Record<string, unknown> signature; the FormData
12
+ // branch only fires through Story 8.2's `<form action={fn}>` wiring.
13
+
14
+ /**
15
+ * Re-run-3 (2026-05-15), refined Re-run-4 (2026-05-15) — local alias
16
+ * for the FormData INSTANCE type that does NOT require `lib: ["dom"]`
17
+ * in the consumer's tsconfig.
18
+ *
19
+ * Re-run-4 fix: pre-batch this inferred `F = typeof FormData` (the
20
+ * constructor), so DOM consumers passing `new FormData()` were typed
21
+ * against the constructor signature and `tsc` would reject the call.
22
+ * The conditional below now extracts the INSTANCE type from the
23
+ * constructor (`new (...args) => I`) when DOM lib is loaded, and
24
+ * falls back to a minimal structural shape in non-DOM targets.
25
+ */
26
+ type RuactFormData = typeof globalThis extends { FormData: new (...args: never[]) => infer Instance }
27
+ ? Instance
28
+ : { append(name: string, value: unknown): void };
29
+
30
+ /**
31
+ * Re-run-4 (2026-05-15) — same conditional-typeof pattern for the
32
+ * fetch `Response` type so the declaration compiles without DOM lib.
33
+ */
34
+ type RuactResponse = typeof globalThis extends { Response: new (...args: never[]) => infer Instance }
35
+ ? Instance
36
+ : unknown;
37
+
38
+ /**
39
+ * Returns a callable accessor for a server function registered with the
40
+ * given Ruby symbol name. The accessor, when invoked, POSTs the args to
41
+ * `/__ruact/fn/${name}`.
42
+ *
43
+ * Story 8.2 (refined 2026-05-17 per review patch R1) — the return type
44
+ * is an intersection of FOUR call signatures so the same exported
45
+ * reference is usable from every call site:
46
+ *
47
+ * 1. `()` / `(args)` / `(prevState, formData)` — direct callers and
48
+ * `useActionState`'s two-arg invocation; returns `Promise<unknown>`.
49
+ * 2. `(formData: FormData)` — assignable to React 19's `<form action>`
50
+ * prop, which is typed as `(formData: FormData) => void | Promise<void>`.
51
+ * Promise generics are invariant in TS, so `Promise<unknown>` is
52
+ * NOT assignable to `Promise<void>` even via the void-discard rule;
53
+ * the intersection lets `<form action={createPost}>` typecheck
54
+ * DIRECTLY against the emitted module without a call-site cast.
55
+ *
56
+ * Runtime behavior is unchanged — `_makeRef` always resolves with the
57
+ * JSON-decoded value. The `Promise<void>` overload is a TYPE-ONLY
58
+ * surface: when React invokes the function from a `<form action>` prop,
59
+ * the return value is discarded by React anyway.
60
+ */
61
+ export function _makeRef(
62
+ name: string,
63
+ ): ((
64
+ arg1?: Record<string, unknown> | RuactFormData,
65
+ arg2?: RuactFormData | Record<string, unknown>,
66
+ ) => Promise<unknown>) &
67
+ ((formData: RuactFormData) => Promise<void>);
68
+
69
+ /**
70
+ * Story 9.3 — the route-driven (v2) accessor. The codegen emits
71
+ * `_makeServerFunction({ method, path, segments })` for every non-GET routed
72
+ * action on a `Ruact::Server` controller. The returned callable targets the
73
+ * REAL Rails route + verb (e.g. `POST /posts`, `PUT /posts/:id`), interpolating
74
+ * dynamic path segments by name from the single call argument, and follows a
75
+ * Bucket-2 `{ "$redirect": "<path>" }` response client-side.
76
+ *
77
+ * Shares the same intersection call-signature contract as {@link _makeRef} so
78
+ * `<form action={createPost}>` and `useActionState` keep type-checking.
79
+ */
80
+ export function _makeServerFunction(descriptor: {
81
+ method: string;
82
+ path: string;
83
+ segments?: string[];
84
+ }): ((
85
+ arg1?: Record<string, unknown> | RuactFormData,
86
+ arg2?: RuactFormData | Record<string, unknown>,
87
+ ) => Promise<unknown>) &
88
+ ((formData: RuactFormData) => Promise<void>);
89
+
90
+ /**
91
+ * Story 9.5 — the read-side (query) accessor. The codegen emits
92
+ * `_makeQuery({ path, kind: "query" })` for every method on a mounted
93
+ * `Ruact::Query` subclass. The returned callable issues a GET to the named
94
+ * query route (`GET /q/<jsId>`), serializing `params` into the query string
95
+ * (FR88: string / number / boolean / null only). Reads are CSRF-free: no
96
+ * body, no `X-CSRF-Token`.
97
+ *
98
+ * Usually consumed through {@link useQuery}, but callable directly in
99
+ * imperative code. The emitted module narrows the param surface per query
100
+ * (`() => Promise<unknown>` when the Ruby method declares no kwargs;
101
+ * `(params: Record<string, unknown>) => Promise<unknown>` when it does).
102
+ */
103
+ export function _makeQuery(descriptor: {
104
+ path: string;
105
+ kind?: string;
106
+ }): (params?: Record<string, unknown>) => Promise<unknown>;
107
+
108
+ /**
109
+ * Story 9.5 — React hook for reading a server query. Pass a query reference
110
+ * (the codegen-emitted `_makeQuery` accessor) and optional params; issues
111
+ * `GET /q/<jsId>` on mount (and whenever the serialized params change) and
112
+ * returns `{ data, loading, error }`. `loading` is true until the first
113
+ * resolution; `error` carries the structured {@link RuactActionError} on
114
+ * failure. A superseded in-flight response is dropped.
115
+ *
116
+ * Request de-duplication across components is Story 9.6; this hook fetches
117
+ * once per mount.
118
+ */
119
+ export function useQuery<T = unknown>(
120
+ reference: (params?: Record<string, unknown>) => Promise<unknown>,
121
+ params?: Record<string, unknown>,
122
+ ): { data: T | undefined; loading: boolean; error: unknown };
123
+
124
+ /**
125
+ * Story 8.2 — issues a Flight refetch of the supplied path (or the
126
+ * current URL when omitted) and swaps the React tree in place. Mirrors
127
+ * Next.js' `revalidatePath` ergonomic: call it after a server action
128
+ * settles when local React state is not enough to reflect the server
129
+ * mutation.
130
+ *
131
+ * Requires the ruact router to be installed (`setupRouter()` publishes
132
+ * `globalThis.__ruact_revalidate`). Throws a descriptive error when
133
+ * called without an installed router so the failure mode is loud rather
134
+ * than a silent no-op.
135
+ */
136
+ export function revalidate(path?: string): Promise<void>;
137
+
138
+ /** Numeric sentinel downstream tooling can read to confirm the real
139
+ * runtime is in place (the Story 8.0a placeholder exported
140
+ * `__PLACEHOLDER__: true`; that export is removed in Story 8.1). */
141
+ export const __RUNTIME_VERSION__: number;
142
+
143
+ /**
144
+ * Re-run-5 (2026-05-15) — app-wide runtime configuration. Hosts in
145
+ * API mode (no CSRF meta tag) call this once at boot to register a
146
+ * default-headers function that supplies the `Authorization: Bearer …`
147
+ * (or similar) header on every server-function call.
148
+ *
149
+ * `defaultHeaders` accepts:
150
+ * - a plain object → merged on every call
151
+ * - a `() => object` function → called on every call (for tokens
152
+ * that may refresh at runtime)
153
+ * - `null` → clears any previously-registered default
154
+ *
155
+ * The gem's own headers (`Accept`, `Content-Type`, `X-CSRF-Token`)
156
+ * win over `defaultHeaders` — CSRF cannot be silently overridden.
157
+ */
158
+ export function configureRuactRuntime(options: {
159
+ defaultHeaders?: Record<string, string> | (() => Record<string, string>) | null;
160
+ }): void;
161
+
162
+ /**
163
+ * Re-run-4 (2026-05-15) — structured error thrown for 4xx/5xx responses.
164
+ * Callers can branch on `status` and inspect `body` (already
165
+ * JSON-decoded if the server's Content-Type indicated JSON) instead
166
+ * of scraping the `message` string.
167
+ */
168
+ export class RuactActionError extends Error {
169
+ readonly actionName: string;
170
+ readonly status: number;
171
+ readonly body: unknown;
172
+ readonly response: RuactResponse;
173
+ }