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
3 changes: 3 additions & 0 deletions web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
- Added `URL.toDart` and `Uri.toJS` extension methods.
- Added missing `Document` and `Window` pointer event getters: `onDrag*`,
`onTouch*`, `onMouse*`.
- Added `JSLiveNodeListWrapper` to support mutable operations on node lists.
- Added `childNodesAsList` to `Node` and `childrenAsList` to `Element` via
extensions.

## 1.1.1

Expand Down
12 changes: 12 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,14 @@ extension UriToURL on Uri {
}
}
}

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

Choose a reason for hiding this comment

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

Maybe this should be nodesAsList, because that was a special helper that dart:html provided in addition to childNodes (which was an immutable 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.

Hmm, I cant see nodesAsList but nodes in dart:html. I think this is less confusing 'nativeMethod+AsList' but up to you of course.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, sorry I meant nodes exists in dart:html. I think you bring up a good point though, I like the nativeMethod+AsList syntax even if it's not consistent with dart:html.

}

extension ElementExtension on Element {
/// Returns [children] as a modifiable [List].
List<Element> get childrenAsList =>
JSLiveNodeListWrapper<Element, HTMLCollection, Element>(this, children);
}
54 changes: 54 additions & 0 deletions 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 @@ -69,3 +70,56 @@ class JSImmutableListWrapper<T extends JSObject, U extends JSObject>
@override
U elementAt(int index) => this[index];
}

/// A wrapper for live node lists. `NodeList` and `HTMLCollection` that are
/// [live](https://developer.mozilla.org/en-US/docs/Web/API/NodeList#live_vs._static_nodelists)
/// can be safely modified at runtime. This requires an instance of `P`, a
/// container that elements would be added to or removed from.
class JSLiveNodeListWrapper<P extends Node, T extends JSObject, U extends Node>
Copy link
Contributor

Choose a reason for hiding this comment

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

dart:html has overrides for first and last for children that use firstElementChild and lastElementChild, respectively.

I think this is the same as item(0) and item(len - 1) for children, but may be worth double-checking.

remove as well is a bit tricky because we should likely use removeChild instead of the gap-closing that ListMixin does.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure if this is realiable source:
https://stackoverflow.com/questions/43324751/is-there-a-difference-between-children0-and-firstelementchild
seems difference is only in returned value when list is empty but we throw exceptions in both cases.

I've added remove. It was indeed tricky as type of parameter in List interface is Object?, please take a look if type checking i used makes sense.

extends JSImmutableListWrapper<T, U> {
final P parentNode;

JSLiveNodeListWrapper(this.parentNode, super.original);

@override
set length(int value) {
if (value > length) {
throw UnsupportedError('Cannot add null to live node List.');
}
for (var i = length - 1; i >= value; i--) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I believe this is new compared to the dart:html version. I think that's okay though since we were throwing in the dart:html version anyways.

Copy link
Contributor Author

@fsw fsw Dec 12, 2025

Choose a reason for hiding this comment

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

yes, it is here to reuse JSImmutableListWrapper logic without overriding many methods

parentNode.removeChild(_jsList.item(i));
}
}

@override
void operator []=(int index, U value) {
RangeError.checkValidRange(index, null, length);
parentNode.replaceChild(value, _jsList.item(index));
}

@override
void add(U element) {
// `ListMixin` implementation only works for lists that allow `null`.
parentNode.appendChild(element);
}

@override
bool remove(Object? element) {
// ignore: invalid_runtime_check_with_js_interop_types
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's add a todo on me to migrate this to isJSAny once we have it.

if ((element is JSAny?) && (element?.isA<Node>() ?? false)) {
if ((element as Node).parentNode == parentNode) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

as remove takes Object? as argument, this 2 ifs were created by trial and error to work in both JS and WASM to determine if argument is indeed element of this list.
identical() (that was used in dart:html) was returning false in wasm here (maybe a separate issue in wasm?)
Maybe there is a better way to do this?

Copy link
Contributor

@srujzs srujzs Dec 17, 2025

Choose a reason for hiding this comment

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

Try strictEquals in dart:js_interop, that should do === which is more akin to identical. identical will indeed fail because on Wasm, these are boxes. Generally always prefer == or strictEquals as that does interop.

parentNode.removeChild(element);
return true;
}
}
return false;
}

@override
void removeRange(int start, int end) {
RangeError.checkValidRange(start, end, length);
for (var i = 0; i < end - start; i++) {
parentNode.removeChild(this[start]);
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be start + i? end is non-inclusive as well so that might be another issue.

I think this would be less confusing if we just modified the given parameter e.g.

for (; start < end; start++) {
  parentNode.removeChild(this[start]);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

d'oh, indeed I made end inclusive, fixed and added tests.

But this[start] is OK. This does not look intuitive but removeChild(N) shifts node list and element N+1th becomes Nth immediately. So for removeRange(5, 8) we want to call removeChild(5) 3 times

We can do something like:

for (; start < end; end--) {
  parentNode.removeChild(this[start]);
}

But not sure if this would be less confusing.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah yes, the list is getting readjusted as you're removing elements. And yeah, I don't think the end-- makes this any less confusing so this is fine.

}
}
}
78 changes: 78 additions & 0 deletions web/test/helpers_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,84 @@ void main() {
expect(() => dartList[0], returnsNormally);
});

test('modify child nodes using JSLiveNodeListWrapper', () {
final div = (document.createElement('div'))
..append(document.createElement('div')..textContent = 'e1')
..append(document.createElement('div')..textContent = 'e2')
..append(document.createElement('div')..textContent = 'e3');

final childNodesList = div.childNodesAsList;
final childrenList = div.childrenAsList;

// Ensure initial list length is correct.
expect(childNodesList.length, 3);
expect(childrenList.length, 3);

childrenList.removeWhere((node) => node.textContent == 'e2');

// Ensure both list were updated.
expect(childNodesList.length, 2);
expect(childrenList.length, 2);

// add node via children
childrenList.add(document.createElement('div')..textContent = 'e4');
// add node via childNodes
childNodesList.add(document.createElement('div')..textContent = 'e5');
// add node directly to parent
div.appendChild(document.createElement('div')..textContent = 'e6');

// Ensure 3 elements were added to both lists
expect(childNodesList.length, 5);
expect(childrenList.length, 5);

// add only text nodes
childNodesList
.addAll([document.createTextNode('t1'), document.createTextNode('t2')]);

// Ensure only childNodes list changed
expect(childNodesList.map((e) => e.textContent).join(), 'e1e3e4e5e6t1t2');
expect(childrenList.map((e) => e.textContent).join(), 'e1e3e4e5e6');

// replace element with text node
childNodesList[2] = document.createTextNode('t3');

// test retainWhere, keep Elements only
childNodesList.retainWhere((e) => e.isA<Element>());

// Ensure only text nodes were removed
expect(childNodesList.length, 4);
expect(childrenList.length, 4);

// test removeRange
childrenList.removeRange(1, 3);

// Ensure 2 elements were removed
expect(childNodesList.map((e) => e.textContent).join(), 'e1e6');
expect(childrenList.map((e) => e.textContent).join(), 'e1e6');

// test []= range exception
expect(() => childNodesList[10] = document.createTextNode('nope'),
throwsRangeError);

// test remove
final removeMe = childNodesList[0];
expect(childNodesList.remove(removeMe), true);
expect(childNodesList.length, 1);

// test remove with objects that are not in list
expect(childNodesList.remove(removeMe), false);
expect(childNodesList.remove(null), false);
// ignore: collection_methods_unrelated_type
expect(childNodesList.remove('test'), false);

final differentParentDiv = document.createElement('div');
document.createElement('div').append(differentParentDiv);
expect(childNodesList.remove(differentParentDiv), false);

// test if nothing was removed
expect(childNodesList.length, 1);
});

test('responseHeaders transforms headers into a map', () async {
final request = XMLHttpRequest()
..open('GET', 'www.google.com')
Expand Down