ruact 0.0.5 → 0.0.6

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/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +67 -18
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  93. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  94. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  95. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  96. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  97. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  98. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  99. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  102. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  104. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  107. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  118. metadata +55 -15
  119. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  120. data/lib/ruact/server_action.rb +0 -131
  121. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  122. data/lib/ruact/server_functions/registry.rb +0 -148
  123. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  124. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  125. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  126. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  127. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  128. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  129. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  130. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  131. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -85,5 +85,132 @@ module Ruact
85
85
  expect(result).to match(/__ruact_component__\("LikeButton"/)
86
86
  end
87
87
  end
88
+
89
+ # Bugfix (Sprint Change Proposal 2026-06-16 §4.5): `<Suspense delay="2.5">`
90
+ # used to be ignored — the preprocessor never carried the attribute, so the
91
+ # SuspenseElement always took its default delay. It now forwards an optional
92
+ # `delay` as `data-ruact-delay` on the emitted <ruact-suspense> element.
93
+ describe "Suspense delay attribute" do
94
+ it "forwards delay to the ruact-suspense element as data-ruact-delay" do
95
+ result = transform.call(%(<Suspense fallback="loading" delay="2.5"><Spinner /></Suspense>))
96
+ expect(result).to include(%(data-ruact-delay="2.5"))
97
+ expect(result).to include(%(data-ruact-fallback="loading"))
98
+ expect(result).to include("</ruact-suspense>")
99
+ end
100
+
101
+ it "omits data-ruact-delay when no delay attribute is present" do
102
+ result = transform.call(%(<Suspense fallback="loading"><Spinner /></Suspense>))
103
+ expect(result).to include(%(data-ruact-fallback="loading"))
104
+ expect(result).not_to include("data-ruact-delay")
105
+ end
106
+
107
+ it "extracts delay regardless of attribute order" do
108
+ result = transform.call(%(<Suspense delay="0.75" fallback="wait"><X /></Suspense>))
109
+ expect(result).to include(%(data-ruact-delay="0.75"))
110
+ expect(result).to include(%(data-ruact-fallback="wait"))
111
+ end
112
+
113
+ it "accepts a single-quoted delay attribute" do
114
+ result = transform.call(%(<Suspense fallback='loading' delay='1.5'><X /></Suspense>))
115
+ expect(result).to include(%(data-ruact-delay="1.5"))
116
+ end
117
+ end
118
+
119
+ # Story 13.5 (FR100) — preprocess-time component-contract validation, wired
120
+ # through an injectable registry seam (a stub responding to +contract_for+).
121
+ describe "component contract validation", :story_13_5 do
122
+ # A minimal stub registry: maps component name → contract Hash (or nil).
123
+ def registry_for(contracts)
124
+ Class.new do
125
+ def initialize(contracts) = (@contracts = contracts)
126
+ def contract_for(name, **) = @contracts[name]
127
+ end.new(contracts)
128
+ end
129
+
130
+ let(:contract) do
131
+ { "props" => { "postId" => "required", "initialCount" => "optional" } }
132
+ end
133
+ let(:registry) { registry_for("LikeButton" => contract) }
134
+
135
+ def run(source, identifier: "app/views/posts/show.html.erb")
136
+ described_class.transform(source, identifier: identifier, registry: registry)
137
+ end
138
+
139
+ it "raises on a missing required prop, naming component + file:line + fix (AC#1, AC#3)" do
140
+ expect { run("<LikeButton initialCount={5} />") }
141
+ .to raise_error(ComponentContractError) do |e|
142
+ expect(e.message).to include("LikeButton")
143
+ expect(e.message).to include("app/views/posts/show.html.erb:1")
144
+ expect(e.message).to include("postId")
145
+ expect(e.message).to include("add the required prop")
146
+ end
147
+ end
148
+
149
+ it "raises on an unknown prop with a did-you-mean suggestion (AC#3)" do
150
+ expect { run("<LikeButton postId={1} postID={2} />") }
151
+ .to raise_error(ComponentContractError, /did you mean "postId"\?/)
152
+ end
153
+
154
+ it "computes the correct line for a call site lower in the template" do
155
+ source = "line1\nline2\n<LikeButton initialCount={5} />"
156
+ expect { run(source) }.to raise_error(ComponentContractError, /show\.html\.erb:3/)
157
+ end
158
+
159
+ it "passes a valid call and emits the normal placeholder (AC#1)" do
160
+ result = run("<LikeButton postId={@post.id} initialCount={5} />")
161
+ expect(result)
162
+ .to eq(%(<%= __ruact_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>))
163
+ end
164
+
165
+ it "does NOT re-wrap the contract error with the generic line/snippet tail" do
166
+ expect { run("<LikeButton initialCount={5} />") }
167
+ .to raise_error(ComponentContractError) { |e| expect(e.message).not_to match(/at line \d+:/) }
168
+ end
169
+
170
+ # AC#2 — opt-in / byte-identity: a component with no contract entry is
171
+ # validated NOT AT ALL and emits the exact same placeholder as pre-13.5.
172
+ describe "opt-in fail-open (AC#2)" do
173
+ it "emits byte-identical output for a contract-less component" do
174
+ source = "<NavBar foo={1} bar={2} />"
175
+ with_contract = run(source) # registry has NO "NavBar" entry → fail open
176
+ without_registry = described_class.transform(source, registry: nil)
177
+ expected = %(<%= __ruact_component__("NavBar", { "foo" => 1, "bar" => 2 }) %>)
178
+ expect(with_contract).to eq(expected)
179
+ expect(without_registry).to eq(expected)
180
+ end
181
+
182
+ it "never consults the registry for a no-tag source (fast path)" do
183
+ spy_registry = registry_for({})
184
+ allow(spy_registry).to receive(:contract_for).and_call_original
185
+ result = described_class.transform("<div><p>plain</p></div>", registry: spy_registry)
186
+ expect(result).to eq("<div><p>plain</p></div>")
187
+ expect(spy_registry).not_to have_received(:contract_for)
188
+ end
189
+
190
+ # Codex review (Patch 2) — the DEFAULT registry (`Ruact.manifest`) must
191
+ # not even be read when the source has no component tags.
192
+ it "never reads Ruact.manifest for a no-tag source (default registry)" do
193
+ allow(Ruact).to receive(:manifest)
194
+ described_class.transform("<div><p>plain</p></div>")
195
+ expect(Ruact).not_to have_received(:manifest)
196
+ end
197
+ end
198
+
199
+ describe "slots (AC#5)" do
200
+ let(:contract) do
201
+ { "props" => { "title" => "required" }, "slots" => { "header" => "required" } }
202
+ end
203
+ let(:registry) { registry_for("Card" => contract) }
204
+
205
+ it "raises when a required slot attribute is omitted at the call site" do
206
+ expect { run("<Card title={@t} />") }
207
+ .to raise_error(ComponentContractError, /missing required slot.*header/m)
208
+ end
209
+
210
+ it "passes when the declared slot is supplied as an attribute" do
211
+ expect { run("<Card title={@t} header={@h} />") }.not_to raise_error
212
+ end
213
+ end
214
+ end
88
215
  end
89
216
  end
@@ -60,25 +60,6 @@ module Ruact
60
60
  end
61
61
  end
62
62
 
63
- describe "Ruact::CurrentUserNotConfiguredError", :story_8_3 do
64
- it "is a subclass of Ruact::Error" do
65
- expect(CurrentUserNotConfiguredError.ancestors).to include(Error)
66
- end
67
-
68
- it "carries a default message that names BOTH Devise and hand-rolled-session worked examples" do
69
- message = CurrentUserNotConfiguredError.new.message
70
- expect(message).to include("Ruact.current_user requires Ruact.config.current_user_resolver to be set")
71
- expect(message).to include("Devise")
72
- expect(message).to include("env['warden']")
73
- expect(message).to include("hand-rolled session")
74
- expect(message).to include("rack.session")
75
- end
76
-
77
- it "accepts a custom message" do
78
- expect(CurrentUserNotConfiguredError.new("explicit").message).to eq("explicit")
79
- end
80
- end
81
-
82
63
  describe "Ruact::UploadTooLargeError", :story_8_5 do
83
64
  it "is a subclass of Ruact::Error (so Story 8.4 rescue_from StandardError catches it)" do
84
65
  expect(UploadTooLargeError.ancestors).to include(Error)
@@ -108,31 +89,5 @@ module Ruact
108
89
  .to raise_error(StandardError)
109
90
  end
110
91
  end
111
-
112
- describe "Ruact::ActionError", :story_8_3 do
113
- it "is a subclass of Ruact::Error" do
114
- expect(ActionError.ancestors).to include(Error)
115
- end
116
-
117
- it "carries status + body so the dispatcher can render without `render` access" do
118
- error = ActionError.new(status: 422, body: { error: "invalid" })
119
- expect(error.status).to eq(422)
120
- expect(error.body).to eq(error: "invalid")
121
- end
122
-
123
- it "accepts a Symbol status" do
124
- error = ActionError.new(status: :unprocessable_entity, body: nil)
125
- expect(error.status).to eq(:unprocessable_entity)
126
- expect(error.body).to be_nil
127
- end
128
-
129
- it "synthesises a legible message when none is given" do
130
- expect(ActionError.new(status: 401, body: nil).message).to include("status=401")
131
- end
132
-
133
- it "honours an explicit custom message" do
134
- expect(ActionError.new(status: 500, body: {}, message: "explicit").message).to eq("explicit")
135
- end
136
- end
137
92
  end
138
93
  end
@@ -12,6 +12,56 @@ module Ruact
12
12
  end
13
13
  end
14
14
 
15
+ # Bugfix (Sprint Change Proposal 2026-06-16 §4.5): the ERB `delay` attribute
16
+ # is carried as `data-ruact-delay` and must reach SuspenseElement#delay; an
17
+ # absent, blank, or unparseable value falls back to the element's default.
18
+ describe "Suspense element conversion (delay)" do
19
+ let(:default_delay) do
20
+ Ruact::Flight::SuspenseElement.new(fallback: nil, children: "x").delay
21
+ end
22
+
23
+ it "parses data-ruact-delay into SuspenseElement#delay as a Float" do
24
+ html = %(<ruact-suspense data-ruact-fallback="loading" data-ruact-delay="2.5"><p>content</p></ruact-suspense>)
25
+ result = convert.call(html)
26
+ expect(result).to be_a(Ruact::Flight::SuspenseElement)
27
+ expect(result.delay).to eq(2.5)
28
+ end
29
+
30
+ it "falls back to the default delay when the attribute is absent" do
31
+ html = %(<ruact-suspense data-ruact-fallback="loading"><p>content</p></ruact-suspense>)
32
+ result = convert.call(html)
33
+ expect(result).to be_a(Ruact::Flight::SuspenseElement)
34
+ expect(result.delay).to eq(default_delay)
35
+ end
36
+
37
+ it "falls back to the default delay when the attribute is unparseable" do
38
+ html = %(<ruact-suspense data-ruact-fallback="loading" data-ruact-delay="soon"><p>content</p></ruact-suspense>)
39
+ expect(convert.call(html).delay).to eq(default_delay)
40
+ end
41
+
42
+ it "falls back to the default delay when the attribute is blank" do
43
+ html = %(<ruact-suspense data-ruact-fallback="loading" data-ruact-delay=""><p>content</p></ruact-suspense>)
44
+ expect(convert.call(html).delay).to eq(default_delay)
45
+ end
46
+
47
+ it "treats delay=\"0\" as an explicit zero delay, not absent" do
48
+ html = %(<ruact-suspense data-ruact-fallback="loading" data-ruact-delay="0"><p>content</p></ruact-suspense>)
49
+ expect(convert.call(html).delay).to eq(0.0)
50
+ end
51
+
52
+ it "parses a delay value with surrounding whitespace" do
53
+ html = %(<ruact-suspense data-ruact-fallback="loading" data-ruact-delay=" 2.5 "><p>content</p></ruact-suspense>)
54
+ expect(convert.call(html).delay).to eq(2.5)
55
+ end
56
+
57
+ it "falls back to the default delay when the value overflows to a non-finite Float" do
58
+ # Float("1e309") => Infinity (Float() does not raise on overflow); a
59
+ # non-finite delay must not reach the renderer's sleep (RangeError).
60
+ html = %(<ruact-suspense data-ruact-fallback="loading" data-ruact-delay="1e309"><p>content</p></ruact-suspense>)
61
+ expect(convert.call(html).delay).to eq(default_delay)
62
+ end
63
+ end
64
+
15
65
  describe "single DOM element" do
16
66
  it "converts a div with class and text child" do
17
67
  result = convert.call('<div class="box">hi</div>')