diff --git a/packages/devtools/src/__tests__/element-id.test.ts b/packages/devtools/src/__tests__/element-id.test.ts new file mode 100644 index 00000000..95a813a2 --- /dev/null +++ b/packages/devtools/src/__tests__/element-id.test.ts @@ -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(); + 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(); + const Real = WeakRef; + vi.stubGlobal( + 'WeakRef', + class { + private readonly ref: WeakRef; + 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(); + }); +}); diff --git a/packages/devtools/src/__tests__/overlay-nativescript-signal-push.test.ts b/packages/devtools/src/__tests__/overlay-nativescript-signal-push.test.ts new file mode 100644 index 00000000..640c2265 --- /dev/null +++ b/packages/devtools/src/__tests__/overlay-nativescript-signal-push.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type Call = [string, Record]; + +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) => { + 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()), + 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)['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)['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(); + }); +}); diff --git a/packages/devtools/src/element-id.ts b/packages/devtools/src/element-id.ts index f129d06f..bf98a12b 100644 --- a/packages/devtools/src/element-id.ts +++ b/packages/devtools/src/element-id.ts @@ -2,12 +2,18 @@ const ids = new WeakMap(); const byId = new Map>(); 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}`; @@ -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); } diff --git a/packages/devtools/src/overlay-nativescript.ts b/packages/devtools/src/overlay-nativescript.ts index d2a1c723..eb0c0c0c 100644 --- a/packages/devtools/src/overlay-nativescript.ts +++ b/packages/devtools/src/overlay-nativescript.ts @@ -207,6 +207,7 @@ async function startSession( } catch (error) { rollback(); lastSignalKey = ''; + historyDelta = false; throw error; } historyDelta = answer?.delta === true;