Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {isCustomElement, runOutsideAngular} from '../utils';

import {initializeOrGetDirectiveForestHooks} from '.';
import {DirectiveForestHooks} from './hooks';
import {IdentityTracker} from './identity-tracker';
import {Hooks} from './profiler';

let inProgress = false;
Expand All @@ -40,6 +41,7 @@ export const start = (onFrame: (frame: ProfilerFrame) => void): void => {
}
eventMap = new Map<any, DirectiveProfile>();
inProgress = true;
IdentityTracker.getInstance().setProfilingActive(true);
hooks = getHooks(onFrame);
initializeOrGetDirectiveForestHooks().profiler.subscribe(hooks);
};
Expand All @@ -50,6 +52,7 @@ export const stop = (): ProfilerFrame => {
initializeOrGetDirectiveForestHooks().profiler.unsubscribe(hooks);
hooks = {};
inProgress = false;
IdentityTracker.getInstance().setProfilingActive(false);
return result;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {IdentityTracker} from './identity-tracker';

describe('IdentityTracker', () => {
let tracker: IdentityTracker;

beforeEach(() => {
(IdentityTracker as any)._instance = undefined;
tracker = IdentityTracker.getInstance();
});

afterEach(() => {
(IdentityTracker as any)._instance = undefined;
document.querySelectorAll('[ng-version]').forEach((el) => el.remove());
});

describe('setProfilingActive', () => {
it('removes pending directives from all maps when profiling stops', () => {
const dir = {};
const internal = tracker as any;
internal._currentDirectiveId.set(dir, 0);
internal._currentDirectivePosition.set(dir, [0]);
internal.isComponent.set(dir, true);
internal._pendingRemovals.add(dir);

tracker.setProfilingActive(false);

expect(tracker.hasDirective(dir)).toBeFalse();
expect(tracker.getDirectiveId(dir)).toBeUndefined();
expect(tracker.getDirectivePosition(dir)).toBeUndefined();
});

it('does not flush pending removals when profiling starts', () => {
const dir = {};
const internal = tracker as any;
internal._currentDirectiveId.set(dir, 0);
internal._currentDirectivePosition.set(dir, [0]);
internal.isComponent.set(dir, true);
internal._pendingRemovals.add(dir);

tracker.setProfilingActive(true);

expect(tracker.hasDirective(dir)).toBeTrue();
expect(tracker.getDirectiveId(dir)).toBe(0);
expect(tracker.getDirectivePosition(dir)).toEqual([0]);
});

it('clears the pending set after flushing', () => {
const dir = {};
(tracker as any)._pendingRemovals.add(dir);

tracker.setProfilingActive(false);

expect((tracker as any)._pendingRemovals.size).toBe(0);
});

it('handles an empty pending set gracefully', () => {
expect(() => tracker.setProfilingActive(false)).not.toThrow();
});

it('flushes multiple pending directives at once', () => {
const dirs = [{}, {}, {}];
const internal = tracker as any;
dirs.forEach((dir, i) => {
internal._currentDirectiveId.set(dir, i);
internal._currentDirectivePosition.set(dir, [i]);
internal.isComponent.set(dir, false);
internal._pendingRemovals.add(dir);
});

tracker.setProfilingActive(false);

dirs.forEach((dir) => {
expect(tracker.hasDirective(dir)).toBeFalse();
});
expect(internal._pendingRemovals.size).toBe(0);
});
});

describe('index() cleanup behavior', () => {
function seedDirective(dir: object, id: number): void {
const internal = tracker as any;
internal._currentDirectiveId.set(dir, id);
internal._currentDirectivePosition.set(dir, [id]);
internal.isComponent.set(dir, false);
}

it('immediately removes a stale directive from all maps when not profiling', () => {
const dir = {};
seedDirective(dir, 0);

tracker.index();

expect(tracker.hasDirective(dir)).toBeFalse();
expect(tracker.getDirectiveId(dir)).toBeUndefined();
expect(tracker.getDirectivePosition(dir)).toBeUndefined();
});

it('keeps a stale directive in maps during profiling, staging it for deferred cleanup', () => {
const dir = {};
seedDirective(dir, 0);

tracker.setProfilingActive(true);
tracker.index();

expect(tracker.hasDirective(dir)).toBeTrue();
expect(tracker.getDirectiveId(dir)).toBe(0);
expect(tracker.getDirectivePosition(dir)).toEqual([0]);
expect((tracker as any)._pendingRemovals.has(dir)).toBeTrue();
});

it('removes deferred directives from maps once profiling stops', () => {
const dir = {};
seedDirective(dir, 0);

tracker.setProfilingActive(true);
tracker.index();

expect(tracker.hasDirective(dir)).toBeTrue();

tracker.setProfilingActive(false);

expect(tracker.hasDirective(dir)).toBeFalse();
});

it('includes removed directives in the returned removedNodes regardless of profiling state', () => {
const dir = {};
const internal = tracker as any;
internal._currentDirectiveId.set(dir, 0);
internal._currentDirectivePosition.set(dir, [0]);
internal.isComponent.set(dir, true);

const {removedNodes} = tracker.index();

expect(removedNodes.length).toBe(1);
expect(removedNodes[0].directive).toBe(dir);
expect(removedNodes[0].isComponent).toBeTrue();
});

it('does not add an already-pending directive to removedNodes twice on the next index call', () => {
const dir = {};
seedDirective(dir, 0);

tracker.setProfilingActive(true);
tracker.index();

const {removedNodes} = tracker.index();

expect(removedNodes.some((n) => n.directive === dir)).toBeTrue();
});

it('grows maps monotonically during profiling and fully clears on stop', () => {
const dirs = [{}, {}, {}];
dirs.forEach((dir, i) => seedDirective(dir, i));

tracker.setProfilingActive(true);
tracker.index();

dirs.forEach((dir) => expect(tracker.hasDirective(dir)).toBeTrue());

tracker.setProfilingActive(false);

dirs.forEach((dir) => expect(tracker.hasDirective(dir)).toBeFalse());
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ export class IdentityTracker {
private _currentDirectiveId = new Map<any, number>();
isComponent = new Map<any, boolean>();

/**
* Directives that were removed while profiling was active.
* Cleanup is deferred until profiling stops so that the profiler
* can still look up IDs / positions of destroyed components.
*/
private _pendingRemovals = new Set<any>();
private _isProfiling = false;

// private constructor for Singleton Pattern
private constructor() {}

Expand All @@ -56,6 +64,18 @@ export class IdentityTracker {
return this._currentDirectiveId.has(dir);
}

/**
* Toggle profiling state. While profiling is active, removed directive
* entries are kept so the profiler can still resolve IDs and positions.
* When profiling stops, deferred removals are flushed.
*/
setProfilingActive(active: boolean): void {
this._isProfiling = active;
if (!active) {
this._flushPendingRemovals();
}
}

index(): {
newNodes: NodeArray;
removedNodes: NodeArray;
Expand All @@ -71,10 +91,11 @@ export class IdentityTracker {
this._currentDirectiveId.forEach((_: number, dir: any) => {
if (!allNodes.has(dir)) {
removedNodes.push({directive: dir, isComponent: !!this.isComponent.get(dir)});
// We can't clean these up because during profiling
// they might be requested for removed components
// this._currentDirectiveId.delete(dir);
// this._currentDirectivePosition.delete(dir);
if (this._isProfiling) {
this._pendingRemovals.add(dir);
} else {
this._cleanupDirective(dir);
}
}
});
return {newNodes, removedNodes, indexedForest, directiveForest};
Expand Down Expand Up @@ -110,9 +131,25 @@ export class IdentityTracker {
}
}

private _cleanupDirective(dir: any): void {
this._currentDirectiveId.delete(dir);
this._currentDirectivePosition.delete(dir);
this.isComponent.delete(dir);
}

private _flushPendingRemovals(): void {
for (const dir of this._pendingRemovals) {
this._cleanupDirective(dir);
}
this._pendingRemovals.clear();
}

destroy(): void {
this._currentDirectivePosition = new Map<any, ElementPosition>();
this._currentDirectiveId = new Map<any, number>();
this.isComponent = new Map<any, boolean>();
this._pendingRemovals.clear();
this._isProfiling = false;
}
}

Expand Down
Loading