ruact 0.0.5 → 0.0.7
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/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +74 -18
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +79 -425
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +71 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +70 -203
- data/lib/ruact/render_pipeline.rb +8 -1
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +109 -0
- data/lib/ruact.rb +20 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +70 -328
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/manifest_resolver_spec.rb +159 -0
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +15 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
- data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +372 -6
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +58 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Story 13.3 (FR98) — unit specs for the pure validation-error normalizer
|
|
4
|
+
# Ruact::ServerFunctions::ValidationErrors.normalize. The normalizer turns an
|
|
5
|
+
# ActiveModel-ish record, a raw ActiveModel::Errors, or a pre-shaped Hash into
|
|
6
|
+
# the canonical wire shape `{ [attribute: String] => Array<String> }` (full
|
|
7
|
+
# messages, `base` key, `{}` on a valid/empty/nil source, idempotent on
|
|
8
|
+
# already-canonical input). Pure — no Rails/request/Ruact.config reads.
|
|
9
|
+
|
|
10
|
+
require "spec_helper"
|
|
11
|
+
require "active_model"
|
|
12
|
+
|
|
13
|
+
require "ruact/server_functions/validation_errors"
|
|
14
|
+
|
|
15
|
+
module Ruact
|
|
16
|
+
module ServerFunctions
|
|
17
|
+
RSpec.describe ValidationErrors, :story_13_3 do
|
|
18
|
+
# A minimal ActiveModel record so the normalizer can exercise the real
|
|
19
|
+
# `errors.group_by_attribute` / `ActiveModel::Error#full_message` API
|
|
20
|
+
# (present since Rails 6.1; gem pins `rails ~> 8.0`).
|
|
21
|
+
let(:record_class) do
|
|
22
|
+
Class.new do
|
|
23
|
+
include ActiveModel::Model
|
|
24
|
+
include ActiveModel::Attributes
|
|
25
|
+
|
|
26
|
+
attribute :title, :string
|
|
27
|
+
attribute :body, :string
|
|
28
|
+
|
|
29
|
+
def self.name = "ValidationErrorsSpecPost"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe ".normalize" do
|
|
34
|
+
context "with a record responding to #errors" do
|
|
35
|
+
it "returns the canonical { attr => [full_message] } shape" do
|
|
36
|
+
record = record_class.new
|
|
37
|
+
record.errors.add(:title, :blank) # "Title can't be blank"
|
|
38
|
+
|
|
39
|
+
expect(described_class.normalize(record)).to eq("title" => ["Title can't be blank"])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "keys base-level errors under the string 'base' with the bare message" do
|
|
43
|
+
record = record_class.new
|
|
44
|
+
record.errors.add(:base, "is invalid overall")
|
|
45
|
+
|
|
46
|
+
expect(described_class.normalize(record)).to eq("base" => ["is invalid overall"])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it "groups multiple attributes and multiple messages per attribute" do
|
|
50
|
+
record = record_class.new
|
|
51
|
+
record.errors.add(:title, :blank) # "Title can't be blank"
|
|
52
|
+
record.errors.add(:title, "is too short") # "Title is too short"
|
|
53
|
+
record.errors.add(:body, "is invalid") # "Body is invalid"
|
|
54
|
+
|
|
55
|
+
expect(described_class.normalize(record)).to eq(
|
|
56
|
+
"title" => ["Title can't be blank", "Title is too short"],
|
|
57
|
+
"body" => ["Body is invalid"]
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "returns {} for a record with no errors (success symmetry)" do
|
|
62
|
+
expect(described_class.normalize(record_class.new)).to eq({})
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
context "with a raw ActiveModel::Errors" do
|
|
67
|
+
it "normalizes the Errors object directly" do
|
|
68
|
+
record = record_class.new
|
|
69
|
+
record.errors.add(:title, :blank)
|
|
70
|
+
|
|
71
|
+
expect(described_class.normalize(record.errors)).to eq("title" => ["Title can't be blank"])
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
context "with a pre-shaped Hash" do
|
|
76
|
+
it "stringifies keys and leaves canonical array values untouched (idempotent)" do
|
|
77
|
+
canonical = { "title" => ["Title can't be blank"] }
|
|
78
|
+
|
|
79
|
+
expect(described_class.normalize(canonical)).to eq(canonical)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "stringifies symbol keys" do
|
|
83
|
+
expect(described_class.normalize(title: ["x"])).to eq("title" => ["x"])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it "coerces a scalar value into a one-element array" do
|
|
87
|
+
expect(described_class.normalize("title" => "can't be blank")).to eq("title" => ["can't be blank"])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "coerces non-string message values to strings" do
|
|
91
|
+
expect(described_class.normalize("count" => [1, 2])).to eq("count" => %w[1 2])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "returns {} for an empty Hash" do
|
|
95
|
+
expect(described_class.normalize({})).to eq({})
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
context "with nil / empty sources" do
|
|
100
|
+
it "returns {} for nil" do
|
|
101
|
+
expect(described_class.normalize(nil)).to eq({})
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "is idempotent — normalizing its own output yields the same Hash" do
|
|
106
|
+
record = record_class.new
|
|
107
|
+
record.errors.add(:title, :blank)
|
|
108
|
+
record.errors.add(:base, "is invalid overall")
|
|
109
|
+
|
|
110
|
+
once = described_class.normalize(record)
|
|
111
|
+
expect(described_class.normalize(once)).to eq(once)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "spec_helper"
|
|
4
4
|
require "active_support/core_ext/string/output_safety"
|
|
5
|
+
require "active_support/string_inquirer"
|
|
5
6
|
|
|
6
7
|
module Ruact
|
|
7
8
|
RSpec.describe ViewHelper do
|
|
@@ -48,5 +49,83 @@ module Ruact
|
|
|
48
49
|
.to raise_error(Ruact::Error, /__ruact_component__ called outside a ruact_render flow/)
|
|
49
50
|
end
|
|
50
51
|
end
|
|
52
|
+
|
|
53
|
+
# Story 14.2 (FR104) — the public JS-asset helper. Emits the dev/prod
|
|
54
|
+
# bootstrap entry `<script>` tags (re-targeting `virtual:ruact/bootstrap`)
|
|
55
|
+
# plus the `__FLIGHT_DATA` inline script. The controller delegates to this
|
|
56
|
+
# one implementation (parity asserted in controller_spec).
|
|
57
|
+
describe "#ruact_js_assets", :story_14_2 do
|
|
58
|
+
let(:asset_helper) do
|
|
59
|
+
obj = Object.new
|
|
60
|
+
obj.extend(described_class)
|
|
61
|
+
obj
|
|
62
|
+
end
|
|
63
|
+
let(:payload) { "0:[\"$\",\"div\",null,{}]\n" }
|
|
64
|
+
|
|
65
|
+
context "when in dev with the Vite dev server running" do
|
|
66
|
+
before do
|
|
67
|
+
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("development"))
|
|
68
|
+
allow(asset_helper).to receive(:vite_dev_running?).and_return(true)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "emits the react-refresh preamble, @vite/client, and the bootstrap module", :aggregate_failures do
|
|
72
|
+
html = asset_helper.ruact_js_assets(payload)
|
|
73
|
+
expect(html).to include("__vite_plugin_react_preamble_installed__")
|
|
74
|
+
expect(html).to include("http://localhost:5173/@vite/client")
|
|
75
|
+
# Targets the virtual entry at the dev server's /@id/__x00__ URL — NOT
|
|
76
|
+
# a stale application.jsx path.
|
|
77
|
+
expect(html).to include("/@id/__x00__#{Ruact.bootstrap_virtual_id}")
|
|
78
|
+
expect(html).not_to include("application.jsx")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "includes the __FLIGHT_DATA inline bootstrap script", :aggregate_failures do
|
|
82
|
+
html = asset_helper.ruact_js_assets(payload)
|
|
83
|
+
expect(html).to include("__FLIGHT_DATA")
|
|
84
|
+
expect(html).to include("d.push(")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "returns an html_safe buffer" do
|
|
88
|
+
expect(asset_helper.ruact_js_assets(payload)).to be_html_safe
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
context "when in prod (Vite manifest lookup)" do
|
|
93
|
+
before do
|
|
94
|
+
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production"))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "looks up the manifest with the SAME id the generated vite.config input uses (AC3 — no drift)" do
|
|
98
|
+
allow(asset_helper).to receive(:vite_manifest_entry).and_return(nil)
|
|
99
|
+
asset_helper.ruact_js_assets(payload)
|
|
100
|
+
expect(asset_helper).to have_received(:vite_manifest_entry).with(Ruact.bootstrap_virtual_id)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "emits the hashed bootstrap URL from the manifest entry", :aggregate_failures do
|
|
104
|
+
allow(asset_helper).to receive(:vite_manifest_entry)
|
|
105
|
+
.with(Ruact.bootstrap_virtual_id).and_return({ "file" => "bootstrap-abc123.js" })
|
|
106
|
+
html = asset_helper.ruact_js_assets(payload)
|
|
107
|
+
expect(html).to include(%(src="/assets/bootstrap-abc123.js"))
|
|
108
|
+
expect(html).to include("__FLIGHT_DATA")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "falls back to /assets/application.js when the manifest entry is missing" do
|
|
112
|
+
allow(asset_helper).to receive(:vite_manifest_entry).and_return(nil)
|
|
113
|
+
expect(asset_helper.ruact_js_assets(payload)).to include("/assets/application.js")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
context "without a Flight payload (entry tags only)" do
|
|
118
|
+
before do
|
|
119
|
+
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production"))
|
|
120
|
+
allow(asset_helper).to receive(:vite_manifest_entry).and_return(nil)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "emits only the entry tag (no __FLIGHT_DATA)", :aggregate_failures do
|
|
124
|
+
html = asset_helper.ruact_js_assets
|
|
125
|
+
expect(html).not_to include("__FLIGHT_DATA")
|
|
126
|
+
expect(html).to include("<script type=\"module\"")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
51
130
|
end
|
|
52
131
|
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -50,11 +50,6 @@ RSpec.configure do |config|
|
|
|
50
50
|
config.before do
|
|
51
51
|
Ruact.instance_variable_set(:@config, nil)
|
|
52
52
|
Ruact.instance_variable_set(:@configured_at_least_once, false)
|
|
53
|
-
# Story 8.0a: both server-function registries are lazy-initialized module
|
|
54
|
-
# singletons. Wipe them between examples so register/clear specs in one file
|
|
55
|
-
# cannot bleed entries into another under random order.
|
|
56
|
-
Ruact.instance_variable_set(:@action_registry, nil)
|
|
57
|
-
Ruact.instance_variable_set(:@query_registry, nil)
|
|
58
53
|
# Story 9.3: specs that set `Rails.logger = instance_double(Logger)` (e.g.
|
|
59
54
|
# railtie_spec, configuration_spec) leave a per-example double on the global
|
|
60
55
|
# after they finish; rspec then refuses to call it from the NEXT example.
|
|
@@ -63,5 +58,20 @@ RSpec.configure do |config|
|
|
|
63
58
|
# before every example so no leaked double survives — examples that need a
|
|
64
59
|
# specific logger set their own in their own `before` (which runs after this).
|
|
65
60
|
Rails.logger = Logger.new(IO::NULL) if defined?(Rails) && Rails.respond_to?(:logger=)
|
|
61
|
+
|
|
62
|
+
# Manifest-over-HTTP fix: the gem suite runs with `Rails.env == development`
|
|
63
|
+
# by default, where `Ruact::ManifestResolver` would otherwise fetch the live
|
|
64
|
+
# manifest from the Vite dev server (localhost:5173) on every ActionView
|
|
65
|
+
# render — making the suite hit the network and go flaky on a dev machine
|
|
66
|
+
# that happens to run a foreign Vite there. Pin the pre-fix behaviour (return
|
|
67
|
+
# the boot-loaded `Ruact.manifest`) so the whole suite is hermetic. The
|
|
68
|
+
# resolver's own spec re-stubs these `.and_call_original` to exercise the real
|
|
69
|
+
# HTTP/fallback paths with `Net::HTTP` mocked. (The programmatic
|
|
70
|
+
# `RenderPipeline#render({erb:})` path passes `registry: nil` and never
|
|
71
|
+
# touches the resolver, so the benchmark measures the real render cost.)
|
|
72
|
+
if defined?(Ruact::ManifestResolver)
|
|
73
|
+
allow(Ruact::ManifestResolver).to receive(:resolve) { Ruact.manifest }
|
|
74
|
+
allow(Ruact::ManifestResolver).to receive(:resolve_soft) { Ruact.manifest }
|
|
75
|
+
end
|
|
66
76
|
end
|
|
67
77
|
end
|
|
@@ -1,15 +1,11 @@
|
|
|
1
|
-
//
|
|
1
|
+
// TypeScript declarations for the route-driven server-functions runtime.
|
|
2
2
|
// Mirrors the JS exports in `index.js` so the generated module's
|
|
3
|
-
// `import {
|
|
4
|
-
// `tsc --noEmit
|
|
3
|
+
// `import { _makeServerFunction } from "ruact/server-functions-runtime"`
|
|
4
|
+
// resolves under `tsc --noEmit`. Story 9.9 demolished the v1 `_makeRef` accessor.
|
|
5
5
|
//
|
|
6
|
-
// The
|
|
7
|
-
// `
|
|
8
|
-
//
|
|
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.
|
|
6
|
+
// The runtime accepts a wider `FormData` argument as well as the conservative
|
|
7
|
+
// `Record<string, unknown>` shape; the FormData branch fires through the
|
|
8
|
+
// `<form action={fn}>` wiring.
|
|
13
9
|
|
|
14
10
|
/**
|
|
15
11
|
* Re-run-3 (2026-05-15), refined Re-run-4 (2026-05-15) — local alias
|
|
@@ -36,46 +32,25 @@ type RuactResponse = typeof globalThis extends { Response: new (...args: never[]
|
|
|
36
32
|
: unknown;
|
|
37
33
|
|
|
38
34
|
/**
|
|
39
|
-
*
|
|
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
|
|
35
|
+
* Story 9.3 — the route-driven accessor. The codegen emits
|
|
71
36
|
* `_makeServerFunction({ method, path, segments })` for every non-GET routed
|
|
72
37
|
* action on a `Ruact::Server` controller. The returned callable targets the
|
|
73
38
|
* REAL Rails route + verb (e.g. `POST /posts`, `PUT /posts/:id`), interpolating
|
|
74
39
|
* dynamic path segments by name from the single call argument, and follows a
|
|
75
40
|
* Bucket-2 `{ "$redirect": "<path>" }` response client-side.
|
|
76
41
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
42
|
+
* Story 8.2 (refined 2026-05-17 per review patch R1) — the return type is an
|
|
43
|
+
* intersection of call signatures so the same exported reference is usable from
|
|
44
|
+
* every call site:
|
|
45
|
+
*
|
|
46
|
+
* 1. `()` / `(args)` / `(prevState, formData)` — direct callers and
|
|
47
|
+
* `useActionState`'s two-arg invocation; returns `Promise<unknown>`.
|
|
48
|
+
* 2. `(formData: FormData)` — assignable to React 19's `<form action>`
|
|
49
|
+
* prop, which is typed as `(formData: FormData) => void | Promise<void>`.
|
|
50
|
+
* Promise generics are invariant in TS, so `Promise<unknown>` is NOT
|
|
51
|
+
* assignable to `Promise<void>` even via the void-discard rule; the
|
|
52
|
+
* intersection lets `<form action={createPost}>` typecheck DIRECTLY
|
|
53
|
+
* against the emitted module without a call-site cast.
|
|
79
54
|
*/
|
|
80
55
|
export function _makeServerFunction(descriptor: {
|
|
81
56
|
method: string;
|
|
@@ -117,9 +92,29 @@ export function _makeQuery(descriptor: {
|
|
|
117
92
|
* order-independent) share ONE in-flight request. Dedup is in-flight only:
|
|
118
93
|
* once a request settles the shared entry is dropped, so a fresh mount
|
|
119
94
|
* refetches (no TTL cache, no stale-while-revalidate).
|
|
95
|
+
*
|
|
96
|
+
* Story 13.4 — the `reference` parameter accepts ANY codegen-emitted accessor
|
|
97
|
+
* shape: the pre-13.4 declaration typed it `(params?: Record<string, unknown>)
|
|
98
|
+
* => Promise<unknown>`, which a now-TYPED query accessor (e.g. `(params: { term:
|
|
99
|
+
* string | number | boolean | null }) => Promise<unknown>`) is NOT assignable to
|
|
100
|
+
* under `strict` (contravariant param mismatch). Widening to `(...args: never[])
|
|
101
|
+
* => Promise<unknown>` accepts every typed/untyped/no-kwargs accessor without a
|
|
102
|
+
* cast and without a regression. `T` (the return type) stays the explicitly-
|
|
103
|
+
* specifiable return-typing generic — `useQuery<User[]>(searchUsers, { term })`.
|
|
104
|
+
*
|
|
105
|
+
* SCOPE BOUNDARY (intentional): the second `params` argument stays
|
|
106
|
+
* `Record<string, unknown>` — `useQuery` does NOT re-check the params SHAPE
|
|
107
|
+
* against the accessor. Per-param compile-time checking (missing-required,
|
|
108
|
+
* unknown-key, wrong-typed) is delivered at the ACCESSOR's own call site
|
|
109
|
+
* (`searchUsers({ term })`), which is where FR99 narrows the type. Inferring the
|
|
110
|
+
* params type here would require a second generic `P` that is impossible to make
|
|
111
|
+
* both inferred-from-the-accessor AND compatible with the explicit
|
|
112
|
+
* `useQuery<User[]>(…)` return-typing form (TS short-circuits inference of a
|
|
113
|
+
* defaulted trailing type parameter, and rejects a partial type-argument list
|
|
114
|
+
* for a non-defaulted one). This matches the pre-13.4 `params` contract exactly.
|
|
120
115
|
*/
|
|
121
116
|
export function useQuery<T = unknown>(
|
|
122
|
-
reference: (
|
|
117
|
+
reference: (...args: never[]) => Promise<unknown>,
|
|
123
118
|
params?: Record<string, unknown>,
|
|
124
119
|
): { data: T | undefined; loading: boolean; error: unknown };
|
|
125
120
|
|
|
@@ -1,26 +1,27 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Server-functions runtime (route-driven, v2).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// `
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Each export of the generated module `app/javascript/.ruact/server-functions.ts`
|
|
4
|
+
// is built by `_makeServerFunction({ method, path, segments })` (mutations) or
|
|
5
|
+
// `_makeQuery({ path, kind: "query" })` (reads), targeting the REAL Rails route
|
|
6
|
+
// + verb. Story 9.9 demolished the v1 `_makeRef` accessor + the synthetic
|
|
7
|
+
// endpoint it POSTed to.
|
|
7
8
|
//
|
|
8
|
-
// Wire contract
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
9
|
+
// Wire contract: mutations send the args as JSON or FormData over the route's
|
|
10
|
+
// real verb; reads issue a GET with params serialized into the query string;
|
|
11
|
+
// responses are JSON. CSRF (mutations only) — the runtime forwards the
|
|
12
|
+
// `<meta name="csrf-token">` value as `X-CSRF-Token` if the meta tag is present
|
|
13
|
+
// (the gem does not impose its own CSRF; the host's `protect_from_forgery`
|
|
14
|
+
// enforces). Reads are CSRF-free (GET semantics).
|
|
14
15
|
//
|
|
15
|
-
// The
|
|
16
|
-
//
|
|
17
|
-
//
|
|
16
|
+
// The export surface (`_makeServerFunction`, `_makeQuery`, `useQuery`,
|
|
17
|
+
// `revalidate`, `configureRuactRuntime`, `RuactActionError`) and the
|
|
18
|
+
// `"ruact/server-functions-runtime"` import path are part of the locked API —
|
|
19
|
+
// do NOT change without coordinated codegen + Vite-plugin updates.
|
|
18
20
|
|
|
19
21
|
// Story 9.5 — `useQuery` is a React hook, so the runtime now depends on React.
|
|
20
22
|
// React is declared as a `peerDependency` (every host already has it; the
|
|
21
|
-
// runtime never bundles its own copy).
|
|
22
|
-
//
|
|
23
|
-
// React-free.
|
|
23
|
+
// runtime never bundles its own copy). The mutation path
|
|
24
|
+
// (`_makeServerFunction`) stays React-free.
|
|
24
25
|
import { useState, useEffect } from "react";
|
|
25
26
|
|
|
26
27
|
const RUNTIME_VERSION = 1;
|
|
@@ -28,8 +29,8 @@ const RUNTIME_VERSION = 1;
|
|
|
28
29
|
// Re-run-5 (2026-05-15) — module-level runtime configuration. Hosts
|
|
29
30
|
// in API mode (no session cookie / no CSRF meta tag) need a way to
|
|
30
31
|
// inject auth headers (`Authorization: Bearer …`) on every call.
|
|
31
|
-
//
|
|
32
|
-
//
|
|
32
|
+
// the accessor signatures are locked by the codegen so we don't widen
|
|
33
|
+
// them; instead, hosts call `configureRuactRuntime` once at app boot
|
|
33
34
|
// to register a headers-producing function. The function runs on
|
|
34
35
|
// every fetch so dynamic tokens (refreshed at runtime) are picked up.
|
|
35
36
|
const runtimeOptions = {
|
|
@@ -73,59 +74,22 @@ export class RuactActionError extends Error {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* Story 8.2 — the returned function accepts up to TWO positional
|
|
82
|
-
* arguments to support React 19's `useActionState` shape:
|
|
83
|
-
*
|
|
84
|
-
* useActionState(action, initialState)
|
|
77
|
+
* Story 9.3 — the route-driven (v2) accessor. The codegen emits
|
|
78
|
+
* `_makeServerFunction({ method, path, segments })` for every non-GET routed
|
|
79
|
+
* action on a `Ruact::Server` controller. The returned callable targets the
|
|
80
|
+
* REAL Rails route + verb (e.g. `POST /posts`, `PUT /posts/:id`).
|
|
85
81
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
82
|
+
* Story 8.2 — the returned function accepts up to TWO positional arguments to
|
|
83
|
+
* support React 19's `useActionState` shape: `useActionState(action,
|
|
84
|
+
* initialState)` calls `action(prevState, formData)` on every submit. The
|
|
85
|
+
* accessor picks the FormData-typed candidate from the call and discards the
|
|
86
|
+
* prevState argument silently — prev-state is a client-only concern, never
|
|
89
87
|
* transmitted to the server. The single-arg shape (`fn(args)` from event
|
|
90
88
|
* handlers; `<form action={fn}>` passing FormData directly) is preserved.
|
|
91
89
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* - 1 arg, plain object / null / undefined → JSON body
|
|
96
|
-
* - 2 args, FormData in either slot → multipart (FormData wins;
|
|
97
|
-
* the other arg is discarded)
|
|
98
|
-
* - 2 args, neither FormData → JSON body of the SECOND arg
|
|
99
|
-
* (the `useActionState` payload
|
|
100
|
-
* slot); first arg discarded
|
|
101
|
-
* as prev-state
|
|
102
|
-
* - 3+ args → TypeError
|
|
103
|
-
*
|
|
104
|
-
* @param {string} name
|
|
105
|
-
* @returns {(arg1?: Record<string, unknown> | FormData, arg2?: FormData | Record<string, unknown>) => Promise<unknown>}
|
|
106
|
-
*/
|
|
107
|
-
export function _makeRef(name) {
|
|
108
|
-
return function ruactServerFunctionCall(...callArgs) {
|
|
109
|
-
if (callArgs.length > 2) {
|
|
110
|
-
throw new TypeError(
|
|
111
|
-
`ruact action :${name} called with ${callArgs.length} arguments — ` +
|
|
112
|
-
"expected 0, 1, or 2 (the useActionState shape)",
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
return ruactPost(name, pickWirePayload(callArgs));
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Story 9.3 — the route-driven (v2) accessor. The codegen emits
|
|
121
|
-
* `_makeServerFunction({ method, path, segments })` for every non-GET routed
|
|
122
|
-
* action on a `Ruact::Server` controller (instead of v1's `_makeRef("<sym>")`).
|
|
123
|
-
* The returned callable targets the REAL Rails route + verb (e.g. `POST /posts`,
|
|
124
|
-
* `PUT /posts/:id`) rather than the v1 synthetic `POST /__ruact/fn/:name`.
|
|
125
|
-
*
|
|
126
|
-
* It shares the exact fetch core (`ruactInvoke`) with `_makeRef` — FormData
|
|
127
|
-
* branching, CSRF meta injection, text-first parsing, `RuactActionError`,
|
|
128
|
-
* `redirect: "error"` — so all the salvaged 8.1/8.2 behaviors are preserved.
|
|
90
|
+
* It uses the shared fetch core (`ruactInvoke`) — FormData branching, CSRF
|
|
91
|
+
* meta injection, text-first parsing, `RuactActionError`, `redirect: "error"`
|
|
92
|
+
* — so all the salvaged 8.1/8.2 behaviors are preserved.
|
|
129
93
|
* Two additions over v1:
|
|
130
94
|
* - **Path-param interpolation (D7):** dynamic `:id`-style segments are read
|
|
131
95
|
* BY NAME from the single call argument (FormData.get / object property) and
|
|
@@ -163,9 +127,9 @@ export function _makeServerFunction(descriptor) {
|
|
|
163
127
|
* named query route (`GET /q/<jsId>`), serializing `params` into the query
|
|
164
128
|
* string.
|
|
165
129
|
*
|
|
166
|
-
* Reads are CSRF-free (NFR27 / the 2026-06-02 ADR addendum
|
|
167
|
-
*
|
|
168
|
-
*
|
|
130
|
+
* Reads are CSRF-free (NFR27 / the 2026-06-02 ADR addendum restored HTTP GET
|
|
131
|
+
* semantics for queries): no request body, no `X-CSRF-Token`. It shares
|
|
132
|
+
* `parseResponse` /
|
|
169
133
|
* `RuactActionError` / `redirect: "error"` with the mutation path so the
|
|
170
134
|
* success + failure shapes are identical.
|
|
171
135
|
*
|
|
@@ -333,8 +297,8 @@ export function useQuery(reference, params) {
|
|
|
333
297
|
}
|
|
334
298
|
|
|
335
299
|
// Story 8.2 — picks the argument the wire request should serialize from
|
|
336
|
-
// `
|
|
337
|
-
// above. Exported through `__internals` for the vitest suite (AC10) — it
|
|
300
|
+
// `_makeServerFunction`'s call-args, following the useActionState rules
|
|
301
|
+
// documented above. Exported through `__internals` for the vitest suite (AC10) — it
|
|
338
302
|
// is intentionally NOT part of the public runtime surface.
|
|
339
303
|
function pickWirePayload(callArgs) {
|
|
340
304
|
const isFD = (v) => typeof FormData !== "undefined" && v instanceof FormData;
|
|
@@ -414,29 +378,10 @@ export const __internals = {
|
|
|
414
378
|
},
|
|
415
379
|
};
|
|
416
380
|
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
// Re-run-3 (2026-05-15) — `encodeURIComponent(name)` so a stray `/`, `?`, or
|
|
422
|
-
// `#` in a name (only reachable through direct/buggy `_makeRef` calls — the
|
|
423
|
-
// gem-side route constraint and the codegen validator both refuse
|
|
424
|
-
// non-identifier characters) cannot rewrite the path or hijack the
|
|
425
|
-
// query/fragment of the request URL.
|
|
426
|
-
function ruactPost(name, args) {
|
|
427
|
-
return ruactInvoke({
|
|
428
|
-
method: "POST",
|
|
429
|
-
url: `/__ruact/fn/${encodeURIComponent(name)}`,
|
|
430
|
-
args,
|
|
431
|
-
label: name,
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Story 9.3 — the shared fetch core for BOTH v1 (`_makeRef`) and v2
|
|
436
|
-
// (`_makeServerFunction`). Extracted verbatim from the original `ruactPost` so
|
|
437
|
-
// neither path drifts: same FormData branching, CSRF injection, `redirect:
|
|
438
|
-
// "error"`, text-first parsing, and structured `RuactActionError`. The only
|
|
439
|
-
// parameters are the verb + URL + the wire payload + a human label for errors.
|
|
381
|
+
// Story 9.3 — the shared fetch core for the route-driven (`_makeServerFunction`)
|
|
382
|
+
// mutation path: FormData branching, CSRF injection, `redirect: "error"`,
|
|
383
|
+
// text-first parsing, and structured `RuactActionError`. The only parameters
|
|
384
|
+
// are the verb + URL + the wire payload + a human label for errors.
|
|
440
385
|
async function ruactInvoke({ method, url, args, label }) {
|
|
441
386
|
const init = buildFetchInit(args, method);
|
|
442
387
|
let response;
|
|
@@ -572,8 +517,7 @@ function readSegment(args, seg) {
|
|
|
572
517
|
// Story 9.3 (AC8 / D2) — the client half of the `$redirect` contract 9.2
|
|
573
518
|
// deferred. A Bucket-2 mutation that `redirect_to`s returns
|
|
574
519
|
// `{ "$redirect": "<path>" }`; follow it via the router handoff and resolve
|
|
575
|
-
// `null` (consistent with the 204→null contract).
|
|
576
|
-
// — the v1 endpoint never emits `$redirect`, so `_makeRef` is unaffected (AC6).
|
|
520
|
+
// `null` (consistent with the 204→null contract).
|
|
577
521
|
function followRedirectIfPresent(parsed) {
|
|
578
522
|
if (
|
|
579
523
|
parsed &&
|