Skip to main content

devtools-ui-widgets

Guidelines for building UI widgets using the MVP architecture in DevTools. Covers Widget lifecycle, lit-html views, and state management.

Source facts

Repository
ChromeDevTools/devtools-frontend
Last source activity
July 27, 2026 at 06:01
Detected SKILL.md language
English
Stars
4,072
Forks
729

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
devtools-ui-widgets
description
Guidelines for building UI widgets using the MVP architecture in DevTools. Covers Widget lifecycle, lit-html views, and state management.
## UI Widget Framework Guide (MVP Architecture) Adhere strictly to the Model-View-Presenter (MVP) architecture. * **Models** (`front_end/models/`): Handle business logic and application state. MUST NOT have knowledge of the UI. * **Presenters (`Widget`)**: Orchestrate data flow, manage UI-specific state, and handle interactions. * **Views (`function`)**: Purely presentational. Stateless functions that receive data/callbacks and render using `lit-html`. **Refactoring Note:** When refactoring legacy widgets to this framework, the widget's class name MUST NOT be changed. If you're refactoring a UI piece that uses shadow root (e.g. custom HTMLElements) to a Widget, make sure to update its styles to wrap the rules with a `@scope to (devtools-widget > *)` rule. ### Presenter (`Widget`) Rules * Location: Co-located in the same file as its View. * MUST extend a base `UI.Widget` class (e.g., `UI.Widget.Widget`). Note that `UI.Widget.Widget` is not an `HTMLElement` and must be appended via `.show()` or `UI.Widget.widget` * Constructor MUST assign the injected view function to a private `#view` field. * Constructor MUST call `super()`. If taking `element?: HTMLElement`, pass it to `super(element)`. `super(true)` is forbidden. * Dependencies from `Universe` SHOULD be requested via static `INJECT`: `static override readonly INJECT = [...] as const;` and received as constructor parameter #2 (`[dep1, dep2]: UI.Widget.WidgetDependencies<typeof MyWidget>`). * Styling MUST be handled within the View. `this.registerCSSFiles()` is forbidden. * Initial render MUST be triggered in `override wasShown(): void` by calling `this.requestUpdate()`. * MUST hold all UI state as private class fields (e.g., `#counter`). * To schedule a UI update, MUST call `this.requestUpdate()`. `performUpdate()` MUST NOT be called directly. ### View (`function`) Rules * MUST be a pure function, typically named `DEFAULT_VIEW`, in the same file as the Presenter. * MUST use the signature: `(input: ViewInput, output: ViewOutput, target: HTMLElement)`. * `input`: Data and event handlers from the Presenter. * `output`: Callbacks for imperative actions (e.g., focus) provided to the Presenter. If unused, the `output` parameter should be typed as `undefined` in the view's signature, and `undefined` should be passed from the presenter. * `target`: The HTML element to render into. * The `input` parameter MUST NOT be destructured. Always use `input.propertyName`. * Responsibility: Render a `lit-html` template into `target`. * MUST NOT hold state, fetch data, or perform business logic. #### Helper Functions in Views Complex views can be broken down using helper functions defined inside `DEFAULT_VIEW` to access `input` and `output`. ```ts // clang-format off const DEFAULT_VIEW = (input: ViewInput, output: ViewOutput, target: HTMLElement): void => { const renderHeader = (): Lit.LitTemplate => { return html`<h1>${input.title}</h1>`; }; // ... render(html` <div class="child-widget"> ${renderHeader()} </div> `, target); }; // clang-format on ``` ### Composition * To render a child widget, the parent's View MUST use lit-html directive `UI.Widget.wiget` * Configuration is passed via the parameters. * Properties passed from a parent MUST be declared as public fields on the child presenter class. * The framework automatically updates these properties and calls `requestUpdate()` on the child when the parent re-renders. ## Refactoring Legacy Components When migrating imperative components (extending `UI.VBox`, `UI.Panel`, or `HTMLElement`) to the MVP architecture: 1. **Analyze Rendering Logic:** Identify where DOM is created (constructor, `wasShown`, imperative methods). This logic moves to the `DEFAULT_VIEW`. 2. **Convert Base Class:** * Prefer extending `UI.Widget.Widget`. * **Special Case:** If the component *must* extend `UI.Panel.Panel` or `UI.Dialog.Dialog` (to retain specific functionality), you cannot use `requestUpdate()`. Instead, call `this.performUpdate()` directly on state changes. 3. **State Migration:** Move DOM-stored state to private class fields. 4. **Update Usage:** Replace `new MyComponent()` instantiations with declarative `widget(MyComponent, {params})` in parent templates. 5. **Scoping Styles:** Ensure your CSS file uses the `@scope` block to prevent style leaks: ```css @scope to (devtools-widget > *) { ... } ``` ## Key Implementation Details (Gotchas) ### Styling Strategy DevTools widgets typically render into Light DOM (the default for `UI.Widget`). To ensure styles are scoped to the component and do not leak into child widgets, wrap your CSS in an `@scope` block: ```css /* myWidget.css */ @scope to (devtools-widget > *) { .my-class { ... } } ``` Then, include the styles in your `DEFAULT_VIEW`: ```ts import myWidgetStyles from './myWidget.css.js'; // ... render(html` <style>${myWidgetStyles}</style> <div class="my-widget">...</div> `, target); ``` **Note:** The `{host: input}` option in `render()` is generally **not required** unless you are using specific Shadow DOM patterns that necessitate it. The `@scope` strategy is the preferred method for style isolation in DevTools widgets. ### Legacy Interop & Refs * **Raw Elements:** Use `Lit.Directives.ref` to obtain a reference to a raw `HTMLElement` if needed for imperative APIs (e.g., `splitWidget.installResizer(element)`). * **Child Widgets:** Use `UI.Widget.widgetRef` to obtain the class instance of a child widget if you need to call methods on it directly (though declarative data flow is preferred). ### Dependencies * **View Dependencies:** The `DEFAULT_VIEW` is typically a module-level function. Ensure all dependencies (enums, constants, other components) are imported at the top of the file so they are available in the function scope. * **Presenter (`Universe`) Dependencies:** To access dependencies managed by `Universe` (e.g. `SDK.TargetManager.TargetManager`, `Common.Settings.Settings`), declare a static `INJECT` array on the `Widget` subclass: ```ts export class MyWidget extends UI.Widget.Widget { static override readonly INJECT = [SDK.TargetManager.TargetManager, Common.Settings.Settings] as const; #targetManager: SDK.TargetManager.TargetManager; #settings: Common.Settings.Settings; #view: View; constructor( element?: HTMLElement, [targetManager, settings]: UI.Widget.WidgetDependencies<typeof MyWidget> = [], view: View = DEFAULT_VIEW, ) { super(element); this.#targetManager = targetManager; this.#settings = settings; this.#view = view; } } ``` `instantiateWidget` automatically resolves dependencies from `Universe` and passes them as constructor parameter #2. In unit tests using `renderElementIntoDOM`, `TestUniverse` handles injection automatically (override via `setTestUniverseForWidgets`). ## Step-by-Step Implementation Example ### 1\. Create the Widget File and Styles Example: `front_end/panels/my_example/MyExampleWidget.ts`. ```css /* front_end/panels/my_example/myExampleWidget.css */ @scope to (devtools-widget > *) { p { color: blue; } } ``` ```ts // front_end/panels/my_example/MyExampleWidget.ts import * as Lit from '../../ui/lit/lit.js'; import * as UI from '../../ui/legacy/legacy.js'; import myExampleWidgetStyles from './myExampleWidget.css.js'; const {html, render, ref} = Lit; // 1. Define Input data shape. (Assuming Child Widget configuration for completeness) export interface ViewInput { title: string; counter: number; onButtonClick: () => void; } // 2. Define Output imperative API shape. export interface ViewOutput { focusButton?: () => void; } // 3. Define the View function. // clang-format off const DEFAULT_VIEW = (input: ViewInput, output: ViewOutput, target: HTMLElement): void => { render(html` <style>${myExampleWidgetStyles}</style> <div class="child-widget"> <h3>${input.title}</h3> <p>Counter: ${input.counter}</p> <button @click=${input.onButtonClick} ${ref(el => { if (el) { output.focusButton = () => el.focus(); } })}>Increment</button> </div> `, target); }; // clang-format on // 4. Type alias for the view. type View = typeof DEFAULT_VIEW; // 5. Define the Presenter. export class MyExampleWidget extends UI.Widget.Widget { #counter: number = 0; // Public field REQUIRED to receive data from a parent. title: string = 'Default Title'; #view: View; #viewOutput: ViewOutput = {}; constructor(element?: HTMLElement, view: View = DEFAULT_VIEW) { super(element); this.#view = view; } override wasShown(): void { super.wasShown(); // Initial render trigger. this.requestUpdate(); } focus(): void { this.#viewOutput.focusButton?.(); } #handleButtonClick = (): void => { this.#counter++; this.requestUpdate(); } override performUpdate(): void { const viewInput = { title: this.title, counter: this.#counter, onButtonClick: this.#handleButtonClick, }; this.#view(viewInput, this.#viewOutput, this.contentElement); } } ``` ### 2\. Compose Widgets (Parent Widget) Example: `front_end/panels/parent/ParentWidget.ts`. Demonstrates passing state via `widgetConfig` and a view with no `output`. ```ts // front_end/panels/parent/ParentWidget.ts import * as Lit from '../../ui/lit/lit.js'; import * as UI from '../../ui/legacy/legacy.js'; import {MyExampleWidget} from '../my_example/MyExampleWidget.ts'; import parentWidgetStyles from './parentWidgetStyles.css'; const {html, render} = Lit; const {widgetConfig} = UI.Widget; interface ViewInput { title: string; onTitleChange: () => void; } // Parent View // clang-format off const DEFAULT_VIEW = (input: ViewInput, output: undefined, target: HTMLElement): void => { render(html` <style>${parentWidgetStyles}</style> <div class="parent-container"> <h1>Parent Widget</h1> <button @click=${input.onTitleChange}>Change Child Title</button> <!-- Pass properties to the child widget. --> ${widget(MyExampleWidget, {title: input.title})} </div> `, target); }; // clang-format on type View = typeof DEFAULT_VIEW; // Parent Presenter export class ParentWidget extends UI.Widget.Widget { #childTitle: string = 'Initial Title'; #view: View; constructor(element?: HTMLElement, view: View = DEFAULT_VIEW) { super(element); this.#view = view; } override wasShown(): void { super.wasShown(); this.requestUpdate(); } #handleChangeTitleClick = () => { this.#childTitle = `Title set at ${new Date().toLocaleTimeString()}`; this.requestUpdate(); } override performUpdate(): void { this.#view({ title: this.#childTitle, onTitleChange: this.#handleChangeTitleClick, }, undefined, this.contentElement); } } ``` ### 3\. Testing Widgets Testing focuses on the Presenter's logic. The View MUST be replaced by a stub using `createViewFunctionStub`. * **`describeWithEnvironment`**: Sets up the testing environment. * **`createViewFunctionStub`**: Stubs the view to capture `input` data. * **`renderElementIntoDOM`**: Attaches the widget to the DOM to trigger lifecycle methods (like `wasShown`). * **`view.nextInput`**: A promise that resolves with the next `input` when the widget re-renders. * **Simulating Events**: Invoke callbacks directly (e.g., `view.input.onButtonClick()`), do not interact with the DOM. ```ts // front_end/panels/my_example/MyExampleWidget.test.ts import { renderElementIntoDOM, } from '../../testing/DOMHelpers.js'; import {describeWithEnvironment} from '../../testing/EnvironmentHelpers.js'; import {createViewFunctionStub} from '../../testing/ViewFunctionHelpers.js'; import * as UI from '../../ui/legacy/legacy.js'; import * as MyExampleWidget from './MyExampleWidget.js'; const {MyExampleWidget} = MyExampleWidget; describeWithEnvironment('MyExampleWidget', () => { // Helper to set up the widget with a stubbed view. async function createWidget() { // 1. Stub the view function. const view = createViewFunctionStub(MyExampleWidget); // 2. Instantiate the widget, injecting the stub. const widget = new MyExampleWidget(undefined, view); // 3. Render the widget into the DOM (triggers `wasShown`). widget.markAsRoot(); renderElementIntoDOM(widget); // 4. Wait for the initial render. await view.nextInput;
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub