ruact 0.0.16 → 0.0.18

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/README.md +1 -1
  4. data/lib/generators/ruact/install/templates/AGENTS.md.tt +6 -6
  5. data/lib/ruact/client_manifest.rb +29 -2
  6. data/lib/ruact/component_contract.rb +4 -2
  7. data/lib/ruact/controller.rb +3 -1
  8. data/lib/ruact/doctor.rb +64 -4
  9. data/lib/ruact/erb_preprocessor.rb +240 -137
  10. data/lib/ruact/errors.rb +15 -15
  11. data/lib/ruact/flight/renderer.rb +23 -9
  12. data/lib/ruact/flight/request.rb +31 -2
  13. data/lib/ruact/flight/row_emitter.rb +9 -0
  14. data/lib/ruact/flight/serializer.rb +22 -7
  15. data/lib/ruact/html_converter.rb +18 -0
  16. data/lib/ruact/render_pipeline.rb +16 -8
  17. data/lib/ruact/testing/component_query.rb +11 -4
  18. data/lib/ruact/testing/flight_wire_parser.rb +2 -2
  19. data/lib/ruact/version.rb +1 -1
  20. data/lib/ruact/view_helper.rb +13 -0
  21. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +44 -2
  22. data/vendor/javascript/vite-plugin-ruact/flight-conformance.test.mjs +306 -0
  23. data/vendor/javascript/vite-plugin-ruact/index.js +89 -14
  24. data/vendor/javascript/vite-plugin-ruact/package-lock.json +804 -3
  25. data/vendor/javascript/vite-plugin-ruact/package.json +5 -1
  26. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +53 -0
  27. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +46 -17
  28. data/vendor/javascript/vite-plugin-ruact/runtime/flight-modules.js +40 -0
  29. data/vendor/javascript/vite-plugin-ruact/runtime/flight-webpack-globals.js +15 -0
  30. data/vendor/javascript/vite-plugin-ruact/runtime/root-boundary.js +41 -0
  31. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +65 -83
  32. data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +83 -0
  33. data/vendor/javascript/vite-plugin-ruact/runtime/transport.js +41 -0
  34. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/LICENSE +21 -0
  35. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/VERSION.json +5 -0
  36. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.development.js +5473 -0
  37. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.production.js +2126 -0
  38. data/vendor/javascript/vite-plugin-ruact/scripts/vendor-flight-client.mjs +124 -0
  39. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +14 -0
  40. metadata +13 -4
  41. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +0 -499
  42. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +0 -436
@@ -14,186 +14,289 @@ module Ruact
14
14
  # The placeholder is replaced by an HTML comment with a unique token:
15
15
  # <!-- __RUACT_0__ -->
16
16
  #
17
+ # A component with children (Story 18-2) keeps its content as ERB, inside a
18
+ # wrapper element HtmlConverter turns into the component's `children`:
19
+ #
20
+ # <Card title={@t}><p><%= @body %></p></Card>
21
+ # → <%= __ruact_component_open__("Card", { "title" => @t }) %><p><%= @body %></p></ruact-component>
22
+ #
17
23
  # The actual ClientReference + props are registered in the binding and
18
24
  # collected by HtmlConverter after the ERB renders.
19
- class ErbPreprocessor
25
+ class ErbPreprocessor # rubocop:disable Metrics/ClassLength
20
26
  # Matches a PascalCase opening tag with optional attributes and optional self-closing.
21
27
  # Examples:
22
28
  # <Button />
23
29
  # <LikeButton postId={@post.id} initialCount={5} />
24
- # <Dialog open={true}>
30
+ # <Card title={@post.title}>
25
31
  COMPONENT_TAG_RE = %r{<([A-Z][A-Za-z0-9]*)(\s[^>]*)?\s*/?>}
26
32
 
27
33
  # Matches <Suspense ...> opening tags (handled before general PascalCase processing).
28
34
  SUSPENSE_OPEN_RE = /<Suspense\b([^>]*?)>/m
29
- SUSPENSE_CLOSE_RE = %r{</Suspense>}
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[^>]*)?>}
35
+ SUSPENSE_CLOSE_RE = %r{</Suspense\s*>}
36
+
37
+ # Matches ANY PascalCase component tag: opening (`<Card>`), self-closing
38
+ # (`<Card />`), or closing (`</Card>`). Capture 1 is the leading slash
39
+ # (present only on a closing tag); capture 2 is the name. The pairing scan
40
+ # walks these left to right with one stack (Story 18-2).
41
+ COMPONENT_ANY_TAG_RE = %r{<(/)?([A-Z][A-Za-z0-9]*)(?:\s[^>]*|/)?>}
40
42
 
41
43
  # 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]*>}
44
+ # closing tag at all?". Without one there is nothing to pair, so the
45
+ # template takes the single-pass path; `</Suspense>` matches too — harmless.
46
+ CLOSING_TAG_PROBE_RE = %r{</[A-Z][A-Za-z0-9]*[\s/>]}
46
47
 
47
48
  # 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).
49
+ # The pairing scan blanks these first so a `</Card>` that lives inside
50
+ # Ruby/ERB string or comment text is never taken for a real closing tag.
52
51
  ERB_ISLAND_RE = /<%.*?%>/m
53
52
 
53
+ # An ERB comment, which renders nothing.
54
+ ERB_COMMENT_RE = /<%#.*?%>/m
55
+
56
+ # The element a paired component's content is wrapped in until
57
+ # {HtmlConverter} turns it into the component's `children`.
58
+ WRAPPER_TAG = "ruact-component"
59
+
60
+ # What each helper the transformed template calls leaves in the HTML, by
61
+ # its render-context token: a component, or the opening of one with
62
+ # children. {RenderPipeline} installs these for a plain-ERB render;
63
+ # ViewHelper writes the same strings in ActionView.
64
+ PLACEHOLDERS = {
65
+ __ruact_component__: ->(token) { "<!-- #{token} -->" },
66
+ __ruact_component_open__: ->(token) { %(<#{WRAPPER_TAG} data-ruact-token="#{token}">) }
67
+ }.freeze
68
+
54
69
  # Transform ERB source, replacing component tags with ERB placeholders.
55
70
  # Returns the transformed source string.
56
71
  #
57
72
  # +identifier+ is the template path (forwarded by {ErbPreprocessorHook} as
58
- # +template.identifier+) so a Story 13.5 contract violation can name the
59
- # call site's file:line. +registry+ is the component contract source — an
60
- # injectable seam (Story 7.1 explicit-context grain); it defaults to the
61
- # process-loaded {Ruact.manifest}. Pass +registry: nil+ (or a stub) in
62
- # specs to control contract lookup; +nil+ forces fail-open (no validation).
73
+ # +template.identifier+) so an error can name the call site's file:line.
74
+ # +registry+ is the component contract source — an injectable seam (Story
75
+ # 7.1 explicit-context grain); it defaults to the process-loaded
76
+ # {Ruact.manifest}. Pass +registry: nil+ (or a stub) in specs to control
77
+ # contract lookup; +nil+ forces fail-open (no validation).
63
78
  def self.transform(source, identifier: nil, registry: :default)
64
79
  new.transform(source, identifier: identifier, registry: registry)
65
80
  end
66
81
 
67
82
  def transform(source, identifier: nil, registry: :default)
68
83
  # NOTE: +registry+ stays the +:default+ sentinel here. It is resolved to
69
- # +Ruact.manifest+ LAZILY, only inside the component-tag block below — a
70
- # source with no PascalCase tags must touch the registry not at all
71
- # (AC2/AC6 fast-path invariant).
72
- # Step 1: transform <Suspense> paired tags into <ruact-suspense> HTML elements.
73
- # This runs before the general component regex so Suspense isn't treated as a component.
74
- result = source
75
- .gsub(SUSPENSE_OPEN_RE) do
76
- attrs = ::Regexp.last_match(1)
77
- fallback = extract_string_attr(attrs, "fallback") || ""
78
- escaped = fallback.gsub('"', "&quot;")
79
- # Optional `delay="2.5"` — the server-side wait (seconds) before
80
- # the deferred chunk streams. Forwarded to SuspenseElement#delay.
81
- delay = extract_string_attr(attrs, "delay")
82
- delay_attr = delay ? %( data-ruact-delay="#{delay.gsub('"', '&quot;')}") : ""
83
- %(<ruact-suspense data-ruact-fallback="#{escaped}"#{delay_attr}>)
84
+ # +Ruact.manifest+ LAZILY, only when a component tag exists — a source
85
+ # with no PascalCase tags must touch the registry not at all (AC2/AC6
86
+ # fast-path invariant).
87
+ @identifier = identifier
88
+ @registry = registry
89
+
90
+ # Step 1: component tags → ERB placeholders. Story 18-2: a tag with a
91
+ # matching closing tag is a component WITH children; its content stays
92
+ # ERB, evaluated in place, inside a wrapper element.
93
+ result = if source.match?(CLOSING_TAG_PROBE_RE)
94
+ rewrite_paired(source)
95
+ else
96
+ rewrite_self_closing(source)
84
97
  end
85
- .gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
86
98
 
87
- # Step 1.5 (Story 15.2 / FR106): before the general component pass, fail
88
- # loudly if any PascalCase component tag is used with children (a matching
89
- # closing tag). Silent degradation of `<Card>Hello</Card>` — the #1
90
- # predictable JSX-habit mistake — becomes a self-contained, re-raised-as-is
91
- # PreprocessorError naming the fix. It scans the ORIGINAL +source+ (so
92
- # file:line is exact) and masks Suspense (the one legitimate paired
93
- # PascalCase tag) newline-for-newline, so `<Suspense>...</Suspense>` can
94
- # never trip and every reported line matches the template verbatim.
95
- detect_children!(source, identifier)
96
-
97
- # Step 2: transform remaining PascalCase self-closing / opening component tags.
98
- result.gsub(COMPONENT_TAG_RE) do |match|
99
- component_name = ::Regexp.last_match(1)
100
- attrs_string = ::Regexp.last_match(2).to_s.strip
101
- match_start = ::Regexp.last_match.begin(0)
102
- line = result[0...match_start].count("\n") + 1
103
-
104
- begin
105
- # lazy — only when a tag exists. `resolve_soft` returns the dev-fetched
106
- # manifest (same source the render path uses, so the boot-race doesn't
107
- # silence FR100 contract checks in dev) and FAILS OPEN to nil when the
108
- # manifest is unresolvable (contract validation is opt-in/fail-open;
109
- # the render path surfaces the clear error). In prod this is the
110
- # boot-loaded Ruact.manifest, unchanged.
111
- registry = ManifestResolver.resolve_soft if registry == :default
112
- pairs = ComponentAttributes.parse(attrs_string)
113
- validate_contract(registry, component_name, pairs.map(&:first),
114
- at: { file: identifier, line: line, snippet: match.strip })
115
- props_ruby = pairs.map { |name, expr| "#{name.inspect} => #{expr}" }.join(", ")
116
- props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
117
- %(<%= __ruact_component__(#{component_name.inspect}, #{props_hash}) %>)
118
- rescue ComponentContractError
119
- # Already carries file:line + offending prop + suggestion — re-raise
120
- # AS-IS (do NOT append the generic "at line N: snippet" tail).
121
- raise
122
- rescue PreprocessorError => e
123
- raise PreprocessorError, "#{e.message} at line #{line}: #{match.strip}"
99
+ # Step 2: <Suspense> paired tags → <ruact-suspense> HTML elements.
100
+ result
101
+ .gsub(SUSPENSE_OPEN_RE) do
102
+ attrs = ::Regexp.last_match(1)
103
+ fallback = extract_string_attr(attrs, "fallback") || ""
104
+ escaped = fallback.gsub('"', "&quot;")
105
+ # Optional `delay="2.5"` — the server-side wait (seconds) before
106
+ # the deferred chunk streams. Forwarded to SuspenseElement#delay.
107
+ delay = extract_string_attr(attrs, "delay")
108
+ delay_attr = delay ? %( data-ruact-delay="#{delay.gsub('"', '&quot;')}") : ""
109
+ %(<ruact-suspense data-ruact-fallback="#{escaped}"#{delay_attr}>)
124
110
  end
125
- end
111
+ .gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
126
112
  end
127
113
 
128
114
  private
129
115
 
130
- # Story 15.2 (FR106) — raise a loud, self-contained {ChildrenNotSupportedError}
131
- # on the FIRST PascalCase component tag used with children (a matching closing
132
- # tag). +source+ is the ORIGINAL template text; +identifier+ is the template
133
- # path (from {ErbPreprocessorHook}). Suspense — the one legitimate paired
134
- # PascalCase tag — is masked newline-for-newline first, so it never trips AND
135
- # every byte position (hence every reported line) still lines up with the raw
136
- # template. The line uses the same idiom as Step 2 (`count("\n") + 1`) on the
137
- # OPENING tag's offset. Message mirrors {ComponentContract.raise_error} shape
138
- # so both loud preprocess errors read identically. A no-op when no pair is
139
- # present — the fast path stays byte-identical.
140
- def detect_children!(source, identifier)
141
- # Fast path: a children pair REQUIRES a literal PascalCase closing tag, so
142
- # a source without one (the common all-self-closing case) can never trip —
143
- # bail before allocating anything. `match?` builds no MatchData, and this
144
- # skips the mask/scan copies entirely, keeping the hot render/preprocess
145
- # path's allocation profile flat (the benchmark renders only self-closing
146
- # components, so it must stay at baseline).
147
- return unless source.match?(CLOSING_TAG_PROBE_RE)
148
-
149
- # ERB islands first, then Suspense — both blank their text newline-for-
150
- # newline so byte offsets (hence reported lines) still match the raw
151
- # template, while neither ERB string text nor the legitimate Suspense pair
152
- # can be seen by the tag scan.
153
- scan = mask_suspense(mask_erb(source))
154
- # PER-NAME open stacks (name → [offsets]) so a closing tag checks for a
155
- # matching open in O(1) via `open_ats[name].last`, keeping the whole scan
156
- # linear even under thousands of stray/unmatched PascalCase closing tags
157
- # (a global stack + `rindex` was quadratic — Codex Round 3).
158
- open_ats = Hash.new { |h, k| h[k] = [] }
116
+ # The common path: no PascalCase closing tag anywhere, so every component
117
+ # tag should be self-closing. One gsub, no masking copies — the render
118
+ # benchmark's allocation profile depends on it. An opening tag that is not
119
+ # self-closing hands the whole template to {#rewrite_paired}, which masks
120
+ # ERB: `<%# wrap in <Card> later %>` is a comment, not an unclosed tag.
121
+ # So does any tag inside an ERB island (a commented-out component): the
122
+ # paired path leaves those alone.
123
+ def rewrite_self_closing(source)
124
+ catch(:open_tag) do
125
+ return source.gsub(COMPONENT_TAG_RE) do |match|
126
+ name = ::Regexp.last_match(1)
127
+ next match if name == "Suspense"
128
+
129
+ throw :open_tag unless match.end_with?("/>")
130
+ throw :open_tag if inside_erb?(source, ::Regexp.last_match.begin(0))
131
+
132
+ placeholder(name, ::Regexp.last_match(2).to_s.strip, match,
133
+ line_at(source, ::Regexp.last_match.begin(0)), :self_closing)
134
+ end
135
+ end
136
+ rewrite_paired(source)
137
+ end
138
+
139
+ # Story 18-2 — pair opening and closing tags with one stack over the
140
+ # template with ERB islands and Suspense blanked (offsets preserved, so
141
+ # every line reported is the template's own), then rewrite each tag in
142
+ # place: a paired opening becomes {ViewHelper#__ruact_component_open__}'s
143
+ # wrapper, its closing `</ruact-component>`, a self-closing tag the usual
144
+ # placeholder. Tags inside ERB text are left alone.
145
+ def rewrite_paired(source)
146
+ scan = mask_suspense(mask_erb(source))
147
+ tags = []
148
+ stack = []
149
+ paired = {}
159
150
 
160
151
  scan.scan(COMPONENT_ANY_TAG_RE) do
161
- m = ::Regexp.last_match
162
- name = m[2]
152
+ m = ::Regexp.last_match
153
+ tag = { name: m[2], from: m.begin(0), to: m.end(0) }
154
+ tags << tag
163
155
 
164
156
  if m[1] # a closing tag `</Name>`
165
- at = open_ats[name].last
166
- next unless at # stray close with no open → literal text, ignore
167
-
168
- raise_children_error(name, identifier, scan, at)
169
- elsif m[0].end_with?("/>") # self-closing → carries no children
170
- next
171
- else # an opening tag `<Name ...>` — record the NEAREST open of this name
172
- open_ats[name] << m.begin(0)
157
+ tag[:closing] = true
158
+ open = stack.pop
159
+ raise_stray(tag[:name], line_at(source, tag[:from])) if open.nil?
160
+ # `<Card><Box></Card>`: the tag left open is the one to fix.
161
+ raise_open_tag(source, open) unless open[:name] == tag[:name]
162
+
163
+ # The value says whether anything sits between the tags: an empty
164
+ # pair passes no children, like `<Card />`. Read from the template
165
+ # itself — ERB between the tags (`<%= @body %>`) is content, an ERB
166
+ # comment is not.
167
+ paired[open[:from]] = !source[open[:to]...tag[:from]].gsub(ERB_COMMENT_RE, "").strip.empty?
168
+ elsif !m[0].end_with?("/>")
169
+ stack << tag
173
170
  end
174
171
  end
172
+ raise_open_tag(source, stack.first) unless stack.empty?
173
+
174
+ splice(source, tags, paired)
175
+ end
176
+
177
+ # +source+ with each tag replaced by its placeholder. +paired+ maps an
178
+ # opening tag's offset to whether its pair holds anything.
179
+ def splice(source, tags, paired)
180
+ out = +""
181
+ cursor = 0
182
+ tags.each do |tag|
183
+ out << source[cursor...tag[:from]]
184
+ out << rewrite_tag(source, tag, paired)
185
+ cursor = tag[:to]
186
+ end
187
+ out << source[cursor..]
188
+ end
189
+
190
+ def rewrite_tag(source, tag, paired)
191
+ return "</#{WRAPPER_TAG}>" if tag[:closing]
192
+
193
+ kind = if !paired.key?(tag[:from]) then :self_closing
194
+ elsif paired[tag[:from]] then :with_children
195
+ else :empty_pair
196
+ end
197
+ text = source[tag[:from]...tag[:to]]
198
+ placeholder(tag[:name], text.match(COMPONENT_TAG_RE)[2].to_s.strip, text, line_at(source, tag[:from]), kind)
199
+ end
200
+
201
+ # The ERB placeholder for one component tag. Props are parsed (and the
202
+ # opt-in contract checked) here; a pair with content passes `children` too.
203
+ # +kind+ is :self_closing, :empty_pair or :with_children.
204
+ def placeholder(name, attrs_string, match, line, kind)
205
+ children = kind == :with_children
206
+ # lazy — only when a tag exists. `resolve_soft` returns the dev-fetched
207
+ # manifest (same source the render path uses, so the boot-race doesn't
208
+ # silence FR100 contract checks in dev) and FAILS OPEN to nil when the
209
+ # manifest is unresolvable (contract validation is opt-in/fail-open; the
210
+ # render path surfaces the clear error). In prod this is the boot-loaded
211
+ # Ruact.manifest, unchanged.
212
+ @registry = ManifestResolver.resolve_soft if @registry == :default
213
+ pairs = ComponentAttributes.parse(attrs_string)
214
+ names = pairs.map(&:first)
215
+ raise_children_twice(name, line) if children && names.include?("children")
216
+
217
+ check_known!(name, line)
218
+ validate_contract(@registry, name, children ? names + ["children"] : names,
219
+ at: { file: @identifier, line: line, snippet: match.strip })
220
+ props_ruby = pairs.map { |prop, expr| "#{prop.inspect} => #{expr}" }.join(", ")
221
+ props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
222
+ helper = kind == :self_closing ? "__ruact_component__" : "__ruact_component_open__"
223
+ %(<%= #{helper}(#{name.inspect}, #{props_hash}) %>)
224
+ rescue ComponentContractError, ComponentTagError, UnknownComponentError
225
+ # Already carry file:line and the fix — re-raise AS-IS (do NOT append the
226
+ # generic "at line N: snippet" tail).
227
+ raise
228
+ rescue PreprocessorError => e
229
+ raise PreprocessorError, "#{e.message} at line #{line}: #{match.strip}"
230
+ end
231
+
232
+ # Story 18-2 — a component the manifest does not have fails here, with the
233
+ # template's file:line, instead of at render with no location. Skipped
234
+ # when there is no manifest to ask (fail open, like the contract check).
235
+ def check_known!(name, line)
236
+ return unless @registry.respond_to?(:component?)
237
+ return if @registry.component?(name)
238
+
239
+ message = @registry.unknown_component_message(name, controller_path: controller_path_from(@identifier))
240
+ # With a close name on offer it is a typo; "did you run the Vite build?"
241
+ # would send the reader the wrong way.
242
+ message = message.lines.grep_v(/Did you run the Vite build/).join.rstrip if message.include?('Did you mean "')
243
+ raise UnknownComponentError, message.sub(/\Aruact: /, "ruact: <#{name}> at #{location(line)} — ")
244
+ end
245
+
246
+ # Whether +offset+ sits inside an ERB island (`<% … %>`): the nearest `<%`
247
+ # before it closes after it. One search back, one short search forward; no
248
+ # allocation.
249
+ def inside_erb?(source, offset)
250
+ opened = source.rindex("<%", offset)
251
+ return false unless opened
252
+
253
+ closed = source.index("%>", opened + 2)
254
+ closed.nil? || closed + 2 > offset
255
+ end
256
+
257
+ def line_at(source, offset)
258
+ source[0...offset].count("\n") + 1
259
+ end
260
+
261
+ def location(line)
262
+ @identifier ? "#{@identifier}:#{line}" : "line #{line}"
263
+ end
264
+
265
+ def raise_open_tag(source, tag)
266
+ text = source[tag[:from]...tag[:to]]
267
+ raise_unclosed(tag[:name], line_at(source, tag[:from]),
268
+ attrs: text.match(COMPONENT_TAG_RE)[2].to_s.strip, snippet: text)
269
+ end
270
+
271
+ # A tag that looks open may only be a `>` inside an attribute value
272
+ # (`title="a > b"`, `title="<%= @t %>"`) cutting the tag short; the
273
+ # attribute's own error says that, so it goes first.
274
+ def raise_unclosed(name, line, attrs:, snippet:)
275
+ begin
276
+ ComponentAttributes.parse(attrs)
277
+ rescue PreprocessorError => e
278
+ raise PreprocessorError, "#{e.message} at line #{line}: #{snippet.strip}"
279
+ end
280
+ raise ComponentTagError,
281
+ "ruact: <#{name}> at #{location(line)} is never closed — write `<#{name} ... />` for a " \
282
+ "component without children, or close it with `</#{name}>`."
283
+ end
175
284
 
176
- nil
285
+ def raise_stray(name, line)
286
+ raise ComponentTagError,
287
+ "ruact: </#{name}> at #{location(line)} closes nothing — there is no open <#{name}> before it."
177
288
  end
178
289
 
179
- # Raise the self-contained {ChildrenNotSupportedError}. +at+ is the OPENING
180
- # tag's byte offset in +scan+ (position-faithful to the raw source), so the
181
- # line uses the same idiom as Step 2. Message mirrors
182
- # {ComponentContract.raise_error} so both loud preprocess errors read alike.
183
- def raise_children_error(component, identifier, scan, at)
184
- line = scan[0...at].count("\n") + 1
185
- location = [identifier, line].compact.join(":")
186
- location = "(unknown location)" if location.empty?
187
- raise ChildrenNotSupportedError,
188
- "ruact: <#{component}> at #{location} children are not supported " \
189
- "— pass content as a prop, e.g. `<#{component} content={...} />`."
290
+ def raise_children_twice(name, line)
291
+ raise ComponentTagError,
292
+ "ruact: <#{name}> at #{location(line)} has content between its tags and a `children={...}` " \
293
+ "prop — pass one of them."
190
294
  end
191
295
 
192
- # Blank out Suspense open/close tags (the one legitimate paired PascalCase
193
- # tag) while preserving EVERY newline and byte offset, so the loud-children
194
- # scan neither trips on `<Suspense>...</Suspense>` nor mis-reports a line when
195
- # a multi-line Suspense opening precedes the offending tag. Non-newline chars
196
- # → spaces (same length); newlines kept verbatim.
296
+ # Blank out Suspense open/close tags while preserving EVERY newline and
297
+ # byte offset, so the pairing scan neither sees `<Suspense>...</Suspense>`
298
+ # nor mis-reports a line after a multi-line Suspense opening. Non-newline
299
+ # chars → spaces (same length); newlines kept verbatim.
197
300
  def mask_suspense(source)
198
301
  source
199
302
  .gsub(SUSPENSE_OPEN_RE) { |m| m.gsub(/[^\n]/, " ") }
@@ -202,7 +305,7 @@ module Ruact
202
305
 
203
306
  # Blank ERB islands (position-faithful, see {mask_suspense}) so component
204
307
  # tags that appear only inside Ruby/ERB string or comment text are invisible
205
- # to the loud-children tag scan.
308
+ # to the pairing scan.
206
309
  def mask_erb(source)
207
310
  source.gsub(ERB_ISLAND_RE) { |m| m.gsub(/[^\n]/, " ") }
208
311
  end
data/lib/ruact/errors.rb CHANGED
@@ -22,21 +22,21 @@ 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
25
+ # Story 18-2 — raised by {Ruact::ErbPreprocessor} at preprocess time when a
26
+ # component tag does not pair: an opening tag never closed (write `<Card />`
27
+ # or close it with `</Card>`), a closing tag that closes nothing, or content
28
+ # between the tags together with a `children=` prop. The message carries
29
+ # the tag, file:line and the fix, so the preprocessor re-raises it as is.
30
+ # Replaces Story 15.2's ChildrenNotSupportedError: components take children now.
31
+ class ComponentTagError < PreprocessorError; end
32
+
33
+ # Story 18-2 — raised by {Ruact::ErbPreprocessor} when a template names a
34
+ # client component the manifest does not have (a typo, a missing
35
+ # "use client"). The same message {ClientManifest#reference_for} raises at
36
+ # render — with the closest name — plus the template's file:line, because
37
+ # it is caught while the template compiles. Only when a manifest is
38
+ # available; without one the render path reports it, as before.
39
+ class UnknownComponentError < PreprocessorError; end
40
40
 
41
41
  # Raised when application code attempts to mutate Ruact::Configuration outside
42
42
  # of a Ruact.configure block. The configuration is frozen after initialization
@@ -14,17 +14,30 @@ module Ruact
14
14
  each(model, bundler_config, streaming: false, **).to_a.join
15
15
  end
16
16
 
17
- def self.each(model, bundler_config, strict_serialization: false, on_as_json_warning: nil,
18
- streaming: true, &)
19
- new(model, bundler_config,
20
- strict_serialization: strict_serialization,
21
- on_as_json_warning: on_as_json_warning).each(streaming: streaming, &)
17
+ # Keyword options other than +streaming:+ go to {#initialize}
18
+ # (strict_serialization:, on_as_json_warning:, development:).
19
+ def self.each(model, bundler_config, streaming: true, **, &)
20
+ new(model, bundler_config, **).each(streaming: streaming, &)
22
21
  end
23
22
 
24
- def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil)
23
+ # Whether rows carry the slots React's development client reads (see
24
+ # Serializer#element_tuple): in Rails' development environment only. The
25
+ # test environment keeps the production wire, so what a request spec
26
+ # asserts is what production sends.
27
+ def self.development_default
28
+ return false unless defined?(Rails) && Rails.respond_to?(:env)
29
+
30
+ Rails.env.development?
31
+ rescue StandardError
32
+ false
33
+ end
34
+
35
+ def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil,
36
+ development: false)
25
37
  @request = Request.new(model, bundler_config,
26
38
  strict_serialization: strict_serialization,
27
- on_as_json_warning: on_as_json_warning)
39
+ on_as_json_warning: on_as_json_warning,
40
+ development: development)
28
41
  end
29
42
 
30
43
  # Yields Flight rows one at a time.
@@ -50,7 +63,8 @@ module Ruact
50
63
  if streaming && deferred[:delay]&.positive?
51
64
  timeout = Ruact.config.suspense_timeout
52
65
  if timeout&.positive? && deferred[:delay] > timeout
53
- yield RowEmitter.error(deferred[:id], JSON.generate("Suspense timeout exceeded"))
66
+ error = RowEmitter.error_payload("Suspense timeout exceeded", digest: "ruact:suspense-timeout")
67
+ yield RowEmitter.error(deferred[:id], JSON.generate(error))
54
68
  next
55
69
  end
56
70
  sleep(deferred[:delay])
@@ -58,7 +72,7 @@ module Ruact
58
72
 
59
73
  # Serialize deferred content — may produce new import rows, and new
60
74
  # regular rows (a string of 1024+ bytes becomes a `T` row the deferred
61
- # model row references as `$T<id>`).
75
+ # model row references as `$<id>`).
62
76
  import_count_before = @request.completed_import_chunks.length
63
77
  regular_count_before = @request.completed_regular_chunks.length
64
78
  deferred_value = serializer.serialize_model(deferred[:element])
@@ -16,11 +16,15 @@ module Ruact
16
16
  # object_id => "$L<hex>" reference (dedup)
17
17
  attr_reader :written_objects
18
18
  attr_reader :next_chunk_id, :pending_chunks, :bundler_config, :root_model,
19
- :strict_serialization, :on_as_json_warning
19
+ :strict_serialization, :on_as_json_warning, :development
20
20
 
21
- def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil)
21
+ def initialize(model, bundler_config, strict_serialization: false, on_as_json_warning: nil,
22
+ development: false)
22
23
  @strict_serialization = strict_serialization
23
24
  @on_as_json_warning = on_as_json_warning
25
+ @development = development
26
+ @suspense_symbol_ref = nil
27
+ @boundary_ref = nil
24
28
  @next_chunk_id = 0
25
29
  @pending_chunks = 0
26
30
  @bundler_config = bundler_config
@@ -42,6 +46,31 @@ module Ruact
42
46
  id
43
47
  end
44
48
 
49
+ # The `"$<hex>"` reference to this response's `"$Sreact.suspense"` symbol
50
+ # row, emitted once, before the first row that uses it.
51
+ def suspense_symbol_ref
52
+ @suspense_symbol_ref ||= begin
53
+ id = allocate_id
54
+ @completed_regular_chunks << RowEmitter.model(id, JSON.generate("$Sreact.suspense"))
55
+ "$#{id.to_s(16)}"
56
+ end
57
+ end
58
+
59
+ # The runtime's own client component that catches an error in a Suspense
60
+ # child (runtime/suspense-boundary.js). Not in the app's manifest: the
61
+ # runtime registers it under this id.
62
+ BOUNDARY_METADATA = ["ruact:boundary", [], "SuspenseBoundary"].freeze
63
+
64
+ # The `"$L<hex>"` reference to {BOUNDARY_METADATA}'s import row, emitted
65
+ # once per response.
66
+ def boundary_ref
67
+ @boundary_ref ||= begin
68
+ id = allocate_id
69
+ @completed_import_chunks << RowEmitter.import(id, JSON.generate(BOUNDARY_METADATA))
70
+ "$L#{id.to_s(16)}"
71
+ end
72
+ end
73
+
45
74
  def increment_pending
46
75
  @pending_chunks += 1
47
76
  end
@@ -22,6 +22,15 @@ module Ruact
22
22
  "#{id.to_s(16)}:E#{error_json}\n"
23
23
  end
24
24
 
25
+ # The error object React's Flight client reads from an E row. Its
26
+ # development build reads `stack` and `env`; a bare string crashes it.
27
+ # Its production build drops the message and keeps `digest`, so the
28
+ # digest is a stable code the runtime turns back into the message
29
+ # (runtime/suspense-boundary.js, DIGEST_MESSAGES).
30
+ def self.error_payload(message, digest:)
31
+ { "digest" => digest, "name" => "Error", "message" => message, "stack" => [], "env" => "Server" }
32
+ end
33
+
25
34
  # A large text row (binary framing, no trailing newline)
26
35
  def self.text(id, text)
27
36
  byte_length = text.bytesize