- 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. Do not pass `classes` or `jslog` in `WidgetOptions` (`super(..., {classes: [...], jslog: ...})`); instead, pass container CSS classes and `jslog` via the third parameter of `render(template, target, {container: {classes: [...], attributes: {jslog: ...}}})` in the view function (and if the widget uses shadow DOM, pass `useShadowDom: 'pure'` and extend `Widget<ShadowRoot>` / `VBox<ShadowRoot>` with `target: HTMLElement|DocumentFragment` so `render()` applies `container.classes` and `container.attributes` to the shadow host `this.element`).
* 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()`. External model, workspace, and setting subscriptions SHOULD be attached in `wasShown()` and detached symmetrically in `willHide()`, reconciling incremental state changes that occurred while hidden rather than unconditionally calling `reset()` and wiping populated presenter state (such as open tree folders or selection) on every re-show.
* MUST hold all UI state as private class fields (e.g., `#counter`).
* To schedule a UI update, MUST call `this.requestUpdate()` asynchronously across all presenter methods, property setters, and event handlers. `performUpdate()` MUST NOT be called directly (except in `UI.Panel`/`UI.Dialog` subclasses where `requestUpdate()` is unavailable or when synchronous layout measurement is documented as strictly required). Presenter and presenter-node classes MUST NOT expose imperative view-refresh methods (e.g., `updateTrailingIcons()`, `updateIcon()`); all visual output should be derived declaratively in the view function.
### 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`.
* **Template Formatting & Whitespace:** The custom `html` tag from `ui/lit/lit.js` (`front_end/ui/lit/strip-whitespace.ts`) automatically strips `\n\s*` outside of `<...>` tags at runtime, so multi-line formatting between text interpolations and inline elements (`<span>`, etc.) does not introduce whitespace text nodes.
* 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 = (): 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 the lit-html directive `UI.Widget.widget`.
* **Prefer `${widget(WidgetClass, {params})}` directly as a child expression** over `<devtools-widget ${widget(WidgetClass, {params})}></devtools-widget>`. Only use an explicit `<devtools-widget>` tag when you need to set other HTML attributes/classes, apply additional directives (such as `widgetRef` or `ref`), or provide child nodes.
* **Strongly prefer the two-argument `widget(WidgetClass, {params})` form** over the single-argument factory callback `widget(element => new WidgetClass(element, ...))`. The two-argument form automatically updates properties and calls `requestUpdate()` on subsequent renders, whereas a factory callback only runs once on initial instantiation.
* If a child widget currently exposes imperative setter methods (e.g., `setStorageKey(key)` or `setStorageBucket(bucket)`) instead of public properties/setters, convert them into standard TypeScript setters (`set storageKey(key)`, `set storageBucket(bucket)`) on the child widget so it can be instantiated and updated declaratively via the two-argument `widget(WidgetClass, {params})` directive.
* Configuration is passed via the parameters.
* Properties passed from a parent MUST be declared as public fields or setters on the child presenter class.
* The framework automatically updates these properties and calls `requestUpdate()` on the child when the parent re-renders.
* **Avoid `UI.Widget.WrapperWidget`:** Do **NOT** pass `UI.Widget` instances (such as `new ChildWidget()`, `UI.TabbedPane.TabbedPane` / `tabbedLocation.tabbedPane()`, `StackLocation.widget()`, `UI.ListWidget.ListWidget`, or `UI.SplitWidget.SplitWidget`) via `ViewInput` to wrap in `UI.Widget.WrapperWidget`.
* **Child widgets:** Refactor the child widget to accept `element?: HTMLElement` in its constructor and setters for its state, and instantiate it declaratively in the view with `${widget(ChildWidget, {...})}`.
* **`TabbedPane` and `StackLocation`:** Create tabbed panes and stack panes declaratively in the view using `<devtools-tabbed-pane>` (or `${widget(UI.TabbedPane.TabbedPane, ...)}`) and `<devtools-stack-pane>` rather than passing a `TabbedPane` or `StackLocation.widget()` instance through `ViewInput` to `WrapperWidget`.
* **`UI.ListWidget.ListWidget` and `UI.ListWidget.Delegate`:** Do NOT keep `this.list` and `UI.ListWidget.Delegate` on the presenter (calling `this.list.clear()`, `this.list.appendItem()`, `this.list.addNewItem()`, rendering `renderItem`/`beginEdit` into a `DocumentFragment`, and mounting `this.list` via `WrapperWidget`). Replace `ListWidget` + `ListWidget.Delegate` with `<devtools-list>` (`ui/components/lists/lists.js`) driven by pure `ViewInput` data and callbacks (using `<devtools-prompt>`, an inline editor row, or `UI.Dialog.Dialog` for editing). If legacy `ListWidget.Editor` DOM/screenshot goldens must be preserved temporarily in an incremental CL, move `ListWidget` + `ListWidget.Delegate` ownership into the view layer (e.g. a small view-layer child class extending `UI.ListWidget.ListWidget` directly and instantiated via `${widget(...)}` in `DEFAULT_VIEW`, without `WrapperWidget`) so the presenter only passes pure data and callbacks in `ViewInput`.
* `WrapperWidget` is only justified when reparenting a shared stateful singleton across multiple containers (such as `ConsoleView.instance()` or `SourcesView`) or bridging an opaque `UI.Widget` returned by an external factory. Remember that `WrapperWidget` sets `display: contents` on its `<devtools-widget>` host element, so never put sizing, border, or background classes on that `<devtools-widget>` host (wrap it in a `<div>` instead).
* **Listening to Child Widget Events (`eventMixin`):** When a child `UI.Widget` uses `Common.ObjectWrapper.eventMixin(UI.Widget.Widget)`, calling `this.dispatchEventToListeners(Events.FOO, payload)` also dispatches a non-bubbling `new CustomEvent(Events.FOO, {detail: payload})` on the widget's host `<devtools-widget>` element. Parent Lit templates can listen declaratively with `<devtools-widget ${widget(ChildWidget, {...})} @Foo=${(e: CustomEvent<Payload>) => input.onFoo(e.detail)}></devtools-widget>` (Lit preserves the case of `@Foo` from the template string). This only works on the `<devtools-widget>` element itself (the `CustomEvent` does not bubble) and only for `UI.Widget` classes using `eventMixin` (plain `ObjectWrapper` subclasses do not have `dispatchDOMEvent`).
## 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 an `@scope to (devtools-widget > *)` block to prevent style leaks. Land `@scope` CSS wrapping in its own small, standalone CL separate from widget migration CLs so visual regressions are easy to bisect:
```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 > *) {
:scope {
display: flex;
}
.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);
```
**Automated Lint Enforcement:**
- `@devtools/no-unscoped-light-dom-styles` (ESLint): checks stylesheets injected by Light DOM widgets (`<style>${styles}</style>`, `this.registerRequiredCSS()`, `appendStyle()`) and requires all rules to sit inside a top-level `@scope` block. Never add new files to the self-cleaning `burndown` list in `eslint.config.mjs`; remove migrated files from the list once scoped.
- `plugin/check_scope_selectors` (stylelint): unconditionally forbids `:host` inside `@scope` (even with `:scope` or `&`), and flags ancestor selectors that receive an implicit `:scope ` descendant combinator and cannot match without `:scope` or `&` (`:host-context()`, `:root`, `html`, `body`, `.theme-with-dark-background`, `.platform-*`, `.overlay-scrollbar-enabled`).
**Key `@scope` Gotchas & Rules:**
* **`:scope` instead of `:host`**: `:host` is Shadow DOM-only and never matches inside `@scope`. Style the widget's root container element with `:scope`.
* **Theme-aware tokens instead of theme or platform classes**: Because un-anchored selectors inside `@scope` receive an implicit `:scope ` prefix, selectors like `.theme-with-dark-background .foo` or `:host-context(...)` look inside the widget for the ancestor class and never match. Use theme-aware design tokens (`--sys-color-*` / `--app-color-*`) instead.
* **Shared stylesheets**: Do not inject `inspectorCommonStyles` or `textButtonStyles` into Light DOM views (`installComponentRootStyles()` already injects them globally into the document and shadow roots). Wrap other shared stylesheets inline as `<style>@scope to (devtools-widget > *) { ${styles} }</style>` so Shadow DOM consumers stay unaffected.
* **Lower boundary and imperative child widgets**: The `(devtools-widget > *)` lower boundary stops cascading into declarative `<devtools-widget>` children (`${widget(...)}`), but it does **not** stop at legacy imperatively attached `<div class="widget">` children (`childWidget.show(container)`).
* **Small, Standalone CLs**: Always land `@scope` CSS wrapping in a dedicated, standalone CL separate from logic, template, or widget-promotion CLs so visual regressions are easy to bisect.
**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 `Directives.ref` (from `ui/lit/lit.js`) 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 inside the view function if you need to call imperative methods on it. **Do NOT store child widget instances on `ViewOutput` as a final result** (doing so is only acceptable temporarily in intermediate migration CLs). Instead, either supply all needed configuration declaratively through setters/inputs on the child widget, or if imperative calls are strictly necessary, expose narrow action functions on `ViewOutput` (e.g., `output.doLayout = () => splitWidget.doLayout()`, `output.revealPosition = pos => childWidget.revealPosition(pos)`). Do not keep `get*ForTest()` methods that return child widget instances via `ViewOutput`; tests should query rendered `<devtools-widget>` elements via `.getWidget()` or assert on `ViewInput` via `createViewFunctionStub`.
### Visibility (`?hidden` Attribute Only)
Toggle element visibility declaratively using the HTML `?hidden` attribute:
```ts
html`<div ?hidden=${!input.isVisible}>...</div>`
```
GitHubで見る