Skip to content

Commit e0c32f4

Browse files
Key support. Implements #8232 (#9850)
* Store component/element keys on RenderTreeFrame Also refactored how RenderTreeFrame gets constructed. The previous arrangement of having ad-hoc ctor overloads for different scenarios became intractible (too many combinations to avoid clashes; risk of accidentally losing field values when cloning). There's now one constructor per RenderTreeFrameType, so you always know where to add any new field values, and implicitly guarantees you don't lose other field values because adding a new param forces updates at all the call sites. * Add StackObjectPool, which will be useful momentarily * Support keyed insertions/deletions * Refactor AppendDiffEntriesForRange to prepare for adding "move" logic * Apply permutations on the JS side * Handle keyed moves by writing a post-edit permutation list * Shrink KeyedItemInfo struct * Include sourcemaps when building client-side Blazor apps with ReferenceFromSource * Update struct length of edit frames now it's explicit layout It's longer now because all the reference-type fields, except the last, now have to be 8 bytes for compatibility with 64-bit runtimes. Previously on Mono WebAssembly the reference-type fields were all 4 bytes. * Tolerate clashing keys (i.e., produce a valid diff, even if suboptimal) * Tolerate keys being added/removed incorrectly * E2E test harness for 'key' * Some more unit test cases * Invert diffing logic to prefer matching by key over sequence Previously it preferred sequence over key, but that's wrong, and surfaces as bugs when you mix keyed and unkeyed items. We need to prefer key over sequence, because key is meant to guarantee preservation, whereas sequence is just best-effort preservation. * Make unit test cases more adversarial * First actual E2E test * In E2E test, verify correct preservation of components * E2E tests for simple insert/delete cases (with and without keys) * E2E test for reordering. Also extend other tests to verify simultaneous editing. * E2E test for many simultaneous changes * Update reference sources * CR: Avoid x = y = z * CR: Only use 'finally' for actual cleanup * CR: Clean up RenderTreeFrame assignment * CR: Include 'key' in RenderTreeFrame.ToString() * CR: Avoid "new T()" in StackObjectPool * CR: Make KeyedItemInfo readonly * CR: Handle change of frame type with matching keys (and sequence) * CR: Add E2E test showing form + key scenarios * Preserve focus across edits * Tweak E2E test case * In client-side Blazor, prevent recursive event handler invocations * Actual E2E tests for moving form elements
1 parent 14496c9 commit e0c32f4

30 files changed

+2416
-181
lines changed

src/Components/Blazor/Build/src/ReferenceFromSource.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
<PropertyGroup>
1313
<BlazorBuildReferenceFromSource>true</BlazorBuildReferenceFromSource>
14-
<BlazorJsPath>$(RepositoryRoot)src\Components\Browser.JS\dist\$(Configuration)\blazor.*.js</BlazorJsPath>
14+
<BlazorJsPath>$(RepositoryRoot)src\Components\Browser.JS\dist\$(Configuration)\blazor.*.js.*</BlazorJsPath>
1515
</PropertyGroup>
1616

1717
<Import Project="$(MSBuildThisFileDirectory)targets/All.props" />

src/Components/Browser.JS/dist/Debug/blazor.server.js

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13263,7 +13263,13 @@ var BrowserRenderer = /** @class */ (function () {
1326313263
clearBetween(rootElementToClear, rootElementToClearEnd);
1326413264
}
1326513265
}
13266+
var ownerDocument = LogicalElements_1.getClosestDomElement(element).ownerDocument;
13267+
var activeElementBefore = ownerDocument && ownerDocument.activeElement;
1326613268
this.applyEdits(batch, element, 0, edits, referenceFrames);
13269+
// Try to restore focus in case it was lost due to an element move
13270+
if ((activeElementBefore instanceof HTMLElement) && ownerDocument && ownerDocument.activeElement !== activeElementBefore) {
13271+
activeElementBefore.focus();
13272+
}
1326713273
};
1326813274
BrowserRenderer.prototype.disposeComponent = function (componentId) {
1326913275
delete this.childComponentLocations[componentId];
@@ -13277,6 +13283,7 @@ var BrowserRenderer = /** @class */ (function () {
1327713283
BrowserRenderer.prototype.applyEdits = function (batch, parent, childIndex, edits, referenceFrames) {
1327813284
var currentDepth = 0;
1327913285
var childIndexAtCurrentDepth = childIndex;
13286+
var permutationList;
1328013287
var arraySegmentReader = batch.arraySegmentReader;
1328113288
var editReader = batch.editReader;
1328213289
var frameReader = batch.frameReader;
@@ -13365,6 +13372,19 @@ var BrowserRenderer = /** @class */ (function () {
1336513372
childIndexAtCurrentDepth = currentDepth === 0 ? childIndex : 0; // The childIndex is only ever nonzero at zero depth
1336613373
break;
1336713374
}
13375+
case RenderBatch_1.EditType.permutationListEntry: {
13376+
permutationList = permutationList || [];
13377+
permutationList.push({
13378+
fromSiblingIndex: childIndexAtCurrentDepth + editReader.siblingIndex(edit),
13379+
toSiblingIndex: childIndexAtCurrentDepth + editReader.moveToSiblingIndex(edit),
13380+
});
13381+
break;
13382+
}
13383+
case RenderBatch_1.EditType.permutationListEnd: {
13384+
LogicalElements_1.permuteLogicalChildren(parent, permutationList);
13385+
permutationList = undefined;
13386+
break;
13387+
}
1336813388
default: {
1336913389
var unknownType = editType; // Compile-time verification that the switch was exhaustive
1337013390
throw new Error("Unknown edit type: " + unknownType);
@@ -14169,11 +14189,54 @@ function getLogicalChildrenArray(element) {
1416914189
return element[logicalChildrenPropname];
1417014190
}
1417114191
exports.getLogicalChildrenArray = getLogicalChildrenArray;
14172-
function getLogicalNextSibling(element) {
14173-
var siblings = getLogicalChildrenArray(getLogicalParent(element));
14174-
var siblingIndex = Array.prototype.indexOf.call(siblings, element);
14175-
return siblings[siblingIndex + 1] || null;
14192+
function permuteLogicalChildren(parent, permutationList) {
14193+
// The permutationList must represent a valid permutation, i.e., the list of 'from' indices
14194+
// is distinct, and the list of 'to' indices is a permutation of it. The algorithm here
14195+
// relies on that assumption.
14196+
// Each of the phases here has to happen separately, because each one is designed not to
14197+
// interfere with the indices or DOM entries used by subsequent phases.
14198+
// Phase 1: track which nodes we will move
14199+
var siblings = getLogicalChildrenArray(parent);
14200+
permutationList.forEach(function (listEntry) {
14201+
listEntry.moveRangeStart = siblings[listEntry.fromSiblingIndex];
14202+
listEntry.moveRangeEnd = findLastDomNodeInRange(listEntry.moveRangeStart);
14203+
});
14204+
// Phase 2: insert markers
14205+
permutationList.forEach(function (listEntry) {
14206+
var marker = listEntry.moveToBeforeMarker = document.createComment('marker');
14207+
var insertBeforeNode = siblings[listEntry.toSiblingIndex + 1];
14208+
if (insertBeforeNode) {
14209+
insertBeforeNode.parentNode.insertBefore(marker, insertBeforeNode);
14210+
}
14211+
else {
14212+
appendDomNode(marker, parent);
14213+
}
14214+
});
14215+
// Phase 3: move descendants & remove markers
14216+
permutationList.forEach(function (listEntry) {
14217+
var insertBefore = listEntry.moveToBeforeMarker;
14218+
var parentDomNode = insertBefore.parentNode;
14219+
var elementToMove = listEntry.moveRangeStart;
14220+
var moveEndNode = listEntry.moveRangeEnd;
14221+
var nextToMove = elementToMove;
14222+
while (nextToMove) {
14223+
var nextNext = nextToMove.nextSibling;
14224+
parentDomNode.insertBefore(nextToMove, insertBefore);
14225+
if (nextToMove === moveEndNode) {
14226+
break;
14227+
}
14228+
else {
14229+
nextToMove = nextNext;
14230+
}
14231+
}
14232+
parentDomNode.removeChild(insertBefore);
14233+
});
14234+
// Phase 4: update siblings index
14235+
permutationList.forEach(function (listEntry) {
14236+
siblings[listEntry.toSiblingIndex] = listEntry.moveRangeStart;
14237+
});
1417614238
}
14239+
exports.permuteLogicalChildren = permuteLogicalChildren;
1417714240
function getClosestDomElement(logicalElement) {
1417814241
if (logicalElement instanceof Element) {
1417914242
return logicalElement;
@@ -14185,6 +14248,12 @@ function getClosestDomElement(logicalElement) {
1418514248
throw new Error('Not a valid logical element');
1418614249
}
1418714250
}
14251+
exports.getClosestDomElement = getClosestDomElement;
14252+
function getLogicalNextSibling(element) {
14253+
var siblings = getLogicalChildrenArray(getLogicalParent(element));
14254+
var siblingIndex = Array.prototype.indexOf.call(siblings, element);
14255+
return siblings[siblingIndex + 1] || null;
14256+
}
1418814257
function appendDomNode(child, parent) {
1418914258
// This function only puts 'child' into the DOM in the right place relative to 'parent'
1419014259
// It does not update the logical children array of anything
@@ -14208,6 +14277,26 @@ function appendDomNode(child, parent) {
1420814277
throw new Error("Cannot append node because the parent is not a valid logical element. Parent: " + parent);
1420914278
}
1421014279
}
14280+
// Returns the final node (in depth-first evaluation order) that is a descendant of the logical element.
14281+
// As such, the entire subtree is between 'element' and 'findLastDomNodeInRange(element)' inclusive.
14282+
function findLastDomNodeInRange(element) {
14283+
if (element instanceof Element) {
14284+
return element;
14285+
}
14286+
var nextSibling = getLogicalNextSibling(element);
14287+
if (nextSibling) {
14288+
// Simple case: not the last logical sibling, so take the node before the next sibling
14289+
return nextSibling.previousSibling;
14290+
}
14291+
else {
14292+
// Harder case: there's no logical next-sibling, so recurse upwards until we find
14293+
// a logical ancestor that does have one, or a physical element
14294+
var logicalParent = getLogicalParent(element);
14295+
return logicalParent instanceof Element
14296+
? logicalParent.lastChild
14297+
: findLastDomNodeInRange(logicalParent);
14298+
}
14299+
}
1421114300
function createSymbolOrFallback(fallback) {
1421214301
return typeof Symbol === 'function' ? Symbol() : fallback;
1421314302
}
@@ -14303,6 +14392,9 @@ var OutOfProcessRenderTreeEditReader = /** @class */ (function () {
1430314392
OutOfProcessRenderTreeEditReader.prototype.newTreeIndex = function (edit) {
1430414393
return readInt32LE(this.batchDataUint8, edit + 8); // 3rd int
1430514394
};
14395+
OutOfProcessRenderTreeEditReader.prototype.moveToSiblingIndex = function (edit) {
14396+
return readInt32LE(this.batchDataUint8, edit + 8); // 3rd int
14397+
};
1430614398
OutOfProcessRenderTreeEditReader.prototype.removedAttributeName = function (edit) {
1430714399
var stringIndex = readInt32LE(this.batchDataUint8, edit + 12); // 4th int
1430814400
return this.stringReader.readString(stringIndex);
@@ -14458,6 +14550,8 @@ var EditType;
1445814550
EditType[EditType["stepIn"] = 6] = "stepIn";
1445914551
EditType[EditType["stepOut"] = 7] = "stepOut";
1446014552
EditType[EditType["updateMarkup"] = 8] = "updateMarkup";
14553+
EditType[EditType["permutationListEntry"] = 9] = "permutationListEntry";
14554+
EditType[EditType["permutationListEnd"] = 10] = "permutationListEnd";
1446114555
})(EditType = exports.EditType || (exports.EditType = {}));
1446214556
var FrameType;
1446314557
(function (FrameType) {

src/Components/Browser.JS/dist/Debug/blazor.webassembly.js

Lines changed: 99 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Browser.JS/dist/Release/blazor.server.js

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Browser.JS/dist/Release/blazor.webassembly.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Browser.JS/src/Rendering/BrowserRenderer.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { RenderBatch, ArraySegment, RenderTreeEdit, RenderTreeFrame, EditType, FrameType, ArrayValues } from './RenderBatch/RenderBatch';
22
import { EventDelegator } from './EventDelegator';
33
import { EventForDotNet, UIEventArgs } from './EventForDotNet';
4-
import { LogicalElement, toLogicalElement, insertLogicalChild, removeLogicalChild, getLogicalParent, getLogicalChild, createAndInsertLogicalContainer, isSvgElement, getLogicalChildrenArray, getLogicalSiblingEnd } from './LogicalElements';
4+
import { LogicalElement, PermutationListEntry, toLogicalElement, insertLogicalChild, removeLogicalChild, getLogicalParent, getLogicalChild, createAndInsertLogicalContainer, isSvgElement, getLogicalChildrenArray, getLogicalSiblingEnd, permuteLogicalChildren, getClosestDomElement } from './LogicalElements';
55
import { applyCaptureIdToElement } from './ElementReferenceCapture';
66
const selectValuePropname = '_blazorSelectValue';
77
const sharedTemplateElemForParsing = document.createElement('template');
@@ -47,7 +47,15 @@ export class BrowserRenderer {
4747
}
4848
}
4949

50+
const ownerDocument = getClosestDomElement(element).ownerDocument;
51+
const activeElementBefore = ownerDocument && ownerDocument.activeElement;
52+
5053
this.applyEdits(batch, element, 0, edits, referenceFrames);
54+
55+
// Try to restore focus in case it was lost due to an element move
56+
if ((activeElementBefore instanceof HTMLElement) && ownerDocument && ownerDocument.activeElement !== activeElementBefore) {
57+
activeElementBefore.focus();
58+
}
5159
}
5260

5361
public disposeComponent(componentId: number) {
@@ -65,6 +73,7 @@ export class BrowserRenderer {
6573
private applyEdits(batch: RenderBatch, parent: LogicalElement, childIndex: number, edits: ArraySegment<RenderTreeEdit>, referenceFrames: ArrayValues<RenderTreeFrame>) {
6674
let currentDepth = 0;
6775
let childIndexAtCurrentDepth = childIndex;
76+
let permutationList: PermutationListEntry[] | undefined;
6877

6978
const arraySegmentReader = batch.arraySegmentReader;
7079
const editReader = batch.editReader;
@@ -152,6 +161,19 @@ export class BrowserRenderer {
152161
childIndexAtCurrentDepth = currentDepth === 0 ? childIndex : 0; // The childIndex is only ever nonzero at zero depth
153162
break;
154163
}
164+
case EditType.permutationListEntry: {
165+
permutationList = permutationList || [];
166+
permutationList.push({
167+
fromSiblingIndex: childIndexAtCurrentDepth + editReader.siblingIndex(edit),
168+
toSiblingIndex: childIndexAtCurrentDepth + editReader.moveToSiblingIndex(edit),
169+
});
170+
break;
171+
}
172+
case EditType.permutationListEnd: {
173+
permuteLogicalChildren(parent, permutationList!);
174+
permutationList = undefined;
175+
break;
176+
}
155177
default: {
156178
const unknownType: never = editType; // Compile-time verification that the switch was exhaustive
157179
throw new Error(`Unknown edit type: ${unknownType}`);

0 commit comments

Comments
 (0)