View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.javascript.host.dom;
16  
17  import java.io.Serializable;
18  import java.lang.ref.WeakReference;
19  import java.lang.reflect.Method;
20  import java.util.ArrayList;
21  import java.util.List;
22  import java.util.function.Function;
23  import java.util.function.Predicate;
24  import java.util.function.Supplier;
25  
26  import org.htmlunit.corejs.javascript.ExternalArrayData;
27  import org.htmlunit.corejs.javascript.Scriptable;
28  import org.htmlunit.corejs.javascript.VarScope;
29  import org.htmlunit.html.DomChangeEvent;
30  import org.htmlunit.html.DomChangeListener;
31  import org.htmlunit.html.DomElement;
32  import org.htmlunit.html.DomNode;
33  import org.htmlunit.html.HtmlAttributeChangeEvent;
34  import org.htmlunit.html.HtmlAttributeChangeListener;
35  import org.htmlunit.html.HtmlElement;
36  import org.htmlunit.html.HtmlPage;
37  import org.htmlunit.javascript.HtmlUnitScriptable;
38  
39  /**
40   * The parent class of {@link NodeList} and {@link org.htmlunit.javascript.host.html.HTMLCollection}.
41   *
42   * @author Daniel Gredler
43   * @author Marc Guillemot
44   * @author Chris Erskine
45   * @author Ahmed Ashour
46   * @author Frank Danek
47   * @author Ronald Brill
48   */
49  public class AbstractList extends HtmlUnitScriptable implements ExternalArrayData {
50  
51      /**
52       * Cache effect of some changes.
53       */
54      public enum EffectOnCache {
55          /** No effect, cache is still valid. */
56          NONE,
57          /** Cache is not valid anymore and should be reset. */
58          RESET
59      }
60  
61      private boolean avoidObjectDetection_;
62  
63      private boolean attributeChangeSensitive_;
64  
65      /**
66       * Cache collection elements when possible, to avoid expensive XPath expression evaluations.
67       */
68      private List<DomNode> cachedElements_;
69  
70      private boolean listenerRegistered_;
71  
72      private Function<HtmlAttributeChangeEvent, EffectOnCache> effectOnCacheFunction_ =
73              (Function<HtmlAttributeChangeEvent, EffectOnCache> & Serializable) event -> EffectOnCache.RESET;
74  
75      private Predicate<DomNode> isMatchingPredicate_ = (Predicate<DomNode> & Serializable) domNode -> false;
76  
77      private Supplier<List<DomNode>> elementsSupplier_ =
78              (Supplier<List<DomNode>> & Serializable)
79                  () -> {
80                      final List<DomNode> response = new ArrayList<>();
81                      final DomNode domNode = getDomNodeOrNull();
82                      if (domNode == null) {
83                          return response;
84                      }
85                      for (final DomNode desc : domNode.getDescendants()) {
86                          if (desc instanceof DomElement && isMatchingPredicate_.test(desc)) {
87                              response.add(desc);
88                          }
89                      }
90                      return response;
91                  };
92  
93      /**
94       * Creates an instance.
95       */
96      public AbstractList() {
97          super();
98      }
99  
100     /**
101      * Creates an instance.
102      *
103      * @param domNode the {@link DomNode}
104      * @param attributeChangeSensitive indicates if the content of the collection may change when an attribute
105      *        of a descendant node of domNode changes (attribute added, modified or removed)
106      * @param initialElements the initial content for the cache
107      */
108     protected AbstractList(final DomNode domNode, final boolean attributeChangeSensitive,
109             final List<DomNode> initialElements) {
110         super();
111         if (domNode != null) {
112             setDomNode(domNode, false);
113             setParentScope(domNode.getScriptableObject().getParentScope());
114             setPrototype(getPrototype(getClass()));
115         }
116         attributeChangeSensitive_ = attributeChangeSensitive;
117         cachedElements_ = initialElements;
118         if (initialElements != null) {
119             registerListener();
120         }
121         setExternalArrayData(getParentScope(), this);
122     }
123 
124     /**
125      * Only needed to make collections like <code>document.all</code> available but "invisible" when simulating Firefox.
126      * {@inheritDoc}
127      */
128     @Override
129     public boolean avoidObjectDetection() {
130         return avoidObjectDetection_;
131     }
132 
133     /**
134      * Sets whether this collection should avoid object detection.
135      *
136      * @param newValue the new value
137      */
138     public void setAvoidObjectDetection(final boolean newValue) {
139         avoidObjectDetection_ = newValue;
140     }
141 
142     /**
143      * Sets the function that determines the effect of attribute changes on the cache.
144      *
145      * @param effectOnCacheFunction the new function
146      */
147     public void setEffectOnCacheFunction(
148             final Function<HtmlAttributeChangeEvent, EffectOnCache> effectOnCacheFunction) {
149         if (effectOnCacheFunction == null) {
150             throw new NullPointerException("EffectOnCacheFunction can't be null");
151         }
152         effectOnCacheFunction_ = effectOnCacheFunction;
153     }
154 
155     /**
156      * Returns the supplier used to retrieve the collection elements.
157      *
158      * @return the element supplier
159      */
160     protected Supplier<List<DomNode>> getElementSupplier() {
161         return elementsSupplier_;
162     }
163 
164     /**
165      * Returns the elements whose associated host objects are available through this collection.
166      * @param elementsSupplier the new supplier
167      */
168     public void setElementsSupplier(final Supplier<List<DomNode>> elementsSupplier) {
169         if (elementsSupplier == null) {
170             throw new NullPointerException("ElementsSupplier can't be null");
171         }
172         elementsSupplier_ = elementsSupplier;
173     }
174 
175     /**
176      * Returns the predicate used to determine whether a node belongs to this collection.
177      *
178      * @return the matching predicate
179      */
180     protected Predicate<DomNode> getIsMatchingPredicate() {
181         return isMatchingPredicate_;
182     }
183 
184     /**
185      * Indicates if the node should belong to the collection.
186      * @param isMatchingPredicate the new predicate
187      */
188     public void setIsMatchingPredicate(final Predicate<DomNode> isMatchingPredicate) {
189         if (isMatchingPredicate == null) {
190             throw new NullPointerException("IsMatchingPredicate can't be null");
191         }
192         isMatchingPredicate_ = isMatchingPredicate;
193     }
194 
195     /**
196      * Private helper that retrieves the item or items corresponding to the specified
197      * index or key.
198      * @param o the index or key corresponding to the element or elements to return
199      * @return the element or elements corresponding to the specified index or key
200      */
201     protected Object getIt(final Object o) {
202         if (o instanceof Number n) {
203             final int i = n.intValue();
204             return get(i, this);
205         }
206         final String key = String.valueOf(o);
207         return get(key, this);
208     }
209 
210     @Override
211     public void setDomNode(final DomNode domNode, final boolean assignScriptObject) {
212         final DomNode oldDomNode = getDomNodeOrNull();
213 
214         super.setDomNode(domNode, assignScriptObject);
215 
216         if (oldDomNode != domNode) {
217             listenerRegistered_ = false;
218         }
219     }
220 
221     /**
222      * Gets the HTML elements from cache or retrieve them at first call.
223      * @return the list of {@link HtmlElement} contained in this collection
224      */
225     public List<DomNode> getElements() {
226         // a bit strange but we like to avoid sync
227         List<DomNode> cachedElements = cachedElements_;
228 
229         if (cachedElements == null) {
230             if (getParentScope() == null) {
231                 cachedElements = new ArrayList<>();
232             }
233             else {
234                 cachedElements = elementsSupplier_.get();
235             }
236             cachedElements_ = cachedElements;
237         }
238         registerListener();
239 
240         // maybe the cache was cleared in between
241         // then this returns the old state and never null
242         return cachedElements;
243     }
244 
245     /**
246      * Registers the DOM and attribute change listeners for this collection.
247      */
248     private void registerListener() {
249         if (!listenerRegistered_) {
250             final DomNode domNode = getDomNodeOrNull();
251             if (domNode != null) {
252                 final DomHtmlAttributeChangeListenerImpl listener = new DomHtmlAttributeChangeListenerImpl(this);
253                 domNode.addDomChangeListener(listener);
254                 if (attributeChangeSensitive_) {
255                     if (domNode instanceof HtmlElement element) {
256                         element.addHtmlAttributeChangeListener(listener);
257                     }
258                     else if (domNode instanceof HtmlPage page) {
259                         page.addHtmlAttributeChangeListener(listener);
260                     }
261                 }
262                 listenerRegistered_ = true;
263             }
264         }
265     }
266 
267     /**
268      * Returns the element or elements that match the specified key. If it is the name
269      * of a property, the property value is returned. If it is the id of an element in
270      * the array, that element is returned. Finally, if it is the name of an element or
271      * elements in the array, then all those elements are returned. Otherwise,
272      * {@link #NOT_FOUND} is returned.
273      * {@inheritDoc}
274      */
275     @Override
276     protected Object getWithPreemption(final String name) {
277         // Test to see if we are trying to get the length of this collection?
278         // If so return NOT_FOUND here to let the property be retrieved using the prototype
279         if ("length".equals(name)) {
280             return NOT_FOUND;
281         }
282 
283         final List<DomNode> elements = getElements();
284 
285         // See if there is an element in the element array with the specified id.
286         final List<DomNode> matchingElements = new ArrayList<>();
287 
288         for (final DomNode next : elements) {
289             if (next instanceof DomElement element) {
290                 final String id = element.getId();
291                 if (name.equals(id)) {
292                     matchingElements.add(next);
293                 }
294             }
295         }
296 
297         if (matchingElements.size() == 1) {
298             return getScriptableForElement(matchingElements.get(0));
299         }
300         else if (!matchingElements.isEmpty()) {
301             final AbstractList collection = create(getDomNodeOrDie(), matchingElements);
302             collection.setAvoidObjectDetection(true);
303             return collection;
304         }
305 
306         // no element found by id, let's search by name
307         return getWithPreemptionByName(name, elements);
308     }
309 
310     /**
311      * Creates a new collection backed by the specified initial elements.
312      *
313      * @param parentScope the parent scope on which to listen for changes
314      * @param initialElements the initial content for the cache
315      * @return the newly created instance
316      */
317     protected AbstractList create(final DomNode parentScope, final List<DomNode> initialElements) {
318         throw new IllegalAccessError("Creation of AbstractListInstances is not allowed.");
319     }
320 
321     /**
322      * Helper for {@link #getWithPreemption(String)} when finding by id doesn't get results.
323      * @param name the property name
324      * @param elements the children elements.
325      * @return {@link Scriptable#NOT_FOUND} if not found
326      */
327     protected Object getWithPreemptionByName(final String name, final List<DomNode> elements) {
328         final List<DomNode> matchingElements = new ArrayList<>();
329         for (final DomNode next : elements) {
330             if (next instanceof DomElement element) {
331                 final String nodeName = element.getAttributeDirect(DomElement.NAME_ATTRIBUTE);
332                 if (name.equals(nodeName)) {
333                     matchingElements.add(next);
334                 }
335             }
336         }
337 
338         if (matchingElements.isEmpty()) {
339             return NOT_FOUND;
340         }
341         else if (matchingElements.size() == 1) {
342             return getScriptableForElement(matchingElements.get(0));
343         }
344 
345         // many elements => build a sub collection
346         final DomNode domNode = getDomNodeOrNull();
347         final AbstractList collection = create(domNode, matchingElements);
348         collection.setAvoidObjectDetection(true);
349         return collection;
350     }
351 
352     /**
353      * Returns the number of elements in this collection.
354      *
355      * @return the number of elements
356      */
357     public int getLength() {
358         return getElements().size();
359     }
360 
361     /**
362      * {@inheritDoc}
363      */
364     @Override
365     public String toString() {
366         return getClass().getSimpleName() + " for " + getDomNodeOrNull();
367     }
368 
369     /**
370      * Called for the js "==".
371      * {@inheritDoc}
372      */
373     @Override
374     protected Object equivalentValues(final Object other) {
375         if (other == this) {
376             return Boolean.TRUE;
377         }
378         else if (other instanceof AbstractList otherArray) {
379             final DomNode domNode = getDomNodeOrNull();
380             final DomNode domNodeOther = otherArray.getDomNodeOrNull();
381             if (getClass() == other.getClass()
382                     && domNode == domNodeOther
383                     && getElements().equals(otherArray.getElements())) {
384                 return Boolean.TRUE;
385             }
386             return NOT_FOUND;
387         }
388 
389         return super.equivalentValues(other);
390     }
391 
392     private static final class DomHtmlAttributeChangeListenerImpl
393                                     implements DomChangeListener, HtmlAttributeChangeListener {
394 
395         private final transient WeakReference<AbstractList> nodeList_;
396 
397         DomHtmlAttributeChangeListenerImpl(final AbstractList nodeList) {
398             super();
399 
400             nodeList_ = new WeakReference<>(nodeList);
401         }
402 
403         /**
404          * {@inheritDoc}
405          */
406         @Override
407         public void nodeAdded(final DomChangeEvent event) {
408             clearCache();
409         }
410 
411         /**
412          * {@inheritDoc}
413          */
414         @Override
415         public void nodeDeleted(final DomChangeEvent event) {
416             clearCache();
417         }
418 
419         /**
420          * {@inheritDoc}
421          */
422         @Override
423         public void attributeAdded(final HtmlAttributeChangeEvent event) {
424             handleChangeOnCache(event);
425         }
426 
427         /**
428          * {@inheritDoc}
429          */
430         @Override
431         public void attributeRemoved(final HtmlAttributeChangeEvent event) {
432             handleChangeOnCache(event);
433         }
434 
435         /**
436          * {@inheritDoc}
437          */
438         @Override
439         public void attributeReplaced(final HtmlAttributeChangeEvent event) {
440             final AbstractList nodes = nodeList_.get();
441             if (null == nodes) {
442                 return;
443             }
444             if (nodes.attributeChangeSensitive_) {
445                 handleChangeOnCache(event);
446             }
447         }
448 
449         /**
450          * Clears the cached collection elements if required by the specified attribute change.
451          *
452          * @param event the attribute change event
453          */
454         private void handleChangeOnCache(final HtmlAttributeChangeEvent event) {
455             final AbstractList nodes = nodeList_.get();
456             if (null == nodes) {
457                 return;
458             }
459 
460             final EffectOnCache effectOnCache = nodes.effectOnCacheFunction_.apply(event);
461             if (EffectOnCache.NONE == effectOnCache) {
462                 return;
463             }
464             if (EffectOnCache.RESET == effectOnCache) {
465                 clearCache();
466             }
467         }
468 
469         private void clearCache() {
470             final AbstractList nodes = nodeList_.get();
471             if (null != nodes) {
472                 nodes.cachedElements_ = null;
473             }
474         }
475     }
476 
477     /**
478      * Returns the scriptable object for the specified element.
479      *
480      * @param object the object for which to get the scriptable
481      * @return the scriptable object
482      */
483     protected Scriptable getScriptableForElement(final Object object) {
484         if (object instanceof Scriptable scriptable) {
485             return scriptable;
486         }
487         return getScriptableFor(object);
488     }
489 
490     /**
491      * {@inheritDoc}
492      */
493     @Override
494     public void defineProperty(final VarScope scope,
495             final String propertyName, final Object delegateTo,
496             final Method getter, final Method setter, final int attributes) {
497         // length is defined on the prototype, don't define it again
498         if ("length".equals(propertyName) && getPrototype() != null) {
499             return;
500         }
501 
502         super.defineProperty(scope, propertyName, delegateTo, getter, setter, attributes);
503     }
504 
505     @Override
506     public Object getArrayElement(final int index) {
507         final List<DomNode> elements = getElements();
508         if (index >= 0 && index < elements.size()) {
509             return getScriptableForElement(elements.get(index));
510         }
511         return NOT_FOUND;
512     }
513 
514     @Override
515     public void setArrayElement(final int index, final Object value) {
516         // ignore
517     }
518 
519     @Override
520     public int getArrayLength() {
521         return getElements().size();
522     }
523 }