dommy 0.12.0 → 0.13.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.
@@ -17,10 +17,12 @@ module Dommy
17
17
  # in the document (e.g. a Nuxt entry module placed above the inline
18
18
  # `window.__NUXT__ = {...}` bootstrap it depends on). Running everything in
19
19
  # one document-order pass would execute the module against half-initialized
20
- # globals. A failed fetch or a throwing script is isolated; `on_error` is
21
- # notified (the Browser collects it for strict mode, the Capybara adapter
22
- # ignores it) so the rest of the page still loads. Shared by `Dommy::Browser`
23
- # and the Capybara driver so script boot lives in one place.
20
+ # globals. A failed fetch or a throwing script is isolated so the rest of the
21
+ # page still loads: a throwing script's exception is REPORTED at the window
22
+ # (WHATWG "report an exception", so `window.onerror` sees it and the host
23
+ # hears only what the page left unhandled), while `on_error` stays the
24
+ # fallback for a windowless document. Shared by `Dommy::Browser` and the
25
+ # Capybara driver so script boot lives in one place.
24
26
  #
25
27
  # The module is the stable entry point; the work lives on ScriptBooter, a
26
28
  # short-lived instance that holds the runtime / document / resources /
@@ -145,24 +147,66 @@ module Dommy
145
147
  ScriptBoot.wire_inline_handlers(@runtime, on_error: @on_error)
146
148
  end
147
149
 
148
- # Fetch + run a dynamically-inserted external script, then fire `load` (or
149
- # `error` if the fetch failed / it threw) so a loader awaiting the script
150
- # element's onload resolves. The src was already taken from the element by
151
- # the mutation coordinator, so this does not re-consume pending state.
150
+ # Fetch + run a dynamically-inserted external script, then fire the event a
151
+ # loader awaiting the element is listening for. The src was already taken
152
+ # from the element by the mutation coordinator, so this does not re-consume
153
+ # pending state.
154
+ #
155
+ # `error` means the FETCH failed — that is the only thing the element's
156
+ # event reports. A script that downloaded fine and then threw still fires
157
+ # `load`, because the load succeeded; its exception is reported at the
158
+ # global instead. Conflating the two tells a chunk loader (webpack, Vite)
159
+ # that the network failed, so it retries or gives up on a chunk that is
160
+ # sitting right there.
152
161
  def run_inserted_external(element, src)
153
- ran = false
154
- if @resources && (url = resolve_url(src)) && (response = @resources.get(url)) && response.success?
162
+ url = resolve_url(src) if @resources
163
+ response = (@resources.get(url) if url)
164
+ return dispatch_script_event(element, "error") unless response&.success?
165
+
166
+ begin
155
167
  with_current_script(element) { @runtime.load_script_cached(response.body, cache_key: url) }
156
- ran = true
168
+ rescue StandardError => e
169
+ report_exception(e)
157
170
  end
158
- dispatch_script_event(element, ran ? "load" : "error")
171
+ dispatch_script_event(element, "load")
159
172
  rescue StandardError => e
160
- @on_error&.call(e)
173
+ # The fetch itself blew up (a resources adapter raising, not a page
174
+ # exception): nothing loaded, so the element reports a failure.
175
+ report_exception(e)
161
176
  dispatch_script_event(element, "error")
162
177
  end
163
178
 
164
179
  private
165
180
 
181
+ # WHATWG "report an exception" for a script whose EVALUATION threw: the
182
+ # exception belongs to the page, so it is reported at the global (firing
183
+ # `window.onerror`, which a page's error reporting listens on) rather than
184
+ # handed straight to the host. Only a report the page leaves unhandled
185
+ # reaches the host, through the window's unhandled-error seam.
186
+ #
187
+ # `on_error` is the fallback for a document with no window to report to
188
+ # (nothing else could hear it) and for the internal failures that are not
189
+ # page exceptions at all.
190
+ def report_exception(error)
191
+ window = (@document.default_view if @document.respond_to?(:default_view))
192
+ return @on_error&.call(error) unless window.respond_to?(:__internal_report_exception__)
193
+
194
+ Dommy::Internal::ExceptionReport.report_at(window, error, value: page_value_for(error))
195
+ end
196
+
197
+ # What the page should see as `event.error`. An engine that raises a host
198
+ # exception for a script\'s throw has already discarded the JS value, so
199
+ # there is nothing left to hand over: the page would get an opaque husk
200
+ # with no `message` or `stack`, and a handler reading either would itself
201
+ # throw — taking its `preventDefault()` down with it. A runtime that can
202
+ # rebuild an equivalent Error inside the realm gets to; nil falls back to
203
+ # whatever was caught.
204
+ def page_value_for(error)
205
+ return nil unless @runtime.respond_to?(:rebuild_error)
206
+
207
+ @runtime.rebuild_error(error)
208
+ end
209
+
166
210
  # Fire the script's load/error event ASYNCHRONOUSLY (a microtask), like a
167
211
  # real browser. Code commonly does `head.appendChild(s); s.onload = …`
168
212
  # (handlers set AFTER insertion), so a synchronous dispatch — during the
@@ -215,7 +259,7 @@ module Dommy
215
259
  def run_one(element)
216
260
  @on_script.call(element, nil) if run_pending(element) && @on_script
217
261
  rescue StandardError => e
218
- @on_error&.call(e)
262
+ report_exception(e)
219
263
  @on_script&.call(element, e)
220
264
  end
221
265
 
@@ -33,6 +33,14 @@ module Dommy
33
33
  JS_REF = "__rb_js_ref"
34
34
  # A human-readable label captured alongside a JS ref (for #to_s/#inspect).
35
35
  JS_LABEL = "__rb_js_label"
36
+ # The thrown value's JS stack, carried alongside a callback's throw so the
37
+ # host can report WHERE the page failed (see ErrorEvent's filename /
38
+ # lineno / colno). A JS value crosses as an opaque ref, so Ruby cannot
39
+ # read `.stack` off it afterwards; it has to come over with the throw.
40
+ JS_STACK = "__rb_js_stack"
41
+ # The value's JS constructor name ("TypeError"), so a host log can say what
42
+ # kind of thing it holds rather than only naming the Ruby wrapper.
43
+ JS_NAME = "__rb_js_name"
36
44
  # Marks a JS ref that implements the EventListener interface (handleEvent).
37
45
  HANDLE_EVENT = "__rb_handle_event"
38
46
  # Marks a JS ref that implements the NodeFilter interface (acceptNode).
@@ -133,6 +133,13 @@ module Dommy
133
133
  # regardless, so this only affects the no-op default.
134
134
  def __internal_navigate_to__(raw, source:, replace: false, sync_cross_doc: true)
135
135
  target = resolve(raw)
136
+ if target.nil?
137
+ # A URL the parser rejects: the Location API throws, following a
138
+ # hyperlink to one does nothing.
139
+ raise DOMException::SyntaxError, "#{raw.inspect} is not a valid URL" if source == :location
140
+
141
+ return
142
+ end
136
143
  if same_document?(href, target)
137
144
  __internal_set_url__(raw)
138
145
  else
@@ -143,11 +150,14 @@ module Dommy
143
150
 
144
151
  private
145
152
 
146
- # Resolve a possibly-relative URL against the current full URL.
153
+ # Resolve a possibly-relative URL against the current full URL with the
154
+ # URL parser; nil when it fails.
147
155
  def resolve(raw)
148
- URI.join(href, raw).to_s
149
- rescue URI::InvalidURIError, ArgumentError
150
- raw
156
+ base = href
157
+ base = nil unless Internal::UrlParser.parse(base) rescue nil
158
+ Internal::UrlParser.serialize(Internal::UrlParser.parse(raw, base))
159
+ rescue Internal::UrlParser::Failure
160
+ nil
151
161
  end
152
162
 
153
163
  # Two URLs address the same document when everything but the fragment matches.
@@ -49,7 +49,9 @@ module Dommy
49
49
  # `navigator.sendBeacon(url, data)` — fire-and-forget POST used for analytics.
50
50
  # Dommy does not emit the request (no background egress); it just reports
51
51
  # success so callers that feature-detect `sendBeacon` take the beacon path.
52
- def send_beacon(_url, _data = nil)
52
+ def send_beacon(url, _data = nil)
53
+ raise Bridge::TypeError, "Invalid URL #{url.to_s.inspect}" if @window.__internal_parse_url__(url).nil?
54
+
53
55
  true
54
56
  end
55
57
 
data/lib/dommy/promise.rb CHANGED
@@ -100,6 +100,10 @@ module Dommy
100
100
  end
101
101
  end
102
102
 
103
+ def settled?
104
+ @state != :pending
105
+ end
106
+
103
107
  private
104
108
 
105
109
  # Drive the event loop over the work that is ready NOW until this promise
@@ -202,10 +206,6 @@ module Dommy
202
206
  self
203
207
  end
204
208
 
205
- def settled?
206
- @state != :pending
207
- end
208
-
209
209
  def schedule_flush
210
210
  @window.scheduler.queue_microtask(proc { flush_handlers })
211
211
  nil
@@ -86,6 +86,14 @@ module Dommy
86
86
  # all — vanilla CRuby use) re-raises, so genuine host bugs still surface.
87
87
  attr_accessor :timer_error_handler
88
88
 
89
+ # WHATWG "report an exception" for a task, supplied by the Window that owns
90
+ # this scheduler. A timer / rAF callback that throws is the page's bug, not
91
+ # the host's: the exception is reported at the global (firing
92
+ # `window.onerror`) and the event loop carries on with the remaining tasks,
93
+ # rather than tearing down whatever Ruby drove the clock. Absent in vanilla
94
+ # CRuby use, where a throwing callback keeps propagating.
95
+ attr_accessor :exception_reporter
96
+
89
97
  # An optional hook (set by a JS runtime) that drains the ENGINE's microtask
90
98
  # (promise-job) queue — the other half of a microtask checkpoint. The
91
99
  # scheduler owns only its own `@microtasks`; the real microtask queue lives
@@ -284,18 +292,28 @@ module Dommy
284
292
  end
285
293
 
286
294
 
287
- # Run a single timer's callback. A raised error is offered to
288
- # `timer_error_handler` (set by the JS runtime); if it swallows the error the
289
- # timer is dropped (so a runaway interval cannot re-stall every tick) and
290
- # browsing continues. With no handler — or one that declines — the error
291
- # propagates, preserving the default crash-on-bug behavior.
295
+ # Run a single timer's callback. The callback is invoked in its RAISING form
296
+ # so a JS throw surfaces here instead of being swallowed by the bridge —
297
+ # WHATWG reports a task's exception at the global and keeps the event loop
298
+ # running, which a silently dropped throw cannot do. The raised error is then
299
+ # offered to `timer_error_handler` (set by the JS runtime); if it swallows the
300
+ # error the timer is dropped (so a runaway interval cannot re-stall every
301
+ # tick) and browsing continues. With no handler — or one that declines — the
302
+ # error propagates, preserving the default crash-on-bug behavior for a host
303
+ # bug, which is not a page exception and must not be reported as one.
292
304
  def invoke_timer(timer, *args)
293
305
  # Run the callback at this timer's nesting level, so a timer it schedules
294
306
  # nests one deeper (driving the 4ms clamp). Restored even if it throws.
295
307
  prev_nesting = @nesting_level
296
308
  @nesting_level = timer.nesting || 0
297
- CallableInvoker.invoke(timer.callback, *args)
309
+ CallableInvoker.invoke_raising(timer.callback, *args)
298
310
  rescue StandardError => e
311
+ # A page exception is reported and the timer keeps its schedule, like a
312
+ # browser: an interval whose callback throws goes on firing.
313
+ return nil if report_task_exception(e)
314
+ # Otherwise the runtime's handler decides. It swallows the errors it had to
315
+ # kill (an execution-timeout runaway, a poisoned VM), and those DO drop the
316
+ # timer so it cannot re-stall every tick. A declined error is a host bug.
299
317
  raise unless @timer_error_handler&.call(e, timer)
300
318
 
301
319
  timer.active = false
@@ -304,5 +322,16 @@ module Dommy
304
322
  ensure
305
323
  @nesting_level = prev_nesting
306
324
  end
325
+
326
+ # Report a task's exception at the global, per WHATWG. Only a `ThrowValue`
327
+ # qualifies: it is the one shape that unambiguously came out of page JS, so a
328
+ # host-side bug (a NoMethodError in Dommy itself) is never disguised as a
329
+ # page error — it falls through and propagates.
330
+ def report_task_exception(error)
331
+ return false unless error.is_a?(Bridge::ThrowValue) && @exception_reporter
332
+
333
+ @exception_reporter.call(error)
334
+ true
335
+ end
307
336
  end
308
337
  end