diff --git a/app/src/__tests__/di-refresh-hints.test.ts b/app/src/__tests__/di-refresh-hints.test.ts
new file mode 100644
index 00000000..97bd963a
--- /dev/null
+++ b/app/src/__tests__/di-refresh-hints.test.ts
@@ -0,0 +1,260 @@
+import { type ComponentFixture, TestBed } from '@angular/core/testing';
+import type { DevframeRpcClient } from 'devframe/client';
+import { afterEach, describe, expect, it } from 'vitest';
+import { DiInspector } from '../pages/di-inspector';
+
+const provider = (token: string) => ({ token, type: 'class', isViewProvider: false });
+
+const element = (id: string, name: string, children: unknown[] = [], deps: string[] = []) => ({
+ injector: { id, type: 'element', name, providerCount: 0, component: name },
+ providers: [],
+ children,
+ dependencies: deps.map((token) => ({ from: name, token, flags: [], providedBy: 'inj-r' })),
+});
+
+const liveTree = {
+ pages: {
+ p1: {
+ pageId: 'p1',
+ reportedAt: 1,
+ roots: [element('inj-1', 'app-root', [element('inj-2', 'app-card', [], ['CartStore'])])],
+ environment: [
+ {
+ injector: { id: 'inj-r', type: 'environment', name: 'Root', providerCount: 2 },
+ providers: [provider('CartStore'), provider('Settings')],
+ children: [
+ {
+ injector: {
+ id: 'inj-a',
+ type: 'environment',
+ name: 'Route: admin',
+ providerCount: 1,
+ inactive: true,
+ },
+ providers: [provider('AdminApi')],
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
+ },
+};
+
+const source = [
+ {
+ token: 'CartStore',
+ source: 'class',
+ file: 'src/cart.ts',
+ line: 3,
+ providedIn: 'root',
+ type: 'injectable',
+ },
+ {
+ token: 'Settings',
+ source: 'class',
+ file: 'src/settings.ts',
+ line: 1,
+ providedIn: 'root',
+ type: 'injectable',
+ },
+ {
+ token: 'Unused',
+ source: 'class',
+ file: 'src/unused.ts',
+ line: 2,
+ providedIn: 'root',
+ type: 'injectable',
+ },
+ {
+ token: 'PANEL_TITLE',
+ source: 'providers array',
+ file: 'src/app.ts',
+ line: 9,
+ type: 'provider',
+ },
+ {
+ token: 'Router',
+ source: 'provideRouter()',
+ file: 'src/app.config.ts',
+ line: 4,
+ providedIn: 'root',
+ type: 'root-provider',
+ },
+ {
+ token: 'provideFeature()',
+ source: 'provideFeature()',
+ file: 'src/app.config.ts',
+ line: 5,
+ type: 'helper',
+ },
+ { token: 'CartStore', source: 'cart', file: 'src/card.ts', line: 7, type: 'injection' },
+];
+
+const calls: { name: string; payload: unknown }[] = [];
+
+function client(tree: unknown): DevframeRpcClient {
+ const rpc = {
+ call: (name: string, payload: unknown) => {
+ calls.push({ name, payload });
+ if (name === 'get-providers') return Promise.resolve(source);
+ if (name === 'request-injector-refresh') return Promise.resolve({ ok: true });
+ return Promise.resolve([]);
+ },
+ callEvent: () => Promise.resolve(),
+ sharedState: () => Promise.resolve({ value: () => tree, on: () => () => {} }),
+ };
+ return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
+}
+
+async function settle(fixture: ComponentFixture
+ Provided in your source, but nothing on this page has injected them so far. This is + not an error: a guard, an interceptor or code that has not run yet may still use them. +
++ Inactive: the router loaded this route's injector, but no element on the page uses + it now. It keeps the services it created until the app is reloaded. +
+ }Angular asks these injectors in order until one has the token.
@@ -492,6 +529,42 @@ function isTree(value: unknown): value is InjectorNode[] { } @else { not provided anywhere } + ++ provide*() calls the scan can't resolve to the tokens they register, such as your + own provider functions. +
+ } @else { ++ {{ + element.directives.join(', ') || '<' + element.tag + '>' + }} + <{{ element.tag }}> + directives, no component +
} @if (graph() && picked() && graph()!.source !== 'selected') {
@@ -417,6 +411,29 @@ const KIND_TOKENS: Record{{ change.value | json }}
+ @if (diffs().get(change.epoch); as diff) {
+ Changes from the entry before ({{ diff.length }})
+
+ @for (d of diff; track d.path) {
+
+ {{ d.path }},
+ @if (d.kind !== 'added') {
+ {{ d.before | json }}
+ }
+ @if (d.kind === 'changed') {
+ to
+ }
+ @if (d.kind !== 'removed') {
+ {{ d.after | json }}
+ }
+
@for (frame of change.stack; track $index) {
@@ -753,6 +770,35 @@ const KIND_TOKENS: Record
+ @for (c of match.candidates; track c.file + c.line) {
+
+ }
+