Skip to content
Merged
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
81 changes: 81 additions & 0 deletions packages/devtools/src/__tests__/element-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { elementById, elementId, elementIdCount, pruneElementIds } from '../element-id.ts';

const attached = () => ({ isConnected: true });
const detached = () => ({ isConnected: false });

function fill(count: number, make: () => object = detached) {
for (let i = 0; i < count; i++) elementId(make());
}

describe('element ids', () => {
afterEach(() => {
vi.unstubAllGlobals();
pruneElementIds();
});

it('keeps the map bounded when elements are detached and nothing prunes it', () => {
const keeper = attached();
const keeperId = elementId(keeper);
fill(5000);
expect(elementIdCount()).toBeLessThan(1200);
expect(elementId(keeper)).toBe(keeperId);
expect(elementById(keeperId)).toBe(keeper);
});

it('keeps ids of connected elements stable across many sweeps', () => {
const live = Array.from({ length: 300 }, attached);
const first = live.map((el) => elementId(el));
fill(4000);
expect(elementById(first[0])).toBe(live[0]);
expect(live.map((el) => elementId(el))).toEqual(first);
});

it('keeps connected non-host elements after a host-only prune and an automatic sweep', () => {
const hosts = new WeakSet<object>();
const host = attached();
hosts.add(host);
pruneElementIds(
(el) => hosts.has(el) && (el as { isConnected?: boolean }).isConnected === true,
);
const other = attached();
const otherId = elementId(other);
const gone = detached();
const goneId = elementId(gone);
fill(4000);
expect(elementById(otherId)).toBe(other);
expect(elementById(goneId)).toBeNull();
expect(elementId(other)).toBe(otherId);
});

it('keeps objects that have no isConnected until they are collected', () => {
const view = { typeName: 'Label' };
const id = elementId(view);
fill(4000);
expect(elementById(id, () => true)).toBe(view);
expect(elementId(view)).toBe(id);
});

it('drops entries whose reference is dead', () => {
const dead = new WeakSet<object>();
const Real = WeakRef;
vi.stubGlobal(
'WeakRef',
class<T extends object> {
private readonly ref: WeakRef<T>;
constructor(target: T) {
this.ref = new Real(target);
}
deref() {
const target = this.ref.deref();
return target && dead.has(target) ? undefined : target;
}
},
);
const doomed = attached();
const doomedId = elementId(doomed);
dead.add(doomed);
fill(4000, attached);
expect(elementById(doomedId)).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

type Call = [string, Record<string, unknown>];

const calls: Call[] = [];
let signalValue = 0;
let failNext = false;

const host = {
typeName: 'AppHostView',
parent: null,
isLoaded: true,
eachChildView: () => undefined,
};
class AppComponent {}
Object.assign(AppComponent, { ɵcmp: { selectors: [['ns-app']] } });
const app = new AppComponent();

vi.mock('@nativescript/core', () => ({
Application: { getRootView: () => host },
isAndroid: false,
}));

vi.mock('devframe/client', () => ({
connectDevframe: vi.fn(async () => ({
status: 'connected',
close: vi.fn(),
scope: () => ({
rpc: {
register: vi.fn(),
call: vi.fn(async (name: string, payload: Record<string, unknown>) => {
if (name === 'push-signal-graph') {
if (failNext) {
failNext = false;
throw new Error('offline');
}
calls.push([name, payload]);
return { delta: true };
}
return undefined;
}),
},
}),
})),
}));

vi.mock('../signal-history.ts', async (importOriginal) => ({
...(await importOriginal<typeof import('../signal-history.ts')>()),
installSignalWriteHook: vi.fn(async () => () => undefined),
}));

vi.mock('../ngrx-overlay.ts', () => ({
attachNgrx: () => ({ push: async () => undefined, stop: () => undefined }),
}));

async function flush() {
for (let i = 0; i < 40; i++) await Promise.resolve();
}

describe('NativeScript signal graph pushes', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('WebSocket', class {});
vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
calls.length = 0;
signalValue = 0;
failNext = false;
(globalThis as Record<string, unknown>)['ng'] = {
getComponent: (h: unknown) => (h === host ? app : null),
getRootComponents: () => [app],
getHostElement: (c: unknown) => (c === app ? host : null),
getInjector: (h: unknown) => h,
ɵgetSignalGraph: () => ({
nodes: [
{ id: '1', kind: 'signal', label: 'count', epoch: signalValue, value: signalValue },
],
}),
};
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
delete (globalThis as Record<string, unknown>)['ng'];
});

it('sends the full history again after a failed push', async () => {
const { initNativeScriptOverlay } = await import('../overlay-nativescript.ts');
const dispose = initNativeScriptOverlay({ baseURL: 'http://localhost:9999/', intervalMs: 100 });
await flush();
expect(calls).toHaveLength(1);
expect(calls[0][1]).toHaveProperty('history');

signalValue = 1;
await vi.advanceTimersByTimeAsync(100);
expect(calls).toHaveLength(2);
expect(calls[1][1]).toHaveProperty('historyDelta');

signalValue = 2;
failNext = true;
await vi.advanceTimersByTimeAsync(100);
expect(calls).toHaveLength(2);

signalValue = 3;
await vi.advanceTimersByTimeAsync(100);
expect(calls).toHaveLength(3);
expect(calls[2][1]).toHaveProperty('history');
expect(calls[2][1]).not.toHaveProperty('historyDelta');
dispose();
});
});
15 changes: 15 additions & 0 deletions packages/devtools/src/element-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ const ids = new WeakMap<object, string>();
const byId = new Map<string, WeakRef<object>>();
const load = Math.random().toString(36).slice(2, 6).padEnd(4, '0');
let nextId = 0;
const FIRST_SWEEP = 512;
let sweepAt = FIRST_SWEEP;

type Connected = (host: object) => boolean;

const isConnected: Connected = (host) => (host as { isConnected?: unknown }).isConnected === true;

const notDisconnected: Connected = (host) =>
(host as { isConnected?: unknown }).isConnected !== false;

export function elementId(el: object): string {
if (byId.size >= sweepAt) sweep(notDisconnected);
let id = ids.get(el);
if (!id) {
id = `c${load}-${++nextId}`;
Expand All @@ -29,8 +35,17 @@ export function elementById(id: string, connected: Connected = isConnected): obj
}

export function pruneElementIds(connected: Connected = isConnected) {
sweep(connected);
}

export function elementIdCount(): number {
return byId.size;
}

function sweep(connected: Connected) {
for (const [id, ref] of byId) {
const el = ref.deref();
if (!el || !connected(el)) byId.delete(id);
}
sweepAt = Math.max(FIRST_SWEEP, byId.size * 2);
}
1 change: 1 addition & 0 deletions packages/devtools/src/overlay-nativescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ async function startSession(
} catch (error) {
rollback();
lastSignalKey = '';
historyDelta = false;
throw error;
}
historyDelta = answer?.delta === true;
Expand Down
Loading