ruact 0.0.6 → 0.0.8
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/CHANGELOG.md +32 -1
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +114 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/ruact/controller.rb +18 -3
- data/lib/ruact/doctor.rb +67 -9
- data/lib/ruact/erb_preprocessor.rb +120 -1
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +14 -3
- data/lib/ruact/render_pipeline.rb +8 -1
- data/lib/ruact/serializable.rb +98 -6
- data/lib/ruact/server.rb +163 -0
- data/lib/ruact/server_functions/introspection.rb +81 -0
- data/lib/ruact/server_functions.rb +26 -4
- data/lib/ruact/testing/component_query.rb +113 -0
- data/lib/ruact/testing/flight_extractor.rb +221 -0
- data/lib/ruact/testing/flight_structure_diff.rb +267 -0
- data/lib/ruact/testing/flight_wire_parser.rb +138 -0
- data/lib/ruact/testing.rb +90 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +11 -4
- data/lib/ruact.rb +1 -0
- data/lib/tasks/ruact.rake +55 -2
- data/spec/ruact/controller_spec.rb +7 -2
- data/spec/ruact/doctor_spec.rb +141 -0
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +285 -0
- data/spec/ruact/manifest_resolver_spec.rb +174 -0
- data/spec/ruact/serializable_spec.rb +126 -0
- data/spec/ruact/server_bucket_request_spec.rb +291 -0
- data/spec/ruact/server_functions/introspection_spec.rb +135 -0
- data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
- data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
- data/spec/ruact/testing/no_production_load_spec.rb +41 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/flight_wire_parser.rb +12 -126
- data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +20 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
- metadata +17 -2
|
@@ -1,267 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../flight_wire_parser"
|
|
4
|
+
require "ruact/testing/flight_structure_diff"
|
|
4
5
|
|
|
5
6
|
module Ruact
|
|
6
7
|
module Spec
|
|
7
|
-
#
|
|
8
|
-
# `Ruact::
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
# may only reference these keys; unknown keys raise upfront so typos
|
|
14
|
-
# like `payloed:` don't silently match every row via `nil == nil`.
|
|
15
|
-
KNOWN_ROW_KEYS = %i[id class payload raw].freeze
|
|
16
|
-
|
|
17
|
-
# Keys that must be present in every expected-row hash passed to
|
|
18
|
-
# `match_flight_structure`. Excludes `:raw` because expected rows are
|
|
19
|
-
# authored as semantic descriptions, not byte-level snapshots.
|
|
20
|
-
REQUIRED_EXPECTED_KEYS = %i[id class payload].freeze
|
|
21
|
-
|
|
22
|
-
# Compute the set of differences between actual parsed rows and the
|
|
23
|
-
# expected structure. Import rows are matched as a multiset (their
|
|
24
|
-
# relative order among each other is not significant per AC1);
|
|
25
|
-
# everything else compares positionally.
|
|
26
|
-
def self.compute(actual_rows, expected_rows)
|
|
27
|
-
validate_expected_rows!(expected_rows)
|
|
28
|
-
return diffs_for_length_mismatch(actual_rows, expected_rows) if actual_rows.length != expected_rows.length
|
|
29
|
-
|
|
30
|
-
diffs = []
|
|
31
|
-
pending_actual_imports = []
|
|
32
|
-
pending_expected_imports = []
|
|
33
|
-
|
|
34
|
-
actual_rows.zip(expected_rows).each_with_index do |(actual, expected), i|
|
|
35
|
-
if actual[:class] == :import && expected[:class] == :import
|
|
36
|
-
pending_actual_imports << [i, actual]
|
|
37
|
-
pending_expected_imports << [i, expected]
|
|
38
|
-
next
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
next if rows_equal?(actual, expected)
|
|
42
|
-
|
|
43
|
-
diffs << build_field_diff(i, actual, expected)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
diffs.concat(diff_imports_unordered(pending_actual_imports, pending_expected_imports))
|
|
47
|
-
diffs.sort_by { |d| d[:idx] }
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Imports are an unordered multiset within their class (AC1). Sort both
|
|
51
|
-
# sides by `:id` (always unique per render) and any leftover diffs come
|
|
52
|
-
# from semantic mismatch in the same-position-after-sort pair.
|
|
53
|
-
def self.diff_imports_unordered(actual_pairs, expected_pairs)
|
|
54
|
-
return [] if actual_pairs.empty? && expected_pairs.empty?
|
|
55
|
-
|
|
56
|
-
sort_key = ->(pair) { [pair[1][:id].to_i, pair[1][:payload].to_s] }
|
|
57
|
-
sorted_actual = actual_pairs.sort_by(&sort_key)
|
|
58
|
-
sorted_expected = expected_pairs.sort_by(&sort_key)
|
|
59
|
-
|
|
60
|
-
sorted_actual.zip(sorted_expected).filter_map do |(act_idx, act_row), (_exp_idx, exp_row)|
|
|
61
|
-
next if rows_equal?(act_row, exp_row)
|
|
62
|
-
|
|
63
|
-
build_field_diff(act_idx, act_row, exp_row)
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def self.diffs_for_length_mismatch(actual_rows, expected_rows)
|
|
68
|
-
diffs = []
|
|
69
|
-
[actual_rows.length, expected_rows.length].max.times do |i|
|
|
70
|
-
actual = actual_rows[i]
|
|
71
|
-
expected = expected_rows[i]
|
|
72
|
-
if actual.nil?
|
|
73
|
-
diffs << { kind: :missing, idx: i, expected_row: expected }
|
|
74
|
-
elsif expected.nil?
|
|
75
|
-
diffs << { kind: :extra, idx: i, actual_row: actual }
|
|
76
|
-
elsif !rows_equal?(actual, expected)
|
|
77
|
-
diffs << build_field_diff(i, actual, expected)
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
diffs
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def self.rows_equal?(actual, expected)
|
|
84
|
-
REQUIRED_EXPECTED_KEYS.all? { |k| actual[k] == expected[k] }
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def self.build_field_diff(idx, actual, expected)
|
|
88
|
-
field_diff = nil
|
|
89
|
-
REQUIRED_EXPECTED_KEYS.each do |key|
|
|
90
|
-
next if actual[key] == expected[key]
|
|
91
|
-
|
|
92
|
-
path, sub_expected, sub_actual = first_difference(expected[key], actual[key], ".#{key}")
|
|
93
|
-
field_diff = { path: path, expected: sub_expected, got: sub_actual }
|
|
94
|
-
break
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
{
|
|
98
|
-
kind: :differs,
|
|
99
|
-
idx: idx,
|
|
100
|
-
row_class: actual[:class],
|
|
101
|
-
path: field_diff[:path],
|
|
102
|
-
expected: field_diff[:expected],
|
|
103
|
-
got: field_diff[:got],
|
|
104
|
-
expected_row: expected,
|
|
105
|
-
got_row: actual
|
|
106
|
-
}
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Walks parallel structures (Hash/Array/scalar) and returns the path,
|
|
110
|
-
# expected leaf, and actual leaf at the first differing position.
|
|
111
|
-
def self.first_difference(expected, actual, path)
|
|
112
|
-
return [path, expected, actual] if expected.class != actual.class
|
|
113
|
-
return [path, expected, actual] unless expected.is_a?(Array) || expected.is_a?(Hash)
|
|
114
|
-
|
|
115
|
-
return diff_in_array(expected, actual, path) if expected.is_a?(Array)
|
|
116
|
-
|
|
117
|
-
diff_in_hash(expected, actual, path)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def self.diff_in_array(expected, actual, path)
|
|
121
|
-
[expected.length, actual.length].max.times do |i|
|
|
122
|
-
return ["#{path}[#{i}]", expected[i], actual[i]] if i >= expected.length || i >= actual.length
|
|
123
|
-
next if expected[i] == actual[i]
|
|
124
|
-
|
|
125
|
-
return first_difference(expected[i], actual[i], "#{path}[#{i}]")
|
|
126
|
-
end
|
|
127
|
-
[path, expected, actual]
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def self.diff_in_hash(expected, actual, path)
|
|
131
|
-
(expected.keys | actual.keys).each do |key|
|
|
132
|
-
return ["#{path}[#{key.inspect}]", expected[key], actual[key]] if !expected.key?(key) || !actual.key?(key)
|
|
133
|
-
next if expected[key] == actual[key]
|
|
134
|
-
|
|
135
|
-
return first_difference(expected[key], actual[key], "#{path}[#{key.inspect}]")
|
|
136
|
-
end
|
|
137
|
-
[path, expected, actual]
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# Validates each expected row carries the required `:id`, `:class`,
|
|
141
|
-
# `:payload` keys. Without this, an expected row of `{ id: 0, class:
|
|
142
|
-
# :model }` would silently pass against any actual row whose payload
|
|
143
|
-
# was nil — because the `==` check reads `expected[:payload]` as nil.
|
|
144
|
-
def self.validate_expected_rows!(expected_rows)
|
|
145
|
-
expected_rows.each_with_index do |row, i|
|
|
146
|
-
unless row.is_a?(Hash)
|
|
147
|
-
raise ArgumentError,
|
|
148
|
-
"match_flight_structure: expected row #{i} must be a Hash, got #{row.class}: #{row.inspect}"
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
missing = REQUIRED_EXPECTED_KEYS.reject { |k| row.key?(k) }
|
|
152
|
-
next if missing.empty?
|
|
153
|
-
|
|
154
|
-
raise ArgumentError,
|
|
155
|
-
"match_flight_structure: expected row #{i} is missing required keys: #{missing.inspect}. " \
|
|
156
|
-
"Each expected row must include :id, :class, and :payload."
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
# Validates a predicate hash for `include_flight_row`. Unknown keys
|
|
161
|
-
# (typos like `payloed:` or `clas:`) raise immediately so they don't
|
|
162
|
-
# silently match any row via `row[:payloed]` returning nil.
|
|
163
|
-
def self.validate_predicate!(predicate)
|
|
164
|
-
unless predicate.is_a?(Hash)
|
|
165
|
-
raise ArgumentError,
|
|
166
|
-
"include_flight_row: predicate must be a Hash, got #{predicate.class}: #{predicate.inspect}"
|
|
167
|
-
end
|
|
168
|
-
raise ArgumentError, "include_flight_row: predicate cannot be empty" if predicate.empty?
|
|
169
|
-
|
|
170
|
-
unknown = predicate.keys - KNOWN_ROW_KEYS
|
|
171
|
-
return if unknown.empty?
|
|
172
|
-
|
|
173
|
-
raise ArgumentError,
|
|
174
|
-
"include_flight_row: predicate has unknown keys: #{unknown.inspect}. " \
|
|
175
|
-
"Allowed keys: #{KNOWN_ROW_KEYS.inspect}."
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def self.format_single(diff)
|
|
179
|
-
case diff[:kind]
|
|
180
|
-
when :missing
|
|
181
|
-
row = diff[:expected_row]
|
|
182
|
-
"Expected row #{diff[:idx]} (#{row[:class]}) was not produced.\n expected: #{row.inspect}"
|
|
183
|
-
when :extra
|
|
184
|
-
row = diff[:actual_row]
|
|
185
|
-
"Got unexpected row #{diff[:idx]} (#{row[:class]}): #{row.inspect}"
|
|
186
|
-
else
|
|
187
|
-
format_field_diff(diff)
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def self.format_field_diff(diff)
|
|
192
|
-
<<~MSG.strip
|
|
193
|
-
Expected Flight output to match structure.
|
|
194
|
-
|
|
195
|
-
Row #{diff[:idx]} (#{diff[:row_class]}) differs at #{diff[:path]}:
|
|
196
|
-
expected: #{diff[:expected].inspect}
|
|
197
|
-
got: #{diff[:got].inspect}
|
|
198
|
-
|
|
199
|
-
Row #{diff[:idx]} (#{diff[:row_class]}) full diff:
|
|
200
|
-
expected: #{diff[:expected_row][:payload].inspect}
|
|
201
|
-
got: #{diff[:got_row][:payload].inspect}
|
|
202
|
-
MSG
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
# Builds the multi-row failure message: header naming the diff count,
|
|
206
|
-
# AC3-specified wording for missing / extra / differing rows, and a
|
|
207
|
-
# `Row N (<class>): ✓` summary line for every matching row so the
|
|
208
|
-
# reader can see what passed.
|
|
209
|
-
def self.format_multi(diffs, actual_rows, expected_rows)
|
|
210
|
-
header = "Expected Flight output to match structure. #{diffs.length} rows differ:"
|
|
211
|
-
total = [actual_rows.length, expected_rows.length].max
|
|
212
|
-
diff_by_idx = diffs.to_h { |d| [d[:idx], d] }
|
|
213
|
-
|
|
214
|
-
body = (0...total).map do |i|
|
|
215
|
-
diff = diff_by_idx[i]
|
|
216
|
-
if diff
|
|
217
|
-
format_entry(diff)
|
|
218
|
-
else
|
|
219
|
-
row_class = (actual_rows[i] || expected_rows[i])[:class]
|
|
220
|
-
"Row #{i} (#{row_class}): ✓"
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
([header, ""] + body).join("\n")
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
def self.format_entry(diff)
|
|
228
|
-
case diff[:kind]
|
|
229
|
-
when :missing
|
|
230
|
-
row = diff[:expected_row]
|
|
231
|
-
"Expected row #{diff[:idx]} (#{row[:class]}) was not produced.\n expected: #{row.inspect}"
|
|
232
|
-
when :extra
|
|
233
|
-
row = diff[:actual_row]
|
|
234
|
-
"Got unexpected row #{diff[:idx]} (#{row[:class]}): #{row.inspect}"
|
|
235
|
-
else
|
|
236
|
-
<<~ENTRY.strip
|
|
237
|
-
Row #{diff[:idx]} (#{diff[:row_class]}) differs at #{diff[:path]}:
|
|
238
|
-
expected: #{diff[:expected].inspect}
|
|
239
|
-
got: #{diff[:got].inspect}
|
|
240
|
-
ENTRY
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# Subset / case-equality match used by `include_flight_row`. Plain
|
|
245
|
-
# values use `==`; richer matchers (`hash_including`, `array_including`,
|
|
246
|
-
# `kind_of`, regexes) use `===` which delegates to their custom logic.
|
|
247
|
-
# Predicate keys are validated upfront by `validate_predicate!`, so
|
|
248
|
-
# this method can assume every key is one of the known row keys.
|
|
249
|
-
def self.row_matches?(row, predicate)
|
|
250
|
-
predicate.all? do |key, expected_value|
|
|
251
|
-
actual_value = row[key]
|
|
252
|
-
case expected_value
|
|
253
|
-
when Symbol, Numeric, NilClass, TrueClass, FalseClass
|
|
254
|
-
expected_value == actual_value
|
|
255
|
-
else
|
|
256
|
-
# rubocop:disable Style/CaseEquality -- intentional: lets RSpec mock argument matchers
|
|
257
|
-
# (hash_including, array_including, kind_of, etc.) drive predicate semantics via #===.
|
|
258
|
-
expected_value === actual_value
|
|
259
|
-
# rubocop:enable Style/CaseEquality
|
|
260
|
-
end
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
end
|
|
264
|
-
# rubocop:enable Metrics/ClassLength
|
|
8
|
+
# Story 15.4 (FR108) — the structural diff engine was PROMOTED onto the
|
|
9
|
+
# gem's `lib/` load path as `Ruact::Testing::FlightStructureDiff` (shared,
|
|
10
|
+
# single implementation — wrap, not fork). This test-only `Ruact::Spec`
|
|
11
|
+
# alias keeps the internal matcher wrappers below and their self-tests
|
|
12
|
+
# working unchanged.
|
|
13
|
+
FlightStructureDiff = Ruact::Testing::FlightStructureDiff
|
|
265
14
|
end
|
|
266
15
|
end
|
|
267
16
|
|
|
@@ -151,11 +151,36 @@ export const __RUNTIME_VERSION__: number;
|
|
|
151
151
|
*
|
|
152
152
|
* The gem's own headers (`Accept`, `Content-Type`, `X-CSRF-Token`)
|
|
153
153
|
* win over `defaultHeaders` — CSRF cannot be silently overridden.
|
|
154
|
+
*
|
|
155
|
+
* Story 15.6 (FR110) — `autoRevalidate` (default `false`) is the app-wide
|
|
156
|
+
* auto-revalidate default: when `true`, every successful, non-redirecting
|
|
157
|
+
* mutation `await`s an in-place Flight refresh of the current path (via
|
|
158
|
+
* `revalidate()`) before resolving with the action's JSON result. A per-call
|
|
159
|
+
* `withRefresh(accessor)` wins over this global default.
|
|
154
160
|
*/
|
|
155
161
|
export function configureRuactRuntime(options: {
|
|
156
162
|
defaultHeaders?: Record<string, string> | (() => Record<string, string>) | null;
|
|
163
|
+
autoRevalidate?: boolean;
|
|
157
164
|
}): void;
|
|
158
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Story 15.6 (FR110) — per-call auto-revalidate wrapper. Wraps a generated
|
|
168
|
+
* server-function accessor and returns a new accessor that forces an in-place
|
|
169
|
+
* Flight refresh of the current path after a successful, non-redirecting call —
|
|
170
|
+
* regardless of the app-wide `autoRevalidate` default (per-call explicit wins).
|
|
171
|
+
* Runtime-only: it does not change the codegen or the wire.
|
|
172
|
+
*
|
|
173
|
+
* A `$redirect` response still wins (the refresh is skipped); a failed mutation
|
|
174
|
+
* still throws before any refresh; and if no router is installed the descriptive
|
|
175
|
+
* `revalidate()` error surfaces (a successful mutation whose refresh rejects
|
|
176
|
+
* rejects the returned promise).
|
|
177
|
+
*
|
|
178
|
+
* The wrapped accessor keeps its own call signature (the same
|
|
179
|
+
* `(arg1?, arg2?) => Promise<unknown>` / `(formData) => Promise<void>`
|
|
180
|
+
* intersection the codegen emits).
|
|
181
|
+
*/
|
|
182
|
+
export function withRefresh<F extends (...args: never[]) => Promise<unknown>>(accessor: F): F;
|
|
183
|
+
|
|
159
184
|
/**
|
|
160
185
|
* Re-run-4 (2026-05-15) — structured error thrown for 4xx/5xx responses.
|
|
161
186
|
* Callers can branch on `status` and inspect `body` (already
|
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
// enforces). Reads are CSRF-free (GET semantics).
|
|
15
15
|
//
|
|
16
16
|
// The export surface (`_makeServerFunction`, `_makeQuery`, `useQuery`,
|
|
17
|
-
// `revalidate`, `configureRuactRuntime`, `RuactActionError`) and
|
|
18
|
-
// `"ruact/server-functions-runtime"` import path are part of the locked API —
|
|
19
|
-
// do NOT change without coordinated codegen + Vite-plugin updates.
|
|
17
|
+
// `revalidate`, `withRefresh`, `configureRuactRuntime`, `RuactActionError`) and
|
|
18
|
+
// the `"ruact/server-functions-runtime"` import path are part of the locked API —
|
|
19
|
+
// do NOT change without coordinated codegen + Vite-plugin updates. (Story 15.6
|
|
20
|
+
// added `withRefresh` — a runtime-only wrapper; it is NOT re-exported through the
|
|
21
|
+
// generated `.ruact/server-functions.ts` barrel, so codegen output is unchanged.)
|
|
20
22
|
|
|
21
23
|
// Story 9.5 — `useQuery` is a React hook, so the runtime now depends on React.
|
|
22
24
|
// React is declared as a `peerDependency` (every host already has it; the
|
|
@@ -33,8 +35,16 @@ const RUNTIME_VERSION = 1;
|
|
|
33
35
|
// them; instead, hosts call `configureRuactRuntime` once at app boot
|
|
34
36
|
// to register a headers-producing function. The function runs on
|
|
35
37
|
// every fetch so dynamic tokens (refreshed at runtime) are picked up.
|
|
38
|
+
// Story 15.6 (FR110) — `autoRevalidate` is the app-wide default for the
|
|
39
|
+
// auto-revalidate opt-in: when `true`, every successful, NON-redirecting mutation
|
|
40
|
+
// (`_makeServerFunction` accessor) `await`s an in-place Flight refresh of the
|
|
41
|
+
// current path (via the existing `revalidate()`) BEFORE resolving with the
|
|
42
|
+
// action's JSON result. Default `false` → today's behavior, byte-identical. A
|
|
43
|
+
// per-call `withRefresh(accessor)` wrapper forces the refresh for a single call
|
|
44
|
+
// and WINS over this global (D5: per-call explicit beats the global default).
|
|
36
45
|
const runtimeOptions = {
|
|
37
46
|
defaultHeaders: null,
|
|
47
|
+
autoRevalidate: false,
|
|
38
48
|
};
|
|
39
49
|
export function configureRuactRuntime(options) {
|
|
40
50
|
if (options && Object.prototype.hasOwnProperty.call(options, "defaultHeaders")) {
|
|
@@ -47,6 +57,15 @@ export function configureRuactRuntime(options) {
|
|
|
47
57
|
);
|
|
48
58
|
}
|
|
49
59
|
}
|
|
60
|
+
if (options && Object.prototype.hasOwnProperty.call(options, "autoRevalidate")) {
|
|
61
|
+
const value = options.autoRevalidate;
|
|
62
|
+
if (typeof value !== "boolean") {
|
|
63
|
+
throw new TypeError(
|
|
64
|
+
"configureRuactRuntime: autoRevalidate must be a boolean",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
runtimeOptions.autoRevalidate = value;
|
|
68
|
+
}
|
|
50
69
|
}
|
|
51
70
|
|
|
52
71
|
/**
|
|
@@ -101,12 +120,28 @@ export class RuactActionError extends Error {
|
|
|
101
120
|
* (`globalThis.__ruact_navigate`, `window.location.assign` fallback) and
|
|
102
121
|
* resolves `null`.
|
|
103
122
|
*
|
|
123
|
+
* Story 15.6 (FR110) — auto-revalidate opt-in. When the call opts in — via the
|
|
124
|
+
* app-wide `configureRuactRuntime({ autoRevalidate: true })` default OR the
|
|
125
|
+
* per-call `withRefresh(accessor)` wrapper — a SUCCESSFUL, NON-redirecting
|
|
126
|
+
* mutation `await`s an in-place Flight refresh of the current path (the existing
|
|
127
|
+
* `revalidate()`) BEFORE the promise resolves, then resolves with the action's
|
|
128
|
+
* JSON result. This is pure client-side COMPOSITION of the two requests that
|
|
129
|
+
* already exist (POST-JSON mutation + GET-Flight refresh) — no new wire shape, no
|
|
130
|
+
* inbound Flight deserialization (serialize-only invariant intact). A `$redirect`
|
|
131
|
+
* WINS: the redirect is followed and the refresh is SKIPPED (refreshing the old
|
|
132
|
+
* path would be wrong). A failed mutation throws before the refresh runs, so no
|
|
133
|
+
* refresh happens. If the refresh is requested with no router installed, the
|
|
134
|
+
* descriptive `revalidate()` error surfaces (it is not swallowed).
|
|
135
|
+
*
|
|
104
136
|
* @param {{ method: string, path: string, segments?: string[] }} descriptor
|
|
105
137
|
* @returns {(arg1?: Record<string, unknown> | FormData, arg2?: FormData | Record<string, unknown>) => Promise<unknown>}
|
|
106
138
|
*/
|
|
107
139
|
export function _makeServerFunction(descriptor) {
|
|
108
140
|
const { method, path, segments = [] } = descriptor || {};
|
|
109
|
-
|
|
141
|
+
// The core invoker takes the raw call-args plus a `refreshOverride`:
|
|
142
|
+
// undefined → fall back to the app-wide `runtimeOptions.autoRevalidate`;
|
|
143
|
+
// true/false → an explicit per-call decision that WINS over the global (D5).
|
|
144
|
+
const invoke = async function (callArgs, refreshOverride) {
|
|
110
145
|
if (callArgs.length > 2) {
|
|
111
146
|
throw new TypeError(
|
|
112
147
|
`ruact server function ${method} ${path} called with ${callArgs.length} arguments — ` +
|
|
@@ -115,8 +150,61 @@ export function _makeServerFunction(descriptor) {
|
|
|
115
150
|
}
|
|
116
151
|
const args = pickWirePayload(callArgs);
|
|
117
152
|
const url = interpolatePath(path, segments, args);
|
|
153
|
+
// AC4 — a failed mutation throws HERE, before any refresh is sequenced.
|
|
118
154
|
const parsed = await ruactInvoke({ method, url, args, label: path });
|
|
119
|
-
|
|
155
|
+
const { value, redirected } = followRedirect(parsed);
|
|
156
|
+
// AC2 — redirect wins: the destination already re-renders; skip the refresh.
|
|
157
|
+
if (redirected) return value;
|
|
158
|
+
const refresh =
|
|
159
|
+
refreshOverride === undefined ? runtimeOptions.autoRevalidate === true : refreshOverride === true;
|
|
160
|
+
if (refresh) {
|
|
161
|
+
// AC1 — refresh the current path in place BEFORE resolving. AC5 — if no
|
|
162
|
+
// router is installed, `revalidate()`'s descriptive error surfaces here
|
|
163
|
+
// (D3: a mutation that succeeded but whose refresh rejects rejects the
|
|
164
|
+
// promise — consistent with `revalidate()`'s loud-by-default stance).
|
|
165
|
+
await revalidate();
|
|
166
|
+
}
|
|
167
|
+
return value;
|
|
168
|
+
};
|
|
169
|
+
const accessor = function ruactServerFunctionCall(...callArgs) {
|
|
170
|
+
return invoke(callArgs, undefined);
|
|
171
|
+
};
|
|
172
|
+
// Stash the core invoker on the accessor (non-enumerable) so `withRefresh` can
|
|
173
|
+
// force a refresh for a single call WITHOUT the codegen emitting a different
|
|
174
|
+
// accessor shape — the generated `.ruact/server-functions.ts` stays byte-
|
|
175
|
+
// identical (AC6). Keyed by a module-private Symbol so host code can't collide.
|
|
176
|
+
Object.defineProperty(accessor, REFRESH_INVOKER, { value: invoke, enumerable: false });
|
|
177
|
+
return accessor;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Story 15.6 (FR110) — module-private handle used by `withRefresh` to reach a
|
|
181
|
+
// server-function accessor's core invoker (see `_makeServerFunction`).
|
|
182
|
+
const REFRESH_INVOKER = Symbol("ruactRefreshInvoker");
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Story 15.6 (FR110) — per-call auto-revalidate wrapper. Wraps a generated
|
|
186
|
+
* server-function accessor and returns a NEW accessor that forces the in-place
|
|
187
|
+
* Flight refresh after a successful, non-redirecting call — regardless of the
|
|
188
|
+
* app-wide `autoRevalidate` default (D5: per-call explicit wins). Runtime-only:
|
|
189
|
+
* it does not change the codegen or the wire.
|
|
190
|
+
*
|
|
191
|
+
* import { createPost } from "@/.ruact/server-functions";
|
|
192
|
+
* import { withRefresh } from "ruact/server-functions-runtime";
|
|
193
|
+
* await withRefresh(createPost)(formData); // mutation + refresh in one await
|
|
194
|
+
*
|
|
195
|
+
* @param {Function} accessor A `_makeServerFunction`-produced accessor.
|
|
196
|
+
* @returns {(...args: unknown[]) => Promise<unknown>} A refreshing accessor.
|
|
197
|
+
*/
|
|
198
|
+
export function withRefresh(accessor) {
|
|
199
|
+
const invoke = typeof accessor === "function" ? accessor[REFRESH_INVOKER] : undefined;
|
|
200
|
+
if (typeof invoke !== "function") {
|
|
201
|
+
throw new TypeError(
|
|
202
|
+
"ruact withRefresh() expects a server-function accessor produced by the ruact codegen " +
|
|
203
|
+
"(imported from @/.ruact/server-functions)",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return function ruactRefreshingCall(...callArgs) {
|
|
207
|
+
return invoke(callArgs, true);
|
|
120
208
|
};
|
|
121
209
|
}
|
|
122
210
|
|
|
@@ -519,6 +607,15 @@ function readSegment(args, seg) {
|
|
|
519
607
|
// `{ "$redirect": "<path>" }`; follow it via the router handoff and resolve
|
|
520
608
|
// `null` (consistent with the 204→null contract).
|
|
521
609
|
function followRedirectIfPresent(parsed) {
|
|
610
|
+
return followRedirect(parsed).value;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Story 15.6 — the same follow logic, but reporting WHETHER a redirect was
|
|
614
|
+
// followed as an explicit boolean. Auto-revalidate needs to distinguish "a
|
|
615
|
+
// redirect was followed → skip the refresh" from "the action genuinely resolved
|
|
616
|
+
// `null`/204 → still refresh" — inferring redirect from `value === null` would
|
|
617
|
+
// wrongly skip the refresh after an ordinary empty-body mutation.
|
|
618
|
+
function followRedirect(parsed) {
|
|
522
619
|
if (
|
|
523
620
|
parsed &&
|
|
524
621
|
typeof parsed === "object" &&
|
|
@@ -526,9 +623,9 @@ function followRedirectIfPresent(parsed) {
|
|
|
526
623
|
typeof parsed.$redirect === "string"
|
|
527
624
|
) {
|
|
528
625
|
navigateTo(parsed.$redirect);
|
|
529
|
-
return null;
|
|
626
|
+
return { value: null, redirected: true };
|
|
530
627
|
}
|
|
531
|
-
return parsed;
|
|
628
|
+
return { value: parsed, redirected: false };
|
|
532
629
|
}
|
|
533
630
|
|
|
534
631
|
function navigateTo(target) {
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
RuactActionError,
|
|
16
16
|
configureRuactRuntime,
|
|
17
17
|
revalidate,
|
|
18
|
+
withRefresh,
|
|
18
19
|
} from "./index.js";
|
|
19
20
|
|
|
20
21
|
// Helper — a route-driven accessor targeting `POST /posts` (the canonical
|
|
@@ -822,3 +823,175 @@ describe("Story 9.3 — _makeServerFunction (route-driven, real path+verb)", ()
|
|
|
822
823
|
expect(result).toEqual({ post: { id: 1 }, $redirect: 42 });
|
|
823
824
|
});
|
|
824
825
|
});
|
|
826
|
+
|
|
827
|
+
// =============================================================================
|
|
828
|
+
// Story 15.6 (FR110) — auto-revalidate opt-in: one await = mutation + refresh
|
|
829
|
+
// =============================================================================
|
|
830
|
+
|
|
831
|
+
describe("Story 15.6 — auto-revalidate opt-in", () => {
|
|
832
|
+
let originalRevalidate;
|
|
833
|
+
let originalNavigate;
|
|
834
|
+
let originalLocation;
|
|
835
|
+
let originalWindow;
|
|
836
|
+
|
|
837
|
+
beforeEach(() => {
|
|
838
|
+
originalRevalidate = globalThis.__ruact_revalidate;
|
|
839
|
+
originalNavigate = globalThis.__ruact_navigate;
|
|
840
|
+
originalLocation = globalThis.location;
|
|
841
|
+
originalWindow = globalThis.window;
|
|
842
|
+
globalThis.location = { pathname: "/posts", search: "?page=2" };
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
afterEach(() => {
|
|
846
|
+
// Reset the app-wide default so a leaked `autoRevalidate: true` can't bleed
|
|
847
|
+
// into the un-opted-in byte-parity tests elsewhere in the suite.
|
|
848
|
+
configureRuactRuntime({ autoRevalidate: false });
|
|
849
|
+
if (originalRevalidate === undefined) delete globalThis.__ruact_revalidate;
|
|
850
|
+
else globalThis.__ruact_revalidate = originalRevalidate;
|
|
851
|
+
globalThis.__ruact_navigate = originalNavigate;
|
|
852
|
+
if (originalLocation === undefined) {
|
|
853
|
+
// jsdom / browser env — leave the real `location` alone
|
|
854
|
+
} else {
|
|
855
|
+
globalThis.location = originalLocation;
|
|
856
|
+
}
|
|
857
|
+
globalThis.window = originalWindow;
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
// (a) request-composition order — POST then Flight refresh, in that order,
|
|
861
|
+
// resolves with the action's JSON result.
|
|
862
|
+
it("global default: refreshes the current path AFTER the mutation and resolves the JSON result", async () => {
|
|
863
|
+
const order = [];
|
|
864
|
+
mockFetchOk({ id: 7 });
|
|
865
|
+
const fetchSpy = globalThis.fetch;
|
|
866
|
+
fetchSpy.mockImplementation(async () => {
|
|
867
|
+
order.push("fetch");
|
|
868
|
+
return {
|
|
869
|
+
ok: true,
|
|
870
|
+
status: 200,
|
|
871
|
+
headers: { get: (n) => (n.toLowerCase() === "content-type" ? "application/json" : null) },
|
|
872
|
+
text: vi.fn().mockResolvedValue(JSON.stringify({ id: 7 })),
|
|
873
|
+
json: vi.fn().mockResolvedValue({ id: 7 }),
|
|
874
|
+
};
|
|
875
|
+
});
|
|
876
|
+
const revalidateSpy = vi.fn(async () => {
|
|
877
|
+
order.push("revalidate");
|
|
878
|
+
});
|
|
879
|
+
globalThis.__ruact_revalidate = revalidateSpy;
|
|
880
|
+
configureRuactRuntime({ autoRevalidate: true });
|
|
881
|
+
|
|
882
|
+
const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
|
|
883
|
+
const result = await createPost({ title: "x" });
|
|
884
|
+
|
|
885
|
+
expect(order).toEqual(["fetch", "revalidate"]);
|
|
886
|
+
expect(revalidateSpy).toHaveBeenCalledWith("/posts?page=2");
|
|
887
|
+
expect(result).toEqual({ id: 7 });
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it("per-call withRefresh: forces the refresh even when the global default is OFF", async () => {
|
|
891
|
+
mockFetchOk({ id: 1 });
|
|
892
|
+
const revalidateSpy = vi.fn().mockResolvedValue(undefined);
|
|
893
|
+
globalThis.__ruact_revalidate = revalidateSpy;
|
|
894
|
+
configureRuactRuntime({ autoRevalidate: false });
|
|
895
|
+
|
|
896
|
+
const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
|
|
897
|
+
const result = await withRefresh(createPost)({ title: "x" });
|
|
898
|
+
|
|
899
|
+
expect(revalidateSpy).toHaveBeenCalledWith("/posts?page=2");
|
|
900
|
+
expect(result).toEqual({ id: 1 });
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it("refreshes after a genuine null/204 result (not misread as a redirect)", async () => {
|
|
904
|
+
mockFetchOk("", { status: 204, contentType: "text/plain" });
|
|
905
|
+
const revalidateSpy = vi.fn().mockResolvedValue(undefined);
|
|
906
|
+
globalThis.__ruact_revalidate = revalidateSpy;
|
|
907
|
+
|
|
908
|
+
const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
|
|
909
|
+
const result = await withRefresh(createPost)({});
|
|
910
|
+
|
|
911
|
+
expect(revalidateSpy).toHaveBeenCalledTimes(1);
|
|
912
|
+
expect(result).toBeNull();
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
// (b) error propagation — a failed mutation rejects and issues NO refresh.
|
|
916
|
+
it("mutation failure propagates and does NOT refresh (refresh sequenced after success)", async () => {
|
|
917
|
+
mockFetchError(422, JSON.stringify({ error: "invalid" }));
|
|
918
|
+
const revalidateSpy = vi.fn().mockResolvedValue(undefined);
|
|
919
|
+
globalThis.__ruact_revalidate = revalidateSpy;
|
|
920
|
+
configureRuactRuntime({ autoRevalidate: true });
|
|
921
|
+
|
|
922
|
+
const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
|
|
923
|
+
|
|
924
|
+
await expect(createPost({ title: "" })).rejects.toMatchObject({
|
|
925
|
+
name: "RuactActionError",
|
|
926
|
+
status: 422,
|
|
927
|
+
});
|
|
928
|
+
expect(revalidateSpy).not.toHaveBeenCalled();
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
// (c) redirect-wins — a $redirect follows the redirect and issues NO refresh.
|
|
932
|
+
it("redirect wins: follows the $redirect, resolves null, and SKIPS the refresh", async () => {
|
|
933
|
+
mockFetchOk({ $redirect: "/posts/1" });
|
|
934
|
+
const navSpy = vi.fn();
|
|
935
|
+
globalThis.__ruact_navigate = navSpy;
|
|
936
|
+
const revalidateSpy = vi.fn().mockResolvedValue(undefined);
|
|
937
|
+
globalThis.__ruact_revalidate = revalidateSpy;
|
|
938
|
+
configureRuactRuntime({ autoRevalidate: true });
|
|
939
|
+
|
|
940
|
+
const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
|
|
941
|
+
const result = await createPost({ title: "x" });
|
|
942
|
+
|
|
943
|
+
expect(navSpy).toHaveBeenCalledWith("/posts/1");
|
|
944
|
+
expect(result).toBeNull();
|
|
945
|
+
expect(revalidateSpy).not.toHaveBeenCalled();
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// (d) no-router — the descriptive revalidate() error surfaces (not swallowed).
|
|
949
|
+
it("no router installed: surfaces the descriptive revalidate() error", async () => {
|
|
950
|
+
mockFetchOk({ id: 1 });
|
|
951
|
+
if ("__ruact_revalidate" in globalThis) delete globalThis.__ruact_revalidate;
|
|
952
|
+
configureRuactRuntime({ autoRevalidate: true });
|
|
953
|
+
|
|
954
|
+
const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
|
|
955
|
+
|
|
956
|
+
await expect(createPost({ title: "x" })).rejects.toThrow(
|
|
957
|
+
/ruact: revalidate\(\) called but no router is installed/,
|
|
958
|
+
);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it("D3: a successful mutation whose refresh REJECTS rejects the returned promise", async () => {
|
|
962
|
+
mockFetchOk({ id: 9 });
|
|
963
|
+
const revalidateSpy = vi.fn().mockRejectedValue(new Error("Flight refresh failed"));
|
|
964
|
+
globalThis.__ruact_revalidate = revalidateSpy;
|
|
965
|
+
|
|
966
|
+
const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
|
|
967
|
+
|
|
968
|
+
await expect(withRefresh(createPost)({ title: "x" })).rejects.toThrow("Flight refresh failed");
|
|
969
|
+
expect(revalidateSpy).toHaveBeenCalledTimes(1);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
// (e) un-opted-in is byte-identical to today: exactly one fetch, no refresh.
|
|
973
|
+
it("un-opted-in call is byte-identical: one fetch, no refresh", async () => {
|
|
974
|
+
mockFetchOk({ id: 1 });
|
|
975
|
+
const revalidateSpy = vi.fn().mockResolvedValue(undefined);
|
|
976
|
+
globalThis.__ruact_revalidate = revalidateSpy;
|
|
977
|
+
// global default stays OFF (reset in afterEach), no withRefresh wrapper.
|
|
978
|
+
|
|
979
|
+
const createPost = _makeServerFunction({ method: "POST", path: "/posts", segments: [] });
|
|
980
|
+
const result = await createPost({ title: "x" });
|
|
981
|
+
|
|
982
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
983
|
+
expect(revalidateSpy).not.toHaveBeenCalled();
|
|
984
|
+
expect(result).toEqual({ id: 1 });
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it("withRefresh throws a descriptive TypeError for a non-accessor argument", () => {
|
|
988
|
+
expect(() => withRefresh(() => {})).toThrow(/server-function accessor produced by the ruact codegen/);
|
|
989
|
+
expect(() => withRefresh(null)).toThrow(TypeError);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it("configureRuactRuntime rejects a non-boolean autoRevalidate", () => {
|
|
993
|
+
expect(() => configureRuactRuntime({ autoRevalidate: "yes" })).toThrow(
|
|
994
|
+
/autoRevalidate must be a boolean/,
|
|
995
|
+
);
|
|
996
|
+
});
|
|
997
|
+
});
|