dommy 0.8.0 → 0.9.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.
- checksums.yaml +4 -4
- data/README.md +3 -3
- data/lib/dommy/animation.rb +4 -0
- data/lib/dommy/attr.rb +11 -5
- data/lib/dommy/backend/makiri_adapter.rb +330 -0
- data/lib/dommy/backend.rb +114 -33
- data/lib/dommy/blob.rb +2 -0
- data/lib/dommy/bridge.rb +11 -0
- data/lib/dommy/browser.rb +217 -0
- data/lib/dommy/compression_streams.rb +4 -0
- data/lib/dommy/crypto.rb +4 -0
- data/lib/dommy/css.rb +487 -50
- data/lib/dommy/custom_elements.rb +2 -2
- data/lib/dommy/data_transfer.rb +2 -0
- data/lib/dommy/data_uri.rb +35 -0
- data/lib/dommy/deferred_response.rb +59 -0
- data/lib/dommy/document.rb +386 -228
- data/lib/dommy/dom_exception.rb +2 -0
- data/lib/dommy/dom_parser.rb +7 -17
- data/lib/dommy/element.rb +502 -155
- data/lib/dommy/event.rb +240 -9
- data/lib/dommy/fetch.rb +152 -34
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +2 -0
- data/lib/dommy/html_canvas_element.rb +230 -0
- data/lib/dommy/html_collection.rb +5 -6
- data/lib/dommy/html_elements.rb +304 -27
- data/lib/dommy/interaction/debug.rb +35 -0
- data/lib/dommy/interaction/dom_summary.rb +131 -0
- data/lib/dommy/interaction/driver.rb +244 -0
- data/lib/dommy/interaction/event_synthesis.rb +56 -0
- data/lib/dommy/interaction/field_interactor.rb +117 -0
- data/lib/dommy/interaction/form_submission.rb +268 -0
- data/lib/dommy/interaction/locator.rb +158 -0
- data/lib/dommy/interaction/role_query.rb +58 -0
- data/lib/dommy/interaction.rb +32 -0
- data/lib/dommy/internal/accessibility_tree.rb +215 -0
- data/lib/dommy/internal/accessible_description.rb +38 -0
- data/lib/dommy/internal/accessible_name.rb +301 -0
- data/lib/dommy/internal/aria_role.rb +252 -0
- data/lib/dommy/internal/aria_snapshot.rb +64 -0
- data/lib/dommy/internal/aria_state.rb +151 -0
- data/lib/dommy/internal/css/calc.rb +242 -0
- data/lib/dommy/internal/css/cascade.rb +430 -0
- data/lib/dommy/internal/css/color.rb +381 -0
- data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
- data/lib/dommy/internal/css/counters.rb +227 -0
- data/lib/dommy/internal/css/custom_properties.rb +183 -0
- data/lib/dommy/internal/css/media_query.rb +302 -0
- data/lib/dommy/internal/css/parser.rb +265 -0
- data/lib/dommy/internal/css/property_registry.rb +512 -0
- data/lib/dommy/internal/css/rule_index.rb +494 -0
- data/lib/dommy/internal/css/supports.rb +158 -0
- data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
- data/lib/dommy/internal/css_rule_text.rb +160 -0
- data/lib/dommy/internal/dom_matching.rb +80 -9
- data/lib/dommy/internal/element_matching.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +33 -0
- data/lib/dommy/internal/mutation_coordinator.rb +95 -4
- data/lib/dommy/internal/namespaces.rb +49 -5
- data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
- data/lib/dommy/internal/parent_node.rb +82 -5
- data/lib/dommy/internal/selector_ast.rb +124 -0
- data/lib/dommy/internal/selector_index.rb +146 -0
- data/lib/dommy/internal/selector_matcher.rb +756 -0
- data/lib/dommy/internal/selector_parser.rb +283 -131
- data/lib/dommy/internal/shadow_root_registry.rb +9 -2
- data/lib/dommy/internal/template_content_registry.rb +26 -18
- data/lib/dommy/internal/xml_serialization.rb +344 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/js/bridge_conformance.rb +80 -0
- data/lib/dommy/js/constructor_resolver.rb +44 -0
- data/lib/dommy/js/custom_element_bridge.rb +90 -0
- data/lib/dommy/js/dom_interfaces.rb +162 -0
- data/lib/dommy/js/handle_table.rb +60 -0
- data/lib/dommy/js/host_bridge.rb +517 -0
- data/lib/dommy/js/host_runtime.js +1495 -0
- data/lib/dommy/js/import_map.rb +58 -0
- data/lib/dommy/js/marshaller.rb +240 -0
- data/lib/dommy/js/module_loader.rb +99 -0
- data/lib/dommy/js/observable_runtime.js +742 -0
- data/lib/dommy/js/runtime.rb +115 -0
- data/lib/dommy/js/script_boot.rb +221 -0
- data/lib/dommy/js/wire_tags.rb +62 -0
- data/lib/dommy/location.rb +2 -0
- data/lib/dommy/media_query_list.rb +50 -14
- data/lib/dommy/message_channel.rb +22 -6
- data/lib/dommy/minitest/assertions.rb +27 -0
- data/lib/dommy/mutation_observer.rb +89 -4
- data/lib/dommy/navigator.rb +34 -2
- data/lib/dommy/node.rb +24 -14
- data/lib/dommy/notification.rb +2 -0
- data/lib/dommy/parser.rb +1 -1
- data/lib/dommy/performance.rb +21 -1
- data/lib/dommy/promise.rb +94 -10
- data/lib/dommy/range.rb +173 -31
- data/lib/dommy/resources.rb +178 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
- data/lib/dommy/scheduler.rb +149 -13
- data/lib/dommy/screen.rb +91 -0
- data/lib/dommy/shadow_root.rb +76 -13
- data/lib/dommy/storage.rb +2 -1
- data/lib/dommy/streams.rb +6 -0
- data/lib/dommy/text_codec.rb +7 -1
- data/lib/dommy/tree_walker.rb +33 -10
- data/lib/dommy/url.rb +13 -1
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +199 -11
- data/lib/dommy/worker.rb +8 -4
- data/lib/dommy/xml_http_request.rb +47 -6
- data/lib/dommy.rb +36 -1
- metadata +96 -10
- data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
- data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
data/lib/dommy/event.rb
CHANGED
|
@@ -130,7 +130,11 @@ module Dommy
|
|
|
130
130
|
end
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
if entry.passive?
|
|
134
|
+
event.__internal_run_passive__ { invoke_listener_isolated(entry.listener, event) }
|
|
135
|
+
else
|
|
136
|
+
invoke_listener_isolated(entry.listener, event)
|
|
137
|
+
end
|
|
134
138
|
|
|
135
139
|
break if event.immediate_propagation_stopped?
|
|
136
140
|
end
|
|
@@ -138,6 +142,36 @@ module Dommy
|
|
|
138
142
|
nil
|
|
139
143
|
end
|
|
140
144
|
|
|
145
|
+
# Run one listener, isolating a throw so it can't escape the dispatch.
|
|
146
|
+
# WHATWG "inner invoke": if a listener's callback throws, the exception is
|
|
147
|
+
# *reported* (to the global error handler) and event dispatch continues with
|
|
148
|
+
# the remaining listeners — a broken handler must not derail the others or
|
|
149
|
+
# abort whatever Ruby drove the dispatch (a synthetic click from the host).
|
|
150
|
+
# The engine already swallows this for a JS-initiated dispatchEvent
|
|
151
|
+
# (HostBridge#invoke_callback, raising:false), but that has proven
|
|
152
|
+
# engine/Ruby-version-dependent — on some QuickJS/Ruby combinations a JS
|
|
153
|
+
# listener's throw surfaces as a Ruby exception here — so guard the
|
|
154
|
+
# host-initiated path too, mirroring MutationObserver#flush.
|
|
155
|
+
def invoke_listener_isolated(listener, event)
|
|
156
|
+
CallableInvoker.invoke_listener(listener, event)
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
__dommy_dump_event_failure__(event, listener, e) if ENV["DOMMY_EVENT_DEBUG"]
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Diagnostic only (DOMMY_EVENT_DEBUG=<file>): when a listener throws, append
|
|
163
|
+
# the event type + error to a log file so an otherwise-unreproducible page
|
|
164
|
+
# failure can be traced to the handler that choked.
|
|
165
|
+
def __dommy_dump_event_failure__(event, listener, error)
|
|
166
|
+
path = ENV["DOMMY_EVENT_DEBUG"]
|
|
167
|
+
line = "=== event listener raised on #{event.type}: #{error.class}: #{error.message.to_s[0, 200]} " \
|
|
168
|
+
"(listener=#{listener.class})\n"
|
|
169
|
+
# `::File` — inside `module Dommy`, bare `File` is Dommy's DOM File.
|
|
170
|
+
::File.write(path, line, mode: "a")
|
|
171
|
+
rescue StandardError
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
141
175
|
# The next target up the propagation path. The default (no parent) suits
|
|
142
176
|
# EventTargets that aren't tree nodes (AbortSignal, XHR, …); Element /
|
|
143
177
|
# Document / ShadowRoot override it to walk the node tree.
|
|
@@ -163,6 +197,17 @@ module Dommy
|
|
|
163
197
|
def capture?
|
|
164
198
|
EventTarget.capture_flag(options)
|
|
165
199
|
end
|
|
200
|
+
|
|
201
|
+
# `{ passive: true }` — the listener promises not to call preventDefault,
|
|
202
|
+
# so the event's preventDefault() is neutralized while it runs.
|
|
203
|
+
def passive?
|
|
204
|
+
case options
|
|
205
|
+
when Hash
|
|
206
|
+
EventTarget.js_truthy?(options.key?("passive") ? options["passive"] : options[:passive])
|
|
207
|
+
else
|
|
208
|
+
false
|
|
209
|
+
end
|
|
210
|
+
end
|
|
166
211
|
end
|
|
167
212
|
|
|
168
213
|
# The capture flag for an addEventListener/removeEventListener options
|
|
@@ -184,6 +229,9 @@ module Dommy
|
|
|
184
229
|
return false if value.nil? || value == false
|
|
185
230
|
return false if defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED)
|
|
186
231
|
return false if value.is_a?(Numeric) && (value.zero? || (value.respond_to?(:nan?) && value.nan?))
|
|
232
|
+
# The bridge marshals JS NaN as the symbol :NaN (it has no Ruby Float
|
|
233
|
+
# equivalent that survives the round-trip), which ToBoolean treats as false.
|
|
234
|
+
return false if value == :NaN
|
|
187
235
|
return false if value == ""
|
|
188
236
|
|
|
189
237
|
true
|
|
@@ -286,6 +334,9 @@ module Dommy
|
|
|
286
334
|
@default_prevented = false
|
|
287
335
|
@propagation_stopped = false
|
|
288
336
|
@immediate_propagation_stopped = false
|
|
337
|
+
# Set while a passive listener runs, so preventDefault() is a no-op (per
|
|
338
|
+
# the passive listener flag in the DOM spec).
|
|
339
|
+
@in_passive_listener = false
|
|
289
340
|
@target = nil
|
|
290
341
|
@current_target = nil
|
|
291
342
|
@event_phase = NONE
|
|
@@ -322,6 +373,16 @@ module Dommy
|
|
|
322
373
|
@immediate_propagation_stopped
|
|
323
374
|
end
|
|
324
375
|
|
|
376
|
+
# Run a block with the passive-listener flag set, so any preventDefault()
|
|
377
|
+
# inside it is neutralized. Restores the prior flag afterward.
|
|
378
|
+
def __internal_run_passive__
|
|
379
|
+
previous = @in_passive_listener
|
|
380
|
+
@in_passive_listener = true
|
|
381
|
+
yield
|
|
382
|
+
ensure
|
|
383
|
+
@in_passive_listener = previous
|
|
384
|
+
end
|
|
385
|
+
|
|
325
386
|
def __internal_prepare_for_dispatch__(target)
|
|
326
387
|
@target ||= target
|
|
327
388
|
end
|
|
@@ -373,14 +434,23 @@ module Dommy
|
|
|
373
434
|
@time_stamp
|
|
374
435
|
when "cancelBubble"
|
|
375
436
|
@propagation_stopped
|
|
437
|
+
when "immediatePropagationStopped"
|
|
438
|
+
# Non-standard, but real frameworks feature-detect it: Stimulus's
|
|
439
|
+
# extendEvent does `"immediatePropagationStopped" in event` and, when
|
|
440
|
+
# present, reads the flag directly instead of installing its own
|
|
441
|
+
# tracking shim. Expose the live flag so that path stays correct (it
|
|
442
|
+
# also keeps `"immediatePropagationStopped" in event` from being a
|
|
443
|
+
# present-but-undefined no-op).
|
|
444
|
+
@immediate_propagation_stopped
|
|
376
445
|
when "eventPhase"
|
|
377
446
|
event_phase
|
|
378
447
|
else
|
|
379
|
-
# An unknown property
|
|
380
|
-
# non-dictionary member passed to the
|
|
381
|
-
# 1}).sweet`) is not reflected on the
|
|
382
|
-
# (target/currentTarget/…) are
|
|
383
|
-
|
|
448
|
+
# An unknown property is genuinely absent: JS `undefined` and
|
|
449
|
+
# `"x" in event` false — e.g. a non-dictionary member passed to the
|
|
450
|
+
# constructor (`new Event("x", {sweet: 1}).sweet`) is not reflected on the
|
|
451
|
+
# event. Genuinely-null DOM attributes (target/currentTarget/…) are
|
|
452
|
+
# explicit cases above and still return nil.
|
|
453
|
+
Bridge::ABSENT
|
|
384
454
|
end
|
|
385
455
|
end
|
|
386
456
|
|
|
@@ -406,7 +476,10 @@ module Dommy
|
|
|
406
476
|
def __js_call__(method, args)
|
|
407
477
|
case method
|
|
408
478
|
when "preventDefault"
|
|
409
|
-
|
|
479
|
+
# A passive listener's preventDefault() is a no-op (DOM "passive listener
|
|
480
|
+
# flag"), so the default action proceeds — what Stimulus's :passive
|
|
481
|
+
# action option relies on.
|
|
482
|
+
@default_prevented = true if @cancelable && !@in_passive_listener
|
|
410
483
|
nil
|
|
411
484
|
when "stopPropagation"
|
|
412
485
|
@propagation_stopped = true
|
|
@@ -531,6 +604,60 @@ module Dommy
|
|
|
531
604
|
end
|
|
532
605
|
end
|
|
533
606
|
|
|
607
|
+
# `ErrorEvent` — the event interface for an uncaught error (fired at
|
|
608
|
+
# `window.onerror`). Error-reporting code constructs it directly
|
|
609
|
+
# (`new ErrorEvent("error", { message, error, … })`); without the constructor
|
|
610
|
+
# that call is "not a function" and takes the whole app down before it renders
|
|
611
|
+
# (x.com's client bootstrap does exactly this).
|
|
612
|
+
class ErrorEvent < Event
|
|
613
|
+
attr_reader :message, :filename, :lineno, :colno, :error
|
|
614
|
+
|
|
615
|
+
def initialize(type, init = nil)
|
|
616
|
+
super
|
|
617
|
+
@message = read_init(init, "message") || ""
|
|
618
|
+
@filename = read_init(init, "filename") || ""
|
|
619
|
+
@lineno = read_init(init, "lineno") || 0
|
|
620
|
+
@colno = read_init(init, "colno") || 0
|
|
621
|
+
@error = read_init(init, "error")
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def __js_get__(key)
|
|
625
|
+
case key
|
|
626
|
+
when "message" then @message
|
|
627
|
+
when "filename" then @filename
|
|
628
|
+
when "lineno" then @lineno
|
|
629
|
+
when "colno" then @colno
|
|
630
|
+
when "error" then @error
|
|
631
|
+
else super
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# `PromiseRejectionEvent` — fired as `unhandledrejection` / `rejectionhandled`
|
|
637
|
+
# when a promise rejects with no handler (`event.promise` / `event.reason`).
|
|
638
|
+
# Critically, its mere existence is what Promise feature-detection (core-js
|
|
639
|
+
# et al.) checks: without it, libraries decide the native Promise is
|
|
640
|
+
# incomplete and swap in a polyfill whose microtask queue the host scheduler
|
|
641
|
+
# cannot flush — silently starving every `.then`/await and hanging SPA
|
|
642
|
+
# hydration (this is why note.com rendered only a shell).
|
|
643
|
+
class PromiseRejectionEvent < Event
|
|
644
|
+
attr_reader :promise, :reason
|
|
645
|
+
|
|
646
|
+
def initialize(type, init = nil)
|
|
647
|
+
super
|
|
648
|
+
@promise = read_init(init, "promise")
|
|
649
|
+
@reason = read_init(init, "reason")
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def __js_get__(key)
|
|
653
|
+
case key
|
|
654
|
+
when "promise" then @promise
|
|
655
|
+
when "reason" then @reason
|
|
656
|
+
else super
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
534
661
|
class MouseEvent < Event
|
|
535
662
|
def initialize(type, init = nil)
|
|
536
663
|
super
|
|
@@ -771,6 +898,8 @@ module Dommy
|
|
|
771
898
|
@rotation_angle
|
|
772
899
|
when "force"
|
|
773
900
|
@force
|
|
901
|
+
else
|
|
902
|
+
Bridge::ABSENT
|
|
774
903
|
end
|
|
775
904
|
end
|
|
776
905
|
end
|
|
@@ -1028,8 +1157,18 @@ module Dommy
|
|
|
1028
1157
|
return composite
|
|
1029
1158
|
end
|
|
1030
1159
|
|
|
1160
|
+
composite.__internal_make_dependent__
|
|
1031
1161
|
list.each do |sig|
|
|
1032
|
-
|
|
1162
|
+
# A plain source signal links directly; a composite is flattened to its
|
|
1163
|
+
# own (already-flat) source signals, so the dependency graph stays one
|
|
1164
|
+
# level deep and abort order is well-defined.
|
|
1165
|
+
sources = sig.__internal_dependent__? ? sig.__internal_source_signals__ : [sig]
|
|
1166
|
+
sources.each do |source|
|
|
1167
|
+
next if composite.__internal_source_signals__.include?(source)
|
|
1168
|
+
|
|
1169
|
+
composite.__internal_add_source__(source)
|
|
1170
|
+
source.__internal_add_dependent__(composite)
|
|
1171
|
+
end
|
|
1033
1172
|
end
|
|
1034
1173
|
|
|
1035
1174
|
composite
|
|
@@ -1038,6 +1177,17 @@ module Dommy
|
|
|
1038
1177
|
def initialize
|
|
1039
1178
|
@aborted = false
|
|
1040
1179
|
@reason = nil
|
|
1180
|
+
# The WHATWG "dependent signal" model backing AbortSignal.any: a composite
|
|
1181
|
+
# signal flattens to its original (non-dependent) source signals, and each
|
|
1182
|
+
# source holds an ordered list of the composites depending on it — so abort
|
|
1183
|
+
# propagation fires in source-then-dependents order, not via event chaining.
|
|
1184
|
+
@dependent = false
|
|
1185
|
+
@source_signals = []
|
|
1186
|
+
@dependent_signals = []
|
|
1187
|
+
# WHATWG "abort algorithms": callbacks run synchronously during abort,
|
|
1188
|
+
# BEFORE the "abort" event — so a consumer (e.g. an Observable subscription)
|
|
1189
|
+
# can tear down ahead of any externally-registered abort listener.
|
|
1190
|
+
@abort_algorithms = []
|
|
1041
1191
|
end
|
|
1042
1192
|
|
|
1043
1193
|
# Background-thread timeout used by `AbortSignal.timeout` when no
|
|
@@ -1084,6 +1234,8 @@ module Dommy
|
|
|
1084
1234
|
@aborted ? @reason : Bridge::UNDEFINED
|
|
1085
1235
|
when "onabort"
|
|
1086
1236
|
@onabort_handler
|
|
1237
|
+
else
|
|
1238
|
+
Bridge::ABSENT
|
|
1087
1239
|
end
|
|
1088
1240
|
end
|
|
1089
1241
|
|
|
@@ -1101,7 +1253,7 @@ module Dommy
|
|
|
1101
1253
|
end
|
|
1102
1254
|
|
|
1103
1255
|
include Bridge::Methods
|
|
1104
|
-
js_methods %w[addEventListener removeEventListener dispatchEvent throwIfAborted]
|
|
1256
|
+
js_methods %w[addEventListener removeEventListener dispatchEvent throwIfAborted __internalAddAbortAlgorithm]
|
|
1105
1257
|
def __js_call__(method, args)
|
|
1106
1258
|
case method
|
|
1107
1259
|
when "addEventListener"
|
|
@@ -1112,22 +1264,101 @@ module Dommy
|
|
|
1112
1264
|
dispatch_event(args[0])
|
|
1113
1265
|
when "throwIfAborted"
|
|
1114
1266
|
throw_if_aborted
|
|
1267
|
+
when "__internalAddAbortAlgorithm"
|
|
1268
|
+
__internal_add_abort_algorithm__(args[0])
|
|
1115
1269
|
end
|
|
1116
1270
|
end
|
|
1117
1271
|
|
|
1272
|
+
# Register an abort algorithm (WHATWG "add an algorithm to a signal"): it runs
|
|
1273
|
+
# synchronously when the signal aborts, before the "abort" event. A no-op once
|
|
1274
|
+
# already aborted — the caller handles that case itself. Exposed to the JS
|
|
1275
|
+
# Observable polyfill so a subscription's consumer-abort fires ahead of any
|
|
1276
|
+
# external abort listener (the spec's downstream-before-upstream ordering).
|
|
1277
|
+
def __internal_add_abort_algorithm__(callback)
|
|
1278
|
+
@abort_algorithms << callback unless @aborted
|
|
1279
|
+
nil
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1118
1282
|
# Sentinel meaning "no reason argument was supplied" — distinct from an
|
|
1119
1283
|
# explicit `null` reason (`abort(null)` keeps reason null, but `abort()` /
|
|
1120
1284
|
# `abort(undefined)` default to a fresh AbortError).
|
|
1121
1285
|
NO_REASON = Object.new
|
|
1122
1286
|
|
|
1287
|
+
# WHATWG "signal abort": set the reason, then propagate to dependent signals
|
|
1288
|
+
# — firing "abort" at this signal FIRST and then at each dependent in the
|
|
1289
|
+
# order it was registered (the reasons are all stamped before any event
|
|
1290
|
+
# fires, so a handler observing a sibling sees it already aborted).
|
|
1123
1291
|
def __internal_mark_aborted__(reason = NO_REASON)
|
|
1124
1292
|
return if @aborted
|
|
1125
1293
|
|
|
1126
1294
|
@aborted = true
|
|
1127
1295
|
no_reason = reason.equal?(NO_REASON) || (defined?(Bridge::UNDEFINED) && reason.equal?(Bridge::UNDEFINED))
|
|
1128
1296
|
@reason = no_reason ? DOMException::AbortError.new("signal is aborted without reason") : reason
|
|
1297
|
+
|
|
1298
|
+
dependents_to_abort = []
|
|
1299
|
+
@dependent_signals.each do |dependent|
|
|
1300
|
+
next if dependent.aborted?
|
|
1301
|
+
|
|
1302
|
+
dependent.__internal_set_reason__(@reason)
|
|
1303
|
+
dependents_to_abort << dependent
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
__internal_run_abort_steps__
|
|
1307
|
+
dependents_to_abort.each(&:__internal_run_abort_steps__)
|
|
1308
|
+
end
|
|
1309
|
+
|
|
1310
|
+
# ----- dependent-signal plumbing (package-private, used by .any) -----
|
|
1311
|
+
|
|
1312
|
+
def __internal_make_dependent__
|
|
1313
|
+
@dependent = true
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1316
|
+
def __internal_dependent__?
|
|
1317
|
+
@dependent
|
|
1318
|
+
end
|
|
1319
|
+
|
|
1320
|
+
def __internal_source_signals__
|
|
1321
|
+
@source_signals
|
|
1322
|
+
end
|
|
1323
|
+
|
|
1324
|
+
def __internal_add_source__(source)
|
|
1325
|
+
@source_signals << source
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1328
|
+
def __internal_add_dependent__(dependent)
|
|
1329
|
+
@dependent_signals << dependent
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
# Mark aborted with a reason WITHOUT firing — the orchestrating source stamps
|
|
1333
|
+
# every dependent's reason up front, then fires the events in order.
|
|
1334
|
+
def __internal_set_reason__(reason)
|
|
1335
|
+
@aborted = true
|
|
1336
|
+
@reason = reason
|
|
1337
|
+
end
|
|
1338
|
+
|
|
1339
|
+
# The spec's "abort steps": run the registered abort algorithms first
|
|
1340
|
+
# (synchronously, ahead of any listener), then fire the trusted "abort" event.
|
|
1341
|
+
def __internal_run_abort_steps__
|
|
1342
|
+
algorithms = @abort_algorithms
|
|
1343
|
+
@abort_algorithms = []
|
|
1344
|
+
algorithms.each { |algo| invoke_abort_algorithm(algo) }
|
|
1129
1345
|
dispatch_event(Event.new("abort", "bubbles" => false, "cancelable" => false).__internal_mark_trusted__)
|
|
1130
1346
|
end
|
|
1347
|
+
|
|
1348
|
+
# Run one abort algorithm (a JS callback over the bridge, or a Ruby proc).
|
|
1349
|
+
# A throw is swallowed deliberately: WHATWG runs every abort algorithm and
|
|
1350
|
+
# then fires the "abort" event regardless, so one algorithm's failure must
|
|
1351
|
+
# not abort the run. (The sole caller — the Observable polyfill — already
|
|
1352
|
+
# catches inside its own teardown, so this is a defensive backstop.)
|
|
1353
|
+
def invoke_abort_algorithm(algo)
|
|
1354
|
+
if algo.respond_to?(:__js_call__)
|
|
1355
|
+
algo.__js_call__("call", [])
|
|
1356
|
+
elsif algo.respond_to?(:call)
|
|
1357
|
+
algo.call
|
|
1358
|
+
end
|
|
1359
|
+
rescue StandardError
|
|
1360
|
+
nil
|
|
1361
|
+
end
|
|
1131
1362
|
end
|
|
1132
1363
|
|
|
1133
1364
|
class AbortController
|
data/lib/dommy/fetch.rb
CHANGED
|
@@ -2,17 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "securerandom"
|
|
5
|
+
require_relative "data_uri"
|
|
5
6
|
|
|
6
7
|
module Dommy
|
|
7
|
-
# `fetch` polyfill. No real network — instead
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# JavaScript installer uses, so tests don't need a JS engine to drive
|
|
11
|
-
# the stub.
|
|
8
|
+
# `fetch` polyfill. No real network — instead resolves a response
|
|
9
|
+
# *entry* and synthesizes a Response from it. Entries come from, in
|
|
10
|
+
# order:
|
|
12
11
|
#
|
|
13
|
-
#
|
|
12
|
+
# 1. `window.globals["__fetch_handler__"]` — a callable
|
|
13
|
+
# `call(url, init) -> entry-or-nil`. This is the seam host
|
|
14
|
+
# environments use to serve real requests (e.g. dommy-rack's
|
|
15
|
+
# NetworkBridge routes same-origin URLs to the Rack app).
|
|
16
|
+
# Returning nil falls through to the stub maps.
|
|
17
|
+
# 2. `JS.global[:__fetchy_stub__]` (a Hash{url => entry})
|
|
18
|
+
# installed by the test. Mirrors the same fixture protocol that
|
|
19
|
+
# `test_fetchy.rb`'s JavaScript installer uses, so tests don't
|
|
20
|
+
# need a JS engine to drive the stub.
|
|
21
|
+
#
|
|
22
|
+
# Each entry supports:
|
|
14
23
|
# "status" / "statusText" / "body" / "contentType" /
|
|
15
|
-
# "headers" (Hash) / "delay" (ms)
|
|
24
|
+
# "headers" (Hash) / "url" / "redirected" / "delay" (ms)
|
|
16
25
|
# plus AbortController signal propagation when `init[:signal]` is
|
|
17
26
|
# passed.
|
|
18
27
|
class FetchFn
|
|
@@ -24,17 +33,12 @@ module Dommy
|
|
|
24
33
|
# `__js_call__("fetch", ...)` or as a callable handle. Both routes
|
|
25
34
|
# delegate to `call(args)` so behavior is identical.
|
|
26
35
|
def __js_call__(_method, args)
|
|
27
|
-
|
|
36
|
+
# Per Fetch, the request URL is resolved against the document base URL up
|
|
37
|
+
# front; the handler, stub maps, and response.url all see the absolute
|
|
38
|
+
# URL (no per-handler resolution).
|
|
39
|
+
url = @window.__internal_resolve_url__(args[0].to_s)
|
|
28
40
|
init = normalize_init(args[1] || {})
|
|
29
41
|
|
|
30
|
-
# Each spec file installs its stub under its own global name.
|
|
31
|
-
# `test_fetchy.rb` uses `__fetchy_stub__`; `test_resource*.rb`
|
|
32
|
-
# use `__resource_fetch_stub__` and `__inject_fetch_stub__`.
|
|
33
|
-
# Check them in order — only one should be set at a time.
|
|
34
|
-
stub_map = @window.globals["__fetchy_stub__"] ||
|
|
35
|
-
@window.globals["__resource_fetch_stub__"] ||
|
|
36
|
-
@window.globals["__inject_fetch_stub__"] ||
|
|
37
|
-
{}
|
|
38
42
|
# `js_eval`'s JS installer increments these globals; mirror so
|
|
39
43
|
# specs that probe `__fetch_count__` / `__last_url__` / etc.
|
|
40
44
|
# observe the same state shape they'd see from a real injector.
|
|
@@ -43,13 +47,43 @@ module Dommy
|
|
|
43
47
|
@window.globals["__last_init__"] = init
|
|
44
48
|
@window.globals["__last_body__"] = init["body"] if init.is_a?(Hash)
|
|
45
49
|
|
|
46
|
-
entry = stub_map[url] if stub_map.is_a?(Hash)
|
|
47
50
|
promise = PromiseValue.new(@window)
|
|
51
|
+
result = resolve_entry(url, init)
|
|
52
|
+
# A handler may answer asynchronously (live network off-thread): it returns
|
|
53
|
+
# a deferred whose response arrives later and is applied on the page thread
|
|
54
|
+
# (via the scheduler inbox). The sync path (stubs / cache / data:) resolves
|
|
55
|
+
# the promise inline, exactly as before.
|
|
56
|
+
if result.respond_to?(:on_complete)
|
|
57
|
+
result.on_complete { |entry| fulfill_from_entry(promise, entry, url, init) }
|
|
58
|
+
else
|
|
59
|
+
fulfill_from_entry(promise, result, url, init)
|
|
60
|
+
end
|
|
61
|
+
promise
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
48
65
|
|
|
66
|
+
# Resolve `promise` from a response entry (nil -> 404), honoring a simulated
|
|
67
|
+
# `delay`. Used both for a synchronous entry and for an async one delivered
|
|
68
|
+
# later on the page thread.
|
|
69
|
+
def fulfill_from_entry(promise, entry, url, init)
|
|
70
|
+
# WHATWG: a fetch is resolved by a networking *task*, never inline during
|
|
71
|
+
# the fetch() call — so the initiating script runs to completion and its
|
|
72
|
+
# microtask checkpoint happens first. Defer the fulfillment onto the
|
|
73
|
+
# scheduler as a task so the synchronous data path gets the same event-loop
|
|
74
|
+
# semantics as the async DeferredResponse path. Without a scheduler (rare
|
|
75
|
+
# embedder), fall back to inline.
|
|
76
|
+
if (sched = @window.respond_to?(:scheduler) ? @window.scheduler : nil)
|
|
77
|
+
sched.set_timeout(proc { deliver_entry(promise, entry, url, init) }, 0)
|
|
78
|
+
else
|
|
79
|
+
deliver_entry(promise, entry, url, init)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def deliver_entry(promise, entry, url, init)
|
|
49
84
|
if entry.nil?
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return promise
|
|
85
|
+
promise.fulfill(Response.new(@window, body: "not found", status: 404, status_text: "Not Found", type: "basic"))
|
|
86
|
+
return
|
|
53
87
|
end
|
|
54
88
|
|
|
55
89
|
body = entry["body"]
|
|
@@ -58,14 +92,15 @@ module Dommy
|
|
|
58
92
|
content_type = entry["contentType"] || "text/plain"
|
|
59
93
|
headers = entry["headers"] || {"Content-Type" => content_type}
|
|
60
94
|
# Simulate a followed redirect: `[:url]` overrides the response URL (the
|
|
61
|
-
# final location) and `[:redirected]` flags it, so consumers that branch
|
|
62
|
-
#
|
|
63
|
-
#
|
|
95
|
+
# final location) and `[:redirected]` flags it, so consumers that branch on
|
|
96
|
+
# `response.redirected` / `response.url` (e.g. Turbo updating history to the
|
|
97
|
+
# redirected location) see a realistic response.
|
|
64
98
|
response_url = entry["url"] || url
|
|
65
99
|
redirected = entry["redirected"] ? true : false
|
|
66
100
|
|
|
67
|
-
|
|
68
|
-
|
|
101
|
+
__dommy_dump_fetch__(url, init, status, headers, body) if ENV["DOMMY_FETCH_DEBUG"]
|
|
102
|
+
|
|
103
|
+
if (delay = entry["delay"])
|
|
69
104
|
install_delayed_resolve(promise, body, status, status_text, headers, init, delay)
|
|
70
105
|
else
|
|
71
106
|
promise.fulfill(
|
|
@@ -73,11 +108,36 @@ module Dommy
|
|
|
73
108
|
headers: headers, url: response_url, redirected: redirected, type: "basic")
|
|
74
109
|
)
|
|
75
110
|
end
|
|
76
|
-
|
|
77
|
-
promise
|
|
78
111
|
end
|
|
79
112
|
|
|
80
|
-
|
|
113
|
+
# Resolve the response entry for a request: a `__fetch_handler__`
|
|
114
|
+
# callable gets first refusal; a nil from it (or no handler) falls
|
|
115
|
+
# through to the stub maps. Each spec file installs its stub under
|
|
116
|
+
# its own global name — `test_fetchy.rb` uses `__fetchy_stub__`;
|
|
117
|
+
# `test_resource*.rb` use `__resource_fetch_stub__` and
|
|
118
|
+
# `__inject_fetch_stub__`. Checked in order; only one should be set
|
|
119
|
+
# at a time.
|
|
120
|
+
def resolve_entry(url, init)
|
|
121
|
+
if (decoded = DataUri.parse(url))
|
|
122
|
+
return {"body" => decoded[:body], "status" => 200, "statusText" => "OK",
|
|
123
|
+
"contentType" => decoded[:content_type]}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
handler = @window.globals["__fetch_handler__"]
|
|
127
|
+
if handler.respond_to?(:call)
|
|
128
|
+
entry = handler.call(url, init)
|
|
129
|
+
return entry if entry
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
stub_map = @window.globals["__fetchy_stub__"] ||
|
|
133
|
+
@window.globals["__resource_fetch_stub__"] ||
|
|
134
|
+
@window.globals["__inject_fetch_stub__"]
|
|
135
|
+
return nil unless stub_map.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
# The URL is now absolute; a stub keyed by a path ("/api") still matches
|
|
138
|
+
# its resolved form ("http://host/api").
|
|
139
|
+
stub_map[url] || stub_map[@window.__internal_url_path__(url)]
|
|
140
|
+
end
|
|
81
141
|
|
|
82
142
|
# Coerce `init` into a Hash with string keys so the rest of the
|
|
83
143
|
# pipeline (and the `__last_init__` globals) sees a uniform shape.
|
|
@@ -97,6 +157,33 @@ module Dommy
|
|
|
97
157
|
h
|
|
98
158
|
end
|
|
99
159
|
|
|
160
|
+
# Diagnostic only (DOMMY_FETCH_DEBUG=<file>): append a record of what the
|
|
161
|
+
# page's fetch() received, so a response that an app's data layer can't
|
|
162
|
+
# consume (e.g. Apollo's "link chain completed without emitting a value",
|
|
163
|
+
# #95) can be traced to the actual request/response bytes Dommy handed it.
|
|
164
|
+
# Every fetch is logged compactly; a GraphQL request also dumps full bodies
|
|
165
|
+
# (that is where the emission breaks), which is the exchange we need to see.
|
|
166
|
+
def __dommy_dump_fetch__(url, init, status, headers, body)
|
|
167
|
+
path = ENV["DOMMY_FETCH_DEBUG"]
|
|
168
|
+
graphql = url.include?("graphql")
|
|
169
|
+
req_headers = init.is_a?(Hash) ? init["headers"] : nil
|
|
170
|
+
req_body = init.is_a?(Hash) ? init["body"] : nil
|
|
171
|
+
content_type = headers.is_a?(Hash) ? headers.find { |k, _| k.to_s.downcase == "content-type" }&.last : nil
|
|
172
|
+
method = (init.is_a?(Hash) ? (init["method"] || "GET") : "GET").to_s.upcase
|
|
173
|
+
cap = graphql ? 8000 : 300
|
|
174
|
+
|
|
175
|
+
lines = ["=== fetch #{graphql ? "[graphql] " : ""}#{method} #{url}"]
|
|
176
|
+
lines << "> req-headers: #{req_headers.inspect}" if graphql && req_headers
|
|
177
|
+
lines << "> req-body: #{req_body.to_s[0, cap]}" if req_body
|
|
178
|
+
lines << "< #{status} #{content_type} (#{body.to_s.bytesize} bytes)"
|
|
179
|
+
lines << "< res-headers: #{headers.inspect}" if graphql
|
|
180
|
+
lines << "< res-body: #{body.to_s[0, cap]}"
|
|
181
|
+
# `::File` — inside `module Dommy`, bare `File` resolves to Dommy's DOM File.
|
|
182
|
+
::File.write(path, "#{lines.join("\n")}\n\n", mode: "a")
|
|
183
|
+
rescue StandardError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
100
187
|
def install_delayed_resolve(promise, body, status, status_text, headers, init, delay_ms)
|
|
101
188
|
# AbortController cancellation: when init.signal is present and
|
|
102
189
|
# `.abort()` fires before the timer, reject with an AbortError.
|
|
@@ -146,9 +233,16 @@ module Dommy
|
|
|
146
233
|
@mode = (opts["mode"] || opts[:mode] || "cors").to_s
|
|
147
234
|
@cache = (opts["cache"] || opts[:cache] || "default").to_s
|
|
148
235
|
@redirect = (opts["redirect"] || opts[:redirect] || "follow").to_s
|
|
236
|
+
# WHATWG: a Request ALWAYS has an associated signal (an AbortSignal). Use a
|
|
237
|
+
# provided signal when present (so `request.signal` is the caller's own
|
|
238
|
+
# controller signal — react-router add/removeEventListener's it directly),
|
|
239
|
+
# else a fresh, never-aborted one. Never undefined, or consumers that read
|
|
240
|
+
# `request.signal.removeEventListener` crash.
|
|
241
|
+
sig = opts["signal"] || opts[:signal]
|
|
242
|
+
@signal = sig.respond_to?(:__js_call__) ? sig : AbortSignal.new
|
|
149
243
|
end
|
|
150
244
|
|
|
151
|
-
attr_reader :headers, :credentials, :mode, :cache, :redirect
|
|
245
|
+
attr_reader :headers, :credentials, :mode, :cache, :redirect, :signal
|
|
152
246
|
|
|
153
247
|
def __js_get__(key)
|
|
154
248
|
case key
|
|
@@ -168,6 +262,10 @@ module Dommy
|
|
|
168
262
|
@cache
|
|
169
263
|
when "redirect"
|
|
170
264
|
@redirect
|
|
265
|
+
when "signal"
|
|
266
|
+
@signal
|
|
267
|
+
else
|
|
268
|
+
Bridge::ABSENT
|
|
171
269
|
end
|
|
172
270
|
end
|
|
173
271
|
|
|
@@ -184,7 +282,8 @@ module Dommy
|
|
|
184
282
|
"credentials" => @credentials,
|
|
185
283
|
"mode" => @mode,
|
|
186
284
|
"cache" => @cache,
|
|
187
|
-
"redirect" => @redirect
|
|
285
|
+
"redirect" => @redirect,
|
|
286
|
+
"signal" => @signal
|
|
188
287
|
)
|
|
189
288
|
end
|
|
190
289
|
end
|
|
@@ -202,13 +301,20 @@ module Dommy
|
|
|
202
301
|
# Redirect statuses accepted by `Response.redirect(url, status)`.
|
|
203
302
|
REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
|
|
204
303
|
|
|
304
|
+
# Forbidden response-header names (WHATWG Fetch): never exposed on a
|
|
305
|
+
# Response's Headers. `Set-Cookie` is handled by the network layer's cookie
|
|
306
|
+
# jar, not JS — and a real server often sends MULTIPLE Set-Cookie headers
|
|
307
|
+
# folded into one newline-joined value, which is an invalid Headers value and
|
|
308
|
+
# used to crash Response construction (e.g. doubleclick's IDE+test_cookie).
|
|
309
|
+
FORBIDDEN_RESPONSE_HEADERS = %w[set-cookie set-cookie2].freeze
|
|
310
|
+
|
|
205
311
|
def initialize(window, body:, status: 200, status_text: "", headers: nil, url: "",
|
|
206
312
|
redirected: false, type: "default", has_body: true)
|
|
207
313
|
@window = window
|
|
208
314
|
@body = body.to_s
|
|
209
315
|
@status = status
|
|
210
316
|
@status_text = status_text.to_s
|
|
211
|
-
@headers = Headers.new(headers
|
|
317
|
+
@headers = Headers.new(strip_forbidden_headers(headers))
|
|
212
318
|
@url = url.to_s
|
|
213
319
|
@redirected = redirected ? true : false
|
|
214
320
|
@type = type
|
|
@@ -217,6 +323,16 @@ module Dommy
|
|
|
217
323
|
@body_stream = nil
|
|
218
324
|
end
|
|
219
325
|
|
|
326
|
+
# Drop forbidden response headers (Set-Cookie/Set-Cookie2) before they reach
|
|
327
|
+
# the Headers object. Only a Hash (the network path) carries them; a Headers
|
|
328
|
+
# or nil passes through unchanged.
|
|
329
|
+
def strip_forbidden_headers(headers)
|
|
330
|
+
return {} if headers.nil?
|
|
331
|
+
return headers unless headers.is_a?(Hash)
|
|
332
|
+
|
|
333
|
+
headers.reject { |name, _| FORBIDDEN_RESPONSE_HEADERS.include?(name.to_s.downcase) }
|
|
334
|
+
end
|
|
335
|
+
|
|
220
336
|
# WHATWG `new Response(body, init)`. Validates the status (200–599, else a
|
|
221
337
|
# RangeError; a null-body status 204/205/304 with a body is a TypeError),
|
|
222
338
|
# defaults statusText to "" and status to 200, accepts `init.headers` as a
|
|
@@ -414,6 +530,8 @@ module Dommy
|
|
|
414
530
|
body_stream
|
|
415
531
|
when "bodyUsed"
|
|
416
532
|
body_used?
|
|
533
|
+
else
|
|
534
|
+
Bridge::ABSENT
|
|
417
535
|
end
|
|
418
536
|
end
|
|
419
537
|
|
|
@@ -622,7 +740,7 @@ module Dommy
|
|
|
622
740
|
def fill(init)
|
|
623
741
|
case init
|
|
624
742
|
when Headers
|
|
625
|
-
init.
|
|
743
|
+
init.__internal_raw_pairs__.each { |name, value| append_value(name, value) }
|
|
626
744
|
when Array
|
|
627
745
|
init.each do |pair|
|
|
628
746
|
unless pair.is_a?(Array) && pair.length == 2
|
|
@@ -639,7 +757,7 @@ module Dommy
|
|
|
639
757
|
|
|
640
758
|
# Internal: a copy of the raw [name, value] pairs — lets one Headers be
|
|
641
759
|
# filled from another without losing duplicates or split Set-Cookie values.
|
|
642
|
-
def
|
|
760
|
+
def __internal_raw_pairs__
|
|
643
761
|
@list.map(&:dup)
|
|
644
762
|
end
|
|
645
763
|
|
|
@@ -653,7 +771,7 @@ module Dommy
|
|
|
653
771
|
end
|
|
654
772
|
|
|
655
773
|
def __js_get__(_key)
|
|
656
|
-
|
|
774
|
+
Bridge::ABSENT # Headers exposes only methods; any property read is absent
|
|
657
775
|
end
|
|
658
776
|
|
|
659
777
|
def __js_set__(_key, _value)
|