Skip to content

Commit c61b660

Browse files
Copilothotlong
andcommitted
feat: complete debug panel with Perf/Expr/Events tabs, DebugCollector, SchemaRenderer debug attrs, MetadataInspector auto-open
- @object-ui/core: add DebugCollector (perf/expr/event data collection, tree-shakeable singleton) - @object-ui/components: add Perf/Expr/Events tabs to DebugPanel (7 built-in tabs total) - @object-ui/react: SchemaRenderer injects data-debug-type/id attrs + reports render perf to DebugCollector when debug enabled - apps/console: MetadataInspector auto-opens when ?__debug URL param is present - Add 16 new tests (DebugCollector: 10, DebugPanel tabs: 3, SchemaRenderer debug: 3) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent bc98505 commit c61b660

9 files changed

Lines changed: 441 additions & 4 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -865,7 +865,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
865865
- [x] **P2: Full Examples Metadata Audit** — Systematic spec compliance audit across all 4 examples: added `type: 'dashboard'` + `description` to todo/kitchen-sink dashboards, refactored msw-todo to use `ObjectSchema.create` + `Field.*` with snake_case field names, added explicit views to kitchen-sink and msw-todo, added missing `successMessage` on CRM opportunity action, 21 automated compliance tests
866866
- [x] **P2: CRM Dashboard Full provider:'object' Adaptation** — Converted all chart and table widgets in CRM dashboard from static `provider: 'value'` to dynamic `provider: 'object'` with aggregation configs. 12 widgets total: 4 KPI metrics (static), 7 charts (sum/count/avg/max aggregation across opportunity, product, order objects), 1 table (dynamic fetch). Cross-object coverage (order), diverse aggregate functions (sum, count, avg, max). Fixed table `close_date` field alignment. Added i18n for 2 new widgets (10 locales). 9 new CRM metadata tests, 6 new DashboardRenderer rendering tests (area/donut/line/cross-object + edge cases). All provider:'object' paths covered.
867867
- [x] **P1: Dashboard provider:'object' Crash & Blank Rendering Fixes** — Fixed 3 critical bugs causing all charts to be blank and tables to crash on provider:'object' dashboards: (1) DashboardRenderer `...options` spread was leaking provider config objects as `data` in data-table and pivot schemas — fixed by destructuring `data` out before spread, (2) DataTableRenderer and PivotTable now guard with `Array.isArray()` for graceful degradation when non-array data arrives, (3) ObjectChart now shows visible loading/warning messages instead of silently rendering blank when `dataSource` is missing. Also added provider:'object' support to DashboardGridLayout (charts, tables, pivots). 2 new regression tests.
868-
- [x] **P1: URL-Driven Debug/Developer Panel** — Universal debug mode activated via `?__debug` URL parameter (amis devtools-style). `@object-ui/core`: exported `DebugFlags`, `parseDebugFlags()`, enhanced `isDebugEnabled()` (URL → globalThis → env resolution, SSR-safe). `@object-ui/react`: `useDebugMode` hook with URL detection, Ctrl+Shift+D shortcut, manual toggle; `SchemaRendererContext` extended with `debugFlags`. `@object-ui/components`: floating `DebugPanel` with Schema/Data/Registry/Flags tabs, plugin-extensible via `extraTabs`. Fine-grained sub-flags: `?__debug_schema`, `?__debug_perf`, `?__debug_data`, `?__debug_expr`, `?__debug_events`, `?__debug_registry`. 32 new tests.
868+
- [x] **P1: URL-Driven Debug/Developer Panel** — Universal debug mode activated via `?__debug` URL parameter (amis devtools-style). `@object-ui/core`: exported `DebugFlags`, `DebugCollector` (perf/expr/event data collection, tree-shakeable), `parseDebugFlags()`, enhanced `isDebugEnabled()` (URL → globalThis → env resolution, SSR-safe). `@object-ui/react`: `useDebugMode` hook with URL detection, Ctrl+Shift+D shortcut, manual toggle; `SchemaRendererContext` extended with `debugFlags`; `SchemaRenderer` injects `data-debug-type`/`data-debug-id` attrs + reports render perf to `DebugCollector` when debug enabled. `@object-ui/components`: floating `DebugPanel` with 7 built-in tabs (Schema, Data, Perf, Expr, Events, Registry, Flags), plugin-extensible via `extraTabs`. Console `MetadataInspector` auto-opens when `?__debug` detected. Fine-grained sub-flags: `?__debug_schema`, `?__debug_perf`, `?__debug_data`, `?__debug_expr`, `?__debug_events`, `?__debug_registry`. 48 new tests.
869869

870870
### Ecosystem & Marketplace
871871
- Plugin marketplace website with search, ratings, and install count

apps/console/src/components/MetadataInspector.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
* Used across ObjectView, DashboardView, PageView, ReportView, and RecordDetailView.
66
*/
77

8-
import { useState } from 'react';
8+
import { useState, useMemo } from 'react';
99
import { Button } from '@object-ui/components';
1010
import { Code2, Copy, Check, ChevronDown, ChevronRight } from 'lucide-react';
11+
import { parseDebugFlags } from '@object-ui/core';
1112

1213
interface MetadataSection {
1314
title: string;
@@ -128,9 +129,19 @@ export function MetadataPanel({ sections, open }: Omit<MetadataInspectorProps, '
128129

129130
/**
130131
* Hook to manage MetadataInspector open/close state.
132+
* Automatically opens when `?__debug` URL parameter is present.
131133
*/
132134
export function useMetadataInspector() {
133-
const [showDebug, setShowDebug] = useState(false);
135+
const autoOpen = useMemo(() => {
136+
try {
137+
return typeof window !== 'undefined'
138+
? parseDebugFlags(window.location.search).enabled
139+
: false;
140+
} catch {
141+
return false;
142+
}
143+
}, []);
144+
const [showDebug, setShowDebug] = useState(autoOpen);
134145
return {
135146
showDebug,
136147
toggleDebug: () => setShowDebug(prev => !prev),

packages/components/src/debug/DebugPanel.tsx

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
import React, { useState, useCallback, useMemo } from 'react';
1010
import type { DebugFlags } from '@object-ui/core';
11-
import { ComponentRegistry } from '@object-ui/core';
11+
import { ComponentRegistry, DebugCollector } from '@object-ui/core';
12+
import type { PerfEntry, ExprEntry, EventEntry, DebugEntry } from '@object-ui/core';
1213
import { cn } from '../lib/utils';
1314

1415
/* ------------------------------------------------------------------ */
@@ -106,6 +107,107 @@ function FlagsTab({ flags }: { flags?: DebugFlags }) {
106107
);
107108
}
108109

110+
/* ------------------------------------------------------------------ */
111+
/* Collector-backed tabs (Perf / Expr / Events) */
112+
/* ------------------------------------------------------------------ */
113+
114+
function useCollectorEntries(kind?: DebugEntry['kind']): DebugEntry[] {
115+
const collector = DebugCollector.getInstance();
116+
const [entries, setEntries] = useState<DebugEntry[]>(() => collector.getEntries(kind));
117+
118+
React.useEffect(() => {
119+
// Sync on mount in case entries were added before subscribe
120+
setEntries(collector.getEntries(kind));
121+
const unsub = collector.subscribe(() => {
122+
setEntries(collector.getEntries(kind));
123+
});
124+
return unsub;
125+
}, [collector, kind]);
126+
127+
return entries;
128+
}
129+
130+
function PerfTab() {
131+
const entries = useCollectorEntries('perf');
132+
const perfItems = entries.map((e) => e.data as PerfEntry);
133+
134+
if (perfItems.length === 0) {
135+
return <p className="text-xs text-muted-foreground italic">No performance data collected yet</p>;
136+
}
137+
return (
138+
<div className="space-y-1 max-h-[60vh] overflow-auto">
139+
{perfItems.map((p, i) => (
140+
<div
141+
key={i}
142+
className={cn(
143+
'flex items-center justify-between px-2 py-1 rounded text-xs font-mono',
144+
p.durationMs > 16 ? 'bg-red-50 text-red-700' : 'bg-muted/30',
145+
)}
146+
>
147+
<span className="truncate mr-2">{p.type}{p.id ? `:${p.id}` : ''}</span>
148+
<span className="shrink-0 tabular-nums">{p.durationMs.toFixed(2)}ms</span>
149+
</div>
150+
))}
151+
<p className="text-[10px] text-muted-foreground mt-2">
152+
{perfItems.length} render{perfItems.length !== 1 ? 's' : ''} tracked
153+
</p>
154+
</div>
155+
);
156+
}
157+
158+
function ExprTab() {
159+
const entries = useCollectorEntries('expr');
160+
const exprItems = entries.map((e) => e.data as ExprEntry);
161+
162+
if (exprItems.length === 0) {
163+
return <p className="text-xs text-muted-foreground italic">No expression evaluations tracked yet</p>;
164+
}
165+
return (
166+
<div className="space-y-1.5 max-h-[60vh] overflow-auto">
167+
{exprItems.map((ex, i) => (
168+
<div key={i} className="px-2 py-1.5 rounded bg-muted/30 text-xs font-mono">
169+
<div className="text-muted-foreground truncate">{ex.expression}</div>
170+
<div className="mt-0.5">{JSON.stringify(ex.result)}</div>
171+
</div>
172+
))}
173+
<p className="text-[10px] text-muted-foreground mt-2">
174+
{exprItems.length} evaluation{exprItems.length !== 1 ? 's' : ''} tracked
175+
</p>
176+
</div>
177+
);
178+
}
179+
180+
function EventsTab() {
181+
const entries = useCollectorEntries('event');
182+
const eventItems = entries.map((e) => e.data as EventEntry);
183+
184+
if (eventItems.length === 0) {
185+
return <p className="text-xs text-muted-foreground italic">No events captured yet</p>;
186+
}
187+
return (
188+
<div className="space-y-1.5 max-h-[60vh] overflow-auto">
189+
{eventItems.map((ev, i) => (
190+
<div key={i} className="px-2 py-1.5 rounded bg-muted/30 text-xs font-mono">
191+
<div className="flex items-center justify-between">
192+
<span className="font-semibold">{ev.action}</span>
193+
<span className="text-[10px] text-muted-foreground tabular-nums">
194+
{new Date(ev.timestamp).toLocaleTimeString()}
195+
</span>
196+
</div>
197+
{ev.payload !== undefined && (
198+
<pre className="mt-0.5 text-[10px] text-muted-foreground truncate">
199+
{JSON.stringify(ev.payload)}
200+
</pre>
201+
)}
202+
</div>
203+
))}
204+
<p className="text-[10px] text-muted-foreground mt-2">
205+
{eventItems.length} event{eventItems.length !== 1 ? 's' : ''} captured
206+
</p>
207+
</div>
208+
);
209+
}
210+
109211
/* ------------------------------------------------------------------ */
110212
/* DebugPanel */
111213
/* ------------------------------------------------------------------ */
@@ -116,6 +218,9 @@ function FlagsTab({ flags }: { flags?: DebugFlags }) {
116218
* Built-in tabs:
117219
* - **Schema** — current rendered JSON schema
118220
* - **Data** — active data context
221+
* - **Perf** — component render timing (highlights slow renders >16ms)
222+
* - **Expr** — expression evaluation trace
223+
* - **Events** — action/event timeline
119224
* - **Registry** — all registered component types
120225
* - **Flags** — current debug flags
121226
*
@@ -133,6 +238,9 @@ export function DebugPanel({
133238
const builtInTabs: DebugPanelTab[] = useMemo(() => [
134239
{ id: 'schema', label: 'Schema', render: () => <SchemaTab schema={schema} /> },
135240
{ id: 'data', label: 'Data', render: () => <DataTab dataContext={dataContext} /> },
241+
{ id: 'perf', label: 'Perf', render: () => <PerfTab /> },
242+
{ id: 'expr', label: 'Expr', render: () => <ExprTab /> },
243+
{ id: 'events', label: 'Events', render: () => <EventsTab /> },
136244
{ id: 'registry', label: 'Registry', render: () => <RegistryTab /> },
137245
{ id: 'flags', label: 'Flags', render: () => <FlagsTab flags={flags} /> },
138246
], [schema, dataContext, flags]);

packages/components/src/debug/__tests__/DebugPanel.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ describe('DebugPanel', () => {
2828
render(<DebugPanel open={true} onClose={vi.fn()} />);
2929
expect(screen.getByTestId('debug-tab-schema')).toBeInTheDocument();
3030
expect(screen.getByTestId('debug-tab-data')).toBeInTheDocument();
31+
expect(screen.getByTestId('debug-tab-perf')).toBeInTheDocument();
32+
expect(screen.getByTestId('debug-tab-expr')).toBeInTheDocument();
33+
expect(screen.getByTestId('debug-tab-events')).toBeInTheDocument();
3134
expect(screen.getByTestId('debug-tab-registry')).toBeInTheDocument();
3235
expect(screen.getByTestId('debug-tab-flags')).toBeInTheDocument();
3336
});
@@ -110,4 +113,22 @@ describe('DebugPanel', () => {
110113
expect(panel).toHaveAttribute('role', 'dialog');
111114
expect(panel).toHaveAttribute('aria-label', 'Developer Debug Panel');
112115
});
116+
117+
it('should show empty state for Perf tab', () => {
118+
render(<DebugPanel open={true} onClose={vi.fn()} />);
119+
fireEvent.click(screen.getByTestId('debug-tab-perf'));
120+
expect(screen.getByTestId('debug-panel-content').textContent).toContain('No performance data');
121+
});
122+
123+
it('should show empty state for Expr tab', () => {
124+
render(<DebugPanel open={true} onClose={vi.fn()} />);
125+
fireEvent.click(screen.getByTestId('debug-tab-expr'));
126+
expect(screen.getByTestId('debug-panel-content').textContent).toContain('No expression evaluations');
127+
});
128+
129+
it('should show empty state for Events tab', () => {
130+
render(<DebugPanel open={true} onClose={vi.fn()} />);
131+
fireEvent.click(screen.getByTestId('debug-tab-events'));
132+
expect(screen.getByTestId('debug-panel-content').textContent).toContain('No events captured');
133+
});
113134
});

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ export * from './theme/index.js';
2424
export * from './data-scope/index.js';
2525
export * from './errors/index.js';
2626
export * from './utils/debug.js';
27+
export * from './utils/debug-collector.js';
2728
export * from './protocols/index.js';
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect, vi, beforeEach } from 'vitest';
10+
import { DebugCollector } from '../debug-collector';
11+
12+
describe('DebugCollector', () => {
13+
beforeEach(() => {
14+
DebugCollector.resetInstance();
15+
});
16+
17+
it('should return a singleton instance', () => {
18+
const a = DebugCollector.getInstance();
19+
const b = DebugCollector.getInstance();
20+
expect(a).toBe(b);
21+
});
22+
23+
it('should collect perf entries', () => {
24+
const collector = DebugCollector.getInstance();
25+
collector.addPerf({ type: 'button', id: 'btn1', durationMs: 5.2, timestamp: Date.now() });
26+
const entries = collector.getEntries('perf');
27+
expect(entries).toHaveLength(1);
28+
expect(entries[0].kind).toBe('perf');
29+
expect((entries[0].data as any).type).toBe('button');
30+
});
31+
32+
it('should collect expr entries', () => {
33+
const collector = DebugCollector.getInstance();
34+
collector.addExpr({ expression: '${data.x > 1}', result: true, timestamp: Date.now() });
35+
const entries = collector.getEntries('expr');
36+
expect(entries).toHaveLength(1);
37+
expect(entries[0].kind).toBe('expr');
38+
expect((entries[0].data as any).result).toBe(true);
39+
});
40+
41+
it('should collect event entries', () => {
42+
const collector = DebugCollector.getInstance();
43+
collector.addEvent({ action: 'navigate', payload: { to: '/home' }, timestamp: Date.now() });
44+
const entries = collector.getEntries('event');
45+
expect(entries).toHaveLength(1);
46+
expect(entries[0].kind).toBe('event');
47+
expect((entries[0].data as any).action).toBe('navigate');
48+
});
49+
50+
it('should return all entries when no kind filter', () => {
51+
const collector = DebugCollector.getInstance();
52+
collector.addPerf({ type: 'text', durationMs: 1, timestamp: Date.now() });
53+
collector.addExpr({ expression: 'a', result: 1, timestamp: Date.now() });
54+
collector.addEvent({ action: 'click', timestamp: Date.now() });
55+
expect(collector.getEntries()).toHaveLength(3);
56+
});
57+
58+
it('should notify subscribers on new entry', () => {
59+
const collector = DebugCollector.getInstance();
60+
const fn = vi.fn();
61+
collector.subscribe(fn);
62+
collector.addPerf({ type: 'card', durationMs: 2, timestamp: Date.now() });
63+
expect(fn).toHaveBeenCalledTimes(1);
64+
expect(fn).toHaveBeenCalledWith(expect.objectContaining({ kind: 'perf' }));
65+
});
66+
67+
it('should allow unsubscribe', () => {
68+
const collector = DebugCollector.getInstance();
69+
const fn = vi.fn();
70+
const unsub = collector.subscribe(fn);
71+
unsub();
72+
collector.addPerf({ type: 'x', durationMs: 0, timestamp: Date.now() });
73+
expect(fn).not.toHaveBeenCalled();
74+
});
75+
76+
it('should clear entries', () => {
77+
const collector = DebugCollector.getInstance();
78+
collector.addPerf({ type: 'x', durationMs: 0, timestamp: Date.now() });
79+
collector.addExpr({ expression: 'a', result: 1, timestamp: Date.now() });
80+
collector.clear();
81+
expect(collector.getEntries()).toHaveLength(0);
82+
});
83+
84+
it('should cap entries at MAX_ENTRIES', () => {
85+
const collector = DebugCollector.getInstance();
86+
for (let i = 0; i < 250; i++) {
87+
collector.addPerf({ type: `c${i}`, durationMs: i, timestamp: Date.now() });
88+
}
89+
// MAX_ENTRIES is 200
90+
expect(collector.getEntries().length).toBeLessThanOrEqual(200);
91+
});
92+
93+
it('should swallow subscriber errors gracefully', () => {
94+
const collector = DebugCollector.getInstance();
95+
const badFn = vi.fn(() => { throw new Error('boom'); });
96+
const goodFn = vi.fn();
97+
collector.subscribe(badFn);
98+
collector.subscribe(goodFn);
99+
collector.addPerf({ type: 'x', durationMs: 0, timestamp: Date.now() });
100+
expect(goodFn).toHaveBeenCalledTimes(1);
101+
});
102+
});

0 commit comments

Comments
 (0)