diff --git a/change/@microsoft-fast-element-65a1eb8c-3989-49a5-811f-fcc4274e53d6.json b/change/@microsoft-fast-element-65a1eb8c-3989-49a5-811f-fcc4274e53d6.json new file mode 100644 index 00000000000..9b0068071af --- /dev/null +++ b/change/@microsoft-fast-element-65a1eb8c-3989-49a5-811f-fcc4274e53d6.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Update the ArrayObserver to better account for sort and reverse", + "packageName": "@microsoft/fast-element", + "email": "7559015+janechu@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/packages/web-components/fast-element/docs/api-report.api.md b/packages/web-components/fast-element/docs/api-report.api.md index cbf3f2e48c8..153459d8c60 100644 --- a/packages/web-components/fast-element/docs/api-report.api.md +++ b/packages/web-components/fast-element/docs/api-report.api.md @@ -19,15 +19,18 @@ export type AddViewBehaviorFactory = (factory: ViewBehaviorFactory) => string; // @public export interface ArrayObserver extends SubscriberSet { + addSort(sort: Sort): void; addSplice(splice: Splice): void; flush(): void; readonly lengthObserver: LengthObserver; reset(oldCollection: any[] | undefined): void; + readonly sortObserver: SortObserver; strategy: SpliceStrategy | null; } // @public export const ArrayObserver: Readonly<{ + readonly sorted: 0; readonly enable: () => void; }>; @@ -774,7 +777,7 @@ export function repeat = Readon export class RepeatBehavior implements ViewBehavior, Subscriber { constructor(directive: RepeatDirective); bind(controller: ViewController): void; - handleChange(source: any, args: Splice[] | ExpressionObserver): void; + handleChange(source: any, args: Splice[] | Sort[] | ExpressionObserver): void; unbind(): void; // @internal (undocumented) views: SyntheticView[]; @@ -822,6 +825,21 @@ export class SlottedDirective extends NodeObservationDirective extends NodeBehaviorOptions, AssignedNodesOptions { } +// @public +export class Sort { + constructor(sorted?: number[] | undefined); + // (undocumented) + sorted?: number[] | undefined; +} + +// @public +export function sortedCount(array: readonly T[]): number; + +// @public +export interface SortObserver extends Subscriber { + sorted: number; +} + // @public export const SourceLifetime: Readonly<{ readonly unknown: undefined; diff --git a/packages/web-components/fast-element/src/debug.ts b/packages/web-components/fast-element/src/debug.ts index 96a50b5b376..68a8c767fd3 100644 --- a/packages/web-components/fast-element/src/debug.ts +++ b/packages/web-components/fast-element/src/debug.ts @@ -13,7 +13,7 @@ const FAST: FASTGlobal = globalThis.FAST; const debugMessages = { [1101 /* needsArrayObservation */]: - "Must call enableArrayObservation before observing arrays.", + "Must call ArrayObserver.enable() before observing arrays.", [1201 /* onlySetDOMPolicyOnce */]: "The DOM Policy can only be set once.", [1202 /* bindingInnerHTMLRequiresTrustedTypes */]: "To bind innerHTML, you must use a TrustedTypesPolicy.", diff --git a/packages/web-components/fast-element/src/index.ts b/packages/web-components/fast-element/src/index.ts index 4001cce2b23..f03e1d24144 100644 --- a/packages/web-components/fast-element/src/index.ts +++ b/packages/web-components/fast-element/src/index.ts @@ -39,6 +39,9 @@ export { Splice, SpliceStrategy, SpliceStrategySupport, + Sort, + SortObserver, + sortedCount, } from "./observation/arrays.js"; export { UpdateQueue, Updates } from "./observation/update-queue.js"; diff --git a/packages/web-components/fast-element/src/observation/arrays.spec.ts b/packages/web-components/fast-element/src/observation/arrays.spec.ts index 27da19775f0..33e13503567 100644 --- a/packages/web-components/fast-element/src/observation/arrays.spec.ts +++ b/packages/web-components/fast-element/src/observation/arrays.spec.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; import { Observable } from "./observable.js"; -import { ArrayObserver, lengthOf, Splice } from "./arrays.js"; +import { ArrayObserver, lengthOf, Splice, Sort } from "./arrays.js"; import { SubscriberSet } from "./notifier.js"; import { Updates } from "./update-queue.js"; @@ -140,13 +140,13 @@ describe("The ArrayObserver", () => { const array = [1, 2, 3, 4]; array.reverse(); - expect(array).members([4, 3, 2, 1]); + expect(array).ordered.members([4, 3, 2, 1]); Array.prototype.reverse.call(array); - expect(array).members([1, 2, 3, 4]); + expect(array).ordered.members([1, 2, 3, 4]); const observer = Observable.getNotifier(array); - let changeArgs: Splice[] | null = null; + let changeArgs: Sort[] | null = null; observer.subscribe({ handleChange(array, args) { @@ -155,23 +155,34 @@ describe("The ArrayObserver", () => { }); array.reverse(); - expect(array).members([4, 3, 2, 1]); + expect(array).ordered.members([4, 3, 2, 1]); await Updates.next(); expect(changeArgs).length(1); - expect(changeArgs![0].addedCount).equal(0); - expect(changeArgs![0].removed).members([]); - expect(changeArgs![0].index).equal(0); - expect(changeArgs![0].reset).equal(true); + expect(changeArgs![0].sorted).to.have.ordered.members( + [ + 3, + 2, + 1, + 0 + ] + ); + changeArgs = null; + array.reverse(); + expect(array).ordered.members([1, 2, 3, 4]); + + await Updates.next(); - Array.prototype.reverse.call(array); - expect(array).members([1, 2, 3, 4]); expect(changeArgs).length(1); - expect(changeArgs![0].addedCount).equal(0); - expect(changeArgs![0].removed).members([]); - expect(changeArgs![0].index).equal(0); - expect(changeArgs![0].reset).equal(true); + expect(changeArgs![0].sorted).to.have.ordered.members( + [ + 3, + 2, + 1, + 0 + ] + ); }); it("observes shifts", async () => { @@ -216,16 +227,17 @@ describe("The ArrayObserver", () => { it("observes sorts", async () => { ArrayObserver.enable(); - let array = [1, 2, 3, 4]; + let array = [1, 3, 2, 4, 3]; array.sort((a, b) => b - a); - expect(array).members([4, 3, 2, 1]); + expect(array).ordered.members([4, 3, 3, 2, 1]); Array.prototype.sort.call(array, (a, b) => a - b); - expect(array).members([1, 2, 3, 4]); + expect(array).ordered.members([1, 2, 3, 3, 4]); + array = [1, 3, 2, 4, 3]; const observer = Observable.getNotifier(array); - let changeArgs: Splice[] | null = null; + let changeArgs: Sort[] | null = null; observer.subscribe({ handleChange(array, args) { @@ -234,26 +246,20 @@ describe("The ArrayObserver", () => { }); array.sort((a, b) => b - a); - expect(array).members([4, 3, 2, 1]); + expect(array).ordered.members([4, 3, 3, 2, 1]); await Updates.next(); expect(changeArgs).length(1); - expect(changeArgs![0].addedCount).equal(0); - expect(changeArgs![0].removed).members([]); - expect(changeArgs![0].index).equal(0); - expect(changeArgs![0].reset).equal(true); - - Array.prototype.sort.call(array, (a, b) => a - b); - expect(array).members([1, 2, 3, 4]); - - await Updates.next(); - - expect(changeArgs).length(1); - expect(changeArgs![0].addedCount).equal(0); - expect(changeArgs![0].removed).members([]); - expect(changeArgs![0].index).equal(0); - expect(changeArgs![0].reset).equal(true); + expect(changeArgs![0].sorted).to.have.ordered.members( + [ + 3, + 1, + 4, + 2, + 0 + ] + ); }); it("observes splices", async () => { diff --git a/packages/web-components/fast-element/src/observation/arrays.ts b/packages/web-components/fast-element/src/observation/arrays.ts index f6c2226f948..82e090a7a2e 100644 --- a/packages/web-components/fast-element/src/observation/arrays.ts +++ b/packages/web-components/fast-element/src/observation/arrays.ts @@ -53,6 +53,18 @@ export class Splice { } } +/** + * A sort array indicates new index positions of array items. + * @public + */ +export class Sort { + /** + * Creates a sort update. + * @param sorted - The updated index of sorted items. + */ + public constructor(public sorted?: number[]) {} +} + /** * Indicates what level of feature support the splice * strategy provides. @@ -598,8 +610,7 @@ function project(array: unknown[], changes: Splice[]): Splice[] { * splices needed to represent the change from the old array to the new array. * @public */ - -let defaultSpliceStrategy: SpliceStrategy = Object.freeze({ +let defaultMutationStrategy: SpliceStrategy = Object.freeze({ support: SpliceStrategySupport.optimized, normalize( @@ -653,7 +664,15 @@ let defaultSpliceStrategy: SpliceStrategy = Object.freeze({ args: any[] ): any { const result = reverse.apply(array, args); - observer.reset(array); + (array as any).sorted++; + + const sortedItems: number[] = []; + for (let i = array.length - 1; i >= 0; i--) { + sortedItems.push(i); + } + + observer.addSort(new Sort(sortedItems)); + return result; }, @@ -679,8 +698,29 @@ let defaultSpliceStrategy: SpliceStrategy = Object.freeze({ sort: typeof Array.prototype.sort, args: any[] ): any[] { + const map = new Map(); + + for (let i = 0, ii = array.length; i < ii; ++i) { + const mapValue = map.get(array[i]) || []; + + map.set(array[i], [...mapValue, i]); + } + const result = sort.apply(array, args); - observer.reset(array); + + (array as any).sorted++; + + const sortedItems: number[] = []; + + for (let i = 0, ii = array.length; i < ii; ++i) { + const indexs = map.get(array[i]); + sortedItems.push(indexs[0]); + + map.set(array[i], indexs.splice(1)); + } + + observer.addSort(new Sort(sortedItems)); + return result; }, @@ -726,14 +766,20 @@ export const SpliceStrategy = Object.freeze({ * @param strategy - The splice strategy to use. */ setDefaultStrategy(strategy: SpliceStrategy) { - defaultSpliceStrategy = strategy; + defaultMutationStrategy = strategy; }, } as const); -function setNonEnumerable(target: any, property: string, value: any): void { +function setNonEnumerable( + target: any, + property: string, + value: any, + writable: boolean = true +): void { Reflect.defineProperty(target, property, { value, enumerable: false, + writable, }); } @@ -748,6 +794,18 @@ export interface LengthObserver extends Subscriber { length: number; } +/** + * Observes array sort. + * @public + */ +export interface SortObserver extends Subscriber { + /** + * The sorted times on the observed array, this should be incremented every time + * an item in the array changes location. + */ + sorted: number; +} + /** * An observer for arrays. * @public @@ -763,12 +821,23 @@ export interface ArrayObserver extends SubscriberSet { */ readonly lengthObserver: LengthObserver; + /** + * The sort observer for the array. + */ + readonly sortObserver: SortObserver; + /** * Adds a splice to the list of changes. * @param splice - The splice to add. */ addSplice(splice: Splice): void; + /** + * Adds a sort to the list of changes. + * @param sort - The sort to add. + */ + addSort(sort: Sort): void; + /** * Indicates that a reset change has occurred. * @param oldCollection - The collection as it was before the reset. @@ -784,9 +853,11 @@ export interface ArrayObserver extends SubscriberSet { class DefaultArrayObserver extends SubscriberSet implements ArrayObserver { private oldCollection: any[] | undefined = void 0; private splices: Splice[] | undefined = void 0; + private sorts: Sort[] | undefined = void 0; private needsQueue: boolean = true; private _strategy: SpliceStrategy | null = null; private _lengthObserver: LengthObserver | undefined = void 0; + private _sortObserver: SortObserver | undefined = void 0; public get strategy(): SpliceStrategy | null { return this._strategy; @@ -817,6 +888,27 @@ class DefaultArrayObserver extends SubscriberSet implements ArrayObserver { return observer; } + public get sortObserver(): SortObserver { + let observer = this._sortObserver; + + if (observer === void 0) { + const array = this.subject; + this._sortObserver = observer = { + sorted: array.sorted, + handleChange() { + if (this.sorted !== array.sorted) { + this.sorted = array.sorted; + Observable.notify(observer, "sorted"); + } + }, + }; + + this.subscribe(observer); + } + + return observer; + } + call: () => void = this.flush; constructor(subject: any[]) { @@ -839,6 +931,16 @@ class DefaultArrayObserver extends SubscriberSet implements ArrayObserver { this.enqueue(); } + public addSort(sort: Sort) { + if (this.sorts === void 0) { + this.sorts = [sort]; + } else { + this.sorts.push(sort); + } + + this.enqueue(); + } + public reset(oldCollection: any[] | undefined): void { this.oldCollection = oldCollection; this.enqueue(); @@ -846,23 +948,27 @@ class DefaultArrayObserver extends SubscriberSet implements ArrayObserver { public flush(): void { const splices = this.splices; + const sorts = this.sorts; const oldCollection = this.oldCollection; - if (splices === void 0 && oldCollection === void 0) { + if (splices === void 0 && oldCollection === void 0 && sorts === void 0) { return; } this.needsQueue = true; this.splices = void 0; + this.sorts = void 0; this.oldCollection = void 0; - this.notify( - (this._strategy ?? defaultSpliceStrategy).normalize( - oldCollection, - this.subject, - splices - ) - ); + sorts !== void 0 + ? this.notify(sorts) + : this.notify( + (this._strategy ?? defaultMutationStrategy).normalize( + oldCollection, + this.subject, + splices + ) + ); } private enqueue(): void { @@ -880,6 +986,7 @@ let enabled = false; * @public */ export const ArrayObserver = Object.freeze({ + sorted: 0, /** * Enables the array observation mechanism. * @remarks @@ -902,6 +1009,7 @@ export const ArrayObserver = Object.freeze({ if (!(proto as any).$fastPatch) { setNonEnumerable(proto, "$fastPatch", 1); + setNonEnumerable(proto, "sorted", 0); [ proto.pop, @@ -916,7 +1024,7 @@ export const ArrayObserver = Object.freeze({ const o = this.$fastController as ArrayObserver; return o === void 0 ? method.apply(this, args) - : (o.strategy ?? defaultSpliceStrategy)[method.name]( + : (o.strategy ?? defaultMutationStrategy)[method.name]( this, o, method, @@ -948,3 +1056,24 @@ export function lengthOf(array: readonly T[]): number { Observable.track(arrayObserver.lengthObserver, "length"); return array.length; } + +/** + * Enables observing the sorted property of an array. + * @param array - The array to observe the sorted property of. + * @returns The sorted property. + * @public + */ +export function sortedCount(array: readonly T[]): number { + if (!array) { + return 0; + } + + let arrayObserver = (array as any).$fastController as ArrayObserver; + if (arrayObserver === void 0) { + ArrayObserver.enable(); + arrayObserver = Observable.getNotifier(array); + } + + Observable.track(arrayObserver.sortObserver, "sorted"); + return (array as any).sorted; +} diff --git a/packages/web-components/fast-element/src/templating/repeat.spec.ts b/packages/web-components/fast-element/src/templating/repeat.spec.ts index f0fb47cad3e..a9694d74ae4 100644 --- a/packages/web-components/fast-element/src/templating/repeat.spec.ts +++ b/packages/web-components/fast-element/src/templating/repeat.spec.ts @@ -134,6 +134,7 @@ describe("The repeat", () => { const itemTemplate = html`${x => x.name}`; const altItemTemplate = html`*${x => x.name}`; const oneThroughTen = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const randomizedOneThroughTen = [5, 4, 6, 1, 7, 3, 2, 10, 9, 8]; const zeroThroughTen = [0].concat(oneThroughTen); const wrappedItemTemplate = html`
${x => x.name}
`; @@ -152,6 +153,16 @@ describe("The repeat", () => { return items; } + function createRandomizedArray(size: number, randomizedOneThroughTen: number[]) { + const items: { name: string, index: number }[] = []; + + for (let i = 0; i < size; ++i) { + items.push({ name: `item${randomizedOneThroughTen[i]}`, index: randomizedOneThroughTen[i] }); + } + + return items; + } + class ViewModel { name = "root"; @observable items: Item[]; @@ -166,6 +177,16 @@ describe("The repeat", () => { } } + class RandomizedViewModel { + name = "root"; + @observable items: Item[]; + @observable template = itemTemplate; + + constructor(size: number) { + this.items = createRandomizedArray(size, randomizedOneThroughTen); + } + } + function createOutput( size: number, filter: (index: number) => boolean = () => true, @@ -294,6 +315,62 @@ describe("The repeat", () => { }); }); + oneThroughTen.forEach(size => { + it(`updates rendered HTML when items are reversed in an array of size ${size}`, async () => { + const { parent, targets, nodeId } = createLocation(); + const directive = repeat( + x => x.items, + itemTemplate + ) as RepeatDirective; + directive.targetNodeId = nodeId; + const behavior = directive.createBehavior(); + const vm = new ViewModel(size); + const controller = createController(vm, targets); + + behavior.bind(controller); + vm.items.reverse(); + + await Updates.next(); + + const htmlString: string = new Array(size).fill(undefined).map((item, index) => { + return `item${index + 1}`; + }).reverse().join(""); + + expect(toHTML(parent)).to.equal(htmlString); + }); + }); + + randomizedOneThroughTen.forEach(size => { + it(`updates rendered HTML when items are sorted in an array of size ${size}`, async () => { + const { parent, targets, nodeId } = createLocation(); + const directive = repeat( + x => x.items, + itemTemplate + ) as RepeatDirective; + directive.targetNodeId = nodeId; + const behavior = directive.createBehavior(); + const vm = new RandomizedViewModel(size); + const controller = createController(vm, targets); + + behavior.bind(controller); + const sortAlgo = (a, b) => b.index - a.index; + vm.items.sort(sortAlgo); + + await Updates.next(); + + const htmlString: string = new Array(size).fill(undefined).map((item, index) => { + return { + name: `item${randomizedOneThroughTen[index]}`, + index: randomizedOneThroughTen[index] + }; + }).sort(sortAlgo).map((item) => { + return item.name; + }).join(""); + + expect(toHTML(parent)).to.equal(htmlString); + }); + }); + oneThroughTen.forEach(size => { it(`updates rendered HTML when a single item is spliced from the end of an array of size ${size}`, async () => { const { parent, targets, nodeId } = createLocation(); diff --git a/packages/web-components/fast-element/src/templating/repeat.ts b/packages/web-components/fast-element/src/templating/repeat.ts index b840820811f..77e23e499fd 100644 --- a/packages/web-components/fast-element/src/templating/repeat.ts +++ b/packages/web-components/fast-element/src/templating/repeat.ts @@ -1,5 +1,5 @@ import { HydrationMarkup, isHydratable } from "../components/hydration.js"; -import { ArrayObserver, Splice } from "../observation/arrays.js"; +import { ArrayObserver, Sort, Splice } from "../observation/arrays.js"; import type { Notifier, Subscriber } from "../observation/notifier.js"; import { Expression, ExpressionObserver, Observable } from "../observation/observable.js"; import { emptyArray } from "../platform.js"; @@ -167,7 +167,7 @@ export class RepeatBehavior implements ViewBehavior, Subscriber { * @param source - The source of the change. * @param args - The details about what was changed. */ - public handleChange(source: any, args: Splice[] | ExpressionObserver): void { + public handleChange(source: any, args: Splice[] | Sort[] | ExpressionObserver): void { if (args === this.itemsBindingObserver) { this.items = this.itemsBindingObserver.bind(this.controller); this.observeItems(); @@ -180,8 +180,10 @@ export class RepeatBehavior implements ViewBehavior, Subscriber { return; } else if (args[0].reset) { this.refreshAllViews(); + } else if (args[0].sorted) { + this.updateSortedViews(args as Sort[]); } else { - this.updateViews(args as Splice[]); + this.updateSplicedViews(args as Splice[]); } } @@ -204,7 +206,34 @@ export class RepeatBehavior implements ViewBehavior, Subscriber { } } - private updateViews(splices: Splice[]): void { + private updateSortedViews(sorts: Sort[]): void { + const views = this.views; + + for (let i = 0, ii = sorts.length; i < ii; ++i) { + const sortedItems = sorts[i].sorted!.slice(); + const unsortedItems = sortedItems.slice().sort(); + + for (let j = 0, jj = sortedItems.length; j < jj; ++j) { + const sortedIndex: number = sortedItems.find( + value => sortedItems[j] === unsortedItems[value] + ) as number; + + if (sortedIndex !== j) { + const removedItems = unsortedItems.splice(sortedIndex, 1); + unsortedItems.splice(j, 0, ...removedItems); + const neighbor = views[j]; + const location = neighbor ? neighbor.firstChild : this.location; + + views[sortedIndex].remove(); + views[sortedIndex].insertBefore(location); + const removedViews = views.splice(sortedIndex, 1); + views.splice(j, 0, ...removedViews); + } + } + } + } + + private updateSplicedViews(splices: Splice[]): void { const views = this.views; const bindView = this.bindView; const items = this.items!;