Skip to content

Commit cfbd2d5

Browse files
committed
perf: Optimize swap rows with LIS algorithm
Implement Longest Increasing Subsequence (LIS) algorithm for optimal DOM reordering during keyed diffing. This reduces DOM operations from O(n) remove+add pairs to O(k) moves where k = n - LIS length. Changes: - Add ComputeLIS() using O(n log n) binary search algorithm (from Inferno) - Add MoveChild patch type for efficient DOM repositioning via insertBefore - Update DiffChildrenCore to use LIS for identifying elements to move - Fix MoveChild to use OLD element IDs (elements exist in DOM before patch) - Add moveChild JS function and batch handler in abies.js - Add comprehensive unit tests for LIS edge cases - Update memory.instructions.md with js-framework-benchmark setup guide Benchmark results (05_swap1k): - Before: 2000 DOM ops (remove all + add all) - BROKEN - After: 2 DOM ops (MoveChild only) - 406.7ms median The algorithm correctly identifies that swapping rows 1↔998 only requires moving 2 elements, not rebuilding the entire list.
1 parent 86f123a commit cfbd2d5

7 files changed

Lines changed: 460 additions & 50 deletions

File tree

.github/instructions/memory.instructions.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,101 @@ The following Toub-inspired optimizations have been applied:
8080

8181
**Applied to**: Abies.Conduit, Abies.Counter, Abies.Presentation, Abies.SubscriptionsDemo
8282

83+
## js-framework-benchmark (Official Performance Testing)
84+
85+
### Setup
86+
87+
The [js-framework-benchmark](https://github.com/nicknash/js-framework-benchmark) is the standard benchmark for comparing frontend framework performance.
88+
89+
**Clone the fork alongside Abies** (same parent directory):
90+
```bash
91+
# From Abies parent directory
92+
cd ..
93+
git clone https://github.com/nicknash/js-framework-benchmark.git js-framework-benchmark-fork
94+
```
95+
96+
Expected structure:
97+
```
98+
parent-directory/
99+
├── Abies/ # This repository
100+
└── js-framework-benchmark-fork/ # Benchmark fork
101+
└── frameworks/keyed/abies/ # Abies framework entry
102+
├── src/ # Source (references Abies project)
103+
└── bundled-dist/ # Published WASM output
104+
```
105+
106+
### Building Abies for Benchmark
107+
108+
**IMPORTANT**: Always do a clean rebuild when testing code changes!
109+
110+
```bash
111+
cd ../js-framework-benchmark-fork/frameworks/keyed/abies/src
112+
113+
# Clean rebuild
114+
rm -rf bin obj
115+
dotnet publish -c Release
116+
117+
# Copy to bundled-dist
118+
rm -rf ../bundled-dist/*
119+
cp -R bin/Release/net10.0/publish/wwwroot/* ../bundled-dist/
120+
```
121+
122+
### Running the Benchmark
123+
124+
1. **Install dependencies** (first time only):
125+
```bash
126+
cd ../js-framework-benchmark-fork
127+
npm ci
128+
cd webdriver-ts
129+
npm ci
130+
```
131+
132+
2. **Start the benchmark server**:
133+
```bash
134+
cd ../js-framework-benchmark-fork
135+
npm run start
136+
# Server runs on http://localhost:8080
137+
```
138+
139+
3. **Run specific benchmarks** (in a new terminal):
140+
```bash
141+
cd ../js-framework-benchmark-fork/webdriver-ts
142+
143+
# Run swap rows benchmark (05_swap1k)
144+
npm run selenium -- --headless --framework abies-v1.0.151-keyed --benchmark 05_swap1k
145+
146+
# Run all benchmarks for Abies
147+
npm run selenium -- --headless --framework abies-v1.0.151-keyed
148+
149+
# Common benchmarks:
150+
# - 01_run1k: Create 1000 rows
151+
# - 02_replace1k: Replace all 1000 rows
152+
# - 03_update10th1k: Update every 10th row
153+
# - 04_select1k: Select a row
154+
# - 05_swap1k: Swap two rows (LIS optimization target)
155+
# - 06_remove1k: Remove a row
156+
# - 07_create10k: Create 10,000 rows
157+
```
158+
159+
4. **View results**:
160+
```bash
161+
# Results saved to:
162+
ls webdriver-ts/results/abies-v1.0.151-keyed_*.json
163+
164+
# View specific result:
165+
cat webdriver-ts/results/abies-v1.0.151-keyed_05_swap1k.json | jq .
166+
```
167+
168+
### Comparison Frameworks
169+
For reference, compare against:
170+
- `vanillajs-keyed` - Baseline (raw DOM manipulation)
171+
- `blazor-wasm-keyed` - .NET Blazor WASM (similar tech stack)
172+
173+
### Benchmark Results (Feb 2025)
174+
175+
| Benchmark | Abies | Blazor | VanillaJS |
176+
|-----------|-------|--------|-----------|
177+
| 05_swap1k | 406.7ms | 94.4ms | 32.2ms |
178+
179+
**Note**: Abies is ~4.3x slower than Blazor on swap due to O(n) diffing overhead, but the LIS algorithm is optimal (2 DOM ops vs 2000 with naive approach).
180+

Abies.Presentation/wwwroot/abies.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,34 @@ setModuleImports('abies.js', {
933933
}
934934
}),
935935

936+
/**
937+
* Moves a child element to a new position within its parent.
938+
* Uses insertBefore semantics: moves child before beforeId, or appends if beforeId is null.
939+
* This is more efficient than remove+add as it preserves the element and its event listeners.
940+
* @param {string} parentId - The ID of the parent element.
941+
* @param {string} childId - The ID of the child element to move.
942+
* @param {string|null} beforeId - The ID of the element to insert before, or null to append.
943+
*/
944+
moveChild: withSpan('moveChild', async (parentId, childId, beforeId) => {
945+
const parent = document.getElementById(parentId);
946+
const child = document.getElementById(childId);
947+
if (!parent) {
948+
console.error(`Parent with ID ${parentId} not found for moveChild.`);
949+
return;
950+
}
951+
if (!child) {
952+
console.error(`Child with ID ${childId} not found for moveChild.`);
953+
return;
954+
}
955+
const before = beforeId ? document.getElementById(beforeId) : null;
956+
if (beforeId && !before) {
957+
console.error(`Before element with ID ${beforeId} not found for moveChild.`);
958+
return;
959+
}
960+
// insertBefore with null as second argument appends to end
961+
parent.insertBefore(child, before);
962+
}),
963+
936964
/**
937965
* Updates the text content of a DOM element.
938966
* @param {number} nodeId - The ID of the node to update.
@@ -1090,6 +1118,26 @@ setModuleImports('abies.js', {
10901118
}
10911119
break;
10921120
}
1121+
case 'MoveChild': {
1122+
const parent = document.getElementById(patch.ParentId);
1123+
const child = document.getElementById(patch.ChildId);
1124+
if (!parent) {
1125+
console.error(`MoveChild failed: parent=${patch.ParentId} not found.`);
1126+
break;
1127+
}
1128+
if (!child) {
1129+
console.error(`MoveChild failed: child=${patch.ChildId} not found.`);
1130+
break;
1131+
}
1132+
const before = patch.BeforeId ? document.getElementById(patch.BeforeId) : null;
1133+
if (patch.BeforeId && !before) {
1134+
console.error(`MoveChild failed: before=${patch.BeforeId} not found.`);
1135+
break;
1136+
}
1137+
// insertBefore with null as second argument appends to end
1138+
parent.insertBefore(child, before);
1139+
break;
1140+
}
10931141
case 'UpdateAttribute': {
10941142
const node = document.getElementById(patch.TargetId);
10951143
if (node) {

Abies.SubscriptionsDemo/wwwroot/abies.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,34 @@ setModuleImports('abies.js', {
933933
}
934934
}),
935935

936+
/**
937+
* Moves a child element to a new position within its parent.
938+
* Uses insertBefore semantics: moves child before beforeId, or appends if beforeId is null.
939+
* This is more efficient than remove+add as it preserves the element and its event listeners.
940+
* @param {string} parentId - The ID of the parent element.
941+
* @param {string} childId - The ID of the child element to move.
942+
* @param {string|null} beforeId - The ID of the element to insert before, or null to append.
943+
*/
944+
moveChild: withSpan('moveChild', async (parentId, childId, beforeId) => {
945+
const parent = document.getElementById(parentId);
946+
const child = document.getElementById(childId);
947+
if (!parent) {
948+
console.error(`Parent with ID ${parentId} not found for moveChild.`);
949+
return;
950+
}
951+
if (!child) {
952+
console.error(`Child with ID ${childId} not found for moveChild.`);
953+
return;
954+
}
955+
const before = beforeId ? document.getElementById(beforeId) : null;
956+
if (beforeId && !before) {
957+
console.error(`Before element with ID ${beforeId} not found for moveChild.`);
958+
return;
959+
}
960+
// insertBefore with null as second argument appends to end
961+
parent.insertBefore(child, before);
962+
}),
963+
936964
/**
937965
* Updates the text content of a DOM element.
938966
* @param {number} nodeId - The ID of the node to update.
@@ -1090,6 +1118,26 @@ setModuleImports('abies.js', {
10901118
}
10911119
break;
10921120
}
1121+
case 'MoveChild': {
1122+
const parent = document.getElementById(patch.ParentId);
1123+
const child = document.getElementById(patch.ChildId);
1124+
if (!parent) {
1125+
console.error(`MoveChild failed: parent=${patch.ParentId} not found.`);
1126+
break;
1127+
}
1128+
if (!child) {
1129+
console.error(`MoveChild failed: child=${patch.ChildId} not found.`);
1130+
break;
1131+
}
1132+
const before = patch.BeforeId ? document.getElementById(patch.BeforeId) : null;
1133+
if (patch.BeforeId && !before) {
1134+
console.error(`MoveChild failed: before=${patch.BeforeId} not found.`);
1135+
break;
1136+
}
1137+
// insertBefore with null as second argument appends to end
1138+
parent.insertBefore(child, before);
1139+
break;
1140+
}
10931141
case 'UpdateAttribute': {
10941142
const node = document.getElementById(patch.TargetId);
10951143
if (node) {

0 commit comments

Comments
 (0)