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 parentScope 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      * @param newValue the new value
135      */
136     public void setAvoidObjectDetection(final boolean newValue) {
137         avoidObjectDetection_ = newValue;
138     }
139 
140     /**
141      * @param effectOnCacheFunction the new function
142      */
143     public void setEffectOnCacheFunction(
144             final Function<HtmlAttributeChangeEvent, EffectOnCache> effectOnCacheFunction) {
145         if (effectOnCacheFunction == null) {
146             throw new NullPointerException("EffectOnCacheFunction can't be null");
147         }
148         effectOnCacheFunction_ = effectOnCacheFunction;
149     }
150 
151     /**
152      * @return elementSupplier
153      */
154     protected Supplier<List<DomNode>> getElementSupplier() {
155         return elementsSupplier_;
156     }
157 
158     /**
159      * Returns the elements whose associated host objects are available through this collection.
160      * @param elementsSupplier the new supplier
161      */
162     public void setElementsSupplier(final Supplier<List<DomNode>> elementsSupplier) {
163         if (elementsSupplier == null) {
164             throw new NullPointerException("ElementsSupplier can't be null");
165         }
166         elementsSupplier_ = elementsSupplier;
167     }
168 
169     /**
170      * @return isMatchingPredicate
171      */
172     protected Predicate<DomNode> getIsMatchingPredicate() {
173         return isMatchingPredicate_;
174     }
175 
176     /**
177      * Indicates if the node should belong to the collection.
178      * @param isMatchingPredicate the new predicate
179      */
180     public void setIsMatchingPredicate(final Predicate<DomNode> isMatchingPredicate) {
181         if (isMatchingPredicate == null) {
182             throw new NullPointerException("IsMatchingPredicate can't be null");
183         }
184         isMatchingPredicate_ = isMatchingPredicate;
185     }
186 
187     /**
188      * Private helper that retrieves the item or items corresponding to the specified
189      * index or key.
190      * @param o the index or key corresponding to the element or elements to return
191      * @return the element or elements corresponding to the specified index or key
192      */
193     protected Object getIt(final Object o) {
194         if (o instanceof Number n) {
195             final int i = n.intValue();
196             return get(i, this);
197         }
198         final String key = String.valueOf(o);
199         return get(key, this);
200     }
201 
202     @Override
203     public void setDomNode(final DomNode domNode, final boolean assignScriptObject) {
204         final DomNode oldDomNode = getDomNodeOrNull();
205 
206         super.setDomNode(domNode, assignScriptObject);
207 
208         if (oldDomNode != domNode) {
209             listenerRegistered_ = false;
210         }
211     }
212 
213     /**
214      * Gets the HTML elements from cache or retrieve them at first call.
215      * @return the list of {@link HtmlElement} contained in this collection
216      */
217     public List<DomNode> getElements() {
218         // a bit strange but we like to avoid sync
219         List<DomNode> cachedElements = cachedElements_;
220 
221         if (cachedElements == null) {
222             if (getParentScope() == null) {
223                 cachedElements = new ArrayList<>();
224             }
225             else {
226                 cachedElements = elementsSupplier_.get();
227             }
228             cachedElements_ = cachedElements;
229         }
230         registerListener();
231 
232         // maybe the cache was cleared in between
233         // then this returns the old state and never null
234         return cachedElements;
235     }
236 
237     private void registerListener() {
238         if (!listenerRegistered_) {
239             final DomNode domNode = getDomNodeOrNull();
240             if (domNode != null) {
241                 final DomHtmlAttributeChangeListenerImpl listener = new DomHtmlAttributeChangeListenerImpl(this);
242                 domNode.addDomChangeListener(listener);
243                 if (attributeChangeSensitive_) {
244                     if (domNode instanceof HtmlElement element) {
245                         element.addHtmlAttributeChangeListener(listener);
246                     }
247                     else if (domNode instanceof HtmlPage page) {
248                         page.addHtmlAttributeChangeListener(listener);
249                     }
250                 }
251                 listenerRegistered_ = true;
252             }
253         }
254     }
255 
256     /**
257      * Returns the element or elements that match the specified key. If it is the name
258      * of a property, the property value is returned. If it is the id of an element in
259      * the array, that element is returned. Finally, if it is the name of an element or
260      * elements in the array, then all those elements are returned. Otherwise,
261      * {@link #NOT_FOUND} is returned.
262      * {@inheritDoc}
263      */
264     @Override
265     protected Object getWithPreemption(final String name) {
266         // Test to see if we are trying to get the length of this collection?
267         // If so return NOT_FOUND here to let the property be retrieved using the prototype
268         if ("length".equals(name)) {
269             return NOT_FOUND;
270         }
271 
272         final List<DomNode> elements = getElements();
273 
274         // See if there is an element in the element array with the specified id.
275         final List<DomNode> matchingElements = new ArrayList<>();
276 
277         for (final DomNode next : elements) {
278             if (next instanceof DomElement element) {
279                 final String id = element.getId();
280                 if (name.equals(id)) {
281                     matchingElements.add(next);
282                 }
283             }
284         }
285 
286         if (matchingElements.size() == 1) {
287             return getScriptableForElement(matchingElements.get(0));
288         }
289         else if (!matchingElements.isEmpty()) {
290             final AbstractList collection = create(getDomNodeOrDie(), matchingElements);
291             collection.setAvoidObjectDetection(true);
292             return collection;
293         }
294 
295         // no element found by id, let's search by name
296         return getWithPreemptionByName(name, elements);
297     }
298 
299     /**
300      * Constructs a new instance with an initial cache value.
301      * @param parentScope the parent scope, on which we listen for changes
302      * @param initialElements the initial content for the cache
303      * @return the newly created instance
304      */
305     protected AbstractList create(final DomNode parentScope, final List<DomNode> initialElements) {
306         throw new IllegalAccessError("Creation of AbstractListInstances is not allowed.");
307     }
308 
309     /**
310      * Helper for {@link #getWithPreemption(String)} when finding by id doesn't get results.
311      * @param name the property name
312      * @param elements the children elements.
313      * @return {@link Scriptable#NOT_FOUND} if not found
314      */
315     protected Object getWithPreemptionByName(final String name, final List<DomNode> elements) {
316         final List<DomNode> matchingElements = new ArrayList<>();
317         for (final DomNode next : elements) {
318             if (next instanceof DomElement element) {
319                 final String nodeName = element.getAttributeDirect(DomElement.NAME_ATTRIBUTE);
320                 if (name.equals(nodeName)) {
321                     matchingElements.add(next);
322                 }
323             }
324         }
325 
326         if (matchingElements.isEmpty()) {
327             return NOT_FOUND;
328         }
329         else if (matchingElements.size() == 1) {
330             return getScriptableForElement(matchingElements.get(0));
331         }
332 
333         // many elements => build a sub collection
334         final DomNode domNode = getDomNodeOrNull();
335         final AbstractList collection = create(domNode, matchingElements);
336         collection.setAvoidObjectDetection(true);
337         return collection;
338     }
339 
340     /**
341      * Returns the length.
342      * @return the length
343      */
344     public int getLength() {
345         return getElements().size();
346     }
347 
348     /**
349      * {@inheritDoc}
350      */
351     @Override
352     public String toString() {
353         return getClass().getSimpleName() + " for " + getDomNodeOrNull();
354     }
355 
356     /**
357      * Called for the js "==".
358      * {@inheritDoc}
359      */
360     @Override
361     protected Object equivalentValues(final Object other) {
362         if (other == this) {
363             return Boolean.TRUE;
364         }
365         else if (other instanceof AbstractList otherArray) {
366             final DomNode domNode = getDomNodeOrNull();
367             final DomNode domNodeOther = otherArray.getDomNodeOrNull();
368             if (getClass() == other.getClass()
369                     && domNode == domNodeOther
370                     && getElements().equals(otherArray.getElements())) {
371                 return Boolean.TRUE;
372             }
373             return NOT_FOUND;
374         }
375 
376         return super.equivalentValues(other);
377     }
378 
379     private static final class DomHtmlAttributeChangeListenerImpl
380                                     implements DomChangeListener, HtmlAttributeChangeListener {
381 
382         private final transient WeakReference<AbstractList> nodeList_;
383 
384         DomHtmlAttributeChangeListenerImpl(final AbstractList nodeList) {
385             super();
386 
387             nodeList_ = new WeakReference<>(nodeList);
388         }
389 
390         /**
391          * {@inheritDoc}
392          */
393         @Override
394         public void nodeAdded(final DomChangeEvent event) {
395             clearCache();
396         }
397 
398         /**
399          * {@inheritDoc}
400          */
401         @Override
402         public void nodeDeleted(final DomChangeEvent event) {
403             clearCache();
404         }
405 
406         /**
407          * {@inheritDoc}
408          */
409         @Override
410         public void attributeAdded(final HtmlAttributeChangeEvent event) {
411             handleChangeOnCache(event);
412         }
413 
414         /**
415          * {@inheritDoc}
416          */
417         @Override
418         public void attributeRemoved(final HtmlAttributeChangeEvent event) {
419             handleChangeOnCache(event);
420         }
421 
422         /**
423          * {@inheritDoc}
424          */
425         @Override
426         public void attributeReplaced(final HtmlAttributeChangeEvent event) {
427             final AbstractList nodes = nodeList_.get();
428             if (null == nodes) {
429                 return;
430             }
431             if (nodes.attributeChangeSensitive_) {
432                 handleChangeOnCache(event);
433             }
434         }
435 
436         private void handleChangeOnCache(final HtmlAttributeChangeEvent event) {
437             final AbstractList nodes = nodeList_.get();
438             if (null == nodes) {
439                 return;
440             }
441 
442             final EffectOnCache effectOnCache = nodes.effectOnCacheFunction_.apply(event);
443             if (EffectOnCache.NONE == effectOnCache) {
444                 return;
445             }
446             if (EffectOnCache.RESET == effectOnCache) {
447                 clearCache();
448             }
449         }
450 
451         private void clearCache() {
452             final AbstractList nodes = nodeList_.get();
453             if (null != nodes) {
454                 nodes.cachedElements_ = null;
455             }
456         }
457     }
458 
459     /**
460      * Gets the scriptable for the provided element that may already be the right scriptable.
461      * @param object the object for which to get the scriptable
462      * @return the scriptable
463      */
464     protected Scriptable getScriptableForElement(final Object object) {
465         if (object instanceof Scriptable scriptable) {
466             return scriptable;
467         }
468         return getScriptableFor(object);
469     }
470 
471     /**
472      * {@inheritDoc}
473      */
474     @Override
475     public void defineProperty(final VarScope scope,
476             final String propertyName, final Object delegateTo,
477             final Method getter, final Method setter, final int attributes) {
478         // length is defined on the prototype, don't define it again
479         if ("length".equals(propertyName) && getPrototype() != null) {
480             return;
481         }
482 
483         super.defineProperty(scope, propertyName, delegateTo, getter, setter, attributes);
484     }
485 
486     @Override
487     public Object getArrayElement(final int index) {
488         final List<DomNode> elements = getElements();
489         if (index >= 0 && index < elements.size()) {
490             return getScriptableForElement(elements.get(index));
491         }
492         return NOT_FOUND;
493     }
494 
495     @Override
496     public void setArrayElement(final int index, final Object value) {
497         // ignore
498     }
499 
500     @Override
501     public int getArrayLength() {
502         return getElements().size();
503     }
504 }