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
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "weft/params"
4
+ require "weft/resolver"
5
+
6
+ module Weft
7
+ class Params
8
+ # Composes a params bag from every source that can supply one. The single
9
+ # place that knows the source stack — components call it at construction
10
+ # for their own bag, and the Router calls it at the top of a request for
11
+ # the bag it hands the first verb block.
12
+ #
13
+ # The stack, top wins. `nil` never wins a level: it means that source
14
+ # didn't have the key.
15
+ #
16
+ # 1. hand-off a caller staged a value for this component (`receives`)
17
+ # 2. overlay a verb block earlier in this request returned the key
18
+ # 3. own wire the request supplied it and this class declares it
19
+ # 4. inherited the bag this one branched from had it
20
+ # 5. derivation this class declares one (registered lazily, never run here)
21
+ # 6. default the declaration's own fallback
22
+ #
23
+ # The overlay speaks *as* the wire: its value replaces the wire's for that
24
+ # key, and an explicit nil clears — masking the wire so resolution falls
25
+ # below it. A derivation always "produces" (a thunk is never nil), so a
26
+ # same-key default sits unreachable behind one.
27
+ class Assembly
28
+ class << self
29
+ def call(...) = new(...).bag
30
+
31
+ # The state a request composes before any component of its own exists —
32
+ # what the first verb block sees. No caller and no enclosing build have
33
+ # run, so the hand-off door isn't merely unsatisfied, it isn't there:
34
+ # a `receives`-only key is absent, declared default and all. Dual it
35
+ # with a `param` or a `defines` to make the key visible here.
36
+ def for_request(component_class, wire_source)
37
+ call(component_class, wire_source, hand_offs: nil)
38
+ end
39
+
40
+ # One-time shadowing warnings, keyed [kind, class, key]. Set#add? races
41
+ # just double-warn; harmless.
42
+ def warned = @warned ||= Set.new
43
+ end
44
+
45
+ # +branched_from+ is the bag this one inherits: a tree ancestor's during
46
+ # a render, the state already composed at the top of a request. Passing
47
+ # +hand_offs: nil+ says the door does not exist (see .for_request);
48
+ # an empty hash says it exists and nobody staged anything.
49
+ def initialize(component_class, wire_source, hand_offs: {}, overlays: {}, branched_from: nil)
50
+ @component_class = component_class
51
+ @received = hand_offs || {}
52
+ @hand_offs = !hand_offs.nil?
53
+ @overlays = overlays
54
+ @wire = Weft::Resolver.resolve_present(component_class, wire_source)
55
+ @inherited = branched_from ? branched_from.branch_data : {}
56
+ @upstream_provenance = branched_from ? branched_from.provenance : {}
57
+ end
58
+
59
+ def bag
60
+ data = @inherited.dup
61
+ keys.each { |key| data[key] = stack_value(key) }
62
+ report_shadowed_derivations(data)
63
+ Weft::Params.new(data, provenance, defaults: declared_defaults)
64
+ end
65
+
66
+ private
67
+
68
+ def keys
69
+ return @component_class.declared_keys if @hand_offs
70
+
71
+ @component_class.declared_keys - hand_off_only_keys
72
+ end
73
+
74
+ # Keys whose only door is `receives`. A dual key is declared on another
75
+ # door too, so it stands on its own without a caller.
76
+ def hand_off_only_keys
77
+ @component_class.received_params.keys - @component_class.params.keys -
78
+ @component_class.derived_params.keys
79
+ end
80
+
81
+ def stack_value(key)
82
+ return @received[key] unless @received[key].nil?
83
+
84
+ wire_level = @overlays.key?(key) ? @overlays[key] : @wire[key]
85
+ [wire_level, @inherited[key], derived_thunk(key)].find { |v| !v.nil? }
86
+ end
87
+
88
+ # Fallbacks, not values: they ride on the bag rather than in it, so a
89
+ # key nobody supplied reads as this class's default without becoming
90
+ # something this class hands to anyone downstream.
91
+ def declared_defaults
92
+ keys.filter_map { |key| [key, default_for(key)] unless default_for(key).nil? }.to_h
93
+ end
94
+
95
+ def derived_thunk(key)
96
+ meta = @component_class.derived_params[key]
97
+ Weft::Params::Thunk.new(meta[:block]) if meta
98
+ end
99
+
100
+ # The wire door's default wins for dual keys — its meta always carries
101
+ # one, and the wire door sits above the hand-off's fallback in the stack.
102
+ def default_for(key)
103
+ wire_meta = @component_class.params[key]
104
+ return wire_meta[:default] if wire_meta
105
+
106
+ @component_class.received_params[key]&.[](:default)
107
+ end
108
+
109
+ # Where the derivation in force for each key was declared — a map of
110
+ # declarations, not of values. It tracks what *would* run, so it
111
+ # survives the value being forced, and survives another source winning
112
+ # the key outright: a derivation that lost is still a derivation
113
+ # someone downstream can collide with. The nearest upstream declaration
114
+ # wins, since its value is the one that would be inherited.
115
+ def provenance
116
+ declared = @upstream_provenance.dup
117
+ @component_class.derived_params.each do |key, meta|
118
+ declared[key] ||= meta[:source_location]
119
+ end
120
+ declared
121
+ end
122
+
123
+ # Two ways a declared derivation ends up dead, each said once per
124
+ # (class, key). Neither is an error — both are shapes a component can
125
+ # legitimately want — but both mean a block someone wrote never runs,
126
+ # which is better heard than discovered.
127
+ def report_shadowed_derivations(data)
128
+ @component_class.derived_params.each do |key, meta|
129
+ next unless @received[key].nil? && @wire[key].nil?
130
+
131
+ warn_upstream_derivation(key, meta) unless @inherited[key].nil?
132
+ warn_overlaid_derivation(key, meta) unless @overlays[key].nil?
133
+ end
134
+ data
135
+ end
136
+
137
+ # An inherited value won and was itself derived by a *different* block.
138
+ # A shared proc (one derivation mixed into many classes) is agreement,
139
+ # not divergence; values inherited through other doors carry no
140
+ # derivation provenance and stay silent.
141
+ def warn_upstream_derivation(key, meta)
142
+ upstream = @upstream_provenance[key]
143
+ return if upstream.nil? || upstream == meta[:source_location]
144
+ return unless warn_once?(:upstream, key)
145
+
146
+ Weft.logger.warn(
147
+ "#{@component_class.name}: inherited #{key.inspect} (derived at #{upstream.join(':')}) " \
148
+ "shadows this class's own derivation (#{meta[:source_location].join(':')}) — the " \
149
+ "ancestor's value wins. Use distinct keys or share one derivation if that isn't intended."
150
+ )
151
+ end
152
+
153
+ # A verb block earlier in this request returned the key. An overlay
154
+ # speaks as the wire, so it outranks the derivation entirely — which is
155
+ # exactly how a callable hands a record it already loaded to the render
156
+ # below it, and also how a derivation quietly stops running.
157
+ def warn_overlaid_derivation(key, meta)
158
+ return unless warn_once?(:overlaid, key)
159
+
160
+ Weft.logger.warn(
161
+ "#{@component_class.name}: #{key.inspect} arrived from a verb block in this request and " \
162
+ "outranks this class's own derivation (#{meta[:source_location].join(':')}), which will " \
163
+ "not run. Return a different key, or drop the derivation if the block is its only source."
164
+ )
165
+ end
166
+
167
+ def warn_once?(kind, key) = self.class.warned.add?([kind, @component_class, key])
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "weft/dsl/sandbox"
4
+ require "weft/error"
5
+
6
+ module Weft
7
+ # Value object representing a component's resolved input bag.
8
+ # Provides method-style access with a clear collision-resolution rule:
9
+ # declared param names win, then the underlying Hash API is available
10
+ # for any name not declared as a param.
11
+ #
12
+ # Entries may be lazy: a `derives` declaration registers a Thunk that runs
13
+ # (at most once per bag) when its key is first read, and never runs if the
14
+ # key goes unread. `to_h` and delegated Hash-API calls materialize every
15
+ # remaining thunk first — the eager escape hatch.
16
+ #
17
+ # Action callables receive a ready-made instance (the sole argument to a
18
+ # +performs+/+transfers+ block); you don't construct these yourself:
19
+ #
20
+ # params.status # => "shipped" (declared param)
21
+ # params.count # => 42 (declared param — wins over Hash#count)
22
+ # params[:status] # => "shipped" (explicit hash access)
23
+ # params.select { ... } # delegates to the underlying hash (materializes)
24
+ # params.to_h # => the underlying hash (explicit escape hatch; materializes)
25
+ class Params
26
+ # @api private
27
+ # A registered-not-yet-run derivation. Immutable, so branch copies may
28
+ # share it: forcing replaces the entry in the forcing bag only, which is
29
+ # what gives copy-on-branch memoization its semantics.
30
+ class Thunk
31
+ attr_reader :block
32
+
33
+ def initialize(block)
34
+ @block = block
35
+ end
36
+ end
37
+
38
+ # @api private
39
+ # Constructed internally (components self-resolve via the source stack;
40
+ # the Router wraps bags for action callables and recovery blocks).
41
+ # +provenance+ maps derives-born keys to their block's source_location —
42
+ # retained through forcing so divergence stays detectable.
43
+ # +defaults+ are the declaring class's own fallbacks, consulted when a
44
+ # read finds nothing. They are never stored as values, so they never ride
45
+ # a branch: a default belongs to whoever declared it, and a component
46
+ # deeper in the tree — or downstream of a hand-off — falls back to its
47
+ # own, not to the one above it.
48
+ def initialize(data, provenance = {}, defaults: {})
49
+ @data = data
50
+ @provenance = provenance
51
+ @defaults = defaults
52
+ @forcing = []
53
+ end
54
+
55
+ # @api private
56
+ attr_reader :provenance
57
+
58
+ # @api private
59
+ # A branchable snapshot for the inheritance axis: forced values and
60
+ # still-lazy thunks both ride (thunks are shared objects — forcing
61
+ # happens per bag, which is what makes the memo copy-on-branch); nils
62
+ # don't ride (nil means "nobody had it" and must not shadow a
63
+ # descendant's own defaults).
64
+ def branch_data
65
+ @data.compact
66
+ end
67
+
68
+ # @api private
69
+ # A same-bag copy with +values+ overlaid at their keys. Unlike
70
+ # to_h-then-merge, nothing materializes: untouched thunks stay lazy, nil
71
+ # entries stay resolved-absent, provenance rides. The plain-context
72
+ # hand-off fallback lands received values through this.
73
+ def overlay(values)
74
+ self.class.new(@data.merge(values), @provenance, defaults: @defaults)
75
+ end
76
+
77
+ # nil means no source had this key — so the read falls to the declared
78
+ # fallback, exactly as it falls past a nil at any other level of the stack.
79
+ def [](key)
80
+ value = @data[key]
81
+ value = force!(key, value) if value.is_a?(Thunk)
82
+ value.nil? ? @defaults[key] : value
83
+ end
84
+
85
+ def key?(key)
86
+ @data.key?(key) || @defaults.key?(key)
87
+ end
88
+
89
+ def to_h = materialized
90
+
91
+ def respond_to_missing?(name, include_private = false)
92
+ key?(name) || @data.respond_to?(name, include_private) || super
93
+ end
94
+
95
+ def method_missing(name, *args, **kwargs, &block)
96
+ if key?(name) && args.empty? && kwargs.empty? && !block
97
+ self[name]
98
+ elsif @data.respond_to?(name)
99
+ materialized.public_send(name, *args, **kwargs, &block)
100
+ else
101
+ super
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ # The bag as a plain hash: every thunk run, every unsupplied key standing
108
+ # at its declared fallback.
109
+ def materialized
110
+ materialize!
111
+ @defaults.merge(@data) { |_key, fallback, value| value.nil? ? fallback : value }
112
+ end
113
+
114
+ # Run a thunk with the bag as its argument (derivations chain by reading
115
+ # sibling keys) and memoize the result in place. A failed derivation is
116
+ # not memoized — like RSpec's let, it reruns if read again. The in-flight
117
+ # list turns circular derivations into a clear error instead of a stack
118
+ # overflow.
119
+ def force!(key, thunk)
120
+ if @forcing.include?(key)
121
+ raise Weft::InvalidUsage,
122
+ "circular derivation: #{(@forcing + [key]).join(' -> ')} " \
123
+ "(a derives block may not read its own key)"
124
+ end
125
+
126
+ @forcing << key
127
+ begin
128
+ @data[key] = Weft::DSL::Sandbox.run(self, &thunk.block)
129
+ ensure
130
+ @forcing.pop
131
+ end
132
+ end
133
+
134
+ def materialize!
135
+ @data.each_key { |key| self[key] }
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "weft/error"
4
+
5
+ module Weft
6
+ # Registry for named interaction presets. Each preset is a bundle of
7
+ # trigger/swap/target defaults that Context expands via loads:.
8
+ #
9
+ # Shipped presets are registered at the bottom of this file. Users will be
10
+ # able to register custom presets in v1.x via the same API.
11
+ module Presets
12
+ # Weft's own element-kwarg vocabulary (keep in sync with
13
+ # Context::Expansion's grammar; :prompt reserved ahead of its arrival).
14
+ # A preset by one of these names would shadow the expansion grammar.
15
+ RESERVED_KWARGS = %i[action navigate loads trigger push_url swap target with confirm prompt].freeze
16
+
17
+ # HTML attribute names a preset would shadow: a registered preset claims
18
+ # its kwarg whenever the value is a Class or String, so `title: "..."`
19
+ # would stop rendering the HTML attribute. Global attributes plus the
20
+ # commonly-passed per-element ones.
21
+ HTML_ATTRIBUTE_NAMES = %i[
22
+ accesskey autocapitalize autofocus class contenteditable dir draggable enterkeyhint
23
+ hidden id inert inputmode lang nonce popover role slot spellcheck style tabindex
24
+ title translate
25
+ href src alt name value type placeholder disabled readonly required checked selected
26
+ multiple method rel download media width height
27
+ ].freeze
28
+
29
+ class << self
30
+ # Register a named interaction preset.
31
+ #
32
+ # Weft::Presets.register :tooltip, trigger: :hover, swap: :fill
33
+ def register(name, **defaults)
34
+ validate_name!(name)
35
+ registry[name] = defaults
36
+ end
37
+
38
+ # Look up a registered preset by name. Returns the defaults hash or nil.
39
+ def lookup(name)
40
+ registry[name]
41
+ end
42
+
43
+ # All registered preset names.
44
+ def registered
45
+ registry.keys
46
+ end
47
+
48
+ private
49
+
50
+ def validate_name!(name)
51
+ if RESERVED_KWARGS.include?(name)
52
+ raise Weft::InvalidDefinition,
53
+ "cannot register preset #{name.inspect} — it is Weft's own element-kwarg vocabulary"
54
+ end
55
+ return unless HTML_ATTRIBUTE_NAMES.include?(name)
56
+
57
+ Weft.logger.warn "preset #{name.inspect} shadows the HTML attribute of the same name — " \
58
+ "elements passing a Class or String value for #{name}: will expand as this " \
59
+ "preset instead of rendering the attribute"
60
+ end
61
+
62
+ def registry
63
+ @registry ||= {}
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ # Shipped interaction presets — each bundles the htmx wiring for a common
70
+ # hypermedia interaction pattern. Adding one is a single line; Context dispatch
71
+ # + loads: expansion handles the rest.
72
+
73
+ Weft::Presets.register :tooltip, trigger: :hover, swap: :fill
74
+ # click_once, not click: the inserted detail lands after the trigger element,
75
+ # so a repeat click would insert a second copy.
76
+ Weft::Presets.register :inline_expand, trigger: :click_once, swap: :after
77
+ Weft::Presets.register :lazy, trigger: :visible, swap: :fill, target: :self
78
+ Weft::Presets.register :modal, trigger: :click, swap: :fill
79
+ Weft::Presets.register :load_more, trigger: :click, swap: :replace, target: :self
80
+ Weft::Presets.register :infinite_scroll, trigger: :visible, swap: :after
81
+ Weft::Presets.register :live_search, trigger: :input, swap: :fill
82
+ Weft::Presets.register :tabs, trigger: :click, swap: :fill
83
+
84
+ # Retry is the odd one out: its value is a URL (the failing component's own GET
85
+ # URL, injected as the :retry_url recovery param), not a target Class. It ships a
86
+ # concrete hx-target so error components never hand-write htmx — outerHTML-swapping
87
+ # the enclosing .weft-error box replaces the whole error display with the fresh
88
+ # component. Callers override target: for a differently-classed container.
89
+ Weft::Presets.register :retry, trigger: :click, swap: :outer_html, target: "closest .weft-error"
90
+
91
+ # Reopen_stream is retry's sibling for closed SSE streams (same URL value,
92
+ # usually :retry_url). Targeting the enclosing sse-swap wrapper — the element
93
+ # left behind after the Router closes a stream — means the fresh render
94
+ # replaces it wholesale, and its new sse-connect attribute makes htmx open a
95
+ # fresh EventSource: live updates resume with a full attempts budget.
96
+ Weft::Presets.register :reopen_stream, trigger: :click, swap: :outer_html, target: "closest [sse-swap]"
data/lib/weft/redirect.rb CHANGED
@@ -14,30 +14,30 @@ module Weft
14
14
  # Convenience wrapper:
15
15
  # Weft.redirect(OrderDetailPage, order_id: order.id)
16
16
  class Redirect
17
- attr_reader :target, :attrs
17
+ attr_reader :target, :params
18
18
 
19
19
  # @api private
20
20
  # Use {Redirect.to} (or {Weft.redirect}) — +new+ is private.
21
- def initialize(target, **attrs)
21
+ def initialize(target, **params)
22
22
  @target = target
23
- @attrs = attrs
23
+ @params = params
24
24
  end
25
25
 
26
26
  # Resolve the redirect URL.
27
- # Page targets: interpolate attrs into page_path pattern.
27
+ # Page targets: interpolate params into page_path pattern.
28
28
  # String targets: use as-is.
29
29
  def url
30
30
  case @target
31
31
  when String
32
32
  @target
33
33
  else
34
- @target.resolve_page_path(@attrs)
34
+ @target.resolve_page_path(@params)
35
35
  end
36
36
  end
37
37
 
38
38
  # Primary constructor.
39
- def self.to(target, **attrs)
40
- new(target, **attrs)
39
+ def self.to(target, **params)
40
+ new(target, **params)
41
41
  end
42
42
 
43
43
  private_class_method :new
@@ -29,30 +29,16 @@ module Weft
29
29
  @routable_explicit = false
30
30
  end
31
31
 
32
+ # Same switch as {abstract!}, named for the other reason to flip it:
33
+ # "I rely on state I can only receive, not reconstruct — my parent must
34
+ # hand it to me every time, so serving me standalone makes no sense."
35
+ alias dependent! abstract!
36
+
32
37
  # Force this class to be routable, even if its declared state would
33
38
  # otherwise make it non-routable. Does not percolate to subclasses.
34
39
  def routable!
35
40
  @routable_explicit = true
36
41
  end
37
-
38
- # Whether this class object has been superseded — its fully-qualified name
39
- # now resolves to a *different* class. This is the code-reload case: a
40
- # reloader (e.g. Zeitwerk in development) redefines the constant, binding a
41
- # new class object to the name while the old one lingers in the registry.
42
- # The registry drops superseded classes so only the current definition
43
- # routes (otherwise the two would look like a route collision).
44
- #
45
- # Classes whose name does not resolve to a constant — anonymous classes,
46
- # or test doubles that stub +.name+ — are never stale. Override for
47
- # bespoke liveness semantics.
48
- #
49
- # @note Uses ActiveSupport's +safe_constantize+, which walks the namespace.
50
- # The registry calls this only at route-resolution time (memoized), so
51
- # the cost is paid once per registry generation, not per request.
52
- def stale?
53
- current = name&.safe_constantize
54
- !current.nil? && !equal?(current)
55
- end
56
42
  end
57
43
  end
58
44
  end
data/lib/weft/registry.rb CHANGED
@@ -1,6 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Weft
4
+ class << self
5
+ # The process-wide registry. Defined here rather than in the gem root
6
+ # because registration happens at class-definition time (the `inherited`
7
+ # hooks on Component and Page call this), so any file that defines a
8
+ # subclass needs the accessor resolvable at load.
9
+ def registry
10
+ @registry ||= Registry.new
11
+ end
12
+ end
13
+
4
14
  # Stores registered Weft::Component and Weft::Page classes for the Router
5
15
  # to consume at request time.
6
16
  #
@@ -45,10 +55,8 @@ module Weft
45
55
  @routes_validated = false
46
56
  end
47
57
 
48
- # Empty the registry. Provided as an explicit reset hook for app-level
49
- # reload integrations and for tests; ordinary reloading is handled
50
- # automatically (superseded classes are pruned — see stale-class handling
51
- # in validate_routes!).
58
+ # Empty the registry. The evict-everything reset for hand-rolled reload
59
+ # integrations and for tests; per-class eviction is #evict.
52
60
  def clear
53
61
  @components.clear
54
62
  @pages.clear
@@ -57,6 +65,21 @@ module Weft
57
65
  @routes_validated = false
58
66
  end
59
67
 
68
+ # Remove one class from the registry, invalidating route lookup so a fresh
69
+ # same-path registration serves cleanly. This is the reload-eviction
70
+ # primitive: Weft.configure_autoloading wires it to Zeitwerk's on_unload
71
+ # callback, and hand-rolled reloaders should call it (or #clear) as classes
72
+ # unload. Safe to call with any object — non-registered classes (models,
73
+ # service objects) fall through untouched. Returns true if evicted.
74
+ def evict(klass) # rubocop:disable Naming/PredicateMethod -- a command with a did-evict status return
75
+ return false unless @components.delete?(klass) || @pages.delete?(klass)
76
+
77
+ @path_index = nil
78
+ @sse_present = nil
79
+ @routes_validated = false
80
+ true
81
+ end
82
+
60
83
  def pages
61
84
  @pages.to_a
62
85
  end
@@ -116,16 +139,6 @@ module Weft
116
139
  page_class.page_path || page_class.send(:default_page_path)
117
140
  end
118
141
 
119
- # Drop classes whose constant has been redefined out from under them (the
120
- # code-reload case — see Weft::Registry::Eligibility#stale?). Without this, a
121
- # reloaded class and its stale predecessor resolve to the same path and look
122
- # like a route collision. Runs once per registry generation via
123
- # validate_routes!; @path_index is rebuilt only if a component was removed.
124
- def prune_stale!
125
- @path_index = nil if @components.reject!(&:stale?)
126
- @pages.reject!(&:stale?)
127
- end
128
-
129
142
  # Build the effective-route table across every routable component (its base
130
143
  # path plus its reserved stream-suffix tail) and routable page (its resolved
131
144
  # pattern), and raise Weft::InvalidDefinition on any duplicate — component vs
@@ -136,9 +149,9 @@ module Weft
136
149
  def validate_routes!
137
150
  return if @routes_validated
138
151
 
139
- prune_stale!
140
152
  seen = {}
141
153
  routable_components.each do |klass|
154
+ warn_dependent_receives!(klass)
142
155
  base = klass.resolved_component_path
143
156
  add_route!(seen, base, klass, :component)
144
157
  add_route!(seen, "#{base}/#{Weft.configuration.stream_suffix}", klass, :stream)
@@ -147,6 +160,24 @@ module Weft
147
160
  @routes_validated = true
148
161
  end
149
162
 
163
+ # A routable component with a required hand-off it cannot reconstruct
164
+ # standalone will raise on every refresh (nothing hands the value over).
165
+ # Defaulted hand-offs are exempt (declaring a default explicitly opts
166
+ # into standalone renders falling back to it), as are dual keys — a wire
167
+ # param or a derives supplies the standalone value. Runs once per
168
+ # registry generation, alongside route validation.
169
+ def warn_dependent_receives!(klass)
170
+ required = klass.received_params.reject { |_, meta| meta.key?(:default) }.keys
171
+ undualed = required - klass.params.keys - klass.derived_params.keys
172
+ return if undualed.empty?
173
+
174
+ Weft.logger.warn(
175
+ "#{klass.name} is routable but depends on hand-offs it cannot reconstruct standalone " \
176
+ "(#{undualed.map(&:inspect).join(', ')}) — a refresh will raise without them. " \
177
+ "Mark the class dependent!, or declare a derives or wire param dual for the key."
178
+ )
179
+ end
180
+
150
181
  def add_route!(seen, path, klass, kind)
151
182
  validate_route_shape!(path, klass, kind)
152
183
  if (existing = seen[path])
@@ -167,10 +198,19 @@ module Weft
167
198
  "a route must be a non-empty string beginning with \"/\"."
168
199
  end
169
200
 
201
+ # Two same-named class objects at one route is the signature of code
202
+ # reloading without eviction — say so, rather than suggesting a rename.
170
203
  def collision_message(path, existing, incoming)
171
- "Route collision on #{path.inspect}: #{route_label(*existing)} and " \
172
- "#{route_label(*incoming)} resolve to the same route. Rename one class, " \
173
- "set an explicit component_path/page_path, or mark one abstract! if it should not route."
204
+ if existing[0].name == incoming[0].name
205
+ "Route collision on #{path.inspect}: two class objects named #{existing[0].name} " \
206
+ "are registered usually a code reloading setup that never evicts. Evict classes " \
207
+ "as they unload (Weft.registry.evict, e.g. from a Zeitwerk on_unload callback), " \
208
+ "or reset with Weft.registry.clear before each reload."
209
+ else
210
+ "Route collision on #{path.inspect}: #{route_label(*existing)} and " \
211
+ "#{route_label(*incoming)} resolve to the same route. Rename one class, " \
212
+ "set an explicit component_path/page_path, or mark one abstract! if it should not route."
213
+ end
174
214
  end
175
215
 
176
216
  def route_label(klass, kind)