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
@@ -28,6 +28,29 @@ module Ruact
28
28
  SUSPENSE_OPEN_RE = /<Suspense\b([^>]*?)>/m
29
29
  SUSPENSE_CLOSE_RE = %r{</Suspense>}
30
30
 
31
+ # Story 15.2 (FR106) — matches ANY PascalCase component tag: opening
32
+ # (`<Card>`), self-closing (`<Card />`), or closing (`</Card>`). Capture 1 is
33
+ # the leading slash (present only on a closing tag); capture 2 is the name.
34
+ # The loud-children detection scans these left-to-right with a stack: an
35
+ # opening pushes, a self-closing (`/>`) is ignored, and a closing that pops a
36
+ # matching opening means that opening had children (paired usage) → loud
37
+ # error. A single linear pass (no backreference/lazy backtracking) keeps the
38
+ # component-dense fast path linear regardless of how many tags go unclosed.
39
+ COMPONENT_ANY_TAG_RE = %r{<(/)?([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>}
40
+
41
+ # Cheap allocation-free probe (`String#match?`) for "is there ANY PascalCase
42
+ # closing tag at all?". The loud-children scan only matters when one exists,
43
+ # so this gates the (copy-heavy) mask/scan work off the common all-self-
44
+ # closing fast path. `</Suspense>` matches too — harmless, it gets masked.
45
+ CLOSING_TAG_PROBE_RE = %r{</[A-Z][A-Za-z0-9]*>}
46
+
47
+ # Newline-preserving mask for ERB islands (`<% … %>`, `<%= … %>`, `<%# … %>`).
48
+ # The loud-children scan blanks these first so a `</Card>` that lives inside
49
+ # Ruby/ERB string or comment text can never be mistaken for a real component
50
+ # closing tag (a valid bare `<Dialog>` opening must not error because some
51
+ # unrelated `<% "</Dialog>" %>` appears later).
52
+ ERB_ISLAND_RE = /<%.*?%>/m
53
+
31
54
  # Matches a +{ruby_expr}+ attribute value — captures everything between the braces.
32
55
  # We use a simple bracket-depth counter approach during scanning instead of regex
33
56
  # because expressions can contain nested braces: {foo.bar({ a: 1 })}.
@@ -66,6 +89,16 @@ module Ruact
66
89
  end
67
90
  .gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
68
91
 
92
+ # Step 1.5 (Story 15.2 / FR106): before the general component pass, fail
93
+ # loudly if any PascalCase component tag is used with children (a matching
94
+ # closing tag). Silent degradation of `<Card>Hello</Card>` — the #1
95
+ # predictable JSX-habit mistake — becomes a self-contained, re-raised-as-is
96
+ # PreprocessorError naming the fix. It scans the ORIGINAL +source+ (so
97
+ # file:line is exact) and masks Suspense (the one legitimate paired
98
+ # PascalCase tag) newline-for-newline, so `<Suspense>...</Suspense>` can
99
+ # never trip and every reported line matches the template verbatim.
100
+ detect_children!(source, identifier)
101
+
69
102
  # Step 2: transform remaining PascalCase self-closing / opening component tags.
70
103
  result.gsub(COMPONENT_TAG_RE) do |match|
71
104
  component_name = ::Regexp.last_match(1)
@@ -74,7 +107,13 @@ module Ruact
74
107
  line = result[0...match_start].count("\n") + 1
75
108
 
76
109
  begin
77
- registry = Ruact.manifest if registry == :default # lazy — only when a tag exists
110
+ # lazy — only when a tag exists. `resolve_soft` returns the dev-fetched
111
+ # manifest (same source the render path uses, so the boot-race doesn't
112
+ # silence FR100 contract checks in dev) and FAILS OPEN to nil when the
113
+ # manifest is unresolvable (contract validation is opt-in/fail-open;
114
+ # the render path surfaces the clear error). In prod this is the
115
+ # boot-loaded Ruact.manifest, unchanged.
116
+ registry = ManifestResolver.resolve_soft if registry == :default
78
117
  pairs = parse_prop_pairs(attrs_string)
79
118
  validate_contract(registry, component_name, pairs.map(&:first),
80
119
  at: { file: identifier, line: line, snippet: match.strip })
@@ -93,6 +132,86 @@ module Ruact
93
132
 
94
133
  private
95
134
 
135
+ # Story 15.2 (FR106) — raise a loud, self-contained {ChildrenNotSupportedError}
136
+ # on the FIRST PascalCase component tag used with children (a matching closing
137
+ # tag). +source+ is the ORIGINAL template text; +identifier+ is the template
138
+ # path (from {ErbPreprocessorHook}). Suspense — the one legitimate paired
139
+ # PascalCase tag — is masked newline-for-newline first, so it never trips AND
140
+ # every byte position (hence every reported line) still lines up with the raw
141
+ # template. The line uses the same idiom as Step 2 (`count("\n") + 1`) on the
142
+ # OPENING tag's offset. Message mirrors {ComponentContract.raise_error} shape
143
+ # so both loud preprocess errors read identically. A no-op when no pair is
144
+ # present — the fast path stays byte-identical.
145
+ def detect_children!(source, identifier)
146
+ # Fast path: a children pair REQUIRES a literal PascalCase closing tag, so
147
+ # a source without one (the common all-self-closing case) can never trip —
148
+ # bail before allocating anything. `match?` builds no MatchData, and this
149
+ # skips the mask/scan copies entirely, keeping the hot render/preprocess
150
+ # path's allocation profile flat (the benchmark renders only self-closing
151
+ # components, so it must stay at baseline).
152
+ return unless source.match?(CLOSING_TAG_PROBE_RE)
153
+
154
+ # ERB islands first, then Suspense — both blank their text newline-for-
155
+ # newline so byte offsets (hence reported lines) still match the raw
156
+ # template, while neither ERB string text nor the legitimate Suspense pair
157
+ # can be seen by the tag scan.
158
+ scan = mask_suspense(mask_erb(source))
159
+ # PER-NAME open stacks (name → [offsets]) so a closing tag checks for a
160
+ # matching open in O(1) via `open_ats[name].last`, keeping the whole scan
161
+ # linear even under thousands of stray/unmatched PascalCase closing tags
162
+ # (a global stack + `rindex` was quadratic — Codex Round 3).
163
+ open_ats = Hash.new { |h, k| h[k] = [] }
164
+
165
+ scan.scan(COMPONENT_ANY_TAG_RE) do
166
+ m = ::Regexp.last_match
167
+ name = m[2]
168
+
169
+ if m[1] # a closing tag `</Name>`
170
+ at = open_ats[name].last
171
+ next unless at # stray close with no open → literal text, ignore
172
+
173
+ raise_children_error(name, identifier, scan, at)
174
+ elsif m[0].end_with?("/>") # self-closing → carries no children
175
+ next
176
+ else # an opening tag `<Name ...>` — record the NEAREST open of this name
177
+ open_ats[name] << m.begin(0)
178
+ end
179
+ end
180
+
181
+ nil
182
+ end
183
+
184
+ # Raise the self-contained {ChildrenNotSupportedError}. +at+ is the OPENING
185
+ # tag's byte offset in +scan+ (position-faithful to the raw source), so the
186
+ # line uses the same idiom as Step 2. Message mirrors
187
+ # {ComponentContract.raise_error} so both loud preprocess errors read alike.
188
+ def raise_children_error(component, identifier, scan, at)
189
+ line = scan[0...at].count("\n") + 1
190
+ location = [identifier, line].compact.join(":")
191
+ location = "(unknown location)" if location.empty?
192
+ raise ChildrenNotSupportedError,
193
+ "ruact: <#{component}> at #{location} children are not supported " \
194
+ "— pass content as a prop, e.g. `<#{component} content={...} />`."
195
+ end
196
+
197
+ # Blank out Suspense open/close tags (the one legitimate paired PascalCase
198
+ # tag) while preserving EVERY newline and byte offset, so the loud-children
199
+ # scan neither trips on `<Suspense>...</Suspense>` nor mis-reports a line when
200
+ # a multi-line Suspense opening precedes the offending tag. Non-newline chars
201
+ # → spaces (same length); newlines kept verbatim.
202
+ def mask_suspense(source)
203
+ source
204
+ .gsub(SUSPENSE_OPEN_RE) { |m| m.gsub(/[^\n]/, " ") }
205
+ .gsub(SUSPENSE_CLOSE_RE) { |m| m.gsub(/[^\n]/, " ") }
206
+ end
207
+
208
+ # Blank ERB islands (position-faithful, see {mask_suspense}) so component
209
+ # tags that appear only inside Ruby/ERB string or comment text are invisible
210
+ # to the loud-children tag scan.
211
+ def mask_erb(source)
212
+ source.gsub(ERB_ISLAND_RE) { |m| m.gsub(/[^\n]/, " ") }
213
+ end
214
+
96
215
  # Extract a string attribute value (double or single quoted) from an attrs string.
97
216
  def extract_string_attr(attrs, name)
98
217
  m = attrs.match(/\b#{Regexp.escape(name)}\s*=\s*"([^"]*)"/) ||
data/lib/ruact/errors.rb CHANGED
@@ -22,6 +22,22 @@ module Ruact
22
22
  # re-wrapping it with the generic "at line N: snippet" tail.
23
23
  class ComponentContractError < PreprocessorError; end
24
24
 
25
+ # Story 15.2 (FR106) — raised by {Ruact::ErbPreprocessor} at preprocess time
26
+ # when a PascalCase component tag is used with children (a matching closing
27
+ # tag, e.g. `<Card>Hello</Card>`). ruact component tags are self-closing only:
28
+ # a component receives a props Hash, never a children element tree. This is the
29
+ # #1 predictable JSX-habit mistake, and it used to degrade silently (the
30
+ # children leaked into the surrounding HTML while the component rendered with
31
+ # none). Subclasses {PreprocessorError} so it flows through the same dev error
32
+ # overlay (NFR30 lineage) and the hook treats it uniformly — but the distinct
33
+ # class (mirroring {ComponentContractError}) lets the preprocessor re-raise it
34
+ # AS-IS (its message already carries the component name + file:line + the exact
35
+ # fix) and lets the 15.7 capstone assert this trap fails loudly by class. The
36
+ # sole legitimate paired PascalCase tag, `<Suspense>...</Suspense>`, is
37
+ # normalized to `<ruact-suspense>` in Step 1 before this detection runs, so it
38
+ # never trips.
39
+ class ChildrenNotSupportedError < PreprocessorError; end
40
+
25
41
  # Raised when application code attempts to mutate Ruact::Configuration outside
26
42
  # of a Ruact.configure block. The configuration is frozen after initialization
27
43
  # to prevent runtime drift; see Story 7.3 for the rationale and the decision
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ruact
6
+ # Resolves the {Ruact::ClientManifest} to use for the current render /
7
+ # ERB-preprocess.
8
+ #
9
+ # PRODUCTION (unchanged): returns the boot-loaded +Ruact.manifest+ (set once by
10
+ # the Railtie's +config.to_prepare+; the Railtie raises at boot when the file is
11
+ # absent). No per-request I/O.
12
+ #
13
+ # DEVELOPMENT (the fix): the boot-time +to_prepare+ read of
14
+ # +public/react-client-manifest.json+ RACES the Vite dev server's first write —
15
+ # on a fresh app Rails can boot (and read the missing file) BEFORE Vite writes
16
+ # it, leaving +Ruact.manifest+ nil even though Vite is running and serving a
17
+ # current manifest. +public/+ is not watched, so +to_prepare+ never re-fires and
18
+ # the first request to a view with a component hits +nil.reference_for+ → 500.
19
+ #
20
+ # To kill that race, dev resolution fetches the LIVE manifest the Vite plugin
21
+ # serves in memory at +GET {vite_dev_server}/__ruact/manifest+ (always fresh,
22
+ # reflects HMR rebuilds), with graceful fallbacks:
23
+ #
24
+ # 1. HTTP fetch from the Vite dev server (short timeout).
25
+ # 2. Fallback: read +public/react-client-manifest.json+ from disk (the Vite
26
+ # plugin still writes it — prod needs it for the build, and it is the
27
+ # fallback when the dev server is down).
28
+ # 3. Neither available → a clear, actionable error (never a cryptic
29
+ # +NoMethodError+ on nil).
30
+ #
31
+ # Memoization grain: the resolver fetches ONCE PER CALL. The two call sites each
32
+ # invoke it once per their scope and reuse the result for every component —
33
+ # +RenderPipeline+ holds the returned manifest in +@manifest+ for the whole
34
+ # render, and +ErbPreprocessor#transform+ resolves it lazily into a local that
35
+ # is reused across every tag in the template. So a render does NOT re-fetch per
36
+ # component (the bug was many +reference_for+ calls all needing a manifest); at
37
+ # most one fetch for the render and one for a (re)compiled template.
38
+ module ManifestResolver
39
+ DEV_MANIFEST_PATH = "/__ruact/manifest"
40
+ # Dev is localhost; fail fast to the file/clear-error fallback. A refused
41
+ # connection (Vite simply down) raises immediately, so this timeout only
42
+ # bounds the pathological "listening but not answering" case.
43
+ HTTP_OPEN_TIMEOUT = 1
44
+ HTTP_READ_TIMEOUT = 1
45
+
46
+ # Resolve the manifest for a render. In dev, raises a clear
47
+ # {Ruact::ManifestError} when neither the dev server nor the on-disk file is
48
+ # available. In any non-development environment, returns +Ruact.manifest+
49
+ # verbatim (production behaviour is untouched).
50
+ #
51
+ # @return [Ruact::ClientManifest, nil]
52
+ def self.resolve
53
+ return Ruact.manifest unless development?
54
+
55
+ dev_manifest(soft: false)
56
+ end
57
+
58
+ # Like {.resolve} but FAIL-OPEN in dev: returns +nil+ (rather than raising)
59
+ # when nothing is resolvable. Used by the ERB preprocessor's opt-in FR100
60
+ # contract validation, which is fail-open by design — a missing manifest must
61
+ # not crash template compilation; the render path then surfaces the clear
62
+ # error from {.resolve}.
63
+ #
64
+ # @return [Ruact::ClientManifest, nil]
65
+ def self.resolve_soft
66
+ return Ruact.manifest unless development?
67
+
68
+ dev_manifest(soft: true)
69
+ end
70
+
71
+ # @return [Ruact::ClientManifest, nil]
72
+ def self.dev_manifest(soft:)
73
+ manifest = fetch_over_http
74
+ return manifest if manifest
75
+
76
+ manifest = load_from_file
77
+ return manifest if manifest
78
+
79
+ return nil if soft
80
+
81
+ raise ManifestError, <<~MSG.strip
82
+ [ruact] Vite dev server unreachable at #{base_url} and no \
83
+ react-client-manifest.json found at #{file_path} — run `bin/dev`.
84
+ MSG
85
+ end
86
+
87
+ # Fetch + parse the live manifest from the Vite dev server, or +nil+ when the
88
+ # server is unreachable / returns a non-200 / unparseable body. Never raises:
89
+ # any failure degrades to the file fallback.
90
+ #
91
+ # @return [Ruact::ClientManifest, nil]
92
+ def self.fetch_over_http
93
+ require "net/http"
94
+ require "uri"
95
+
96
+ # `chomp("/")` so a base configured WITH a trailing slash
97
+ # ("http://localhost:5173/") does not yield a double-slashed
98
+ # "//__ruact/manifest" that misses the Vite middleware mount.
99
+ uri = URI.parse("#{base_url.chomp('/')}#{DEV_MANIFEST_PATH}")
100
+ response = Net::HTTP.start(
101
+ uri.host, uri.port,
102
+ use_ssl: uri.scheme == "https",
103
+ open_timeout: HTTP_OPEN_TIMEOUT, read_timeout: HTTP_READ_TIMEOUT
104
+ ) { |http| http.get(uri.request_uri) }
105
+
106
+ return nil unless response.is_a?(Net::HTTPSuccess)
107
+
108
+ ClientManifest.from_hash(JSON.parse(response.body))
109
+ rescue StandardError
110
+ nil
111
+ end
112
+
113
+ # Fallback: load the on-disk manifest the Vite plugin also writes, or +nil+
114
+ # when it is absent (the boot-race window) / unreadable.
115
+ #
116
+ # @return [Ruact::ClientManifest, nil]
117
+ def self.load_from_file
118
+ path = file_path
119
+ return nil unless path && File.exist?(path)
120
+
121
+ ClientManifest.load(path)
122
+ rescue StandardError
123
+ nil
124
+ end
125
+
126
+ # @return [String] the configured Vite dev-server base URL.
127
+ def self.base_url
128
+ Ruact.config.vite_dev_server
129
+ end
130
+
131
+ # @return [String, nil] the on-disk manifest path (config override or default).
132
+ def self.file_path
133
+ configured = Ruact.config.manifest_path
134
+ return configured.to_s if configured
135
+
136
+ return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
137
+
138
+ Rails.root.join("public", "react-client-manifest.json").to_s
139
+ end
140
+
141
+ # @return [Boolean] true only in a real Rails development environment. Any
142
+ # other env (production, test, or no Rails at all) takes the untouched
143
+ # +Ruact.manifest+ path.
144
+ def self.development?
145
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.respond_to?(:development?) &&
146
+ Rails.env.development?
147
+ end
148
+ end
149
+ end
data/lib/ruact/railtie.rb CHANGED
@@ -100,9 +100,20 @@ module Ruact
100
100
  # Extracted as a class method for direct testability without a full Rails app.
101
101
  def self.check_vite!
102
102
  require "socket"
103
- TCPSocket.new("localhost", 5173).close
104
- rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
105
- Rails.logger.warn "[ruact] Vite dev server not detected at localhost:5173 " \
103
+ require "uri"
104
+ uri = URI.parse(Ruact.config.vite_dev_server)
105
+ host = uri.host || "localhost"
106
+ port = uri.port || 5173
107
+ # `connect_timeout` so a blackholed/remote configured host can't stall dev
108
+ # boot from `after_initialize` until the OS TCP timeout (matches
109
+ # ViewHelper#vite_dev_running?).
110
+ TCPSocket.new(host, port, connect_timeout: 1).close
111
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError
112
+ # Broad rescue (incl. SocketError) because the host is now configurable: a
113
+ # misconfigured/unresolvable `vite_dev_server` must downgrade to this dev
114
+ # warning, never crash boot from `after_initialize`. `host`/`port` are
115
+ # assigned before the connect attempt, so they are always in scope here.
116
+ Rails.logger.warn "[ruact] Vite dev server not detected at #{host}:#{port} " \
106
117
  "— run npm run dev for HMR"
107
118
  end
108
119
 
@@ -166,7 +166,14 @@ module Ruact
166
166
  def render_erb_enum(erb_source, binding_context, streaming:)
167
167
  Enumerator.new do |y|
168
168
  render_context = RenderContext.new
169
- transformed = ErbPreprocessor.transform(erb_source)
169
+ # `registry: nil` → fail-open, no FR100 contract validation for this
170
+ # low-level programmatic render path (contract checking lives on the
171
+ # ActionView preprocessor hook used by real controllers). This keeps the
172
+ # path off `Ruact::ManifestResolver` entirely — historically it read the
173
+ # global `Ruact.manifest` (typically nil here, so already no validation),
174
+ # so passing nil is byte-for-byte equivalent while avoiding any dev HTTP
175
+ # fetch / test-double dispatch inside callers that measure this path.
176
+ transformed = ErbPreprocessor.transform(erb_source, registry: nil)
170
177
  receiver = binding_context.eval("self")
171
178
  prev_ctx = receiver.instance_variable_get(:@__ruact_render_context__)
172
179
  inject_helper!(binding_context, render_context)
@@ -5,32 +5,71 @@ module Ruact
5
5
  # client component. Declare which attributes are safe to serialize with
6
6
  # +ruact_props+; only those attributes will be included in the wire payload.
7
7
  #
8
- # @example
8
+ # Works on POROs and on ActiveRecord models alike. For a PORO the loud check
9
+ # fires at class-load; for an ActiveRecord model (whose attribute readers are
10
+ # defined lazily) the same loud check is deferred to the first
11
+ # +ruact_serialize+ — see {ClassMethods#ruact_props}.
12
+ #
13
+ # @example PORO
9
14
  # class Post
10
15
  # include Ruact::Serializable
11
16
  # attr_reader :id, :title, :secret
12
17
  # ruact_props :id, :title # :secret is never sent to the client
13
18
  # end
19
+ #
20
+ # @example ActiveRecord model
21
+ # class Post < ApplicationRecord
22
+ # include Ruact::Serializable
23
+ # ruact_props :id, :title # other columns never cross to the client
24
+ # end
14
25
  module Serializable
15
26
  def self.included(base)
16
27
  base.extend(ClassMethods)
17
28
  base.instance_variable_set(:@ruact_props, [])
29
+ base.instance_variable_set(:@ruact_deferred_props, [])
18
30
  end
19
31
 
20
32
  module ClassMethods
21
33
  # Declare which instance methods should be included in the serialized
22
- # payload. Raises +ArgumentError+ at class-load time if any name has no
23
- # corresponding method defined on the class.
34
+ # payload.
35
+ #
36
+ # The loud-omission guarantee is preserved: a name with no corresponding
37
+ # method still raises a clean +ArgumentError+. Only the *timing* of that
38
+ # check depends on the class:
39
+ #
40
+ # * **PORO** — checked eagerly at class-load (macro-invocation) time, as
41
+ # before. A typo'd/absent prop raises immediately.
42
+ # * **ActiveRecord model** — ActiveRecord defines its attribute reader
43
+ # methods lazily (on first instance access), so at macro-invocation time
44
+ # +method_defined?(:title)+ is +false+ even for a real column. Checking
45
+ # eagerly would either reject a valid model at boot or require a live DB
46
+ # connection at class-load (a Rails anti-pattern). So for a lazy-attribute
47
+ # class the not-yet-defined names are recorded and their loud check is
48
+ # deferred to the first +ruact_serialize+ (via +respond_to?+ on the
49
+ # instance), where the DB is up. A genuine typo still raises the same
50
+ # clean +ArgumentError+ — just at first render of that model, not at boot.
24
51
  #
25
52
  # @param attrs [Array<Symbol>]
53
+ # @raise [ArgumentError] immediately for an undefined prop on a PORO; at
54
+ # first +ruact_serialize+ for an undefined prop on an ActiveRecord model.
26
55
  def ruact_props(*attrs)
56
+ deferred = []
27
57
  attrs.each do |attr|
28
- unless method_defined?(attr)
29
- raise ArgumentError,
30
- "ruact_props: method `#{attr}` is not defined on #{self}"
58
+ next if method_defined?(attr)
59
+
60
+ # Lazy-attribute (ActiveRecord) class: the reader may still appear on
61
+ # first instance access. Record it and check loudly on first serialize
62
+ # instead of failing a valid model at boot.
63
+ if ruact_lazy_attribute_class?
64
+ deferred << attr
65
+ next
31
66
  end
67
+
68
+ raise ArgumentError,
69
+ "ruact_props: method `#{attr}` is not defined on #{self}"
32
70
  end
33
71
  @ruact_props = attrs
72
+ @ruact_deferred_props = deferred
34
73
  end
35
74
 
36
75
  # Returns the list of declared prop names as symbols.
@@ -46,12 +85,65 @@ module Ruact
46
85
  end
47
86
  []
48
87
  end
88
+
89
+ # Names whose eager loud check was deferred to first serialize (lazy
90
+ # ActiveRecord attributes). Read from the same class that declared the
91
+ # props, so subclasses share the parent declaration.
92
+ #
93
+ # @return [Array<Symbol>]
94
+ def ruact_deferred_props_list
95
+ klass = self
96
+ while klass
97
+ if klass.instance_variable_defined?(:@ruact_props)
98
+ return klass.instance_variable_get(:@ruact_deferred_props) || []
99
+ end
100
+
101
+ klass = klass.superclass
102
+ end
103
+ []
104
+ end
105
+
106
+ # Run the deferred loud check once, on first +ruact_serialize+. A recorded
107
+ # name that the instance does not +respond_to?+ (a typo, or a genuinely
108
+ # absent column) raises the same clean +ArgumentError+ the eager path would.
109
+ #
110
+ # Memoization keys on the *validated deferred list itself* (not a bare
111
+ # boolean), so any change to the effective declaration — a re-declaration
112
+ # on this class OR on an ancestor whose props a subclass inherits — is
113
+ # detected and re-validated loudly on the next serialize. Once a given
114
+ # list has been validated it costs one array comparison per serialize.
115
+ #
116
+ # @param instance [Object]
117
+ # @raise [ArgumentError]
118
+ def ruact_validate_deferred_props!(instance)
119
+ deferred = ruact_deferred_props_list
120
+ return if @ruact_deferred_props_validated == deferred
121
+
122
+ deferred.each do |attr|
123
+ next if instance.respond_to?(attr)
124
+
125
+ raise ArgumentError,
126
+ "ruact_props: method `#{attr}` is not defined on #{self}"
127
+ end
128
+ @ruact_deferred_props_validated = deferred
129
+ end
130
+
131
+ # True when this class defines its attribute reader methods lazily, i.e.
132
+ # an ActiveRecord model. Detected WITHOUT a hard ActiveRecord dependency
133
+ # (the gem stays single-dep +nokogiri+): the constant is only referenced
134
+ # when it is already defined in the host.
135
+ #
136
+ # @return [Boolean]
137
+ def ruact_lazy_attribute_class?
138
+ !!(defined?(ActiveRecord::Base) && self < ActiveRecord::Base)
139
+ end
49
140
  end
50
141
 
51
142
  # Serialize only the attributes declared with +ruact_props+.
52
143
  #
53
144
  # @return [Hash{String => Object}]
54
145
  def ruact_serialize
146
+ self.class.ruact_validate_deferred_props!(self)
55
147
  self.class.ruact_props_list.to_h { |attr| [attr.to_s, public_send(attr)] }
56
148
  end
57
149
  end