From 17b6fb1e73bd5615a6c898d6fb36e946ec276014 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 17 Jun 2026 16:50:57 -0300 Subject: [PATCH] feat: allow deferred attaching and applying of properties during CD --- .../src/tests/renderer-tests.spec.ts | 1382 +++++++++-------- .../angular/src/lib/deferred-renderer-ops.ts | 100 ++ .../angular/src/lib/nativescript-renderer.ts | 32 +- .../angular/src/lib/platform-nativescript.ts | 17 +- packages/angular/src/lib/tokens.ts | 11 + packages/angular/src/lib/view-util.ts | 67 +- 6 files changed, 914 insertions(+), 695 deletions(-) create mode 100644 packages/angular/src/lib/deferred-renderer-ops.ts diff --git a/apps/nativescript-demo-ng/src/tests/renderer-tests.spec.ts b/apps/nativescript-demo-ng/src/tests/renderer-tests.spec.ts index ed549ac7..1ad0f518 100644 --- a/apps/nativescript-demo-ng/src/tests/renderer-tests.spec.ts +++ b/apps/nativescript-demo-ng/src/tests/renderer-tests.spec.ts @@ -1,688 +1,694 @@ -// // make sure you import mocha-config before @angular/core - -// import { assert } from "./test-config"; -// import { Component, ComponentRef, ElementRef, NgZone, Renderer2, ViewChild } from "@angular/core"; -// import { ProxyViewContainer, LayoutBase, StackLayout, ContentView, Button, isIOS, View, Label } from "@nativescript/core"; -// import { Red } from "@nativescript/core/color/known-colors"; -// import { dumpView } from "./test-utils"; -// import { registerElement } from "@nativescript/angular"; -// import { fontInternalProperty, backgroundInternalProperty } from "@nativescript/core/ui/core/view" -// import { nsTestBedAfterEach, nsTestBedBeforeEach, nsTestBedRender } from "@nativescript/angular/testing"; -// import { ComponentFixture, TestBed, async } from "@angular/core/testing"; -// import { Observable, ReplaySubject } from "rxjs"; - -// @Component({ -// template: `` -// }) -// export class ZonedRenderer { -// constructor(public elementRef: ElementRef, public renderer: Renderer2) { } -// } - -// @Component({ -// template: `` -// }) -// export class LayoutWithLabel { -// constructor(public elementRef: ElementRef) { } -// } - -// @Component({ -// selector: "label-cmp", -// template: `` -// }) -// export class LabelCmp { -// constructor(public elementRef: ElementRef) { -// } -// } - -// @Component({ -// template: `` -// }) -// export class LabelContainer { -// constructor(public elementRef: ElementRef) { } -// } - -// @Component({ -// selector: "projectable-cmp", -// template: `` -// }) -// export class ProjectableCmp { -// constructor(public elementRef: ElementRef) { -// } -// } -// @Component({ -// template: ` -// -// ` -// }) -// export class ProjectionContainer { -// constructor(public elementRef: ElementRef) { } -// } - -// @Component({ -// selector: "styled-label-cmp", -// styles: [ -// "Label { color: red; }", -// ], -// template: `` -// }) -// export class StyledLabelCmp { -// constructor(public elementRef: ElementRef) { -// } -// } - -// @Component({ -// selector: "styled-label-cmp2", -// styles: [ -// `Label { color: red; }`, -// ` -// StackLayout { color: brown; } -// TextField { color: red; background-color: lime; } -// `, -// ], -// template: ` -// -// -// -// -// ` -// }) -// export class StyledLabelCmp2 { -// constructor(public elementRef: ElementRef) { -// } -// } - -// @Component({ -// selector: "host-styled", -// styles: [` -// Label { -// color: blue; -// } - -// :host Label { -// color: red; -// } -// ` -// ], -// template: `` -// }) -// export class HostStyledCmp { -// constructor(public elementRef: ElementRef) { -// } -// } - -// @Component({ -// selector: "host-styled-parent", -// template: ` -// -// -// ` -// }) -// export class HostStyledParentCmp { -// constructor(public elementRef: ElementRef) { -// } -// } - -// @Component({ -// selector: "ng-if-label", -// template: `` -// }) -// export class NgIfLabel { -// public show: boolean = false; -// constructor(public elementRef: ElementRef) { -// } -// } - -// @Component({ -// selector: "ng-if-two-elements", -// template: ` -// -// -// -// -// ` -// }) -// export class NgIfTwoElements { -// public show: boolean = false; -// constructor(public elementRef: ElementRef) { -// } -// } - -// @Component({ -// selector: "ng-if-multiple", -// template: ` -// -// -// -// -// -// -// -// ` -// }) -// export class NgIfMultiple { -// public show: boolean = false; -// constructor(public elementRef: ElementRef) { -// } -// } - -// @Component({ -// selector: "ng-if-else", -// template: ` -// -// - -// -// -// -// -// ` -// }) -// export class NgIfElseComponent { -// public show: boolean = true; -// constructor(public elementRef: ElementRef) { -// } -// } - -// @Component({ -// selector: "ng-if-then-else", -// template: ` -// -// -// - -// -// -// - -// -// -// -// -// ` -// }) -// export class NgIfThenElseComponent { -// public show: boolean = true; -// constructor(public elementRef: ElementRef) { -// } -// } - -// export class ButtonCounter extends Button { -// nativeBackgroundRedraws = 0; -// backgroundInternalSetNativeCount = 0; -// fontInternalSetNativeCount = 0; - -// [backgroundInternalProperty.setNative](value) { -// this.backgroundInternalSetNativeCount++; -// return super[backgroundInternalProperty.setNative](value); -// } -// [fontInternalProperty.setNative](value) { -// this.fontInternalSetNativeCount++; -// return super[fontInternalProperty.setNative](value); -// } -// _redrawNativeBackground(value: any): void { -// this.nativeBackgroundRedraws++; -// super["_redrawNativeBackground"](value); -// } -// } -// registerElement("ButtonCounter", () => ButtonCounter); - -// @Component({ -// selector: "ng-control-setters-count", -// template: ` -// -// -// -// -// -// -// `, -// styles: [` -// #btn2, #btn3, #btn4 { -// border-width: 2; -// border-color: teal; -// border-radius: 20; -// font-weight: 400; -// font-size: 32; -// }`] -// }) -// export class NgControlSettersCount { -// @ViewChild("btn1", { static: false }) btn1: ElementRef; -// @ViewChild("btn2", { static: false }) btn2: ElementRef; -// @ViewChild("btn3", { static: false }) btn3: ElementRef; -// @ViewChild("btn3", { static: false }) btn4: ElementRef; - -// get buttons(): ElementRef[] { return [this.btn1, this.btn2, this.btn3, this.btn4]; } - -// ready$: Observable = new ReplaySubject(1); - -// ngAfterViewInit() { -// (this.ready$ as ReplaySubject).next(true); -// } -// } - -// @Component({ -// selector: "ng-for-label", -// template: `` -// }) -// export class NgForLabel { -// public items: Array = ["one", "two", "three"]; -// constructor(public elementRef: ElementRef) { -// } -// } - -// describe("Renderer E2E", () => { -// beforeEach(nsTestBedBeforeEach([ -// LayoutWithLabel, LabelCmp, LabelContainer, -// ProjectableCmp, ProjectionContainer, -// StyledLabelCmp, StyledLabelCmp2, -// HostStyledCmp, HostStyledParentCmp, -// NgIfLabel, NgIfThenElseComponent, NgIfMultiple, -// NgIfTwoElements, NgIfMultiple, -// NgIfElseComponent, NgIfThenElseComponent, -// NgForLabel, ZonedRenderer -// ])); -// afterEach(nsTestBedAfterEach(false)); - -// it("component with a layout", () => { -// return nsTestBedRender(LayoutWithLabel).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const componentRoot = componentRef.instance.elementRef.nativeElement; -// assert.equal("(proxyviewcontainer (stacklayout (label)))", dumpView(componentRoot)); -// }); -// }); - -// it("component without a layout", () => { -// return nsTestBedRender(LabelContainer).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const componentRoot = componentRef.instance.elementRef.nativeElement; -// assert.equal("(proxyviewcontainer (gridlayout (proxyviewcontainer (label))))", dumpView(componentRoot)); -// }); -// }); - -// it("projects content into components", () => { -// return nsTestBedRender(ProjectionContainer).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const componentRoot = componentRef.instance.elementRef.nativeElement; -// assert.equal( -// "(proxyviewcontainer (gridlayout (proxyviewcontainer (stacklayout (button)))))", -// dumpView(componentRoot)); -// }); -// }); - -// it("applies component styles from single source", () => { -// return nsTestBedRender(StyledLabelCmp).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const componentRoot = componentRef.instance.elementRef.nativeElement; -// const label = (componentRoot).getChildAt(0); -// assert.equal(Red, label.style.color.hex); -// }); -// }); - -// it("applies component :host styles", () => { -// return nsTestBedRender(HostStyledParentCmp).then((fixture) => { -// const proxyView = fixture.componentRef.instance.elementRef.nativeElement; - -// for (let i = 0; i < 2; i += 1) { -// const child = proxyView.getChildAt(i) as ProxyViewContainer; -// const label = child.getChildAt(0) as Label; -// assert.equal(Red, label.style.color.hex); -// } -// }); -// }); - -// it("applies component styles from multiple sources", () => { -// return nsTestBedRender(StyledLabelCmp2).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const componentRoot = componentRef.instance.elementRef.nativeElement; -// const layout = (componentRoot).getChildAt(0); - -// const label = (layout).getChildAt(0); -// assert.equal(Red, label.style.color.hex); - -// const textField = (layout).getChildAt(1); -// console.log("TEXT style.color: " + textField.style.color); -// assert.equal(Red, textField.style.color.hex); -// }); -// }); - -// it("executes events inside NgZone when listen is called inside NgZone", async(() => { -// const eventName = "someEvent"; -// const view = new StackLayout(); -// const eventArg = { eventName, object: view }; -// const callback = (arg) => { -// assert.equal(arg, eventArg); -// assert.isTrue(NgZone.isInAngularZone(), "Event should be executed inside NgZone"); -// }; - -// nsTestBedRender(ZonedRenderer).then((fixture: ComponentFixture) => { -// fixture.ngZone.run(() => { -// fixture.componentInstance.renderer.listen(view, eventName, callback); -// }); - -// setTimeout(() => { -// fixture.ngZone.runOutsideAngular(() => { -// view.notify(eventArg); -// }); -// }, 10); -// }); - -// })); - -// it("executes events inside NgZone when listen is called outside NgZone", async(() => { -// const eventName = "someEvent"; -// const view = new StackLayout(); -// const eventArg = { eventName, object: view }; -// const callback = (arg) => { -// assert.equal(arg, eventArg); -// assert.isTrue(NgZone.isInAngularZone(), "Event should be executed inside NgZone"); -// }; -// nsTestBedRender(ZonedRenderer).then((fixture: ComponentFixture) => { -// fixture.ngZone.runOutsideAngular(() => { -// fixture.componentInstance.renderer.listen(view, eventName, callback); - -// view.notify(eventArg); -// }); -// }); -// })); - -// describe("Structural directives", () => { -// it("ngIf hides component when false", () => { -// return nsTestBedRender(NgIfLabel).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const componentRoot = componentRef.instance.elementRef.nativeElement; -// assert.equal("(proxyviewcontainer)", dumpView(componentRoot)); -// }); -// }); - -// it("ngIf show component when true", () => { -// return nsTestBedRender(NgIfLabel).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const component = componentRef.instance; -// const componentRoot = component.elementRef.nativeElement; - -// component.show = true; -// fixture.detectChanges(); -// assert.equal("(proxyviewcontainer (label))", dumpView(componentRoot)); -// }); -// }); - -// it("ngIf shows elements in correct order when two are rendered", () => { -// return nsTestBedRender(NgIfTwoElements).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const component = componentRef.instance; -// const componentRoot = component.elementRef.nativeElement; - -// component.show = true; -// fixture.detectChanges(); -// assert.equal( -// "(proxyviewcontainer (stacklayout (label), (button)))", -// dumpView(componentRoot)); -// }); -// }); - -// it("ngIf shows elements in correct order when multiple are rendered and there's *ngIf", () => { -// return nsTestBedRender(NgIfMultiple).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const component = componentRef.instance; -// const componentRoot = component.elementRef.nativeElement; - -// component.show = true; -// fixture.detectChanges(); -// assert.equal( -// "(proxyviewcontainer " + -// "(stacklayout " + -// "(label[text=1]), " + -// "(label[text=2]), " + -// "(label[text=3]), " + -// "(label[text=4]), " + // the content to be conditionally displayed -// "(label[text=5])" + -// ")" + -// ")", -// dumpView(componentRoot, true)); -// }); -// }); - -// it("ngIfElse show 'if' template when condition is true", () => { -// return nsTestBedRender(NgIfElseComponent).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const component = componentRef.instance; -// const componentRoot = component.elementRef.nativeElement; - -// fixture.detectChanges(); - -// assert.equal( -// "(proxyviewcontainer " + -// "(stacklayout " + -// "(label[text=If])" + -// ")" + -// ")", - -// dumpView(componentRoot, true)); -// }); -// }); - -// it("ngIfElse show 'else' template when condition is false", () => { -// return nsTestBedRender(NgIfElseComponent).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const component = componentRef.instance; -// const componentRoot = component.elementRef.nativeElement; - -// component.show = false; -// fixture.detectChanges(); -// assert.equal( -// "(proxyviewcontainer " + -// "(stacklayout " + -// "(label[text=Else])" + -// ")" + -// ")", - -// dumpView(componentRoot, true)); -// }); -// }); - -// it("ngIfThenElse show 'then' template when condition is true", () => { -// return nsTestBedRender(NgIfThenElseComponent).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const component = componentRef.instance; -// const componentRoot = component.elementRef.nativeElement; - -// fixture.detectChanges(); -// assert.equal( -// "(proxyviewcontainer " + -// "(stacklayout " + -// "(label[text=Then])" + -// ")" + -// ")", - -// dumpView(componentRoot, true)); -// }); -// }); - -// it("ngIfThenElse show 'else' template when condition is false", () => { -// return nsTestBedRender(NgIfThenElseComponent).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const component = componentRef.instance; -// const componentRoot = component.elementRef.nativeElement; - -// component.show = false; -// fixture.detectChanges(); -// assert.equal( -// "(proxyviewcontainer " + -// "(stacklayout " + -// "(label[text=Else])" + -// ")" + -// ")", - -// dumpView(componentRoot, true)); -// }); -// }); - -// it("ngFor creates element for each item", () => { -// return nsTestBedRender(NgForLabel).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const componentRoot = componentRef.instance.elementRef.nativeElement; -// assert.equal( -// "(proxyviewcontainer (label[text=one]), (label[text=two]), (label[text=three]))", -// dumpView(componentRoot, true)); -// }); -// }); - -// it("ngFor updates when item is removed", () => { -// return nsTestBedRender(NgForLabel).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const component = componentRef.instance; -// const componentRoot = component.elementRef.nativeElement; - -// component.items.splice(1, 1); -// fixture.detectChanges(); - -// assert.equal( -// "(proxyviewcontainer (label[text=one]), (label[text=three]))", -// dumpView(componentRoot, true)); -// }); -// }); - -// it("ngFor updates when item is inserted", () => { -// return nsTestBedRender(NgForLabel).then((fixture) => { -// const componentRef: ComponentRef = fixture.componentRef; -// const component = componentRef.instance; -// const componentRoot = component.elementRef.nativeElement; - -// component.items.splice(1, 0, "new"); -// fixture.detectChanges(); - -// assert.equal( -// "(proxyviewcontainer " + -// "(label[text=one]), (label[text=new]), (label[text=two]), (label[text=three]))", -// dumpView(componentRoot, true)); -// }); -// }); -// }); -// }); - -// describe("Renderer createElement", () => { -// let renderer: Renderer2 = null; -// beforeEach(nsTestBedBeforeEach([ZonedRenderer])); -// afterEach(nsTestBedAfterEach(false)); -// beforeEach(() => { -// return nsTestBedRender(ZonedRenderer).then((fixture: ComponentFixture) => { -// fixture.ngZone.run(() => { -// renderer = fixture.componentInstance.renderer; -// }); -// }); -// }); - -// it("creates element from CamelCase", () => { -// const result = renderer.createElement("StackLayout"); -// assert.instanceOf(result, StackLayout, "Renderer should create StackLayout form 'StackLayout'"); -// }); - -// it("creates element from lowercase", () => { -// const result = renderer.createElement("stacklayout"); -// assert.instanceOf(result, StackLayout, "Renderer should create StackLayout form 'stacklayout'"); -// }); - -// it("creates element from kebab-case", () => { -// const result = renderer.createElement("stack-layout"); -// assert.instanceOf(result, StackLayout, "Renderer should create StackLayout form 'stack-layout'"); -// }); - -// it("creates ProxyViewContainer for unknownTag", () => { -// const result = renderer.createElement("unknown-tag"); -// assert.instanceOf(result, ProxyViewContainer, "Renderer should create ProxyViewContainer form 'unknown-tag'"); -// }); -// }); - -// describe("Renderer attach/detach", () => { -// let renderer: Renderer2 = null; -// beforeEach(nsTestBedBeforeEach([ZonedRenderer])); -// afterEach(nsTestBedAfterEach(false)); -// beforeEach(() => { -// return nsTestBedRender(ZonedRenderer).then((fixture: ComponentFixture) => { -// fixture.ngZone.run(() => { -// renderer = fixture.componentInstance.renderer; -// }); -// }); -// }); - -// it("createElement element with parent attaches element to content view", () => { -// const parent = renderer.createElement("ContentView"); -// const button = `, + imports: [NativeScriptCommonModule, ProjectableCmp], + schemas: [NO_ERRORS_SCHEMA], +}) +class ProjectionContainer { + elementRef = inject(ElementRef); +} + +describe('NativeScriptRenderer component structure', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [LayoutWithLabel, LabelContainer, ProjectionContainer], + schemas: [NO_ERRORS_SCHEMA], + }); + }); + + it('renders a component with a layout', () => { + const fixture = TestBed.createComponent(LayoutWithLabel); + fixture.detectChanges(); + expect(dumpView(fixture.componentInstance.elementRef.nativeElement)).toBe( + '(proxyviewcontainer (stacklayout (label)))', + ); + }); + + it('renders a component without a layout', () => { + const fixture = TestBed.createComponent(LabelContainer); + fixture.detectChanges(); + expect(dumpView(fixture.componentInstance.elementRef.nativeElement)).toBe( + '(proxyviewcontainer (gridlayout (proxyviewcontainer (label))))', + ); + }); + + it('projects content into a component', () => { + const fixture = TestBed.createComponent(ProjectionContainer); + fixture.detectChanges(); + expect(dumpView(fixture.componentInstance.elementRef.nativeElement)).toBe( + '(proxyviewcontainer (gridlayout (proxyviewcontainer (stacklayout (button)))))', + ); + }); +}); + +@Component({ + selector: 'styled-label', + styles: ['Label { color: red; }'], + template: ``, + imports: [NativeScriptCommonModule], + schemas: [NO_ERRORS_SCHEMA], +}) +class StyledLabelCmp { + elementRef = inject(ElementRef); +} + +@Component({ + selector: 'host-styled', + styles: [`Label { color: blue; } :host Label { color: red; }`], + template: ``, + imports: [NativeScriptCommonModule], + schemas: [NO_ERRORS_SCHEMA], +}) +class HostStyledCmp {} + +@Component({ + selector: 'host-styled-parent', + template: ``, + imports: [NativeScriptCommonModule, HostStyledCmp], + schemas: [NO_ERRORS_SCHEMA], +}) +class HostStyledParentCmp { + elementRef = inject(ElementRef); +} + +@Component({ + selector: 'styled-label2', + styles: ['Label { color: red; }', `StackLayout { color: brown; } TextField { color: red; background-color: lime; }`], + template: ` + + + `, + imports: [NativeScriptCommonModule], + schemas: [NO_ERRORS_SCHEMA], +}) +class StyledLabelCmp2 { + elementRef = inject(ElementRef); +} + +describe('NativeScriptRenderer component styles', () => { + const RED = '#FF0000'; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [StyledLabelCmp, HostStyledParentCmp, StyledLabelCmp2], + schemas: [NO_ERRORS_SCHEMA], + }); + }); + + it('applies component styles from a single source', () => { + const fixture = TestBed.createComponent(StyledLabelCmp); + fixture.detectChanges(); + const host = fixture.componentInstance.elementRef.nativeElement as ProxyViewContainer; + const label = host.getChildAt(0) as Label; + expect(label.style.color.hex).toBe(RED); + }); + + it('applies component :host styles', () => { + const fixture = TestBed.createComponent(HostStyledParentCmp); + fixture.detectChanges(); + const host = fixture.componentInstance.elementRef.nativeElement as ProxyViewContainer; + for (let i = 0; i < 2; i++) { + const child = host.getChildAt(i) as ProxyViewContainer; + const label = child.getChildAt(0) as Label; + expect(label.style.color.hex).toBe(RED); + } + }); + + it('applies component styles from multiple sources', () => { + const fixture = TestBed.createComponent(StyledLabelCmp2); + fixture.detectChanges(); + const host = fixture.componentInstance.elementRef.nativeElement as ProxyViewContainer; + const layout = host.getChildAt(0) as LayoutBase; + const label = layout.getChildAt(0) as Label; + const textField = layout.getChildAt(1) as TextField; + expect(label.style.color.hex).toBe(RED); + expect(textField.style.color.hex).toBe(RED); + }); +}); + +// --------------------------------------------------------------------------- +// View loading: with deferral enabled, a freshly attached view must still load +// exactly once (the feature batches the attach, it must not duplicate it). +// --------------------------------------------------------------------------- +class CounterLabel extends Label { + loadedCount = 0; + onLoaded() { + this.loadedCount++; + super.onLoaded(); + } +} +registerElement('CounterLabel', () => CounterLabel); + +@Component({ + selector: 'loads-once', + template: ``, + imports: [NativeScriptCommonModule], + schemas: [NO_ERRORS_SCHEMA], +}) +class LoadsOnceComp { + @ViewChild('c', { static: true, read: ElementRef }) c: ElementRef; +} + +describe('NativeScriptRenderer view loading (deferral enabled)', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [LoadsOnceComp], + providers: [{ provide: DEFER_NATIVE_OPS_DURING_CD, useValue: true }], + }); + }); + + it('loads a newly attached view exactly once', async () => { + const fixture = TestBed.createComponent(LoadsOnceComp); + fixture.detectChanges(); + await fixture.whenRenderingDone(); + expect(fixture.componentInstance.c.nativeElement.loadedCount).toBe(1); + }); +}); diff --git a/packages/angular/src/lib/deferred-renderer-ops.ts b/packages/angular/src/lib/deferred-renderer-ops.ts new file mode 100644 index 00000000..8beb1c05 --- /dev/null +++ b/packages/angular/src/lib/deferred-renderer-ops.ts @@ -0,0 +1,100 @@ +import { NgView } from './views'; + +/** + * Implemented by {@link ViewUtil}. The controller is intentionally decoupled + * from ViewUtil (it only knows this interface) so we don't create a circular + * import between the renderer and the view utilities. + */ +export interface VisualTreeFlusher { + flushVisualAdd(parent: NgView, child: NgView): void; +} + +/** + * Batches native side-effects produced while Angular is running change + * detection and applies them in a single pass when CD finishes. + * + * Only operations that touch the *native* layer are deferred: + * - native property/style/class application + * - attaching a view to the native (visual) tree, which is what triggers a + * view to actually load (create its native view + measure/layout) + * + * The *logical* tree (parentNode/firstChild/nextSibling/...) is always kept in + * sync synchronously by {@link ViewUtil}, because Angular reads it back during + * the same CD pass. We never defer that. + * + * Benefits of deferring to the end of CD: + * - a view created and removed within the same CD never loads natively at all + * - a freshly built subtree is attached (and therefore loaded) exactly once, + * instead of loading incrementally as each child is appended to a live parent + * - native property writes happen once, while the view is still unloaded + */ +export class DeferredRendererOps { + private _deferring = false; + get deferring(): boolean { + return this._deferring; + } + + /** Ordered native property/style/class/value writes. */ + private ops: Array<() => void> = []; + /** parent -> set of children awaiting native attach, plus the owning flusher. */ + private visualAdds = new Map }>(); + + /** Open a new deferral window. Flushes any leftovers from a CD pass that threw. */ + begin(): void { + if (this.ops.length || this.visualAdds.size) { + // A previous tick threw between begin() and end(); apply what it queued + // before starting a fresh window so nothing is lost. + this.flush(); + } + this._deferring = true; + } + + queueOp(op: () => void): void { + this.ops.push(op); + } + + queueVisualAdd(parent: NgView, child: NgView, flusher: VisualTreeFlusher): void { + let entry = this.visualAdds.get(parent); + if (!entry) { + entry = { flusher, children: new Set() }; + this.visualAdds.set(parent, entry); + } + entry.children.add(child); + } + + /** Cancel a pending attach (the child was removed/moved before the flush). */ + cancelVisualAdd(parent: NgView, child: NgView): void { + const entry = this.visualAdds.get(parent); + if (entry && entry.children.delete(child) && entry.children.size === 0) { + this.visualAdds.delete(parent); + } + } + + /** Apply every queued native operation, then clear the window. */ + flush(): void { + this._deferring = false; + const ops = this.ops; + const visualAdds = this.visualAdds; + this.ops = []; + this.visualAdds = new Map(); + + // 1) Apply native properties first, while views are still unloaded, so the + // subsequent attach loads each view with its final property values. + for (let i = 0; i < ops.length; i++) { + ops[i](); + } + + // 2) Attach subtrees. For each parent, walk its (now final) logical child + // list right-to-left so that when we attach a child, its next visual + // sibling (the insertion anchor) is already in the native tree. + for (const [parent, entry] of visualAdds) { + let node = parent.lastChild; + while (node) { + if (entry.children.has(node)) { + entry.flusher.flushVisualAdd(parent, node); + } + node = node.previousSibling; + } + } + } +} diff --git a/packages/angular/src/lib/nativescript-renderer.ts b/packages/angular/src/lib/nativescript-renderer.ts index 59ae7a51..fd0a009d 100644 --- a/packages/angular/src/lib/nativescript-renderer.ts +++ b/packages/angular/src/lib/nativescript-renderer.ts @@ -18,10 +18,12 @@ import { profile, View, } from '@nativescript/core'; +import { DeferredRendererOps } from './deferred-renderer-ops'; import { isKnownView } from './element-registry'; import { NAMESPACE_FILTERS } from './property-filter'; import { APP_ROOT_VIEW, + DEFER_NATIVE_OPS_DURING_CD, ENABLE_REUSABE_VIEWS, NATIVESCRIPT_ROOT_MODULE_ID, PREVENT_SPECIFIC_EVENTS_DURING_CD, @@ -64,6 +66,13 @@ function inRootZone() { providedIn: 'root', }) export class NativeScriptRendererHelperService { + /** + * Batches native side-effects produced during change detection. Shared across + * the renderer factory (which opens/flushes the window in begin()/end()) and + * all renderers (whose ViewUtil enqueues into it). Inert unless the factory + * opens a window, which it only does when DEFER_NATIVE_OPS_DURING_CD is set. + */ + readonly deferral = new DeferredRendererOps(); private _executingDomChanges = 0; get executingDomChanges() { return this._executingDomChanges; @@ -115,9 +124,11 @@ export class NativeScriptRendererFactory implements RendererFactory2 { optional: true, }); private wrapCdInTransaction = __APPLE__ && inject(WRAP_CD_IN_TRANSACTION); + private deferNativeOps = inject(DEFER_NATIVE_OPS_DURING_CD); + private rendererHelper = inject(NativeScriptRendererHelperService); private injector = inject(Injector); private cdDepth = 0; - private viewUtil = new ViewUtil(this.namespaceFilters, this.reuseViews); + private viewUtil = new ViewUtil(this.namespaceFilters, this.reuseViews, this.rendererHelper.deferral); constructor() { if (typeof this.reuseViews !== 'boolean') { @@ -168,6 +179,11 @@ export class NativeScriptRendererFactory implements RendererFactory2 { return renderer; } begin() { + if (this.deferNativeOps) { + // Open a deferral window: native property writes and view attaches issued + // during this CD pass are queued and applied in end(). + this.rendererHelper.deferral.begin(); + } if (__APPLE__ && this.wrapCdInTransaction) { if (this.cdDepth > 0) { // previous tick threw between begin and end; flush it @@ -181,6 +197,11 @@ export class NativeScriptRendererFactory implements RendererFactory2 { } } end() { + if (this.deferNativeOps) { + // Apply all queued native work before committing the transaction so the + // batched mutations land inside it. + this.rendererHelper.deferral.flush(); + } if (__APPLE__ && this.wrapCdInTransaction) { CATransaction.commit(); this.cdDepth--; @@ -226,8 +247,8 @@ class NativeScriptRenderer implements Renderer2 { private reuseViews = inject(ENABLE_REUSABE_VIEWS, { optional: true, }); - private viewUtil = new ViewUtil(this.namespaceFilters, this.reuseViews); _rendererHelper = inject(NativeScriptRendererHelperService); + private viewUtil = new ViewUtil(this.namespaceFilters, this.reuseViews, this._rendererHelper.deferral); private specificPreventedEvents = new Set( inject(PREVENT_SPECIFIC_EVENTS_DURING_CD, { optional: true, @@ -426,7 +447,12 @@ class NativeScriptRenderer implements Renderer2 { NativeScriptDebug.rendererLog(`NativeScriptRenderer.setValue renderNode: ${node}, value: ${value}`); } if (node instanceof TextNode) { - node.text = value; + const deferral = this._rendererHelper.deferral; + if (deferral.deferring) { + deferral.queueOp(() => (node.text = value)); + } else { + node.text = value; + } } // throw new Error("Method not implemented."); } diff --git a/packages/angular/src/lib/platform-nativescript.ts b/packages/angular/src/lib/platform-nativescript.ts index 321174f3..b4e6bbec 100644 --- a/packages/angular/src/lib/platform-nativescript.ts +++ b/packages/angular/src/lib/platform-nativescript.ts @@ -19,7 +19,13 @@ import { DOCUMENT, LocationChangeListener, LocationStrategy, PlatformLocation } import { NativeScriptPlatformRefProxy } from './platform-ref'; import { AppHostView } from './app-host-view'; import { Color, GridLayout } from '@nativescript/core'; -import { defaultPageFactory, ENABLE_REUSABE_VIEWS, PAGE_FACTORY, WRAP_CD_IN_TRANSACTION } from './tokens'; +import { + DEFER_NATIVE_OPS_DURING_CD, + defaultPageFactory, + ENABLE_REUSABE_VIEWS, + PAGE_FACTORY, + WRAP_CD_IN_TRANSACTION, +} from './tokens'; import { AppLaunchView } from './application'; import { NATIVESCRIPT_MODULE_PROVIDERS, NATIVESCRIPT_MODULE_STATIC_PROVIDERS } from './nativescript'; @@ -145,6 +151,12 @@ export interface BootstrapContext { export interface NativeScriptApplicationConfig extends ApplicationConfig { reusableViews?: boolean; + /** + * Batch native side-effects (property/style/class writes and view + * attaching/loading) produced during change detection and apply them once, + * after CD finishes. The logical tree Angular reads during CD stays in sync. + */ + deferNativeOpsDuringChangeDetection?: boolean; ios?: { wrapChangeDetectionInTransaction?: boolean; }; @@ -158,6 +170,9 @@ function createProvidersConfig(options?: NativeScriptApplicationConfig, context? if (options?.ios?.wrapChangeDetectionInTransaction) { nsProviders.push({ provide: WRAP_CD_IN_TRANSACTION, useValue: true }); } + if (options?.deferNativeOpsDuringChangeDetection) { + nsProviders.push({ provide: DEFER_NATIVE_OPS_DURING_CD, useValue: true }); + } return { platformRef: context?.platformRef, appProviders: [ diff --git a/packages/angular/src/lib/tokens.ts b/packages/angular/src/lib/tokens.ts index 97642d50..f74e4101 100644 --- a/packages/angular/src/lib/tokens.ts +++ b/packages/angular/src/lib/tokens.ts @@ -14,6 +14,17 @@ export const WRAP_CD_IN_TRANSACTION = new InjectionToken('NativeScriptW factory: () => false, }); +/** + * When enabled, native side-effects produced during change detection (applying + * native properties/styles/classes and attaching/loading native views) are + * batched and applied once, after CD finishes. The logical view tree Angular + * reads back during CD is always kept in sync synchronously. + */ +export const DEFER_NATIVE_OPS_DURING_CD = new InjectionToken('NativeScriptDeferNativeOpsDuringCd', { + providedIn: 'root', + factory: () => false, +}); + export type PageFactory = (options: PageFactoryOptions) => Page; export interface PageFactoryOptions { isBootstrap?: boolean; diff --git a/packages/angular/src/lib/view-util.ts b/packages/angular/src/lib/view-util.ts index 99bc8e5e..7f026772 100644 --- a/packages/angular/src/lib/view-util.ts +++ b/packages/angular/src/lib/view-util.ts @@ -1,4 +1,5 @@ import { unsetValue, View } from '@nativescript/core'; +import { DeferredRendererOps, VisualTreeFlusher } from './deferred-renderer-ops'; import { getViewClass, getViewMeta, isKnownView } from './element-registry'; import { NamespaceFilter } from './property-filter'; import { @@ -75,10 +76,11 @@ function printSiblingsTree(view: NgView) { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type const propertyMaps: Map> = new Map>(); -export class ViewUtil { +export class ViewUtil implements VisualTreeFlusher { constructor( private namespaceFilters?: NamespaceFilter[], private reuseViews?: boolean, + private deferral?: DeferredRendererOps, ) {} /** * Inserts a child into a parrent, preferably before next. @@ -118,8 +120,16 @@ export class ViewUtil { } if (!isDetachedElement(child)) { - const nextVisual = this.findNextVisual(next); - this.addToVisualTree(extendedParent, extendedChild, nextVisual); + if (this.deferral?.deferring) { + // Keep the logical parent correct synchronously (Angular reads + // `parentNode` back during CD); defer the native attach/load to the + // end of the CD pass. + extendedChild.parentNode = extendedParent; + this.deferral.queueVisualAdd(extendedParent, extendedChild, this); + } else { + const nextVisual = this.findNextVisual(next); + this.addToVisualTree(extendedParent, extendedChild, nextVisual); + } } else if (isInvisibleNode(extendedChild)) { const nextVisual = this.findNextVisual(next); this.addInvisibleNode(extendedParent, extendedChild, nextVisual); @@ -186,6 +196,20 @@ export class ViewUtil { } } + /** + * Applies a previously deferred native attach. Called by the deferral + * controller at the end of the CD pass, once the logical tree is final. + */ + public flushVisualAdd(parent: NgView, child: NgView): void { + // The child may have been moved to another parent (handled by that parent's + // own flush) or detached after it was queued. + if (child.parentNode !== parent || isDetachedElement(child)) { + return; + } + const nextVisual = this.findNextVisual(child.nextSibling); + this.addToVisualTree(parent, child, nextVisual); + } + private addInvisibleNode(parent: NgView, child: NgView, next: NgView): void { if (parent.meta?.insertInvisibleNode) { parent.meta.insertInvisibleNode(parent, child, next); @@ -236,6 +260,9 @@ export class ViewUtil { this.removeFromList(extendedParent, extendedChild); if (!isDetachedElement(extendedChild)) { + // If this child was queued for a (not yet applied) native attach, drop it. + // The immediate detach below is then a no-op since it was never attached. + this.deferral?.cancelVisualAdd(extendedParent, extendedChild); this.removeFromVisualTree(extendedParent, extendedChild); } else if (isInvisibleNode(extendedChild)) { this.removeInvisibleNode(extendedParent, extendedChild); @@ -425,7 +452,14 @@ export class ViewUtil { if (!view || (namespace && !this.runsIn(namespace))) { return; } + if (this.deferral?.deferring) { + this.deferral.queueOp(() => this.applyProperty(view, attributeName, value)); + return; + } + this.applyProperty(view, attributeName, value); + } + private applyProperty(view: NgView, attributeName: string, value: any): void { if (attributeName.indexOf('.') !== -1) { // Handle nested properties const properties = attributeName.split('.'); @@ -562,11 +596,30 @@ export class ViewUtil { } private syncClasses(view: NgView): void { + // The class map (source of truth) is already mutated synchronously above; + // only the native className write is deferred. It reads the final map at + // flush time, so coalescing add/remove within a CD pass is automatic. + if (this.deferral?.deferring) { + this.deferral.queueOp(() => this.applySyncClasses(view)); + return; + } + this.applySyncClasses(view); + } + + private applySyncClasses(view: NgView): void { const classValue = (Array).from(this.cssClasses(view).keys()).join(' '); view.className = classValue; } public setStyle(view: View, styleName: string, value: any) { + if (this.deferral?.deferring) { + this.deferral.queueOp(() => this.applyStyle(view, styleName, value)); + return; + } + this.applyStyle(view, styleName, value); + } + + private applyStyle(view: View, styleName: string, value: any) { if (isCssVariable(styleName)) { view.style.setUnscopedCssVariable(styleName, value); view._onCssStateChange(); @@ -576,6 +629,14 @@ export class ViewUtil { } public removeStyle(view: View, styleName: string) { + if (this.deferral?.deferring) { + this.deferral.queueOp(() => this.applyRemoveStyle(view, styleName)); + return; + } + this.applyRemoveStyle(view, styleName); + } + + private applyRemoveStyle(view: View, styleName: string) { if (isCssVariable(styleName)) { // TODO: expose this on core (view.style as any).unscopedCssVariables.delete(styleName);