@dimina-kit/devtools 0.4.0-dev.20260701155140 → 0.4.0-dev.20260702164903
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main/app/max-listeners-diagnostic.js +5 -1
- package/dist/main/index.bundle.js +1533 -1237
- package/dist/main/ipc/bridge-router.d.ts +28 -0
- package/dist/main/ipc/bridge-router.js +175 -50
- package/dist/main/ipc/session-listener-bag.d.ts +41 -0
- package/dist/main/ipc/session-listener-bag.js +40 -0
- package/dist/main/ipc/simulator.js +5 -1
- package/dist/main/services/elements-forward/index.js +19 -0
- package/dist/main/services/network-forward/index.js +13 -0
- package/dist/main/services/views/destroy-child-view.d.ts +9 -0
- package/dist/main/services/views/destroy-child-view.js +23 -0
- package/dist/main/services/views/host-toolbar-view.d.ts +21 -0
- package/dist/main/services/views/host-toolbar-view.js +249 -0
- package/dist/main/services/views/inject-when-ready.d.ts +41 -0
- package/dist/main/services/views/inject-when-ready.js +62 -0
- package/dist/main/services/views/native-simulator-devtools-host.d.ts +26 -0
- package/dist/main/services/views/native-simulator-devtools-host.js +484 -0
- package/dist/main/services/views/native-simulator-view.d.ts +28 -0
- package/dist/main/services/views/native-simulator-view.js +428 -0
- package/dist/main/services/views/overlay-panels-view.d.ts +29 -0
- package/dist/main/services/views/overlay-panels-view.js +150 -0
- package/dist/main/services/views/placement-reconciler.d.ts +73 -0
- package/dist/main/services/views/placement-reconciler.js +120 -0
- package/dist/main/services/views/resolve-project-editor-target.d.ts +13 -0
- package/dist/main/services/views/resolve-project-editor-target.js +33 -0
- package/dist/main/services/views/view-manager.d.ts +20 -16
- package/dist/main/services/views/view-manager.js +66 -1545
- package/dist/main/services/views/workbench-view.d.ts +15 -0
- package/dist/main/services/views/workbench-view.js +170 -0
- package/dist/main/services/workspace/workspace-service.js +1 -1
- package/dist/preload/windows/host-toolbar-runtime.cjs.map +2 -2
- package/dist/preload/windows/main.cjs +10 -1
- package/dist/preload/windows/main.cjs.map +2 -2
- package/dist/preload/windows/simulator.cjs +6 -0
- package/dist/preload/windows/simulator.cjs.map +2 -2
- package/dist/preload/windows/simulator.js +6 -0
- package/dist/renderer/assets/index-B-Dqb7G0.js +49 -0
- package/dist/renderer/assets/{input-Djg2NR4U.js → input-Cjk0wSEh.js} +2 -2
- package/dist/renderer/assets/ipc-transport-D5dKIti1.css +1 -0
- package/dist/renderer/assets/{ipc-transport-Be-AzCub.js → ipc-transport-OZS-KmOW.js} +2 -2
- package/dist/renderer/assets/{popover-eDUh4NYe.js → popover-CQVvOe90.js} +2 -2
- package/dist/renderer/assets/{select-DesFfkVV.js → select-BQDXi5ih.js} +2 -2
- package/dist/renderer/assets/{settings-api-HQ7ZFPWi.js → settings-api-B6x2mhaD.js} +2 -2
- package/dist/renderer/assets/{settings-CRmO7zUG.js → settings-tP3Px2KR.js} +2 -2
- package/dist/renderer/assets/{workbenchSettings-CZBWsliT.js → workbenchSettings-Bim9ol9R.js} +2 -2
- package/dist/renderer/entries/main/index.html +6 -6
- package/dist/renderer/entries/popover/index.html +5 -5
- package/dist/renderer/entries/settings/index.html +5 -5
- package/dist/renderer/entries/workbench-settings/index.html +4 -4
- package/dist/shared/bridge-channels.d.ts +17 -0
- package/dist/shared/bridge-channels.js +9 -0
- package/dist/shared/ipc-channels.d.ts +1 -0
- package/dist/shared/ipc-channels.js +6 -0
- package/dist/shared/ipc-schemas.d.ts +6 -0
- package/dist/shared/ipc-schemas.js +10 -0
- package/dist/simulator/assets/{bridge-channels-BUQ5AbvJ.js → bridge-channels-zhsumfky.js} +2 -2
- package/dist/simulator/assets/{device-shell-CT3LRnTB.js → device-shell-CUl0ILfn.js} +2 -2
- package/dist/simulator/assets/{simulator-BizhGGE3.js → simulator-Dz8iGcgy.js} +5 -5
- package/dist/simulator/assets/{simulator-mini-app-BfNuDF3R.js → simulator-mini-app-Bt639XL_.js} +2 -2
- package/dist/simulator/simulator.html +2 -1
- package/package.json +5 -5
- package/dist/renderer/assets/index-jDrNHghH.js +0 -49
- package/dist/renderer/assets/ipc-transport-C6-KPMqw.css +0 -1
|
@@ -25,6 +25,29 @@ export interface RenderEvent {
|
|
|
25
25
|
* Lets the current-page panel push the route without a separate lookup. */
|
|
26
26
|
pagePath?: string;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Point-in-time counts of every resource class RouterState owns. Leak coverage
|
|
30
|
+
* (unit + e2e) asserts EXACT equality of this ledger around a churn cycle —
|
|
31
|
+
* coarse memory sampling cannot see a leaked listener or a stale map entry, so
|
|
32
|
+
* the owner of the bookkeeping reports precise counts instead.
|
|
33
|
+
*/
|
|
34
|
+
export interface BridgeResourceCensus {
|
|
35
|
+
appSessions: number;
|
|
36
|
+
pageSessions: number;
|
|
37
|
+
serviceWcBindings: number;
|
|
38
|
+
/** Distinct live simulator webContents currently hosting ≥1 session. */
|
|
39
|
+
simulatorWcs: number;
|
|
40
|
+
/** Total session memberships across all simulator wcs. */
|
|
41
|
+
simulatorWcBindings: number;
|
|
42
|
+
renderWcBindings: number;
|
|
43
|
+
pendingApiCalls: number;
|
|
44
|
+
/**
|
|
45
|
+
* `listenerCount('destroyed')` per unique live simulator wc (keyed by wc id).
|
|
46
|
+
* One teardown hook per live session — a count above the hosted-session count
|
|
47
|
+
* is the one-dead-listener-per-soft-reload leak class.
|
|
48
|
+
*/
|
|
49
|
+
simulatorDestroyedListeners: Record<number, number>;
|
|
50
|
+
}
|
|
28
51
|
export interface BridgeRouterHandle {
|
|
29
52
|
/** Whether native-host mode is on (main is the source of truth). */
|
|
30
53
|
isNativeHost(): boolean;
|
|
@@ -71,6 +94,11 @@ export interface BridgeRouterHandle {
|
|
|
71
94
|
* error channel.
|
|
72
95
|
*/
|
|
73
96
|
disposeSessionsForSimulator?(simulatorWcId: number): Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* The router's resource ledger (see BridgeResourceCensus). Optional so the
|
|
99
|
+
* many partial BridgeRouterHandle test mocks don't each have to stub it.
|
|
100
|
+
*/
|
|
101
|
+
census?(): BridgeResourceCensus;
|
|
74
102
|
/**
|
|
75
103
|
* The flag-gated debugTap (§7) over the bridge message stream. Exposed so a
|
|
76
104
|
* hidden devtools panel / automation can read `.entries()` when
|
|
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
4
4
|
import { BRIDGE_CHANNELS as C, SIMULATOR_EVENTS as E, deviceInfoToHostEnv } from '../../shared/bridge-channels.js';
|
|
5
5
|
import { isPersistentSimulatorApi } from '../../shared/simulator-api-metadata.js';
|
|
6
6
|
import { devtoolsPackageRoot } from '../utils/paths.js';
|
|
7
|
+
import { createSessionListenerBag } from './session-listener-bag.js';
|
|
7
8
|
import { createDebugTap } from '@dimina-kit/electron-deck/main';
|
|
8
9
|
import { startDiminaResourceServer } from '../services/dimina-resource-server.js';
|
|
9
10
|
import { buildServiceHostSpawnUrl, createServiceHostWindow, navigateServiceHost, serviceHostSpec, } from '../windows/service-host-window/create.js';
|
|
@@ -147,7 +148,8 @@ export function installBridgeRouter(ctx) {
|
|
|
147
148
|
const state = {
|
|
148
149
|
appSessions: new Map(),
|
|
149
150
|
pageSessions: new Map(),
|
|
150
|
-
|
|
151
|
+
serviceWcIdToAppSessionId: new Map(),
|
|
152
|
+
simulatorWcIdToAppSessionIds: new Map(),
|
|
151
153
|
wcIdToBridgeId: new Map(),
|
|
152
154
|
pendingApiCalls: new Map(),
|
|
153
155
|
pool: null,
|
|
@@ -299,8 +301,37 @@ export function installBridgeRouter(ctx) {
|
|
|
299
301
|
return Promise.all(ids.map((id) => disposeAppSession(state, id))).then(() => { });
|
|
300
302
|
},
|
|
301
303
|
debugTap: state.debugTap,
|
|
304
|
+
census: () => {
|
|
305
|
+
const simulatorDestroyedListeners = {};
|
|
306
|
+
let simulatorWcBindings = 0;
|
|
307
|
+
for (const ids of state.simulatorWcIdToAppSessionIds.values())
|
|
308
|
+
simulatorWcBindings += ids.size;
|
|
309
|
+
for (const ap of state.appSessions.values()) {
|
|
310
|
+
const wc = ap.simulatorWc;
|
|
311
|
+
if (!wc.isDestroyed() && simulatorDestroyedListeners[wc.id] === undefined) {
|
|
312
|
+
simulatorDestroyedListeners[wc.id] = wc.listenerCount('destroyed');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
appSessions: state.appSessions.size,
|
|
317
|
+
pageSessions: state.pageSessions.size,
|
|
318
|
+
serviceWcBindings: state.serviceWcIdToAppSessionId.size,
|
|
319
|
+
simulatorWcs: state.simulatorWcIdToAppSessionIds.size,
|
|
320
|
+
simulatorWcBindings,
|
|
321
|
+
renderWcBindings: state.wcIdToBridgeId.size,
|
|
322
|
+
pendingApiCalls: state.pendingApiCalls.size,
|
|
323
|
+
simulatorDestroyedListeners,
|
|
324
|
+
};
|
|
325
|
+
},
|
|
302
326
|
};
|
|
303
327
|
ctx.bridge = bridgeHandle;
|
|
328
|
+
// e2e resource-census probe: Playwright's electronApp.evaluate() reads the
|
|
329
|
+
// router's ledger straight off this main-process global — no IPC surface
|
|
330
|
+
// needed. Test builds only.
|
|
331
|
+
if (process.env.NODE_ENV === 'test') {
|
|
332
|
+
;
|
|
333
|
+
globalThis.__diminaResourceCensus = () => bridgeHandle.census?.();
|
|
334
|
+
}
|
|
304
335
|
// Always-on guest console fan-out. Owns `ctx.guestConsole` (the sink the
|
|
305
336
|
// consoleLog case below routes to) so that render-layer console output is
|
|
306
337
|
// mirrored into the service host's own console — surfacing it in the embedded
|
|
@@ -322,8 +353,7 @@ export function installBridgeRouter(ctx) {
|
|
|
322
353
|
const ap = state.appSessions.get(payload.appSessionId);
|
|
323
354
|
if (!ap)
|
|
324
355
|
return;
|
|
325
|
-
|
|
326
|
-
if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
|
|
356
|
+
if (!senderBoundToSession(state, event.sender, ap))
|
|
327
357
|
return;
|
|
328
358
|
if (ap.pages.has(payload.bridgeId)) {
|
|
329
359
|
ap.activeBridgeId = payload.bridgeId;
|
|
@@ -340,8 +370,7 @@ export function installBridgeRouter(ctx) {
|
|
|
340
370
|
const ap = state.appSessions.get(payload.appSessionId);
|
|
341
371
|
if (!ap)
|
|
342
372
|
return;
|
|
343
|
-
|
|
344
|
-
if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
|
|
373
|
+
if (!senderBoundToSession(state, event.sender, ap))
|
|
345
374
|
return;
|
|
346
375
|
ap.pageStack = payload.stack;
|
|
347
376
|
};
|
|
@@ -366,8 +395,7 @@ export function installBridgeRouter(ctx) {
|
|
|
366
395
|
// its tab (mirrors the default path's postMessage(pageUnload) eviction).
|
|
367
396
|
if (payload.event === 'pageUnload' && ctx.appData) {
|
|
368
397
|
const ap = state.appSessions.get(payload.appSessionId);
|
|
369
|
-
|
|
370
|
-
if (ap && senderApp && senderApp.appSessionId === ap.appSessionId) {
|
|
398
|
+
if (ap && senderBoundToSession(state, event.sender, ap)) {
|
|
371
399
|
ctx.appData.evictBridge(ap.appId, payload.bridgeId);
|
|
372
400
|
}
|
|
373
401
|
}
|
|
@@ -384,9 +412,11 @@ export function installBridgeRouter(ctx) {
|
|
|
384
412
|
if (!target)
|
|
385
413
|
return;
|
|
386
414
|
// Only the app's service window or simulator window can issue dispose.
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
415
|
+
// (Unknown senders pass — lenient by design; a simulator WCV is authorized
|
|
416
|
+
// for every session it hosts, so an older session's own dispose stays
|
|
417
|
+
// valid while a newer spawn shares the wc.)
|
|
418
|
+
if (!senderBoundToSession(state, event.sender, target) && appByWc(state, event.sender)) {
|
|
419
|
+
console.warn(`[bridge-router] DISPOSE rejected: sender not bound to target ${target.appSessionId}`);
|
|
390
420
|
return;
|
|
391
421
|
}
|
|
392
422
|
// AppData bridge eviction happens inside disposeAppSession (single
|
|
@@ -581,8 +611,8 @@ async function handleSpawn(state, ctx, event, opts) {
|
|
|
581
611
|
pages: new Map(),
|
|
582
612
|
activeBridgeId: null,
|
|
583
613
|
poolEntryId,
|
|
584
|
-
onServiceClosed: null,
|
|
585
614
|
onServiceBoot: null,
|
|
615
|
+
listenerBag: createSessionListenerBag(),
|
|
586
616
|
registryHandle: null,
|
|
587
617
|
};
|
|
588
618
|
const rootPage = {
|
|
@@ -601,8 +631,8 @@ async function handleSpawn(state, ctx, event, opts) {
|
|
|
601
631
|
state.appSessions.set(appSessionId, appSession);
|
|
602
632
|
state.pageSessions.set(bridgeId, rootPage);
|
|
603
633
|
appSession.pages.set(bridgeId, rootPage);
|
|
604
|
-
bindWc(state.
|
|
605
|
-
|
|
634
|
+
bindWc(state.serviceWcIdToAppSessionId, serviceWindow.webContents, appSessionId);
|
|
635
|
+
bindSimulatorWc(state.simulatorWcIdToAppSessionIds, simulatorWc, appSessionId);
|
|
606
636
|
// Track the service-host webContents as a Connection (foundation.md §4.3) and
|
|
607
637
|
// own this session's serviceWc→appSessionId binding on its CURRENT lifetime
|
|
608
638
|
// segment. On a pooled-window REUSE, `disposeAppSession` calls
|
|
@@ -614,16 +644,15 @@ async function handleSpawn(state, ctx, event, opts) {
|
|
|
614
644
|
// wc outlives individual app sessions — cross-connection state, §4.4 — so it
|
|
615
645
|
// cannot hang off any single session's segment.)
|
|
616
646
|
state.connections.acquire(serviceWindow.webContents).own(() => {
|
|
617
|
-
if (state.
|
|
618
|
-
state.
|
|
647
|
+
if (state.serviceWcIdToAppSessionId.get(appSession.serviceWc.id) === appSessionId) {
|
|
648
|
+
state.serviceWcIdToAppSessionId.delete(appSession.serviceWc.id);
|
|
619
649
|
}
|
|
620
650
|
});
|
|
621
651
|
appSession.registryHandle = ctx.registry.add(() => disposeAppSession(state, appSessionId));
|
|
622
652
|
const onServiceClosed = () => {
|
|
623
653
|
void disposeAppSession(state, appSessionId, { serviceAlreadyClosed: true });
|
|
624
654
|
};
|
|
625
|
-
appSession.
|
|
626
|
-
serviceWindow.once('closed', onServiceClosed);
|
|
655
|
+
appSession.listenerBag.once(serviceWindow, 'closed', onServiceClosed);
|
|
627
656
|
// The simulator WCV owns the app's UI lifetime. When it is destroyed —
|
|
628
657
|
// project close (`views.disposeAll`/detach) or a DeviceShell respawn
|
|
629
658
|
// (`attachNativeSimulator`, e.g. watcher hot reload) — the guest never gets
|
|
@@ -636,7 +665,7 @@ async function handleSpawn(state, ctx, event, opts) {
|
|
|
636
665
|
const onSimulatorDestroyed = () => {
|
|
637
666
|
void disposeAppSession(state, appSessionId);
|
|
638
667
|
};
|
|
639
|
-
|
|
668
|
+
appSession.listenerBag.once(simulatorWc, 'destroyed', onSimulatorDestroyed);
|
|
640
669
|
// The simulator WCV may have been torn down DURING this spawn's awaits
|
|
641
670
|
// (loadAppConfig / resource server / service-host creation) — e.g. a project
|
|
642
671
|
// close/switch that ran while we were mid-flight. In that case its 'destroyed'
|
|
@@ -703,8 +732,7 @@ async function handlePageOpen(state, event, opts) {
|
|
|
703
732
|
if (!ap)
|
|
704
733
|
throw new Error(`[bridge-router] PAGE_OPEN unknown appSession ${opts.appSessionId}`);
|
|
705
734
|
// Only the simulator window owning the app can open additional pages.
|
|
706
|
-
|
|
707
|
-
if (!senderApp || senderApp.appSessionId !== opts.appSessionId) {
|
|
735
|
+
if (!senderBoundToSession(state, event.sender, ap)) {
|
|
708
736
|
throw new Error('[bridge-router] PAGE_OPEN rejected: caller not bound to app session');
|
|
709
737
|
}
|
|
710
738
|
const pagePath = normalizePagePath(opts.pagePath);
|
|
@@ -739,8 +767,7 @@ function handlePageClose(state, sender, payload) {
|
|
|
739
767
|
const ap = state.appSessions.get(page.appSessionId);
|
|
740
768
|
if (!ap)
|
|
741
769
|
return;
|
|
742
|
-
|
|
743
|
-
if (!senderApp || senderApp.appSessionId !== ap.appSessionId) {
|
|
770
|
+
if (!senderBoundToSession(state, sender, ap)) {
|
|
744
771
|
console.warn('[bridge-router] PAGE_CLOSE rejected: caller not bound to app session');
|
|
745
772
|
return;
|
|
746
773
|
}
|
|
@@ -750,8 +777,7 @@ function handlePageLifecycle(state, sender, payload) {
|
|
|
750
777
|
const ap = state.appSessions.get(payload.appSessionId);
|
|
751
778
|
if (!ap)
|
|
752
779
|
return;
|
|
753
|
-
|
|
754
|
-
if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
|
|
780
|
+
if (!senderBoundToSession(state, sender, ap))
|
|
755
781
|
return;
|
|
756
782
|
forwardToService(ap, {
|
|
757
783
|
type: payload.event,
|
|
@@ -763,8 +789,7 @@ function handleNavCallback(state, sender, payload) {
|
|
|
763
789
|
const ap = state.appSessions.get(payload.appSessionId);
|
|
764
790
|
if (!ap)
|
|
765
791
|
return;
|
|
766
|
-
|
|
767
|
-
if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
|
|
792
|
+
if (!senderBoundToSession(state, sender, ap))
|
|
768
793
|
return;
|
|
769
794
|
const result = { errMsg: payload.errMsg };
|
|
770
795
|
if (payload.ok) {
|
|
@@ -794,23 +819,50 @@ async function bootServiceHost(state, ap, ctx) {
|
|
|
794
819
|
reportLogicLoadFailure(ap, ctx);
|
|
795
820
|
return;
|
|
796
821
|
}
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
|
|
822
|
+
// A root page absent from the compiled manifest — most commonly a page the
|
|
823
|
+
// developer deleted, then hot-reloaded to — is not registered in logic.js.
|
|
824
|
+
// BOTH the service runtime AND the render runtime eager-`modRequire(pagePath)`
|
|
825
|
+
// it and throw `module <pagePath> not found`, which aborts the launch and
|
|
826
|
+
// leaves the simulator permanently blank. Gate BOTH sends and surface a
|
|
827
|
+
// WeChat-style diagnostic instead: the shell stays alive (an empty phone frame
|
|
828
|
+
// + a clear Console error) rather than a silent dead blank. Choosing a valid
|
|
829
|
+
// fallback page belongs at the renderer reload source (it owns the pagePath
|
|
830
|
+
// the render guest is spawned with); here we only refuse a load guaranteed to
|
|
831
|
+
// throw, so main and render never disagree about which page is live.
|
|
832
|
+
const rootPage = ap.pages.get(ap.appSessionId);
|
|
833
|
+
const rootMissing = !!rootPage && !pageInManifest(ap, rootPage.pagePath);
|
|
834
|
+
if (rootMissing) {
|
|
835
|
+
reportPageNotFound(ap, ctx, rootPage.pagePath);
|
|
836
|
+
}
|
|
837
|
+
else if (rootPage) {
|
|
838
|
+
// serviceLoaded is flipped only when service responds with
|
|
839
|
+
// `serviceResourceLoaded`; see handleContainerMsg. Setting it here would
|
|
840
|
+
// race a per-page `resourceLoaded` ahead of the service-side handler.
|
|
841
|
+
forwardToService(ap, makeLoadResource(ap, rootPage, 'service'));
|
|
842
|
+
}
|
|
801
843
|
// Flush any render `loadResource` that arrived (renderHostReady) while
|
|
802
844
|
// injection was in-flight — now that the bundle is confirmed present.
|
|
845
|
+
// `sendRenderLoadResource` refuses a page absent from the manifest, so a
|
|
846
|
+
// missing root never reaches the throwing render `modRequire`.
|
|
803
847
|
for (const page of ap.pages.values()) {
|
|
804
|
-
if (page.renderLoadPending)
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
848
|
+
if (!page.renderLoadPending)
|
|
849
|
+
continue;
|
|
850
|
+
page.renderLoadPending = false;
|
|
851
|
+
sendRenderLoadResource(ap, page);
|
|
808
852
|
}
|
|
809
853
|
}
|
|
810
854
|
function sendRenderLoadResource(ap, page) {
|
|
811
|
-
if (page.renderWc
|
|
812
|
-
|
|
813
|
-
|
|
855
|
+
if (!page.renderWc || page.renderWc.isDestroyed())
|
|
856
|
+
return;
|
|
857
|
+
// Single choke point for BOTH the boot flush above and the late
|
|
858
|
+
// `renderHostReady` direct-send in `routeFromRender`: never send a render load
|
|
859
|
+
// for a page absent from the compiled manifest. The render runtime
|
|
860
|
+
// eager-`modRequire`s the pagePath and would throw `module <pagePath> not
|
|
861
|
+
// found`, blanking the simulator (a page the developer deleted, then
|
|
862
|
+
// hot-reloaded to). bootServiceHost surfaces the one-shot diagnostic.
|
|
863
|
+
if (!pageInManifest(ap, page.pagePath))
|
|
864
|
+
return;
|
|
865
|
+
page.renderWc.send(C.TO_RENDER, { msg: makeLoadResource(ap, page, 'render') });
|
|
814
866
|
}
|
|
815
867
|
/** The compiled logic.js URL `injectLogicBundle` fetches (mode-dependent). */
|
|
816
868
|
function logicBundleUrl(ap) {
|
|
@@ -857,6 +909,21 @@ function reportLogicLoadFailure(ap, ctx) {
|
|
|
857
909
|
console.error(message);
|
|
858
910
|
ctx.guestConsole?.emit({ source: 'service', level: 'error', args: [message] });
|
|
859
911
|
}
|
|
912
|
+
/** Whether `pagePath` still exists in the app's freshly compiled manifest. */
|
|
913
|
+
function pageInManifest(ap, pagePath) {
|
|
914
|
+
return ap.manifest.pages.includes(normalizePagePath(pagePath));
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* WeChat-devtools-style "page does not exist" diagnostic. Emitted (main log +
|
|
918
|
+
* guest Console) when a mount targets a pagePath that is not in the compiled
|
|
919
|
+
* manifest — most commonly a page the developer deleted, then hot-reloaded.
|
|
920
|
+
*/
|
|
921
|
+
function reportPageNotFound(ap, ctx, pagePath) {
|
|
922
|
+
const message = `Page[${pagePath}] not found. May be caused by: 1. Forgetting to add page route in app.json. `
|
|
923
|
+
+ '2. Invoking Page() in async task.';
|
|
924
|
+
console.error(`[bridge-router] ${message}`);
|
|
925
|
+
ctx.guestConsole?.emit({ source: 'service', level: 'error', args: [message] });
|
|
926
|
+
}
|
|
860
927
|
function maybeSendResourceLoaded(ap, page) {
|
|
861
928
|
if (page.resourceLoadedSent || !ap.serviceLoaded || !page.renderLoaded)
|
|
862
929
|
return;
|
|
@@ -1260,11 +1327,13 @@ function handleApiResponse(state, sender, payload) {
|
|
|
1260
1327
|
clearTimeout(pending.timer);
|
|
1261
1328
|
return;
|
|
1262
1329
|
}
|
|
1263
|
-
// Only
|
|
1330
|
+
// Only a simulator window bound to the OWNING session may respond. Membership
|
|
1331
|
+
// (senderBoundToSession), not sender-resolution equality: during a soft-reload
|
|
1332
|
+
// overlap the shared simulator wc still answers the outgoing session's
|
|
1333
|
+
// in-flight calls, which a latest-wins comparison would drop. Validate BEFORE
|
|
1264
1334
|
// mutating pending state so a spoofed/foreign response can't tear down a
|
|
1265
1335
|
// live subscription.
|
|
1266
|
-
|
|
1267
|
-
if (!senderApp || senderApp.appSessionId !== ap.appSessionId) {
|
|
1336
|
+
if (!senderBoundToSession(state, sender, ap)) {
|
|
1268
1337
|
console.warn('[bridge-router] API_RESPONSE rejected: sender not bound to app session');
|
|
1269
1338
|
return;
|
|
1270
1339
|
}
|
|
@@ -1395,9 +1464,20 @@ function pageFromMsg(state, ap, msg) {
|
|
|
1395
1464
|
function appByWc(state, wc) {
|
|
1396
1465
|
if (wc.isDestroyed())
|
|
1397
1466
|
return undefined;
|
|
1398
|
-
const appSessionId = state.
|
|
1467
|
+
const appSessionId = state.serviceWcIdToAppSessionId.get(wc.id);
|
|
1399
1468
|
if (appSessionId)
|
|
1400
1469
|
return state.appSessions.get(appSessionId);
|
|
1470
|
+
// A simulator wc hosts several sessions during a soft reload; a message that
|
|
1471
|
+
// doesn't name its session belongs to the LATEST still-alive spawn (the Set
|
|
1472
|
+
// preserves spawn order).
|
|
1473
|
+
const hosted = state.simulatorWcIdToAppSessionIds.get(wc.id);
|
|
1474
|
+
if (hosted) {
|
|
1475
|
+
let latest;
|
|
1476
|
+
for (const id of hosted)
|
|
1477
|
+
latest = state.appSessions.get(id) ?? latest;
|
|
1478
|
+
if (latest)
|
|
1479
|
+
return latest;
|
|
1480
|
+
}
|
|
1401
1481
|
const bridgeId = state.wcIdToBridgeId.get(wc.id);
|
|
1402
1482
|
if (bridgeId) {
|
|
1403
1483
|
const page = state.pageSessions.get(bridgeId);
|
|
@@ -1406,6 +1486,21 @@ function appByWc(state, wc) {
|
|
|
1406
1486
|
}
|
|
1407
1487
|
return undefined;
|
|
1408
1488
|
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Whether `sender` is authorized to control app session `ap`. A simulator WCV
|
|
1491
|
+
* is authorized for EVERY session it hosts (`simulatorWcIdToAppSessionIds` is
|
|
1492
|
+
* a membership set, so an older session's own control traffic stays valid
|
|
1493
|
+
* while a newer spawn boots on the same wc); any other sender (service host,
|
|
1494
|
+
* render guest) resolves one-to-one through `appByWc`.
|
|
1495
|
+
*/
|
|
1496
|
+
function senderBoundToSession(state, sender, ap) {
|
|
1497
|
+
if (sender.isDestroyed())
|
|
1498
|
+
return false;
|
|
1499
|
+
const hosted = state.simulatorWcIdToAppSessionIds.get(sender.id);
|
|
1500
|
+
if (hosted)
|
|
1501
|
+
return hosted.has(ap.appSessionId);
|
|
1502
|
+
return appByWc(state, sender)?.appSessionId === ap.appSessionId;
|
|
1503
|
+
}
|
|
1409
1504
|
function resolveAppByBridgeId(state, bridgeId) {
|
|
1410
1505
|
if (state.appSessions.has(bridgeId))
|
|
1411
1506
|
return state.appSessions.get(bridgeId);
|
|
@@ -1419,6 +1514,17 @@ function bindWc(map, wc, id) {
|
|
|
1419
1514
|
return;
|
|
1420
1515
|
map.set(wc.id, id);
|
|
1421
1516
|
}
|
|
1517
|
+
/** Add a session to a simulator wc's hosted set (created on first bind). */
|
|
1518
|
+
function bindSimulatorWc(map, wc, id) {
|
|
1519
|
+
if (wc.isDestroyed())
|
|
1520
|
+
return;
|
|
1521
|
+
let hosted = map.get(wc.id);
|
|
1522
|
+
if (!hosted) {
|
|
1523
|
+
hosted = new Set();
|
|
1524
|
+
map.set(wc.id, hosted);
|
|
1525
|
+
}
|
|
1526
|
+
hosted.add(id);
|
|
1527
|
+
}
|
|
1422
1528
|
function readBridgeId(msg) {
|
|
1423
1529
|
return typeof msg.body?.bridgeId === 'string' ? msg.body.bridgeId : undefined;
|
|
1424
1530
|
}
|
|
@@ -1483,6 +1589,14 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
|
|
|
1483
1589
|
state.pageSessions.delete(page.bridgeId);
|
|
1484
1590
|
}
|
|
1485
1591
|
ap.pages.clear();
|
|
1592
|
+
// Detach every session-scoped hook on emitters that outlive this session —
|
|
1593
|
+
// the shared simulator WCV's 'destroyed' and the service window's 'closed' —
|
|
1594
|
+
// BEFORE the window is released/closed below: a pool-recycled window must
|
|
1595
|
+
// not re-trigger this session's teardown from a stale 'closed', and the
|
|
1596
|
+
// simulator wc must not accumulate one dead hook per soft reload until the
|
|
1597
|
+
// MaxListeners warning fires. Idempotent with teardown being triggered BY
|
|
1598
|
+
// one of these hooks (removing a fired once() is a no-op).
|
|
1599
|
+
ap.listenerBag.dispose();
|
|
1486
1600
|
if (ap.poolEntryId !== null && state.pool && !opts.serviceAlreadyClosed) {
|
|
1487
1601
|
// Soft reuse (foundation.md §4.3): the pooled service-host webContents keeps
|
|
1488
1602
|
// its wc.id but is about to run a NEW app session. Reset its Connection
|
|
@@ -1492,13 +1606,10 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
|
|
|
1492
1606
|
// clean segment. The connection object stays alive (it's reused), so this is
|
|
1493
1607
|
// reset (not close); a real destroy still closes it via its 'destroyed' hook.
|
|
1494
1608
|
state.connections.reset(ap.serviceWc.id);
|
|
1495
|
-
// Return the window to the pool instead of closing it. Detach the
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
// into the next spawn's recycled window.
|
|
1499
|
-
if (ap.onServiceClosed && !ap.serviceWindow.isDestroyed()) {
|
|
1500
|
-
ap.serviceWindow.removeListener('closed', ap.onServiceClosed);
|
|
1501
|
-
}
|
|
1609
|
+
// Return the window to the pool instead of closing it. Detach the boot
|
|
1610
|
+
// listener first (the 'closed' hook already came off with the listener-bag
|
|
1611
|
+
// dispose above) so a stale did-finish-load can't boot this disposed
|
|
1612
|
+
// session into the next spawn's recycled window.
|
|
1502
1613
|
if (ap.onServiceBoot && !ap.serviceWindow.isDestroyed()) {
|
|
1503
1614
|
ap.serviceWindow.webContents.removeListener('did-finish-load', ap.onServiceBoot);
|
|
1504
1615
|
}
|
|
@@ -1518,8 +1629,22 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
|
|
|
1518
1629
|
else if (!opts.serviceAlreadyClosed && !ap.serviceWindow.isDestroyed()) {
|
|
1519
1630
|
ap.serviceWindow.close();
|
|
1520
1631
|
}
|
|
1521
|
-
|
|
1522
|
-
|
|
1632
|
+
// Value-checked unbind for the service wc: this delete runs after the
|
|
1633
|
+
// pool-release await above, so on the pool path the next spawn may have
|
|
1634
|
+
// already re-acquired the SAME window and rebound its wc id — only remove
|
|
1635
|
+
// an entry that still names THIS session.
|
|
1636
|
+
if (state.serviceWcIdToAppSessionId.get(ap.serviceWc.id) === appSessionId) {
|
|
1637
|
+
state.serviceWcIdToAppSessionId.delete(ap.serviceWc.id);
|
|
1638
|
+
}
|
|
1639
|
+
// The simulator wc's hosted set sheds only this session; sessions that are
|
|
1640
|
+
// still live on the shared wc (the soft-reload survivor) keep their
|
|
1641
|
+
// membership. The map entry goes away with the last session.
|
|
1642
|
+
const hosted = state.simulatorWcIdToAppSessionIds.get(ap.simulatorWc.id);
|
|
1643
|
+
if (hosted) {
|
|
1644
|
+
hosted.delete(appSessionId);
|
|
1645
|
+
if (hosted.size === 0)
|
|
1646
|
+
state.simulatorWcIdToAppSessionIds.delete(ap.simulatorWc.id);
|
|
1647
|
+
}
|
|
1523
1648
|
// Only the local fallback server needs closing; the dev-server base is owned
|
|
1524
1649
|
// by the workspace session, not this app session.
|
|
1525
1650
|
if (ap.resourceServer) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped listener bookkeeping. An app session hangs lifecycle hooks on
|
|
3
|
+
* emitters it does NOT own — the shared simulator WebContentsView, a pooled
|
|
4
|
+
* service-host window — and those emitters outlive the session (one simulator
|
|
5
|
+
* wc soft-reloads through many sessions). Every such hook is registered
|
|
6
|
+
* through this bag so the session's single teardown chokepoint can detach them
|
|
7
|
+
* all with one `dispose()`; a hook left behind survives as a dead listener on
|
|
8
|
+
* the shared emitter, growing by one per soft reload until Node's MaxListeners
|
|
9
|
+
* warning fires.
|
|
10
|
+
*/
|
|
11
|
+
type Listener = (...args: unknown[]) => void;
|
|
12
|
+
/**
|
|
13
|
+
* Structural surface of the Node/Electron emitters the bag manages.
|
|
14
|
+
* `removeListener(event, fn)` also detaches a `once(event, fn)` registration:
|
|
15
|
+
* Node's once wrapper keeps a `.listener` back-reference to `fn` and
|
|
16
|
+
* removeListener matches through it. `isDestroyed` covers Electron objects
|
|
17
|
+
* (WebContents / BrowserWindow) whose methods throw once destroyed.
|
|
18
|
+
*/
|
|
19
|
+
export interface BagEmitter {
|
|
20
|
+
on(event: string, listener: Listener): unknown;
|
|
21
|
+
once(event: string, listener: Listener): unknown;
|
|
22
|
+
removeListener(event: string, listener: Listener): unknown;
|
|
23
|
+
isDestroyed?(): boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface SessionListenerBag {
|
|
26
|
+
/** Attach `fn` via `emitter.on` and track it for dispose. No-op after dispose. */
|
|
27
|
+
on(emitter: BagEmitter, event: string, fn: Listener): void;
|
|
28
|
+
/** Attach `fn` via `emitter.once` and track it for dispose. No-op after dispose. */
|
|
29
|
+
once(emitter: BagEmitter, event: string, fn: Listener): void;
|
|
30
|
+
/**
|
|
31
|
+
* Detach every tracked listener that may still be attached. Emitters that
|
|
32
|
+
* report `isDestroyed()` are skipped — their listeners died with them, and
|
|
33
|
+
* touching a destroyed Electron object throws. Removing a `once` that
|
|
34
|
+
* already fired is a no-op, so dispose is safe to call FROM one of the
|
|
35
|
+
* bag's own hooks (teardown triggered by the hook itself). Idempotent.
|
|
36
|
+
*/
|
|
37
|
+
dispose(): void;
|
|
38
|
+
}
|
|
39
|
+
export declare function createSessionListenerBag(): SessionListenerBag;
|
|
40
|
+
export {};
|
|
41
|
+
//# sourceMappingURL=session-listener-bag.d.ts.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped listener bookkeeping. An app session hangs lifecycle hooks on
|
|
3
|
+
* emitters it does NOT own — the shared simulator WebContentsView, a pooled
|
|
4
|
+
* service-host window — and those emitters outlive the session (one simulator
|
|
5
|
+
* wc soft-reloads through many sessions). Every such hook is registered
|
|
6
|
+
* through this bag so the session's single teardown chokepoint can detach them
|
|
7
|
+
* all with one `dispose()`; a hook left behind survives as a dead listener on
|
|
8
|
+
* the shared emitter, growing by one per soft reload until Node's MaxListeners
|
|
9
|
+
* warning fires.
|
|
10
|
+
*/
|
|
11
|
+
export function createSessionListenerBag() {
|
|
12
|
+
const records = [];
|
|
13
|
+
let disposed = false;
|
|
14
|
+
return {
|
|
15
|
+
on(emitter, event, fn) {
|
|
16
|
+
if (disposed)
|
|
17
|
+
return;
|
|
18
|
+
emitter.on(event, fn);
|
|
19
|
+
records.push({ emitter, event, fn });
|
|
20
|
+
},
|
|
21
|
+
once(emitter, event, fn) {
|
|
22
|
+
if (disposed)
|
|
23
|
+
return;
|
|
24
|
+
emitter.once(event, fn);
|
|
25
|
+
records.push({ emitter, event, fn });
|
|
26
|
+
},
|
|
27
|
+
dispose() {
|
|
28
|
+
if (disposed)
|
|
29
|
+
return;
|
|
30
|
+
disposed = true;
|
|
31
|
+
for (const { emitter, event, fn } of records) {
|
|
32
|
+
if (emitter.isDestroyed?.())
|
|
33
|
+
continue;
|
|
34
|
+
emitter.removeListener(event, fn);
|
|
35
|
+
}
|
|
36
|
+
records.length = 0;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=session-listener-bag.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ServiceHostChannel, SimulatorChannel, SimulatorCustomApiChannel } from '../../shared/ipc-channels.js';
|
|
2
|
-
import { SimulatorAttachNativeSchema, SimulatorCustomApiInvokeSchema, SimulatorSetDeviceInfoSchema, } from '../../shared/ipc-schemas.js';
|
|
2
|
+
import { SimulatorAttachNativeSchema, SimulatorCustomApiInvokeSchema, SimulatorSetDeviceInfoSchema, SimulatorSoftReloadSchema, } from '../../shared/ipc-schemas.js';
|
|
3
3
|
import { deviceInfoToHostEnv } from '../../shared/bridge-channels.js';
|
|
4
4
|
import { validate } from '../utils/ipc-schema.js';
|
|
5
5
|
import { IpcRegistry } from '../utils/ipc-registry.js';
|
|
@@ -8,6 +8,10 @@ export function registerSimulatorIpc(ctx) {
|
|
|
8
8
|
.handle(SimulatorChannel.AttachNative, (_, ...args) => {
|
|
9
9
|
const [simulatorUrl, simWidth] = validate(SimulatorChannel.AttachNative, SimulatorAttachNativeSchema, args);
|
|
10
10
|
return ctx.views.attachNativeSimulator(simulatorUrl, simWidth);
|
|
11
|
+
})
|
|
12
|
+
.handle(SimulatorChannel.SoftReload, (_, ...args) => {
|
|
13
|
+
const [simulatorUrl] = validate(SimulatorChannel.SoftReload, SimulatorSoftReloadSchema, args);
|
|
14
|
+
return ctx.views.softReloadNativeSimulator(simulatorUrl);
|
|
11
15
|
})
|
|
12
16
|
.handle(SimulatorChannel.Detach, () => {
|
|
13
17
|
ctx.views.detachSimulator();
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
* hook + dispatch scripts) as self-contained code.
|
|
50
50
|
*/
|
|
51
51
|
import { webContents as electronWebContents } from 'electron';
|
|
52
|
+
import { isFrontendSettled } from '../views/inject-when-ready.js';
|
|
52
53
|
/**
|
|
53
54
|
* CDP domains that target the RENDER GUEST'S document tree. A command in one of
|
|
54
55
|
* these is intercepted and sent to the active render guest's debugger instead of
|
|
@@ -274,6 +275,12 @@ export function installElementsForward(deps) {
|
|
|
274
275
|
function dispatchToFrontend(message) {
|
|
275
276
|
if (disposed || devtoolsWc.isDestroyed())
|
|
276
277
|
return;
|
|
278
|
+
// Unified settled gate (same predicate as the reconcile tick): an unsettled
|
|
279
|
+
// front-end wipes its state on load anyway, so the message is meaningless
|
|
280
|
+
// there — and the executeJavaScript would queue one did-stop-loading waiter
|
|
281
|
+
// per push (an activePage burst piles them). The post-settle tick re-primes.
|
|
282
|
+
if (!isFrontendSettled(devtoolsWc))
|
|
283
|
+
return;
|
|
277
284
|
let json;
|
|
278
285
|
try {
|
|
279
286
|
json = JSON.stringify(message);
|
|
@@ -309,6 +316,10 @@ export function installElementsForward(deps) {
|
|
|
309
316
|
function pushDocumentUpdated() {
|
|
310
317
|
if (disposed || devtoolsWc.isDestroyed())
|
|
311
318
|
return;
|
|
319
|
+
// Same unified settled gate as dispatchToFrontend — render events (the
|
|
320
|
+
// activePage burst) drive this during churn.
|
|
321
|
+
if (!isFrontendSettled(devtoolsWc))
|
|
322
|
+
return;
|
|
312
323
|
let script;
|
|
313
324
|
try {
|
|
314
325
|
script = buildDocumentUpdatedScript();
|
|
@@ -594,6 +605,14 @@ export function installElementsForward(deps) {
|
|
|
594
605
|
stop();
|
|
595
606
|
return;
|
|
596
607
|
}
|
|
608
|
+
// An unsettled front-end can't run the script anyway, and executeJavaScript
|
|
609
|
+
// against it queues one did-stop-loading waiter PER TICK on the emitter
|
|
610
|
+
// (150ms × a seconds-long reload piles past the MaxListeners ceiling).
|
|
611
|
+
// MUST be the shared Electron-aligned predicate — a bare isLoading()
|
|
612
|
+
// probe diverges from Electron's internal isLoadingMainFrame gate and
|
|
613
|
+
// keeps piling. Skip; the first post-settle tick re-establishes the hook.
|
|
614
|
+
if (!isFrontendSettled(devtoolsWc))
|
|
615
|
+
return;
|
|
597
616
|
devtoolsWc
|
|
598
617
|
.executeJavaScript(buildReconcileScript())
|
|
599
618
|
.then((res) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DisposableRegistry, toDisposable } from '@dimina-kit/electron-deck/main';
|
|
2
|
+
import { isFrontendSettled } from '../views/inject-when-ready.js';
|
|
2
3
|
// ── requestId namespacing (pure, testable) ──────────────────────────────────
|
|
3
4
|
/**
|
|
4
5
|
* CDP events whose `params.requestId` we rewrite into the virtual namespace.
|
|
@@ -359,6 +360,18 @@ export function createNetworkForwarder(bridge) {
|
|
|
359
360
|
trimQueue();
|
|
360
361
|
return;
|
|
361
362
|
}
|
|
363
|
+
if (!isFrontendSettled(wc)) {
|
|
364
|
+
// An unsettled front-end can't run the dispatch script anyway — and every
|
|
365
|
+
// executeJavaScript against it queues one did-stop-loading waiter on the
|
|
366
|
+
// emitter, so a relaunch's network burst piles them past the MaxListeners
|
|
367
|
+
// ceiling. MUST be the shared Electron-aligned predicate (a bare
|
|
368
|
+
// isLoading() probe diverges from the internal isLoadingMainFrame gate).
|
|
369
|
+
// Hold the (bounded) queue; the next event's flush or the ready-retry
|
|
370
|
+
// delivers after the load.
|
|
371
|
+
trimQueue();
|
|
372
|
+
scheduleReadyRetry();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
362
375
|
if (sink === 'idle')
|
|
363
376
|
beginProbing();
|
|
364
377
|
// Pack greedily up to MAX_BATCH_CHARS so one executeJavaScript stays sized;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { BrowserWindow, WebContentsView } from 'electron';
|
|
2
|
+
/**
|
|
3
|
+
* Detach a child overlay WebContentsView from the main window's contentView and
|
|
4
|
+
* close its webContents. Tolerates an already-removed view and a destroyed
|
|
5
|
+
* webContents (best-effort teardown). The single low-level view-destroy path
|
|
6
|
+
* shared by every overlay domain.
|
|
7
|
+
*/
|
|
8
|
+
export declare function destroyChildView(mainWindow: BrowserWindow, view: WebContentsView | null): void;
|
|
9
|
+
//# sourceMappingURL=destroy-child-view.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detach a child overlay WebContentsView from the main window's contentView and
|
|
3
|
+
* close its webContents. Tolerates an already-removed view and a destroyed
|
|
4
|
+
* webContents (best-effort teardown). The single low-level view-destroy path
|
|
5
|
+
* shared by every overlay domain.
|
|
6
|
+
*/
|
|
7
|
+
export function destroyChildView(mainWindow, view) {
|
|
8
|
+
if (!view)
|
|
9
|
+
return;
|
|
10
|
+
if (!mainWindow.isDestroyed()) {
|
|
11
|
+
try {
|
|
12
|
+
mainWindow.contentView.removeChildView(view);
|
|
13
|
+
}
|
|
14
|
+
catch { /* already removed */ }
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
if (!view.webContents.isDestroyed()) {
|
|
18
|
+
view.webContents.close();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch { /* ignore */ }
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=destroy-child-view.js.map
|