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
32 changes: 30 additions & 2 deletions app/src/__tests__/app-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ const connection = vi.hoisted(() => ({
answers: [] as boolean[],
/** Sends a `connection:status` event to the last connected client's listeners. */
emitStatus: (_status: string) => {},
/** The options each attempt passed to `connectDevframe`. */
options: [] as unknown[],
/** How many clients the panel closed. */
closed: 0,
}));

function fakeClient(): DevframeRpcClient {
Expand All @@ -27,12 +31,15 @@ function fakeClient(): DevframeRpcClient {
return () => {};
},
},
close: () => {},
close: () => {
connection.closed++;
},
} as unknown as DevframeRpcClient;
}

vi.mock('devframe/client', () => ({
connectDevframe: () => {
connectDevframe: (options?: unknown) => {
connection.options.push(options);
const ok = connection.answers[connection.attempts++] ?? false;
return ok ? Promise.resolve(fakeClient()) : Promise.reject(new Error('no server'));
},
Expand All @@ -55,6 +62,8 @@ afterEach(() => {
document.body.innerHTML = '';
connection.attempts = 0;
connection.answers = [];
connection.options = [];
connection.closed = 0;
TestBed.resetTestingModule();
});

Expand Down Expand Up @@ -112,6 +121,25 @@ describe('App connection retry', () => {
fixture.destroy();
});

it('caches only build-meta, in a new client after the connection drops', async () => {
connection.answers = [true, true];
const fixture = TestBed.createComponent(App);
await settle(fixture);
expect(connection.options[0]).toMatchObject({
cacheOptions: { functions: ['pangular:build-meta'] },
});

// A restarted server gets a new client, so nothing cached before is reused.
connection.emitStatus('disconnected');
await settle(fixture);
expect(connection.closed).toBe(1);
await vi.waitFor(() => expect(connection.attempts).toBe(2), { timeout: 3000 });
expect(connection.options[1]).toMatchObject({
cacheOptions: { functions: ['pangular:build-meta'] },
});
fixture.destroy();
});

it('stops retrying once the panel is gone', async () => {
connection.answers = [false, true];
const fixture = TestBed.createComponent(App);
Expand Down
7 changes: 5 additions & 2 deletions app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import { hostPageId, scopeToPage } from './page-id';
import { angularNativePage, type PlatformPage } from './native-page';
import { initialTab, storeTab, storedTab } from './tab-memory';
import { detectBaseURL } from './base-url';
import { clearHighlightsOnHide, rpcTry } from './rpc';
import { clearHighlightsOnHide, panelCacheOptions, rpcTry } from './rpc';
import { EditorOpener } from './ui/source-link';
import { viewAccent } from './view-accents';
import {
Expand Down Expand Up @@ -1279,7 +1279,10 @@ export class App implements OnInit, OnDestroy {
this.connecting = true;
clearTimeout(this.retryTimer);
const baseURL = detectBaseURL();
connectDevframe(baseURL ? { baseURL } : {}).then(
connectDevframe({
...(baseURL ? { baseURL } : {}),
cacheOptions: panelCacheOptions(),
}).then(
(client) => {
this.connecting = false;
if (this.destroyed) {
Expand Down
10 changes: 10 additions & 0 deletions app/src/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { PANEL_CACHED_RPC } from '@pangular-inspector/devtools/config';
import type { DevframeRpcClient } from 'devframe/client';

/**
* The panel client's devframe `cacheOptions`: an explicit list, so only the
* functions in `PANEL_CACHED_RPC` are cached, never whatever else the server
* advertises as cacheable. devframe clears it when the connection changes.
*/
export function panelCacheOptions(): { functions: string[] } {
return { functions: [...PANEL_CACHED_RPC] };
}

/** Calls a `pangular` RPC; resolves `null` without a client, rejections propagate. */
export function rpcCall(
client: DevframeRpcClient | null,
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/src/content/contributing/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ If a code change needs no docs change, add the `no-docs` label to the pull reque
3. Map it to its inspector in `RPC_INSPECTOR` in `packages/devtools/src/config.ts`, so turning the inspector off removes it.
4. Call it from the UI in `app/src/pages/`.

The panel's devframe client caches the functions listed in `PANEL_CACHED_RPC` in `packages/devtools/src/config.ts`, and only those. A cached call is answered from the client until the connection drops, so a restarted server is asked again. Add a function there only when its answer can't change while the server runs, and give it `type: 'static'` or `cacheable: true`. Live page data and source scans never qualify.

### Add a tab

1. Create a component in `app/src/pages/`.
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/src/content/inspectors/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ The Forms and SSR & HTTP cards read the live page only, and update while it runs

If the project block says **Project details unavailable**, check that the dev server is running, then click **Try again**. In a [static report](../getting-started/cli.md#static-report) there is no **Try again** button, so run `pangular build` again to rebuild the report.

The panel reads the project details once per connection and keeps them. If you upgrade a package or edit `angular.json` while the dev server runs, restart the dev server or reload the panel to see the change.

## FAQ

<ngmd-accordion>
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion extension/ui/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

164 changes: 164 additions & 0 deletions packages/devtools/src/__tests__/panel-rpc-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { defineDevframe } from 'devframe';
import { createDevServer } from 'devframe/adapters/dev';
import { connectDevframe, type DevframeRpcClient } from 'devframe/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PANEL_CACHED_RPC, RPC_INSPECTOR, SHARED_RPC } from '../config.ts';
import { getBuildMeta } from '../rpc/build-meta.ts';

type Started = Awaited<ReturnType<typeof createDevServer>>;

/**
* A devframe server with the real `build-meta` and a live query, each counting
* the calls that reach the server, so the tests see what the client cached.
*/
async function startServer(cwd: string, calls: Map<string, number>): Promise<Started> {
const count = (name: string) => calls.set(name, (calls.get(name) ?? 0) + 1);
const definition = defineDevframe({
id: 'pangular',
name: 'Pangular Inspector',
version: '0.0.0',
packageName: '@pangular-inspector/devtools',
homepage: 'https://pangular-inspector.dev',
description: 'Cache test server',
setup(ctx) {
const my = ctx.scope('pangular');
my.rpc.register({
...getBuildMeta,
setup: async (setupCtx) => {
// The dev server's context reads process.cwd(); point it at the fixture.
const { handler } = await getBuildMeta.setup!({ ...setupCtx, cwd });
return {
handler: (...args: []) => {
count('build-meta');
return handler!(...args);
},
};
},
});
my.rpc.register({
name: 'list-pages',
type: 'query',
jsonSerializable: true,
handler: () => {
count('list-pages');
return [];
},
});
},
});
return createDevServer(definition, {
host: '127.0.0.1',
port: 0,
auth: false,
mcp: false,
openBrowser: false,
});
}

function scoped(client: DevframeRpcClient) {
return client.scope('pangular').rpc as unknown as {
call: (name: string) => Promise<unknown>;
};
}

describe('panel RPC cache', () => {
let cwd: string;
const servers: Started[] = [];
const clients: DevframeRpcClient[] = [];

beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'pangular-cache-'));
writeFileSync(join(cwd, 'package.json'), JSON.stringify({ name: 'cache-demo' }));
});

afterEach(async () => {
for (const client of clients.splice(0)) client.close?.();
for (const server of servers.splice(0)) await server.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
vi.unstubAllGlobals();
});

async function connect(server: Started): Promise<DevframeRpcClient> {
// The browser client resolves the socket URL against its page location.
vi.stubGlobal('location', new URL(`${server.origin}/`));
// It also reuses the connection an earlier client stored on the global
// object, which points at an earlier server here.
const stored = globalThis as Record<string, unknown>;
delete stored['__DEVFRAME_CONNECTION__'];
delete stored['__DEVFRAME_CONNECTION_META__'];
Comment thread
erkamyaman marked this conversation as resolved.
const client = await connectDevframe({
baseURL: `${server.origin}/`,
cacheOptions: { functions: [...PANEL_CACHED_RPC] },
webmcp: false,
callTimeout: 5000,
});
clients.push(client);
await client.ensureTrusted();
return client;
}

async function start(calls: Map<string, number>): Promise<Started> {
const server = await startServer(cwd, calls);
servers.push(server);
return server;
}

it('lists only static or cacheable functions the panel server registers', () => {
expect(PANEL_CACHED_RPC).toEqual(['pangular:build-meta']);
expect(getBuildMeta.type).toBe('static');
expect(getBuildMeta.cacheable).toBe(true);
for (const name of PANEL_CACHED_RPC) {
const bare = name.replace(/^pangular:/, '');
expect(SHARED_RPC.includes(bare) || bare in RPC_INSPECTOR).toBe(true);
}
});

it('answers a repeat build-meta call from the cache', async () => {
const calls = new Map<string, number>();
const client = await connect(await start(calls));
const first = (await scoped(client).call('build-meta')) as { projectName: string };
const second = await scoped(client).call('build-meta');
expect(first.projectName).toBe('cache-demo');
expect(second).toEqual(first);
expect(calls.get('build-meta')).toBe(1);
expect(client.cacheManager.has('pangular:build-meta', [])).toBe(true);
});

it('sends every call of a function outside the list to the server', async () => {
const calls = new Map<string, number>();
const client = await connect(await start(calls));
await scoped(client).call('list-pages');
await scoped(client).call('list-pages');
await scoped(client).call('list-pages');
expect(calls.get('list-pages')).toBe(3);
expect(client.cacheManager.has('pangular:list-pages', [])).toBe(false);
});

it('asks a restarted server again instead of serving the old answer', async () => {
const before = new Map<string, number>();
const server = await start(before);
const client = await connect(server);
await scoped(client).call('build-meta');
expect(client.cacheManager.has('pangular:build-meta', [])).toBe(true);

const dropped = new Promise<void>((resolve) =>
client.events.on('connection:status', (status) => {
if (status !== 'connected') resolve();
}),
);
await server.close();
await dropped;
// devframe clears the cache when the connection status changes.
expect(client.cacheManager.has('pangular:build-meta', [])).toBe(false);

writeFileSync(join(cwd, 'package.json'), JSON.stringify({ name: 'renamed-demo' }));
const after = new Map<string, number>();
const next = await connect(await start(after));
const meta = (await scoped(next).call('build-meta')) as { projectName: string };
expect(meta.projectName).toBe('renamed-demo');
expect(after.get('build-meta')).toBe(1);
});
});
11 changes: 11 additions & 0 deletions packages/devtools/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,17 @@ export const SHARED_RPC: readonly string[] = [
'axe-result',
];

/**
* Server RPC functions the panel's devframe client caches (its `cacheOptions`),
* so a repeat call is answered without a round trip. Only functions whose
* answer can't change while the server runs belong here, each one `static` or
* marked `cacheable: true`. Live page data and source scans never do: they
* change as the app runs and as files are edited. devframe drops the cache
* when the connection drops, and the panel connects a new client then, so a
* restarted server is asked again.
*/
export const PANEL_CACHED_RPC: readonly string[] = ['pangular:build-meta'];

/**
* The inspector each server RPC function belongs to. A function missing here
* is shared and always registered.
Expand Down
5 changes: 5 additions & 0 deletions packages/devtools/src/rpc/build-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ const BuildMetaSchema = v.object({
export const getBuildMeta = defineRpcFunction({
name: 'build-meta',
type: 'static',
// Versions, project name and SSR come from package.json, node_modules and
// angular.json, which a running dev server doesn't reload, so the panel
// caches the answer for the connection (PANEL_CACHED_RPC). `static` already
// implies it for devframe; the flag says so here.
cacheable: true,
jsonSerializable: true,
snapshot: true,
args: [],
Expand Down
Loading