From 7b6f1b9c50e5d6be83bbc517ba14bb5dda9d1d65 Mon Sep 17 00:00:00 2001 From: mitul-s Date: Mon, 15 Jun 2026 14:30:02 -0400 Subject: [PATCH 1/9] fix(web-shared): stop data inspector duplicating expanded objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expanded objects/arrays now render bracket delimiters ({ … } / [ … ]) instead of repeating the inline preview alongside the child tree. Co-authored-by: Cursor --- .../data-inspector-expanded-brackets.md | 5 + .../src/components/ui/data-inspector.tsx | 107 ++++++++++++++++-- 2 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 .changeset/data-inspector-expanded-brackets.md diff --git a/.changeset/data-inspector-expanded-brackets.md b/.changeset/data-inspector-expanded-brackets.md new file mode 100644 index 0000000000..6f131035e8 --- /dev/null +++ b/.changeset/data-inspector-expanded-brackets.md @@ -0,0 +1,5 @@ +--- +'@workflow/web-shared': patch +--- + +Fix the data inspector showing an object/array's contents twice when expanded: expanded nodes now render bracket delimiters (`{ … }` / `[ … ]`) instead of repeating the inline preview. diff --git a/packages/web-shared/src/components/ui/data-inspector.tsx b/packages/web-shared/src/components/ui/data-inspector.tsx index 3ad40d5d42..e0040fe6e2 100644 --- a/packages/web-shared/src/components/ui/data-inspector.tsx +++ b/packages/web-shared/src/components/ui/data-inspector.tsx @@ -409,6 +409,7 @@ function NodeRenderer({ name, data, isNonenumerable, + expanded, }: { depth: number; name?: string; @@ -500,6 +501,19 @@ function NodeRenderer({ ); } + // Expanded plain object / array → show just the opening bracket instead of + // the inline preview. react-inspector renders the children below regardless, + // so showing the preview here duplicates the same data on the label row. + if (expanded && isPlainContainer(data)) { + return ( + + ); + } + // Default rendering (same as react-inspector's built-in) if (depth === 0) { return ; @@ -509,6 +523,47 @@ function NodeRenderer({ ); } +/** + * True for values react-inspector renders as an expandable bracketed container + * that we want to show as `{ … }` / `[ … ]`: arrays and plain objects. Class + * instances (Date, Map, Set, Error, …) keep their descriptive labels. + */ +function isPlainContainer(data: unknown): boolean { + if (Array.isArray(data)) return true; + if (data === null || typeof data !== 'object') return false; + const proto = Object.getPrototypeOf(data); + return proto === Object.prototype || proto === null; +} + +/** + * Label for an expanded object/array: `[name: ]{` or `[name: ][`. The marker + * classes drive the CSS that renders the matching closing bracket on its own + * line below the children (see BRACKET_STYLES). + */ +function OpenBracketLabel({ + name, + isArray, + isNonenumerable, +}: { + name?: string; + isArray: boolean; + isNonenumerable?: boolean; +}) { + return ( + + {typeof name === 'string' && ( + <> + + : + + )} + {isArray ? '[' : '{'} + + ); +} + // --------------------------------------------------------------------------- // Public component // --------------------------------------------------------------------------- @@ -583,6 +638,33 @@ export function collapseRefs(data: unknown): unknown { return result; } +/** + * Renders the closing bracket for expanded objects/arrays on its own line. + * + * react-inspector's TreeNode markup is `
  • label
      children
  • ` + * and has no concept of a closing bracket, so we synthesize it with a `::after` + * pseudo-element on the expanded node. The bracket character is picked via + * `:has()` so arrays close with `]` and objects with `}`. The opening bracket + * (and these marker classes) are emitted by NodeRenderer only when the node is + * expanded, so the rule never matches collapsed nodes. + */ +const BRACKET_STYLES = ` +.wf-di-tree li[role="treeitem"]:has(> div > .wf-di-bracket)::after { + display: block; + /* Aligns the closing bracket with the opening bracket's line. 12px matches + react-inspector's TREENODE_PADDING_LEFT (the child indent), which is a + fixed value independent of the disclosure-arrow glyph width. */ + padding-left: 12px; + white-space: pre; +} +.wf-di-tree li[role="treeitem"]:has(> div > .wf-di-curly)::after { + content: "}"; +} +.wf-di-tree li[role="treeitem"]:has(> div > .wf-di-square)::after { + content: "]"; +} +`; + export interface DataInspectorProps { /** The data to inspect */ data: unknown; @@ -625,15 +707,22 @@ export function DataInspector({ const content = ( - + {/* React 19 hoists & dedupes this +
    + +
    ); From c7870cafa37b442ba7e29ea9e8d7046247ab4bd1 Mon Sep 17 00:00:00 2001 From: mitul-s Date: Tue, 16 Jun 2026 18:27:59 -0400 Subject: [PATCH 2/9] nice --- .../data-inspector-expanded-brackets.md | 2 +- packages/web-shared/package.json | 1 - .../src/components/ui/data-inspector.tsx | 840 ++++++++++++------ .../src/components/ui/inspector-theme.ts | 110 --- packages/web-shared/src/lib/hydration.ts | 4 +- 5 files changed, 588 insertions(+), 369 deletions(-) delete mode 100644 packages/web-shared/src/components/ui/inspector-theme.ts diff --git a/.changeset/data-inspector-expanded-brackets.md b/.changeset/data-inspector-expanded-brackets.md index 6f131035e8..98dbe46145 100644 --- a/.changeset/data-inspector-expanded-brackets.md +++ b/.changeset/data-inspector-expanded-brackets.md @@ -2,4 +2,4 @@ '@workflow/web-shared': patch --- -Fix the data inspector showing an object/array's contents twice when expanded: expanded nodes now render bracket delimiters (`{ … }` / `[ … ]`) instead of repeating the inline preview. +Rework the data inspector's JSON rendering to match the Vercel dashboard 1:1: bracket notation (`{ … }` / `[ … ]`), pink keys, typed value colors, `▸`/`▾` disclosure icons, trailing commas, and a `...` collapsed indicator. Replaces the `react-inspector` engine with an in-house tree renderer while keeping the workflow-specific value handling (StreamRef/RunRef badges, encrypted markers, decoded byte streams, dates, class instances). diff --git a/packages/web-shared/package.json b/packages/web-shared/package.json index 27879eacc8..df7f3bdb59 100644 --- a/packages/web-shared/package.json +++ b/packages/web-shared/package.json @@ -59,7 +59,6 @@ "lucide-react": "0.575.0", "react": "19.1.0", "react-dom": "19.1.0", - "react-inspector": "9.0.0", "react-use-measure": "2.1.1", "react-virtuoso": "4.18.1", "shiki": "4.0.0", diff --git a/packages/web-shared/src/components/ui/data-inspector.tsx b/packages/web-shared/src/components/ui/data-inspector.tsx index e0040fe6e2..832b1b3fb7 100644 --- a/packages/web-shared/src/components/ui/data-inspector.tsx +++ b/packages/web-shared/src/components/ui/data-inspector.tsx @@ -1,29 +1,30 @@ 'use client'; /** - * Reusable data inspector component built on react-inspector. + * Reusable data inspector for the o11y UI. * - * All data rendering in the o11y UI should use this component to ensure - * consistent theming, custom type handling (StreamRef, ClassInstanceRef), - * and expand behavior. + * Renders JSON with a small in-house tree renderer whose DOM and behavior + * mirror `react-json-view-lite` so the output matches the Vercel dashboard + * (vercel/front) 1:1 — bracket notation (`{ … }` / `[ … ]`), pink keys, typed + * value colors, `▸`/`▾` disclosure icons, and a `...` collapsed indicator. + * + * On top of plain JSON it keeps the workflow-specific value handling that the + * dashboard needs: StreamRef / RunRef badges, encrypted markers, decoded byte + * streams, Dates, and named class instances. */ import { Lock } from 'lucide-react'; import { createContext, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, + type RefObject, useContext, - useEffect, + useId, useMemo, useRef, useState, } from 'react'; -import { - ObjectInspector, - ObjectLabel, - ObjectName, - ObjectRootLabel, - ObjectValue, -} from 'react-inspector'; import { useDarkMode } from '../../hooks/use-dark-mode'; import { ENCRYPTED_DISPLAY_NAME } from '../../lib/hydration'; import { @@ -31,13 +32,6 @@ import { type FormattedStreamChunkDisplay, formatArrayBufferViewForDisplay, } from '../../lib/stream-display'; -import { - type InspectorThemeExtended, - inspectorThemeDark, - inspectorThemeExtendedDark, - inspectorThemeExtendedLight, - inspectorThemeLight, -} from './inspector-theme'; import { Button } from './button'; import { Spinner } from './spinner'; @@ -67,6 +61,13 @@ interface BytesDisplay { decodedFrom?: DecodedStreamChunkSource; } +interface ClassInstanceRef { + __type: typeof CLASS_INSTANCE_REF_TYPE; + className: string; + classId: string; + data: unknown; +} + function deserializeChunkText(text: string): string { try { const parsed = JSON.parse(text); @@ -106,12 +107,7 @@ export function isBytesDisplay(value: unknown): value is BytesDisplay { return desc?.value === BYTES_DISPLAY_TYPE; } -function isClassInstanceRef(value: unknown): value is { - __type: string; - className: string; - classId: string; - data: unknown; -} { +function isClassInstanceRef(value: unknown): value is ClassInstanceRef { return ( value !== null && typeof value === 'object' && @@ -120,6 +116,15 @@ function isClassInstanceRef(value: unknown): value is { ); } +function isEncryptedMarker(value: unknown): boolean { + return ( + value !== null && + typeof value === 'object' && + (value as { constructor?: { name?: string } }).constructor?.name === + ENCRYPTED_DISPLAY_NAME + ); +} + // --------------------------------------------------------------------------- // Stream click context (passed through from the panel) // --------------------------------------------------------------------------- @@ -150,6 +155,128 @@ export const RunClickContext = createContext< ((runId: string) => void) | undefined >(undefined); +// --------------------------------------------------------------------------- +// Styling +// +// Class names and rules mirror vercel/front's json-message.module.css so the +// rendered tree is visually identical. Shipped as a React 19 hoistable -
    - -
    - + + ); - let wrapped = content; - if (onStreamClick) { - wrapped = ( + content = ( - {wrapped} + {content} ); } if (onRunClick) { - wrapped = ( + content = ( - {wrapped} + {content} ); } if (onDecrypt) { - wrapped = ( + content = ( - {wrapped} + {content} ); } - return wrapped; + return content; } +// --------------------------------------------------------------------------- +// Render stabilization (avoid re-renders when data is deeply equal) +// --------------------------------------------------------------------------- + function useStableInspectorData(next: T): T { const previousRef = useRef(next); if (!isDeepEqual(previousRef.current, next)) { diff --git a/packages/web-shared/src/components/ui/inspector-theme.ts b/packages/web-shared/src/components/ui/inspector-theme.ts deleted file mode 100644 index 5d04188082..0000000000 --- a/packages/web-shared/src/components/ui/inspector-theme.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Shared theme configuration for react-inspector's ObjectInspector. - * - * Colors follow Geist's Shiki JSON palette so the inspector reads the same - * as highlighted code blocks across the product: - * - property names / punctuation: --ds-gray-1000 (default foreground) - * - strings / numbers / booleans: --ds-green-900 - * - null / undefined: --ds-gray-900 (muted) - * - regexp / function: --ds-purple-900 - * - date: --ds-pink-900 - * - * Because the `--ds-*` tokens adapt to theme automatically, the light and - * dark objects are intentionally identical. - */ - -// --------------------------------------------------------------------------- -// Extended color tokens not supported by react-inspector's built-in theme -// system, applied via our custom nodeRenderer in data-inspector.tsx. -// --------------------------------------------------------------------------- - -export interface InspectorThemeExtended { - /** Color for Date values (Node: 'magenta') */ - OBJECT_VALUE_DATE_COLOR: string; -} - -export const inspectorThemeExtendedLight: InspectorThemeExtended = { - OBJECT_VALUE_DATE_COLOR: 'var(--ds-pink-900)', -}; - -export const inspectorThemeExtendedDark: InspectorThemeExtended = { - OBJECT_VALUE_DATE_COLOR: 'var(--ds-pink-900)', -}; - -// --------------------------------------------------------------------------- -// Shared structural values (same in both themes) -// --------------------------------------------------------------------------- - -const shared = { - BASE_FONT_SIZE: '11px', - BASE_LINE_HEIGHT: 1.4, - BASE_BACKGROUND_COLOR: 'transparent', - OBJECT_PREVIEW_ARRAY_MAX_PROPERTIES: 10, - OBJECT_PREVIEW_OBJECT_MAX_PROPERTIES: 5, - HTML_TAGNAME_TEXT_TRANSFORM: 'lowercase' as const, - ARROW_MARGIN_RIGHT: 3, - ARROW_FONT_SIZE: 12, - TREENODE_FONT_FAMILY: 'var(--font-mono)', - TREENODE_FONT_SIZE: '11px', - TREENODE_LINE_HEIGHT: 1.4, - TREENODE_PADDING_LEFT: 12, - TABLE_DATA_BACKGROUND_IMAGE: 'none', - TABLE_DATA_BACKGROUND_SIZE: '0', -}; - -// --------------------------------------------------------------------------- -// Light theme -// --------------------------------------------------------------------------- - -const geistTheme = { - ...shared, - - // Base text - BASE_COLOR: 'var(--ds-gray-1000)', - - // Property names — default foreground (matches JSON key color in Geist Shiki) - OBJECT_NAME_COLOR: 'var(--ds-gray-1000)', - - // Strings & symbols — green - OBJECT_VALUE_STRING_COLOR: 'var(--ds-green-900)', - OBJECT_VALUE_SYMBOL_COLOR: 'var(--ds-green-900)', - - // Numbers & booleans — green (Geist JSON tokens) - OBJECT_VALUE_NUMBER_COLOR: 'var(--ds-green-900)', - OBJECT_VALUE_BOOLEAN_COLOR: 'var(--ds-green-900)', - - // null — muted foreground - OBJECT_VALUE_NULL_COLOR: 'var(--ds-gray-900)', - - // undefined — muted foreground - OBJECT_VALUE_UNDEFINED_COLOR: 'var(--ds-gray-900)', - - // RegExp — purple - OBJECT_VALUE_REGEXP_COLOR: 'var(--ds-purple-900)', - - // Functions — purple - OBJECT_VALUE_FUNCTION_PREFIX_COLOR: 'var(--ds-purple-900)', - - // HTML (rarely used here, kept consistent with the palette) - HTML_TAG_COLOR: 'var(--ds-gray-900)', - HTML_TAGNAME_COLOR: 'var(--ds-blue-900)', - HTML_ATTRIBUTE_NAME_COLOR: 'var(--ds-amber-900)', - HTML_ATTRIBUTE_VALUE_COLOR: 'var(--ds-green-900)', - HTML_COMMENT_COLOR: 'var(--ds-gray-700)', - HTML_DOCTYPE_COLOR: 'var(--ds-gray-700)', - - // Structural - ARROW_COLOR: 'var(--ds-gray-700)', - TABLE_BORDER_COLOR: 'var(--ds-gray-300)', - TABLE_TH_BACKGROUND_COLOR: 'var(--ds-gray-100)', - TABLE_TH_HOVER_COLOR: 'var(--ds-gray-200)', - TABLE_SORT_ICON_COLOR: 'var(--ds-gray-700)', -}; - -export const inspectorThemeLight = geistTheme; - -// --------------------------------------------------------------------------- -// Dark theme -// --------------------------------------------------------------------------- - -export const inspectorThemeDark = geistTheme; diff --git a/packages/web-shared/src/lib/hydration.ts b/packages/web-shared/src/lib/hydration.ts index 936c35c1fb..2caf2cf0c7 100644 --- a/packages/web-shared/src/lib/hydration.ts +++ b/packages/web-shared/src/lib/hydration.ts @@ -298,7 +298,7 @@ export function getWebRevivers(): Revivers { // Web-specific overrides for class instances. // Create objects with a dynamically-named constructor so that - // react-inspector shows the class name (it reads constructor.name). + // the data inspector shows the class name (it reads constructor.name). Class: (value) => ``, Instance: (value) => { // Run instances are rendered as clickable RunRef badges @@ -311,7 +311,7 @@ export function getWebRevivers(): Revivers { const props = data && typeof data === 'object' ? { ...data } : { value: data }; // Create a constructor with the right name using computed property - // so react-inspector's `object.constructor.name` shows the class name. + // so the data inspector's `object.constructor.name` shows the class name. // Must use `function` (not arrow) because arrow functions have no .prototype. // biome-ignore lint/complexity/useArrowFunction: arrow functions have no .prototype const ctor = { [className]: function () {} }[className]!; From bd77178a41fc712da7604a63e5e6cc383f8a7de1 Mon Sep 17 00:00:00 2001 From: mitul-s Date: Tue, 16 Jun 2026 18:30:16 -0400 Subject: [PATCH 3/9] fix: sync lockfile after dropping react-inspector The react-inspector removal landed in package.json but the lockfile was reverted during cleanup, breaking frozen-lockfile installs in CI. Co-authored-by: Cursor --- pnpm-lock.yaml | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a24df0bc4a..34453ee439 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1215,9 +1215,6 @@ importers: react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) - react-inspector: - specifier: 9.0.0 - version: 9.0.0(react@19.1.0) react-use-measure: specifier: 2.1.1 version: 2.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -15466,11 +15463,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 - react-inspector@9.0.0: - resolution: {integrity: sha512-w/VJucSeHxlwRa2nfM2k7YhpT1r5EtlDOClSR+L7DyQP91QMdfFEDXDs9bPYN4kzP7umFtom7L0b2GGjph4Kow==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -26522,14 +26514,6 @@ snapshots: optionalDependencies: vite: 7.3.2(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) - '@vitest/mocker@4.0.18(vite@7.3.2(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': - dependencies: - '@vitest/spy': 4.0.18 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.2(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) - '@vitest/mocker@4.0.18(vite@7.3.2(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.18 @@ -34341,10 +34325,6 @@ snapshots: dependencies: react: 19.2.3 - react-inspector@9.0.0(react@19.1.0): - dependencies: - react: 19.1.0 - react-is@16.13.1: {} react-is@17.0.2: {} @@ -37015,7 +36995,7 @@ snapshots: vitest@4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) + '@vitest/mocker': 4.0.18(vite@7.3.2(@types/node@24.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 From a05092cbc70fe4ed71d9540d503b24c0ace3360f Mon Sep 17 00:00:00 2001 From: mitul-s Date: Wed, 17 Jun 2026 12:48:03 -0400 Subject: [PATCH 4/9] fix(web-shared): keep data inspector colors theme-aware Drop the dark-mode color overrides (and the data-theme/useDarkMode wiring) and rely on the theme-aware --ds-* tokens, matching front: strings stay green in both light and dark instead of turning blue. Co-authored-by: Cursor --- .../src/components/ui/data-inspector.tsx | 43 ++----------------- 1 file changed, 4 insertions(+), 39 deletions(-) diff --git a/packages/web-shared/src/components/ui/data-inspector.tsx b/packages/web-shared/src/components/ui/data-inspector.tsx index 832b1b3fb7..0228615116 100644 --- a/packages/web-shared/src/components/ui/data-inspector.tsx +++ b/packages/web-shared/src/components/ui/data-inspector.tsx @@ -25,7 +25,6 @@ import { useRef, useState, } from 'react'; -import { useDarkMode } from '../../hooks/use-dark-mode'; import { ENCRYPTED_DISPLAY_NAME } from '../../lib/hydration'; import { type DecodedStreamChunkSource, @@ -161,8 +160,8 @@ export const RunClickContext = createContext< // Class names and rules mirror vercel/front's json-message.module.css so the // rendered tree is visually identical. Shipped as a React 19 hoistable - + ); From 883cef8c60a52865155daf3455fb80e006f37db8 Mon Sep 17 00:00:00 2001 From: mitul-s Date: Wed, 17 Jun 2026 15:31:56 -0400 Subject: [PATCH 5/9] test(web-shared): add data inspector tests; tidy comments Cover collapseRefs ref/typed-array/Map/Set handling and the rendered tree (keys, value colors, brackets, commas, collapse/expand, empties, dates, class/Map/Set prefixes) via jsdom + testing-library. Co-authored-by: Cursor --- .../data-inspector-expanded-brackets.md | 2 +- packages/web-shared/package.json | 3 + .../src/components/ui/data-inspector.test.tsx | 180 ++++++++++++++++++ .../src/components/ui/data-inspector.tsx | 24 ++- pnpm-lock.yaml | 53 +++--- 5 files changed, 223 insertions(+), 39 deletions(-) create mode 100644 packages/web-shared/src/components/ui/data-inspector.test.tsx diff --git a/.changeset/data-inspector-expanded-brackets.md b/.changeset/data-inspector-expanded-brackets.md index 98dbe46145..98128f03af 100644 --- a/.changeset/data-inspector-expanded-brackets.md +++ b/.changeset/data-inspector-expanded-brackets.md @@ -2,4 +2,4 @@ '@workflow/web-shared': patch --- -Rework the data inspector's JSON rendering to match the Vercel dashboard 1:1: bracket notation (`{ … }` / `[ … ]`), pink keys, typed value colors, `▸`/`▾` disclosure icons, trailing commas, and a `...` collapsed indicator. Replaces the `react-inspector` engine with an in-house tree renderer while keeping the workflow-specific value handling (StreamRef/RunRef badges, encrypted markers, decoded byte streams, dates, class instances). +Rework the data inspector's JSON rendering: bracket notation (`{ … }` / `[ … ]`), colored keys, typed value colors, `▸`/`▾` disclosure icons, trailing commas, and a `...` collapsed indicator. Replaces the `react-inspector` engine with an in-house tree renderer while keeping the workflow-specific value handling (StreamRef/RunRef badges, encrypted markers, decoded byte streams, dates, class instances). diff --git a/packages/web-shared/package.json b/packages/web-shared/package.json index df7f3bdb59..8500c6dc68 100644 --- a/packages/web-shared/package.json +++ b/packages/web-shared/package.json @@ -69,12 +69,15 @@ }, "devDependencies": { "@biomejs/biome": "catalog:", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", "@types/node": "catalog:", "@types/react": "19", "@types/react-dom": "19", "@workflow/errors": "workspace:*", "@workflow/tsconfig": "workspace:*", "ai": "catalog:", + "jsdom": "26.1.0", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/packages/web-shared/src/components/ui/data-inspector.test.tsx b/packages/web-shared/src/components/ui/data-inspector.test.tsx new file mode 100644 index 0000000000..af9e219c63 --- /dev/null +++ b/packages/web-shared/src/components/ui/data-inspector.test.tsx @@ -0,0 +1,180 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { collapseRefs, DataInspector, isBytesDisplay } from './data-inspector'; + +afterEach(cleanup); + +function renderInspector(data: unknown, expandLevel = 3) { + return render(); +} + +function tree(container: HTMLElement): HTMLElement { + const el = container.querySelector('.wf-json-view'); + if (!el) throw new Error('inspector container not found'); + return el; +} + +function texts(container: HTMLElement, selector: string): string[] { + return Array.from(container.querySelectorAll(selector)).map( + (n) => n.textContent ?? '' + ); +} + +describe('collapseRefs', () => { + it('converts typed arrays to a bytes-display marker', () => { + const result = collapseRefs({ delta: new Uint8Array([1, 2, 3]) }) as { + delta: unknown; + }; + expect(isBytesDisplay(result.delta)).toBe(true); + }); + + it('converts typed arrays nested inside arrays', () => { + const result = collapseRefs([new Uint8Array([1])]) as unknown[]; + expect(isBytesDisplay(result[0])).toBe(true); + }); + + it('leaves plain primitives untouched', () => { + expect(collapseRefs(42)).toBe(42); + expect(collapseRefs('hi')).toBe('hi'); + expect(collapseRefs(null)).toBe(null); + expect(collapseRefs(undefined)).toBe(undefined); + }); + + it('does not convert a DataView (only array views)', () => { + const view = new DataView(new ArrayBuffer(4)); + expect(collapseRefs(view)).toBe(view); + }); + + it('leaves Date instances untouched', () => { + const date = new Date('2026-01-01T00:00:00.000Z'); + expect(collapseRefs(date)).toBe(date); + }); + + it('preserves Map and Set containers while collapsing their contents', () => { + const map = collapseRefs(new Map([['k', new Uint8Array([1])]])) as Map< + string, + unknown + >; + expect(map).toBeInstanceOf(Map); + expect(isBytesDisplay(map.get('k'))).toBe(true); + + const set = collapseRefs(new Set([new Uint8Array([1])])) as Set; + expect(set).toBeInstanceOf(Set); + expect(isBytesDisplay([...set][0])).toBe(true); + }); + + it('recurses into plain objects but returns a new object', () => { + const input = { a: { b: 1 } }; + const result = collapseRefs(input) as typeof input; + expect(result).not.toBe(input); + expect(result.a.b).toBe(1); + }); +}); + +describe('DataInspector rendering', () => { + it('renders object keys, typed value colors, and quotes strings', () => { + const { container } = renderInspector({ + name: 'exec', + count: 3, + ok: true, + missing: null, + }); + const root = tree(container); + + expect(texts(root, '.wf-json-label')).toEqual([ + 'name:', + 'count:', + 'ok:', + 'missing:', + ]); + expect(texts(root, '.wf-json-string')).toContain('"exec"'); + expect(texts(root, '.wf-json-number')).toContain('3'); + expect(texts(root, '.wf-json-boolean')).toContain('true'); + expect(texts(root, '.wf-json-null')).toContain('null'); + }); + + it('renders undefined with its own value style', () => { + const { container } = renderInspector({ closureVars: undefined }); + expect(texts(tree(container), '.wf-json-undefined')).toEqual(['undefined']); + }); + + it('renders brackets and trailing commas between entries', () => { + const { container } = renderInspector({ a: 1, b: 2 }); + const punctuation = texts(tree(container), '.wf-json-punctuation'); + expect(punctuation).toContain('{'); + expect(punctuation).toContain('}'); + // one comma after the first (non-last) entry, none after the last + expect(punctuation.filter((p) => p === ',')).toHaveLength(1); + }); + + it('renders array elements without keys, using square brackets', () => { + const { container } = renderInspector([1, 'two']); + const root = tree(container); + expect(root.querySelectorAll('.wf-json-label')).toHaveLength(0); + const punctuation = texts(root, '.wf-json-punctuation'); + expect(punctuation).toContain('['); + expect(punctuation).toContain(']'); + }); + + it('renders an empty object as {} with no expander or ... indicator', () => { + const { container } = renderInspector({}); + const root = tree(container); + expect(texts(root, '.wf-json-punctuation')).toEqual(['{', '}']); + expect(root.querySelector('[data-json-expander]')).toBeNull(); + expect(root.querySelector('.wf-json-collapsed-content')).toBeNull(); + }); + + it('renders an empty array as []', () => { + const { container } = renderInspector([]); + expect(texts(tree(container), '.wf-json-punctuation')).toEqual(['[', ']']); + expect(tree(container).querySelector('[data-json-expander]')).toBeNull(); + }); + + it('collapses nodes deeper than expandLevel and shows the ... indicator', () => { + const { container } = renderInspector({ outer: { inner: 1 } }, 1); + const root = tree(container); + // outer (level 1) is collapsed + expect(root.querySelector('.wf-json-collapsed-content')).not.toBeNull(); + // its child is not rendered yet + expect(texts(root, '.wf-json-label')).not.toContain('inner:'); + }); + + it('expands a collapsed node when its expander is clicked', () => { + const { container } = renderInspector({ outer: { inner: 1 } }, 1); + const root = tree(container); + // the collapsed node's expander carries the expand-icon class (the expanded + // root carries collapse-icon), so this targets `outer`, not the root. + const expander = root.querySelector( + '.wf-json-expand-icon[data-json-expander]' + ); + expect(expander).not.toBeNull(); + fireEvent.click(expander as HTMLButtonElement); + expect(texts(tree(container), '.wf-json-label')).toContain('inner:'); + }); + + it('renders Dates as ISO strings with the date style', () => { + const { container } = renderInspector({ + at: new Date('2026-01-02T03:04:05.000Z'), + }); + expect(texts(tree(container), '.wf-json-date')).toEqual([ + '2026-01-02T03:04:05.000Z', + ]); + }); + + it('prefixes class instances, Maps, and Sets with a type name', () => { + class Widget { + id = 1; + } + const { container } = renderInspector({ + widget: new Widget(), + m: new Map([['k', 'v']]), + s: new Set([1]), + }); + const classNames = texts(tree(container), '.wf-json-classname'); + expect(classNames).toContain('Widget'); + expect(classNames).toContain('Map'); + expect(classNames).toContain('Set'); + }); +}); diff --git a/packages/web-shared/src/components/ui/data-inspector.tsx b/packages/web-shared/src/components/ui/data-inspector.tsx index 0228615116..a80bf5a2ee 100644 --- a/packages/web-shared/src/components/ui/data-inspector.tsx +++ b/packages/web-shared/src/components/ui/data-inspector.tsx @@ -3,14 +3,13 @@ /** * Reusable data inspector for the o11y UI. * - * Renders JSON with a small in-house tree renderer whose DOM and behavior - * mirror `react-json-view-lite` so the output matches the Vercel dashboard - * (vercel/front) 1:1 — bracket notation (`{ … }` / `[ … ]`), pink keys, typed - * value colors, `▸`/`▾` disclosure icons, and a `...` collapsed indicator. + * Renders JSON as a collapsible tree: bracket notation (`{ … }` / `[ … ]`), + * colored keys, typed value colors, `▸`/`▾` disclosure icons, and a `...` + * collapsed indicator. * - * On top of plain JSON it keeps the workflow-specific value handling that the - * dashboard needs: StreamRef / RunRef badges, encrypted markers, decoded byte - * streams, Dates, and named class instances. + * On top of plain JSON it handles the workflow-specific value types: StreamRef / + * RunRef badges, encrypted markers, decoded byte streams, Dates, and named + * class instances. */ import { Lock } from 'lucide-react'; @@ -157,11 +156,10 @@ export const RunClickContext = createContext< // --------------------------------------------------------------------------- // Styling // -// Class names and rules mirror vercel/front's json-message.module.css so the -// rendered tree is visually identical. Shipped as a React 19 hoistable