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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  3. data/docs/internal/decisions/server-functions-api.md +55 -0
  4. data/lib/generators/ruact/install/install_generator.rb +114 -1
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
  6. data/lib/ruact/controller.rb +18 -3
  7. data/lib/ruact/doctor.rb +67 -9
  8. data/lib/ruact/erb_preprocessor.rb +120 -1
  9. data/lib/ruact/errors.rb +16 -0
  10. data/lib/ruact/manifest_resolver.rb +149 -0
  11. data/lib/ruact/railtie.rb +14 -3
  12. data/lib/ruact/render_pipeline.rb +8 -1
  13. data/lib/ruact/serializable.rb +98 -6
  14. data/lib/ruact/server.rb +163 -0
  15. data/lib/ruact/server_functions/introspection.rb +81 -0
  16. data/lib/ruact/server_functions.rb +26 -4
  17. data/lib/ruact/testing/component_query.rb +113 -0
  18. data/lib/ruact/testing/flight_extractor.rb +221 -0
  19. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  20. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  21. data/lib/ruact/testing.rb +90 -0
  22. data/lib/ruact/version.rb +1 -1
  23. data/lib/ruact/view_helper.rb +11 -4
  24. data/lib/ruact.rb +1 -0
  25. data/lib/tasks/ruact.rake +55 -2
  26. data/spec/ruact/controller_spec.rb +7 -2
  27. data/spec/ruact/doctor_spec.rb +141 -0
  28. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  29. data/spec/ruact/install_generator_spec.rb +285 -0
  30. data/spec/ruact/manifest_resolver_spec.rb +174 -0
  31. data/spec/ruact/serializable_spec.rb +126 -0
  32. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  33. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  34. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  35. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  36. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  37. data/spec/spec_helper.rb +15 -0
  38. data/spec/support/flight_wire_parser.rb +12 -126
  39. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  40. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  41. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  42. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  43. data/vendor/javascript/vite-plugin-ruact/index.js +20 -0
  44. data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
  45. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  46. 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
- # Internal helpers shared by the structural Flight matchers. Lives under
8
- # `Ruact::Spec` (test-only namespace) — no top-level constants leak into
9
- # the gem's public API surface.
10
- # rubocop:disable Metrics/ClassLength -- single cohesive helper for matcher diffing/formatting; splitting would scatter related logic across files.
11
- class FlightStructureDiff
12
- # Keys the parser produces on every row. Predicates and expected rows
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 the
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
- return async function ruactServerFunctionCall(...callArgs) {
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
- return followRedirectIfPresent(parsed);
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
+ });