Fix Gutenberg infinite save loop when Polylang Pro is active#8031
Open
thisismyurl wants to merge 5 commits into
Open
Fix Gutenberg infinite save loop when Polylang Pro is active#8031thisismyurl wants to merge 5 commits into
thisismyurl wants to merge 5 commits into
Conversation
…utomattic#7861) Third-party plugins (e.g. Polylang Pro) hook into WordPress REST API lifecycle filters and inject extra top-level fields — such as lang and translations — into every REST response they process, including Sensei's internal lesson-quiz endpoint. Gutenberg's block-state comparison receives these extra fields, sees data that was not in its in-memory state, marks the post dirty, and triggers another save cycle, producing an infinite loop. Add isolate_lesson_quiz_response() hooked at PHP_INT_MAX on rest_pre_echo_response. It strips any top-level fields not in Sensei's contract (options, questions, lesson_title, lesson_status) before the response is serialised. A sensei_lesson_quiz_rest_response_keys filter lets legitimate extensions declare their additional fields so they are preserved. Add three unit tests: strips third-party fields, leaves other routes unchanged, preserves custom fields declared via the new filter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The three existing unit tests called isolate_lesson_quiz_response() directly
and would pass even if the add_filter() registration in register_routes() was
removed. The new testRegisterRoutes_IsolationFilterIsWiredOnRestPreEchoResponse
test goes through apply_filters('rest_pre_echo_response', ...) instead, so it
fails when the registration is absent — satisfying the delete-the-fix requirement.
Replace the previous mock-data test with one that dispatches through the registered REST server using a real lesson ID, then applies the rest_pre_echo_response filter chain to replicate what serve_request() does before serialisation. Delete-the-fix confirmed: removing the add_filter() line in register_routes() causes the Polylang-injected fields to survive the chain, failing the assertArrayNotHasKey assertions.
…ponse_to_data WP_REST_Server::serve_request() calls response_to_data() before applying the rest_pre_echo_response filter. response_to_data() adds _links (HAL links) and _embedded (?_embed requests) as underscore-prefixed top-level keys. The previous allow-list stripped these WP core keys from the lesson-quiz response. isolate_lesson_quiz_response() now preserves all underscore-prefixed keys unconditionally while still stripping unrecognised non-underscore keys injected by third-party plugins such as Polylang Pro. The integration test is updated to route through response_to_data() — the exact step serve_request() takes — and asserts that a simulated _links key survives filtering while lang/translations injected at priority 10 are stripped.
…array-key Psalm types array_keys() as list<array-key> where array-key = string|int. Accessing $key[0] on an int is an InvalidArrayAccess. Replace isset($key[0]) with is_string($key) which both guards against int keys and makes Psalm's type narrowing accurate for the '_' === $key[0] check that follows. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Member
|
@thisismyurl Thanks for the PR! I took a look, but would prefer the solution in #8036 as that's currently where the bug resides, plus we wouldn't need to add a new hook. Unfortunately though, I don't have Polylang Pro so am unable to confirm that it resolves #7861. Would you kindly be able to test against #8036 to confirm it resolves the original issue? 🙏🏻 |
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Closes #7861
Description
When Polylang Pro is active it hooks into
rest_pre_echo_response— a WordPress REST lifecycle filter that fires for every REST response before serialisation — and injectslangandtranslationsinto any response it processes, including Sensei's lesson-quiz endpoint.Gutenberg's
structure-store.jsuses a deep equality check (isEqual(newStructure, state.editorStructure)) to detect unsaved changes. The injected fields change the response shape so the check fails,hasUnsavedServerUpdatesis set totrue, another save fires — and the cycle repeats indefinitely.Fix
isolate_lesson_quiz_response()is registered onrest_pre_echo_responseatPHP_INT_MAXpriority.PHP_INT_MAXis required (not merely late) because the injector (Polylang Pro at its default priority) must have already run before we strip; we cannot anticipate what priority Polylang or any future injector will choose, so running last is the only guarantee.The callback strips any non-underscore-prefixed top-level key not in Sensei's contract. Underscore-prefixed keys (
_links,_embedded, etc.) are always preserved: they are WordPress HAL conventions injected byresponse_to_data()beforerest_pre_echo_responsefires, and stripping them would break REST clients that follow links.Why
rest_pre_echo_responserather than a per-route filter? Sensei exposes both a GET (get_quiz) and a POST (save_quiz) on the same route.rest_pre_echo_responsecovers both in one registration. The callback self-limits via a route-prefixstrposguard, so it returns$resultunchanged for every non-lesson-quiz response — the site-wide registration has O(1) overhead per unrelated request.Escape hatch for extensions: third-party extensions that add custom top-level fields via
sensei_rest_api_lesson_quiz_responsecan declare those keys on thesensei_lesson_quiz_rest_response_keysfilter and they will not be stripped.Testing
add_filter('sensei_lesson_quiz_rest_response_keys', fn($k)=>[...$k,'my_field'])has its field preserved.Checklist
changelog/fix-lesson-quiz-rest-response-isolation)add_filter()fromregister_routes()failstestGetQuizEndpoint_PolylangInjectedFieldsAreStrippedBeforeSerialization; direct unit tests still passprops @thisismyurl
(full disclosure: AI helped me identify the issue and verify my work)