Skip to content

Widgets: carry a declarative icon through the widget pipeline #80938

Description

@retrofox

Part of the Dashboard Overview #77616.

A widget's icon is the only piece of its visual identity that no layer of the pipeline can see.

This proposes carrying it as a declarative reference, resolved in the same place the widget pipeline already resolves declarative references.

Problem

Icons are authored in the widget's JS module, as a React element imported from @wordpress/icons:

import { calendar } from '@wordpress/icons';

export default { name: 'core/events', icon: calendar };

Every other identity field travels through widget.json into the build manifest, the PHP registry, and wp/v2/widget-modules. The icon does not. Anything that wants a widget's icon has to import and execute the widget's metadata module first, so an inserter listing fifty widget types pays fifty dynamic imports to draw fifty icons. A widget that ships no JS module cannot declare an icon at all, and nothing server-side can render one.

The reason was that an icon is a React element, not a serializable value. That is no longer the case.

What changed

Core's Icons API stabilized the registry, the wp/v2/icons endpoint, and the Icon block in 7.0, and the 7.1 iteration (#75715, Core ticket #64847) added the public registration functions in #77260. An icon is now addressable by a namespaced collection/icon-name string, resolvable server-side with wp_get_icon() and client-side through the icon core-data entity (packages/core-data/src/entities.js:242-251).

The Icon block is the precedent for storing one: a plain string attribute in packages/block-library/src/icon/block.json, resolved with getEntityRecord( 'root', 'icon', name ) at packages/block-library/src/icon/edit.js:80.

Widgets would be first to use this in a metadata file. block.json's icon is still a Dashicons slug.

Contract

One nullable string field, three authoring forms, told apart by their own shape. A leading < is markup, a .svg suffix is a file in the widget directory, anything else is a registered icon name. Icon names admit neither dots nor angle brackets, so the three never collide and no discriminator key is needed.

{ "icon": "core/shield" }
{ "icon": "<svg viewBox=\"0 0 24 24\">…</svg>" }
{ "icon": "icon.svg" }

The React element in widget.ts stays as the fourth form, following the override semantics already in place: a server value wins, and the module's value stands when there is none (packages/widget-primitives/src/types.ts:283-286). Widgets in tree declare no icon in JSON, so their elements keep rendering untouched.

No i18n work. An icon's label lives in the icon registry, already translated, so widget-i18n.json is untouched.

Resolution belongs in useWidgetTypes

The package already does exactly this for attribute field types, and the reasoning transfers whole. From packages/widget-primitives/src/stories/field-types.md: the application registers what a name means, the widget references the name, and the hook "is the only point in the system that recognizes the names; hosts and DataForm downstream see native DataViews vocabulary and nothing else". The type comment says the same, useWidgetTypes is "the single boundary" that turns records into a WidgetType (packages/widget-primitives/src/types.ts:230-236).

An icon name is the same kind of reference, so it resolves at the same boundary. Hosts receive a renderable icon and never see a name, the way they already receive plain DataViews fields and never see location.

One difference shapes the implementation. A field type is code the host owns and registers synchronously at init (routes/dashboard/field-types/index.ts:29, called at routes/dashboard/stage.tsx:26). An icon is data behind a REST entity, so what the host registers is a resolver rather than a value. The hook is already asynchronous, it awaits import( record.widget_module ) inside a Promise.all and exposes isResolving, so awaiting icon resolution in the same pass costs no new machinery.

This keeps @wordpress/widget-primitives host-neutral, which is why it can only be done this way: the package depends on @wordpress/dataviews and @wordpress/element and nothing else, so it cannot read the icon entity itself. The host registers the resolver next to its field types, closing over core-data; the package stays a contract.

Two consequences worth stating up front:

  • If the resolver returns a React element, WidgetType.icon keeps its current type and nothing downstream changes at all, including the dashboard chrome. Whether the resolved representation is an element or markup is the one open decision here.
  • An unregistered or unresolvable name degrades silently and leaves the widget without an icon, matching the rule field types already follow.

Registration timing

Whatever registers the resolver has to do it before the hook resolves. Which level owns that is not settled and does not need to be for this to proceed. @wordpress/dashboard-init runs before the page renders and registers the widgetModule entity there (packages/dashboard-init/src/index.ts:15-31); field types register at module scope in the route instead (routes/dashboard/stage.tsx:26). Both are valid homes today, and the vocabulary may well move to a different level as more hosts appear.

What the contract depends on is the guarantee rather than the location: the resolver is in place by the time a host consumes useWidgetTypes, and a reference that is not yet resolvable degrades to no icon instead of breaking. That is what makes moving the registration level later a non-event.

Step 1: declarative icon by registered name

Layer File Change
Build typedef packages/wp-build/lib/widget-utils.mjs:47-58 icon in WidgetMetadata
Manifest packages/wp-build/lib/build.mjs:2039-2053 and :2205-2218 Carry icon into build/widgets/registry.php
Type object lib/experimental/dashboard-widgets/class-wp-widget-type.php public $icon
Registration lib/experimental/dashboard-widgets/widget-types.php:266-282 Validate the name shape, drop what does not match
REST lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php:182-220, schema at :241 Expose icon
Client types packages/widget-primitives/src/types.ts:32, :287-303 WidgetIcon accepts a string; icon joins the record as string | null, declared outside the Pick<> so a ReactElement cannot enter a wire type
Resolution packages/widget-primitives/src/hooks/use-widget-types.ts:66-99 plus a new registry beside field-types/ Resolve the reference while assembling each WidgetType
Host routes/dashboard/field-types/index.ts and routes/dashboard/stage.tsx:26 Register an icon resolver reading the icon entity
Schema schemas/json/widget.json New icon property (#77640)
  • Decide the resolved representation: React element, which changes nothing downstream, or markup, which needs a render branch in the chrome
  • Carry icon through build, PHP registry, and REST, validating the name at registration
  • Add the icon resolver registry and resolve references in useWidgetTypes
  • Register the dashboard's resolver against the icon entity
  • Add icon to schemas/json/widget.json (Widgets: Add widget.json metadata schema #77640) and adopt it in the core widget.json files
  • Document the mechanism next to field-types.md, which is its template

Step 2: the widget.ts element

The element form works today. This is about making the two coexist deterministically once the JSON field exists, and saying so in the types rather than leaving it implicit in the merge.

  • Settle and document precedence between the JSON reference and the module element in useWidgetTypes
  • Fix widgets/welcome/widget.ts:4, which declares icon: 'wordpress' as a string against a ReactElement type. It renders an empty SVG rather than failing, unnoticed because Welcome is full-bleed and draws no header. The same file carries a dead apiVersion: 1 and duplicates category from its widget.json

Step 3: SVG

For icons the registry does not carry. Both forms resolve to sanitized markup at registration, so nothing changes client-side beyond accepting markup where a name was expected.

Sanitization reuses wp_kses() with an SVG allow-list, which is what the Icons API itself does in sanitize_icon_content() (lib/compat/wordpress-7.0/class-wp-icons-registry.php:182-208, borrowed from twentytwenty_get_theme_svg). It lives in lib/experimental/dashboard-widgets/widget-types.php next to gutenberg_sanitize_widget_help() and gutenberg_sanitize_widget_actions(), the registration gate that graduates into Core with the rest of the feature. Matching the allow-list keeps widget icons and registered icons on the same rules and needs no new Core function. The list is narrow, svg, path and polygon only, which is what @wordpress/icons uses.

  • Sanitize inline SVG at registration, alongside the existing help and action sanitizers
  • Resolve .svg paths against the widget directory, reusing the traversal rules in gutenberg_resolve_widget_action_href() (widget-types.php:126-164)
  • Accept markup where a name is expected, client-side

Note on icons that are not registered

packages/icons/src/manifest.json carries 339 icons, but only the 88 marked public: true reach the core/ collection (packages/icons/lib/generate-manifest-php.cjs:62-67). Of the six widgets that import an icon, three resolve by name today: calendar (Events), shield (Site Health), audio (Hello Dolly). Three do not: wordpress (Hello World), drafts (Quick Draft), site-logo (Site Preview).

Promoting those to public is not the answer, since public also means selectable in the Icon block and shipped in the icon library, which is a product decision about the library rather than about widgets. Two ways forward instead:

  • Use a registered icon. The public set covers all three plausibly: create or pencil for Quick Draft, desktop for Site Preview, block-default for Hello World, which is a demo widget.
  • Register our own. wp_register_icon( 'collection/name', [ 'label' => …, 'content' => '<svg…>' ] ) accepts markup or a file_path. Under a widget-owned collection rather than core/, which the API reserves for WordPress core icons and which stays equal to the icons manifest.

Either way Step 2 keeps the module element working, so no widget is blocked on this.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions