You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(iapi-check): flag Interactivity API usages that WordPress 7.2 removes - #91
Adds wp-tooling iapi-check, which finds the Interactivity API usages that WordPress 7.2 (due 2026-12-09) stops supporting. WordPress/gutenberg#84098 removes the expired deprecations from the client runtime: data-wp-ignore, two-hyphen unique IDs, and ! applied to a function. Code that still uses them breaks quietly. The command runs locally and in CI (wp-shared-workflows ci-check-iapi.yml).
Closes
N/A
Changes
src/ci/cli-support.js (refactor, first commit): coverage-gate's flag parser, choice validation, workflow-command emitter and $GITHUB_OUTPUT / $GITHUB_STEP_SUMMARY writers move here so both CI commands share them. coverage-gate's messages and behaviour are unchanged; its annotations gain optional col and title.
src/ci/iapi-check/ and src/cli/commands/iapi-check.js: scans *.php, *.html, *.js, *.jsx, *.ts, *.tsx, *.mjs and *.cjs as raw text, so directives in render.php, templates, patterns, PHP strings and JSX are all found. Skips node_modules/, vendor/, .git/, build output, minified bundles, symlinks, --ignore matches and lines that start a comment. Rules:
Rule
Severity
What 7.2 does
ignore-directive
error
data-wp-ignore is gone; the subtree hydrates
lifecycle-double-hyphen-id
error
data-wp-init/watch/run--id callbacks are skipped
event-double-hyphen-id
error
data-wp-on--click--id listens for a click--id event, never fires on click
negated-function
error
!actions.x / !callbacks.x (also ns::!actions.x; !ns::actions.x never resolved its namespace, so it is skipped) is undefined, never called
router-state-navigation
warning
state.navigation from core/router, deprecated in 7.0
Each finding has file, line, column and the exact replacement (Use data-wp-watch---logger.). IDs built at run time ('data-wp-watch--' . $id, template literals, sprintf) are caught too.
Output:text (default), json, or github: annotations with paths re-rooted to the repository root, a summary table, and findings, errors and warnings step outputs. Exit 1 on any error unless --soft-fail, which reports everything as a warning and exits 0. --dry-run as every command must have.
@rtcamp/wp-tooling/ci also exports checkIapi, scanIapiText, IapiCheckError and IAPI_RULES. README, package AGENTS.md and CHANGELOG updated; the fixture project is excluded from ESLint because it is deliberately old code.
How I verified
$ npm run check # in node-packages/wp-tooling
Test Suites: 83 passed, 83 total
Tests: 1516 passed, 1516 total # on main after #80 and #83; 135 for iapi-check, 3 for the dispatcher
$ npm run lint # repository root, exit 0
$ npm run test:coverage # repository root, exit 0; new code ~98.6% lines
The patterns were checked against Gutenberg's own directive-name parser (splitDirectiveName() in vdom.ts) over 118,092 generated names: no false positives, and every suggested replacement parses. The only misses are names nobody writes (runs of four or more hyphens). A 1-2 MB file scans in about 12 ms.
On a real project: ICIMOD is clean. Comment-only lines are skipped, because an early run flagged directive names inside comments.
--output github on the fixture project: repo-relative annotation paths, --soft-fail exits 0 with warnings, without it exits 1.
Acceptance criteria
Runtime behavior
Every rule fires on its positive cases and stays quiet on the valid forms (---id, data-wp-on-async*, !state.*, single-hyphen custom events)
Annotations attach to repository paths when --working-dir is a subdirectory
--soft-fail exits 0; errors exit 1 otherwise
Code quality
Lint: zero errors
Static analysis: zero errors
Tests pass
Housekeeping
CHANGELOG.md entry under ## Unreleased
Reviewer notes
negated-function is a heuristic. Statically we cannot know what resolves to a function, so it flags only the actions. and callbacks. namespaces, which are functions by convention. !state.x and !context.x stay valid in 7.2 (getters and booleans still work).
router-state-navigation only runs on files that mention core/router or interactivity-router, so a theme's own menu store with state.navigation is not flagged.
A block deprecation that keeps the old names on purpose is flagged.--ignore covers it today; an inline ignore comment would be a small follow-up.
Needs a 2.1.0 release before December 9 to be useful in CI.
Move coverage-gate's table-driven flag parser, choice validation,
workflow-command emitter and $GITHUB_OUTPUT / $GITHUB_STEP_SUMMARY
writers (with their --dry-run previews) into src/ci/cli-support.js so
the next CI command reuses them instead of copying them. Messages and
behaviour are unchanged.
annotation() accepts optional col and title properties, and the render
module exports printable(), codeSpan(), escapeTableCell() and plural()
for reuse.
…moves
WordPress/gutenberg#84098 removes the expired Interactivity API
deprecations from the client runtime in WordPress 7.2 (due 2026-12-09).
`wp-tooling iapi-check` scans a project's php, html, js, jsx, ts, tsx,
mjs and cjs files as raw text and reports, with file, line, column and
the replacement:
- ignore-directive: data-wp-ignore is removed
- lifecycle-double-hyphen-id: data-wp-init/watch/run--id no longer run
- event-double-hyphen-id: data-wp-on(-window|-document)--event--id
listens for an "event--id" event
- negated-function: "!" on actions.* or callbacks.* resolves to undefined
- router-state-navigation (warning): core/router state.navigation
Output is text, json or github (annotations re-rooted to the repository
root, a step summary table, and findings/errors/warnings outputs). Exits
1 on any error finding unless --soft-fail. Skips dependency, build and
git directories, minified bundles, symbolic links, --ignore matches and
commented-out lines.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Unresolved matching and exclusion bugs cause missed PHP deprecations, false positives for valid or unrelated usages, and inconsistent file filtering with stateful regular expressions.
- A unique ID printed with an inline PHP tag (data-wp-watch--<?php echo
$id; ?>, data-wp-on--click--<?= $id ?>) is reported. A closing quote
only counts as a run-time ID when concatenation follows ('...--' . $id,
'...--' + id): set_attribute( 'data-wp-watch--', ... ) is a directive
with no ID, which still runs.
- negated-function also reads an escaped opening quote (echo
"...=\\"!actions.x\\"") and spaces before an array's closing bracket.
It reports only !actions.* and ns::!actions.*: the runtime reads the
namespace first, so !ns::actions.* never resolved, before 7.2 too.
- router-state-navigation follows the name core/router's state is bound
to (state: router, import { state as rs }, router.state, a direct
store( 'core/router' ).state), so another store's state.navigation in
the same file is no longer reported. Its message says to switch to
.navigating once the plugin requires WordPress 7.2.
- An ignore pattern with the g or y flag is reset before each path, so a
kept lastIndex no longer lets every other matching file through.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
The rules incorrectly flag non-router state and literal-valued directives while missing deprecated usage in valid unquoted HTML attributes, causing both false CI failures and undetected breakages.
This also flags literal-valued directives: <li data-wp-key="!actions.item"> becomes an error even though Gutenberg assigns that value directly to vnode.key in hooks.tsx, without evaluating or negating a function. The key remains valid after the runtime change, so this can fail CI for unaffected markup. Exclude literal-valued directives, including data-wp-key and its three-hyphen unique-ID forms, from this rule and add a negative test.
Detect deprecated directives with unquoted HTML values
Unquoted attribute values are valid HTML, but the required opening quote makes <b data-wp-bind--hidden=!actions.isOpen> produce no findings. WordPress evaluates that value exactly like the quoted form, so this deprecated usage passes CI despite breaking in 7.2. Add a separate unquoted HTML = branch while retaining the quote requirement for PHP/JS/JSX expressions, and cover it with a regression test.
This fallback also flags state that is explicitly owned by another store. For example, const { actions } = store('core/router'); const { state } = store('acme/menu'); state.navigation.isOpen; creates no router-state head, so state is assumed to be router state and the menu access receives an incorrect warning/replacement. Avoid assuming state when it is bound to a non-router store (or require a proven router-state binding), and cover this actions-only router usage in the tests.
Exclude member-access state references from router matches
The identifier boundary allows a preceding dot, so with const { state } = store('core/router'), an unrelated menu.state.navigation.isOpen also matches as state.navigation. This reports another store's property as deprecated router state and recommends changing the wrong API. Exclude a preceding member-access dot from the boundary and add a regression test containing both accesses.
The helper only escaped `$`. The names come from identifier matches,
so nothing else can occur today, but a full escape keeps it safe if the
binding pattern ever widens and clears the CodeQL alert.
The reason will be displayed to describe this comment to others. Learn more.
Exported rule regexes keep their search position between calls, so later scans miss findings
IAPI_RULES is exported, and its patterns are the same global (g) RegExp objects scanText uses. Object.freeze is shallow, and matchAll starts at the source regex's lastIndex, so a caller that runs .test() or .exec() on a rule pattern breaks later scans:
const{ scanIapiText,IAPI_RULES}=require('@rtcamp/wp-tooling/ci');consthtml='<div data-wp-ignore>';scanIapiText(html,'a.html');// 1 findingIAPI_RULES.find((r)=>r.id==='ignore-directive').pattern.test(html);// lastIndex is now 19scanIapiText(html,'a.html');// 0 findings
This is the same lastIndex bug resetAndTest() fixed for --ignore. Suggest building a fresh regex per call (new RegExp(p.source, p.flags)), or exporting copies.
The reason will be displayed to describe this comment to others. Learn more.
One unreadable file aborts the scan, and --soft-fail turns that into a green job. An EACCES/EIO from readdirSync/readFileSync throws IapiCheckError, which drops every finding. Under --soft-fail, the shared workflow's default, the job exits 0 with a single ::warning::iapi-check: cannot read ...: no findings, no step outputs, no summary. A root-owned folder, such as a docker-mounted wp-env MySQL directory, is enough to trigger it.
Suggest warning per file, counting the unreadable entries, and carrying on with the scan.
The reason will be displayed to describe this comment to others. Learn more.
isCommented() treats a leading # as a comment in every language, and a leading * as a block-comment line even when no block comment is open. Real code on those lines is skipped:
classA{
#html ='<b data-wp-on--click--x="actions.y"></b>';// priv.ts: private field, no findings}
*load(){if(state.navigation.hasStarted){}},// gen.js: generator method, no findings
Generator methods are the normal async-action form in IAPI stores. Suggest passing the extension in and applying # only to .php, and treating a line starting with * as a comment only when a /* block is actually open.
The reason will be displayed to describe this comment to others. Learn more.
Directives after \n or \t escapes are missed. The (?<![\w-]) lookbehind on every directive pattern sees the n/t of an escape as a name character, so it rejects the match:
$html = "<div\n\tdata-wp-ignore>"; // no findings
The same applies to "\tdata-wp-watch--x=..." and '\ndata-wp-on--click--id'. Suggest a lookbehind that also accepts an escape, e.g. (?<=^|[^\w-]|\\[nrt]).
message: `${match[0]} is removed in WordPress ${WP_VERSION}: the element and its descendants now hydrate like any other element. Remove the attribute.`,
The reason will be displayed to describe this comment to others. Learn more.
Before 7.2, toVdom only honoured ignore when ignore && !island, so on the same element as data-wp-interactive it already hydrated. The message "the element and its descendants now hydrate" is wrong there, and with iapi-check-fail: true this fails CI on markup that behaves the same. The PR's own fixture blocks/legacy/render.php:8-9 is this case.
<divdata-wp-interactive="acme" data-wp-ignore><!-- reported as an error at 1:33 -->
Suggest skipping the finding, or downgrading it to a warning, when data-wp-interactive is on the same tag.
The reason will be displayed to describe this comment to others. Learn more.
.mts and .cts aren't in MARKUP_EXTENSIONS or SCRIPT_EXTENSIONS, but detect-changes counts them in the js bucket ([cm]?[jt]s), so they trigger the job and are never scanned. A view.mts with 'data-wp-watch--' + id gives no findings, while the same code in view.ts is reported. Add 'mts' and 'cts' to both lists.
The reason will be displayed to describe this comment to others. Learn more.
node-packages/wp-tooling/AGENTS.md asks for error messages that say what was expected and what was received. This one, and cannot read "${path.join(root, file)}" at line 252, only give the error code. Add the expectation the way the --working-dir check does at line 165, e.g. (${err.code || err.message}); expected a readable directory.
The reason will be displayed to describe this comment to others. Learn more.
When the file has no router state binding, the fallback assumes state is the router's. It does this even when the file binds state to its own store, as long as the router is mentioned anywhere in the file:
import{actions}from'@wordpress/interactivity-router';const{ state }=store('acme/menu');state.navigation.isOpen;// router-state-navigation: "Read state.navigating instead"
The same happens with const { actions } = await import( '@wordpress/interactivity-router' ), the usual way to call actions.navigate, and when core/router only appears in a comment. ci-check-iapi.yml in rtCamp/wp-shared-workflows#67 runs this check by default, so these false warnings will show up on PRs.
Suggest skipping the fallback when state is destructured from a store other than core/router, and adding this mixed-store case as a regression test. This still keeps the fallback for const { state } = store<RouterStore>( 'core/router' ), which the destructure regexes don't parse.
/** `const { state } = store( 'acme/menu' )`: `state` belongs to another store. */constOTHER_STORE_DESTRUCTURE_RE=/\{([^{}]*)\}\s*=\s*store\s*(?:<[^>]*>)?\s*\(\s*['"](?!core\/router['"])/g;
The reason will be displayed to describe this comment to others. Learn more.
A trailing -- with no ID isn't flagged, but it breaks in 7.2
<button data-wp-on--click--="actions.toggle"> returns no findings. parseDirectiveName() gives it the suffix click--, both before and after WordPress/gutenberg#84098. The old on.ts cut that at -- (split( '--', 2 )[ 0 ]), so it listened for click. Now the whole suffix is the event name, so it listens for click-- and the handler never fires. The old runtime never warned about this either, because the empty ID is falsy. on-window and on-document behave the same way.
Lifecycle directives are different: data-wp-watch-- gets no suffix and still runs. So the LIFECYCLE_RE note ("a closing quote on its own is a directive with no ID, which still works") is right there. It doesn't hold for events, but EVENT_RE uses the same lookahead.
Suggest a third branch that matches a name ending at --:
In describeEvent(), when match[4] !== undefined, use text.slice( 0, -2 ) as the replacement, with a message like:
data-wp-on--click-- ends with two hyphens. WordPress 7.2 reads click-- as the event name, so the handler no longer fires on click. Use data-wp-on--click, or data-wp-on--click--- if a unique ID follows.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does
Adds
wp-tooling iapi-check, which finds the Interactivity API usages that WordPress 7.2 (due 2026-12-09) stops supporting. WordPress/gutenberg#84098 removes the expired deprecations from the client runtime:data-wp-ignore, two-hyphen unique IDs, and!applied to a function. Code that still uses them breaks quietly. The command runs locally and in CI (wp-shared-workflowsci-check-iapi.yml).Closes
N/A
Changes
src/ci/cli-support.js(refactor, first commit): coverage-gate's flag parser, choice validation, workflow-command emitter and$GITHUB_OUTPUT/$GITHUB_STEP_SUMMARYwriters move here so both CI commands share them. coverage-gate's messages and behaviour are unchanged; its annotations gain optionalcolandtitle.src/ci/iapi-check/andsrc/cli/commands/iapi-check.js: scans*.php,*.html,*.js,*.jsx,*.ts,*.tsx,*.mjsand*.cjsas raw text, so directives inrender.php, templates, patterns, PHP strings and JSX are all found. Skipsnode_modules/,vendor/,.git/, build output, minified bundles, symlinks,--ignorematches and lines that start a comment. Rules:ignore-directivedata-wp-ignoreis gone; the subtree hydrateslifecycle-double-hyphen-iddata-wp-init/watch/run--idcallbacks are skippedevent-double-hyphen-iddata-wp-on--click--idlistens for aclick--idevent, never fires on clicknegated-function!actions.x/!callbacks.x(alsons::!actions.x;!ns::actions.xnever resolved its namespace, so it is skipped) isundefined, never calledrouter-state-navigationstate.navigationfromcore/router, deprecated in 7.0Each finding has file, line, column and the exact replacement (
Use data-wp-watch---logger.). IDs built at run time ('data-wp-watch--' . $id, template literals,sprintf) are caught too.Output:
text(default),json, orgithub: annotations with paths re-rooted to the repository root, a summary table, andfindings,errorsandwarningsstep outputs. Exit 1 on any error unless--soft-fail, which reports everything as a warning and exits 0.--dry-runas every command must have.@rtcamp/wp-tooling/cialso exportscheckIapi,scanIapiText,IapiCheckErrorandIAPI_RULES. README, package AGENTS.md and CHANGELOG updated; the fixture project is excluded from ESLint because it is deliberately old code.How I verified
splitDirectiveName()invdom.ts) over 118,092 generated names: no false positives, and every suggested replacement parses. The only misses are names nobody writes (runs of four or more hyphens). A 1-2 MB file scans in about 12 ms.--output githubon the fixture project: repo-relative annotation paths,--soft-failexits 0 with warnings, without it exits 1.Acceptance criteria
Runtime behavior
---id,data-wp-on-async*,!state.*, single-hyphen custom events)--working-diris a subdirectory--soft-failexits 0; errors exit 1 otherwiseCode quality
Housekeeping
CHANGELOG.mdentry under## UnreleasedReviewer notes
negated-functionis a heuristic. Statically we cannot know what resolves to a function, so it flags only theactions.andcallbacks.namespaces, which are functions by convention.!state.xand!context.xstay valid in 7.2 (getters and booleans still work).router-state-navigationonly runs on files that mentioncore/routerorinteractivity-router, so a theme's own menu store withstate.navigationis not flagged.--ignorecovers it today; an inline ignore comment would be a small follow-up.