* 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
This commit is contained in:
parent
14496c9989
commit
e0c32f42f4
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
<PropertyGroup>
|
||||
<BlazorBuildReferenceFromSource>true</BlazorBuildReferenceFromSource>
|
||||
<BlazorJsPath>$(RepositoryRoot)src\Components\Browser.JS\dist\$(Configuration)\blazor.*.js</BlazorJsPath>
|
||||
<BlazorJsPath>$(RepositoryRoot)src\Components\Browser.JS\dist\$(Configuration)\blazor.*.js.*</BlazorJsPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(MSBuildThisFileDirectory)targets/All.props" />
|
||||
|
|
|
|||
|
|
@ -13263,7 +13263,13 @@ var BrowserRenderer = /** @class */ (function () {
|
|||
clearBetween(rootElementToClear, rootElementToClearEnd);
|
||||
}
|
||||
}
|
||||
var ownerDocument = LogicalElements_1.getClosestDomElement(element).ownerDocument;
|
||||
var activeElementBefore = ownerDocument && ownerDocument.activeElement;
|
||||
this.applyEdits(batch, element, 0, edits, referenceFrames);
|
||||
// Try to restore focus in case it was lost due to an element move
|
||||
if ((activeElementBefore instanceof HTMLElement) && ownerDocument && ownerDocument.activeElement !== activeElementBefore) {
|
||||
activeElementBefore.focus();
|
||||
}
|
||||
};
|
||||
BrowserRenderer.prototype.disposeComponent = function (componentId) {
|
||||
delete this.childComponentLocations[componentId];
|
||||
|
|
@ -13277,6 +13283,7 @@ var BrowserRenderer = /** @class */ (function () {
|
|||
BrowserRenderer.prototype.applyEdits = function (batch, parent, childIndex, edits, referenceFrames) {
|
||||
var currentDepth = 0;
|
||||
var childIndexAtCurrentDepth = childIndex;
|
||||
var permutationList;
|
||||
var arraySegmentReader = batch.arraySegmentReader;
|
||||
var editReader = batch.editReader;
|
||||
var frameReader = batch.frameReader;
|
||||
|
|
@ -13365,6 +13372,19 @@ var BrowserRenderer = /** @class */ (function () {
|
|||
childIndexAtCurrentDepth = currentDepth === 0 ? childIndex : 0; // The childIndex is only ever nonzero at zero depth
|
||||
break;
|
||||
}
|
||||
case RenderBatch_1.EditType.permutationListEntry: {
|
||||
permutationList = permutationList || [];
|
||||
permutationList.push({
|
||||
fromSiblingIndex: childIndexAtCurrentDepth + editReader.siblingIndex(edit),
|
||||
toSiblingIndex: childIndexAtCurrentDepth + editReader.moveToSiblingIndex(edit),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case RenderBatch_1.EditType.permutationListEnd: {
|
||||
LogicalElements_1.permuteLogicalChildren(parent, permutationList);
|
||||
permutationList = undefined;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
var unknownType = editType; // Compile-time verification that the switch was exhaustive
|
||||
throw new Error("Unknown edit type: " + unknownType);
|
||||
|
|
@ -14169,11 +14189,54 @@ function getLogicalChildrenArray(element) {
|
|||
return element[logicalChildrenPropname];
|
||||
}
|
||||
exports.getLogicalChildrenArray = getLogicalChildrenArray;
|
||||
function getLogicalNextSibling(element) {
|
||||
var siblings = getLogicalChildrenArray(getLogicalParent(element));
|
||||
var siblingIndex = Array.prototype.indexOf.call(siblings, element);
|
||||
return siblings[siblingIndex + 1] || null;
|
||||
function permuteLogicalChildren(parent, permutationList) {
|
||||
// The permutationList must represent a valid permutation, i.e., the list of 'from' indices
|
||||
// is distinct, and the list of 'to' indices is a permutation of it. The algorithm here
|
||||
// relies on that assumption.
|
||||
// Each of the phases here has to happen separately, because each one is designed not to
|
||||
// interfere with the indices or DOM entries used by subsequent phases.
|
||||
// Phase 1: track which nodes we will move
|
||||
var siblings = getLogicalChildrenArray(parent);
|
||||
permutationList.forEach(function (listEntry) {
|
||||
listEntry.moveRangeStart = siblings[listEntry.fromSiblingIndex];
|
||||
listEntry.moveRangeEnd = findLastDomNodeInRange(listEntry.moveRangeStart);
|
||||
});
|
||||
// Phase 2: insert markers
|
||||
permutationList.forEach(function (listEntry) {
|
||||
var marker = listEntry.moveToBeforeMarker = document.createComment('marker');
|
||||
var insertBeforeNode = siblings[listEntry.toSiblingIndex + 1];
|
||||
if (insertBeforeNode) {
|
||||
insertBeforeNode.parentNode.insertBefore(marker, insertBeforeNode);
|
||||
}
|
||||
else {
|
||||
appendDomNode(marker, parent);
|
||||
}
|
||||
});
|
||||
// Phase 3: move descendants & remove markers
|
||||
permutationList.forEach(function (listEntry) {
|
||||
var insertBefore = listEntry.moveToBeforeMarker;
|
||||
var parentDomNode = insertBefore.parentNode;
|
||||
var elementToMove = listEntry.moveRangeStart;
|
||||
var moveEndNode = listEntry.moveRangeEnd;
|
||||
var nextToMove = elementToMove;
|
||||
while (nextToMove) {
|
||||
var nextNext = nextToMove.nextSibling;
|
||||
parentDomNode.insertBefore(nextToMove, insertBefore);
|
||||
if (nextToMove === moveEndNode) {
|
||||
break;
|
||||
}
|
||||
else {
|
||||
nextToMove = nextNext;
|
||||
}
|
||||
}
|
||||
parentDomNode.removeChild(insertBefore);
|
||||
});
|
||||
// Phase 4: update siblings index
|
||||
permutationList.forEach(function (listEntry) {
|
||||
siblings[listEntry.toSiblingIndex] = listEntry.moveRangeStart;
|
||||
});
|
||||
}
|
||||
exports.permuteLogicalChildren = permuteLogicalChildren;
|
||||
function getClosestDomElement(logicalElement) {
|
||||
if (logicalElement instanceof Element) {
|
||||
return logicalElement;
|
||||
|
|
@ -14185,6 +14248,12 @@ function getClosestDomElement(logicalElement) {
|
|||
throw new Error('Not a valid logical element');
|
||||
}
|
||||
}
|
||||
exports.getClosestDomElement = getClosestDomElement;
|
||||
function getLogicalNextSibling(element) {
|
||||
var siblings = getLogicalChildrenArray(getLogicalParent(element));
|
||||
var siblingIndex = Array.prototype.indexOf.call(siblings, element);
|
||||
return siblings[siblingIndex + 1] || null;
|
||||
}
|
||||
function appendDomNode(child, parent) {
|
||||
// This function only puts 'child' into the DOM in the right place relative to 'parent'
|
||||
// It does not update the logical children array of anything
|
||||
|
|
@ -14208,6 +14277,26 @@ function appendDomNode(child, parent) {
|
|||
throw new Error("Cannot append node because the parent is not a valid logical element. Parent: " + parent);
|
||||
}
|
||||
}
|
||||
// Returns the final node (in depth-first evaluation order) that is a descendant of the logical element.
|
||||
// As such, the entire subtree is between 'element' and 'findLastDomNodeInRange(element)' inclusive.
|
||||
function findLastDomNodeInRange(element) {
|
||||
if (element instanceof Element) {
|
||||
return element;
|
||||
}
|
||||
var nextSibling = getLogicalNextSibling(element);
|
||||
if (nextSibling) {
|
||||
// Simple case: not the last logical sibling, so take the node before the next sibling
|
||||
return nextSibling.previousSibling;
|
||||
}
|
||||
else {
|
||||
// Harder case: there's no logical next-sibling, so recurse upwards until we find
|
||||
// a logical ancestor that does have one, or a physical element
|
||||
var logicalParent = getLogicalParent(element);
|
||||
return logicalParent instanceof Element
|
||||
? logicalParent.lastChild
|
||||
: findLastDomNodeInRange(logicalParent);
|
||||
}
|
||||
}
|
||||
function createSymbolOrFallback(fallback) {
|
||||
return typeof Symbol === 'function' ? Symbol() : fallback;
|
||||
}
|
||||
|
|
@ -14303,6 +14392,9 @@ var OutOfProcessRenderTreeEditReader = /** @class */ (function () {
|
|||
OutOfProcessRenderTreeEditReader.prototype.newTreeIndex = function (edit) {
|
||||
return readInt32LE(this.batchDataUint8, edit + 8); // 3rd int
|
||||
};
|
||||
OutOfProcessRenderTreeEditReader.prototype.moveToSiblingIndex = function (edit) {
|
||||
return readInt32LE(this.batchDataUint8, edit + 8); // 3rd int
|
||||
};
|
||||
OutOfProcessRenderTreeEditReader.prototype.removedAttributeName = function (edit) {
|
||||
var stringIndex = readInt32LE(this.batchDataUint8, edit + 12); // 4th int
|
||||
return this.stringReader.readString(stringIndex);
|
||||
|
|
@ -14458,6 +14550,8 @@ var EditType;
|
|||
EditType[EditType["stepIn"] = 6] = "stepIn";
|
||||
EditType[EditType["stepOut"] = 7] = "stepOut";
|
||||
EditType[EditType["updateMarkup"] = 8] = "updateMarkup";
|
||||
EditType[EditType["permutationListEntry"] = 9] = "permutationListEntry";
|
||||
EditType[EditType["permutationListEnd"] = 10] = "permutationListEnd";
|
||||
})(EditType = exports.EditType || (exports.EditType = {}));
|
||||
var FrameType;
|
||||
(function (FrameType) {
|
||||
|
|
|
|||
|
|
@ -1023,7 +1023,13 @@ var BrowserRenderer = /** @class */ (function () {
|
|||
clearBetween(rootElementToClear, rootElementToClearEnd);
|
||||
}
|
||||
}
|
||||
var ownerDocument = LogicalElements_1.getClosestDomElement(element).ownerDocument;
|
||||
var activeElementBefore = ownerDocument && ownerDocument.activeElement;
|
||||
this.applyEdits(batch, element, 0, edits, referenceFrames);
|
||||
// Try to restore focus in case it was lost due to an element move
|
||||
if ((activeElementBefore instanceof HTMLElement) && ownerDocument && ownerDocument.activeElement !== activeElementBefore) {
|
||||
activeElementBefore.focus();
|
||||
}
|
||||
};
|
||||
BrowserRenderer.prototype.disposeComponent = function (componentId) {
|
||||
delete this.childComponentLocations[componentId];
|
||||
|
|
@ -1037,6 +1043,7 @@ var BrowserRenderer = /** @class */ (function () {
|
|||
BrowserRenderer.prototype.applyEdits = function (batch, parent, childIndex, edits, referenceFrames) {
|
||||
var currentDepth = 0;
|
||||
var childIndexAtCurrentDepth = childIndex;
|
||||
var permutationList;
|
||||
var arraySegmentReader = batch.arraySegmentReader;
|
||||
var editReader = batch.editReader;
|
||||
var frameReader = batch.frameReader;
|
||||
|
|
@ -1125,6 +1132,19 @@ var BrowserRenderer = /** @class */ (function () {
|
|||
childIndexAtCurrentDepth = currentDepth === 0 ? childIndex : 0; // The childIndex is only ever nonzero at zero depth
|
||||
break;
|
||||
}
|
||||
case RenderBatch_1.EditType.permutationListEntry: {
|
||||
permutationList = permutationList || [];
|
||||
permutationList.push({
|
||||
fromSiblingIndex: childIndexAtCurrentDepth + editReader.siblingIndex(edit),
|
||||
toSiblingIndex: childIndexAtCurrentDepth + editReader.moveToSiblingIndex(edit),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case RenderBatch_1.EditType.permutationListEnd: {
|
||||
LogicalElements_1.permuteLogicalChildren(parent, permutationList);
|
||||
permutationList = undefined;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
var unknownType = editType; // Compile-time verification that the switch was exhaustive
|
||||
throw new Error("Unknown edit type: " + unknownType);
|
||||
|
|
@ -1929,11 +1949,54 @@ function getLogicalChildrenArray(element) {
|
|||
return element[logicalChildrenPropname];
|
||||
}
|
||||
exports.getLogicalChildrenArray = getLogicalChildrenArray;
|
||||
function getLogicalNextSibling(element) {
|
||||
var siblings = getLogicalChildrenArray(getLogicalParent(element));
|
||||
var siblingIndex = Array.prototype.indexOf.call(siblings, element);
|
||||
return siblings[siblingIndex + 1] || null;
|
||||
function permuteLogicalChildren(parent, permutationList) {
|
||||
// The permutationList must represent a valid permutation, i.e., the list of 'from' indices
|
||||
// is distinct, and the list of 'to' indices is a permutation of it. The algorithm here
|
||||
// relies on that assumption.
|
||||
// Each of the phases here has to happen separately, because each one is designed not to
|
||||
// interfere with the indices or DOM entries used by subsequent phases.
|
||||
// Phase 1: track which nodes we will move
|
||||
var siblings = getLogicalChildrenArray(parent);
|
||||
permutationList.forEach(function (listEntry) {
|
||||
listEntry.moveRangeStart = siblings[listEntry.fromSiblingIndex];
|
||||
listEntry.moveRangeEnd = findLastDomNodeInRange(listEntry.moveRangeStart);
|
||||
});
|
||||
// Phase 2: insert markers
|
||||
permutationList.forEach(function (listEntry) {
|
||||
var marker = listEntry.moveToBeforeMarker = document.createComment('marker');
|
||||
var insertBeforeNode = siblings[listEntry.toSiblingIndex + 1];
|
||||
if (insertBeforeNode) {
|
||||
insertBeforeNode.parentNode.insertBefore(marker, insertBeforeNode);
|
||||
}
|
||||
else {
|
||||
appendDomNode(marker, parent);
|
||||
}
|
||||
});
|
||||
// Phase 3: move descendants & remove markers
|
||||
permutationList.forEach(function (listEntry) {
|
||||
var insertBefore = listEntry.moveToBeforeMarker;
|
||||
var parentDomNode = insertBefore.parentNode;
|
||||
var elementToMove = listEntry.moveRangeStart;
|
||||
var moveEndNode = listEntry.moveRangeEnd;
|
||||
var nextToMove = elementToMove;
|
||||
while (nextToMove) {
|
||||
var nextNext = nextToMove.nextSibling;
|
||||
parentDomNode.insertBefore(nextToMove, insertBefore);
|
||||
if (nextToMove === moveEndNode) {
|
||||
break;
|
||||
}
|
||||
else {
|
||||
nextToMove = nextNext;
|
||||
}
|
||||
}
|
||||
parentDomNode.removeChild(insertBefore);
|
||||
});
|
||||
// Phase 4: update siblings index
|
||||
permutationList.forEach(function (listEntry) {
|
||||
siblings[listEntry.toSiblingIndex] = listEntry.moveRangeStart;
|
||||
});
|
||||
}
|
||||
exports.permuteLogicalChildren = permuteLogicalChildren;
|
||||
function getClosestDomElement(logicalElement) {
|
||||
if (logicalElement instanceof Element) {
|
||||
return logicalElement;
|
||||
|
|
@ -1945,6 +2008,12 @@ function getClosestDomElement(logicalElement) {
|
|||
throw new Error('Not a valid logical element');
|
||||
}
|
||||
}
|
||||
exports.getClosestDomElement = getClosestDomElement;
|
||||
function getLogicalNextSibling(element) {
|
||||
var siblings = getLogicalChildrenArray(getLogicalParent(element));
|
||||
var siblingIndex = Array.prototype.indexOf.call(siblings, element);
|
||||
return siblings[siblingIndex + 1] || null;
|
||||
}
|
||||
function appendDomNode(child, parent) {
|
||||
// This function only puts 'child' into the DOM in the right place relative to 'parent'
|
||||
// It does not update the logical children array of anything
|
||||
|
|
@ -1968,6 +2037,26 @@ function appendDomNode(child, parent) {
|
|||
throw new Error("Cannot append node because the parent is not a valid logical element. Parent: " + parent);
|
||||
}
|
||||
}
|
||||
// Returns the final node (in depth-first evaluation order) that is a descendant of the logical element.
|
||||
// As such, the entire subtree is between 'element' and 'findLastDomNodeInRange(element)' inclusive.
|
||||
function findLastDomNodeInRange(element) {
|
||||
if (element instanceof Element) {
|
||||
return element;
|
||||
}
|
||||
var nextSibling = getLogicalNextSibling(element);
|
||||
if (nextSibling) {
|
||||
// Simple case: not the last logical sibling, so take the node before the next sibling
|
||||
return nextSibling.previousSibling;
|
||||
}
|
||||
else {
|
||||
// Harder case: there's no logical next-sibling, so recurse upwards until we find
|
||||
// a logical ancestor that does have one, or a physical element
|
||||
var logicalParent = getLogicalParent(element);
|
||||
return logicalParent instanceof Element
|
||||
? logicalParent.lastChild
|
||||
: findLastDomNodeInRange(logicalParent);
|
||||
}
|
||||
}
|
||||
function createSymbolOrFallback(fallback) {
|
||||
return typeof Symbol === 'function' ? Symbol() : fallback;
|
||||
}
|
||||
|
|
@ -1996,6 +2085,8 @@ var EditType;
|
|||
EditType[EditType["stepIn"] = 6] = "stepIn";
|
||||
EditType[EditType["stepOut"] = 7] = "stepOut";
|
||||
EditType[EditType["updateMarkup"] = 8] = "updateMarkup";
|
||||
EditType[EditType["permutationListEntry"] = 9] = "permutationListEntry";
|
||||
EditType[EditType["permutationListEnd"] = 10] = "permutationListEnd";
|
||||
})(EditType = exports.EditType || (exports.EditType = {}));
|
||||
var FrameType;
|
||||
(function (FrameType) {
|
||||
|
|
@ -2089,15 +2180,16 @@ var diffReader = {
|
|||
};
|
||||
// Keep in sync with memory layout in RenderTreeEdit.cs
|
||||
var editReader = {
|
||||
structLength: 16,
|
||||
structLength: 20,
|
||||
editType: function (edit) { return Environment_1.platform.readInt32Field(edit, 0); },
|
||||
siblingIndex: function (edit) { return Environment_1.platform.readInt32Field(edit, 4); },
|
||||
newTreeIndex: function (edit) { return Environment_1.platform.readInt32Field(edit, 8); },
|
||||
removedAttributeName: function (edit) { return Environment_1.platform.readStringField(edit, 12); },
|
||||
moveToSiblingIndex: function (edit) { return Environment_1.platform.readInt32Field(edit, 8); },
|
||||
removedAttributeName: function (edit) { return Environment_1.platform.readStringField(edit, 16); },
|
||||
};
|
||||
// Keep in sync with memory layout in RenderTreeFrame.cs
|
||||
var frameReader = {
|
||||
structLength: 28,
|
||||
structLength: 36,
|
||||
frameType: function (frame) { return Environment_1.platform.readInt32Field(frame, 4); },
|
||||
subtreeLength: function (frame) { return Environment_1.platform.readInt32Field(frame, 8); },
|
||||
elementReferenceCaptureId: function (frame) { return Environment_1.platform.readStringField(frame, 16); },
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
import { RenderBatch, ArraySegment, RenderTreeEdit, RenderTreeFrame, EditType, FrameType, ArrayValues } from './RenderBatch/RenderBatch';
|
||||
import { EventDelegator } from './EventDelegator';
|
||||
import { EventForDotNet, UIEventArgs } from './EventForDotNet';
|
||||
import { LogicalElement, toLogicalElement, insertLogicalChild, removeLogicalChild, getLogicalParent, getLogicalChild, createAndInsertLogicalContainer, isSvgElement, getLogicalChildrenArray, getLogicalSiblingEnd } from './LogicalElements';
|
||||
import { LogicalElement, PermutationListEntry, toLogicalElement, insertLogicalChild, removeLogicalChild, getLogicalParent, getLogicalChild, createAndInsertLogicalContainer, isSvgElement, getLogicalChildrenArray, getLogicalSiblingEnd, permuteLogicalChildren, getClosestDomElement } from './LogicalElements';
|
||||
import { applyCaptureIdToElement } from './ElementReferenceCapture';
|
||||
const selectValuePropname = '_blazorSelectValue';
|
||||
const sharedTemplateElemForParsing = document.createElement('template');
|
||||
|
|
@ -47,7 +47,15 @@ export class BrowserRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
const ownerDocument = getClosestDomElement(element).ownerDocument;
|
||||
const activeElementBefore = ownerDocument && ownerDocument.activeElement;
|
||||
|
||||
this.applyEdits(batch, element, 0, edits, referenceFrames);
|
||||
|
||||
// Try to restore focus in case it was lost due to an element move
|
||||
if ((activeElementBefore instanceof HTMLElement) && ownerDocument && ownerDocument.activeElement !== activeElementBefore) {
|
||||
activeElementBefore.focus();
|
||||
}
|
||||
}
|
||||
|
||||
public disposeComponent(componentId: number) {
|
||||
|
|
@ -65,6 +73,7 @@ export class BrowserRenderer {
|
|||
private applyEdits(batch: RenderBatch, parent: LogicalElement, childIndex: number, edits: ArraySegment<RenderTreeEdit>, referenceFrames: ArrayValues<RenderTreeFrame>) {
|
||||
let currentDepth = 0;
|
||||
let childIndexAtCurrentDepth = childIndex;
|
||||
let permutationList: PermutationListEntry[] | undefined;
|
||||
|
||||
const arraySegmentReader = batch.arraySegmentReader;
|
||||
const editReader = batch.editReader;
|
||||
|
|
@ -152,6 +161,19 @@ export class BrowserRenderer {
|
|||
childIndexAtCurrentDepth = currentDepth === 0 ? childIndex : 0; // The childIndex is only ever nonzero at zero depth
|
||||
break;
|
||||
}
|
||||
case EditType.permutationListEntry: {
|
||||
permutationList = permutationList || [];
|
||||
permutationList.push({
|
||||
fromSiblingIndex: childIndexAtCurrentDepth + editReader.siblingIndex(edit),
|
||||
toSiblingIndex: childIndexAtCurrentDepth + editReader.moveToSiblingIndex(edit),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case EditType.permutationListEnd: {
|
||||
permuteLogicalChildren(parent, permutationList!);
|
||||
permutationList = undefined;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const unknownType: never = editType; // Compile-time verification that the switch was exhaustive
|
||||
throw new Error(`Unknown edit type: ${unknownType}`);
|
||||
|
|
|
|||
|
|
@ -155,13 +155,60 @@ export function getLogicalChildrenArray(element: LogicalElement) {
|
|||
return element[logicalChildrenPropname] as LogicalElement[];
|
||||
}
|
||||
|
||||
function getLogicalNextSibling(element: LogicalElement): LogicalElement | null {
|
||||
const siblings = getLogicalChildrenArray(getLogicalParent(element)!);
|
||||
const siblingIndex = Array.prototype.indexOf.call(siblings, element);
|
||||
return siblings[siblingIndex + 1] || null;
|
||||
export function permuteLogicalChildren(parent: LogicalElement, permutationList: PermutationListEntry[]) {
|
||||
// The permutationList must represent a valid permutation, i.e., the list of 'from' indices
|
||||
// is distinct, and the list of 'to' indices is a permutation of it. The algorithm here
|
||||
// relies on that assumption.
|
||||
|
||||
// Each of the phases here has to happen separately, because each one is designed not to
|
||||
// interfere with the indices or DOM entries used by subsequent phases.
|
||||
|
||||
// Phase 1: track which nodes we will move
|
||||
const siblings = getLogicalChildrenArray(parent);
|
||||
permutationList.forEach((listEntry: PermutationListEntryWithTrackingData) => {
|
||||
listEntry.moveRangeStart = siblings[listEntry.fromSiblingIndex];
|
||||
listEntry.moveRangeEnd = findLastDomNodeInRange(listEntry.moveRangeStart);
|
||||
});
|
||||
|
||||
// Phase 2: insert markers
|
||||
permutationList.forEach((listEntry: PermutationListEntryWithTrackingData) => {
|
||||
const marker = listEntry.moveToBeforeMarker = document.createComment('marker');
|
||||
const insertBeforeNode = siblings[listEntry.toSiblingIndex + 1] as any as Node;
|
||||
if (insertBeforeNode) {
|
||||
insertBeforeNode.parentNode!.insertBefore(marker, insertBeforeNode);
|
||||
} else {
|
||||
appendDomNode(marker, parent);
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 3: move descendants & remove markers
|
||||
permutationList.forEach((listEntry: PermutationListEntryWithTrackingData) => {
|
||||
const insertBefore = listEntry.moveToBeforeMarker!;
|
||||
const parentDomNode = insertBefore.parentNode!;
|
||||
const elementToMove = listEntry.moveRangeStart!;
|
||||
const moveEndNode = listEntry.moveRangeEnd!;
|
||||
let nextToMove = elementToMove as any as Node | null;
|
||||
while (nextToMove) {
|
||||
const nextNext = nextToMove.nextSibling;
|
||||
parentDomNode.insertBefore(nextToMove, insertBefore);
|
||||
|
||||
if (nextToMove === moveEndNode) {
|
||||
break;
|
||||
} else {
|
||||
nextToMove = nextNext;
|
||||
}
|
||||
}
|
||||
|
||||
parentDomNode.removeChild(insertBefore);
|
||||
});
|
||||
|
||||
// Phase 4: update siblings index
|
||||
permutationList.forEach((listEntry: PermutationListEntryWithTrackingData) => {
|
||||
siblings[listEntry.toSiblingIndex] = listEntry.moveRangeStart!;
|
||||
});
|
||||
}
|
||||
|
||||
function getClosestDomElement(logicalElement: LogicalElement) {
|
||||
export function getClosestDomElement(logicalElement: LogicalElement) {
|
||||
if (logicalElement instanceof Element) {
|
||||
return logicalElement;
|
||||
} else if (logicalElement instanceof Comment) {
|
||||
|
|
@ -171,6 +218,24 @@ function getClosestDomElement(logicalElement: LogicalElement) {
|
|||
}
|
||||
}
|
||||
|
||||
export interface PermutationListEntry {
|
||||
fromSiblingIndex: number,
|
||||
toSiblingIndex: number,
|
||||
}
|
||||
|
||||
interface PermutationListEntryWithTrackingData extends PermutationListEntry {
|
||||
// These extra properties are used internally when processing the permutation list
|
||||
moveRangeStart?: LogicalElement,
|
||||
moveRangeEnd?: Node,
|
||||
moveToBeforeMarker?: Node,
|
||||
}
|
||||
|
||||
function getLogicalNextSibling(element: LogicalElement): LogicalElement | null {
|
||||
const siblings = getLogicalChildrenArray(getLogicalParent(element)!);
|
||||
const siblingIndex = Array.prototype.indexOf.call(siblings, element);
|
||||
return siblings[siblingIndex + 1] || null;
|
||||
}
|
||||
|
||||
function appendDomNode(child: Node, parent: LogicalElement) {
|
||||
// This function only puts 'child' into the DOM in the right place relative to 'parent'
|
||||
// It does not update the logical children array of anything
|
||||
|
|
@ -192,6 +257,27 @@ function appendDomNode(child: Node, parent: LogicalElement) {
|
|||
}
|
||||
}
|
||||
|
||||
// Returns the final node (in depth-first evaluation order) that is a descendant of the logical element.
|
||||
// As such, the entire subtree is between 'element' and 'findLastDomNodeInRange(element)' inclusive.
|
||||
function findLastDomNodeInRange(element: LogicalElement) {
|
||||
if (element instanceof Element) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const nextSibling = getLogicalNextSibling(element);
|
||||
if (nextSibling) {
|
||||
// Simple case: not the last logical sibling, so take the node before the next sibling
|
||||
return (nextSibling as any as Node).previousSibling;
|
||||
} else {
|
||||
// Harder case: there's no logical next-sibling, so recurse upwards until we find
|
||||
// a logical ancestor that does have one, or a physical element
|
||||
const logicalParent = getLogicalParent(element)!;
|
||||
return logicalParent instanceof Element
|
||||
? logicalParent.lastChild
|
||||
: findLastDomNodeInRange(logicalParent);
|
||||
}
|
||||
}
|
||||
|
||||
function createSymbolOrFallback(fallback: string): symbol | string {
|
||||
return typeof Symbol === 'function' ? Symbol() : fallback;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,10 @@ class OutOfProcessRenderTreeEditReader implements RenderTreeEditReader {
|
|||
return readInt32LE(this.batchDataUint8, edit as any + 8); // 3rd int
|
||||
}
|
||||
|
||||
moveToSiblingIndex(edit: RenderTreeEdit) {
|
||||
return readInt32LE(this.batchDataUint8, edit as any + 8); // 3rd int
|
||||
}
|
||||
|
||||
removedAttributeName(edit: RenderTreeEdit) {
|
||||
const stringIndex = readInt32LE(this.batchDataUint8, edit as any + 12); // 4th int
|
||||
return this.stringReader.readString(stringIndex);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export interface RenderTreeEditReader {
|
|||
editType(edit: RenderTreeEdit): EditType;
|
||||
siblingIndex(edit: RenderTreeEdit): number;
|
||||
newTreeIndex(edit: RenderTreeEdit): number;
|
||||
moveToSiblingIndex(edit: RenderTreeEdit): number;
|
||||
removedAttributeName(edit: RenderTreeEdit): string | null;
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +72,8 @@ export enum EditType {
|
|||
stepIn = 6,
|
||||
stepOut = 7,
|
||||
updateMarkup = 8,
|
||||
permutationListEntry = 9,
|
||||
permutationListEnd = 10,
|
||||
}
|
||||
|
||||
export enum FrameType {
|
||||
|
|
|
|||
|
|
@ -83,16 +83,17 @@ const diffReader = {
|
|||
|
||||
// Keep in sync with memory layout in RenderTreeEdit.cs
|
||||
const editReader = {
|
||||
structLength: 16,
|
||||
structLength: 20,
|
||||
editType: (edit: RenderTreeEdit) => platform.readInt32Field(edit as any, 0) as EditType,
|
||||
siblingIndex: (edit: RenderTreeEdit) => platform.readInt32Field(edit as any, 4),
|
||||
newTreeIndex: (edit: RenderTreeEdit) => platform.readInt32Field(edit as any, 8),
|
||||
removedAttributeName: (edit: RenderTreeEdit) => platform.readStringField(edit as any, 12),
|
||||
moveToSiblingIndex: (edit: RenderTreeEdit) => platform.readInt32Field(edit as any, 8),
|
||||
removedAttributeName: (edit: RenderTreeEdit) => platform.readStringField(edit as any, 16),
|
||||
};
|
||||
|
||||
// Keep in sync with memory layout in RenderTreeFrame.cs
|
||||
const frameReader = {
|
||||
structLength: 28,
|
||||
structLength: 36,
|
||||
frameType: (frame: RenderTreeFrame) => platform.readInt32Field(frame as any, 4) as FrameType,
|
||||
subtreeLength: (frame: RenderTreeFrame) => platform.readInt32Field(frame as any, 8),
|
||||
elementReferenceCaptureId: (frame: RenderTreeFrame) => platform.readStringField(frame as any, 16),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
using Microsoft.JSInterop;
|
||||
|
|
@ -13,6 +14,10 @@ namespace Microsoft.AspNetCore.Components.Browser
|
|||
/// </summary>
|
||||
public static class RendererRegistryEventDispatcher
|
||||
{
|
||||
private static bool isDispatchingEvent;
|
||||
private static Queue<IncomingEventInfo> deferredIncomingEvents
|
||||
= new Queue<IncomingEventInfo>();
|
||||
|
||||
/// <summary>
|
||||
/// For framework use only.
|
||||
/// </summary>
|
||||
|
|
@ -20,9 +25,62 @@ namespace Microsoft.AspNetCore.Components.Browser
|
|||
public static Task DispatchEvent(
|
||||
BrowserEventDescriptor eventDescriptor, string eventArgsJson)
|
||||
{
|
||||
var eventArgs = ParseEventArgsJson(eventDescriptor.EventArgsType, eventArgsJson);
|
||||
var renderer = RendererRegistry.Current.Find(eventDescriptor.BrowserRendererId);
|
||||
return renderer.DispatchEventAsync(eventDescriptor.EventHandlerId, eventArgs);
|
||||
// Be sure we only run one event handler at once. Although they couldn't run
|
||||
// simultaneously anyway (there's only one thread), they could run nested on
|
||||
// the stack if somehow one event handler triggers another event synchronously.
|
||||
// We need event handlers not to overlap because (a) that's consistent with
|
||||
// server-side Blazor which uses a sync context, and (b) the rendering logic
|
||||
// relies completely on the idea that within a given scope it's only building
|
||||
// or processing one batch at a time.
|
||||
//
|
||||
// The only currently known case where this makes a difference is in the E2E
|
||||
// tests in ReorderingFocusComponent, where we hit what seems like a Chrome bug
|
||||
// where mutating the DOM cause an element's "change" to fire while its "input"
|
||||
// handler is still running (i.e., nested on the stack) -- this doesn't happen
|
||||
// in Firefox. Possibly a future version of Chrome may fix this, but even then,
|
||||
// it's conceivable that DOM mutation events could trigger this too.
|
||||
|
||||
if (isDispatchingEvent)
|
||||
{
|
||||
var info = new IncomingEventInfo(eventDescriptor, eventArgsJson);
|
||||
deferredIncomingEvents.Enqueue(info);
|
||||
return info.TaskCompletionSource.Task;
|
||||
}
|
||||
else
|
||||
{
|
||||
isDispatchingEvent = true;
|
||||
try
|
||||
{
|
||||
var eventArgs = ParseEventArgsJson(eventDescriptor.EventArgsType, eventArgsJson);
|
||||
var renderer = RendererRegistry.Current.Find(eventDescriptor.BrowserRendererId);
|
||||
return renderer.DispatchEventAsync(eventDescriptor.EventHandlerId, eventArgs);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isDispatchingEvent = false;
|
||||
if (deferredIncomingEvents.Count > 0)
|
||||
{
|
||||
ProcessNextDeferredEvent();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessNextDeferredEvent()
|
||||
{
|
||||
var info = deferredIncomingEvents.Dequeue();
|
||||
var task = DispatchEvent(info.EventDescriptor, info.EventArgsJson);
|
||||
task.ContinueWith(_ =>
|
||||
{
|
||||
if (task.Exception != null)
|
||||
{
|
||||
info.TaskCompletionSource.SetException(task.Exception);
|
||||
}
|
||||
else
|
||||
{
|
||||
info.TaskCompletionSource.SetResult(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static UIEventArgs ParseEventArgsJson(string eventArgsType, string eventArgsJson)
|
||||
|
|
@ -78,5 +136,19 @@ namespace Microsoft.AspNetCore.Components.Browser
|
|||
/// </summary>
|
||||
public string EventArgsType { get; set; }
|
||||
}
|
||||
|
||||
readonly struct IncomingEventInfo
|
||||
{
|
||||
public readonly BrowserEventDescriptor EventDescriptor;
|
||||
public readonly string EventArgsJson;
|
||||
public readonly TaskCompletionSource<object> TaskCompletionSource;
|
||||
|
||||
public IncomingEventInfo(BrowserEventDescriptor eventDescriptor, string eventArgsJson)
|
||||
{
|
||||
EventDescriptor = eventDescriptor;
|
||||
EventArgsJson = eventArgsJson;
|
||||
TaskCompletionSource = new TaskCompletionSource<object>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -771,12 +771,18 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
public readonly int ComponentId;
|
||||
public readonly System.ArraySegment<Microsoft.AspNetCore.Components.RenderTree.RenderTreeEdit> Edits;
|
||||
}
|
||||
[System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)]
|
||||
[System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Explicit)]
|
||||
public readonly partial struct RenderTreeEdit
|
||||
{
|
||||
[System.Runtime.InteropServices.FieldOffsetAttribute(8)]
|
||||
public readonly int MoveToSiblingIndex;
|
||||
[System.Runtime.InteropServices.FieldOffsetAttribute(8)]
|
||||
public readonly int ReferenceFrameIndex;
|
||||
[System.Runtime.InteropServices.FieldOffsetAttribute(16)]
|
||||
public readonly string RemovedAttributeName;
|
||||
[System.Runtime.InteropServices.FieldOffsetAttribute(4)]
|
||||
public readonly int SiblingIndex;
|
||||
[System.Runtime.InteropServices.FieldOffsetAttribute(0)]
|
||||
public readonly Microsoft.AspNetCore.Components.RenderTree.RenderTreeEditType Type;
|
||||
}
|
||||
public enum RenderTreeEditType
|
||||
|
|
@ -789,6 +795,8 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
StepIn = 6,
|
||||
StepOut = 7,
|
||||
UpdateMarkup = 8,
|
||||
PermutationListEntry = 9,
|
||||
PermutationListEnd = 10,
|
||||
}
|
||||
public enum RenderTreeFrameType
|
||||
{
|
||||
|
|
|
|||
|
|
@ -454,13 +454,30 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
/// <param name="value">The value for the key.</param>
|
||||
public void SetKey(object value)
|
||||
{
|
||||
// This is just a placeholder to enable work in parallel in the
|
||||
// aspnetcore-tooling repo.
|
||||
//
|
||||
// The real implementation will involve multiple overloads, likely:
|
||||
// SetKey(int value) -- underlying logic
|
||||
// SetKey<T>(T value) where T: struct -- avoids boxing 'value' before calling .GetHashCode()
|
||||
// SetKey(object value) -- performs null check before calling .GetHashCode()
|
||||
if (value == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
var parentFrameIndex = GetCurrentParentFrameIndex();
|
||||
if (!parentFrameIndex.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot set a key outside the scope of a component or element.");
|
||||
}
|
||||
|
||||
var parentFrameIndexValue = parentFrameIndex.Value;
|
||||
ref var parentFrame = ref _entries.Buffer[parentFrameIndexValue];
|
||||
switch (parentFrame.FrameType)
|
||||
{
|
||||
case RenderTreeFrameType.Element:
|
||||
parentFrame = parentFrame.WithElementKey(value); // It's a ref var, so this writes to the array
|
||||
break;
|
||||
case RenderTreeFrameType.Component:
|
||||
parentFrame = parentFrame.WithComponentKey(value); // It's a ref var, so this writes to the array
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Cannot set a key on a frame of type {parentFrame.FrameType}.");
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenComponentUnchecked(int sequence, Type componentType)
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@
|
|||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.RenderTree
|
||||
{
|
||||
internal static class RenderTreeDiffBuilder
|
||||
{
|
||||
enum DiffAction { Match, Insert, Delete }
|
||||
|
||||
public static RenderTreeDiff ComputeDiff(
|
||||
Renderer renderer,
|
||||
RenderBatchBuilder batchBuilder,
|
||||
|
|
@ -35,107 +36,302 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
int oldStartIndex, int oldEndIndexExcl,
|
||||
int newStartIndex, int newEndIndexExcl)
|
||||
{
|
||||
// This is deliberately a very large method. Parts of it could be factored out
|
||||
// into other private methods, but doing so comes at a consequential perf cost,
|
||||
// because it involves so much parameter passing. You can think of the code here
|
||||
// as being several private methods (delimited by #region) pre-inlined.
|
||||
//
|
||||
// A naive "extract methods"-type refactoring will worsen perf by about 10%. So,
|
||||
// if you plan to refactor this, be sure to benchmark the old and new versions
|
||||
// on Mono WebAssembly.
|
||||
|
||||
var hasMoreOld = oldEndIndexExcl > oldStartIndex;
|
||||
var hasMoreNew = newEndIndexExcl > newStartIndex;
|
||||
var prevOldSeq = -1;
|
||||
var prevNewSeq = -1;
|
||||
var oldTree = diffContext.OldTree;
|
||||
var newTree = diffContext.NewTree;
|
||||
while (hasMoreOld || hasMoreNew)
|
||||
var matchWithNewTreeIndex = -1; // Only used when action == DiffAction.Match
|
||||
Dictionary<object, KeyedItemInfo> keyedItemInfos = null;
|
||||
|
||||
try
|
||||
{
|
||||
var oldSeq = hasMoreOld ? oldTree[oldStartIndex].Sequence : int.MaxValue;
|
||||
var newSeq = hasMoreNew ? newTree[newStartIndex].Sequence : int.MaxValue;
|
||||
|
||||
if (oldSeq == newSeq)
|
||||
while (hasMoreOld || hasMoreNew)
|
||||
{
|
||||
AppendDiffEntriesForFramesWithSameSequence(ref diffContext, oldStartIndex, newStartIndex);
|
||||
oldStartIndex = NextSiblingIndex(oldTree[oldStartIndex], oldStartIndex);
|
||||
newStartIndex = NextSiblingIndex(newTree[newStartIndex], newStartIndex);
|
||||
hasMoreOld = oldEndIndexExcl > oldStartIndex;
|
||||
hasMoreNew = newEndIndexExcl > newStartIndex;
|
||||
prevOldSeq = oldSeq;
|
||||
prevNewSeq = newSeq;
|
||||
}
|
||||
else
|
||||
{
|
||||
bool treatAsInsert;
|
||||
var oldLoopedBack = oldSeq <= prevOldSeq;
|
||||
var newLoopedBack = newSeq <= prevNewSeq;
|
||||
if (oldLoopedBack == newLoopedBack)
|
||||
{
|
||||
// Both sequences are proceeding through the same loop block, so do a simple
|
||||
// preordered merge join (picking from whichever side brings us closer to being
|
||||
// back in sync)
|
||||
treatAsInsert = newSeq < oldSeq;
|
||||
DiffAction action;
|
||||
|
||||
if (oldLoopedBack)
|
||||
{
|
||||
// If both old and new have now looped back, we must reset their 'looped back'
|
||||
// tracker so we can treat them as proceeding through the same loop block
|
||||
prevOldSeq = prevNewSeq = -1;
|
||||
}
|
||||
}
|
||||
else if (oldLoopedBack)
|
||||
#region "Read keys and sequence numbers"
|
||||
int oldSeq, newSeq;
|
||||
object oldKey, newKey;
|
||||
if (hasMoreOld)
|
||||
{
|
||||
// Old sequence looped back but new one didn't
|
||||
// The new sequence either has some extra trailing elements in the current loop block
|
||||
// which we should insert, or omits some old trailing loop blocks which we should delete
|
||||
// TODO: Find a way of not recomputing this next flag on every iteration
|
||||
var newLoopsBackLater = false;
|
||||
for (var testIndex = newStartIndex + 1; testIndex < newEndIndexExcl; testIndex++)
|
||||
{
|
||||
if (newTree[testIndex].Sequence < newSeq)
|
||||
{
|
||||
newLoopsBackLater = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the new sequence loops back later to an earlier point than this,
|
||||
// then we know it's part of the existing loop block (so should be inserted).
|
||||
// If not, then it's unrelated to the previous loop block (so we should treat
|
||||
// the old items as trailing loop blocks to be removed).
|
||||
treatAsInsert = newLoopsBackLater;
|
||||
ref var oldFrame = ref oldTree[oldStartIndex];
|
||||
oldSeq = oldFrame.Sequence;
|
||||
oldKey = KeyValue(ref oldFrame);
|
||||
}
|
||||
else
|
||||
{
|
||||
// New sequence looped back but old one didn't
|
||||
// The old sequence either has some extra trailing elements in the current loop block
|
||||
// which we should delete, or the new sequence has extra trailing loop blocks which we
|
||||
// should insert
|
||||
// TODO: Find a way of not recomputing this next flag on every iteration
|
||||
var oldLoopsBackLater = false;
|
||||
for (var testIndex = oldStartIndex + 1; testIndex < oldEndIndexExcl; testIndex++)
|
||||
{
|
||||
if (oldTree[testIndex].Sequence < oldSeq)
|
||||
{
|
||||
oldLoopsBackLater = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the old sequence loops back later to an earlier point than this,
|
||||
// then we know it's part of the existing loop block (so should be removed).
|
||||
// If not, then it's unrelated to the previous loop block (so we should treat
|
||||
// the new items as trailing loop blocks to be inserted).
|
||||
treatAsInsert = !oldLoopsBackLater;
|
||||
oldSeq = int.MaxValue;
|
||||
oldKey = null;
|
||||
}
|
||||
|
||||
if (treatAsInsert)
|
||||
if (hasMoreNew)
|
||||
{
|
||||
InsertNewFrame(ref diffContext, newStartIndex);
|
||||
newStartIndex = NextSiblingIndex(newTree[newStartIndex], newStartIndex);
|
||||
hasMoreNew = newEndIndexExcl > newStartIndex;
|
||||
prevNewSeq = newSeq;
|
||||
ref var newFrame = ref newTree[newStartIndex];
|
||||
newSeq = newFrame.Sequence;
|
||||
newKey = KeyValue(ref newFrame);
|
||||
}
|
||||
else
|
||||
{
|
||||
RemoveOldFrame(ref diffContext, oldStartIndex);
|
||||
oldStartIndex = NextSiblingIndex(oldTree[oldStartIndex], oldStartIndex);
|
||||
hasMoreOld = oldEndIndexExcl > oldStartIndex;
|
||||
prevOldSeq = oldSeq;
|
||||
newSeq = int.MaxValue;
|
||||
newKey = null;
|
||||
}
|
||||
#endregion
|
||||
|
||||
// If there's a key on either side, prefer matching by key not sequence
|
||||
if (oldKey != null || newKey != null)
|
||||
{
|
||||
#region "Get diff action by matching on key"
|
||||
if (Equals(oldKey, newKey))
|
||||
{
|
||||
// Keys match
|
||||
action = DiffAction.Match;
|
||||
matchWithNewTreeIndex = newStartIndex;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keys don't match
|
||||
if (keyedItemInfos == null)
|
||||
{
|
||||
keyedItemInfos = BuildKeyToInfoLookup(diffContext, oldStartIndex, oldEndIndexExcl, newStartIndex, newEndIndexExcl);
|
||||
}
|
||||
|
||||
var oldKeyItemInfo = oldKey != null ? keyedItemInfos[oldKey] : new KeyedItemInfo(-1, -1, false);
|
||||
var newKeyItemInfo = newKey != null ? keyedItemInfos[newKey] : new KeyedItemInfo(-1, -1, false);
|
||||
var oldKeyIsInNewTree = oldKeyItemInfo.NewIndex >= 0 && oldKeyItemInfo.IsUnique;
|
||||
var newKeyIsInOldTree = newKeyItemInfo.OldIndex >= 0 && newKeyItemInfo.IsUnique;
|
||||
|
||||
// If either key is not in the other tree, we can handle it as an insert or a delete
|
||||
// on this iteration. We're only forced to use the move logic that's not the case
|
||||
// (i.e., both keys are in both trees)
|
||||
if (oldKeyIsInNewTree && newKeyIsInOldTree)
|
||||
{
|
||||
// It's a move
|
||||
// Since the recipient of the diff script already has the old frame (the one with oldKey)
|
||||
// at the current siblingIndex, recurse into oldKey and update its descendants in place.
|
||||
// We re-order the frames afterwards.
|
||||
action = DiffAction.Match;
|
||||
matchWithNewTreeIndex = oldKeyItemInfo.NewIndex;
|
||||
|
||||
// Track the post-edit sibling indices of the moved items
|
||||
// Since diffContext.SiblingIndex only increases, we can be sure the values we
|
||||
// write at this point will remain correct, because there won't be any further
|
||||
// insertions/deletions at smaller sibling indices.
|
||||
keyedItemInfos[oldKey] = oldKeyItemInfo.WithOldSiblingIndex(diffContext.SiblingIndex);
|
||||
keyedItemInfos[newKey] = newKeyItemInfo.WithNewSiblingIndex(diffContext.SiblingIndex);
|
||||
}
|
||||
else if (!hasMoreNew)
|
||||
{
|
||||
// If we've run out of new items, we must be looking at just an old item, so delete it
|
||||
action = DiffAction.Delete;
|
||||
}
|
||||
else
|
||||
{
|
||||
// It's an insertion or a deletion, or both
|
||||
// If the new key is in both trees, but the old key isn't, then the old item was deleted
|
||||
// Otherwise, it's either an insertion or *both* insertion+deletion, so pick insertion and get the deletion on the next iteration if needed
|
||||
action = newKeyIsInOldTree ? DiffAction.Delete : DiffAction.Insert;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
else
|
||||
{
|
||||
#region "Get diff action by matching on sequence number"
|
||||
// Neither side is keyed, so match by sequence number
|
||||
if (oldSeq == newSeq)
|
||||
{
|
||||
// Sequences match
|
||||
action = DiffAction.Match;
|
||||
matchWithNewTreeIndex = newStartIndex;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Sequences don't match
|
||||
var oldLoopedBack = oldSeq <= prevOldSeq;
|
||||
var newLoopedBack = newSeq <= prevNewSeq;
|
||||
if (oldLoopedBack == newLoopedBack)
|
||||
{
|
||||
// Both sequences are proceeding through the same loop block, so do a simple
|
||||
// preordered merge join (picking from whichever side brings us closer to being
|
||||
// back in sync)
|
||||
action = newSeq < oldSeq ? DiffAction.Insert : DiffAction.Delete;
|
||||
|
||||
if (oldLoopedBack)
|
||||
{
|
||||
// If both old and new have now looped back, we must reset their 'looped back'
|
||||
// tracker so we can treat them as proceeding through the same loop block
|
||||
prevOldSeq = -1;
|
||||
prevNewSeq = -1;
|
||||
}
|
||||
}
|
||||
else if (oldLoopedBack)
|
||||
{
|
||||
// Old sequence looped back but new one didn't
|
||||
// The new sequence either has some extra trailing elements in the current loop block
|
||||
// which we should insert, or omits some old trailing loop blocks which we should delete
|
||||
// TODO: Find a way of not recomputing this next flag on every iteration
|
||||
var newLoopsBackLater = false;
|
||||
for (var testIndex = newStartIndex + 1; testIndex < newEndIndexExcl; testIndex++)
|
||||
{
|
||||
if (newTree[testIndex].Sequence < newSeq)
|
||||
{
|
||||
newLoopsBackLater = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the new sequence loops back later to an earlier point than this,
|
||||
// then we know it's part of the existing loop block (so should be inserted).
|
||||
// If not, then it's unrelated to the previous loop block (so we should treat
|
||||
// the old items as trailing loop blocks to be removed).
|
||||
action = newLoopsBackLater ? DiffAction.Insert : DiffAction.Delete;
|
||||
}
|
||||
else
|
||||
{
|
||||
// New sequence looped back but old one didn't
|
||||
// The old sequence either has some extra trailing elements in the current loop block
|
||||
// which we should delete, or the new sequence has extra trailing loop blocks which we
|
||||
// should insert
|
||||
// TODO: Find a way of not recomputing this next flag on every iteration
|
||||
var oldLoopsBackLater = false;
|
||||
for (var testIndex = oldStartIndex + 1; testIndex < oldEndIndexExcl; testIndex++)
|
||||
{
|
||||
if (oldTree[testIndex].Sequence < oldSeq)
|
||||
{
|
||||
oldLoopsBackLater = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the old sequence loops back later to an earlier point than this,
|
||||
// then we know it's part of the existing loop block (so should be removed).
|
||||
// If not, then it's unrelated to the previous loop block (so we should treat
|
||||
// the new items as trailing loop blocks to be inserted).
|
||||
action = oldLoopsBackLater ? DiffAction.Delete : DiffAction.Insert;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region "Apply diff action"
|
||||
switch (action)
|
||||
{
|
||||
case DiffAction.Match:
|
||||
AppendDiffEntriesForFramesWithSameSequence(ref diffContext, oldStartIndex, matchWithNewTreeIndex);
|
||||
oldStartIndex = NextSiblingIndex(oldTree[oldStartIndex], oldStartIndex);
|
||||
newStartIndex = NextSiblingIndex(newTree[newStartIndex], newStartIndex);
|
||||
hasMoreOld = oldEndIndexExcl > oldStartIndex;
|
||||
hasMoreNew = newEndIndexExcl > newStartIndex;
|
||||
prevOldSeq = oldSeq;
|
||||
prevNewSeq = newSeq;
|
||||
break;
|
||||
case DiffAction.Insert:
|
||||
InsertNewFrame(ref diffContext, newStartIndex);
|
||||
newStartIndex = NextSiblingIndex(newTree[newStartIndex], newStartIndex);
|
||||
hasMoreNew = newEndIndexExcl > newStartIndex;
|
||||
prevNewSeq = newSeq;
|
||||
break;
|
||||
case DiffAction.Delete:
|
||||
RemoveOldFrame(ref diffContext, oldStartIndex);
|
||||
oldStartIndex = NextSiblingIndex(oldTree[oldStartIndex], oldStartIndex);
|
||||
hasMoreOld = oldEndIndexExcl > oldStartIndex;
|
||||
prevOldSeq = oldSeq;
|
||||
break;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region "Write permutations list"
|
||||
if (keyedItemInfos != null)
|
||||
{
|
||||
var hasPermutations = false;
|
||||
foreach (var keyValuePair in keyedItemInfos)
|
||||
{
|
||||
var value = keyValuePair.Value;
|
||||
if (value.OldSiblingIndex >= 0 && value.NewSiblingIndex >= 0)
|
||||
{
|
||||
// This item moved
|
||||
hasPermutations = true;
|
||||
diffContext.Edits.Append(
|
||||
RenderTreeEdit.PermutationListEntry(value.OldSiblingIndex, value.NewSiblingIndex));
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPermutations)
|
||||
{
|
||||
// It's much easier for the recipient to handle if we're explicit about
|
||||
// when the list is finished
|
||||
diffContext.Edits.Append(RenderTreeEdit.PermutationListEnd());
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (keyedItemInfos != null)
|
||||
{
|
||||
keyedItemInfos.Clear();
|
||||
diffContext.KeyedItemInfoDictionaryPool.Return(keyedItemInfos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<object, KeyedItemInfo> BuildKeyToInfoLookup(DiffContext diffContext, int oldStartIndex, int oldEndIndexExcl, int newStartIndex, int newEndIndexExcl)
|
||||
{
|
||||
var result = diffContext.KeyedItemInfoDictionaryPool.Get();
|
||||
var oldTree = diffContext.OldTree;
|
||||
var newTree = diffContext.NewTree;
|
||||
|
||||
while (oldStartIndex < oldEndIndexExcl)
|
||||
{
|
||||
ref var frame = ref oldTree[oldStartIndex];
|
||||
var key = KeyValue(ref frame);
|
||||
if (key != null)
|
||||
{
|
||||
result[key] = new KeyedItemInfo(oldStartIndex, -1, isUnique: !result.ContainsKey(key));
|
||||
}
|
||||
|
||||
oldStartIndex = NextSiblingIndex(frame, oldStartIndex);
|
||||
}
|
||||
|
||||
while (newStartIndex < newEndIndexExcl)
|
||||
{
|
||||
ref var frame = ref newTree[newStartIndex];
|
||||
var key = KeyValue(ref frame);
|
||||
if (key != null)
|
||||
{
|
||||
result[key] = result.TryGetValue(key, out var existingEntry)
|
||||
? new KeyedItemInfo(existingEntry.OldIndex, newStartIndex, isUnique: existingEntry.NewIndex < 0)
|
||||
: new KeyedItemInfo(-1, newStartIndex, isUnique: true);
|
||||
}
|
||||
|
||||
newStartIndex = NextSiblingIndex(frame, newStartIndex);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static object KeyValue(ref RenderTreeFrame frame)
|
||||
{
|
||||
switch (frame.FrameType)
|
||||
{
|
||||
case RenderTreeFrameType.Element:
|
||||
return frame.ElementKey;
|
||||
case RenderTreeFrameType.Component:
|
||||
return frame.ComponentKey;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -315,11 +511,21 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
ref var oldFrame = ref oldTree[oldFrameIndex];
|
||||
ref var newFrame = ref newTree[newFrameIndex];
|
||||
|
||||
// This can't happen for sequence-matched frames from .razor components, but it can happen if you write your
|
||||
// builder logic manually or if two dissimilar frames matched by key. Treat as completely unrelated.
|
||||
var newFrameType = newFrame.FrameType;
|
||||
if (oldFrame.FrameType != newFrameType)
|
||||
{
|
||||
InsertNewFrame(ref diffContext, newFrameIndex);
|
||||
RemoveOldFrame(ref diffContext, oldFrameIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// We can assume that the old and new frames are of the same type, because they correspond
|
||||
// to the same sequence number (and if not, the behaviour is undefined).
|
||||
// TODO: Consider supporting dissimilar types at same sequence for custom IComponent implementations.
|
||||
// It should only be a matter of calling RemoveOldFrame+InsertNewFrame
|
||||
switch (newFrame.FrameType)
|
||||
switch (newFrameType)
|
||||
{
|
||||
case RenderTreeFrameType.Text:
|
||||
{
|
||||
|
|
@ -718,6 +924,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
public readonly ArrayBuilder<RenderTreeEdit> Edits;
|
||||
public readonly ArrayBuilder<RenderTreeFrame> ReferenceFrames;
|
||||
public readonly Dictionary<string, int> AttributeDiffSet;
|
||||
public readonly StackObjectPool<Dictionary<object, KeyedItemInfo>> KeyedItemInfoDictionaryPool;
|
||||
public readonly int ComponentId;
|
||||
public int SiblingIndex;
|
||||
|
||||
|
|
@ -736,6 +943,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
Edits = batchBuilder.EditsBuffer;
|
||||
ReferenceFrames = batchBuilder.ReferenceFramesBuffer;
|
||||
AttributeDiffSet = batchBuilder.AttributeDiffSet;
|
||||
KeyedItemInfoDictionaryPool = batchBuilder.KeyedItemInfoDictionaryPool;
|
||||
SiblingIndex = 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,47 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.RenderTree
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a single edit operation on a component's render tree.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Explicit)]
|
||||
public readonly struct RenderTreeEdit
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the type of the edit operation.
|
||||
/// </summary>
|
||||
public readonly RenderTreeEditType Type;
|
||||
[FieldOffset(0)] public readonly RenderTreeEditType Type;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the index of the sibling frame that the edit relates to.
|
||||
/// </summary>
|
||||
public readonly int SiblingIndex;
|
||||
[FieldOffset(4)] public readonly int SiblingIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the index of related data in an associated render frames array. For example, if the
|
||||
/// <see cref="Type"/> value is <see cref="RenderTreeEditType.PrependFrame"/>, gets the
|
||||
/// index of the new frame data in an associated render tree.
|
||||
/// </summary>
|
||||
public readonly int ReferenceFrameIndex;
|
||||
[FieldOffset(8)] public readonly int ReferenceFrameIndex;
|
||||
|
||||
/// <summary>
|
||||
/// If the <see cref="Type"/> value is <see cref="RenderTreeEditType.PermutationListEntry"/>,
|
||||
/// gets the sibling index to which the frame should be moved.
|
||||
/// </summary>
|
||||
// NOTE: Other code relies on the assumption that ReferenceFrameIndex and
|
||||
// MoveToSiblingIndex share a memory slot. If you change this, be sure to
|
||||
// update affected usages of ReferenceFrameIndex.
|
||||
[FieldOffset(8)] public readonly int MoveToSiblingIndex;
|
||||
|
||||
/// <summary>
|
||||
/// If the <see cref="Type"/> value is <see cref="RenderTreeEditType.RemoveAttribute"/>,
|
||||
/// gets the name of the attribute that is being removed.
|
||||
/// </summary>
|
||||
public readonly string RemovedAttributeName;
|
||||
[FieldOffset(16)] public readonly string RemovedAttributeName;
|
||||
|
||||
private RenderTreeEdit(RenderTreeEditType type) : this()
|
||||
{
|
||||
|
|
@ -44,11 +54,14 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
SiblingIndex = siblingIndex;
|
||||
}
|
||||
|
||||
private RenderTreeEdit(RenderTreeEditType type, int siblingIndex, int referenceFrameIndex) : this()
|
||||
private RenderTreeEdit(RenderTreeEditType type, int siblingIndex, int referenceFrameOrMoveToSiblingIndex) : this()
|
||||
{
|
||||
Type = type;
|
||||
SiblingIndex = siblingIndex;
|
||||
ReferenceFrameIndex = referenceFrameIndex;
|
||||
|
||||
// MoveToSiblingIndex is stored in the same slot as ReferenceFrameIndex,
|
||||
// so assigning to either one is equivalent
|
||||
ReferenceFrameIndex = referenceFrameOrMoveToSiblingIndex;
|
||||
}
|
||||
|
||||
private RenderTreeEdit(RenderTreeEditType type, int siblingIndex, string removedAttributeName) : this()
|
||||
|
|
@ -81,5 +94,11 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
|
||||
internal static RenderTreeEdit StepOut()
|
||||
=> new RenderTreeEdit(RenderTreeEditType.StepOut);
|
||||
|
||||
internal static RenderTreeEdit PermutationListEntry(int fromSiblingIndex, int toSiblingIndex)
|
||||
=> new RenderTreeEdit(RenderTreeEditType.PermutationListEntry, fromSiblingIndex, toSiblingIndex);
|
||||
|
||||
internal static RenderTreeEdit PermutationListEnd()
|
||||
=> new RenderTreeEdit(RenderTreeEditType.PermutationListEnd);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,5 +51,18 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
/// should be updated.
|
||||
/// </summary>
|
||||
UpdateMarkup = 8,
|
||||
|
||||
/// <summary>
|
||||
/// An entry in a sparse permutation list. That is, a list of old indices with
|
||||
/// corresponding new indices, which altogether describe a valid permutation of
|
||||
/// the children at the current edit position.
|
||||
/// </summary>
|
||||
PermutationListEntry = 9,
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that the preceding series of <see cref="PermutationListEntry"/> entries
|
||||
/// is now complete.
|
||||
/// </summary>
|
||||
PermutationListEnd = 10,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.RenderTree
|
||||
|
|
@ -17,13 +16,22 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
// Note that the struct layout has to be valid in both 32-bit and 64-bit runtime platforms,
|
||||
// which means that all reference-type fields need to take up 8 bytes (except for the last
|
||||
// one, which will be sized as either 4 or 8 bytes depending on the runtime platform).
|
||||
// This is not optimal for the Mono-WebAssembly case because that's always 32-bit so the
|
||||
// reference-type fields could be reduced to 4 bytes each. We could use ifdefs to have
|
||||
// different fields offsets for the 32 and 64 bit compile targets, but then we'd have the
|
||||
// complexity of needing different binaries when loaded into Mono-WASM vs desktop.
|
||||
// Eventually we might stop using this shared memory interop altogether (and would have to
|
||||
// if running as a web worker) so for now to keep things simple, treat reference types as
|
||||
// 8 bytes here.
|
||||
|
||||
// Although each frame type uses the slots for different purposes, the runtime does not
|
||||
// allow reference type slots to overlap with each other or with value-type slots.
|
||||
// Here's the current layout:
|
||||
//
|
||||
// Offset Type
|
||||
// ------ ----
|
||||
// 0-3 Int32 (sequence number)
|
||||
// 4-7 Int32 (frame type)
|
||||
// 8-15 Value types (usage varies by frame type)
|
||||
// 16-23 Reference type (usage varies by frame type)
|
||||
// 24-31 Reference type (usage varies by frame type)
|
||||
// 32-39 Reference type (usage varies by frame type)
|
||||
//
|
||||
// On Mono WebAssembly, because it's 32-bit, the final slot occupies bytes 32-35,
|
||||
// so the struct length is only 36.
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// Common
|
||||
|
|
@ -58,6 +66,12 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
/// </summary>
|
||||
[FieldOffset(16)] public readonly string ElementName;
|
||||
|
||||
/// <summary>
|
||||
/// If the <see cref="FrameType"/> property equals <see cref="RenderTreeFrameType.Element"/>,
|
||||
/// gets the element's diffing key, or null if none was specified.
|
||||
/// </summary>
|
||||
[FieldOffset(24)] public readonly object ElementKey;
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// RenderTreeFrameType.Text
|
||||
// --------------------------------------------------------------------------------
|
||||
|
|
@ -119,6 +133,12 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
/// </summary>
|
||||
[FieldOffset(24)] internal readonly ComponentState ComponentState;
|
||||
|
||||
/// <summary>
|
||||
/// If the <see cref="FrameType"/> property equals <see cref="RenderTreeFrameType.Component"/>,
|
||||
/// gets the component's diffing key, or null if none was specified.
|
||||
/// </summary>
|
||||
[FieldOffset(32)] public readonly object ComponentKey;
|
||||
|
||||
/// <summary>
|
||||
/// If the <see cref="FrameType"/> property equals <see cref="RenderTreeFrameType.Component"/>,
|
||||
/// gets the child component instance. Otherwise, the value is undefined.
|
||||
|
|
@ -184,66 +204,72 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
/// </summary>
|
||||
[FieldOffset(16)] public readonly string MarkupContent;
|
||||
|
||||
private RenderTreeFrame(int sequence, string elementName, int elementSubtreeLength)
|
||||
// Element constructor
|
||||
private RenderTreeFrame(int sequence, int elementSubtreeLength, string elementName, object elementKey)
|
||||
: this()
|
||||
{
|
||||
Sequence = sequence;
|
||||
FrameType = RenderTreeFrameType.Element;
|
||||
Sequence = sequence;
|
||||
ElementName = elementName;
|
||||
ElementSubtreeLength = elementSubtreeLength;
|
||||
ElementName = elementName;
|
||||
ElementKey = elementKey;
|
||||
}
|
||||
|
||||
private RenderTreeFrame(int sequence, Type componentType, int componentSubtreeLength)
|
||||
// Component constructor
|
||||
private RenderTreeFrame(int sequence, int componentSubtreeLength, Type componentType, ComponentState componentState, object componentKey)
|
||||
: this()
|
||||
{
|
||||
Sequence = sequence;
|
||||
FrameType = RenderTreeFrameType.Component;
|
||||
Sequence = sequence;
|
||||
ComponentType = componentType;
|
||||
ComponentSubtreeLength = componentSubtreeLength;
|
||||
ComponentType = componentType;
|
||||
ComponentKey = componentKey;
|
||||
|
||||
if (componentState != null)
|
||||
{
|
||||
ComponentState = componentState;
|
||||
ComponentId = componentState.ComponentId;
|
||||
}
|
||||
}
|
||||
|
||||
private RenderTreeFrame(int sequence, Type componentType, int subtreeLength, ComponentState componentState)
|
||||
: this(sequence, componentType, subtreeLength)
|
||||
{
|
||||
ComponentId = componentState.ComponentId;
|
||||
ComponentState = componentState;
|
||||
}
|
||||
|
||||
private RenderTreeFrame(int sequence, string textContent)
|
||||
: this()
|
||||
{
|
||||
FrameType = RenderTreeFrameType.Text;
|
||||
Sequence = sequence;
|
||||
TextContent = textContent;
|
||||
}
|
||||
|
||||
private RenderTreeFrame(int sequence, string attributeName, object attributeValue)
|
||||
: this()
|
||||
{
|
||||
FrameType = RenderTreeFrameType.Attribute;
|
||||
Sequence = sequence;
|
||||
AttributeName = attributeName;
|
||||
AttributeValue = attributeValue;
|
||||
}
|
||||
|
||||
private RenderTreeFrame(int sequence, string attributeName, object attributeValue, int eventHandlerId)
|
||||
: this()
|
||||
{
|
||||
FrameType = RenderTreeFrameType.Attribute;
|
||||
Sequence = sequence;
|
||||
AttributeName = attributeName;
|
||||
AttributeValue = attributeValue;
|
||||
AttributeEventHandlerId = eventHandlerId;
|
||||
}
|
||||
|
||||
// Region constructor
|
||||
private RenderTreeFrame(int sequence, int regionSubtreeLength)
|
||||
: this()
|
||||
{
|
||||
FrameType = RenderTreeFrameType.Region;
|
||||
Sequence = sequence;
|
||||
FrameType = RenderTreeFrameType.Region;
|
||||
RegionSubtreeLength = regionSubtreeLength;
|
||||
}
|
||||
|
||||
// Text/markup constructor
|
||||
private RenderTreeFrame(int sequence, bool isMarkup, string textOrMarkup)
|
||||
: this()
|
||||
{
|
||||
Sequence = sequence;
|
||||
if (isMarkup)
|
||||
{
|
||||
FrameType = RenderTreeFrameType.Markup;
|
||||
MarkupContent = textOrMarkup;
|
||||
}
|
||||
else
|
||||
{
|
||||
FrameType = RenderTreeFrameType.Text;
|
||||
TextContent = textOrMarkup;
|
||||
}
|
||||
}
|
||||
|
||||
// Attribute constructor
|
||||
private RenderTreeFrame(int sequence, string attributeName, object attributeValue, int attributeEventHandlerId)
|
||||
: this()
|
||||
{
|
||||
FrameType = RenderTreeFrameType.Attribute;
|
||||
Sequence = sequence;
|
||||
AttributeName = attributeName;
|
||||
AttributeValue = attributeValue;
|
||||
AttributeEventHandlerId = attributeEventHandlerId;
|
||||
}
|
||||
|
||||
// Element reference capture constructor
|
||||
private RenderTreeFrame(int sequence, Action<ElementRef> elementReferenceCaptureAction, string elementReferenceCaptureId)
|
||||
: this()
|
||||
{
|
||||
|
|
@ -253,6 +279,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
ElementReferenceCaptureId = elementReferenceCaptureId;
|
||||
}
|
||||
|
||||
// Component reference capture constructor
|
||||
private RenderTreeFrame(int sequence, Action<object> componentReferenceCaptureAction, int parentFrameIndex)
|
||||
: this()
|
||||
{
|
||||
|
|
@ -262,36 +289,23 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
ComponentReferenceCaptureParentFrameIndex = parentFrameIndex;
|
||||
}
|
||||
|
||||
// If we need further constructors whose signatures clash with the patterns above,
|
||||
// we can add extra args to this general-purpose one.
|
||||
private RenderTreeFrame(int sequence, RenderTreeFrameType frameType, string markupContent)
|
||||
: this()
|
||||
{
|
||||
FrameType = frameType;
|
||||
Sequence = sequence;
|
||||
MarkupContent = markupContent;
|
||||
}
|
||||
|
||||
internal static RenderTreeFrame Element(int sequence, string elementName)
|
||||
=> new RenderTreeFrame(sequence, elementName: elementName, elementSubtreeLength: 0);
|
||||
=> new RenderTreeFrame(sequence, elementSubtreeLength: 0, elementName, null);
|
||||
|
||||
internal static RenderTreeFrame Text(int sequence, string textContent)
|
||||
=> new RenderTreeFrame(sequence, textContent: textContent);
|
||||
=> new RenderTreeFrame(sequence, isMarkup: false, textOrMarkup: textContent);
|
||||
|
||||
internal static RenderTreeFrame Markup(int sequence, string markupContent)
|
||||
=> new RenderTreeFrame(sequence, RenderTreeFrameType.Markup, markupContent);
|
||||
|
||||
internal static RenderTreeFrame Attribute(int sequence, string name, MulticastDelegate value)
|
||||
=> new RenderTreeFrame(sequence, attributeName: name, attributeValue: value);
|
||||
=> new RenderTreeFrame(sequence, isMarkup: true, textOrMarkup: markupContent);
|
||||
|
||||
internal static RenderTreeFrame Attribute(int sequence, string name, object value)
|
||||
=> new RenderTreeFrame(sequence, attributeName: name, attributeValue: value);
|
||||
=> new RenderTreeFrame(sequence, attributeName: name, attributeValue: value, attributeEventHandlerId: 0);
|
||||
|
||||
internal static RenderTreeFrame ChildComponent(int sequence, Type componentType)
|
||||
=> new RenderTreeFrame(sequence, componentType, 0);
|
||||
=> new RenderTreeFrame(sequence, componentSubtreeLength: 0, componentType, null, null);
|
||||
|
||||
internal static RenderTreeFrame PlaceholderChildComponentWithSubtreeLength(int subtreeLength)
|
||||
=> new RenderTreeFrame(0, typeof(IComponent), subtreeLength);
|
||||
=> new RenderTreeFrame(0, componentSubtreeLength: subtreeLength, typeof(IComponent), null, null);
|
||||
|
||||
internal static RenderTreeFrame Region(int sequence)
|
||||
=> new RenderTreeFrame(sequence, regionSubtreeLength: 0);
|
||||
|
|
@ -303,25 +317,31 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
=> new RenderTreeFrame(sequence, componentReferenceCaptureAction: componentReferenceCaptureAction, parentFrameIndex: parentFrameIndex);
|
||||
|
||||
internal RenderTreeFrame WithElementSubtreeLength(int elementSubtreeLength)
|
||||
=> new RenderTreeFrame(Sequence, elementName: ElementName, elementSubtreeLength: elementSubtreeLength);
|
||||
=> new RenderTreeFrame(Sequence, elementSubtreeLength: elementSubtreeLength, ElementName, ElementKey);
|
||||
|
||||
internal RenderTreeFrame WithComponentSubtreeLength(int componentSubtreeLength)
|
||||
=> new RenderTreeFrame(Sequence, componentType: ComponentType, componentSubtreeLength: componentSubtreeLength);
|
||||
=> new RenderTreeFrame(Sequence, componentSubtreeLength: componentSubtreeLength, ComponentType, ComponentState, ComponentKey);
|
||||
|
||||
internal RenderTreeFrame WithAttributeSequence(int sequence)
|
||||
=> new RenderTreeFrame(sequence, attributeName: AttributeName, attributeValue: AttributeValue);
|
||||
=> new RenderTreeFrame(sequence, attributeName: AttributeName, AttributeValue, AttributeEventHandlerId);
|
||||
|
||||
internal RenderTreeFrame WithComponent(ComponentState componentState)
|
||||
=> new RenderTreeFrame(Sequence, ComponentType, ComponentSubtreeLength, componentState);
|
||||
=> new RenderTreeFrame(Sequence, componentSubtreeLength: ComponentSubtreeLength, ComponentType, componentState, ComponentKey);
|
||||
|
||||
internal RenderTreeFrame WithAttributeEventHandlerId(int eventHandlerId)
|
||||
=> new RenderTreeFrame(Sequence, AttributeName, AttributeValue, eventHandlerId);
|
||||
=> new RenderTreeFrame(Sequence, attributeName: AttributeName, AttributeValue, eventHandlerId);
|
||||
|
||||
internal RenderTreeFrame WithRegionSubtreeLength(int regionSubtreeLength)
|
||||
=> new RenderTreeFrame(Sequence, regionSubtreeLength: regionSubtreeLength);
|
||||
|
||||
internal RenderTreeFrame WithElementReferenceCaptureId(string elementReferenceCaptureId)
|
||||
=> new RenderTreeFrame(Sequence, ElementReferenceCaptureAction, elementReferenceCaptureId);
|
||||
=> new RenderTreeFrame(Sequence, elementReferenceCaptureAction: ElementReferenceCaptureAction, elementReferenceCaptureId);
|
||||
|
||||
internal RenderTreeFrame WithElementKey(object elementKey)
|
||||
=> new RenderTreeFrame(Sequence, elementSubtreeLength: ElementSubtreeLength, ElementName, elementKey);
|
||||
|
||||
internal RenderTreeFrame WithComponentKey(object componentKey)
|
||||
=> new RenderTreeFrame(Sequence, componentSubtreeLength: ComponentSubtreeLength, ComponentType, ComponentState, componentKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
// Just to be nice for debugging and unit tests.
|
||||
|
|
@ -333,10 +353,10 @@ namespace Microsoft.AspNetCore.Components.RenderTree
|
|||
return $"Attribute: (seq={Sequence}, id={AttributeEventHandlerId}) '{AttributeName}'='{AttributeValue}'";
|
||||
|
||||
case RenderTreeFrameType.Component:
|
||||
return $"Component: (seq={Sequence}, len={ComponentSubtreeLength}) {ComponentType}";
|
||||
return $"Component: (seq={Sequence}, key={ComponentKey ?? "(none)"}, len={ComponentSubtreeLength}) {ComponentType}";
|
||||
|
||||
case RenderTreeFrameType.Element:
|
||||
return $"Element: (seq={Sequence}, len={ElementSubtreeLength}) {ElementName}";
|
||||
return $"Element: (seq={Sequence}, key={ElementKey ?? "(none)"}, len={ElementSubtreeLength}) {ElementName}";
|
||||
|
||||
case RenderTreeFrameType.Region:
|
||||
return $"Region: (seq={Sequence}, len={RegionSubtreeLength})";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.RenderTree
|
||||
{
|
||||
// This is a very simple object pool that requires Get and Return calls to be
|
||||
// balanced as in a stack. It retains up to 'maxPreservedItems' instances in
|
||||
// memory, then for any further requests it supplies untracked instances.
|
||||
|
||||
internal class StackObjectPool<T> where T : class
|
||||
{
|
||||
private readonly int _maxPreservedItems;
|
||||
private readonly Func<T> _instanceFactory;
|
||||
private readonly T[] _contents;
|
||||
private int _numSuppliedItems;
|
||||
private int _numTrackedItems;
|
||||
|
||||
public StackObjectPool(int maxPreservedItems, Func<T> instanceFactory)
|
||||
{
|
||||
_maxPreservedItems = maxPreservedItems;
|
||||
_instanceFactory = instanceFactory ?? throw new ArgumentNullException(nameof(instanceFactory));
|
||||
_contents = new T[_maxPreservedItems];
|
||||
}
|
||||
|
||||
public T Get()
|
||||
{
|
||||
_numSuppliedItems++;
|
||||
|
||||
if (_numSuppliedItems <= _maxPreservedItems)
|
||||
{
|
||||
if (_numTrackedItems < _numSuppliedItems)
|
||||
{
|
||||
// Need to allocate a new one
|
||||
var newItem = _instanceFactory();
|
||||
_contents[_numTrackedItems++] = newItem;
|
||||
return newItem;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Can use one that's already in the pool
|
||||
return _contents[_numSuppliedItems - 1];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Pool is full; return untracked instance
|
||||
return _instanceFactory();
|
||||
}
|
||||
}
|
||||
|
||||
public void Return(T instance)
|
||||
{
|
||||
if (_numSuppliedItems <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("There are no outstanding instances to return.");
|
||||
}
|
||||
else if (_numSuppliedItems <= _maxPreservedItems)
|
||||
{
|
||||
// We check you're returning the right instance only as a way of
|
||||
// catching Get/Return mismatch bugs
|
||||
var expectedInstance = _contents[_numSuppliedItems - 1];
|
||||
if (!ReferenceEquals(instance, expectedInstance))
|
||||
{
|
||||
throw new ArgumentException($"Attempting to return wrong pooled instance. {nameof(Get)}/{nameof(Return)} calls must form a stack.");
|
||||
}
|
||||
}
|
||||
|
||||
// It's a valid call. Track that we're no longer "supplying" the top item,
|
||||
// but keep the instance in the _contents array for future reuse.
|
||||
_numSuppliedItems--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Rendering
|
||||
{
|
||||
// Used internally during diffing to track what we know about keyed items and their positions
|
||||
internal readonly struct KeyedItemInfo
|
||||
{
|
||||
public readonly int OldIndex;
|
||||
public readonly int NewIndex;
|
||||
public readonly int OldSiblingIndex;
|
||||
public readonly int NewSiblingIndex;
|
||||
public readonly bool IsUnique;
|
||||
|
||||
public KeyedItemInfo(int oldIndex, int newIndex, bool isUnique)
|
||||
{
|
||||
OldIndex = oldIndex;
|
||||
NewIndex = newIndex;
|
||||
OldSiblingIndex = -1;
|
||||
NewSiblingIndex = -1;
|
||||
|
||||
// Non-unique keys are problematic, because there's no way to know which instance
|
||||
// should match with which other, plus they would force us to keep track of which
|
||||
// usages have been consumed as we proceed through the diff. Since this is such
|
||||
// an edge case, we "tolerate" it just by tracking which keys have duplicates, and
|
||||
// for those ones, we never treat them as moved. Instead for those we fall back on
|
||||
// insert+delete behavior, i.e., not preserving elements/components.
|
||||
//
|
||||
// Guidance for developers is therefore to use distinct keys.
|
||||
IsUnique = isUnique;
|
||||
}
|
||||
|
||||
private KeyedItemInfo(in KeyedItemInfo copyFrom, int oldSiblingIndex, int newSiblingIndex)
|
||||
{
|
||||
this = copyFrom;
|
||||
OldSiblingIndex = oldSiblingIndex;
|
||||
NewSiblingIndex = newSiblingIndex;
|
||||
}
|
||||
|
||||
public KeyedItemInfo WithOldSiblingIndex(int oldSiblingIndex)
|
||||
=> new KeyedItemInfo(this, oldSiblingIndex, NewSiblingIndex);
|
||||
|
||||
public KeyedItemInfo WithNewSiblingIndex(int newSiblingIndex)
|
||||
=> new KeyedItemInfo(this, OldSiblingIndex, newSiblingIndex);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,9 @@ namespace Microsoft.AspNetCore.Components.Rendering
|
|||
// Scratch data structure for understanding attribute diffs.
|
||||
public Dictionary<string, int> AttributeDiffSet { get; } = new Dictionary<string, int>();
|
||||
|
||||
internal StackObjectPool<Dictionary<object, KeyedItemInfo>> KeyedItemInfoDictionaryPool { get; }
|
||||
= new StackObjectPool<Dictionary<object, KeyedItemInfo>>(maxPreservedItems: 10, () => new Dictionary<object, KeyedItemInfo>());
|
||||
|
||||
public void ClearStateForCurrentBatch()
|
||||
{
|
||||
// This method is used to reset the builder back to a default state so it can
|
||||
|
|
|
|||
|
|
@ -1047,6 +1047,107 @@ namespace Microsoft.AspNetCore.Components.Test
|
|||
frame => AssertFrame.Element(frame, "elem", 1, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanAddKeyToElement()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new RenderTreeBuilder(new TestRenderer());
|
||||
var keyValue = new object();
|
||||
|
||||
// Act
|
||||
builder.OpenElement(0, "elem");
|
||||
builder.AddAttribute(1, "attribute before", "before value");
|
||||
builder.SetKey(keyValue);
|
||||
builder.AddAttribute(2, "attribute after", "after value");
|
||||
builder.CloseElement();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(
|
||||
builder.GetFrames().AsEnumerable(),
|
||||
frame =>
|
||||
{
|
||||
AssertFrame.Element(frame, "elem", 3, 0);
|
||||
Assert.Same(keyValue, frame.ElementKey);
|
||||
},
|
||||
frame => AssertFrame.Attribute(frame, "attribute before", "before value", 1),
|
||||
frame => AssertFrame.Attribute(frame, "attribute after", "after value", 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanAddKeyToComponent()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new RenderTreeBuilder(new TestRenderer());
|
||||
var keyValue = new object();
|
||||
|
||||
// Act
|
||||
builder.OpenComponent<TestComponent>(0);
|
||||
builder.AddAttribute(1, "param before", 123);
|
||||
builder.SetKey(keyValue);
|
||||
builder.AddAttribute(2, "param after", 456);
|
||||
builder.CloseComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(
|
||||
builder.GetFrames().AsEnumerable(),
|
||||
frame =>
|
||||
{
|
||||
AssertFrame.Component<TestComponent>(frame, 3, 0);
|
||||
Assert.Same(keyValue, frame.ComponentKey);
|
||||
},
|
||||
frame => AssertFrame.Attribute(frame, "param before", 123, 1),
|
||||
frame => AssertFrame.Attribute(frame, "param after", 456, 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CannotAddKeyOutsideComponentOrElement_TreeRoot()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new RenderTreeBuilder(new TestRenderer());
|
||||
|
||||
// Act/Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
{
|
||||
builder.SetKey(new object());
|
||||
});
|
||||
Assert.Equal("Cannot set a key outside the scope of a component or element.", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CannotAddKeyOutsideComponentOrElement_RegionRoot()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new RenderTreeBuilder(new TestRenderer());
|
||||
|
||||
// Act/Assert
|
||||
builder.OpenElement(0, "some element");
|
||||
builder.OpenRegion(1);
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
{
|
||||
builder.SetKey(new object());
|
||||
});
|
||||
Assert.Equal($"Cannot set a key on a frame of type {RenderTreeFrameType.Region}.", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CannotAddNullKey()
|
||||
{
|
||||
// Although we could translate 'null' into either some default "null key"
|
||||
// instance, or just no-op the call, it almost certainly indicates a programming
|
||||
// error so it's better to fail.
|
||||
|
||||
// Arrange
|
||||
var builder = new RenderTreeBuilder(new TestRenderer());
|
||||
|
||||
// Act/Assert
|
||||
var ex = Assert.Throws<ArgumentNullException>(() =>
|
||||
{
|
||||
builder.OpenElement(0, "elem");
|
||||
builder.SetKey(null);
|
||||
});
|
||||
Assert.Equal("value", ex.ParamName);
|
||||
}
|
||||
|
||||
private class TestComponent : IComponent
|
||||
{
|
||||
public void Configure(RenderHandle renderHandle) { }
|
||||
|
|
|
|||
|
|
@ -103,6 +103,417 @@ namespace Microsoft.AspNetCore.Components.Test
|
|||
entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecognizesKeyedElementInsertions()
|
||||
{
|
||||
// Arrange
|
||||
oldTree.OpenElement(0, "container");
|
||||
oldTree.SetKey("retained key");
|
||||
oldTree.AddContent(1, "Existing");
|
||||
oldTree.CloseElement();
|
||||
|
||||
newTree.OpenElement(0, "container");
|
||||
newTree.SetKey("new key");
|
||||
newTree.AddContent(1, "Inserted");
|
||||
newTree.CloseElement();
|
||||
|
||||
newTree.OpenElement(0, "container");
|
||||
newTree.SetKey("retained key");
|
||||
newTree.AddContent(1, "Existing");
|
||||
newTree.CloseElement();
|
||||
|
||||
// Without the key, it would change the text "Existing" to "Inserted", then insert a new "Existing" below it
|
||||
// With the key, it just inserts a new "Inserted" at the top
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
entry =>
|
||||
{
|
||||
AssertEdit(entry, RenderTreeEditType.PrependFrame, 0);
|
||||
Assert.Equal(0, entry.ReferenceFrameIndex);
|
||||
Assert.Equal("new key", referenceFrames[entry.ReferenceFrameIndex].ElementKey);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecognizesKeyedElementDeletions()
|
||||
{
|
||||
// Arrange
|
||||
oldTree.OpenElement(0, "container");
|
||||
oldTree.SetKey("will delete");
|
||||
oldTree.AddContent(1, "First");
|
||||
oldTree.CloseElement();
|
||||
|
||||
oldTree.OpenElement(0, "container");
|
||||
oldTree.SetKey("will retain");
|
||||
oldTree.AddContent(1, "Second");
|
||||
oldTree.CloseElement();
|
||||
|
||||
newTree.OpenElement(0, "container");
|
||||
newTree.SetKey("will retain");
|
||||
newTree.AddContent(1, "Second");
|
||||
newTree.CloseElement();
|
||||
|
||||
// Without the key, it changes the text content of "First" to "Second", then deletes the other "Second"
|
||||
// With the key, it just deletes "First"
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecognizesSimultaneousKeyedElementInsertionsAndDeletions()
|
||||
{
|
||||
// Arrange
|
||||
oldTree.OpenElement(0, "container");
|
||||
oldTree.SetKey("original key");
|
||||
oldTree.AddContent(1, "Original");
|
||||
oldTree.CloseElement();
|
||||
|
||||
newTree.OpenElement(0, "container");
|
||||
newTree.SetKey("new key");
|
||||
newTree.AddContent(1, "Inserted");
|
||||
newTree.CloseElement();
|
||||
|
||||
// Without the key, it would change the text "Original" to "Inserted"
|
||||
// With the key, it deletes the old element and inserts the new element
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
entry =>
|
||||
{
|
||||
AssertEdit(entry, RenderTreeEditType.PrependFrame, 0);
|
||||
Assert.Equal(0, entry.ReferenceFrameIndex);
|
||||
Assert.Equal("new key", referenceFrames[entry.ReferenceFrameIndex].ElementKey);
|
||||
},
|
||||
entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecognizesKeyedComponentInsertions()
|
||||
{
|
||||
// Arrange
|
||||
oldTree.OpenComponent<CaptureSetParametersComponent>(0);
|
||||
oldTree.SetKey("retained key");
|
||||
oldTree.AddAttribute(1, "ParamName", "Param old value");
|
||||
oldTree.CloseComponent();
|
||||
GetRenderedBatch(new RenderTreeBuilder(renderer), oldTree, false); // Assign initial IDs
|
||||
var oldComponent = GetComponents<CaptureSetParametersComponent>(oldTree).Single();
|
||||
|
||||
newTree.OpenComponent<CaptureSetParametersComponent>(0);
|
||||
newTree.SetKey("new key");
|
||||
newTree.AddAttribute(1, "ParamName", "New component param value");
|
||||
newTree.CloseComponent();
|
||||
|
||||
newTree.OpenComponent<CaptureSetParametersComponent>(0);
|
||||
newTree.SetKey("retained key");
|
||||
newTree.AddAttribute(1, "ParamName", "Param new value");
|
||||
newTree.CloseComponent();
|
||||
|
||||
// Without the key, it would modify the param on the first component,
|
||||
// then insert a new second component.
|
||||
// With the key, it inserts a new first component, then modifies the
|
||||
// param on the second component.
|
||||
|
||||
// Act
|
||||
var batch = GetRenderedBatch(initializeFromFrames: false);
|
||||
var newComponents = GetComponents<CaptureSetParametersComponent>(newTree);
|
||||
|
||||
// Assert: Inserts new component at position 0
|
||||
Assert.Equal(1, batch.UpdatedComponents.Count);
|
||||
Assert.Collection(batch.UpdatedComponents.Array[0].Edits,
|
||||
entry => AssertEdit(entry, RenderTreeEditType.PrependFrame, 0));
|
||||
|
||||
// Assert: Retains old component instance in position 1, and updates its params
|
||||
Assert.Same(oldComponent, newComponents[1]);
|
||||
Assert.Equal(2, oldComponent.SetParametersCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecognizesKeyedComponentDeletions()
|
||||
{
|
||||
// Arrange
|
||||
oldTree.OpenComponent<FakeComponent>(0);
|
||||
oldTree.SetKey("will delete");
|
||||
oldTree.AddAttribute(1, nameof(FakeComponent.StringProperty), "Anything");
|
||||
oldTree.CloseComponent();
|
||||
|
||||
oldTree.OpenComponent<FakeComponent>(0);
|
||||
oldTree.SetKey("will retain");
|
||||
oldTree.AddAttribute(1, nameof(FakeComponent.StringProperty), "Retained param value");
|
||||
oldTree.CloseComponent();
|
||||
|
||||
// Instantiate initial components
|
||||
GetRenderedBatch(new RenderTreeBuilder(renderer), oldTree, false);
|
||||
var oldComponents = GetComponents(oldTree);
|
||||
|
||||
newTree.OpenComponent<FakeComponent>(0);
|
||||
newTree.SetKey("will retain");
|
||||
newTree.AddAttribute(1, nameof(FakeComponent.StringProperty), "Retained param value");
|
||||
newTree.CloseComponent();
|
||||
|
||||
// Without the key, it updates the param on the first component, then
|
||||
// deletes the second.
|
||||
// With the key, it just deletes the first.
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
var newComponent = GetComponents(newTree).Single();
|
||||
|
||||
// Assert
|
||||
Assert.Same(oldComponents[1], newComponent);
|
||||
Assert.Collection(result.Edits,
|
||||
entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecognizesSimultaneousKeyedComponentInsertionsAndDeletions()
|
||||
{
|
||||
// Arrange
|
||||
oldTree.OpenComponent<FakeComponent>(0);
|
||||
oldTree.SetKey("original key");
|
||||
oldTree.CloseComponent();
|
||||
|
||||
// Instantiate initial component
|
||||
GetRenderedBatch(new RenderTreeBuilder(renderer), oldTree, false);
|
||||
var oldComponent = GetComponents(oldTree).Single();
|
||||
Assert.NotNull(oldComponent);
|
||||
|
||||
newTree.OpenComponent<FakeComponent>(0);
|
||||
newTree.SetKey("new key");
|
||||
newTree.CloseComponent();
|
||||
|
||||
// Without the key, it would retain the component
|
||||
// With the key, it deletes the old component and inserts the new component
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
var newComponent = GetComponents(newTree).Single();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(newComponent);
|
||||
Assert.NotSame(oldComponent, newComponent);
|
||||
Assert.Collection(result.Edits,
|
||||
entry =>
|
||||
{
|
||||
AssertEdit(entry, RenderTreeEditType.PrependFrame, 0);
|
||||
Assert.Equal(0, entry.ReferenceFrameIndex);
|
||||
Assert.Equal("new key", referenceFrames[entry.ReferenceFrameIndex].ComponentKey);
|
||||
},
|
||||
entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesClashingKeys_FirstUsage()
|
||||
{
|
||||
// This scenario is problematic for the algorithm if it uses a "first key
|
||||
// usage wins" policy for duplicate keys. It would not end up with attrib1b
|
||||
// anywhere in the output, because whenever it sees key1 in oldTree, it tries
|
||||
// to diff against the first usage of key1 in newTree, which has attrib1a.
|
||||
|
||||
// However, because of the actual "duplicated keys are excluded from the
|
||||
// dictionary match" policy, we don't preserve any of the key1 items, and
|
||||
// the diff is valid.
|
||||
|
||||
// Arrange
|
||||
AddWithKey(oldTree, "key3", "attrib3");
|
||||
AddWithKey(oldTree, "key1", "attrib1a");
|
||||
AddWithKey(oldTree, "key1", "attrib1a");
|
||||
AddWithKey(oldTree, "key2", "attrib2");
|
||||
|
||||
AddWithKey(newTree, "key1", "attrib1a");
|
||||
AddWithKey(newTree, "key2", "attrib2");
|
||||
AddWithKey(newTree, "key1", "attrib1b");
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
// Insert key1+attrib1a at the top
|
||||
edit =>
|
||||
{
|
||||
AssertEdit(edit, RenderTreeEditType.PrependFrame, 0);
|
||||
Assert.Equal("attrib1a", referenceFrames[edit.ReferenceFrameIndex + 1].AttributeValue);
|
||||
},
|
||||
// Delete key3+attrib3
|
||||
edit => AssertEdit(edit, RenderTreeEditType.RemoveFrame, 1),
|
||||
// Delete key1+attrib1a
|
||||
edit => AssertEdit(edit, RenderTreeEditType.RemoveFrame, 1),
|
||||
// Delete the other key1+attrib1a
|
||||
edit => AssertEdit(edit, RenderTreeEditType.RemoveFrame, 1),
|
||||
// Insert key1+attrib1b at the bottom
|
||||
edit =>
|
||||
{
|
||||
AssertEdit(edit, RenderTreeEditType.PrependFrame, 2);
|
||||
Assert.Equal("attrib1b", referenceFrames[edit.ReferenceFrameIndex + 1].AttributeValue);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesClashingKeys_LastUsage()
|
||||
{
|
||||
// This scenario is problematic for the algorithm if it uses a "last key
|
||||
// usage wins" policy for duplicate keys. It would not end up with attrib1b
|
||||
// anywhere in the output, because when it sees key1 in oldTree, it tries
|
||||
// to diff against the last usage of key1 in newTree, which has attrib1a.
|
||||
|
||||
// However, because of the actual "duplicated keys are excluded from the
|
||||
// dictionary match" policy, we don't preserve any of the key1 items, and
|
||||
// the diff is valid.
|
||||
|
||||
// Arrange
|
||||
AddWithKey(oldTree, "key1", "attrib1a");
|
||||
AddWithKey(oldTree, "key2", "attrib2");
|
||||
AddWithKey(oldTree, "key1", "attrib1b");
|
||||
|
||||
AddWithKey(newTree, "key2", "attrib2");
|
||||
AddWithKey(newTree, "key1", "attrib1b");
|
||||
AddWithKey(newTree, "key1", "attrib1a");
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
// Delete key1+attrib1a
|
||||
edit => AssertEdit(edit, RenderTreeEditType.RemoveFrame, 0),
|
||||
// Insert a new key1+attrib1a at the bottom
|
||||
edit =>
|
||||
{
|
||||
AssertEdit(edit, RenderTreeEditType.PrependFrame, 2);
|
||||
Assert.Equal("attrib1a", referenceFrames[edit.ReferenceFrameIndex + 1].AttributeValue);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesInsertionOfUnkeyedItemsAroundKey()
|
||||
{
|
||||
// The fact that the new sequence numbers are descending makes this
|
||||
// problematic if it prefers matching by sequence over key.
|
||||
// However, since the policy is to prefer key over sequence, it works OK.
|
||||
|
||||
// Arrange
|
||||
oldTree.OpenElement(1, "el");
|
||||
oldTree.SetKey("some key");
|
||||
oldTree.CloseElement();
|
||||
|
||||
newTree.OpenElement(2, "other");
|
||||
newTree.CloseElement();
|
||||
|
||||
newTree.OpenElement(1, "el");
|
||||
newTree.SetKey("some key");
|
||||
newTree.CloseElement();
|
||||
|
||||
newTree.OpenElement(0, "other 2");
|
||||
newTree.CloseElement();
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
edit => AssertEdit(edit, RenderTreeEditType.PrependFrame, 0),
|
||||
edit => AssertEdit(edit, RenderTreeEditType.PrependFrame, 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesDeletionOfUnkeyedItemsAroundKey()
|
||||
{
|
||||
// The fact that the old sequence numbers are descending makes this
|
||||
// problematic if it prefers matching by sequence over key.
|
||||
// However, since the policy is to prefer key over sequence, it works OK.
|
||||
|
||||
// Arrange
|
||||
oldTree.OpenElement(2, "other");
|
||||
oldTree.CloseElement();
|
||||
|
||||
oldTree.OpenElement(1, "el");
|
||||
oldTree.SetKey("some key");
|
||||
oldTree.CloseElement();
|
||||
|
||||
oldTree.OpenElement(0, "other 2");
|
||||
oldTree.CloseElement();
|
||||
|
||||
newTree.OpenElement(1, "el");
|
||||
newTree.SetKey("some key");
|
||||
newTree.CloseElement();
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
edit => AssertEdit(edit, RenderTreeEditType.RemoveFrame, 0),
|
||||
edit => AssertEdit(edit, RenderTreeEditType.RemoveFrame, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesKeyBeingAdded()
|
||||
{
|
||||
// This is an anomolous situation that can't occur with .razor components.
|
||||
// It represents the case where, for the same sequence number, we have an
|
||||
// old frame without a key and a new frame with a key.
|
||||
|
||||
// Arrange
|
||||
oldTree.OpenElement(0, "el");
|
||||
oldTree.CloseElement();
|
||||
|
||||
newTree.OpenElement(0, "el");
|
||||
newTree.SetKey("some key");
|
||||
newTree.CloseElement();
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
// Insert new
|
||||
edit =>
|
||||
{
|
||||
AssertEdit(edit, RenderTreeEditType.PrependFrame, 0);
|
||||
Assert.Equal("some key", referenceFrames[edit.ReferenceFrameIndex].ElementKey);
|
||||
},
|
||||
// Delete old
|
||||
edit => AssertEdit(edit, RenderTreeEditType.RemoveFrame, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesKeyBeingRemoved()
|
||||
{
|
||||
// This is an anomolous situation that can't occur with .razor components.
|
||||
// It represents the case where, for the same sequence number, we have an
|
||||
// old frame with a key and a new frame without a key.
|
||||
|
||||
// Arrange
|
||||
oldTree.OpenElement(0, "el");
|
||||
oldTree.SetKey("some key");
|
||||
oldTree.CloseElement();
|
||||
|
||||
newTree.OpenElement(0, "el");
|
||||
newTree.CloseElement();
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
// Insert new
|
||||
edit => AssertEdit(edit, RenderTreeEditType.PrependFrame, 0),
|
||||
// Delete old
|
||||
edit => AssertEdit(edit, RenderTreeEditType.RemoveFrame, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecognizesTrailingSequenceWithinLoopBlockBeingRemoved()
|
||||
{
|
||||
|
|
@ -1492,6 +1903,254 @@ namespace Microsoft.AspNetCore.Components.Test
|
|||
Assert.Empty(referenceFrames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecognizesKeyedElementMoves()
|
||||
{
|
||||
// Arrange
|
||||
oldTree.OpenElement(0, "container");
|
||||
oldTree.SetKey("first key");
|
||||
oldTree.AddContent(1, "First");
|
||||
oldTree.CloseElement();
|
||||
|
||||
oldTree.AddContent(2, "Unkeyed item");
|
||||
|
||||
oldTree.OpenElement(0, "container");
|
||||
oldTree.SetKey("second key");
|
||||
oldTree.AddContent(1, "Second");
|
||||
oldTree.CloseElement();
|
||||
|
||||
newTree.OpenElement(0, "container");
|
||||
newTree.SetKey("second key");
|
||||
newTree.AddContent(1, "Second");
|
||||
newTree.CloseElement();
|
||||
|
||||
newTree.AddContent(2, "Unkeyed item");
|
||||
|
||||
newTree.OpenElement(0, "container");
|
||||
newTree.SetKey("first key");
|
||||
newTree.AddContent(1, "First modified");
|
||||
newTree.CloseElement();
|
||||
|
||||
// Without the key, it changes the text contents of both
|
||||
// With the key, it reorders them and just updates the text content of one
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
// First we update the modified descendants in place
|
||||
entry => AssertEdit(entry, RenderTreeEditType.StepIn, 0),
|
||||
entry =>
|
||||
{
|
||||
AssertEdit(entry, RenderTreeEditType.UpdateText, 0);
|
||||
Assert.Equal(0, entry.ReferenceFrameIndex);
|
||||
Assert.Equal("First modified", referenceFrames[entry.ReferenceFrameIndex].TextContent);
|
||||
},
|
||||
entry => AssertEdit(entry, RenderTreeEditType.StepOut, 0),
|
||||
|
||||
// Then we have the permutation list
|
||||
entry => AssertPermutationListEntry(entry, 0, 2),
|
||||
entry => AssertPermutationListEntry(entry, 2, 0),
|
||||
entry => AssertEdit(entry, RenderTreeEditType.PermutationListEnd, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecognizesKeyedComponentMoves()
|
||||
{
|
||||
// Arrange
|
||||
oldTree.OpenComponent<CaptureSetParametersComponent>(0);
|
||||
oldTree.SetKey("first key");
|
||||
oldTree.AddAttribute(1, nameof(FakeComponent.StringProperty), "First param");
|
||||
oldTree.CloseComponent();
|
||||
|
||||
oldTree.AddContent(2, "Unkeyed item");
|
||||
|
||||
oldTree.OpenComponent<CaptureSetParametersComponent>(0);
|
||||
oldTree.SetKey("second key");
|
||||
oldTree.AddAttribute(1, nameof(FakeComponent.StringProperty), "Second param");
|
||||
oldTree.CloseComponent();
|
||||
|
||||
GetRenderedBatch(new RenderTreeBuilder(renderer), oldTree, false); // Assign initial IDs
|
||||
var oldComponents = GetComponents<CaptureSetParametersComponent>(oldTree);
|
||||
|
||||
newTree.OpenComponent<CaptureSetParametersComponent>(0);
|
||||
newTree.SetKey("second key");
|
||||
newTree.AddAttribute(1, nameof(FakeComponent.StringProperty), "Second param");
|
||||
newTree.CloseComponent();
|
||||
|
||||
newTree.AddContent(2, "Unkeyed item");
|
||||
|
||||
newTree.OpenComponent<CaptureSetParametersComponent>(0);
|
||||
newTree.SetKey("first key");
|
||||
newTree.AddAttribute(1, nameof(FakeComponent.StringProperty), "First param modified");
|
||||
newTree.CloseComponent();
|
||||
|
||||
// Without the key, it changes the parameter on both
|
||||
// With the key, it reorders them and just updates the parameter of one
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
var newComponents = GetComponents<CaptureSetParametersComponent>(newTree);
|
||||
|
||||
// Assert: Retains component instances
|
||||
Assert.Same(oldComponents[0], newComponents[1]);
|
||||
Assert.Same(oldComponents[1], newComponents[0]);
|
||||
|
||||
// Assert: Supplies updated params only to (originally) first component
|
||||
Assert.Equal(2, oldComponents[0].SetParametersCallCount);
|
||||
Assert.Equal(1, oldComponents[1].SetParametersCallCount);
|
||||
|
||||
// Assert: Correct diff
|
||||
Assert.Collection(result.Edits,
|
||||
entry => AssertPermutationListEntry(entry, 0, 2),
|
||||
entry => AssertPermutationListEntry(entry, 2, 0),
|
||||
entry => AssertEdit(entry, RenderTreeEditType.PermutationListEnd, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanMoveBeforeInsertedItem()
|
||||
{
|
||||
// Arrange
|
||||
AddWithKey(oldTree, "will retain");
|
||||
AddWithKey(oldTree, "will move");
|
||||
|
||||
AddWithKey(newTree, "will move");
|
||||
AddWithKey(newTree, "newly inserted");
|
||||
AddWithKey(newTree, "will retain");
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
entry => {
|
||||
AssertEdit(entry, RenderTreeEditType.PrependFrame, 1);
|
||||
Assert.Equal(0, entry.ReferenceFrameIndex);
|
||||
Assert.Equal("newly inserted", referenceFrames[entry.ReferenceFrameIndex].ElementKey);
|
||||
},
|
||||
entry => AssertPermutationListEntry(entry, 0, 2),
|
||||
entry => AssertPermutationListEntry(entry, 2, 0),
|
||||
entry => AssertEdit(entry, RenderTreeEditType.PermutationListEnd, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanMoveBeforeDeletedItem()
|
||||
{
|
||||
// Arrange
|
||||
AddWithKey(oldTree, "will retain");
|
||||
AddWithKey(oldTree, "will delete");
|
||||
AddWithKey(oldTree, "will move");
|
||||
|
||||
AddWithKey(newTree, "will move");
|
||||
AddWithKey(newTree, "will retain");
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 1),
|
||||
entry => AssertPermutationListEntry(entry, 0, 1),
|
||||
entry => AssertPermutationListEntry(entry, 1, 0),
|
||||
entry => AssertEdit(entry, RenderTreeEditType.PermutationListEnd, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanMoveAfterInsertedItem()
|
||||
{
|
||||
// Arrange
|
||||
AddWithKey(oldTree, "will move");
|
||||
AddWithKey(oldTree, "will retain");
|
||||
|
||||
AddWithKey(newTree, "newly inserted");
|
||||
AddWithKey(newTree, "will retain");
|
||||
AddWithKey(newTree, "will move");
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
entry => {
|
||||
AssertEdit(entry, RenderTreeEditType.PrependFrame, 0);
|
||||
Assert.Equal(0, entry.ReferenceFrameIndex);
|
||||
Assert.Equal("newly inserted", referenceFrames[entry.ReferenceFrameIndex].ElementKey);
|
||||
},
|
||||
entry => AssertPermutationListEntry(entry, 1, 2),
|
||||
entry => AssertPermutationListEntry(entry, 2, 1),
|
||||
entry => AssertEdit(entry, RenderTreeEditType.PermutationListEnd, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanMoveAfterDeletedItem()
|
||||
{
|
||||
// Arrange
|
||||
AddWithKey(oldTree, "will move");
|
||||
AddWithKey(oldTree, "will delete");
|
||||
AddWithKey(oldTree, "will retain");
|
||||
|
||||
AddWithKey(newTree, "will retain");
|
||||
AddWithKey(newTree, "will move");
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 1),
|
||||
entry => AssertPermutationListEntry(entry, 0, 1),
|
||||
entry => AssertPermutationListEntry(entry, 1, 0),
|
||||
entry => AssertEdit(entry, RenderTreeEditType.PermutationListEnd, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanChangeFrameTypeWithMatchingSequenceNumber()
|
||||
{
|
||||
oldTree.OpenElement(0, "some elem");
|
||||
oldTree.AddContent(1, "Hello!");
|
||||
oldTree.CloseElement();
|
||||
|
||||
newTree.AddContent(0, "some text");
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
entry =>
|
||||
{
|
||||
AssertEdit(entry, RenderTreeEditType.PrependFrame, 0);
|
||||
Assert.Equal(0, entry.ReferenceFrameIndex);
|
||||
Assert.Equal("some text", referenceFrames[entry.ReferenceFrameIndex].TextContent);
|
||||
},
|
||||
entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanChangeFrameTypeWithMatchingKey()
|
||||
{
|
||||
oldTree.OpenComponent<FakeComponent>(0);
|
||||
oldTree.CloseComponent();
|
||||
|
||||
newTree.OpenElement(0, "some elem");
|
||||
newTree.SetKey("my key");
|
||||
newTree.CloseElement();
|
||||
|
||||
// Act
|
||||
var (result, referenceFrames) = GetSingleUpdatedComponent();
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Edits,
|
||||
entry =>
|
||||
{
|
||||
AssertEdit(entry, RenderTreeEditType.PrependFrame, 0);
|
||||
Assert.Equal(0, entry.ReferenceFrameIndex);
|
||||
Assert.Equal("some elem", referenceFrames[entry.ReferenceFrameIndex].ElementName);
|
||||
},
|
||||
entry => AssertEdit(entry, RenderTreeEditType.RemoveFrame, 1));
|
||||
}
|
||||
|
||||
private (RenderTreeDiff, RenderTreeFrame[]) GetSingleUpdatedComponent(bool initializeFromFrames = false)
|
||||
{
|
||||
var result = GetSingleUpdatedComponentWithBatch(initializeFromFrames);
|
||||
|
|
@ -1524,6 +2183,28 @@ namespace Microsoft.AspNetCore.Components.Test
|
|||
return batchBuilder.ToBatch();
|
||||
}
|
||||
|
||||
private static IList<IComponent> GetComponents(RenderTreeBuilder builder)
|
||||
=> GetComponents<IComponent>(builder);
|
||||
|
||||
private static IList<T> GetComponents<T>(RenderTreeBuilder builder) where T : IComponent
|
||||
=> builder.GetFrames().AsEnumerable()
|
||||
.Where(x => x.FrameType == RenderTreeFrameType.Component)
|
||||
.Select(x => (T)x.Component)
|
||||
.ToList();
|
||||
|
||||
private static void AddWithKey(RenderTreeBuilder builder, object key, string attributeValue = null)
|
||||
{
|
||||
builder.OpenElement(0, "el");
|
||||
builder.SetKey(key);
|
||||
|
||||
if (attributeValue != null)
|
||||
{
|
||||
builder.AddAttribute(1, "attrib", attributeValue);
|
||||
}
|
||||
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
private class FakeRenderer : Renderer
|
||||
{
|
||||
public FakeRenderer() : base(new TestServiceProvider(), new RendererSynchronizationContext())
|
||||
|
|
@ -1613,5 +2294,15 @@ namespace Microsoft.AspNetCore.Components.Test
|
|||
Assert.Equal(type, edit.Type);
|
||||
Assert.Equal(siblingIndex, edit.SiblingIndex);
|
||||
}
|
||||
|
||||
private static void AssertPermutationListEntry(
|
||||
RenderTreeEdit edit,
|
||||
int fromSiblingIndex,
|
||||
int toSiblingIndex)
|
||||
{
|
||||
Assert.Equal(RenderTreeEditType.PermutationListEntry, edit.Type);
|
||||
Assert.Equal(fromSiblingIndex, edit.SiblingIndex);
|
||||
Assert.Equal(toSiblingIndex, edit.MoveToSiblingIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.RenderTree
|
||||
{
|
||||
public class StackObjectPoolTest
|
||||
{
|
||||
[Fact]
|
||||
public void CanGetInstances()
|
||||
{
|
||||
// Arrange
|
||||
var stackObjectPool = new StackObjectPool<object>(10, () => new object());
|
||||
|
||||
// Act
|
||||
var instance1 = stackObjectPool.Get();
|
||||
var instance2 = stackObjectPool.Get();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(instance1);
|
||||
Assert.NotNull(instance2);
|
||||
Assert.NotSame(instance1, instance2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanReturnInstances()
|
||||
{
|
||||
// Arrange
|
||||
var stackObjectPool = new StackObjectPool<object>(10, () => new object());
|
||||
var instance1 = stackObjectPool.Get();
|
||||
var instance2 = stackObjectPool.Get();
|
||||
|
||||
// Act/Assert
|
||||
// No exception means success
|
||||
stackObjectPool.Return(instance2);
|
||||
stackObjectPool.Return(instance1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReusesInstancesInPoolUpToCapacity()
|
||||
{
|
||||
// Arrange
|
||||
var stackObjectPool = new StackObjectPool<object>(10, () => new object());
|
||||
var instance1 = stackObjectPool.Get();
|
||||
var instance2 = stackObjectPool.Get();
|
||||
stackObjectPool.Return(instance2);
|
||||
stackObjectPool.Return(instance1);
|
||||
|
||||
// Act
|
||||
var instance1b = stackObjectPool.Get();
|
||||
var instance2b = stackObjectPool.Get();
|
||||
var instance3 = stackObjectPool.Get();
|
||||
|
||||
// Assert
|
||||
Assert.Same(instance1, instance1b);
|
||||
Assert.Same(instance2, instance2b);
|
||||
Assert.NotNull(instance3);
|
||||
Assert.NotSame(instance1, instance3);
|
||||
Assert.NotSame(instance2, instance3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuppliesTransientInstancesWhenExceedingCapacity()
|
||||
{
|
||||
// Arrange
|
||||
var stackObjectPool = new StackObjectPool<object>(1, () => new object());
|
||||
|
||||
// Act 1: Returns distinct instances beyond capacity
|
||||
var instance1 = stackObjectPool.Get();
|
||||
var instance2 = stackObjectPool.Get();
|
||||
var instance3 = stackObjectPool.Get();
|
||||
Assert.NotNull(instance1);
|
||||
Assert.NotNull(instance2);
|
||||
Assert.NotNull(instance3);
|
||||
Assert.Equal(3, new[] { instance1, instance2, instance3 }.Distinct().Count());
|
||||
|
||||
// Act 2: Can return all instances, including transient ones
|
||||
stackObjectPool.Return(instance3);
|
||||
stackObjectPool.Return(instance2);
|
||||
stackObjectPool.Return(instance1);
|
||||
|
||||
// Act 3: Reuses only the non-transient instances
|
||||
var instance1b = stackObjectPool.Get();
|
||||
var instance2b = stackObjectPool.Get();
|
||||
Assert.Same(instance1, instance1b);
|
||||
Assert.NotSame(instance2b, instance2);
|
||||
Assert.Equal(4, new[] { instance1, instance2, instance3, instance2b }.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CannotReturnWhenEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var stackObjectPool = new StackObjectPool<object>(10, () => new object());
|
||||
|
||||
// Act/Assert
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
{
|
||||
stackObjectPool.Return(new object());
|
||||
});
|
||||
Assert.Equal("There are no outstanding instances to return.", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CannotReturnMismatchingTrackedItem()
|
||||
{
|
||||
// Arrange
|
||||
var stackObjectPool = new StackObjectPool<object>(10, () => new object());
|
||||
var instance1 = stackObjectPool.Get();
|
||||
var instance2 = stackObjectPool.Get();
|
||||
|
||||
// Act/Assert
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
{
|
||||
stackObjectPool.Return(instance1);
|
||||
});
|
||||
Assert.Equal("Attempting to return wrong pooled instance. Get/Return calls must form a stack.", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -105,7 +105,11 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
// for this specific RenderTreeEditType.
|
||||
_binaryWriter.Write((int)edit.Type);
|
||||
_binaryWriter.Write(edit.SiblingIndex);
|
||||
|
||||
// ReferenceFrameIndex and MoveToSiblingIndex share a slot, so this writes
|
||||
// whichever one applies to the edit type
|
||||
_binaryWriter.Write(edit.ReferenceFrameIndex);
|
||||
|
||||
WriteString(edit.RemovedAttributeName, allowDeduplication: true);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,351 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BasicTestApp;
|
||||
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
|
||||
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
|
||||
using Microsoft.AspNetCore.E2ETesting;
|
||||
using Microsoft.JSInterop;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Interactions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||
{
|
||||
public class KeyTest : BasicTestAppTestBase
|
||||
{
|
||||
public KeyTest(
|
||||
BrowserFixture browserFixture,
|
||||
ToggleExecutionModeServerFixture<Program> serverFixture,
|
||||
ITestOutputHelper output)
|
||||
: base(browserFixture, serverFixture, output)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void InitializeAsyncCore()
|
||||
{
|
||||
// On WebAssembly, page reloads are expensive so skip if possible
|
||||
Navigate(ServerPathBase, noReload: !_serverFixture.UsingAspNetHost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanInsert()
|
||||
{
|
||||
PerformTest(
|
||||
before: new[]
|
||||
{
|
||||
new Node("orig1", "A"),
|
||||
new Node("orig2", "B"),
|
||||
},
|
||||
after: new[]
|
||||
{
|
||||
new Node("new1", "Inserted before") { IsNew = true },
|
||||
new Node("orig1", "A"),
|
||||
new Node("new2", "Inserted between") { IsNew = true },
|
||||
new Node("orig2", "B edited"),
|
||||
new Node("new3", "Inserted after") { IsNew = true },
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanDelete()
|
||||
{
|
||||
PerformTest(
|
||||
before: new[]
|
||||
{
|
||||
new Node("orig1", "A"), // Will delete first
|
||||
new Node("orig2", "B"),
|
||||
new Node("orig3", "C"), // Will delete in middle
|
||||
new Node("orig4", "D"),
|
||||
new Node("orig5", "E"), // Will delete at end
|
||||
},
|
||||
after: new[]
|
||||
{
|
||||
new Node("orig2", "B"),
|
||||
new Node("orig4", "D edited"),
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanInsertUnkeyed()
|
||||
{
|
||||
PerformTest(
|
||||
before: new[]
|
||||
{
|
||||
new Node("orig1", "A"),
|
||||
new Node("orig2", "B"),
|
||||
},
|
||||
after: new[]
|
||||
{
|
||||
new Node(null, "Inserted before") { IsNew = true },
|
||||
new Node("orig1", "A edited"),
|
||||
new Node(null, "Inserted between") { IsNew = true },
|
||||
new Node("orig2", "B"),
|
||||
new Node(null, "Inserted after") { IsNew = true },
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanDeleteUnkeyed()
|
||||
{
|
||||
PerformTest(
|
||||
before: new[]
|
||||
{
|
||||
new Node(null, "A"), // Will delete first
|
||||
new Node("orig2", "B"),
|
||||
new Node(null, "C"), // Will delete in middle
|
||||
new Node("orig4", "D"),
|
||||
new Node(null, "E"), // Will delete at end
|
||||
},
|
||||
after: new[]
|
||||
{
|
||||
new Node("orig2", "B edited"),
|
||||
new Node("orig4", "D"),
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanReorder()
|
||||
{
|
||||
PerformTest(
|
||||
before: new[]
|
||||
{
|
||||
new Node("keyA", "A",
|
||||
new Node("keyA1", "A1"),
|
||||
new Node("keyA2", "A2"),
|
||||
new Node("keyA3", "A3")),
|
||||
new Node("keyB", "B",
|
||||
new Node("keyB1", "B1"),
|
||||
new Node("keyB2", "B2"),
|
||||
new Node("keyB3", "B3")),
|
||||
new Node("keyC", "C",
|
||||
new Node("keyC1", "C1"),
|
||||
new Node("keyC2", "C2"),
|
||||
new Node("keyC3", "C3")),
|
||||
},
|
||||
after: new[]
|
||||
{
|
||||
// We're implicitly verifying that all the component instances were preserved,
|
||||
// because we're not marking any with "IsNew = true"
|
||||
new Node("keyC", "C", // Rotate all three (ABC->CAB)
|
||||
// Swap first and last
|
||||
new Node("keyC3", "C3"),
|
||||
new Node("keyC2", "C2 edited"),
|
||||
new Node("keyC1", "C1")),
|
||||
new Node("keyA", "A",
|
||||
// Swap first two
|
||||
new Node("keyA2", "A2 edited"),
|
||||
new Node("keyA1", "A1"),
|
||||
new Node("keyA3", "A3")),
|
||||
new Node("keyB", "B edited",
|
||||
// Swap last two
|
||||
new Node("keyB1", "B1"),
|
||||
new Node("keyB3", "B3"),
|
||||
new Node("keyB2", "B2 edited")),
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanReorderInsertDeleteAndEdit_WithAndWithoutKeys()
|
||||
{
|
||||
// This test is a complex bundle of many types of changes happening simultaneously
|
||||
PerformTest(
|
||||
before: new[]
|
||||
{
|
||||
new Node("keyA", "A",
|
||||
new Node("keyA1", "A1"),
|
||||
new Node(null, "A2 unkeyed"),
|
||||
new Node("keyA3", "A3"),
|
||||
new Node("keyA4", "A4")),
|
||||
new Node("keyB", "B",
|
||||
new Node(null, "B1 unkeyed"),
|
||||
new Node("keyB2", "B2"),
|
||||
new Node("keyB3", "B3"),
|
||||
new Node("keyB4", "B4")),
|
||||
new Node("keyC", "C",
|
||||
new Node("keyC1", "C1"),
|
||||
new Node("keyC2", "C2"),
|
||||
new Node("keyC3", "C3"),
|
||||
new Node(null, "C4 unkeyed")),
|
||||
},
|
||||
after: new[]
|
||||
{
|
||||
// Swapped A and C
|
||||
new Node("keyC", "C",
|
||||
// C1-4 were reordered
|
||||
// C5 was inserted
|
||||
new Node("keyC5", "C5 inserted") { IsNew = true },
|
||||
new Node("keyC2", "C2"),
|
||||
// C6 was inserted with no key
|
||||
new Node(null, "C6 unkeyed inserted") { IsNew = true },
|
||||
// C1 was edited
|
||||
new Node("keyC1", "C1 edited"),
|
||||
new Node("keyC3", "C3")
|
||||
// C4 unkeyed was deleted
|
||||
),
|
||||
// B was deleted
|
||||
// D was inserted
|
||||
new Node("keyD", "D inserted",
|
||||
new Node("keyB1", "D1") { IsNew = true }, // Matches an old key, but treated as new because we don't move between parents
|
||||
new Node("keyD2", "D2") { IsNew = true },
|
||||
new Node(null, "D3 unkeyed") { IsNew = true })
|
||||
{ IsNew = true },
|
||||
new Node("keyA", "A",
|
||||
new Node("keyA1", "A1"),
|
||||
// A2 (unkeyed) was edited
|
||||
new Node(null, "A2 unkeyed edited"),
|
||||
new Node("keyA3", "A3"),
|
||||
// A4 was deleted
|
||||
// A5 was inserted
|
||||
new Node("keyA5", "A5 inserted") { IsNew = true }),
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanRetainFocusWhileMovingTextBox()
|
||||
{
|
||||
var appElem = MountTestComponent<ReorderingFocusComponent>();
|
||||
Func<IWebElement> textboxFinder = () => appElem.FindElement(By.CssSelector(".incomplete-items .item-1 input[type=text]"));
|
||||
var textToType = "Hello this is a long string that should be typed";
|
||||
var expectedTextTyped = "";
|
||||
|
||||
textboxFinder().Clear();
|
||||
|
||||
// On each keystroke, the boxes will be shuffled. The text will only
|
||||
// be inserted correctly if focus is retained.
|
||||
textboxFinder().Click();
|
||||
while (textToType.Length > 0)
|
||||
{
|
||||
var nextBlockLength = Math.Min(5, textToType.Length);
|
||||
var nextBlock = textToType.Substring(0, nextBlockLength);
|
||||
textToType = textToType.Substring(nextBlockLength);
|
||||
expectedTextTyped += nextBlock;
|
||||
|
||||
// Send keys to whatever has focus
|
||||
new Actions(Browser).SendKeys(nextBlock).Perform();
|
||||
Browser.Equal(expectedTextTyped, () => textboxFinder().GetAttribute("value"));
|
||||
|
||||
// We delay between typings to ensure the events aren't all collapsed into one.
|
||||
await Task.Delay(100);
|
||||
}
|
||||
|
||||
// Verify that after all this, we can still move the edited item
|
||||
// This was broken originally because of unexpected event-handling behavior
|
||||
// in Chrome (it raised events recursively)
|
||||
appElem.FindElement(
|
||||
By.CssSelector(".incomplete-items .item-1 input[type=checkbox]")).Click();
|
||||
Browser.Equal(expectedTextTyped, () => appElem
|
||||
.FindElement(By.CssSelector(".complete-items .item-1 input[type=text]"))
|
||||
.GetAttribute("value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanUpdateCheckboxStateWhileMovingIt()
|
||||
{
|
||||
var appElem = MountTestComponent<ReorderingFocusComponent>();
|
||||
Func<IWebElement> checkboxFinder = () => appElem.FindElement(By.CssSelector(".item-2 input[type=checkbox]"));
|
||||
Func<IEnumerable<bool>> incompleteItemStates = () => appElem
|
||||
.FindElements(By.CssSelector(".incomplete-items input[type=checkbox]"))
|
||||
.Select(elem => elem.Selected);
|
||||
Func<IEnumerable<bool>> completeItemStates = () => appElem
|
||||
.FindElements(By.CssSelector(".complete-items input[type=checkbox]"))
|
||||
.Select(elem => elem.Selected);
|
||||
|
||||
// Verify initial state
|
||||
Browser.Equal(new[] { false, false, false, false, false }, incompleteItemStates);
|
||||
Browser.Equal(Array.Empty<bool>(), completeItemStates);
|
||||
|
||||
// Check a box; see it moves and becomes the sole checked item
|
||||
checkboxFinder().Click();
|
||||
Browser.True(() => checkboxFinder().Selected);
|
||||
Browser.Equal(new[] { false, false, false, false }, incompleteItemStates);
|
||||
Browser.Equal(new[] { true }, completeItemStates);
|
||||
|
||||
// Also uncheck it; see it moves and becomes unchecked
|
||||
checkboxFinder().Click();
|
||||
Browser.False(() => checkboxFinder().Selected);
|
||||
Browser.Equal(new[] { false, false, false, false, false }, incompleteItemStates);
|
||||
Browser.Equal(Array.Empty<bool>(), completeItemStates);
|
||||
}
|
||||
|
||||
private void PerformTest(Node[] before, Node[] after)
|
||||
{
|
||||
var rootBefore = new Node(null, "root", before);
|
||||
var rootAfter = new Node(null, "root", after);
|
||||
var jsonBefore = Json.Serialize(rootBefore);
|
||||
var jsonAfter = Json.Serialize(rootAfter);
|
||||
|
||||
var appElem = MountTestComponent<KeyCasesComponent>();
|
||||
var textbox = appElem.FindElement(By.TagName("textarea"));
|
||||
var updateButton = appElem.FindElement(By.TagName("button"));
|
||||
|
||||
SetTextAreaValueFast(textbox, jsonBefore);
|
||||
updateButton.Click();
|
||||
ValidateRenderedOutput(appElem, rootBefore, validatePreservation: false);
|
||||
|
||||
SetTextAreaValueFast(textbox, jsonAfter);
|
||||
updateButton.Click();
|
||||
ValidateRenderedOutput(appElem, rootAfter, validatePreservation: true);
|
||||
}
|
||||
|
||||
private static void ValidateRenderedOutput(IWebElement appElem, Node expectedRootNode, bool validatePreservation)
|
||||
{
|
||||
var actualRootElem = appElem.FindElement(By.CssSelector(".render-output > .node"));
|
||||
var actualRootNode = ReadNodeFromDOM(actualRootElem);
|
||||
AssertNodesEqual(expectedRootNode, actualRootNode, validatePreservation);
|
||||
}
|
||||
|
||||
private static void AssertNodesEqual(Node expectedRootNode, Node actualRootNode, bool validatePreservation)
|
||||
{
|
||||
Assert.Equal(expectedRootNode.Label, actualRootNode.Label);
|
||||
|
||||
if (validatePreservation)
|
||||
{
|
||||
Assert.Equal(expectedRootNode.IsNew, actualRootNode.IsNew);
|
||||
}
|
||||
|
||||
Assert.Collection(
|
||||
actualRootNode.Children,
|
||||
expectedRootNode.Children.Select<Node, Action<Node>>(expectedChild =>
|
||||
(actualChild => AssertNodesEqual(expectedChild, actualChild, validatePreservation))).ToArray());
|
||||
}
|
||||
|
||||
private static Node ReadNodeFromDOM(IWebElement nodeElem)
|
||||
{
|
||||
var label = nodeElem.FindElement(By.ClassName("label")).Text;
|
||||
var childNodes = nodeElem
|
||||
.FindElements(By.XPath("*[@class='children']/*[@class='node']"));
|
||||
return new Node(key: null, label, childNodes.Select(ReadNodeFromDOM).ToArray())
|
||||
{
|
||||
IsNew = nodeElem.FindElement(By.ClassName("is-new")).Text == "true"
|
||||
};
|
||||
}
|
||||
|
||||
private void SetTextAreaValueFast(IWebElement textAreaElementWithId, string value)
|
||||
{
|
||||
var javascript = (IJavaScriptExecutor)Browser;
|
||||
javascript.ExecuteScript($"document.getElementById('{textAreaElementWithId.GetAttribute("id")}').value = {Json.Serialize(value)}");
|
||||
textAreaElementWithId.SendKeys(" "); // So it fires the change event
|
||||
}
|
||||
|
||||
class Node
|
||||
{
|
||||
public string Key { get; }
|
||||
public string Label { get; }
|
||||
public Node[] Children { get; }
|
||||
public bool IsNew { get; set; }
|
||||
|
||||
public Node(string key, string label, params Node[] children)
|
||||
{
|
||||
Key = key;
|
||||
Label = label;
|
||||
Children = children ?? Array.Empty<Node>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,8 @@
|
|||
<option value="BasicTestApp.FormsTest.TypicalValidationComponent">Typical validation</option>
|
||||
<option value="BasicTestApp.FormsTest.NotifyPropertyChangedValidationComponent">INotifyPropertyChanged validation</option>
|
||||
<option value="BasicTestApp.InteropOnInitializationComponent">Interop on initialization</option>
|
||||
<option value="BasicTestApp.KeyCasesComponent">Key cases</option>
|
||||
<option value="BasicTestApp.ReorderingFocusComponent">Reordering focus retention</option>
|
||||
</select>
|
||||
|
||||
@if (SelectedComponentType != null)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
@using Microsoft.JSInterop
|
||||
<div class="key-cases">
|
||||
<div class="model">
|
||||
<p>Model</p>
|
||||
<textarea bind="@modelJson" id="key-model"></textarea>
|
||||
<button onclick="@Update">Update</button>
|
||||
</div>
|
||||
<div class="render-output">
|
||||
<p>Output</p>
|
||||
<CascadingValue Value="@renderContext" IsFixed="true">
|
||||
<KeyCasesTreeNode Data="@parsedRootNode" />
|
||||
</CascadingValue>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style type="text/css">
|
||||
.key-cases { display: flex; }
|
||||
.key-cases > * { padding: 1rem; border: 1px solid silver; }
|
||||
.key-cases .model { width: 22rem; display: flex; flex-direction: column; }
|
||||
.key-cases .render-output { flex-grow: 1; }
|
||||
.key-cases textarea { height: 24rem; margin: 1rem 0; }
|
||||
.key-cases .children { margin-left: 0.35rem; border-left: 1px solid silver; margin-bottom: 0.3rem; }
|
||||
.key-cases p { margin-top: 0; }
|
||||
.key-cases .node .node::before { content: '–'; color: silver; float: left; margin-right: 0.3rem; }
|
||||
.key-cases .node .node .children { margin-left: 1.1rem; }
|
||||
</style>
|
||||
|
||||
@functions {
|
||||
string modelJson = @"{
|
||||
""label"": ""root"",
|
||||
""children"": [
|
||||
{
|
||||
""key"": ""a"",
|
||||
""label"": ""A"",
|
||||
""children"": [
|
||||
{ ""key"": ""a1"", ""label"": ""A1"" },
|
||||
{ ""key"": ""a2"", ""label"": ""A2"" },
|
||||
{ ""key"": ""a3"", ""label"": ""A3"" }
|
||||
]
|
||||
},
|
||||
{
|
||||
""key"": ""b"",
|
||||
""label"": ""B"",
|
||||
""children"": [
|
||||
{ ""key"": ""b1"", ""label"": ""B1"" },
|
||||
{ ""key"": ""b2"", ""label"": ""B2"" },
|
||||
{ ""key"": ""b3"", ""label"": ""B3"" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}";
|
||||
|
||||
KeyCasesTreeNode.Node parsedRootNode;
|
||||
RenderContext renderContext = new RenderContext();
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
Update();
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
renderContext.UpdateCount++;
|
||||
parsedRootNode = Json.Deserialize<KeyCasesTreeNode.Node>(modelJson);
|
||||
}
|
||||
|
||||
public class RenderContext
|
||||
{
|
||||
// This is so the descendants can detect and display whether they are
|
||||
// newly-instantiated on any given render
|
||||
public int UpdateCount { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<div class="node">
|
||||
<strong class="label">@Data.Label</strong>
|
||||
[
|
||||
Instance: @instanceId;
|
||||
|
||||
Is new:
|
||||
@if (firstCreatedOnUpdateCount == RenderContext.UpdateCount)
|
||||
{
|
||||
<strong class="is-new">true</strong>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="is-new">false</span>
|
||||
}
|
||||
]
|
||||
|
||||
@if (Data.Children?.Any() ?? false)
|
||||
{
|
||||
<div class="children">@{
|
||||
foreach (var child in Data.Children)
|
||||
{
|
||||
if (child.Key != null)
|
||||
{
|
||||
<KeyCasesTreeNode key="@child.Key" Data="@child" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<KeyCasesTreeNode Data="@child" />
|
||||
}
|
||||
}
|
||||
}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@functions {
|
||||
public class Node
|
||||
{
|
||||
public object Key { get; set; }
|
||||
public string Label { get; set; }
|
||||
public List<Node> Children { get; set; }
|
||||
}
|
||||
|
||||
string instanceId = Guid.NewGuid().ToString("D").Substring(0, 6);
|
||||
int firstCreatedOnUpdateCount;
|
||||
|
||||
[Parameter] Node Data { get; set; }
|
||||
[CascadingParameter] KeyCasesComponent.RenderContext RenderContext { get; set; }
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
firstCreatedOnUpdateCount = RenderContext.UpdateCount;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<h3>To do</h3>
|
||||
|
||||
<p>
|
||||
This component will randomly reorder the todo items on each keystroke.
|
||||
The point of this is to show that focus is correctly preserved even
|
||||
when items are moved around. Also, by checking the boxes to move items
|
||||
between the two lists, we show that use of <code>key</code> causes the
|
||||
form element state to behave as expected.
|
||||
</p>
|
||||
|
||||
<ul class="incomplete-items">
|
||||
@foreach (var item in todoItems.Where(item => !item.IsDone))
|
||||
{
|
||||
<li key="@item.Id" class="@($"item-{item.Id}")">
|
||||
<input type="checkbox" bind="@item.IsDone"/>
|
||||
<input type="text" bind="@item.Text" oninput="@Shuffle" />
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<h3>Done</h3>
|
||||
|
||||
<ul class="complete-items">
|
||||
@foreach (var item in todoItems.Where(item => item.IsDone))
|
||||
{
|
||||
<li key="@item.Id" class="@($"item-{item.Id}")">
|
||||
<input type="checkbox" bind="@item.IsDone" />
|
||||
<input type="text" bind="@item.Text" oninput="@Shuffle" />
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@functions {
|
||||
Random rng = new Random();
|
||||
TodoItem[] todoItems = new[]
|
||||
{
|
||||
new TodoItem { Id = 1, Text = "First" },
|
||||
new TodoItem { Id = 2, Text = "Second" },
|
||||
new TodoItem { Id = 3, Text = "Third" },
|
||||
new TodoItem { Id = 4, Text = "Fourth" },
|
||||
new TodoItem { Id = 5, Text = "Fifth" },
|
||||
};
|
||||
|
||||
void Shuffle()
|
||||
{
|
||||
todoItems = todoItems.OrderBy(x => rng.Next()).ToArray();
|
||||
}
|
||||
|
||||
class TodoItem
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Text { get; set; }
|
||||
public bool IsDone { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
|
|
@ -11,4 +11,3 @@
|
|||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue