ruact 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -1
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +114 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/ruact/controller.rb +18 -3
- data/lib/ruact/doctor.rb +67 -9
- data/lib/ruact/erb_preprocessor.rb +120 -1
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +14 -3
- data/lib/ruact/render_pipeline.rb +8 -1
- data/lib/ruact/serializable.rb +98 -6
- data/lib/ruact/server.rb +163 -0
- data/lib/ruact/server_functions/introspection.rb +81 -0
- data/lib/ruact/server_functions.rb +26 -4
- data/lib/ruact/testing/component_query.rb +113 -0
- data/lib/ruact/testing/flight_extractor.rb +221 -0
- data/lib/ruact/testing/flight_structure_diff.rb +267 -0
- data/lib/ruact/testing/flight_wire_parser.rb +138 -0
- data/lib/ruact/testing.rb +90 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +11 -4
- data/lib/ruact.rb +1 -0
- data/lib/tasks/ruact.rake +55 -2
- data/spec/ruact/controller_spec.rb +7 -2
- data/spec/ruact/doctor_spec.rb +141 -0
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +285 -0
- data/spec/ruact/manifest_resolver_spec.rb +174 -0
- data/spec/ruact/serializable_spec.rb +126 -0
- data/spec/ruact/server_bucket_request_spec.rb +291 -0
- data/spec/ruact/server_functions/introspection_spec.rb +135 -0
- data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
- data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
- data/spec/ruact/testing/no_production_load_spec.rb +41 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/flight_wire_parser.rb +12 -126
- data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +20 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
- metadata +17 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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)
|
data/lib/ruact/serializable.rb
CHANGED
|
@@ -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
|
-
#
|
|
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.
|
|
23
|
-
#
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|