From 77c23f9a21cecf105a88450863dc3f62be5c9a35 Mon Sep 17 00:00:00 2001 From: Franciszek Szczepan Wawrzak Date: Thu, 11 Dec 2025 12:32:23 +0100 Subject: [PATCH 01/13] support for mutable nodes lists --- web/lib/src/helpers/extensions.dart | 12 ++++++++ web/lib/src/helpers/lists.dart | 33 +++++++++++++++++++++ web/test/helpers_test.dart | 46 +++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) diff --git a/web/lib/src/helpers/extensions.dart b/web/lib/src/helpers/extensions.dart index 555b0e23..33d7f764 100644 --- a/web/lib/src/helpers/extensions.dart +++ b/web/lib/src/helpers/extensions.dart @@ -25,6 +25,7 @@ import 'dart:convert'; import 'dart:js_interop'; import '../dom.dart'; +import 'lists.dart'; export 'cross_origin.dart' show CrossOriginContentWindowExtension, CrossOriginWindowExtension; @@ -103,3 +104,14 @@ extension UriToURL on Uri { } } } + +extension NodeExtension on Node { + /// Returns [childNodes] ad modifiable [List] + List get childNodesAsList => JSLiveNodeListWrapper(this, childNodes); +} + +extension ElementExtension on Element { + /// Returns [children] ad modifiable [List] + List get childrenAsList => + JSLiveNodeListWrapper(this, children); +} diff --git a/web/lib/src/helpers/lists.dart b/web/lib/src/helpers/lists.dart index 18e0f936..9a214a1a 100644 --- a/web/lib/src/helpers/lists.dart +++ b/web/lib/src/helpers/lists.dart @@ -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 @@ -69,3 +70,35 @@ class JSImmutableListWrapper @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

+ extends JSImmutableListWrapper { + 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--) { + parentNode.removeChild(_jsList.item(i)); + } + } + + @override + void operator []=(int index, Node value) { + parentNode.replaceChild(_jsList.item(index), value); + } + + @override + void add(Node element) { + // `ListMixin` implementation only works for lists that allow `null`. + parentNode.appendChild(element); + } +} diff --git a/web/test/helpers_test.dart b/web/test/helpers_test.dart index ce5d425c..0f3b543f 100644 --- a/web/test/helpers_test.dart +++ b/web/test/helpers_test.dart @@ -40,6 +40,52 @@ void main() { expect(() => dartList[0], returnsNormally); }); + test('modify child nodes using JSLiveNodeListWrapper', () { + final div = (document.createElement('div')) + ..append(document.createElement('div')..textContent = '1') + ..append(document.createElement('div')..textContent = '2') + ..append(document.createElement('div')..textContent = '3'); + + 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 == '2'); + + // Ensure both list were updated. + expect(childNodesList.length, 2); + expect(childrenList.length, 2); + + // add node via children + childrenList.add(document.createElement('div')..textContent = '4'); + // add node via childNodes + childNodesList.add(document.createElement('div')..textContent = '5'); + // add node directly to parent + div.appendChild(document.createElement('div')..textContent = '6'); + + // Ensure 3 elements were added to both lists + expect(childNodesList.length, 5); + expect(childrenList.length, 5); + + // add only text nodes + childNodesList.addAll( + [document.createTextNode('txt1'), document.createTextNode('txt2')]); + + // Ensure only childNodes list changed + expect(childNodesList.length, 7); + expect(childrenList.length, 5); + + // test retainWhere, keep Elements only + childNodesList.retainWhere((e) => e.isA()); + + // Ensure only text nodes were removed + expect(childNodesList.length, 5); + expect(childrenList.length, 5); + }); + test('responseHeaders transforms headers into a map', () async { final request = XMLHttpRequest() ..open('GET', 'www.google.com') From c3d91d852e0c77555517450096c5667f199ac5dc Mon Sep 17 00:00:00 2001 From: Franciszek Szczepan Wawrzak Date: Thu, 11 Dec 2025 14:05:49 +0100 Subject: [PATCH 02/13] changelog entry for mutable lists --- web/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 28c66938..9af0d360 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -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 nodes lists. +- Added `childNodesAsList` to `Node` and `childrenAsList` to `Element` via + extensions. ## 1.1.1 From b2c91fd96881e4686bf7af0ffdc449cd04a77376 Mon Sep 17 00:00:00 2001 From: Franciszek Szczepan Wawrzak Date: Thu, 11 Dec 2025 14:11:22 +0100 Subject: [PATCH 03/13] bugfix mutable list []= operator and add test --- web/lib/src/helpers/lists.dart | 2 +- web/test/helpers_test.dart | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/web/lib/src/helpers/lists.dart b/web/lib/src/helpers/lists.dart index 9a214a1a..799a9da7 100644 --- a/web/lib/src/helpers/lists.dart +++ b/web/lib/src/helpers/lists.dart @@ -93,7 +93,7 @@ class JSLiveNodeListWrapper

@override void operator []=(int index, Node value) { - parentNode.replaceChild(_jsList.item(index), value); + parentNode.replaceChild(value, _jsList.item(index)); } @override diff --git a/web/test/helpers_test.dart b/web/test/helpers_test.dart index 0f3b543f..a805c297 100644 --- a/web/test/helpers_test.dart +++ b/web/test/helpers_test.dart @@ -78,12 +78,15 @@ void main() { expect(childNodesList.length, 7); expect(childrenList.length, 5); + // replace element with text node + childNodesList[2] = document.createTextNode('txt3'); + // test retainWhere, keep Elements only childNodesList.retainWhere((e) => e.isA()); // Ensure only text nodes were removed - expect(childNodesList.length, 5); - expect(childrenList.length, 5); + expect(childNodesList.length, 4); + expect(childrenList.length, 4); }); test('responseHeaders transforms headers into a map', () async { From 37605f0184e699db5b625ef7fa7a825b50c94425 Mon Sep 17 00:00:00 2001 From: Franciszek Szczepan Wawrzak Date: Thu, 11 Dec 2025 15:31:31 +0100 Subject: [PATCH 04/13] applied gemini suggestions, added more tests --- web/CHANGELOG.md | 2 +- web/lib/src/helpers/extensions.dart | 4 ++-- web/lib/src/helpers/lists.dart | 13 +++++++++++-- web/test/helpers_test.dart | 11 +++++++++++ 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 9af0d360..29c32761 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -8,7 +8,7 @@ - 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 nodes lists. +- Added `JSLiveNodeListWrapper` to support mutable operations on node lists. - Added `childNodesAsList` to `Node` and `childrenAsList` to `Element` via extensions. diff --git a/web/lib/src/helpers/extensions.dart b/web/lib/src/helpers/extensions.dart index 33d7f764..afe9164b 100644 --- a/web/lib/src/helpers/extensions.dart +++ b/web/lib/src/helpers/extensions.dart @@ -106,12 +106,12 @@ extension UriToURL on Uri { } extension NodeExtension on Node { - /// Returns [childNodes] ad modifiable [List] + /// Returns [childNodes] as a modifiable [List] List get childNodesAsList => JSLiveNodeListWrapper(this, childNodes); } extension ElementExtension on Element { - /// Returns [children] ad modifiable [List] + /// Returns [children] as a modifiable [List] List get childrenAsList => JSLiveNodeListWrapper(this, children); } diff --git a/web/lib/src/helpers/lists.dart b/web/lib/src/helpers/lists.dart index 799a9da7..0c7b69b0 100644 --- a/web/lib/src/helpers/lists.dart +++ b/web/lib/src/helpers/lists.dart @@ -92,13 +92,22 @@ class JSLiveNodeListWrapper

} @override - void operator []=(int index, Node value) { + void operator []=(int index, U value) { + RangeError.checkValidRange(index, null, length); parentNode.replaceChild(value, _jsList.item(index)); } @override - void add(Node element) { + void add(U element) { // `ListMixin` implementation only works for lists that allow `null`. parentNode.appendChild(element); } + + @override + void removeRange(int start, int end) { + RangeError.checkValidRange(start, end, length); + for (var i = 0; i < end - start + 1; i++) { + parentNode.removeChild(this[start]); + } + } } diff --git a/web/test/helpers_test.dart b/web/test/helpers_test.dart index a805c297..4c73de96 100644 --- a/web/test/helpers_test.dart +++ b/web/test/helpers_test.dart @@ -87,6 +87,17 @@ void main() { // Ensure only text nodes were removed expect(childNodesList.length, 4); expect(childrenList.length, 4); + + // test removeRange + childrenList.removeRange(1, 2); + + // Ensure 2 elements were removed + expect(childNodesList.length, 2); + expect(childrenList.length, 2); + + // test []= range exception + expect(() => childNodesList[10] = document.createTextNode('nope'), throwsRangeError); + }); test('responseHeaders transforms headers into a map', () async { From 8435092476319c8f21feb4ea41898860a2b89110 Mon Sep 17 00:00:00 2001 From: Franciszek Szczepan Wawrzak Date: Thu, 11 Dec 2025 20:15:53 +0100 Subject: [PATCH 05/13] fix format --- web/test/helpers_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/test/helpers_test.dart b/web/test/helpers_test.dart index 4c73de96..5d6d29d6 100644 --- a/web/test/helpers_test.dart +++ b/web/test/helpers_test.dart @@ -96,8 +96,8 @@ void main() { expect(childrenList.length, 2); // test []= range exception - expect(() => childNodesList[10] = document.createTextNode('nope'), throwsRangeError); - + expect(() => childNodesList[10] = document.createTextNode('nope'), + throwsRangeError); }); test('responseHeaders transforms headers into a map', () async { From 17ba46476be0ef4cbb706551405f06346a418474 Mon Sep 17 00:00:00 2001 From: Franciszek S Wawrzak Date: Thu, 11 Dec 2025 20:45:05 +0100 Subject: [PATCH 06/13] final member Co-authored-by: Kevin Moore --- web/lib/src/helpers/lists.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/lib/src/helpers/lists.dart b/web/lib/src/helpers/lists.dart index 0c7b69b0..2075e615 100644 --- a/web/lib/src/helpers/lists.dart +++ b/web/lib/src/helpers/lists.dart @@ -77,7 +77,7 @@ class JSImmutableListWrapper /// container that elements would be added to or removed from. class JSLiveNodeListWrapper

extends JSImmutableListWrapper { - P parentNode; + final P parentNode; JSLiveNodeListWrapper(this.parentNode, super.original); From f9d92267be9abf35fadee11080258abf20e16653 Mon Sep 17 00:00:00 2001 From: Franciszek Szczepan Wawrzak Date: Fri, 12 Dec 2025 16:36:34 +0100 Subject: [PATCH 07/13] removeRange bugfix, code review suggestions --- web/lib/src/helpers/extensions.dart | 4 ++-- web/lib/src/helpers/lists.dart | 2 +- web/test/helpers_test.dart | 30 ++++++++++++++--------------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/web/lib/src/helpers/extensions.dart b/web/lib/src/helpers/extensions.dart index afe9164b..6bb3e6c1 100644 --- a/web/lib/src/helpers/extensions.dart +++ b/web/lib/src/helpers/extensions.dart @@ -106,12 +106,12 @@ extension UriToURL on Uri { } extension NodeExtension on Node { - /// Returns [childNodes] as a modifiable [List] + /// Returns [childNodes] as a modifiable [List]. List get childNodesAsList => JSLiveNodeListWrapper(this, childNodes); } extension ElementExtension on Element { - /// Returns [children] as a modifiable [List] + /// Returns [children] as a modifiable [List]. List get childrenAsList => JSLiveNodeListWrapper(this, children); } diff --git a/web/lib/src/helpers/lists.dart b/web/lib/src/helpers/lists.dart index 2075e615..fd6f2f89 100644 --- a/web/lib/src/helpers/lists.dart +++ b/web/lib/src/helpers/lists.dart @@ -106,7 +106,7 @@ class JSLiveNodeListWrapper

@override void removeRange(int start, int end) { RangeError.checkValidRange(start, end, length); - for (var i = 0; i < end - start + 1; i++) { + for (var i = 0; i < end - start; i++) { parentNode.removeChild(this[start]); } } diff --git a/web/test/helpers_test.dart b/web/test/helpers_test.dart index 5d6d29d6..ea0f1c94 100644 --- a/web/test/helpers_test.dart +++ b/web/test/helpers_test.dart @@ -42,9 +42,9 @@ void main() { test('modify child nodes using JSLiveNodeListWrapper', () { final div = (document.createElement('div')) - ..append(document.createElement('div')..textContent = '1') - ..append(document.createElement('div')..textContent = '2') - ..append(document.createElement('div')..textContent = '3'); + ..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; @@ -53,33 +53,33 @@ void main() { expect(childNodesList.length, 3); expect(childrenList.length, 3); - childrenList.removeWhere((node) => node.textContent == '2'); + 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 = '4'); + childrenList.add(document.createElement('div')..textContent = 'e4'); // add node via childNodes - childNodesList.add(document.createElement('div')..textContent = '5'); + childNodesList.add(document.createElement('div')..textContent = 'e5'); // add node directly to parent - div.appendChild(document.createElement('div')..textContent = '6'); + 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('txt1'), document.createTextNode('txt2')]); + childNodesList + .addAll([document.createTextNode('t1'), document.createTextNode('t2')]); // Ensure only childNodes list changed - expect(childNodesList.length, 7); - expect(childrenList.length, 5); + 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('txt3'); + childNodesList[2] = document.createTextNode('t3'); // test retainWhere, keep Elements only childNodesList.retainWhere((e) => e.isA()); @@ -89,11 +89,11 @@ void main() { expect(childrenList.length, 4); // test removeRange - childrenList.removeRange(1, 2); + childrenList.removeRange(1, 3); // Ensure 2 elements were removed - expect(childNodesList.length, 2); - expect(childrenList.length, 2); + 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'), From 194b07eab76ae33f3802bbc88679f0392625c827 Mon Sep 17 00:00:00 2001 From: Franciszek Szczepan Wawrzak Date: Fri, 12 Dec 2025 17:56:46 +0100 Subject: [PATCH 08/13] call removeNode on remove instead of _closeGap --- web/lib/src/helpers/lists.dart | 11 +++++++++++ web/test/helpers_test.dart | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/web/lib/src/helpers/lists.dart b/web/lib/src/helpers/lists.dart index fd6f2f89..75902931 100644 --- a/web/lib/src/helpers/lists.dart +++ b/web/lib/src/helpers/lists.dart @@ -103,6 +103,17 @@ class JSLiveNodeListWrapper

parentNode.appendChild(element); } + @override + bool remove(Object? element) { + if ((element as JSAny?)?.isA() ?? false) { + if (identical((element as Node).parentNode, parentNode)) { + parentNode.removeChild(element); + return true; + } + } + return false; + } + @override void removeRange(int start, int end) { RangeError.checkValidRange(start, end, length); diff --git a/web/test/helpers_test.dart b/web/test/helpers_test.dart index ea0f1c94..333583e4 100644 --- a/web/test/helpers_test.dart +++ b/web/test/helpers_test.dart @@ -98,6 +98,18 @@ void main() { // 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 non existing element + expect(childNodesList.remove(removeMe), false); + expect(childNodesList.remove(null), false); + // ignore: collection_methods_unrelated_type + expect(childNodesList.remove('test'), false); + expect(childNodesList.length, 1); }); test('responseHeaders transforms headers into a map', () async { From 8dcf595a09a7b5d31629754e5905c97f50ff288e Mon Sep 17 00:00:00 2001 From: Franciszek Szczepan Wawrzak Date: Sat, 13 Dec 2025 18:14:00 +0100 Subject: [PATCH 09/13] fix remove for wasm compilation --- web/lib/src/helpers/lists.dart | 5 +++-- web/test/helpers_test.dart | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/web/lib/src/helpers/lists.dart b/web/lib/src/helpers/lists.dart index 75902931..aaff036c 100644 --- a/web/lib/src/helpers/lists.dart +++ b/web/lib/src/helpers/lists.dart @@ -105,8 +105,9 @@ class JSLiveNodeListWrapper

@override bool remove(Object? element) { - if ((element as JSAny?)?.isA() ?? false) { - if (identical((element as Node).parentNode, parentNode)) { + // ignore: invalid_runtime_check_with_js_interop_types + if ((element is JSAny?) && (element?.isA() ?? false)) { + if ((element as Node).parentNode == parentNode) { parentNode.removeChild(element); return true; } diff --git a/web/test/helpers_test.dart b/web/test/helpers_test.dart index 333583e4..9b23a817 100644 --- a/web/test/helpers_test.dart +++ b/web/test/helpers_test.dart @@ -104,11 +104,13 @@ void main() { expect(childNodesList.remove(removeMe), true); expect(childNodesList.length, 1); - // test remove non existing element + // 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); + expect(childNodesList.remove(document.createTextNode('t1')), false); + expect(childNodesList.length, 1); }); From c7bc01080caf942d0c4ab148b0bce29b20f077a2 Mon Sep 17 00:00:00 2001 From: Franciszek Szczepan Wawrzak Date: Sat, 13 Dec 2025 18:24:47 +0100 Subject: [PATCH 10/13] proper test for removal of node with different parent --- web/test/helpers_test.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/test/helpers_test.dart b/web/test/helpers_test.dart index 9b23a817..ad102e8e 100644 --- a/web/test/helpers_test.dart +++ b/web/test/helpers_test.dart @@ -109,8 +109,12 @@ void main() { expect(childNodesList.remove(null), false); // ignore: collection_methods_unrelated_type expect(childNodesList.remove('test'), false); - expect(childNodesList.remove(document.createTextNode('t1')), 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); }); From 5a4a1e9e3482bf0775a812093de55f9da1ffcd3b Mon Sep 17 00:00:00 2001 From: Franciszek Szczepan Wawrzak Date: Thu, 18 Dec 2025 16:57:16 +0100 Subject: [PATCH 11/13] update implementation to be more compatible with dart:html respective versions --- web/lib/src/helpers/extensions.dart | 10 +- web/lib/src/helpers/lists.dart | 313 +++++++++++++++++++++++++--- web/test/helpers_test.dart | 92 ++++++-- 3 files changed, 360 insertions(+), 55 deletions(-) diff --git a/web/lib/src/helpers/extensions.dart b/web/lib/src/helpers/extensions.dart index 6bb3e6c1..af1af2c6 100644 --- a/web/lib/src/helpers/extensions.dart +++ b/web/lib/src/helpers/extensions.dart @@ -107,11 +107,15 @@ extension UriToURL on Uri { extension NodeExtension on Node { /// Returns [childNodes] as a modifiable [List]. - List get childNodesAsList => JSLiveNodeListWrapper(this, childNodes); + List get childNodesAsList => NodeListListWrapper(this, childNodes); } extension ElementExtension on Element { /// Returns [children] as a modifiable [List]. - List get childrenAsList => - JSLiveNodeListWrapper(this, children); + List get childrenAsList => HTMLCollectionListWrapper(this, children); +} + +extension NodeListExtension on NodeList { + /// Returns node list as a modifiable [List]. + List get asList => JSImmutableListWrapper(this); } diff --git a/web/lib/src/helpers/lists.dart b/web/lib/src/helpers/lists.dart index aaff036c..595de38a 100644 --- a/web/lib/src/helpers/lists.dart +++ b/web/lib/src/helpers/lists.dart @@ -66,60 +66,313 @@ class JSImmutableListWrapper if (length > 1) throw StateError('More than one element'); return first; } - - @override - U elementAt(int index) => this[index]; } -/// A wrapper for live node lists. `NodeList` and `HTMLCollection` that are +/// 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) -/// 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

- extends JSImmutableListWrapper { - final P parentNode; +/// 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 get _parent; + _JSList get _list; + + bool contains(Object? element) { + // 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() ?? false)) { + if ((element as Node).parentNode == _parent) { + return true; + } + } + return false; + } + + bool remove(Object? element) { + if (contains(element)) { + _parent.removeChild(element as Node); + return true; + } else { + return false; + } + } - JSLiveNodeListWrapper(this.parentNode, super.original); + int get length => _list.length; - @override set length(int value) { if (value > length) { - throw UnsupportedError('Cannot add null to live node List.'); + throw UnsupportedError('Cannot add empty nodes.'); } for (var i = length - 1; i >= value; i--) { - parentNode.removeChild(_jsList.item(i)); + _parent.removeChild(_list.item(i)); } } - @override + 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); - parentNode.replaceChild(value, _jsList.item(index)); + _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, true); + } + + void retainWhere(bool Function(U element) test) { + _filter(test, false); + } + + Iterator get iterator; + + void _filter(bool Function(U element) test, bool removeMatching) { + // This implementation of removeWhere/retainWhere is more efficient + // than the default in ListBase. Child nodes can be removed in constant + // time. + final i = iterator; + U? removeMe; + while (i.moveNext()) { + if (removeMe != null) { + _parent.removeChild(removeMe); + removeMe = null; + } + if (test(i.current) == removeMatching) { + 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 iterable) { + 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.appendChild(otherList._parent.firstChild!); + } + } + + for (var element in iterable) { + _parent.appendChild(element); + } + } + + void insertAll(int index, Iterable 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 { + @override + Element get current => _current!; + + Element? _current; + bool start = true; + + _HTMLCollectionIterator(this._current); + + @override + 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, _LiveNodeListMixin { + @override + final Element _parent; + @override + _JSList get _list => _JSList(_htmlCollection); + + final HTMLCollection _htmlCollection; + + HTMLCollectionListWrapper(this._parent, this._htmlCollection); + + @override + Iterator get iterator => + _HTMLCollectionIterator(_parent.firstElementChild); + + @override + bool get isEmpty { + return _parent.firstElementChild == null; } @override - void add(U element) { - // `ListMixin` implementation only works for lists that allow `null`. - parentNode.appendChild(element); + Element get first { + final result = _parent.firstElementChild; + if (result == null) throw StateError('No elements'); + return result; } @override - bool remove(Object? element) { - // ignore: invalid_runtime_check_with_js_interop_types - if ((element is JSAny?) && (element?.isA() ?? false)) { - if ((element as Node).parentNode == parentNode) { - parentNode.removeChild(element); - return true; - } + 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!); } - return false; } +} +/// Allows iterating `NodeList` with `nextSibling` for optimisation and easier encapsulation +class _NodeListIterator implements Iterator { @override - void removeRange(int start, int end) { - RangeError.checkValidRange(start, end, length); - for (var i = 0; i < end - start; i++) { - parentNode.removeChild(this[start]); + 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, _LiveNodeListMixin { + @override + final Node _parent; + @override + _JSList get _list => _JSList(_nodeList); + + final NodeList _nodeList; + + NodeListListWrapper(this._parent, this._nodeList); + + @override + Iterator get iterator => _NodeListIterator(_parent.firstChild); + + @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!); } } } diff --git a/web/test/helpers_test.dart b/web/test/helpers_test.dart index ad102e8e..4954e2e0 100644 --- a/web/test/helpers_test.dart +++ b/web/test/helpers_test.dart @@ -41,6 +41,13 @@ void main() { }); test('modify child nodes using JSLiveNodeListWrapper', () { + void expectNodeListEquals(List list, List contents) { + expect(list.length, contents.length); + for (var i = 0; i < contents.length; i++) { + expect(list[i].textContent, contents[i]); + } + } + final div = (document.createElement('div')) ..append(document.createElement('div')..textContent = 'e1') ..append(document.createElement('div')..textContent = 'e2') @@ -49,15 +56,23 @@ void main() { final childNodesList = div.childNodesAsList; final childrenList = div.childrenAsList; - // Ensure initial list length is correct. - expect(childNodesList.length, 3); - expect(childrenList.length, 3); + // Ensure initial lists are correct. + expectNodeListEquals(childNodesList, ['e1', 'e2', 'e3']); + expectNodeListEquals(childrenList, ['e1', 'e2', 'e3']); childrenList.removeWhere((node) => node.textContent == 'e2'); // Ensure both list were updated. - expect(childNodesList.length, 2); - expect(childrenList.length, 2); + expectNodeListEquals(childNodesList, ['e1', 'e3']); + expectNodeListEquals(childrenList, ['e1', 'e3']); + + // add only text nodes + childNodesList + .addAll([document.createTextNode('t1'), document.createTextNode('t2')]); + + // Ensure only childNodes list changed + expectNodeListEquals(childNodesList, ['e1', 'e3', 't1', 't2']); + expectNodeListEquals(childrenList, ['e1', 'e3']); // add node via children childrenList.add(document.createElement('div')..textContent = 'e4'); @@ -67,33 +82,26 @@ void main() { 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'); + expectNodeListEquals( + childNodesList, ['e1', 'e3', 't1', 't2', 'e4', 'e5', 'e6']); + expectNodeListEquals(childrenList, ['e1', 'e3', 'e4', 'e5', 'e6']); // replace element with text node - childNodesList[2] = document.createTextNode('t3'); + childNodesList[4] = document.createTextNode('t3'); // test retainWhere, keep Elements only childNodesList.retainWhere((e) => e.isA()); // Ensure only text nodes were removed - expect(childNodesList.length, 4); - expect(childrenList.length, 4); + expectNodeListEquals(childNodesList, ['e1', 'e3', 'e5', 'e6']); + expectNodeListEquals(childrenList, ['e1', 'e3', 'e5', 'e6']); // 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'); + expectNodeListEquals(childNodesList, ['e1', 'e6']); + expectNodeListEquals(childrenList, ['e1', 'e6']); // test []= range exception expect(() => childNodesList[10] = document.createTextNode('nope'), @@ -102,7 +110,7 @@ void main() { // test remove final removeMe = childNodesList[0]; expect(childNodesList.remove(removeMe), true); - expect(childNodesList.length, 1); + expectNodeListEquals(childNodesList, ['e6']); // test remove with objects that are not in list expect(childNodesList.remove(removeMe), false); @@ -115,7 +123,47 @@ void main() { expect(childNodesList.remove(differentParentDiv), false); // test if nothing was removed - expect(childNodesList.length, 1); + expectNodeListEquals(childNodesList, ['e6']); + + final newTextNodes = [ + document.createTextNode('t3'), + document.createTextNode('t4') + ]; + final newDiv = (document.createElement('div')) + ..append(document.createElement('div')..textContent = 'e7') + ..append(document.createElement('div')..textContent = 'e8'); + + // adding text nodes via addAll + childNodesList.addAll(newTextNodes); + expectNodeListEquals(childNodesList, ['e6', 't3', 't4']); + expectNodeListEquals(childrenList, ['e6']); + + // adding div nodes from other element + childrenList.addAll(newDiv.childrenAsList); + expectNodeListEquals(childNodesList, ['e6', 't3', 't4', 'e7', 'e8']); + expectNodeListEquals(childrenList, ['e6', 'e7', 'e8']); + + // adding from self should throw exception + expect(() => childrenList.addAll(div.childrenAsList), throwsArgumentError); + expect( + () => childNodesList.addAll(div.childNodesAsList), throwsArgumentError); + + // insertAll test + childNodesList.insertAll( + 1, [document.createTextNode('t5'), document.createTextNode('t6')]); + expectNodeListEquals( + childNodesList, ['e6', 't5', 't6', 't3', 't4', 'e7', 'e8']); + expectNodeListEquals(childrenList, ['e6', 'e7', 'e8']); + + // empty elements list + childrenList.clear(); + expectNodeListEquals(childNodesList, ['t5', 't6', 't3', 't4']); + expectNodeListEquals(childrenList, []); + + // empty both lists + childNodesList.clear(); + expectNodeListEquals(childNodesList, []); + expectNodeListEquals(childrenList, []); }); test('responseHeaders transforms headers into a map', () async { From 823351b4f56fe8b438dca5e55574666fc7216322 Mon Sep 17 00:00:00 2001 From: Franciszek Szczepan Wawrzak Date: Thu, 18 Dec 2025 18:55:09 +0100 Subject: [PATCH 12/13] improve checking for equality --- web/lib/src/helpers/lists.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/lib/src/helpers/lists.dart b/web/lib/src/helpers/lists.dart index 595de38a..bb78c61e 100644 --- a/web/lib/src/helpers/lists.dart +++ b/web/lib/src/helpers/lists.dart @@ -81,7 +81,7 @@ abstract mixin class _LiveNodeListMixin

{ // 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() ?? false)) { - if ((element as Node).parentNode == _parent) { + if ((element as Node).parentNode.strictEquals(_parent).toDart) { return true; } } From d48f68c055638e4739372cc5fcf42d0536b3f71c Mon Sep 17 00:00:00 2001 From: Franciszek Szczepan Wawrzak Date: Thu, 18 Dec 2025 18:59:49 +0100 Subject: [PATCH 13/13] reflect changes in changelog --- web/CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 29c32761..bd0828c4 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -8,9 +8,11 @@ - 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 `HTMLCollectionListWrapper` and `NodeListListWrapper` to support + mutable operations on node lists. - Added `childNodesAsList` to `Node` and `childrenAsList` to `Element` via - extensions. + extensions. +- Added `asList` to `NodeList` via extension. ## 1.1.1