Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions apps/automated/src/ui/lifecycle/lifecycle-tests.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as helper from '../../ui-helper';
import * as btnCounter from './pages/button-counter';
import * as TKUnit from '../../tk-unit';
import { NativeUpdates } from '@nativescript/core';

// Integration tests that asser sertain runtime behavior, lifecycle events atc.

Expand Down Expand Up @@ -74,6 +75,72 @@ export function test_setting_one_property_while_suspedned_does_not_call_other_pr
TKUnit.assertEqual(btn1.fontInternalSetNativeCount, 2, 'fontInternal.setNative at step4');
}

export function test_native_updates_batch_coalesces_like_batch_update() {
const page = helper.navigateToModule('ui/lifecycle/pages/page-one');
const btn1 = page.getViewById<btnCounter.Button>('btn1');

TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 1, 'backgroundInternal.setNative at step1');
TKUnit.assertEqual(btn1.fontInternalSetNativeCount, 1, 'fontInternal.setNative at step1');

NativeUpdates.batch(() => {
// None
});

TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 1, 'backgroundInternal.setNative at step2');
TKUnit.assertEqual(btn1.fontInternalSetNativeCount, 1, 'fontInternal.setNative at step2');

NativeUpdates.batch(() => {
btn1.style.borderWidth = '22';
});

TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 2, 'backgroundInternal.setNative at step3');
TKUnit.assertEqual(btn1.fontInternalSetNativeCount, 1, 'fontInternal.setNative at step3');

NativeUpdates.batch(() => {
btn1.style.fontSize = 69;
});

TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 2, 'backgroundInternal.setNative at step4');
TKUnit.assertEqual(btn1.fontInternalSetNativeCount, 2, 'fontInternal.setNative at step4');
}

export function test_flush_native_updates_pushes_what_a_batch_is_holding() {
const page = helper.navigateToModule('ui/lifecycle/pages/page-one');
const btn1 = page.getViewById<btnCounter.Button>('btn1');

TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 1, 'backgroundInternal.setNative after inflation');

NativeUpdates.batch(() => {
btn1.style.borderWidth = '22';
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 1, 'nothing is pushed while the batch is open');

TKUnit.assertTrue(btn1.flushNativeUpdates(), 'the flush should reach the native view');
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 2, 'the flush pushes what is pending');
});

TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 2, 'closing the batch does not push again');
}

export function test_flush_native_updates_force_pushes_while_unloaded() {
const page = helper.navigateToModule('ui/lifecycle/pages/page-one');
// btn1 matches no selector in page-one.css, so loading it back does not write css values.
const btn1 = page.getViewById<btnCounter.Button>('btn1');

TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 1, 'backgroundInternal.setNative after inflation');

btn1.callUnloaded();
btn1.style.borderWidth = '22';

TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 1, 'nothing is pushed while unloaded');

TKUnit.assertTrue(btn1.flushNativeUpdates({ force: true }), 'a forced flush should reach the existing native view');
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 2, 'the forced flush pushes what is pending');

btn1.callLoaded();

TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 2, 'loading does not push the flushed value again');
}

//
// Commented out because in webpack5 css loading has been rewritten, and does not use page.css
//
Expand Down
117 changes: 117 additions & 0 deletions packages/core/ui/core/native-updates/batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { ViewBase } from '../view-base';
import type { CssAnimationProperty, CssProperty, Property } from '../properties';
import type { Invalidation } from './invalidation';

/** A change to a node's child list. Phase 0 never records one. */
export interface ChildMutation {
readonly kind: 'insert' | 'remove' | 'move';
readonly child: ViewBase;
readonly index: number;
}

export type NativeUpdateProperty = Property<any, any> | CssProperty<any, any> | CssAnimationProperty<any, any>;

/** What a commit applies: a property, through its `[setNative]`, or an aggregate invalidation. */
export type NativeUpdateEntry = NativeUpdateProperty | Invalidation;

export type NativeUpdateApplier = (batch: NativeUpdateBatch, entry: NativeUpdateEntry) => void;

const NO_CHILDREN: readonly ChildMutation[] = Object.freeze([]);

/**
* Everything that is dirty on one node at commit time, in the order the commit will apply it.
* Handed to `ViewBase.commitNativeUpdates`, which may reorder, pre-empt or drop entries.
*/
export class NativeUpdateBatch {
/** Every value the node carries is dirty: it is being applied to a native view for the first time. */
public readonly isMount: boolean;
public readonly children: readonly ChildMutation[] = NO_CHILDREN;

private readonly _entries: NativeUpdateEntry[];
private readonly _handled: boolean[];
private readonly _apply: NativeUpdateApplier;
private readonly _previous: Map<NativeUpdateProperty, unknown> | undefined;
Comment thread
farfromrefug marked this conversation as resolved.
private _index: Map<NativeUpdateEntry, number> | undefined;

constructor(
public readonly node: ViewBase,
isMount: boolean,
entries: NativeUpdateEntry[],
previous: Map<NativeUpdateProperty, unknown> | undefined,
apply: NativeUpdateApplier,
) {
this.isMount = isMount;
this._entries = entries;
this._handled = new Array(entries.length).fill(false);
this._previous = previous;
this._apply = apply;
}

/** Everything this commit applies, in commit order; `has()` tells whether an entry is still pending. */
public get entries(): ReadonlyArray<NativeUpdateEntry> {
return this._entries;
}

/** Whether the entry is still waiting to be applied by this commit. */
public has(entry: NativeUpdateEntry): boolean {
const index = this.indexOf(entry);

return index >= 0 && !this._handled[index];
}

/** The value the property held at the last commit; `undefined` on a mount. */
public previous<T>(property: NativeUpdateProperty): T | undefined {
return this._previous?.get(property) as T | undefined;
}

/** Runs the entry's handler now. An entry that is not pending, or already handled, is a no-op. */
public apply(entry: NativeUpdateEntry): void {
const index = this.indexOf(entry);
if (index < 0 || this._handled[index]) {
return;
}

this._handled[index] = true;
this._apply(this, this._entries[index]);
}

/** Marks the entry handled without touching the native view. */
public skip(entry: NativeUpdateEntry): void {
const index = this.indexOf(entry);
if (index >= 0) {
this._handled[index] = true;
}
}

/**
* Applies everything still pending, in commit order.
* @private
*/
public _applyRemaining(): void {
const entries = this._entries;
const handled = this._handled;

for (let i = 0, length = entries.length; i < length; i++) {
// A handler may apply later entries itself, so the flag is re-read on every step.
if (!handled[i]) {
handled[i] = true;
this._apply(this, entries[i]);
}
}
}

private indexOf(entry: NativeUpdateEntry): number {
let index = this._index;
if (!index) {
index = this._index = new Map<NativeUpdateEntry, number>();
const entries = this._entries;
for (let i = 0, length = entries.length; i < length; i++) {
index.set(entries[i], i);
}
}

const found = index.get(entry);

return found === undefined ? -1 : found;
}
}
6 changes: 6 additions & 0 deletions packages/core/ui/core/native-updates/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { Invalidation, InvalidationPhase } from './invalidation';
export type { InvalidationOptions } from './invalidation';
export { NativeUpdateBatch } from './batch';
export type { ChildMutation, NativeUpdateApplier, NativeUpdateProperty, NativeUpdateEntry } from './batch';
export { NativeUpdates } from './scheduler';
export type { FlushNativeUpdatesOptions, NativeUpdatesMode } from './scheduler';
41 changes: 41 additions & 0 deletions packages/core/ui/core/native-updates/invalidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* The order a commit applies invalidations in. Phase 0 carries the phase but does not order by
* it yet: nothing in core declares an invalidation, so every batch is properties then aggregates.
*/
export enum InvalidationPhase {
Style,
Structure,
Mount,
Props,
Content,
Layout,
Paint,
}

export interface InvalidationOptions {
readonly phase?: InvalidationPhase;
/** The handler needs a laid out size, so a commit may have to defer it until there is one. */
readonly needsBounds?: boolean;
}

/**
* A named dirty flag several properties can share, so that one native rebuild serves all of them.
* A node handles it with `[invalidation.apply](batch)`, mirroring `[property.setNative](value)`.
*/
export class Invalidation {
public readonly name: string;
public readonly apply: symbol;
public readonly phase: InvalidationPhase;
public readonly needsBounds: boolean;

constructor(name: string, options?: InvalidationOptions) {
this.name = name;
this.apply = Symbol(`${name}:applyInvalidation`);
this.phase = options?.phase ?? InvalidationPhase.Props;
this.needsBounds = options?.needsBounds ?? false;
}

public toString(): string {
return `Invalidation(${this.name})`;
}
}
Loading
Loading