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
@@ -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.
@@ -1,15 +1,11 @@
1
- // Story 8.1 — TypeScript declarations for the real server-functions runtime.
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 { _makeRef } from "ruact/server-functions-runtime"` resolves under
4
- // `tsc --noEmit` (AC10's import guarantee).
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 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.
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
- * 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
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
- * Shares the same intersection call-signature contract as {@link _makeRef} so
78
- * `<form action={createPost}>` and `useActionState` keep type-checking.
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: (params?: Record<string, unknown>) => Promise<unknown>,
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
- // Story 8.1 — real server-functions runtime.
1
+ // Server-functions runtime (route-driven, v2).
2
2
  //
3
- // Replaces the Story 8.0a placeholder. Each export of the generated module
4
- // `app/javascript/.ruact/server-functions.ts` calls `_makeRef("<symbol>")`
5
- // and gets back a function that, when invoked, POSTs to
6
- // `/__ruact/fn/<symbol>` with the args serialized as JSON or FormData.
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 (locked by Story 8.0 ADR Decision-log clarification of
9
- // 2026-05-13, items 2–3): POST for everything (actions AND queries),
10
- // request body carries the args, response is JSON. CSRF symmetric — the
11
- // runtime forwards the `<meta name="csrf-token">` value as `X-CSRF-Token`
12
- // if the meta tag is present in the document (the gem does not impose its
13
- // own CSRF; the host's `protect_from_forgery` is what enforces).
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 `_makeRef` export surface and the `"ruact/server-functions-runtime"`
16
- // import path are part of the locked API — do NOT change without coordinated
17
- // codegen + Vite-plugin updates.
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). This is the runtime's first React
22
- // import — the mutation path (`_makeRef` / `_makeServerFunction`) stays
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
- // `_makeRef`'s signature is locked by the codegen so we don't widen
32
- // it; instead, hosts call `configureRuactRuntime` once at app boot
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
- * Returns a callable accessor for a server function registered with the
77
- * given Ruby symbol name. The accessor, when invoked, POSTs the args to
78
- * `/__ruact/fn/${name}` and resolves with the response (JSON-decoded for
79
- * application/json responses, text for everything else).
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
- * calls `action(prevState, formData)` on every submit. `_makeRef` picks
87
- * the FormData-typed candidate from the call and discards the prevState
88
- * argument silently prev-state is a client-only concern, never
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
- * Argument shape selection rules (first match wins):
93
- * - 0 args → JSON body, `{}`
94
- * - 1 arg, FormData → multipart
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 voids the old
167
- * `POST /__ruact/fn/:id` query mechanism and restores HTTP GET semantics):
168
- * no request body, no `X-CSRF-Token`. It shares `parseResponse` /
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
- // `_makeRef`'s call-args, following the rules documented in the JSDoc
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
- // v1 (Story 8.1)POST to the synthetic endpoint. A thin wrapper over the
418
- // shared `ruactInvoke` core; the URL, verb, and error label are exactly what
419
- // 8.1 used so the v1 path stays byte-behavior-identical (Story 9.3 AC6).
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). Only applied on the v2 path
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 &&