Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
- Added `URL.toDart` and `Uri.toJS` extension methods.
- Added missing `Document` and `Window` pointer event getters: `onDrag*`,
`onTouch*`, `onMouse*`.
- Added `HTMLCollectionListWrapper` and `NodeListListWrapper` to support
mutable operations on node lists.
- Added `childNodesAsList` to `Node` and `childrenAsList` to `Element` via
extensions.
- Added `asList` to `NodeList` via extension.

## 1.1.1

Expand Down
16 changes: 16 additions & 0 deletions web/lib/src/helpers/extensions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import 'dart:convert';
import 'dart:js_interop';

import '../dom.dart';
import 'lists.dart';

export 'cross_origin.dart'
show CrossOriginContentWindowExtension, CrossOriginWindowExtension;
Expand Down Expand Up @@ -103,3 +104,18 @@ extension UriToURL on Uri {
}
}
}

extension NodeExtension on Node {
/// Returns [childNodes] as a modifiable [List].
List<Node> get childNodesAsList => NodeListListWrapper(this, childNodes);
}

extension ElementExtension on Element {
/// Returns [children] as a modifiable [List].
List<Element> get childrenAsList => HTMLCollectionListWrapper(this, children);
}

extension NodeListExtension on NodeList {
/// Returns node list as a modifiable [List].
List<Element> get asList => JSImmutableListWrapper(this);
}
309 changes: 308 additions & 1 deletion web/lib/src/helpers/lists.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import 'dart:collection';
import 'dart:js_interop';
import '../dom/dom.dart';

/// `_JSList` acts as a wrapper around a JS list object providing an interface to
/// access the list items and list length while also allowing us to specify the
Expand Down Expand Up @@ -65,7 +66,313 @@ class JSImmutableListWrapper<T extends JSObject, U extends JSObject>
if (length > 1) throw StateError('More than one element');
return first;
}
}

/// This mixin exists to avoid repetition in `NodeListListWrapper` and `HTMLCollectionListWrapper`
/// It can be also used for `HTMLCollection` and `NodeList` that is
/// [live](https://developer.mozilla.org/en-US/docs/Web/API/NodeList#live_vs._static_nodelists)
/// and can be safely modified at runtime.
/// This requires an instance of `P`, a container that elements would be added to or removed from.
abstract mixin class _LiveNodeListMixin<P extends Node, U extends Node> {
P get _parent;
_JSList<U> get _list;

bool contains(Object? element) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing this is an optimization over having to check the contents of the list.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Precisely. Just one JS call to compare parent as it is available instead of iterating. This is refactored code from dart:html remove method that was checking this anyway to return true/false.

// TODO(srujzs): migrate this ifs to isJSAny once we have it
// ignore: invalid_runtime_check_with_js_interop_types
if ((element is JSAny?) && (element?.isA<Node>() ?? false)) {
if ((element as Node).parentNode.strictEquals(_parent).toDart) {
return true;
}
}
return false;
}

bool remove(Object? element) {
if (contains(element)) {
_parent.removeChild(element as Node);
return true;
} else {
return false;
}
}

int get length => _list.length;

set length(int value) {
if (value > length) {
throw UnsupportedError('Cannot add empty nodes.');
}
for (var i = length - 1; i >= value; i--) {
_parent.removeChild(_list.item(i));
}
}

U operator [](int index) {
if (index > length || index < 0) {
throw IndexError.withLength(index, length, indexable: this);
}
return _list.item(index);
}

void operator []=(int index, U value) {
RangeError.checkValidRange(index, null, length);
_parent.replaceChild(value, _list.item(index));
}

void add(U value) {
_parent.appendChild(value);
}

void removeRange(int start, int end) {
RangeError.checkValidRange(start, end, length);
for (var i = 0; i < end - start; i++) {
_parent.removeChild(this[start]);
}
}

U removeAt(int index) {
final result = this[index];
_parent.removeChild(result);
return result;
}

void fillRange(int start, int end, [U? fill]) {
// without cloning the element we would end up with one `fill` instance
// this method does not make much sense in nodes lists
throw UnsupportedError('Cannot fillRange on Node list');
}

U get last;

U removeLast() {
final result = last;
_parent.removeChild(result);
return result;
}

void removeWhere(bool Function(U element) test) {
_filter(test, false);
}

void retainWhere(bool Function(U element) test) {
_filter(test, true);
}

Iterator<U> get iterator;

void _filter(bool Function(U element) test, bool requiredTestValue) {
// This implementation of removeWhere/retainWhere is more efficient
// than the default in ListBase. Child nodes can be removed in constant
// time.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can just short-circuit if removeMatching is false?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you mean to skip the loop at all then no because we use this for retainMatching and need to remove elements where test is false. This name came from dart:html, I have renamed this parameter to requiredTestValue to be less mind boggling. Does it make sense?

final i = iterator;
U? removeMe;
while (i.moveNext()) {
if (removeMe != null) {
_parent.removeChild(removeMe);
removeMe = null;
}
if (test(i.current) != requiredTestValue) {
removeMe = i.current;
}
}
if (removeMe != null) {
_parent.removeChild(removeMe);
removeMe = null;
}
}

void insert(int index, U element) {
if (index < 0 || index > length) {
throw RangeError.range(index, 0, length);
}
if (index == length) {
_parent.appendChild(element);
} else {
_parent.insertBefore(element, this[index]);
}
}

void addAll(Iterable<U> iterable) {
if (iterable is _LiveNodeListMixin) {
final otherList = iterable as _LiveNodeListMixin;
if (otherList._parent.strictEquals(_parent).toDart) {
throw ArgumentError('Cannot add nodes from same parent');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a no-op in _ChildNodeListLazy. Maybe we should return?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes but insertAll in dart:html was throwing this exception so I did this to unify this behavior.

Adding/inserting nodes from a parent to itself does not make much sense and I would consider doing this a bug and prefer to get an exception than for nothing to happen.

}
// Optimized route for copying between nodes.
for (var len = otherList.length; len > 0; --len) {
_parent.appendChild(otherList._parent.firstChild!);
}
}

for (var element in iterable) {
_parent.appendChild(element);
}
}

void insertAll(int index, Iterable<U> iterable) {
if (index == length) {
addAll(iterable);
} else {
final child = this[index];
if (iterable is _LiveNodeListMixin) {
final otherList = iterable as _LiveNodeListMixin;
if (otherList._parent.strictEquals(_parent).toDart) {
throw ArgumentError('Cannot add nodes from same parent');
}
// Optimized route for copying between nodes.
for (var len = otherList.length; len > 0; --len) {
_parent.insertBefore(otherList._parent.firstChild!, child);
}
} else {
for (var node in iterable) {
_parent.insertBefore(node, child);
}
}
}
}
}

/// Allows iterating `HTMLCollection` with `nextElementSibling` for optimisation and easier encapsulation
class _HTMLCollectionIterator implements Iterator<Element> {
@override
Element get current => _current!;

Element? _current;
bool start = true;

_HTMLCollectionIterator(this._current);

@override
U elementAt(int index) => this[index];
bool moveNext() {
if (start) {
start = false;
} else {
_current = _current?.nextElementSibling;
}
return _current != null;
}
}

/// Wrapper for `HTMLCollection` returned from `children` that implements modifiable list interface and allows easier DOM manipulation.
/// This is loosely based on `_ChildrenElementList` from `dart:html` to preserve compatibility
class HTMLCollectionListWrapper
with ListMixin<Element>, _LiveNodeListMixin<Element, Element> {
@override
final Element _parent;
@override
_JSList<Element> get _list => _JSList<Element>(_htmlCollection);

final HTMLCollection _htmlCollection;

HTMLCollectionListWrapper(this._parent, this._htmlCollection);

@override
Iterator<Element> get iterator =>
_HTMLCollectionIterator(_parent.firstElementChild);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Do we need a separate iterator class vs toList().iterator like dart:html does instead?

Copy link
Contributor Author

@fsw fsw Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added this to be able to abstract removeWhere / retainWhere / _filter logic from dart:html while preserving its underlying behavior. It was using same logic except nextSibling for childNodes and nextElementSibling for children.

Ive kept this as default iterator as this way we have less calls to underlying JS/DOM and should be faster.

Difference is in error handling (no ConcurrentModificationError is checked/thrown). As modifying list while iterating is discouraged anyway is this a big issue?

Here is a simple benchmark:

final div = document.createElement('div');
for (var i = 0; i < 10000; i++) {
  div.childNodesAsList..add(HTMLDivElement())..add(Text('txt'));
}

final listIterator = div.childNodesAsList.iterator; //original ListIterator
final nodeIterator = NodeListIterator(div.firstChild); //_NodeListIterator

void iterate(Iterator<Node> iterator) {
  var counter = 0;
  final start = window.performance.now();

  while (iterator.moveNext()) {
    iterator.current.nodeName;
    counter ++;
  }
  final end = window.performance.now();
  print('iterated over $counter elements in ${end - start}');
}

print('listIterator');
iterate(listIterator);
print('nodeIterator');
iterate(nodeIterator);

result with dart2wasm:

listIterator
iterated over 20000 elements in 9.5
nodeIterator
iterated over 20000 elements in 4.2999999998137355

result with dart2js:

listIterator
iterated over 20000 elements in 3.8999999999068677
nodeIterator
iterated over 20000 elements in 1.8999999999068677


@override
bool get isEmpty {
return _parent.firstElementChild == null;
}

@override
Element get first {
final result = _parent.firstElementChild;
if (result == null) throw StateError('No elements');
return result;
}

@override
Element get last {
final result = _parent.lastElementChild;
if (result == null) throw StateError('No elements');
return result;
}

@override
Element get single {
final l = length;
if (l == 0) throw StateError('No elements');
if (l > 1) throw StateError('More than one element');
return _parent.firstElementChild!;
}

@override
void clear() {
while (_parent.firstElementChild != null) {
_parent.removeChild(_parent.firstElementChild!);
}
}
}

/// Allows iterating `NodeList` with `nextSibling` for optimisation and easier encapsulation
class _NodeListIterator implements Iterator<Node> {
@override
Node get current => _current!;

Node? _current;
bool start = true;

_NodeListIterator(this._current);

@override
bool moveNext() {
if (start) {
start = false;
} else {
_current = _current?.nextSibling;
}
return _current != null;
}
}

/// Wrapper for `NodeList` returned from `childNodes` that implements modifiable list interface and allows easier DOM manipulation.
/// This is loosely based on `_ChildNodeListLazy` from `dart:html` to preserve compatibility
class NodeListListWrapper with ListMixin<Node>, _LiveNodeListMixin<Node, Node> {
@override
final Node _parent;
@override
_JSList<Node> get _list => _JSList<Node>(_nodeList);

final NodeList _nodeList;

NodeListListWrapper(this._parent, this._nodeList);

@override
Iterator<Node> get iterator => _NodeListIterator(_parent.firstChild);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above.


@override
bool get isEmpty {
return _parent.firstChild == null;
}

@override
Node get first {
final result = _parent.firstChild;
if (result == null) throw StateError('No elements');
return result;
}

@override
Node get last {
final result = _parent.lastChild;
if (result == null) throw StateError('No elements');
return result;
}

@override
Node get single {
final l = length;
if (l == 0) throw StateError('No elements');
if (l > 1) throw StateError('More than one element');
return _parent.firstChild!;
}

@override
void clear() {
while (_parent.firstChild != null) {
_parent.removeChild(_parent.firstChild!);
}
}
}
Loading