Skip to content

Inline JS Expressions in Interactivity API#12383

Open
nickchomey wants to merge 8 commits into
WordPress:trunkfrom
nickchomey:iAPI-inline-js
Open

Inline JS Expressions in Interactivity API#12383
nickchomey wants to merge 8 commits into
WordPress:trunkfrom
nickchomey:iAPI-inline-js

Conversation

@nickchomey

@nickchomey nickchomey commented Jul 1, 2026

Copy link
Copy Markdown

Trac ticket: WordPress/gutenberg#79765 (this is the php component of the proposed improvement to Gutenberg. I was told in Slack to create the issue in the Gutenberg repo)

As noted in the parent issue, the PHP side of this effort performs best-effort evaluation of expressions, references to state, context, actions and callbacks references, where practical. If it cannot evaluate the expression, it simply passes through to the browser to be hydrated, as is already the case. In that sense, this represents a progressive enhancement, whose capabilities could be expanded over time as the need becomes apparent.

Two approaches for evaluating the JS expressions have been implemented here along with accompanying tests, and both run simultaneously under WP_DEBUG so their results can be compared for parity. Neither is considered the "correct" answer at this stage. This dual-implementation strategy exists to show code reviewers that both approaches were carefully considered and tested against each other. One will be removed before anything is merged into WordPress Core.

Approach A: Regex transform + token validation + eval()

The original JS expression is transformed via regex: state.X.Y$__st['X']['Y'] and context.X$__ctx['X']. The resulting PHP expression is validated by token_get_all() against an allowlist/denylist that rejects dangerous PHP constructs (object operators, backticks, include, eval, etc.) and defers assignments/function calls to the client. Derived-state Closures are substituted with JSON literals. The validated, substituted expression is then evaluated with eval("return ( $expr );").

Limitations: In addition to the challenge of preventing the execution of dangerous expressions on the server, PHP's eval() inherits PHP semantics - &&/|| return booleans (not operands), empty arrays and '0' are falsy (not truthy as in JS), + does not concatenate strings, and == follows PHP's loose comparison rules. These ~13 known divergences are documented in the test matrix.

Approach B: Custom lexer + recursive-descent parser + interpreter

A new WP_Interactivity_Expression_Evaluator class consumes the original JS expression directly - no regex transforms, no eval(). It implements a full recursive-descent parser with correct JS operator precedence and an evaluator with proper JS semantics:

  • Lexer: Tokenizes numbers (int/float), strings (single/double quotes), all JS operators (===, !==, ??, **, bitwise, etc.), identifiers (greedy dotted paths), ; statement separators, and JS keywords (true/false/null).
  • Parser: Full grammar from Program down through Primary, with correct precedence and multi-statement (;-delimited) support.
  • Evaluator: Proper JS truthiness (empty arrays and '0' are truthy), short-circuit operators return their operand (not a coerced boolean), + concatenates strings, ==/!= follow JS loose equality, ?? treats only null as nullish, and all bitwise/shift/exponentiation operators work.

Limitations (shared with Approach A): Function-call syntax (actions.foo(), Math.max(...)), assignments, and comma operators are rejected as unsupported (return null → client handles at hydration). actions.* and callbacks.* identifiers are recognized but resolve to null server-side (since they are client-only concepts), ensuring expressions that mix state/context with actions/callbacks gracefully defer to the client.

The approaches compared

Approach B eliminates all ~13 known PHP-vs-JS semantic divergences, never calls eval(), and produces correct JS semantics for the supported expression subset. In contrast, Approach A inherits PHP eval() semantics for &&/|| operand return, "truthiness" of '0' and [], loose equality, and string concatenation. Both approaches reject the same set of unsupported constructs (function calls, assignments, comma operators), deferring those expressions to the client.

The expression-benchmark.php does a quick benchmark of the two approaches. Approach A is marginally faster, but they both add negligible overhead (~0.5 _micro_seconds per expression).

Multi-statement support

Following Datastar's convention, ;-delimited expressions are supported on both client and server. On the client side, a new splitStatements() helper (with IIFE-aware regex) splits expressions into individual statements. On the server side, both Approach A and Approach B handle ;-separated statements with "last statement wins" semantics for return-value directives. The server returns null (defers to client) if any statement contains mutations, assignments, or references to actions/callbacks.

The splitStatements() (client) and split_expression_into_statements() (server) helpers use a regex to split ;-delimited expressions while respecting string literals, template literals, regex literals, and IIFE bodies. The original regex comes from Datastar's genRx() function and underwent extensive testing there (and was just updated/optimized as a result of some of my findings during this effort)

One modification was made to the PHP version of the regex, due to the fact that PCRE and V8 backtrack differently inside the greedy * quantifier. To keep both engines correct:

  • PHP (PCRE): Uses \\/, \\", \\', \\` - matches escape sequences as atomic two‑character units (e.g. \" is only consumed when \ immediately precedes "). Both faster and correct in PCRE.
  • JS (V8): Uses \/, \", \', \` - matches bare delimiter characters and relies on greedy backtracking. The PHP‑style pattern breaks in V8 for edge cases like "hello \"world;foo\"" where the ; is inside an escaped string.

Comparison scripts are provided to verify correctness and performance.

actions.* and callbacks.* support

Both server-side approaches recognize actions.* and callbacks.* as valid identifier prefixes. Since these are client-only concepts (JS function references), they resolve to null on the server. This ensures expressions like actions.increment or state.count && callbacks.isValid are handled gracefully rather than producing errors - the server returns null and the client handles the expression at hydration. Likewise an el and evt arg are ignored while evaluating the expressions.

What's supported where

Feature Client Server (Approach A) Server (Approach B)
Comparisons (===, !==, >, <, etc.)
Logical (&&, ||, ??) ✅ (PHP semantics) ✅ (correct JS semantics)
Ternary (a ? b : c)
Arithmetic (+, -, *, /, %, **)
Bitwise (&, |, ^, ~, <<, >>)
Unary (!, -, ~)
Grouping (( ... ))
String literals
Number literals (int/float)
true/false/null literals
state.* / context.* paths
Derived-state Closures in expressions
actions.* / callbacks.* references ✅ (→ null) ✅ (→ null)
;-delimited multi-statement
Function call syntax foo(...)
Assignments (=, +=, ++)
typeof, instanceof, in

Testing

WordPress core Unit Tests

All tests are in tests/phpunit/tests/interactivity-api/ under @group interactivity-api:

  • wpInteractivityExpressionEvaluator.php - Tests for the Approach B evaluator directly: integer/float literals with strict equality, short-circuit operand return, complex boolean grouping, bitwise/shift/exponentiation, function-call rejection, derived-state Closure invocation and recording, chained Closures, and empty input.

  • wpInteractivityAPIEvaluateFullExpression.php - Tests through the public evaluate() entry point: basic comparisons, logical operators, complex boolean logic, nullish coalescing, ternaries, boolean/null literals, unary negation, integer strict equality regression, bitwise/shift/exponentiation, short-circuit operand return, compound expressions over derived-state Closures, and invalid/dangerous expression rejection.

  • wpInteractivityAPIExpressionApproaches.php - Direct A vs B comparison: 13 matching cases where both approaches agree, and 13 documented divergences where Approach B produces correct JS semantics but Approach A inherits PHP eval() behavior (empty array truthiness, '0' truthiness, operand-returning short-circuit, loose equality, string concatenation with +, etc.).

  • wpInteractivityAPIEvaluateExpressionSafety.php - Token validation: valid read-only expressions, unsupported assignments/calls, and ~35 invalid/dangerous/PHP-specific constructs (object operators, backticks, magic constants, cast operators, eval, include, exit, match, etc.).

  • wpInteractivityAPISubstituteClosures.php - Closure substitution: leaf closure substitution with JSON literals, mid-path closure substitution, closure returning closure chains, and closure invocation counting.

All 407 tests pass (886 assertions). The test matrix explicitly documents every known semantic divergence between the two approaches, making the trade-offs visible to reviewers.

Running the tests

# Unit Tests
cd wordpress-develop
npm run test:php -- --group interactivity-api

# Regex comparison (PHP - PCRE engine)
cd wordpress-develop
php tests/phpunit/development/regex-comparison.php

# Performance comparison A vs B

cd wordpress-develop
php tests/phpunit/development/expression-benchmark.php

Known limitations & future work

  1. One approach will be removed before merge. Both Approach A and Approach B are experiments. The dual implementation exists solely to demonstrate that both strategies were evaluated. One will be selected (and the other removed) before this code is merged into WordPress Core.

  2. Approach A vs B produces different results for some actions.*/callbacks.* edge cases. Approach A transforms them to PHP null and evaluates with PHP semantics (e.g. 5 && nullfalse in PHP vs null in JS). Approach B resolves them to null with correct JS semantics. These divergences are documented in the test matrix and expected during the comparison phase.

  3. Both approaches reject the same unsupported constructs. Function-call syntax (foo()), assignments (=, +=, ++), comma operators, typeof, instanceof, and other constructs not in the supported grammar are rejected by both approaches (return null → client handles at hydration).

  4. Multi-statement expressions with mutations defer to client. When a ;-delimited expression contains assignments or increment/decrement operators, both server-side approaches detect the unconsumed tokens and return null (defer to client). This is correct because the server cannot persist state mutations.

  5. splitStatements() regex has theoretical edge cases with division-vs-regex ambiguity (e.g. a/b/g; c) and nested IIFEs, but these are unlikely to occur in practice for directive expressions and are handled gracefully by falling through to [^;] matching.

  6. Double evaluation under WP_DEBUG - every expression is evaluated by both Approach A and Approach B so their results can be compared. This doubles the per-expression cost in debug mode but is intentional and temporary.

Use of AI Tools

AI assistance: Yes
Tool(s): VSCode GitHub Copilot
Model(s): GPT-5.4, Deepseek V4 Flash and Pro
Used for: Frankly everything. But I iterated with it as a pair programmer.
-->


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props nickchomey.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Hi there! 👋

Thank you for your contribution to WordPress! 💖

It looks like this is your first pull request to wordpress-develop. Here are a few things to be aware of that may help you out!

No one monitors this repository for new pull requests. Pull requests must be attached to a Trac ticket to be considered for inclusion in WordPress Core. To attach a pull request to a Trac ticket, please include the ticket's full URL in your pull request description.

Pull requests are never merged on GitHub. The WordPress codebase continues to be managed through the SVN repository that this GitHub repository mirrors. Please feel free to open pull requests to work on any contribution you are making.

More information about how GitHub pull requests can be used to contribute to WordPress can be found in the Core Handbook.

Please include automated tests. Including tests in your pull request is one way to help your patch be considered faster. To learn about WordPress' test suites, visit the Automated Testing page in the handbook.

If you have not had a chance, please review the Contribute with Code page in the WordPress Core Handbook.

The Developer Hub also documents the various coding standards that are followed:

Thank you,
The WordPress Project

@nickchomey nickchomey changed the title I api inline js Inline JS Expressions in Interactivity API Jul 1, 2026
@nickchomey nickchomey marked this pull request as draft July 1, 2026 18:15
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Trac Ticket Missing

This pull request is missing a link to a Trac ticket. For a contribution to be considered, there must be a corresponding ticket in Trac.

To attach a pull request to a Trac ticket, please include the ticket's full URL in your pull request description. More information about contributing to WordPress on GitHub can be found in the Core Handbook.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant