Skip to content
Merged
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
65 changes: 47 additions & 18 deletions packages/renderer/src/engines/native/blocks/each.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { arrayFromObject, isArray, isEmpty } from '@semantic-ui/utils';
import { isBlockClose, isBlockOpen, MARKER_VERSION } from '../../../build-html-string.js';
import { decodeItemKey, getCollectionType, getEachData, getItemID } from '../../../shared/each.js';
import { lisIndices } from '../../../shared/lis.js';
import { defineBlock } from '../define-block.js';
import { ReactiveDataContext } from '../reactive-context.js';
import { registerBlock } from './registry.js';
Expand Down Expand Up @@ -186,6 +187,25 @@ function disposeRecord(record) {
disposeRecordDOM(record);
}

// Survivors to leave untouched in Phase 2: the LIS of their old DOM positions.
// Everything off it is the minimal move set. Fresh records (still carrying their
// build fragment) are excluded — they always insert.
function lisKeepSet(records) {
const positions = [];
const recordIndex = [];
for (let i = 0; i < records.length; i++) {
const record = records[i];
if (!record || record.fragment) { continue; }
positions.push(record.index);
recordIndex.push(i);
}
const keep = new Set();
for (const seqIndex of lisIndices(positions)) {
keep.add(recordIndex[seqIndex]);
}
return keep;
}

// Lit-style head/tail keyed reconcile (lit-html's repeat directive).
// Walks both ends inward; lazily builds key→index maps only when forced
// by non-contiguous changes. Common cases (head/tail unchanged, single
Expand All @@ -212,6 +232,7 @@ function reconcile({ records, items, collectionType, node, data, scope, region,
let newTail = items.length - 1;
let oldKeyToIdx;
let newKeySet;
let freshCount = 0;

while (oldHead <= oldTail && newHead <= newTail) {
const oldHeadRec = oldRecords[oldHead];
Expand Down Expand Up @@ -273,6 +294,7 @@ function reconcile({ records, items, collectionType, node, data, scope, region,
renderAST,
isSVG,
});
freshCount++;
}
else {
newRecords[newHead] = oldRecord;
Expand All @@ -295,6 +317,7 @@ function reconcile({ records, items, collectionType, node, data, scope, region,
renderAST,
isSVG,
});
freshCount++;
newHead++;
}

Expand All @@ -303,30 +326,36 @@ function reconcile({ records, items, collectionType, node, data, scope, region,
if (record !== null) { disposeRecord(record); }
}

// Phase 2: linearize DOM order using markers.
//
// `cursor` is always a currently-attached node that we know the next
// item's startMarker should follow. It starts as region.anchor and
// advances to each placed record's endMarker. Markers are stable — they
// are the invariant that survives inner-block mutations.
//
// For each record:
// - If the record's startMarker is already the cursor's nextSibling,
// the record is already in the right place; skip insertion.
// - Otherwise, extract its [startMarker .. content .. endMarker]
// range into a fragment, then insert the fragment after the cursor.
// Fresh records start with their content already in the fragment
// we built in createRecord — insert it directly.
// Move only records off the LIS of their old DOM positions — minimal moves for
// any permutation (Vue `getSequence`, inferno). `record.index` still holds the
// *old* position here: Phase 1 never writes it, Phase 3 does afterward. Fresh
// records (still carrying their build fragment) always insert. Build the LIS
// only on a real reorder; otherwise survivors stay ascending and `cursor`'s
// in-place skip suffices.
let cursor = region.anchor;
for (const record of newRecords) {
let reordered = false;
// Fewer than two survivors can't be out of order, so all-fresh reconciles
// (replace, create) skip the scan entirely.
if (items.length - freshCount > 1) {
let lastOldIndex = -1;
for (const record of newRecords) {
if (!record || record.fragment) { continue; }
if (record.index < lastOldIndex) {
reordered = true;
break;
}
lastOldIndex = record.index;
}
}
const keep = reordered ? lisKeepSet(newRecords) : null;
for (let i = 0; i < newRecords.length; i++) {
const record = newRecords[i];
if (!record) { continue; }
if (record.fragment) {
// Freshly created record — content and markers are in the fragment.
cursor.after(record.fragment);
record.fragment = null;
}
else if (record.startMarker.previousSibling !== cursor) {
// Existing record in the wrong position — extract and reinsert.
else if (keep ? !keep.has(i) : record.startMarker.previousSibling !== cursor) {
const fragment = document.createDocumentFragment();
extractRangeToFragment(record.startMarker, record.endMarker, fragment);
cursor.after(fragment);
Expand Down
33 changes: 33 additions & 0 deletions packages/renderer/src/shared/lis.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
Longest strictly-increasing subsequence — the move-minimization behind keyed
list reorders. Vue (`getSequence`), inferno, and ivi all use it to move only
the nodes genuinely out of order. Pure logic, no DOM.
*/

// Indices into `values` of a longest strictly-increasing subsequence
// (O(n log n) patience sort with predecessor links).
export function lisIndices(values) {
const predecessor = new Array(values.length);
const tails = [];
for (let i = 0; i < values.length; i++) {
const value = values[i];
let lo = 0;
let hi = tails.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (values[tails[mid]] < value) { lo = mid + 1; }
else { hi = mid; }
}
predecessor[i] = lo > 0 ? tails[lo - 1] : -1;
if (lo === tails.length) { tails.push(i); }
else { tails[lo] = i; }
}
const result = new Array(tails.length);
let k = tails.length;
let node = k > 0 ? tails[k - 1] : -1;
while (k > 0) {
result[--k] = node;
node = predecessor[node];
}
return result;
}
31 changes: 31 additions & 0 deletions packages/renderer/test/unit/lis.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { lisIndices } from '../../src/shared/lis.js';

const pick = (values, idx) => idx.map((i) => values[i]);
const strictlyIncreasing = (a) => a.every((v, i) => i === 0 || a[i - 1] < v);

describe('lisIndices', () => {
it('is empty for empty input', () => {
expect(lisIndices([])).toEqual([]);
});

it('returns the lone index for a single element', () => {
expect(lisIndices([7])).toEqual([0]);
});

it('keeps every index when already ascending', () => {
expect(lisIndices([0, 1, 2, 3])).toEqual([0, 1, 2, 3]);
});

it('keeps a single index when strictly descending', () => {
expect(lisIndices([3, 2, 1, 0])).toHaveLength(1);
});

it('returns ascending indices selecting a strictly-increasing, maximal subsequence', () => {
const values = [2, 1, 5, 3, 4, 7, 6];
const idx = lisIndices(values);
expect(idx).toEqual([...idx].sort((a, b) => a - b));
expect(strictlyIncreasing(pick(values, idx))).toBe(true);
expect(idx).toHaveLength(4); // e.g. values 1,3,4,6
});
});
Loading