Inline JS Expressions in Interactivity API#12383
Conversation
|
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 Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
|
Hi there! 👋 Thank you for your contribution to WordPress! 💖 It looks like this is your first pull request to 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, |
Test using WordPress PlaygroundThe 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
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
Trac Ticket MissingThis 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. |
6cb2b18 to
2d8a961
Compare
…ting shows it is even faster
2d8a961 to
cbd4420
Compare
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_DEBUGso 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']andcontext.X→$__ctx['X']. The resulting PHP expression is validated bytoken_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 witheval("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_Evaluatorclass consumes the original JS expression directly - no regex transforms, noeval(). It implements a full recursive-descent parser with correct JS operator precedence and an evaluator with proper JS semantics:===,!==,??,**, bitwise, etc.), identifiers (greedy dotted paths),;statement separators, and JS keywords (true/false/null).;-delimited) support.'0'are truthy), short-circuit operators return their operand (not a coerced boolean),+concatenates strings,==/!=follow JS loose equality,??treats onlynullas 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 (returnnull→ client handles at hydration).actions.*andcallbacks.*identifiers are recognized but resolve tonullserver-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 PHPeval()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.phpdoes 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 newsplitStatements()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 returnsnull(defers to client) if any statement contains mutations, assignments, or references to actions/callbacks.The
splitStatements()(client) andsplit_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:\\/,\\",\\',\\`- matches escape sequences as atomic two‑character units (e.g.\"is only consumed when\immediately precedes"). Both faster and correct in PCRE.\/,\",\',\`- 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.*andcallbacks.*supportBoth server-side approaches recognize
actions.*andcallbacks.*as valid identifier prefixes. Since these are client-only concepts (JS function references), they resolve tonullon the server. This ensures expressions likeactions.incrementorstate.count && callbacks.isValidare handled gracefully rather than producing errors - the server returnsnulland the client handles the expression at hydration. Likewise anelandevtarg are ignored while evaluating the expressions.What's supported where
===,!==,>,<, etc.)&&,||,??)a ? b : c)+,-,*,/,%,**)&,|,^,~,<<,>>)!,-,~)( ... ))true/false/nullliteralsstate.*/context.*pathsactions.*/callbacks.*referencesnull)null);-delimited multi-statementfoo(...)=,+=,++)typeof,instanceof,inTesting
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 publicevaluate()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 PHPeval()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
Known limitations & future work
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.
Approach A vs B produces different results for some
actions.*/callbacks.*edge cases. Approach A transforms them to PHPnulland evaluates with PHP semantics (e.g.5 && null→falsein PHP vsnullin JS). Approach B resolves them tonullwith correct JS semantics. These divergences are documented in the test matrix and expected during the comparison phase.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 (returnnull→ client handles at hydration).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 returnnull(defer to client). This is correct because the server cannot persist state mutations.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.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.