• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/*
2 * Copyright (c) 2025 Huawei Device Co., Ltd.
3 * Licensed under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License.
5 * You may obtain a copy of the License at
6 *
7 *     http://www.apache.org/licenses/LICENSE-2.0
8 *
9 * Unless required by applicable law or agreed to in writing, software
10 * distributed under the License is distributed on an "AS IS" BASIS,
11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 * See the License for the specific language governing permissions and
13 * limitations under the License.
14 *
15 * all definitions in this file are framework internal
16*/
17
18/**
19 * Repeat with .virtualScroll TS side implementation
20 * for C++ side part of the implementation read the comments at top of file
21 * core/components_ng/syntax/repeat_virtual_scroll_node.h
22 *
23 * See the factory solutions in pu_repeat.ts
24 * __RepeatVirtualScroll2Impl gets created before first render
25 *
26 * onGetRid4Index(index) called upon layout calling C++ GetFrameChildByIndex
27 * (whenever C++ does not find UINode sub-tree for index in L1)
28 * 1- gets the data item - item might be lazy loaded (documented below)
29 * 2- calculates the templateId (called ttype internally) for this index, then
30 * 3- canUpdateTryMatch(index, ttype) tries to find spare UINode tree (identified by its RID)
31 * 4- if found calls updateChild(rid, item, index, ....)
32 *     updateChild simply updates repeatItem.item, .index, and requests UI updates synchronously (updateDirty2(true)
33 *     TS informs to C++ the RID , and that existing UINode sub-tree has been updated
34 * 5- if not fond calls createNewChild(index, ttype, ...)
35 *    creates a new RepeatItem instance with a new RID
36 *    calls the .each or .template item builder function to perform initial render
37 *    TS informs C++ about the RID and that new UINode sub-tree root node should be taken from ViewStackProcessor
38 *    added to C++ side RID -> UINode map / general cache
39 *
40 * onRecycleItems(fromIndex, upToBeforeIndex) - called from C++ RecycleItems
41 * - move L1 items with fromIndex <= index < upToBeforeIndex to L2.
42 *   calls C++ RepeatVirtualScroll2Native.setInvalid so also C++ side moves the RID from L1 to L2
43 *   C++ does NOT remove the item from active tree, or change its Active status
44 *   if so needed at the end of one processing cycle onActiveRange will do
45 *
46 * onActiveRange(....) - called from C++
47 * - TS iterates over L1 items to check which which one is still in informed range, move to L2
48 *   does NOT call RepeatVirtualScroll2Caches::SetInvalid
49 * - C++ does the same
50 *   a) iterate over L1 to remove items no longer in active range, move to L2
51 *   b) iterate over L2, remove from render tree and set Active status
52 *   c) order purge idle task
53 *   d) calculate dynamicCachedCount for .each and for templateIds that do not have specified cachedCount option
54 *
55 * - Note: the rather convolute algorithm that uses parameters to decide if item is in active range or not
56 *         needs to be exactly same in this function on TS side and on C++ side to ensure L1 info in TS and in C++ side
57 *         reman in sync.
58 *
59 * onMoveFromTo(moveFrom, moveTo) - called from C++, used for drag drop sorting
60 * - TS received moveFromTo from C++ and updated its own moveFromTo. Mainly used to do the following two things:
61 *   a) update its own moveFromTo.
62 *   b) reset its own moveFromTo.
63 *
64 * How does the drag-drop-sorting work?
65 *    1)users long press the ListItem and drag starts
66 *    2)whenever the index of the dragged item changes, the translation table(this.moveFromTo_) takes care of fixing
67 *      the order. While drag is on-going we do not expect the app to make any changes to the array. If it does,
68 *      the visual result might not be what is expected
69 *    3)user drops item
70 *    4)FireOnMove resets the translation table
71 *    5)onMove callback to the application, application changes the array
72 *    6)frameWork observes array change, triggers rerender
73 *    7)Repeat rerender
74 *
75 * why TS needs maintain moveFromTo_?
76 * - Mainly used to serve step_2 above
77 * - For a more specific description, please refer to the description above function "convertFromToIndex"
78 *
79 * when onMoveFromTo will be called from C++?
80 * - When users drag item and move item without dropping, each order change will trigger MoveData(function in C++).
81 *  For example, [a, b, c, d] shows in List, user drags item 'a' and move it after to 'c' without dropping.
82 *  During this period, MoveData(0, 1) and MoveData(1, 2) will be called separately, and this.onMoveFromTo_ will
83 *  be updated to [0, 1] and [0, 2] in sequence
84 *
85 *
86 * onPurge called from C++  RepeatVirtualScrollNode::Purge in idle task
87 *  ensure L1 side is within limits (cachedCount), deletes UINode subtrees that do not fit the permissible side
88 *  cachedCount is defined for each templateId / ttype for for .each separately
89 *
90 *
91 * rerender called from ViewV2 UpdateElement(repeatElmtId)
92 *
93 * what triggers Repeat rerender?
94 * - new source array assignment; array add, delete, move item
95 * - input to templateId function changed
96 * - input to key function changed (if key used)
97 * - totalCount changed
98 *
99 * cached array item at index and new array item at index2 are considered the same if
100 * - the keys computed from them are the same (if key used)
101 * - if items compare equal with === operator
102 * the algorithm that compares items can handle duplicate array items.
103 * the algorithm will fail unnoticed if cached array item and new array item are different but their key is the same
104 *   (this violates the requirement of stable keys)
105 *
106 * read the source code of rerender, each step has documentation
107 *
108 * the outcome of rerender is
109 * 1- array items in cached and new array (active range), aka retained array items, repeatItem.index updated if changed
110 * 2- delete array items -> RID / UINode sub-tree moved to L2
111 * 3- added array items - try to find fitting RID / UINode subtree (same templateId / ttype) and
112 *    update by updating repeatItem.item and .index newly added array items that can not be rendered
113 *    by update are NOT rendered, will deal with new renders when GetFrameChildByIndex requests
114 * 4- synchronous partial update to update all bindings that need update from step 1 and 3.
115 * 5- inform new L1 (and thereby also L2) to C++ by calling updateL1Rid4Index(list of rid), same call also
116 *    invalidates container layout starting from first changes item index, updates also totalCount on C++ side
117 *
118 *
119 * Lazy loading feature, available for API 18 ->
120 *
121 * 1- getItemUnmonitored() executes onLazyLoadingFunc_ function if the function is defined and requested item is not in
122 *    the source array. Member variable lazyLoadingIndex_ is used to store the requested index.
123 * 2- ArrayProxyHandler catches the operation (this.arr_[index] = newItem) and calls tryFastRelayout.
124 * 3- tryFastRelayout calls updateTotalCount(), enqueues a requestContainerReLayout call to the container (that lets the
125 *    container know there is a new item and the new total count) and returns true, meaning Repeat should be excluded
126 *    from following fireChange.
127 * 4- onLazyLoadingFunc_ function returns, lazyLoadingIndex_ is set to -1
128 * 5- getItemUnmonitored() returns the lazy loaded item
129 *
130 * Lazy loading can happen in these situations:
131 * 1- onGetRid4Index(index) is called the first time for this index
132 * 2- rerender step 1 checks if item in this.arr_[index] (from the old active range) has changed,
133 *    but there is no item at this.arr_[index]
134 *    (this can happen e.g. when the application calls this.arr_ = [])
135 *
136 * Why lazy loading does not need rerender?
137 *   In case 2 abowe Repeat is already rerendering, and onGetRid4Index executes always either createNewChild or
138 *   updateChild, so we don't need a rerender after a lazy loading. Rerender CAN still happen if the totalCount
139 *   was defined with a variable that updates its value after lazy loading has added an item (changed the array length)!
140 *   Therefore using onTotalCount function is recommended with onLazyLoading.
141 *
142 * Situations when an Error is thrown:
143 * 1- if during lazy loading the ArrayProxyHandler reports some other operation than 'set' (arr[index] = ...)
144 * 2- if during lazy loading the ArrayProxyHandler reports 'set' to some other index than lazyLoadingIndex_
145 *
146 * Also, it is considered an error situation if there still is no item in this.arr_[index] after onLazyLoadingFunc_(index)
147 * returns, but we will not throw Error. Note that the container stops renderding when this happens.
148 *
149 *
150 * The most important data structures
151 *
152 * RID - Repeat Item ID - a unique number, uniquely identifies a RepeatItem - templateId - UINode triple
153 *    these found data items remain together until deleted from the system
154 *    (ie.. until purge or unload deletes the UINode subtree)
155 *
156 * meta4Rid_: Map<number, RIDMeta<T>>
157 * - for each RID:
158 *    - constant: RepeatItem
159 *    - constant: ttype
160 *    - mutable: key
161 * - counterpart on C++ side RepeatVirtualScroll2Caches.cacheItem4Rid_ maps RID -> UINode
162 *
163 * activeDataItems -  Array<ActiveDataItem<T | void>>
164 *   This is the central data structure for rerender, as allows to compare previous item value / keys
165 *   Sparse array, only includes items for active range
166 *   - array item value at last render/update, rid, ttype, key (if used), some state info
167 *
168 * spareRid_ : set<RID>  RID currently not in active range, "L2"
169 *
170 * ttypeGenFunc_: templateId function
171 * itemGenFuncs_: map of item builder functions  per templateId / ttype and .each
172 */
173
174class ActiveDataItem<T> {
175    public item: T;
176    public rid?: number;
177    public ttype?: string;
178    public key?: string;
179    public state: number;
180
181    public static readonly UINodeExists = 1;
182    public static readonly UINodeRenderFailed = 2;
183    public static readonly UINodeToBeRendered = 3;
184    public static readonly NoValue = 4;
185
186    protected constructor(state: number, itemValue?: T, rid?: number, ttype?: string, key?: string) {
187        this.state = state;
188        this.item = itemValue;
189        this.rid = rid;
190        this.ttype = ttype;
191        this.key = key;
192    }
193
194    // factory functions for permissible ActiveDataItems
195    public static createWithUINode<T>(itemValue: T, rid: number, ttype: string, key?: string): ActiveDataItem<T> {
196        return new ActiveDataItem(ActiveDataItem.UINodeExists, itemValue, rid, ttype, key);
197    }
198
199    public static createFailedToCreateUINodeDataItem<T>(itemValue: T): ActiveDataItem<T> {
200        return new ActiveDataItem(ActiveDataItem.UINodeRenderFailed, itemValue);
201    }
202
203    public static createMissingDataItem(): ActiveDataItem<void> {
204        return new ActiveDataItem<void>(ActiveDataItem.NoValue);
205    }
206
207    public static createToBeRenderedDataItem<T>(itemValue: T, ttype: string, key?: string): ActiveDataItem<T> {
208        return new ActiveDataItem(ActiveDataItem.UINodeToBeRendered, itemValue, undefined, ttype, key);
209    }
210
211    public toString(): string {
212        return this.state === ActiveDataItem.UINodeExists
213            ? `[rid: ${this.rid}, ttype: ${this.ttype}${this.key ? ', key: ' + this.key : ''}]`
214            : `[no item]`;
215    }
216
217    public dump(): string {
218        const state = this.state === ActiveDataItem.UINodeExists
219            ? 'UINode exists'
220            : this.state === ActiveDataItem.UINodeRenderFailed
221                ? 'UINode failed to render'
222                : this.state === ActiveDataItem.UINodeToBeRendered
223                    ? 'UINode to be rendered'
224                    : this.state === ActiveDataItem.NoValue
225                        ? 'No data value'
226                        : 'unknown state (error)';
227        const rid = this.rid ?? 'no RID/not rendered';
228        const ttype = this.ttype ?? 'ttype N/A';
229        return (this.state === ActiveDataItem.UINodeExists)
230            ? `state: '${state}', RID: ${rid}, ttype: ${ttype}, key: ${this.key}`
231            : `state: '${state}'`;
232    }
233
234    public shortDump() : string {
235        const state = this.state === ActiveDataItem.UINodeExists
236            ? 'UINode exists'
237            : this.state === ActiveDataItem.UINodeRenderFailed
238                ? 'UINode failed to render'
239                : this.state === ActiveDataItem.NoValue
240                    ? 'No data value'
241                    : 'unknown state (error)';
242        const rid = this.rid ?? 'no RID/not rendered';
243        const ttype = this.ttype ?? 'ttype N/A';
244        return (this.state === ActiveDataItem.UINodeExists)
245            ? `state: '${state}', RID: ${rid}, ttype: ${ttype}`
246            : `state: '${state}'`;
247    }
248}
249
250// info about each created UINode / each RepeatItem / each RID
251// only optional key is allowed to change
252class RIDMeta<T> {
253    public readonly repeatItem_: __RepeatItemV2<T>;
254    public readonly ttype_: string;
255    public key_?: string;
256
257    constructor(repeatItem: __RepeatItemV2<T>, ttype: string, key?: string) {
258        this.repeatItem_ = repeatItem;
259        this.ttype_ = ttype;
260        this.key_ = key;
261    }
262}
263
264// see enum NG::UINode::NotificationType
265enum NotificationType {
266    START_CHANGE_POSITION = 0, END_CHANGE_POSITION, START_AND_END_CHANGE_POSITION
267}
268
269// empty function
270const NOOP : () => void = function() {};
271
272class __RepeatVirtualScroll2Impl<T> {
273    public static readonly REF_META = Symbol('__repeat_ref_meta__');
274
275    private arr_: Array<T>;
276
277    // pointer to self, used to subscribe to source array
278    private selfPtr_ = new WeakRef<__RepeatVirtualScroll2Impl<T>>(this);
279
280    // key function
281    private keyGenFunc_?: RepeatKeyGenFunc<T>;
282
283    // is key function specified ?
284    private useKeys_: boolean = false;
285
286    // index <-> key bidirectional mapping
287    private key4Index_: Map<number, string> = new Map<number, string>();
288    private index4Key_: Map<string, number> = new Map<string, number>();
289    // duplicate keys
290    private oldDuplicateKeys_: Set<string> = new Set<string>();
291
292    // map if .each and .template functions
293    private itemGenFuncs_: { [type: string]: RepeatItemGenFunc<T> };
294
295    // templateId function
296    private ttypeGenFunc_?: RepeatTTypeGenFunc<T>;
297
298    // virtualScroll({ totalCount: number }), optional to set
299    private totalCount_: (() => number) | number | undefined;
300
301    // virtualScroll({ onLazyLoading: (index: number) => void }), optional to set
302    private onLazyLoadingFunc_: (index : number) => void;
303    private lazyLoadingIndex_: number = -1;
304
305    // .template 3rd parameter, cachedCount
306    private templateOptions_: { [type: string]: RepeatTemplateImplOptions };
307
308    // reuse node in L2 cache or not
309    private allowUpdate_?: boolean = true;
310
311    // factory for interface RepeatItem<T> objects
312    private mkRepeatItem_: (item: T, index?: number) => __RepeatItemFactoryReturn<T>;
313
314    // register drag drop manager, only used for List
315    private onMoveHandler_?: OnMoveHandler;
316
317    // register drag drop Handler Class, only used for List onMove
318    private itemDragEventHandler_?: ItemDragEventHandler;
319
320    // update from C++ when MoveData happens
321    // reset from C++ when FireOnMove happens
322    private moveFromTo_?: [number, number] = undefined;
323
324    // RepeatVirtualScroll2Node elmtId
325    public repeatElmtId_: number = -1;
326
327    private owningViewV2_: ViewV2;
328
329    // used to generate unique RID
330    private nextRid: number = 1;
331
332    // previously informed active range from - to
333    private activeRange_: [number, number] = [Number.NaN, Number.NaN];
334
335    // previously informed visible range from - to
336    private visibleRange_: [number, number] = [Number.NaN, Number.NaN];
337
338    // adjusted activeRange[0] based on accumulated array mutations
339    private activeRangeAdjustedStart_ = Number.NaN;
340
341    // adjusted visibleRange[0] based on accumulated array mutations
342    private visibleRangeAdjustedStart_ = Number.NaN;
343
344    // Map containing all rid: rid -> RepeatItem, ttype, key?
345    // entires never change
346    private meta4Rid_: Map<number, RIDMeta<T>> = new Map<number, RIDMeta<T>>();
347
348    // Map containing all rid: rid -> ttype,
349    // entires never change
350    // private ttype4Rid_: Map<number, string> = new Map<number, string>();
351
352    // sparse Array containing copy of data items and optionally keys in active range
353    private activeDataItems_: Array<ActiveDataItem<T | void>> = new Array<ActiveDataItem<T | void>>();
354
355    // rid not in L1 / not in active range belong to this set
356    // they are no longer associated with a data item
357    private spareRid_: Set<number> = new Set<number>();
358
359    // record the additional spare rid added in addRemovedItemsToSpare()
360    private additionalSpareRid_: Set<number> = new Set<number>();
361
362    // record the rid that need to Call OnRecycle() on C++ side
363    private ridNeedToRecycle_: Set<number> = new Set<number>();
364
365    // request container re-layout
366    private firstIndexChanged_: number = 0;
367
368    private firstIndexChangedInTryFastRelayout_: number = Number.NaN;
369
370    // optimization: true if any items have bindings with their indexes
371    private hasItemBindingsToIndex_ = false;
372
373    // optimization: flag for checking if rerender is already ongoing
374    private rerenderOngoing_: boolean = false;
375
376    // microtask to sync render-tree after tryFastRelayout
377    private nextTickTask_: ((changeIndex?: number) => void) | undefined = undefined;
378
379    // prevents reRender() trigger
380    private preventReRender_: boolean = false;
381
382    // when access view model record dependency on 'this'.
383    private startRecordDependencies(clearBindings: boolean = false): void {
384        ObserveV2.getObserve().startRecordDependencies(this.owningViewV2_, this.repeatElmtId_, clearBindings);
385    }
386
387    private stopRecordDependencies(): void {
388        ObserveV2.getObserve().stopRecordDependencies();
389    }
390
391    /**
392     * return array item if it exists
393     *
394     * @param index
395     * @returns tuple data item exists , data item
396     *   (need to do like this to differentiate missing data item and undefined item value
397     *   same as std::optional in C++)
398     */
399    private getItemUnmonitored(index: number | string): [boolean, T] {
400        stateMgmtConsole.debug(`getItemUnmonitored ${index} data item exists: ${index in this.arr_}`);
401        if (this.onLazyLoadingFunc_ && !(index in this.arr_)) {
402            this.lazyLoadingIndex_ = index as number;
403            // ensure unrecorded lazy loading!
404            ObserveV2.getObserve().executeUnrecorded(() => { this.onLazyLoadingFunc_(this.lazyLoadingIndex_) });
405            if (!(index in this.arr_)) {
406                const msg = ` onLazyLoading function did not provide data to index ${index}`;
407                stateMgmtConsole.applicationError(`${this.constructor.name}(${this.repeatElmtId_}): ${msg}`);
408            }
409            this.lazyLoadingIndex_ = -1;
410        }
411        return [(index in this.arr_), this.arr_[index]];
412    }
413
414    private getItemMonitored(index: number | string): [boolean, T] {
415        stateMgmtConsole.debug(`getItemMonitored ${index} data item exists: ${index in this.arr_}`);
416
417        this.startRecordDependencies(/* do not clear bindings */ false);
418
419        const result = this.arr_[index];
420
421        this.stopRecordDependencies();
422
423        return [(index in this.arr_), result];
424    }
425
426    private totalCount(forceRetrieveTotalCount = false): number {
427        // when 'totalCount' is set as an <observable>, we call updateElement() just to
428        // retrieve its actual value - prevent triggering re-render here.
429        if (forceRetrieveTotalCount && typeof this.totalCount_ === 'number') {
430            this.preventReRender_ = true;
431            this.owningViewV2_.UpdateElement(this.repeatElmtId_);
432            this.preventReRender_ = false;
433            return this.totalCount();
434        }
435        if (typeof this.totalCount_ === 'function') {
436            let totalCount = this.totalCount_();
437            return (Number.isInteger(totalCount) && totalCount >= 0) ? totalCount : this.arr_.length;
438        }
439        return this.totalCount_ ?? this.arr_.length;
440    }
441
442    // initial render
443    // called from __Repeat.render
444    public render(config: __RepeatConfig<T>, isInitialRender: boolean): void {
445
446        if (this.arr_ && this.arr_ !== config.arr && this.arr_[__RepeatVirtualScroll2Impl.REF_META] !== undefined) {
447            // unsubscribe from the old source array
448            this.arr_[__RepeatVirtualScroll2Impl.REF_META].delete(this.selfPtr_);
449        }
450
451        // switch to the new array
452        this.arr_ = config.arr;
453
454        // add subscribers Set if needed
455        if (!(__RepeatVirtualScroll2Impl.REF_META in this.arr_)) {
456            this.arr_[__RepeatVirtualScroll2Impl.REF_META] = new Set<WeakRef<__RepeatVirtualScroll2Impl<T>>>();
457        }
458
459        // subscribe to the new source array
460        if (this.arr_[__RepeatVirtualScroll2Impl.REF_META] !== undefined) {
461            this.arr_[__RepeatVirtualScroll2Impl.REF_META].add(this.selfPtr_);
462        }
463
464        // totalCount can be function, number or undefined
465        this.totalCount_ = config.totalCount;
466
467        this.onMoveHandler_ = config.onMoveHandler;
468        this.itemDragEventHandler_ = config.itemDragEventHandler;
469
470        this.owningViewV2_ = config.owningView_;
471        if ((this.owningViewV2_ instanceof ViewV2) && ('onLazyLoading' in config)) {
472            this.onLazyLoadingFunc_ = config.onLazyLoading;
473        }
474
475        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_})`,
476            `totalCount ${this.totalCount()} arr length ${this.arr_.length} .`);
477
478        if (!this.onLazyLoadingFunc_ && this.totalCount() > this.arr_.length) {
479            stateMgmtConsole.applicationError(`${this.constructor.name}(${this.repeatElmtId_})`,
480                `'totalCount' must not exceed the array length without 'onLazyLoading' being defined!`);
481        }
482
483        if (isInitialRender) {
484            this.itemGenFuncs_ = config.itemGenFuncs;
485            this.ttypeGenFunc_ = config.ttypeGenFunc;
486            this.templateOptions_ = config.templateOptions;
487
488            this.keyGenFunc_ = config.keyGenFunc;
489            this.allowUpdate_ = config.reusable;
490
491            this.mkRepeatItem_ = config.mkRepeatItem;
492
493            if (!(this.owningViewV2_ instanceof ViewV2)) {
494                stateMgmtConsole.applicationWarn(`${this.constructor.name}(${this.repeatElmtId_}))`,
495                    `it is not allowed to use Repeat virtualScroll inside a @Component!`);
496            }
497
498            if (!this.itemGenFuncs_[RepeatEachFuncTtype]) {
499                throw new Error(`${this.constructor.name}(${this.repeatElmtId_}))` +
500                    `lacks mandatory '.each' attribute function, i.e. has no default item builder. Application error!`);
501            }
502
503            this.initialRender();
504        } else {
505            this.preventReRender_ || this.reRender();
506        }
507
508        this.updateTemplateOptions();
509    }
510
511    private updateTemplateOptions(): void {
512        if (!this.allowUpdate_) {
513            for (const templateType in this.templateOptions_) {
514                this.templateOptions_[templateType] = { cachedCountSpecified: true, cachedCount: 0 };
515            }
516        }
517    }
518
519    private initialRender(): void {
520        this.repeatElmtId_ = ObserveV2.getCurrentRecordedId();
521
522        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) initialRender()`,
523            `data array length: ${this.arr_.length}, totalCount: ${this.totalCount()} - start`);
524
525        const arrLen = this.onLazyLoadingFunc_ ? this.totalCount() : this.arr_.length;
526        // Create the RepeatVirtualScroll2Node object
527        // pass the C++ to TS callback functions.
528        RepeatVirtualScroll2Native.create(arrLen, this.totalCount(), {
529            onGetRid4Index: this.onGetRid4Index.bind(this),
530            onRecycleItems: this.onRecycleItems.bind(this),
531            onActiveRange: this.onActiveRange.bind(this),
532            onMoveFromTo: this.onMoveFromTo.bind(this),
533            onPurge: this.onPurge.bind(this)
534        });
535
536        // init onMove
537        RepeatVirtualScroll2Native.onMove(this.repeatElmtId_, this.onMoveHandler_, this.itemDragEventHandler_);
538
539        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) initialRender() data array length: `,
540            `${this.arr_.length}, totalCount: ${this.totalCount()} - done`);
541    }
542
543    // given data item and the ttype it needs to be rendered with from updated array:
544    // find same data item in activeDataItems, it also still needs to use same ttype as given
545    // return its { index in activeDataItems, its rid }
546    private findDataItemInOldActivateDataItemsByValue(
547        dataItem: T, ttype: string): { oldIndexStr: string, rid?: number } | undefined {
548        for (const oldIndex in this.activeDataItems_) {
549            const oldItem = this.activeDataItems_[oldIndex];
550            if (oldItem.item === dataItem && oldItem.rid && oldItem.ttype === ttype) {
551                return { oldIndexStr: oldIndex, rid: oldItem.rid };
552            }
553        }
554        return undefined;
555    }
556
557    // find same data item in activeDataItems by key, it also still needs to use same ttype as given
558    private findDataItemInOldActivateDataItemsByKey(
559        key: string, ttype: string): {oldIndexStr: string, rid?: number} | undefined {
560        for (const oldIndex in this.activeDataItems_) {
561            const oldItem = this.activeDataItems_[oldIndex];
562            if (oldItem.key === key && oldItem.rid && oldItem.ttype === ttype) {
563                return { oldIndexStr: oldIndex, rid: oldItem.rid };
564            }
565        }
566        return undefined;
567    }
568
569    // update Repeat, see overview documentation at the top of this file.
570    private reRender(): void {
571        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) reRender() data array length: `,
572            `${this.arr_.length}, totalCount: ${this.totalCount()} - start`);
573
574        this.rerenderOngoing_ = true;
575
576        // update onMove
577        // scenario: developers control whether onMove exists or not dynamically.
578        RepeatVirtualScroll2Native.onMove(this.repeatElmtId_, this.onMoveHandler_, this.itemDragEventHandler_);
579
580        const activeRangeFrom = this.activeRange_[0];
581        const activeRangeTo = this.activeRange_[1];
582        const arrLen = this.onLazyLoadingFunc_ ? this.totalCount() : this.arr_.length;
583
584        stateMgmtConsole.debug(`checking range ${activeRangeFrom} - ${activeRangeTo}`);
585
586        this.firstIndexChanged_ = Math.min(activeRangeTo + 1, this.arr_.length);
587
588        // replacement for this.activeDataItems_
589        const newActiveDataItems: Array<ActiveDataItem<void | T>> = new Array<ActiveDataItem<void | T>>();
590
591        // replacement for l1Rid4Index_ index -> rid map on C++ side
592        // will send to C++ when done
593        const newL1Rid4Index: Map<number, number> = new Map<number, number>();
594
595        // clear keys for new rerender
596        this.key4Index_.clear();
597        this.index4Key_.clear();
598        this.oldDuplicateKeys_.clear();
599
600        // step 1. move data items to newActiveDataItems that are unchanged
601        // (same item / same key, still at same index, same ttype)
602        // create createMissingDataItem -type entries for all other new data items.
603        if (!this.moveItemsUnchanged(newActiveDataItems, newL1Rid4Index)) {
604            this.rerenderOngoing_ = false;
605            return;
606        }
607
608        // step 2. move retained data items
609        // these are items with same value / same key in new and old array:
610        // their index has changed, ttype is unchanged
611        this.moveRetainedItems(newActiveDataItems, newL1Rid4Index);
612
613        // step 3. remaining old data items, i.e. data item removed from source array
614        // add their rid to spare
615        this.addRemovedItemsToSpare();
616
617        // step 4: data items in new source array that are either new in the array
618        // or have been there before but need to be rendered with different ttype
619        // if canUpdate then do the update.
620        // if need new render, do not do the new render right away. Wait for layout to ask
621        // for the item to render.
622        this.newItemsNeedToRender(newActiveDataItems, newL1Rid4Index);
623
624        // render all data changes in one go
625        ObserveV2.getObserve().updateDirty2(true);
626
627        this.activeDataItems_ = newActiveDataItems;
628
629        stateMgmtConsole.debug(`rerender result: `,
630            `\nspareRid : ${this.dumpSpareRid()}`,
631            `\nthis.dumpDataItems: ${this.activeDataItems_}`,
632            `\nnewL1Rid4Index: ${JSON.stringify(Array.from(newL1Rid4Index))}`,
633            `\nfirst item changed at index ${this.firstIndexChanged_} .`);
634
635        if (!isNaN(this.firstIndexChangedInTryFastRelayout_)) {
636            this.firstIndexChanged_ = Math.min(this.firstIndexChanged_, this.firstIndexChangedInTryFastRelayout_);
637            this.firstIndexChangedInTryFastRelayout_ = Number.NaN;
638        }
639        RepeatVirtualScroll2Native.updateL1Rid4Index(this.repeatElmtId_, arrLen, this.totalCount(),
640            this.firstIndexChanged_, Array.from(newL1Rid4Index), Array.from(this.ridNeedToRecycle_));
641
642        this.rerenderOngoing_ = false;
643
644        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) reRender() data array length: `,
645            `${this.arr_.length}, totalCount: ${this.totalCount()} - done`);
646    }
647
648    private moveItemsUnchanged(
649        newActiveDataItems: Array<ActiveDataItem<void | T>>, newL1Rid4Index: Map<number, number>): boolean {
650        let hasChanges = false;
651        for (const indexS in this.activeDataItems_) {
652            const activeIndex = parseInt(indexS);
653            if (activeIndex < 0) {
654                // out of range to consider
655                continue;
656            }
657            if (activeIndex >= this.arr_.length || activeIndex >= this.totalCount()) {
658                // data item has been popped from arr_ array that are part of active range
659                hasChanges = true;
660                break;
661            }
662
663            const [dataItemExists, dataItemAtIndex] = this.getItemUnmonitored(activeIndex);
664            if (!dataItemExists) {
665                stateMgmtConsole.debug(`index ${activeIndex} no data in new array, had data before `,
666                    `${this.activeDataItems_[indexS].state !== ActiveDataItem.NoValue}`);
667                hasChanges = hasChanges || (this.activeDataItems_[indexS].state !== ActiveDataItem.NoValue);
668                newActiveDataItems[activeIndex] = ActiveDataItem.createMissingDataItem();
669                continue;
670            }
671
672            const ttype = this.computeTtype(dataItemAtIndex, activeIndex,
673                /* monitored access already enabled */ false);
674            const key = this.computeKey(dataItemAtIndex, activeIndex,
675                /* monitor access already on-going */ false, newActiveDataItems);
676
677            // compare with ttype and data item, or with ttype and key
678            if ((ttype === this.activeDataItems_[activeIndex].ttype) &&
679                ((!this.useKeys_ && dataItemAtIndex === this.activeDataItems_[activeIndex].item) ||
680                (this.useKeys_ && key === this.activeDataItems_[activeIndex].key))) {
681                stateMgmtConsole.debug(
682                    `index ${activeIndex} ttype '${ttype}'${this.useKeys_ ? ', key ' + key : ''} `,
683                    `and dataItem unchanged.`);
684                newActiveDataItems[activeIndex] = this.activeDataItems_[activeIndex];
685
686                // add to index -> rid map to be sent to C++
687                newL1Rid4Index.set(activeIndex, this.activeDataItems_[activeIndex].rid);
688
689                // the data item is handled, remove it from old active data range
690                // so we do not use it again
691                delete this.activeDataItems_[activeIndex];
692            } else {
693                stateMgmtConsole.debug(`index ${activeIndex} has changed `,
694                    `${dataItemAtIndex !== this.activeDataItems_[activeIndex].item}, ttype ${ttype} has changed `,
695                    `${ttype !== this.activeDataItems_[activeIndex].ttype}, key ${key} has changed `,
696                    `${key !== this.activeDataItems_[activeIndex].key}, using keys ${this.useKeys_}`);
697                newActiveDataItems[activeIndex] =
698                    ActiveDataItem.createToBeRenderedDataItem(dataItemAtIndex, ttype, key);
699                hasChanges = true;
700            }
701        } // for activeItems
702
703        // tells the container to adjust the scroll position (when it's needed)
704        const hasNotifiedLayoutChange = this.notifyContainerLayoutChangeAcc();
705
706        if (hasChanges) {
707            return true;
708        }
709
710        // invalidate the layout only for items beyond active range
711        // this is for the case that there is space for more visible items in the container.
712        // triggers layout to request FrameCount() / totalCount and if increased newly added source array items
713        this.activeDataItems_ = newActiveDataItems;
714
715        if (hasNotifiedLayoutChange) {
716            this.requestContainerReLayout();
717        } else {
718            this.requestContainerReLayout(Math.min(this.totalCount() - 1, this.activeRange_[1] + 1));
719        }
720
721        return false;
722    }
723
724    private moveRetainedItems(
725        newActiveDataItems: Array<ActiveDataItem<void | T>>, newL1Rid4Index: Map<number, number>): void {
726        for (const indexS in newActiveDataItems) {
727            const activeIndex = parseInt(indexS);
728            const newActiveDataItemAtActiveIndex = newActiveDataItems[activeIndex];
729
730            if (newActiveDataItemAtActiveIndex.state === ActiveDataItem.UINodeExists) {
731                // same index in new and old, processed in step 1
732                continue;
733            }
734
735            if (newActiveDataItemAtActiveIndex.state === ActiveDataItem.NoValue) {
736                stateMgmtConsole.debug(`new index ${activeIndex} missing in updated source array.`);
737                this.firstIndexChanged_ = Math.min(this.firstIndexChanged_, activeIndex);
738                continue;
739            }
740
741            // retainedItem must have same item value / same key, same ttype, but new index
742            let movedDataItem;
743            if (this.useKeys_) {
744                const key = this.computeKey(newActiveDataItemAtActiveIndex.item as T, activeIndex,
745                    /* monitor access already ongoing */ false, newActiveDataItems);
746                movedDataItem =
747                    this.findDataItemInOldActivateDataItemsByKey(key, newActiveDataItemAtActiveIndex.ttype);
748            } else {
749                movedDataItem = this.findDataItemInOldActivateDataItemsByValue(
750                    newActiveDataItemAtActiveIndex.item as T, newActiveDataItemAtActiveIndex.ttype);
751            }
752
753            if (movedDataItem) {
754                // data item rendered before, and needed ttype to render has not changed
755                newActiveDataItemAtActiveIndex.rid = movedDataItem.rid;
756                newActiveDataItemAtActiveIndex.state = ActiveDataItem.UINodeExists;
757
758                // add to index -> rid map to be sent to C++
759                newL1Rid4Index.set(activeIndex, movedDataItem.rid);
760
761                // index has changed, update it in RepeatItem
762                const ridMeta = this.meta4Rid_.get(movedDataItem.rid);
763                stateMgmtConsole.debug(`new index ${activeIndex} / old index ${movedDataItem.oldIndexStr}: `,
764                    `keep in L1: rid ${movedDataItem.rid}, unchanged ttype '${newActiveDataItemAtActiveIndex.ttype}'`);
765                ridMeta.repeatItem_.updateIndex(activeIndex);
766                this.firstIndexChanged_ = Math.min(this.firstIndexChanged_, activeIndex);
767
768                // the data item is handled, remove it from old active data range
769                // so we do not use it again
770                delete this.activeDataItems_[movedDataItem.oldIndexStr];
771            } else {
772                // update is needed for this data item
773                // either because dataItem is new, or new ttype needs to used
774                stateMgmtConsole.debug(`need update for index ${activeIndex}`);
775                this.firstIndexChanged_ = Math.min(this.firstIndexChanged_, activeIndex);
776            }
777        } // for new data items in active range
778    }
779
780    private addRemovedItemsToSpare(): void {
781        this.additionalSpareRid_.clear();
782        for (let oldIndex in this.activeDataItems_) {
783            if (this.activeDataItems_[oldIndex].rid) {
784                this.spareRid_.add(this.activeDataItems_[oldIndex].rid);
785                this.additionalSpareRid_.add(this.activeDataItems_[oldIndex].rid);
786                const index = parseInt(oldIndex);
787                this.index4Key_.delete(this.key4Index_.get(index));
788                this.key4Index_.delete(index);
789            }
790        }
791    }
792
793    private newItemsNeedToRender(
794        newActiveDataItems: Array<ActiveDataItem<void | T>>, newL1Rid4Index: Map<number, number>): void {
795        this.ridNeedToRecycle_.clear();
796        for (const indexS in newActiveDataItems) {
797            const activeIndex = parseInt(indexS);
798            const newActiveDataItemAtActiveIndex = newActiveDataItems[activeIndex];
799
800            if (newActiveDataItemAtActiveIndex.state !== ActiveDataItem.UINodeToBeRendered) {
801                continue;
802            }
803
804            const optRid = this.canUpdate(newActiveDataItemAtActiveIndex.ttype);
805            if (optRid <= 0) {
806                stateMgmtConsole.debug(`active range index ${activeIndex}: no rid found to update`);
807                continue;
808            }
809            const ridMeta = this.meta4Rid_.get(optRid);
810            if (ridMeta) {
811                // found rid / repeatItem to update
812                stateMgmtConsole.debug(`index ${activeIndex}: update rid ${optRid} / ttype `,
813                    `'${newActiveDataItemAtActiveIndex.ttype}'`);
814
815                newActiveDataItemAtActiveIndex.rid = optRid;
816                newActiveDataItemAtActiveIndex.state = ActiveDataItem.UINodeExists;
817
818                if (this.useKeys_) {
819                    const key = this.computeKey(newActiveDataItemAtActiveIndex.item as T, activeIndex,
820                        /* monitor access already ongoing */ false, newActiveDataItems);
821                    newActiveDataItemAtActiveIndex.key = key;
822                    ridMeta.key_ = key;
823                }
824
825                // spare rid is used
826                this.spareRid_.delete(optRid);
827
828                // add to index -> rid map to be sent to C++
829                newL1Rid4Index.set(activeIndex, optRid);
830
831                // if the rid is recycled in current render, notify C++ to call OnRecycle()
832                if (this.additionalSpareRid_.has(optRid)) {
833                    this.ridNeedToRecycle_.add(optRid);
834                }
835
836                // don't need to call getItem here, already checked that the data item exists
837                ridMeta.repeatItem_.updateItem(newActiveDataItemAtActiveIndex.item as T);
838                ridMeta.repeatItem_.updateIndex(activeIndex);
839
840                this.firstIndexChanged_ = Math.min(this.firstIndexChanged_, activeIndex);
841            }
842        };
843    }
844
845    private computeTtype(item: T, index: number, monitorAccess: boolean): string {
846        if (this.ttypeGenFunc_ === undefined) {
847            return RepeatEachFuncTtype;
848        }
849        // record dependencies if monitoring is enabled
850        monitorAccess && this.startRecordDependencies(false);
851        let ttype = RepeatEachFuncTtype;
852        try {
853            ttype = this.ttypeGenFunc_(item, index);
854        } catch (e) {
855            stateMgmtConsole.applicationError(
856                `${this.constructor.name}(${this.repeatElmtId_}): Error generating ttype at index: ${index}`,
857                e?.message);
858        }
859        if (ttype in this.itemGenFuncs_ === false) {
860            stateMgmtConsole.applicationError(`${this.constructor.name}(${this.repeatElmtId_}):`,
861                `No template found for ttype '${ttype}'`);
862            ttype = RepeatEachFuncTtype;
863        }
864        monitorAccess && this.stopRecordDependencies();
865        return ttype;
866    }
867
868    private computeKey(item: T, index: number, monitorAccess: boolean = true,
869        activateDataItems?: Array<ActiveDataItem<void | T>>): string | undefined {
870        if (!this.useKeys_) {
871            return undefined;
872        }
873
874        let key = this.key4Index_.get(index);
875        if (!key) {
876            monitorAccess && this.startRecordDependencies(false);
877            try {
878                key = this.keyGenFunc_(item, index);
879            } catch {
880                stateMgmtConsole.applicationError(`${this.constructor.name}(${this.repeatElmtId_}):`,
881                    `unstable key function. Fix the key gen function in your application!`);
882                key = this.mkRandomKey(index, '_key-gen-crashed_');
883            }
884            monitorAccess && this.stopRecordDependencies();
885
886            const optIndex1: number | undefined = this.index4Key_.get(key);
887            if (optIndex1 !== undefined || this.oldDuplicateKeys_.has(key)) {
888                key = this.handleDuplicateKey(index, key, optIndex1, activateDataItems);
889            } else {
890                this.key4Index_.set(index, key);
891                this.index4Key_.set(key, index);
892            }
893        }
894        return key;
895    }
896
897    private mkRandomKey(index: number, origKey: string): string {
898        return `___${index}_+_${origKey}_+_${Math.random()}`;
899    }
900
901    // when generating key for index2, detected that index1 has same key already
902    // need to change the key for both index'es
903    // returns random key for index 2
904    private handleDuplicateKey(curIndex: number, origKey: string, prevIndex?: number,
905        activateDataItems?: Array<ActiveDataItem<void | T>>): string {
906        this.oldDuplicateKeys_.add(origKey);
907
908        const curKey = this.mkRandomKey(curIndex, origKey);
909        this.key4Index_.set(curIndex, curKey);
910        this.index4Key_.set(curKey, curIndex);
911
912        if (prevIndex !== undefined) {
913            // also make a new key for prevIndex
914            const prevKey = this.mkRandomKey(prevIndex, origKey);
915            this.key4Index_.set(prevIndex, prevKey);
916            this.index4Key_.set(prevKey, prevIndex);
917            this.index4Key_.delete(origKey);
918            if (activateDataItems && activateDataItems[prevIndex] !== undefined) {
919                stateMgmtConsole.debug(`correcting key of activeDataItem index ${prevIndex} from `,
920                    `'${activateDataItems[prevIndex].key}' to '${prevKey}'.`);
921                activateDataItems[prevIndex].key = prevKey;
922            }
923        }
924        stateMgmtConsole.applicationError(`${this.constructor.name}(${this.repeatElmtId_}): `,
925            `Detected duplicate key for index ${curIndex}! `,
926            `Generated random key will decrease Repeat performance. Fix the key gen function in your application!`);
927        return curKey;
928    }
929
930    /**
931     * called from C++ GetFrameChild whenever need to create new node and add to L1
932     * or update spare node and add back to L1
933     *
934     * @param forIndex
935     * @returns
936     */
937    private onGetRid4Index(forIndex: number): [number, number] {
938        if (forIndex < 0 || forIndex >= this.totalCount()) {
939            throw new Error(`${this.constructor.name}(${this.repeatElmtId_}) onGetRid4Index index ${forIndex}` +
940                `\ndata array length: ${this.arr_.length}, totalCount: ${this.totalCount()}: ` +
941                `Out of range, application error.`);
942        }
943        const [dataItemExists, dataItem] = this.getItemUnmonitored(forIndex);
944        if (!dataItemExists) {
945            stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) `,
946                `onGetRid4Index index ${forIndex} - missing data item.`);
947            this.activeDataItems_[forIndex] = ActiveDataItem.createMissingDataItem();
948            return [0, /* failed to create or update */ 0];
949        }
950
951        const ttype = this.computeTtype(dataItem, forIndex, /* enable monitored access */ true);
952        const key = this.computeKey(dataItem, forIndex, /* monitor access*/ true, this.activeDataItems_);
953        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onGetRid4Index index ${forIndex}, `,
954            `ttype is '${ttype}' data array length: ${this.arr_.length}, totalCount: ${this.totalCount()} - start`);
955
956        // spare UINode / RID available to update?
957        const optRid = this.canUpdateTryMatch(ttype, dataItem, key);
958
959        const result: [number, number] = (optRid > 0)
960            ? this.updateChild(optRid, ttype, forIndex, key)
961            : this.createNewChild(forIndex, ttype, key);
962
963        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onGetRid4Index index ${forIndex} `,
964            `ttype is '${ttype}' data array length: ${this.arr_.length}, totalCount: ${this.totalCount()} - DONE`);
965        return result;
966    }
967
968    // return RID of Node that can be updated (matching ttype),
969    // or -1 if none
970    private canUpdate(ttype: string): number {
971        if (!this.allowUpdate_) {
972            return -1;
973        }
974        for (const rid of this.spareRid_) {
975            if (this.meta4Rid_.get(rid).ttype_ === ttype) {
976                stateMgmtConsole.debug(`canUpdate: Found spare rid ${rid} for ttype '${ttype}'`);
977                return rid;
978            }
979        }
980        stateMgmtConsole.debug(`canUpdate: Found NO spare rid for ttype '${ttype}'`);
981        return -1;
982    }
983
984    // return RID of Node that can be updated (matching ttype),
985    // or -1 if none
986    private canUpdateTryMatch(ttype: string, dataItem: T, key?: string): number {
987        if (!this.allowUpdate_) {
988            return -1;
989        }
990        // 1. round: find matching RID, also data item matches
991        for (const rid of this.spareRid_) {
992            const ridMeta = this.meta4Rid_.get(rid);
993            // compare ttype and data item, or ttype and key
994            if (ridMeta && ridMeta.ttype_ === ttype &&
995                ((!this.useKeys_ && ridMeta.repeatItem_?.item === dataItem) ||
996                (this.useKeys_ && ridMeta.key_ === key))) {
997                stateMgmtConsole.debug(
998                    `canUpdateTryMatch: Found spare rid ${rid} for ttype '${ttype}' contentItem matches.`);
999                return rid;
1000            }
1001        }
1002
1003        // just find a matching RID
1004        for (const rid of this.spareRid_) {
1005            if (this.meta4Rid_.get(rid).ttype_ === ttype) {
1006                stateMgmtConsole.debug(`canUpdateTryMatch: Found spare rid ${rid} for ttype '${ttype}'`);
1007                return rid;
1008            }
1009        }
1010        stateMgmtConsole.debug(`canUpdateTryMatch: Found NO spare rid for ttype '${ttype}'`);
1011        return -1;
1012    }
1013
1014    /**
1015     * crete new Child node onto the ViewStackProcessor
1016     *
1017     * @param forIndex
1018     * @param ttype
1019     * @returns [ success, 1 for new node created ]
1020     */
1021    private createNewChild(forIndex: number, ttype: string, key?: string): [number, number] {
1022        let itemGenFunc = this.itemGenFuncs_[ttype];
1023        this.startRecordDependencies();
1024
1025        // item exists in arr_, has been checked before
1026        const [_, dataItem] = this.getItemUnmonitored(forIndex);
1027        const repeatItem = this.mkRepeatItem_(dataItem, forIndex) as __RepeatItemV2<T>;
1028        const rid = this.nextRid++;
1029
1030        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) createNewChild index ${forIndex} -> `,
1031            `new rid ${rid} / ttype ${ttype}, key ${key} - data array length: ${this.arr_.length}, `,
1032            `totalCount: ${this.totalCount()} - start`);
1033
1034        try {
1035            // execute item builder function
1036            const isTemplate: boolean = (ttype !== RepeatEachFuncTtype);
1037            itemGenFunc(repeatItem);
1038            RepeatVirtualScroll2Native.setCreateByTemplate(isTemplate);
1039        } catch (e) {
1040            this.stopRecordDependencies();
1041
1042            stateMgmtConsole.applicationError(`${this.constructor.name}(${this.repeatElmtId_}) `,
1043                `initialRenderChild(forIndex: ${forIndex}, templateId: '${ttype}') -> RID ${rid}: `,
1044                `data array length: ${this.arr_.length}, totalCount: ${this.totalCount()} - `,
1045                `item initial render failed!`);
1046            this.activeDataItems_[forIndex] = ActiveDataItem.createFailedToCreateUINodeDataItem(this.arr_[forIndex]);
1047            return [0, /* did not success creating new UINode */ 0];
1048        }
1049
1050        // do any items have bindings with their indexes?
1051        // // this.hasItemBindingsToIndex_ ||= repeatItem.hasBindingToIndex();
1052
1053        // a new UINode subtree, create a new rid -> RepeatItem, ttype, key
1054        this.meta4Rid_.set(rid, new RIDMeta(repeatItem, ttype, key));
1055        this.activeDataItems_[forIndex] = ActiveDataItem.createWithUINode(this.arr_[forIndex], rid, ttype, key);
1056
1057        this.stopRecordDependencies();
1058
1059        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) createNewChild index ${forIndex} -> `,
1060            `new rid ${rid} / ttype ${ttype}, key ${key} - data array length: ${this.arr_.length}, `,
1061            `totalCount: ${this.totalCount()} - done`);
1062        return [rid, /* created new UINode successfully */ 1];
1063    }
1064
1065    /**
1066    * update given rid / RepeatItem to data item of given index
1067    *
1068    * @param rid
1069    * @param ttype
1070    * @param forIndex
1071    * @returns [ success, 2 for updated existing node ]
1072    */
1073    private updateChild(rid: number, ttype: string, forIndex: number, key?: string): [number, number] {
1074        const ridMeta = this.meta4Rid_.get(rid);
1075        if (!ridMeta || !ridMeta.repeatItem_) {
1076            // error
1077            stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) `,
1078                `updateChild(forIndex: ${forIndex}, templateId: ${ttype}) <- RID ${rid}: `,
1079                `data array length: ${this.arr_.length}, totalCount: ${this.totalCount()}. `,
1080                `Failed to find RepeatItem. Internal error!.`);
1081            this.activeDataItems_[forIndex] = ActiveDataItem.createFailedToCreateUINodeDataItem(this.arr_[forIndex]);
1082            return [0, /* failed to update */ 0];
1083        }
1084
1085        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) updateChild`,
1086            `(forIndex: ${forIndex}, templateId: ${ttype}) <- RID ${rid}, key: ${key}: `,
1087            `data array length: ${this.arr_.length}, totalCount: ${this.totalCount()} - start`);
1088
1089        // item exists in arr_, has been checked before
1090        const [_, dataItem] = this.getItemMonitored(forIndex);
1091
1092        // rid is taken, move out of spare Set
1093        this.spareRid_.delete(rid);
1094        this.activeDataItems_[forIndex] = ActiveDataItem.createWithUINode(dataItem, rid, ttype, key);
1095        if (this.useKeys_ && key !== undefined) {
1096            // rid, repeatItem, ttype are constant, but key changes in ridMeta on child update
1097            ridMeta.key_ = key;
1098        }
1099
1100        if (ridMeta.repeatItem_.item !== dataItem || ridMeta.repeatItem_.index !== forIndex) {
1101            // repeatItem needs update, will trigger partial update to using UINodes:
1102            ridMeta.repeatItem_.updateItem(dataItem);
1103            ridMeta.repeatItem_.updateIndex(forIndex);
1104
1105            ObserveV2.getObserve().updateDirty2(/* update synchronously */ true);
1106            stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) updateChild`,
1107                `(forIndex: ${forIndex}, templateId: ${ttype}) <- RID ${rid}, key: ${key}: `,
1108                `update has been done - done`);
1109        } else {
1110            stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) updateChild`,
1111                `(forIndex: ${forIndex}, templateId: ${ttype}) <- RID ${rid}, key: ${key}: `,
1112                `item and index value as in spare, no update needed - done`);
1113        }
1114
1115        return [rid, /* update success */ 2];
1116    }
1117
1118    /**
1119     * overloaded function from FrameNode
1120     * called from layout, inform index range of active / L1 items that can be removed from L1
1121     * to spare nodes, allow to update them
1122     * Note: Grid, List layout has a bug: Frequently calls GetFrameChildForIndex for the index 'fromIndex'
1123     *       which moves this item back to L1
1124     *
1125     * @param fromIndex
1126     * @param toIndex
1127     */
1128    private onRecycleItems(fromIndex: number, toIndex: number): void {
1129        // avoid negative fromIndex
1130        fromIndex = Math.max(0, fromIndex);
1131        for (let index = fromIndex; index < toIndex; index++) {
1132            // when ListItem is being dragged without dropping, index will be mapped.
1133            let indexMapped = this.convertFromToIndex(index);
1134            if (indexMapped >= this.totalCount() || !(indexMapped in this.activeDataItems_)) {
1135                continue;
1136            }
1137            if (this.activeDataItems_[indexMapped].state === ActiveDataItem.UINodeExists) {
1138                this.dropFromL1ActiveNodes(indexMapped);
1139            }
1140        }
1141        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onRecycleItems(${fromIndex}...<${toIndex})`,
1142            `after applying changes:\n${this.dumpSpareRid()}\n${this.dumpDataItems()}`);
1143    }
1144
1145    private onMoveFromTo(moveFrom: number, moveTo: number): void {
1146        moveFrom = Math.trunc(moveFrom);
1147        moveTo = Math.trunc(moveTo);
1148        if (!this.isNonNegative(moveFrom) || !this.isNonNegative(moveTo)) {
1149            this.moveFromTo_ = undefined;
1150            stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onMoveFromTo param invalid,`,
1151                `reset moveFromTo.`);
1152            return;
1153        }
1154        if (this.moveFromTo_) {
1155            this.moveFromTo_[1] = moveTo;
1156            if (this.moveFromTo_[1] === this.moveFromTo_[0]) {
1157                this.moveFromTo_ = undefined;
1158            }
1159        } else {
1160            this.moveFromTo_ = [moveFrom, moveTo];
1161        }
1162        if (this.moveFromTo_) {
1163            stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onMoveFromTo updated`,
1164                `(${this.moveFromTo_[0]}, ${this.moveFromTo_[1]})`);
1165        } else {
1166            stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onMoveFromTo data moved`,
1167                `to original pos, reset moveFromTo.`);
1168        }
1169    }
1170
1171    private isNonNegative(index: number): boolean {
1172        return (Number.isFinite(index) && index >= 0);
1173    }
1174
1175    /**
1176     * What is the effective time of these two function(convertFromToIndex, convertFromToIndexRevert)?
1177     * - Only valid when ListItem is being dragged up and moved without dropping.
1178     * - Otherwise, this.moveFromTo_ is undefined, nothing will be processed and the original index value
1179     *  will be returned directly.
1180     *
1181     * How does this function convert index value?
1182     * - Look at this scenario.
1183     * - If original arr is [a, b, c, d], and users long-press item 'a' and drag it up and move it to pos after 'c'
1184     *  without dropping. What users see is [b, c, a, d]. Then this.moveFromTo_ is [0, 2].
1185     * - If mapping index by convertFromToIndex:
1186     *  index is 2, then the mappedIndex is 0.
1187     *  index is 1, then the mappedIndex is 2.
1188     *  index is 0, then the mappedIndex is 1.
1189     * - If mapping index by convertFromToIndexRevert:
1190     *  index is 0, then the mappedIndex is 2.
1191     *  index is 1, then the mappedIndex is 0.
1192     *  index is 2, then the mappedIndex is 1.
1193     *
1194     * Why these two function is needed?
1195     * - Simply put, they are used for onActiveRange and onRecycleItems when drag and drop sorting is on-going.
1196     * - Specifically, also based on the scenario upon and List is scrolling at the same time:
1197     *  a) if onActiveRange(1, 3) is being called, then, convertFromToIndexRevert is needed.
1198     *      "onActiveRange" is iterating activeDataItems_ and need to map each index in it by convertFromToIndexRevert,
1199     *      to judge mappedIndex whether is in active range. Otherwise, item 'a' whose index is 0 is not in active
1200     *      range and will be deleted. But actually, item 'a' whose index is 0 has been dragged to index 2.
1201     *      So item that need to be deleted is 'b', whose index is 1 and its mappedIndex is 0.
1202     *  b) if onRecycleItems(0, 1) is being called, then, convertFromToIndex is needed.
1203     *      "onRecycleItems(0, 1)" is iterating index from fromIndex to toIndex. In this scene, it needs to
1204     *      map each index by convertFromToIndex. Otherwise, item 'a' whose index is 0 will be removed from L1.
1205     *      But actually, item 'a' whose index is 0 has been dragged to index 2. So item that need to be removed
1206     *      is 'b', whose index is 1 and its mappedIndex is 0.
1207     */
1208    private convertFromToIndex(index: number): number {
1209        if (!this.moveFromTo_) {
1210            return index;
1211        }
1212        if (this.moveFromTo_[1] === index) {
1213            return this.moveFromTo_[0];
1214        }
1215        if (this.moveFromTo_[0] <= index && index < this.moveFromTo_[1]) {
1216            return index + 1;
1217        }
1218        if (this.moveFromTo_[1] < index && index <= this.moveFromTo_[0]) {
1219            return index - 1;
1220        }
1221        return index;
1222    }
1223
1224    // used for onActiveRange. Specific instructions are provided above.
1225    private convertFromToIndexRevert(index: number): number {
1226        if (!this.moveFromTo_) {
1227            return index;
1228        }
1229        if (this.moveFromTo_[0] === index) {
1230            return this.moveFromTo_[1];
1231        }
1232        if (this.moveFromTo_[0] < index && index <= this.moveFromTo_[1]) {
1233            return index - 1;
1234        }
1235        if (this.moveFromTo_[1] <= index && index < this.moveFromTo_[0]) {
1236            return index + 1;
1237        }
1238        return index;
1239    }
1240
1241    private dropFromL1ActiveNodes(index: number, invalidate: boolean = true): boolean {
1242        if (!(index in this.activeDataItems_)) {
1243            return false;
1244        }
1245
1246        const rid: number | undefined = this.activeDataItems_[index].rid;
1247        const ttype: string | undefined = this.activeDataItems_[index].ttype;
1248
1249        // delete makes array item empty, does not re-index.
1250        delete this.activeDataItems_[index];
1251        this.index4Key_.delete(this.key4Index_.get(index));
1252        this.key4Index_.delete(index);
1253
1254        if (rid === undefined || ttype === undefined) {
1255            // data item is not rendered, yet
1256            stateMgmtConsole.debug(`dropFromL1ActiveNodes index: ${index} no rid ${rid} or no ttype `,
1257                `'${ttype ? ttype : 'undefined'}'. Dropping un-rendered item silently.`);
1258            return false;
1259        }
1260
1261        // add to spare rid Set
1262        this.spareRid_.add(rid);
1263
1264        if (invalidate) {
1265            stateMgmtConsole.debug(`dropFromL1ActiveNodes index: ${index} - rid: ${rid}/ttype: '${ttype}' `,
1266                `- spareRid: ${this.dumpSpareRid()} - invalidate in RepeatVirtualScroll2Node ...`);
1267            // call RepeatVirtualScroll2Caches::SetInvalid
1268            RepeatVirtualScroll2Native.setInvalid(this.repeatElmtId_, rid);
1269        } else {
1270            stateMgmtConsole.debug(`dropFromL1ActiveNodes index: ${index} - rid: ${rid}/ttype: '${ttype}' `,
1271                `- spareRid: ${this.dumpSpareRid()}`);
1272        }
1273
1274        return true;
1275    }
1276
1277    private onActiveRange(nStart: number, nEnd: number, vStart: number, vEnd: number, isLoop: boolean,
1278        forceUpdate: boolean): void {
1279        if (Number.isNaN(this.activeRange_[0])) {
1280            // first call to onActiveRange / no active node
1281            this.activeRange_ = [nStart, nEnd];
1282            this.visibleRange_ = [vStart, vEnd];
1283            this.activeRangeAdjustedStart_ = nStart;
1284            this.visibleRangeAdjustedStart_ = vStart;
1285        } else if (this.activeRange_[0] === nStart && this.activeRange_[1] === nEnd) {
1286            if (this.visibleRange_[0] !== vStart || this.visibleRange_[1] !== vEnd) {
1287                stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onActiveRange`,
1288                    `update visibleRange_ (vStart: ${vStart}, vEnd: ${vEnd})`);
1289                this.visibleRange_ = [vStart, vEnd];
1290                this.visibleRangeAdjustedStart_ = vStart;
1291            }
1292            if (!isLoop && !forceUpdate) {
1293                stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onActiveRange`,
1294                    `(nStart: ${nStart}, nEnd: ${nEnd})`,
1295                    `data array length: ${this.arr_.length}, totalCount: ${this.totalCount()} - unchanged, skipping.`);
1296                return;
1297            }
1298        }
1299
1300        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onActiveRange`,
1301            `(nStart: ${nStart}, nEnd: ${nEnd}), (start: ${vStart}, end: ${vEnd})`,
1302            `data array length: ${this.arr_.length}, totalCount: ${this.totalCount()} - start`);
1303
1304        // check which of the activeDataItems needs to be removed from L1 & activeDataItems
1305        let numberOfActiveItems = 0;
1306        for (let index = 0; index < this.activeDataItems_.length; index++) {
1307            // when ListItem is being dragged without dropping, index will be mapped.
1308            let indexMapped = this.convertFromToIndexRevert(index);
1309            if (!(index in this.activeDataItems_)) {
1310                continue;
1311            }
1312
1313            // same condition as in C++ RepeatVirtualScroll2Node::CheckNode4IndexInL1
1314            let remainInL1 = (nStart <= indexMapped && indexMapped <= nEnd);
1315            if (isLoop) {
1316                remainInL1 = remainInL1 ||
1317                    (nStart > nEnd && (nStart <= index || index <= nEnd)) ||
1318                    (nStart < 0 && index >= nStart + this.totalCount()) ||
1319                    (nEnd >= this.totalCount() && index <= nEnd - this.totalCount());
1320            }
1321            stateMgmtConsole.debug(`index: ${index}: ${remainInL1 ? 'keep in L1' : 'drop from L1'}`,
1322                `dataItem: ${this.activeDataItems_[index].dump()}`);
1323
1324            if (remainInL1) {
1325                if (this.activeDataItems_[index].state === ActiveDataItem.UINodeExists) {
1326                    numberOfActiveItems += 1;
1327                }
1328            } else {
1329                if (this.activeDataItems_[index].state === ActiveDataItem.UINodeExists) {
1330                    this.dropFromL1ActiveNodes(index, /* call C++ RepeatVirtualScroll2Caches::SetInvalid */ false);
1331                }
1332            }
1333        }
1334
1335        // memorize
1336        this.activeRange_ = [nStart, nEnd];
1337        this.visibleRange_ = [vStart, vEnd];
1338        this.activeRangeAdjustedStart_ = nStart;
1339        this.visibleRangeAdjustedStart_ = vStart;
1340
1341        stateMgmtConsole.debug(`onActiveRange Result: number remaining activeItems ${numberOfActiveItems}.`,
1342            `\n${this.dumpDataItems()}\n${this.dumpSpareRid()}\n${this.dumpRepeatItem4Rid()}`);
1343
1344        // adjust dynamic cachedCount for each template type that is using dynamic cached count
1345        stateMgmtConsole.debug(`templateOptions_ ${JSON.stringify(this.templateOptions_)}`);
1346        Object.entries(this.templateOptions_).forEach((pair) => {
1347            const options: RepeatTemplateImplOptions = pair[1];
1348            if (!options.cachedCountSpecified) {
1349                options.cachedCount = Number.isInteger(options.cachedCount)
1350                    ? Math.max(numberOfActiveItems, options.cachedCount)
1351                    : numberOfActiveItems;
1352            }
1353        });
1354        stateMgmtConsole.debug(this.dumpCachedCount());
1355    }
1356
1357    // handles circular ranges by normalizing the start and end values within
1358    // the circular range [-totalCount, +totalCount]
1359    private isIndexInRange(index: number, start: number, end: number, totalCount = this.totalCount()): boolean {
1360        // to codechecker: yes, need switch to math names here
1361        let [i, a, b, n] = [index, start, end, totalCount];
1362
1363        // convert all indices to range [0, n-1], e.g. [-1] should become [0]
1364        a = ((a >= 0) ? a : Math.abs(a + 1)) % n;
1365        b = ((b >= 0) ? b : Math.abs(b + 1)) % n;
1366
1367        // shift values so that 'start' ('a') becomes 0
1368        i = (i - a + n) % n;
1369        b = (b - a + n) % n;
1370
1371        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) isIndexInRange:`,
1372            `${index} in [${start}, ${end}] totalCount=${this.totalCount()} =>`, i <= b);
1373        return i <= b;
1374    }
1375
1376    private hasOverlapWithActiveRange(startIndex: number, endIndex: number): boolean {
1377        // ensure we are not out of bounds
1378        const start = Math.min(startIndex, this.totalCount() - 1);
1379        const end = Math.min(endIndex, this.totalCount() - 1);
1380
1381        // ranges ovelap if at least one boundary of one range is inside the other
1382        return this.isIndexInRange(start, this.activeRange_[0], this.activeRange_[1]) ||
1383            this.isIndexInRange(end, this.activeRange_[0], this.activeRange_[1]) ||
1384            this.isIndexInRange(this.activeRange_[0], start, end) ||
1385            this.isIndexInRange(this.activeRange_[1], start, end);
1386    }
1387
1388    private needRerenderChange(changeIndex: number, deleteCount: number, addCount: number): boolean {
1389        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) needRerenderChange(${changeIndex},`,
1390            `${deleteCount}, ${addCount}), activeRange:`, ...this.activeRange_);
1391
1392        // 0. Looping detected. We're in Swiper or another fancy container, need rerender
1393        if (this.activeRange_[1] < this.activeRange_[0]) {
1394            stateMgmtConsole.debug(`needRerenderChange (0) return`, true);
1395            return true;
1396        }
1397
1398        // 1. All changed or shifted items are behind the end of the activeRange, no need rerender
1399        if (changeIndex > this.activeRange_[1]) {
1400            stateMgmtConsole.debug(`needRerenderChange (1) return`, false);
1401            return false;
1402        }
1403
1404        // 2. New items have added within the activeRange, need rerender
1405        if (this.hasOverlapWithActiveRange(changeIndex, changeIndex + addCount)) {
1406            stateMgmtConsole.debug(`needRerenderChange (2) return`, true);
1407            return true;
1408        }
1409
1410        // 3. All changes are before the active range, no shifts
1411        if (deleteCount === addCount) {
1412            stateMgmtConsole.debug(`needRerenderChange (3) return`, false);
1413            return false;
1414        }
1415
1416        // 4. Items in the activeRange have only shifted. If there are no index-dependent items,
1417        // no need rerender
1418
1419        // Keep the code below commented out until FrameNode::NotifyChange is fixed
1420        // // let hasDependencyOnIndex = true;
1421        // // has dependency on index in itemgen func ?
1422        // // hasDependencyOnIndex = this.hasItemBindingsToIndex_;
1423        // // has dependency on index in typegen func ?
1424        // // hasDependencyOnIndex ||= (this.ttypeGenFunc_?.length >= 2);
1425        // // has dependency on index in keygen func ?
1426        // // hasDependencyOnIndex ||= (this.keyGenFunc_?.length >= 2);
1427
1428        // // if (!hasDependencyOnIndex) {
1429        // //     stateMgmtConsole.debug(`needRerenderChange (4) return`, false);
1430        // //     return false;
1431        // // }
1432
1433        stateMgmtConsole.debug(`needRerenderChange return`, true);
1434        return true;
1435    }
1436
1437    // update this.activeRangeAdjustedStart_
1438    private adjustActiveRangeStart(index: number, deleteCount: number, addCount: number): void {
1439        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_})`,
1440            `adjustActiveRangeStart(${index}, ${deleteCount}, ${addCount})`);
1441
1442        // If activeRange_ is not yet known, do nothing
1443        if (isNaN(this.activeRangeAdjustedStart_) || isNaN(this.visibleRangeAdjustedStart_)) {
1444            this.activeRangeAdjustedStart_ = this.activeRange_[0];
1445            this.visibleRangeAdjustedStart_ = this.visibleRange_[0];
1446        }
1447        if (isNaN(this.activeRangeAdjustedStart_) || isNaN(this.visibleRangeAdjustedStart_)) {
1448            return;
1449        }
1450
1451        // count changes before visible range
1452        if (index <= this.visibleRangeAdjustedStart_) {
1453            this.visibleRangeAdjustedStart_ -= Math.min(deleteCount, this.visibleRangeAdjustedStart_ - index);
1454            this.visibleRangeAdjustedStart_ += addCount;
1455        }
1456
1457        // count changes before active range
1458        if (index <= this.activeRangeAdjustedStart_) {
1459            this.activeRangeAdjustedStart_ -= Math.min(deleteCount, this.activeRangeAdjustedStart_ - index);
1460            this.activeRangeAdjustedStart_ += addCount;
1461        }
1462
1463        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_})`,
1464            `activeRangeAdjustedStart_ = ${this.activeRangeAdjustedStart_}`,
1465            `visibleRangeAdjustedStart_ = ${this.visibleRangeAdjustedStart_}`);
1466    }
1467
1468    // Return false if a regular re-render is required. Otherwise, only notify the container
1469    // about a layout change and schedule a re-layout
1470    public tryFastRelayout(arrChange: string, args: Array<unknown>): boolean {
1471        // if rerender is already running, just skip everything
1472        if (this.rerenderOngoing_) {
1473            return true;
1474        }
1475
1476        if (this.lazyLoadingIndex_ !== -1 && arrChange !== 'set') {
1477            const msg = `onLazyLoading function executed illegal operation: ${arrChange}!`;
1478            throw new Error(`${this.constructor.name}(${this.repeatElmtId_}) ${msg}`);
1479        }
1480
1481        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) tryFastRelayout for '${arrChange}'`,
1482            `args: ${args}, activeRange: ${this.activeRange_}, visibleRange: ${this.visibleRange_}`);
1483
1484        // Note that the change in the array has already been done!
1485        // Now our activeRange reflects the array before the change
1486        // and this.arr_.length is the change after the change!
1487
1488        if (arrChange === 'push') {
1489            const originalLength = this.arr_.length - args.length;
1490            return this.tryFastRelayoutForChange(originalLength, this.arr_.length - args.length, 0, args.length);
1491        }
1492
1493        if (arrChange === 'pop') {
1494            const originalLength = this.arr_.length + 1;
1495            return this.tryFastRelayoutForChange(originalLength, this.arr_.length, 1, 0);
1496        }
1497
1498        if (arrChange === 'shift') {
1499            const originalLength = this.arr_.length + 1;
1500            return this.tryFastRelayoutForChange(originalLength, 0, 1, 0);
1501        }
1502
1503        if (arrChange === 'unshift') {
1504            const originalLength = this.arr_.length - args.length;
1505            return this.tryFastRelayoutForChange(originalLength, 0, 0, args.length);
1506        }
1507
1508        if (arrChange === 'splice') {
1509            // first parameter contains original array length before splice
1510            const [originalLength, index = undefined, deleteCount = undefined, ...items] = args as number[];
1511            return this.tryFastRelayoutForChange(originalLength, index, deleteCount, items.length);
1512        }
1513
1514        if (arrChange === 'set') {
1515            const changeIndex = args[0] as number;
1516            if (this.lazyLoadingIndex_ !== -1 && changeIndex !== this.lazyLoadingIndex_) {
1517                const msg = `onLazyLoading function illegally set to index: ${changeIndex}`;
1518                throw new Error(`${this.constructor.name}(${this.repeatElmtId_}) ${msg}`);
1519            }
1520            return (changeIndex >= 0) && this.tryFastRelayoutForChange(this.arr_.length, changeIndex, 0, 0);
1521        }
1522
1523        // discard nextTickTask if it's scheduled
1524        this.nextTickTask_ &&= NOOP;
1525        return false;
1526    }
1527
1528    private tryFastRelayoutForChange(originalLength: number, index: number | undefined,
1529        deleteCount: number | undefined, addCount: number): boolean {
1530
1531        // array hasn't changed, render is not needed
1532        if (index === undefined && deleteCount === undefined && addCount === 0) {
1533            return true;
1534        }
1535
1536        // normalize index
1537        let nIndex = index ?? 0;
1538        if (nIndex < 0) {
1539            nIndex = Math.max(nIndex + originalLength, 0);
1540        }
1541        if (nIndex > originalLength) {
1542            nIndex = originalLength;
1543        }
1544
1545        // normalize deleteCount
1546        let nDeleteCount = deleteCount ?? (originalLength - nIndex);
1547        nDeleteCount = Math.min(nDeleteCount, originalLength - nIndex);
1548        nDeleteCount = Math.max(nDeleteCount, 0);
1549
1550        return this.tryFastRelayoutForChangeNormalized(nIndex, nDeleteCount, addCount);
1551    }
1552
1553    private tryFastRelayoutForChangeNormalized(index: number, deleteCount: number, addCount: number): boolean {
1554        // update accumulated active-range offset here
1555        this.adjustActiveRangeStart(index, deleteCount, addCount);
1556
1557        if (this.lazyLoadingIndex_ === -1 && this.needRerenderChange(index, deleteCount, addCount)) {
1558            // discard nextTickTask if it's scheduled
1559            this.nextTickTask_ &&= NOOP;
1560            this.updateFirstIndexChangedInTryFastRelayout(index);
1561            return false;
1562        }
1563
1564        if (deleteCount !== addCount) {
1565            // forcibly retrieve totalCount (needed only when 'totalCount' option is used)
1566            this.totalCount(true);
1567        }
1568
1569        // schedule microtask to run after all synchronous array updates
1570        if (this.nextTickTask_ === undefined) {
1571            Promise.resolve().then(() => {
1572                this.nextTickTask_?.();
1573                this.nextTickTask_ = undefined;
1574            });
1575            this.nextTickTask_ = () : void => {
1576                stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}), nextTickTask()`);
1577                this.notifyContainerLayoutChangeAcc();
1578                this.requestContainerReLayout();
1579            };
1580        }
1581
1582        return true;
1583    }
1584
1585    private updateFirstIndexChangedInTryFastRelayout(index: number): void {
1586        if (isNaN(this.firstIndexChangedInTryFastRelayout_)) {
1587            this.firstIndexChangedInTryFastRelayout_ = index;
1588        } else {
1589            this.firstIndexChangedInTryFastRelayout_ = Math.min(this.firstIndexChangedInTryFastRelayout_, index);
1590        }
1591    }
1592
1593    private notifyContainerLayoutChangeAcc(): boolean {
1594        const changeCount = this.visibleRangeAdjustedStart_ - this.visibleRange_[0];
1595        if (isNaN(changeCount) || changeCount === 0) {
1596            return false;
1597        }
1598
1599        // get changeIndex to notify container with accumulated changes
1600        let changeIndex = (changeCount < 0) ? 0 : this.visibleRange_[0];
1601
1602        // tells the container to adjust the scroll position, exact behavior is determined by
1603        // List.maintainVisibleContentPosition(bool)
1604        this.notifyContainerLayoutChange(changeIndex, changeCount);
1605        this.visibleRangeAdjustedStart_ = NaN;
1606        this.activeRangeAdjustedStart_ = NaN;
1607        return true;
1608    }
1609
1610    private notifyContainerLayoutChange(changeIndex: number, changeCount: number,
1611        notificationType = NotificationType.END_CHANGE_POSITION): void {
1612        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_})`,
1613            `notifyContainerLayoutChange(${changeIndex}, ${changeCount}, ${NotificationType[notificationType]})`);
1614
1615        const arrLen = this.onLazyLoadingFunc_ ? this.totalCount() : this.arr_.length;
1616        // triggers FrameNode::NotifyChange in CPP side
1617        RepeatVirtualScroll2Native.notifyContainerLayoutChange(this.repeatElmtId_, arrLen, this.totalCount(),
1618            changeIndex, changeCount, notificationType);
1619    }
1620
1621    private requestContainerReLayout(changeIndex?: number): void {
1622        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) requestContainerReLayout`, changeIndex);
1623
1624        const arrLen = this.onLazyLoadingFunc_ ? this.totalCount() : this.arr_.length;
1625        // trigger MarkNeedSyncRenderTree, MarkNeedFrameFlushDirty in CPP side
1626        RepeatVirtualScroll2Native.requestContainerReLayout(this.repeatElmtId_, arrLen, this.totalCount(), changeIndex);
1627    }
1628
1629    private onPurge(): void {
1630        stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) purge(), totalCount: `,
1631            `${this.totalCount()} - start`);
1632
1633        // deep copy templateOptions_
1634        let availableCachedCount: { [ttype: string]: number } = {};
1635        Object.entries(this.templateOptions_).forEach((pair) => {
1636            availableCachedCount[pair[0]] = pair[1].cachedCount as number;
1637        });
1638
1639        // Improvement needed:
1640        // this is a simplistic purge is more or less randomly purges
1641        // extra nodes
1642        // avoid delete on iterated Set, copy into Array
1643        const spareRid1 : Array<number> = Array.from(this.spareRid_);
1644        for (const rid of spareRid1) {
1645            const ttype: string = this.meta4Rid_.get(rid).ttype_;
1646            if (availableCachedCount[ttype] === 0) {
1647                // purge rid
1648                this.purgeNode(rid);
1649            } else {
1650                availableCachedCount[ttype] -= 1;
1651            }
1652        }
1653        stateMgmtConsole.debug(`onPurge after applying changes: \n${this.dumpSpareRid()}\n${this.dumpDataItems()}`);
1654    }
1655
1656    private purgeNode(rid: number): void {
1657        stateMgmtConsole.debug(`delete node rid: ${rid}.`);
1658        this.meta4Rid_.delete(rid);
1659        this.spareRid_.delete(rid);
1660        RepeatVirtualScroll2Native.removeNode(rid);
1661    }
1662
1663    private dumpSpareRid(): string {
1664        return `spareRid size: ${this.spareRid_.size} ` +
1665            `${JSON.stringify(Array.from(this.spareRid_).map(rid => `rid: ${rid}`))}.`;
1666    }
1667
1668    private dumpRepeatItem4Rid(): string {
1669        return `meta4Rid_ size: ${this.meta4Rid_.size}: ${JSON.stringify(Array.from(this.meta4Rid_))}.`;
1670    }
1671
1672    private dumpDataItems(): string {
1673        let result = ``;
1674        let sepa = '';
1675        let count = 0;
1676        for (const index in this.activeDataItems_) {
1677            const dataItemDump = this.activeDataItems_[index].dump();
1678            const repeatItemIndex = this.activeDataItems_[index].rid
1679                ? this.meta4Rid_.get(this.activeDataItems_[index].rid)?.repeatItem_?.index
1680                : 'N/A';
1681            result += `${sepa}index ${index}, ${dataItemDump} (repeatItemIndex ${repeatItemIndex})`;
1682            sepa = ', \n';
1683            count += 1;
1684        }
1685        return `activeDataItems(array): length: ${this.activeDataItems_.length}, ` +
1686            `range: [${this.activeRange_[0]}-${this.activeRange_[1]}], ` +
1687            `entries count: ${count} =============\n${result}`;
1688    }
1689
1690    private dumpCachedCount(): string {
1691        let result = '';
1692        let sepa = '';
1693        Object.entries(this.templateOptions_).forEach((pair) => {
1694            const options: RepeatTemplateImplOptions = pair[1];
1695            result += `${sepa}'template ${pair[0]}': specified: ${options.cachedCountSpecified} ` +
1696                `cachedCount: ${options.cachedCount}: `;
1697            sepa = ', ';
1698        });
1699        return result;
1700    }
1701
1702    private dumpKeys(): string {
1703        let result = '';
1704        let sepa = '';
1705        this.key4Index_.forEach((key, index) => {
1706            result += `${sepa}${index}: ${key}`;
1707            sepa = '\n';
1708        });
1709        return result;
1710    }
1711};