Class: Dommy::Internal::NodeWrapperCache
- Inherits:
-
Object
- Object
- Dommy::Internal::NodeWrapperCache
- 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
- #create_attribute(name) ⇒ Object
- #create_attribute_ns(namespace_uri, qualified_name) ⇒ Object
- #create_cdata_section(text) ⇒ Object
- #create_comment(text) ⇒ Object
- #create_document_fragment ⇒ Object
-
#create_element(name) ⇒ Object
Factory methods.
- #create_element_ns(namespace_uri, qualified_name) ⇒ Object
-
#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.
- #create_text_node(text) ⇒ Object
- #get_element_by_id(id) ⇒ Object
- #get_elements_by_class_name(name) ⇒ Object
- #get_elements_by_name(name) ⇒ Object
- #get_elements_by_tag_name(name) ⇒ Object
-
#initialize(document) ⇒ NodeWrapperCache
constructor
A new instance of NodeWrapperCache.
-
#peek(node) ⇒ Object
The cached wrapper for
node, or nil — WITHOUT creating one (unlike #wrap). -
#query_selector(selector) ⇒ Object
Query methods.
- #query_selector_all(selector) ⇒ Object
-
#register(nokogiri_node, wrapper) ⇒ Object
Register an externally-built wrapper.
-
#reset_wrapper(nokogiri_node) ⇒ Object
Clear cached wrapper (used by customElements.define for upgrades).
-
#wrap(node) ⇒ Object
Returns the wrapped node, creating and caching if needed.
-
#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).
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_fragment ⇒ Object
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 |