Skip to content

Commit e3a2ff0

Browse files
committed
feat(core): add NativeUpdates scheduler and flushNativeUpdates
batch()/begin()/end() coalesce the writes made to each node into one commit, and flushNativeUpdates pushes what is pending, optionally past the loaded hold.
1 parent 14a36be commit e3a2ff0

8 files changed

Lines changed: 413 additions & 20 deletions

File tree

apps/automated/src/ui/lifecycle/lifecycle-tests.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as helper from '../../ui-helper';
22
import * as btnCounter from './pages/button-counter';
33
import * as TKUnit from '../../tk-unit';
4+
import { NativeUpdates } from '@nativescript/core';
45

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

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

78+
export function test_native_updates_batch_coalesces_like_batch_update() {
79+
const page = helper.navigateToModule('ui/lifecycle/pages/page-one');
80+
const btn1 = page.getViewById<btnCounter.Button>('btn1');
81+
82+
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 1, 'backgroundInternal.setNative at step1');
83+
TKUnit.assertEqual(btn1.fontInternalSetNativeCount, 1, 'fontInternal.setNative at step1');
84+
85+
NativeUpdates.batch(() => {
86+
// None
87+
});
88+
89+
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 1, 'backgroundInternal.setNative at step2');
90+
TKUnit.assertEqual(btn1.fontInternalSetNativeCount, 1, 'fontInternal.setNative at step2');
91+
92+
NativeUpdates.batch(() => {
93+
btn1.style.borderWidth = '22';
94+
});
95+
96+
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 2, 'backgroundInternal.setNative at step3');
97+
TKUnit.assertEqual(btn1.fontInternalSetNativeCount, 1, 'fontInternal.setNative at step3');
98+
99+
NativeUpdates.batch(() => {
100+
btn1.style.fontSize = 69;
101+
});
102+
103+
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 2, 'backgroundInternal.setNative at step4');
104+
TKUnit.assertEqual(btn1.fontInternalSetNativeCount, 2, 'fontInternal.setNative at step4');
105+
}
106+
107+
export function test_flush_native_updates_pushes_what_a_batch_is_holding() {
108+
const page = helper.navigateToModule('ui/lifecycle/pages/page-one');
109+
const btn1 = page.getViewById<btnCounter.Button>('btn1');
110+
111+
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 1, 'backgroundInternal.setNative after inflation');
112+
113+
NativeUpdates.batch(() => {
114+
btn1.style.borderWidth = '22';
115+
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 1, 'nothing is pushed while the batch is open');
116+
117+
TKUnit.assertTrue(btn1.flushNativeUpdates(), 'the flush should reach the native view');
118+
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 2, 'the flush pushes what is pending');
119+
});
120+
121+
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 2, 'closing the batch does not push again');
122+
}
123+
124+
export function test_flush_native_updates_force_pushes_while_unloaded() {
125+
const page = helper.navigateToModule('ui/lifecycle/pages/page-one');
126+
// btn1 matches no selector in page-one.css, so loading it back does not write css values.
127+
const btn1 = page.getViewById<btnCounter.Button>('btn1');
128+
129+
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 1, 'backgroundInternal.setNative after inflation');
130+
131+
btn1.callUnloaded();
132+
btn1.style.borderWidth = '22';
133+
134+
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 1, 'nothing is pushed while unloaded');
135+
136+
TKUnit.assertTrue(btn1.flushNativeUpdates({ force: true }), 'a forced flush should reach the existing native view');
137+
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 2, 'the forced flush pushes what is pending');
138+
139+
btn1.callLoaded();
140+
141+
TKUnit.assertEqual(btn1.backgroundInternalSetNativeCount, 2, 'loading does not push the flushed value again');
142+
}
143+
77144
//
78145
// Commented out because in webpack5 css loading has been rewritten, and does not use page.css
79146
//

packages/core/ui/core/native-updates/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ export { Invalidation, InvalidationPhase } from './invalidation';
22
export type { InvalidationOptions } from './invalidation';
33
export { NativeUpdateBatch } from './batch';
44
export type { ChildMutation, NativeUpdateApplier, NativeUpdateProperty, NativeUpdateTarget } from './batch';
5+
export { NativeUpdates } from './scheduler';
6+
export type { FlushNativeUpdatesOptions, NativeUpdatesMode } from './scheduler';

packages/core/ui/core/native-updates/native-updates.spec.ts

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { View } from '../view';
44
import { Style } from '../../styling/style';
55
import { CssProperty, Property } from '../properties';
66
import { NativeUpdateBatch } from './batch';
7+
import { NativeUpdates } from './scheduler';
8+
9+
/** `SuspendType.Loaded`; the enum is internal but the bit is part of the field's contract. */
10+
const Loaded = 1 << 20;
711

812
const log: string[] = [];
913

@@ -144,14 +148,25 @@ describe('the batch', () => {
144148
}
145149

146150
it('reports what is pending and what was handled', () => {
147-
const view: any = loaded(new OrderedView());
151+
const seen: boolean[] = [];
152+
153+
class ReportingView extends TestView {
154+
public commitNativeUpdates(batch: NativeUpdateBatch): void {
155+
this.commits.push(batch);
156+
seen.push(batch.has(oneProperty), batch.has(twoProperty));
157+
batch.apply(oneProperty);
158+
seen.push(batch.has(oneProperty));
159+
super.commitNativeUpdates(batch);
160+
seen.push(batch.has(oneProperty));
161+
}
162+
}
148163

164+
const view: any = loaded(new ReportingView());
165+
seen.length = 0;
149166
view.one = 'a';
150167

151-
const batch = commitOf(view);
152-
expect(batch.node).toBe(view);
153-
expect(batch.has(oneProperty)).toBe(false);
154-
expect(batch.has(twoProperty)).toBe(false);
168+
expect(commitOf(view).node).toBe(view);
169+
expect(seen).toEqual([true, false, false, false]);
155170
});
156171

157172
it('is a mount batch on the first commit only', () => {
@@ -217,3 +232,145 @@ describe('the batch', () => {
217232
expect(Object.isFrozen(children)).toBe(true);
218233
});
219234
});
235+
236+
describe('the scheduler', () => {
237+
it('only implements the sync mode', () => {
238+
expect(NativeUpdates.mode).toBe('sync');
239+
240+
NativeUpdates.mode = 'sync';
241+
expect(NativeUpdates.mode).toBe('sync');
242+
243+
expect(() => {
244+
(<any>NativeUpdates).mode = 'microtask';
245+
}).toThrow(/'sync' is the only mode/);
246+
});
247+
248+
it('coalesces the writes made to a node inside a batch', () => {
249+
const view: any = loaded(new TestView());
250+
251+
NativeUpdates.batch(() => {
252+
view.two = 'b';
253+
view.one = 'a';
254+
view.two = 'b2';
255+
256+
expect(log).toEqual([]);
257+
expect(view._pendingPrevious).toBeUndefined();
258+
});
259+
260+
expect(log).toEqual(['two=b2', 'one=a']);
261+
expect(view._suspendNativeUpdatesCount).toBe(0);
262+
});
263+
264+
it('commits touched nodes in the order they were first written to', () => {
265+
const first: any = loaded(new TestView());
266+
const second: any = loaded(new TestView());
267+
268+
NativeUpdates.batch(() => {
269+
second.one = 'second';
270+
first.one = 'first';
271+
second.two = 'second-two';
272+
});
273+
274+
expect(log).toEqual(['one=second', 'two=second-two', 'one=first']);
275+
});
276+
277+
it('commits only when the outermost batch closes', () => {
278+
const view: any = loaded(new TestView());
279+
280+
NativeUpdates.batch(() => {
281+
NativeUpdates.batch(() => {
282+
view.one = 'a';
283+
});
284+
285+
expect(log).toEqual([]);
286+
expect(NativeUpdates._depth).toBe(1);
287+
});
288+
289+
expect(log).toEqual(['one=a']);
290+
expect(NativeUpdates._depth).toBe(0);
291+
});
292+
293+
it('closes the batch when the callback throws', () => {
294+
const view: any = loaded(new TestView());
295+
296+
expect(() =>
297+
NativeUpdates.batch(() => {
298+
view.one = 'a';
299+
throw new Error('boom');
300+
}),
301+
).toThrow('boom');
302+
303+
expect(log).toEqual(['one=a']);
304+
expect(NativeUpdates._depth).toBe(0);
305+
});
306+
307+
it('rejects an unmatched end', () => {
308+
expect(() => NativeUpdates.end()).toThrow(/without a matching begin/);
309+
});
310+
});
311+
312+
describe('flushNativeUpdates', () => {
313+
it('pushes what a batch is holding and leaves the batch open', () => {
314+
const view: any = loaded(new TestView());
315+
316+
NativeUpdates.batch(() => {
317+
view.one = 'a';
318+
expect(view.flushNativeUpdates()).toBe(true);
319+
expect(log).toEqual(['one=a']);
320+
expect(view._suspendNativeUpdatesCount).toBe(1);
321+
322+
view.two = 'b';
323+
});
324+
325+
expect(log).toEqual(['one=a', 'two=b']);
326+
});
327+
328+
it('reports nothing to push to when the node has no native view', () => {
329+
const view: any = new TestView();
330+
view.one = 'a';
331+
332+
expect(view.flushNativeUpdates()).toBe(false);
333+
expect(view.flushNativeUpdates({ force: true })).toBe(false);
334+
expect(log).toEqual([]);
335+
});
336+
337+
it('leaves an unloaded node alone without force', () => {
338+
const view: any = new TestView();
339+
view.one = 'a';
340+
view._setupUI({});
341+
342+
expect(view.flushNativeUpdates()).toBe(false);
343+
expect(log).toEqual([]);
344+
});
345+
346+
it('pushes to an unloaded node with force, keeping the holds', () => {
347+
const view: any = new TestView();
348+
view.one = 'a';
349+
view._setupUI({});
350+
351+
expect(view.flushNativeUpdates({ force: true })).toBe(true);
352+
expect(log).toEqual(['one=a']);
353+
expect(view._suspendNativeUpdatesCount).toBe(Loaded);
354+
355+
log.length = 0;
356+
view.two = 'b';
357+
view.callLoaded();
358+
359+
expect(log).toEqual(['two=b']);
360+
});
361+
362+
it('flushes a subtree after each node', () => {
363+
const parent: any = loaded(new TestView());
364+
const child: any = loaded(new TestView());
365+
parent.eachChildView = (callback: any) => callback(child);
366+
367+
NativeUpdates.batch(() => {
368+
child.one = 'child';
369+
parent.one = 'parent';
370+
371+
expect(NativeUpdates.flush(parent)).toBe(true);
372+
});
373+
374+
expect(log).toEqual(['one=parent', 'one=child']);
375+
});
376+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { ViewBase } from '../view-base';
2+
import { SuspendType } from '../view-base/suspend-type';
3+
4+
export type NativeUpdatesMode = 'sync';
5+
6+
export interface FlushNativeUpdatesOptions {
7+
/** Flush the node's descendants as well, each after its own host. */
8+
subtree?: boolean;
9+
/** Push to a native view that already exists even while the node is not loaded. */
10+
force?: boolean;
11+
}
12+
13+
let depth = 0;
14+
15+
/** The nodes an open batch is holding, in the order they were first written to. */
16+
const held = new Set<ViewBase>();
17+
18+
/**
19+
* Decides when native updates are committed. The only mode in this version is `'sync'`: every
20+
* write commits as it is made, except inside `batch()`/`begin()`…`end()`, which coalesces the
21+
* writes made to each node into one commit per node when the outermost batch closes.
22+
*/
23+
export const NativeUpdates = {
24+
/** Depth of the open batches; `0` means a write commits where it is made. @private */
25+
_depth: 0,
26+
27+
get mode(): NativeUpdatesMode {
28+
return 'sync';
29+
},
30+
31+
set mode(value: NativeUpdatesMode) {
32+
if (value !== 'sync') {
33+
throw new Error(`NativeUpdates.mode cannot be set to '${value}': 'sync' is the only mode this version implements.`);
34+
}
35+
},
36+
37+
/** Runs `callback` with commits coalesced per node. Nestable; only the outermost one commits. */
38+
batch<T>(callback: () => T): T {
39+
NativeUpdates.begin();
40+
try {
41+
return callback();
42+
} finally {
43+
NativeUpdates.end();
44+
}
45+
},
46+
47+
/** The unpaired form of `batch()`, for renderers that have their own begin/end hooks. */
48+
begin(): void {
49+
depth++;
50+
NativeUpdates._depth = depth;
51+
},
52+
53+
end(): void {
54+
if (depth === 0) {
55+
throw new Error('NativeUpdates.end() called without a matching begin().');
56+
}
57+
58+
depth--;
59+
NativeUpdates._depth = depth;
60+
61+
if (depth !== 0 || held.size === 0) {
62+
return;
63+
}
64+
65+
const nodes = Array.from(held);
66+
held.clear();
67+
68+
for (let i = 0, length = nodes.length; i < length; i++) {
69+
nodes[i]._resumeNativeUpdates(SuspendType.Incremental);
70+
}
71+
},
72+
73+
/**
74+
* Commits now: the node and, unless told otherwise, its subtree. Without a node, commits every
75+
* node the open batch is holding, leaving the batch open. Returns whether anything was pushed.
76+
*/
77+
flush(view?: ViewBase, options?: FlushNativeUpdatesOptions): boolean {
78+
if (view) {
79+
return view.flushNativeUpdates({ subtree: options?.subtree ?? true, force: options?.force });
80+
}
81+
82+
let flushed = false;
83+
for (const node of held) {
84+
flushed = node.flushNativeUpdates(options) || flushed;
85+
}
86+
87+
return flushed;
88+
},
89+
90+
/**
91+
* Holds the node for the open batch the first time it is written to.
92+
* @private
93+
*/
94+
_hold(view: ViewBase): void {
95+
if (!held.has(view)) {
96+
held.add(view);
97+
view._suspendNativeUpdates(SuspendType.Incremental);
98+
}
99+
},
100+
};

0 commit comments

Comments
 (0)