weft 0.1.0 → 0.2.0

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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -28
  3. data/README.md +46 -23
  4. data/docs/app-patterns.md +8 -7
  5. data/docs/arbre.md +49 -18
  6. data/docs/configuration.md +42 -35
  7. data/docs/dsl.md +356 -105
  8. data/docs/error-handling.md +64 -24
  9. data/docs/examples/active-search.md +10 -10
  10. data/docs/examples/browser-dialogs.md +10 -10
  11. data/docs/examples/bulk-update.md +11 -11
  12. data/docs/examples/click-to-edit.md +14 -14
  13. data/docs/examples/click-to-load.md +8 -8
  14. data/docs/examples/delete-row.md +17 -19
  15. data/docs/examples/edit-row.md +17 -14
  16. data/docs/examples/file-upload.md +5 -5
  17. data/docs/examples/infinite-scroll.md +8 -8
  18. data/docs/examples/inline-expansion.md +8 -8
  19. data/docs/examples/inline-validation.md +17 -17
  20. data/docs/examples/lazy-loading.md +8 -8
  21. data/docs/examples/live-ticker.md +1 -1
  22. data/docs/examples/modal-dialog.md +3 -3
  23. data/docs/examples/progress-bar.md +1 -1
  24. data/docs/examples/reset-user-input.md +7 -7
  25. data/docs/examples/tabs.md +4 -4
  26. data/docs/examples/tooltip.md +8 -8
  27. data/docs/examples/updating-other-content.md +9 -9
  28. data/docs/examples/value-select.md +11 -11
  29. data/docs/params.md +112 -0
  30. data/docs/routing.md +13 -13
  31. data/docs/tutorial.md +46 -48
  32. data/lib/weft/action.rb +4 -2
  33. data/lib/weft/autoloading.rb +69 -0
  34. data/lib/weft/component.rb +97 -31
  35. data/lib/weft/configuration.rb +37 -5
  36. data/lib/weft/context/expansion.rb +184 -0
  37. data/lib/weft/context/interception.rb +22 -2
  38. data/lib/weft/context/modifiers.rb +78 -0
  39. data/lib/weft/context/traversal.rb +80 -0
  40. data/lib/weft/context/wiring.rb +85 -0
  41. data/lib/weft/context.rb +70 -164
  42. data/lib/weft/defaults/error_component.rb +57 -21
  43. data/lib/weft/defaults/error_page.rb +12 -10
  44. data/lib/weft/defaults/not_found_component.rb +14 -12
  45. data/lib/weft/defaults/not_found_page.rb +9 -8
  46. data/lib/weft/dsl/actions.rb +9 -9
  47. data/lib/weft/dsl/inclusions.rb +48 -11
  48. data/lib/weft/dsl/params.rb +265 -0
  49. data/lib/weft/dsl/recoveries.rb +36 -6
  50. data/lib/weft/dsl/sandbox.rb +26 -0
  51. data/lib/weft/dsl/triggers.rb +28 -8
  52. data/lib/weft/dsl/updates.rb +31 -8
  53. data/lib/weft/error.rb +12 -1
  54. data/lib/weft/page/assets.rb +222 -0
  55. data/lib/weft/page/head.rb +87 -0
  56. data/lib/weft/page.rb +55 -239
  57. data/lib/weft/params/assembly.rb +170 -0
  58. data/lib/weft/params.rb +138 -0
  59. data/lib/weft/presets.rb +96 -0
  60. data/lib/weft/redirect.rb +7 -7
  61. data/lib/weft/registry/eligibility.rb +5 -19
  62. data/lib/weft/registry.rb +58 -18
  63. data/lib/weft/resolver.rb +48 -20
  64. data/lib/weft/router/actions.rb +106 -22
  65. data/lib/weft/router/errors.rb +223 -83
  66. data/lib/weft/router/oob_includes.rb +202 -17
  67. data/lib/weft/router/streaming.rb +86 -20
  68. data/lib/weft/router.rb +33 -25
  69. data/lib/weft/version.rb +1 -1
  70. data/lib/weft.rb +37 -24
  71. metadata +32 -8
  72. data/lib/weft/attributes.rb +0 -65
  73. data/lib/weft/dsl/attributes.rb +0 -43
  74. data/lib/weft/shorthands.rb +0 -57
@@ -1,5 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+ require "uri"
5
+
6
+ require "weft/context"
7
+ require "weft/dsl/sandbox"
8
+ require "weft/error"
9
+ require "weft/page"
10
+ require "weft/params"
11
+ require "weft/resolver"
12
+
3
13
  module Weft
4
14
  class Router
5
15
  # Error-and-recovery slice of the Router. Walks recovers chains for
@@ -8,32 +18,39 @@ module Weft
8
18
  # knob (D1, D2 no-op, D3 no-op), and houses the safety-net fallbacks
9
19
  # for unmatched errors and recovery handlers that themselves raise.
10
20
  #
11
- # Also owns the schema-gated auto-injection of recovery attributes
12
- # (:exception, :request_path, :status_code, :component_id, :retry_url).
21
+ # Also owns the schema-gated auto-injection of recovery params
22
+ # (:exception, :request_path, :status_code, :component_tag, :retry_url,
23
+ # :attempts_remaining) and the identity a recovery fragment inherits from
24
+ # the component it stands in for.
13
25
  #
14
- # Depends on Router internals: `resolver`, `headers`, `status`,
15
- # `request`, `redirect`, `htmx_request?`.
26
+ # Depends on Router internals: `headers`, `status`, `request`,
27
+ # `redirect`, `htmx_request?`.
16
28
  module Errors # rubocop:disable Metrics/ModuleLength
17
- # Each entry: { redirect_safe: true } means the auto-injected attribute is
29
+ # Each entry: { redirect_safe: true } means the auto-injected param is
18
30
  # included even when building a redirect URL. The component-context
19
- # attributes (:exception, :component_id, :retry_url) are not redirect-safe
20
- # no element to preserve identity of, Exception objects aren't
21
- # URL-encodable, and the destination Page rebuilds its own URL.
22
- AUTO_INJECTED_ATTRS = [
23
- { key: :exception, redirect_safe: false },
24
- { key: :request_path, redirect_safe: true },
25
- { key: :status_code, redirect_safe: true },
26
- { key: :component_id, redirect_safe: false },
27
- { key: :retry_url, redirect_safe: false }
31
+ # params (:exception, :retry_url) are not redirect-safe — Exception
32
+ # objects aren't URL-encodable, and the destination Page rebuilds its
33
+ # own URL.
34
+ # :attempts_remaining is populated only on the SSE push path (nil, and
35
+ # therefore skipped, on HTTP recoveries) — its presence tells a recovery
36
+ # component it is rendering inside a live stream, counting down to the
37
+ # close; 0 marks the final frame.
38
+ AUTO_INJECTED_PARAMS = [
39
+ { key: :exception, redirect_safe: false },
40
+ { key: :request_path, redirect_safe: true },
41
+ { key: :status_code, redirect_safe: true },
42
+ { key: :component_tag, redirect_safe: false },
43
+ { key: :retry_url, redirect_safe: false },
44
+ { key: :attempts_remaining, redirect_safe: false }
28
45
  ].freeze
29
- private_constant :AUTO_INJECTED_ATTRS
46
+ private_constant :AUTO_INJECTED_PARAMS
30
47
 
31
48
  private
32
49
 
33
50
  # Walk a Page-context recovers chain (B1, B2, C1 page-context, C4).
34
51
  # `originating_page_class` is nil for routing misses (no specific Page);
35
52
  # the gem-default chain on Weft::Page handles those.
36
- def handle_page_chain_failure(error, originating_page_class:, originating_attrs: {})
53
+ def handle_page_chain_failure(error, originating_page_class:, originating_params: nil, originating_wire: nil)
37
54
  root = originating_page_class || Weft::Page
38
55
  entry = root.recovery_for(error)
39
56
  return page_safety_net(error) unless entry
@@ -43,36 +60,41 @@ module Weft
43
60
  return htmx_redirect_to_error_page(error) if d1_applies?(entry, error)
44
61
 
45
62
  target = root.resolve_recovery_target(entry)
46
- merged_attrs = recovery_merged_attrs(entry, originating_attrs, error)
63
+ block_delta = invoke_recovery_block(entry, originating_params || Weft::Params.new({}), error)
47
64
 
48
65
  if page_target?(target)
49
- dispatch_page_recovery(target, merged_attrs, error)
66
+ dispatch_page_recovery(target, block_delta, error, entry, universe: originating_wire)
50
67
  else
51
- render_recovery_component(target, merged_attrs, error, component_ctx: {})
68
+ render_recovery_component(target, block_delta, error,
69
+ component_ctx: { status: recovery_status(error, entry) },
70
+ universe: originating_wire)
52
71
  end
53
72
  end
54
73
 
55
- def recovery_merged_attrs(entry, originating_attrs, error)
56
- block_result = invoke_recovery_block(entry, originating_attrs, error)
57
- originating_attrs.merge(block_result)
58
- end
59
-
60
74
  # Render or redirect for a Page recovery target. htmx requests get the
61
75
  # Page's body content as a fragment; traditional requests get the full
62
- # document. Status comes from the exception.
63
- def dispatch_page_recovery(page_class, merged_attrs, error)
64
- injected = inject_auto_attrs(page_class, merged_attrs, error, on_redirect: false)
65
- render_attrs = resolver.resolve(page_class, injected)
66
- status recovery_status(error)
67
- htmx_request? ? page_body_html(page_class, render_attrs) : page_class.render(**render_attrs)
76
+ # document. Status comes from the exception, or the entry's override.
77
+ # The page renders against the request's universe; the recovery values
78
+ # ride as overlays (one universe per request).
79
+ def dispatch_page_recovery(page_class, block_delta, error, entry = nil, universe: nil)
80
+ wire_status = recovery_status(error, entry)
81
+ overlays = block_delta.merge(auto_param_overlay(error, { status: wire_status }))
82
+ status wire_status
83
+ wire = universe || filtered_params
84
+ htmx_request? ? page_body_html(page_class, wire, overlays) : render_full_page(page_class, wire, overlays)
85
+ end
86
+
87
+ def render_full_page(page_class, wire_params, overlays)
88
+ klass = page_class
89
+ Weft::Context.new({}, nil, wire_params: wire_params, overlays: overlays) { insert_tag(klass) }.to_s
68
90
  end
69
91
 
70
92
  # Extract the rendered HTML inside a Page's <body>. For htmx fragment
71
93
  # responses to full-document failures — the surrounding doc shell is
72
94
  # already on the client; only the body content should swap.
73
- def page_body_html(page_class, attrs)
95
+ def page_body_html(page_class, wire_params, overlays)
74
96
  klass = page_class
75
- ctx = Weft::Context.new({}, nil) { insert_tag(klass, **attrs) }
97
+ ctx = Weft::Context.new({}, nil, wire_params: wire_params, overlays: overlays) { insert_tag(klass) }
76
98
  page_instance = ctx.children.first
77
99
  body_el = page_instance.children.find { |c| c.respond_to?(:tag_name) && c.tag_name == "body" }
78
100
  body_el ? body_el.children.join : page_instance.to_s
@@ -92,14 +114,17 @@ module Weft
92
114
  end
93
115
  end
94
116
 
95
- def render_error(component_class, resolved_attrs, error)
117
+ # +state+ is the bag the request had composed when it broke — what the
118
+ # recovery block reads. Bags are for blocks; the wire hash below is for
119
+ # URLs, and stays a plain hash so building one can't force a derivation.
120
+ def render_error(component_class, state, error)
96
121
  entry = component_class.recovery_for(error)
97
122
  if entry
98
123
  # D1: htmx + :redirect knob + gem-default fallthrough → HX-Redirect.
99
124
  return htmx_redirect_to_error_page(error) if d1_applies?(entry, error)
100
125
 
101
126
  begin
102
- return render_recovery(component_class, entry, resolved_attrs, error)
127
+ return render_recovery(component_class, entry, state, error)
103
128
  rescue StandardError => e
104
129
  # The recovery handler itself raised — fall through to the hardcoded
105
130
  # safety net rather than recursing. Surface the original error;
@@ -109,7 +134,14 @@ module Weft
109
134
  end
110
135
 
111
136
  status recovery_status(error)
112
- render_generic_error(component_class, resolved_attrs, error)
137
+ render_generic_error(component_class, error)
138
+ end
139
+
140
+ # The failing component's own declared wire params. Never the bag: this
141
+ # feeds retry URLs and redirect query strings, where materializing a
142
+ # derivation would run user code in the middle of error handling.
143
+ def error_wire_params(component_class)
144
+ Weft::Resolver.resolve(component_class, filtered_params)
113
145
  end
114
146
 
115
147
  # D1 applies when: the htmx_errors knob is :redirect, the request is htmx,
@@ -124,44 +156,114 @@ module Weft
124
156
 
125
157
  def htmx_redirect_to_error_page(error)
126
158
  target = Weft.configuration.error_page
127
- attrs = inject_auto_attrs(target, {}, error, on_redirect: true)
128
- headers["HX-Redirect"] = target.redirect_url(attrs)
159
+ params_for_url = inject_auto_params(target, {}, error, on_redirect: true)
160
+ headers["HX-Redirect"] = target.redirect_url(params_for_url)
129
161
  status 204
130
162
  ""
131
163
  end
132
164
 
133
- # Execute a matched recovery entry: invoke block, merge attrs, dispatch
134
- # to either a Page-redirect (HX-Redirect / 302) or a fragment render
165
+ # Execute a matched recovery entry: invoke the block, then dispatch to
166
+ # either a Page-redirect (HX-Redirect / 302) or a fragment render
135
167
  # depending on the target's type.
136
- def render_recovery(component_class, entry, resolved_attrs, error)
137
- block_result = invoke_recovery_block(entry, resolved_attrs, error)
138
- merged_attrs = resolved_attrs.merge(block_result)
168
+ def render_recovery(component_class, entry, state, error)
169
+ block_result = invoke_recovery_block(entry, state, error)
139
170
  target = component_class.resolve_recovery_target(entry)
171
+ wire = error_wire_params(component_class)
140
172
  component_ctx = {
141
- originating_id: component_class.weft_id_for(resolved_attrs),
142
- retry_url: compute_retry_url(component_class, resolved_attrs)
173
+ originating_id: resolved_dom_id(component_class,
174
+ unbuilt_instance(component_class, filtered_params), wire),
175
+ originating_tag: component_tag_for(component_class),
176
+ retry_url: compute_retry_url(component_class, wire),
177
+ status: recovery_status(error, entry)
143
178
  }
144
179
 
145
180
  if page_target?(target)
146
- redirect_to_recovery_page(target, merged_attrs, error)
181
+ redirect_to_recovery_page(target, wire.merge(block_result), error, component_ctx)
147
182
  else
148
- render_recovery_component(target, merged_attrs, error, component_ctx: component_ctx)
183
+ render_recovery_component(target, block_result, error, component_ctx: component_ctx)
149
184
  end
150
185
  end
151
186
 
187
+ # The failing component's wrapper tag, so a recovery fragment can render
188
+ # as a like-for-like element (a div swapped into <tr> position is invalid
189
+ # table content). `allocate` skips initialize — no param resolution, so
190
+ # reading the tag can't re-raise the failure; a tag_name that depends on
191
+ # instance state falls back to nil (target renders its own default).
192
+ def component_tag_for(component_class)
193
+ component_class.allocate.tag_name
194
+ rescue StandardError
195
+ nil
196
+ end
197
+
198
+ # An instance constructed only to be asked about itself. Params resolve
199
+ # at construction, so it answers for its own identity and URL before
200
+ # `build` runs, and unlike the class-level derivation it honors a
201
+ # `weft_dom_id` override. nil when the construction itself is what
202
+ # raises. Where the same component goes on to render, pass that instance
203
+ # rather than making a second one — the two would carry separate bags,
204
+ # and a derivation behind a declared param would run in each.
205
+ def unbuilt_instance(component_class, wire_params, overlays: {}, branch_bag: nil)
206
+ component_class.new(Weft::Context.new({}, nil, wire_params: wire_params,
207
+ overlays: overlays, branch_bag: branch_bag))
208
+ rescue StandardError
209
+ nil
210
+ end
211
+
212
+ # The DOM id a component wears, asked of an unbuilt instance first and
213
+ # falling back to the class-level derivation from an already-resolved
214
+ # params hash — that covers a construction that raises before the
215
+ # instance exists.
216
+ #
217
+ # Identity that raises when *asked* does not fall through to the
218
+ # class-level derivation, on purpose: a raising `weft_dom_id` is an
219
+ # override, and the convention it overrode would answer with a
220
+ # different id — landing the fragment on some other component's
221
+ # element. Landing nowhere beats landing somewhere wrong.
222
+ def resolved_dom_id(component_class, instance, fallback_params = nil)
223
+ return instance.weft_dom_id if instance
224
+ return component_class.weft_dom_id_for(fallback_params) if fallback_params
225
+
226
+ unresolved_dom_id(component_class)
227
+ rescue StandardError
228
+ unresolved_dom_id(component_class)
229
+ end
230
+
231
+ # Identity can itself raise — a `weft_dom_id` reading a record deleted
232
+ # between renders — and the id it would have had is unrecoverable.
233
+ #
234
+ # TEMPORARY: an unresolvable identity gets a unique throwaway, so it can
235
+ # never collide with or displace another fragment. Such a fragment simply
236
+ # lands nowhere, which is the honest outcome when we cannot say where it
237
+ # belongs. Component identity is due a design pass of its own; this is a
238
+ # placeholder until it gets one.
239
+ def unresolved_dom_id(component_class)
240
+ "#{component_class.weft_dom_id_for}-unresolved-#{SecureRandom.hex(4)}"
241
+ end
242
+
243
+ # A recovery fragment stands in the failing component's place, so it
244
+ # wears the failing component's id — a swap addressed anywhere else
245
+ # lands somewhere else, or nowhere at all. A page-context failure has no
246
+ # originating component, and its target keeps its own id.
247
+ def claim_dom_id(component, dom_id)
248
+ component.set_attribute("id", dom_id) if dom_id
249
+ component
250
+ end
251
+
152
252
  # GET URL to render the failing component fresh: its resolved_component_path
153
- # plus the resolved attrs as query string. For action endpoints this gives
253
+ # plus the resolved params as query string. For action endpoints this gives
154
254
  # the underlying component's view (not the action URL).
155
- def compute_retry_url(component_class, resolved_attrs)
255
+ def compute_retry_url(component_class, resolved_params)
156
256
  url = component_class.resolved_component_path
157
- params = resolved_attrs.compact
158
- params.empty? ? url : "#{url}?#{URI.encode_www_form(params)}"
257
+ query = resolved_params.compact
258
+ query.empty? ? url : "#{url}?#{URI.encode_www_form(query)}"
159
259
  end
160
260
 
161
- def invoke_recovery_block(entry, resolved_attrs, error)
261
+ # The block reads the state the request had composed, exactly as the
262
+ # build that failed would have read it.
263
+ def invoke_recovery_block(entry, state, error)
162
264
  return {} unless entry[:block]
163
265
 
164
- result = entry[:block].call(Weft::Attributes.new(resolved_attrs), error)
266
+ result = Weft::DSL::Sandbox.run(state, error, &entry[:block])
165
267
  result.is_a?(Hash) ? result : {}
166
268
  end
167
269
 
@@ -169,9 +271,10 @@ module Weft
169
271
  target.is_a?(Class) && defined?(Weft::Page) && target <= Weft::Page
170
272
  end
171
273
 
172
- def redirect_to_recovery_page(target, merged_attrs, error)
173
- attrs_for_url = inject_auto_attrs(target, merged_attrs, error, on_redirect: true)
174
- url = target.redirect_url(attrs_for_url)
274
+ def redirect_to_recovery_page(target, merged_params, error, component_ctx = {})
275
+ params_for_url = inject_auto_params(target, merged_params, error,
276
+ on_redirect: true, component_ctx: component_ctx)
277
+ url = target.redirect_url(params_for_url)
175
278
 
176
279
  if request.env["HTTP_HX_REQUEST"]
177
280
  headers["HX-Redirect"] = url
@@ -182,26 +285,60 @@ module Weft
182
285
  end
183
286
  end
184
287
 
185
- def render_recovery_component(target, merged_attrs, error, component_ctx:)
186
- injected = inject_auto_attrs(target, merged_attrs, error,
187
- on_redirect: false, component_ctx: component_ctx)
188
- status recovery_status(error)
189
- # Resolve schema-exact for the target so the failing component's attrs
190
- # (which may share no schema with the target) can't leak as HTML
191
- # attributes. Declared auto-injected attrs survive: their defaults are
192
- # nil, and Resolver#coerce passes non-nil values through unchanged.
193
- target.render(**resolver.resolve(target, injected))
194
- end
195
-
196
- # Schema-gated auto-injection of recovery attributes. A recovers target
197
- # opts in to receiving each value by declaring `attribute :<key>` — the
198
- # Router only injects keys the target's schema knows about (and only if
199
- # the value is non-nil).
200
- def inject_auto_attrs(target, attrs, error, on_redirect:, component_ctx: {})
201
- schema = target.respond_to?(:attributes) ? target.attributes : {}
202
- values = auto_attr_values(error, component_ctx)
203
- injected = attrs.dup
204
- AUTO_INJECTED_ATTRS.each do |meta|
288
+ # Fragment-only sibling of render_recovery for SSE push failures. Walks
289
+ # the chain via component_recovery_for (a stream swap can't take a Page),
290
+ # and returns the recovery target's children-only HTML — the client swap
291
+ # is innerHTML into the original component's persistent wrapper, so the
292
+ # frame ships content, not wrapper (like-for-like with a normal push).
293
+ # No status (headers are long flushed on a live stream), no oob includes
294
+ # (their params presume the successful build that didn't happen), and no
295
+ # htmx_errors redirect knob (an HTTP-path concern). Returns nil when the
296
+ # chain yields nothing; the caller owns rescue and logging.
297
+ def render_push_recovery(component_class, state, error, attempts_remaining:)
298
+ entry = component_class.component_recovery_for(error)
299
+ return nil unless entry
300
+
301
+ block_delta = invoke_recovery_block(entry, state, error)
302
+ target = component_class.resolve_recovery_target(entry)
303
+ component_ctx = {
304
+ retry_url: compute_retry_url(component_class, error_wire_params(component_class)),
305
+ attempts_remaining: attempts_remaining,
306
+ status: recovery_status(error, entry)
307
+ }
308
+ overlays = block_delta.merge(auto_param_overlay(error, component_ctx))
309
+ build_component_with_wire(target, filtered_params, overlays: overlays).content
310
+ end
311
+
312
+ # The target resolves its own schema from the request's universe; the
313
+ # recovery values — block delta plus the auto-injected params — ride
314
+ # as overlays, reaching any depth of the recovery render. The failing
315
+ # component's resolution never crowns a new universe, so its defaults
316
+ # and derivations can't leak into the target's.
317
+ def render_recovery_component(target, block_delta, error, component_ctx:, universe: nil)
318
+ overlays = block_delta.merge(auto_param_overlay(error, component_ctx))
319
+ status component_ctx.fetch(:status) { recovery_status(error) }
320
+ component = build_component_with_wire(target, universe || filtered_params, overlays: overlays)
321
+ claim_dom_id(component, component_ctx[:originating_id]).to_s
322
+ end
323
+
324
+ # The auto-injected values as an overlay: non-nil values only — a nil
325
+ # here would read as "clear the wire's value," which absence must not do.
326
+ # Declaration-driven reads make schema-gating unnecessary on renders
327
+ # (an unread overlay key is inert); redirects still gate via
328
+ # inject_auto_params below.
329
+ def auto_param_overlay(error, component_ctx)
330
+ auto_param_values(error, component_ctx).compact
331
+ end
332
+
333
+ # Schema-gated auto-injection of recovery params for REDIRECT URLs. A
334
+ # recovers target opts in to receiving each value by declaring
335
+ # `param :<key>` — only keys the target's schema knows about (and only
336
+ # redirect-safe, non-nil values) ride the URL.
337
+ def inject_auto_params(target, base_params, error, on_redirect:, component_ctx: {})
338
+ schema = target.respond_to?(:params) ? target.params : {}
339
+ values = auto_param_values(error, component_ctx)
340
+ injected = base_params.dup
341
+ AUTO_INJECTED_PARAMS.each do |meta|
205
342
  next if on_redirect && !meta[:redirect_safe]
206
343
  next unless schema.key?(meta[:key])
207
344
 
@@ -211,23 +348,26 @@ module Weft
211
348
  injected
212
349
  end
213
350
 
214
- def auto_attr_values(error, component_ctx)
351
+ def auto_param_values(error, component_ctx)
215
352
  {
216
353
  exception: error,
217
354
  request_path: request.path,
218
- status_code: recovery_status(error),
219
- component_id: component_ctx[:originating_id],
220
- retry_url: component_ctx[:retry_url]
355
+ status_code: component_ctx[:status] || recovery_status(error),
356
+ component_tag: component_ctx[:originating_tag],
357
+ retry_url: component_ctx[:retry_url],
358
+ attempts_remaining: component_ctx[:attempts_remaining]
221
359
  }
222
360
  end
223
361
 
224
- def recovery_status(error)
225
- error.is_a?(Weft::HTTPError) ? error.status : 500
362
+ # The matched entry's status: override wins; otherwise the error's own
363
+ # semantics (HTTPError carries a status, anything else reports 500).
364
+ def recovery_status(error, entry = nil)
365
+ entry&.[](:status) || (error.is_a?(Weft::HTTPError) ? error.status : 500)
226
366
  end
227
367
 
228
- def render_generic_error(component_class, resolved_attrs, error)
368
+ def render_generic_error(component_class, error)
229
369
  component_name = component_class.name || "Component"
230
- retry_url = compute_retry_url(component_class, resolved_attrs)
370
+ retry_url = compute_retry_url(component_class, error_wire_params(component_class))
231
371
 
232
372
  Weft::Context.new({}, nil) do
233
373
  error_style = "padding:1rem; border:1px solid #fca5a5; border-radius:6px; " \