Class: Dommy::Internal::NodeWrapperCache

Inherits:
Object
  • Object
show all
Defined in:
lib/dommy/internal/node_wrapper_cache.rb

Overview

Manages DOM node identity via wrapper caching. Ensures that wrap_node(nokogiri_node) always returns the same Ruby object. Separates identity/caching management from Document's public DOM API.

Constant Summary collapse

NAMESPACE_UNSET =

Distinguishes "no namespace argument given" (derive from the backend / document) from an explicit nil namespace passed by createElementNS.

Object.new.freeze
QUERY_CACHE_CAP =

Cap on distinct cached selectors before the query cache is cleared wholesale — a backstop against a page that generates unbounded unique selector strings; real pages reuse a small set (tens).

512
ASCII_WHITESPACE =

DOM "ASCII whitespace" (https://infra.spec.whatwg.org/#ascii-whitespace): TAB, LF, FF, CR, SPACE — NOT Ruby's \s (which also matches VT / U+000B) and NOT any Unicode space (U+00A0, U+2000…). Class tokens split on exactly this set, so a class of a single U+000B or U+00A0 is ONE token.

/[\t\n\f\r ]+/

Instance Method Summary collapse

Constructor Details

#initialize(document) ⇒ NodeWrapperCache

Returns a new instance of NodeWrapperCache.



18
19
20
21
22
23
24
25
26
27
28
29
30
31
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 18

def initialize(document)
  @document = document
  @wrappers = {}
  # Memoizes document-rooted CSS query results within a DOM generation.
  # querySelector(All) over a large tree is a full descendant walk, yet a
  # heavy page issues the SAME selector hundreds of times between mutations
  # (measured ~87% repeats on a real site). `Document#style_generation`
  # bumps on every childList / attribute / characterData mutation — and on
  # focus / active-element changes too, so `:focus`-dependent selectors are
  # invalidated correctly — so a result tagged with the generation it was
  # computed in stays valid until the next mutation, then is recomputed
  # lazily. Keyed by [kind, selector] => [generation, value].
  @query_cache = {}
end

Instance Method Details

#create_attribute(name) ⇒ Object



110
111
112
113
114
115
116
117
118
119
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 110

def create_attribute(name)
  str = domstring(name)
  raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
  raise DOMException::InvalidCharacterError, "invalid attribute name: #{str.inspect}" unless str.match?(Namespaces::NAME)

  # WHATWG createAttribute: an HTML document lower-cases the name (an XML
  # document preserves it). Attr.new no longer folds case, so do it here.
  str = str.downcase if @document.html_document?
  Attr.new(str, document: @document)
end

#create_attribute_ns(namespace_uri, qualified_name) ⇒ Object



121
122
123
124
125
126
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 121

def create_attribute_ns(namespace_uri, qualified_name)
  namespace_uri = nil if namespace_uri.equal?(Bridge::UNDEFINED)
  qualified_name = domstring(qualified_name)
  ns, prefix, local = Namespaces.validate_and_extract(namespace_uri, qualified_name)
  Attr.new(qualified_name, namespace_uri: ns, prefix: prefix, local_name: local, document: @document)
end

#create_cdata_section(text) ⇒ Object



86
87
88
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 86

def create_cdata_section(text)
  wrap_node(Backend.create_cdata(text.to_s, @document.backend_doc))
end

#create_comment(text) ⇒ Object



90
91
92
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 90

def create_comment(text)
  wrap_node(Backend.create_comment(text.to_s, @document.backend_doc))
end

#create_document_fragmentObject



106
107
108
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 106

def create_document_fragment
  wrap_node(@document.backend_doc.fragment(""))
end

#create_element(name) ⇒ Object

Factory methods



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 59

def create_element(name)
  str = domstring(name)
  raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
  raise DOMException::InvalidCharacterError, "invalid element name: #{str.inspect}" unless str.match?(Namespaces::HTML_NAME)

  # WHATWG createElement: lowercase (ASCII) the name only in an HTML
  # document; the namespace is the HTML namespace for HTML/XHTML documents
  # and null for a non-XHTML XML document. Record the metadata so the
  # element's localName/tagName/namespaceURI getters report it faithfully
  # (in particular case preservation for XML/XHTML).
  if @document.html_document?
    local = str.downcase(:ascii)
    namespace = Element::HTML_NAMESPACE
  else
    local = str
    namespace = @document.content_type == "application/xhtml+xml" ? Element::HTML_NAMESPACE : nil
  end

  wrapper = wrap_node(Backend.create_element(local, @document.backend_doc))
  wrapper.__internal_set_namespace__(namespace, nil, local, local)
  wrapper
end

#create_element_ns(namespace_uri, qualified_name) ⇒ Object



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 128

def create_element_ns(namespace_uri, qualified_name)
  # WHATWG "validate and extract": QName-validate the qualifiedName
  # (InvalidCharacterError) and apply the prefix/namespace rules
  # (NamespaceError), then build the element with its prefix bound.
  # namespace is nullable (undefined → null); qualifiedName is a plain
  # DOMString (undefined → "undefined", null → "null").
  namespace_uri = nil if namespace_uri.equal?(Bridge::UNDEFINED)
  qualified_name = domstring(qualified_name)
  ns, prefix, local = Namespaces.validate_and_extract(namespace_uri, qualified_name, context: :element)

  # An XML backend rejects some DOM-valid qualified names (an invalid char
  # in the local part, which DOM permits): the loose creator builds them
  # verbatim. A genuinely invalid name it (or the strict path) rejects with
  # an ArgumentError becomes an InvalidCharacterError, per DOM.
  el =
    begin
      Backend.create_element_loose(qualified_name, prefix, local, ns, @document.backend_doc) ||
        Backend.create_element(qualified_name, @document.backend_doc)
    rescue ArgumentError
      raise DOMException::InvalidCharacterError, "'#{qualified_name}' is not a valid element name"
    end
  Backend.add_namespace_definition(el, prefix, ns) if ns

  wrapper = build_element_wrapper(el, namespace: ns, local_name: local)
  wrapper.__internal_set_namespace__(ns, prefix, local, qualified_name)
  wrapper
end

#create_processing_instruction(target, data) ⇒ Object

WHATWG Document.createProcessingInstruction: the target must be a valid XML Name and the data must not contain the PI close delimiter "?>", else InvalidCharacterError. The result is a real backend-backed PI node.



97
98
99
100
101
102
103
104
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 97

def create_processing_instruction(target, data)
  t = domstring(target)
  d = domstring(data)
  raise DOMException::InvalidCharacterError, "invalid processing instruction target: #{t.inspect}" unless t.match?(Namespaces::NAME)
  raise DOMException::InvalidCharacterError, "processing instruction data must not contain '?>'" if d.include?("?>")

  wrap_node(Backend.create_processing_instruction(t, d, @document.backend_doc))
end

#create_text_node(text) ⇒ Object



82
83
84
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 82

def create_text_node(text)
  wrap_node(Backend.create_text(text.to_s, @document.backend_doc))
end

#get_element_by_id(id) ⇒ Object



196
197
198
199
200
201
202
203
204
205
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 196

def get_element_by_id(id)
  return nil if id.nil? || id.to_s.empty?

  # getElementById matches the `id` attribute literally — it is NOT a CSS
  # selector, so an id with selector-special characters (e.g. React's
  # `useId` values like `:rjm:`) is valid and must still resolve. Escape it
  # to a valid id-selector ident before handing it to the backend's CSS
  # engine (a raw "##{id}" would be an invalid selector and raise).
  wrap(@document.backend_doc.at_css("##{Dommy::CSSNamespace.escape(id)}"))
end

#get_elements_by_class_name(name) ⇒ Object



226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 226

def get_elements_by_class_name(name)
  tokens = name.to_s.split(ASCII_WHITESPACE).reject(&:empty?)
  doc = @document.backend_doc
  cache = self
  HTMLCollection.new do
    next [] if tokens.empty?

    # Match class tokens directly rather than composing a `.tok` CSS
    # selector string — an exotic class token (control chars, Unicode
    # spaces, quotes) can't be safely embedded in a selector, and the
    # split must be ASCII-whitespace, not the CSS engine's tokenization.
    doc.css("[class]").select do |n|
      classes = n["class"].to_s.split(ASCII_WHITESPACE)
      tokens.all? { |t| classes.include?(t) }
    end.map { |n| cache.wrap(n) }.compact
  end
end

#get_elements_by_name(name) ⇒ Object



211
212
213
214
215
216
217
218
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 211

def get_elements_by_name(name)
  doc = @document.backend_doc
  cache = self
  key = name.to_s
  HTMLCollection.new do
    doc.css("[name='#{key}']").map { |x| cache.wrap(x) }.compact
  end
end

#get_elements_by_tag_name(name) ⇒ Object



207
208
209
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 207

def get_elements_by_tag_name(name)
  HTMLCollection.elements_by_tag_name(@document.backend_doc, @document, name)
end

#peek(node) ⇒ Object

The cached wrapper for node, or nil — WITHOUT creating one (unlike #wrap). Used by cross-document adoption to find the live descendant wrappers that must be reseated onto the imported copy.



252
253
254
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 252

def peek(node)
  node && @wrappers[identity_key(node)]
end

#query_selector(selector) ⇒ Object

Query methods



170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 170

def query_selector(selector)
  return nil if selector.nil?

  key = selector.to_s
  hit = query_cache_get(:first, key)
  return hit.first if hit # [result] tuple — distinguishes a cached nil match from a miss

  ast = Internal::SelectorParser.parse!(selector)
  result = Internal::SelectorMatcher.query_first(@document, ast)
  query_cache_set(:first, key, [result])
  result
end

#query_selector_all(selector) ⇒ Object



183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 183

def query_selector_all(selector)
  return NodeList.new if selector.nil?

  key = selector.to_s
  hit = query_cache_get(:all, key)
  return NodeList.new(hit) if hit # NodeList.new copies, so the cached array is never aliased

  ast = Internal::SelectorParser.parse!(selector)
  matches = Internal::SelectorMatcher.query(@document, ast)
  query_cache_set(:all, key, matches)
  NodeList.new(matches)
end

#register(nokogiri_node, wrapper) ⇒ Object

Register an externally-built wrapper. Used by Document#adopt_node when migrating a wrapper from another document so the existing Ruby object survives the move rather than being replaced by a freshly-built one.



260
261
262
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 260

def register(nokogiri_node, wrapper)
  @wrappers[identity_key(nokogiri_node)] = wrapper
end

#reset_wrapper(nokogiri_node) ⇒ Object

Clear cached wrapper (used by customElements.define for upgrades)



245
246
247
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 245

def reset_wrapper(nokogiri_node)
  @wrappers.delete(identity_key(nokogiri_node))
end

#wrap(node) ⇒ Object

Returns the wrapped node, creating and caching if needed. Maintains DOM identity across repeated traversals.



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 35

def wrap(node)
  return nil unless node

  key = identity_key(node)
  cached = @wrappers[key]
  # A hit is only trustworthy if the cached wrapper still describes the
  # SAME kind of node as the one now at this identity key. The key is a
  # backend pointer/object_id, and a backend MAY recycle a freed node's
  # identity (Nokogiri reuses object_ids; Makiri reuses the lxb pointer of
  # a *transient* node — e.g. a throwaway fragment parsed by
  # `Parser.fragment` — once it's GC'd). When that happens the stale entry
  # would hand back a wrapper of the wrong type (observed: a Fragment
  # clone resolving to a cached TextNode). Validate cheaply via nodeType —
  # compared against a static class→type map so we never dereference the
  # cached wrapper's (possibly freed) backend node — and rebuild on a miss.
  return cached if cached && cached_wrapper_live?(cached, node)

  wrapper = build_wrapper_for(node)
  @wrappers[key] = wrapper if wrapper
  wrapper
end

#wrap_cloned_element_ns(node, namespace, prefix, local, qualified_name) ⇒ Object

Wrap a freshly-cloned backend element whose original was built via createElementNS: route the wrapper class by the known local name (the backend node name may be the full qualified name, e.g. "foo:div", which would otherwise resolve to HTMLUnknownElement) and reapply its namespace metadata (namespaceURI / prefix / localName / tagName).



161
162
163
164
165
166
# File 'lib/dommy/internal/node_wrapper_cache.rb', line 161

def wrap_cloned_element_ns(node, namespace, prefix, local, qualified_name)
  reset_wrapper(node)
  wrapper = build_element_wrapper(node, namespace: namespace, local_name: local)
  wrapper.__internal_set_namespace__(namespace, prefix, local, qualified_name)
  wrapper
end