Skip to content

Fix Gutenberg infinite save loop when Polylang Pro is active#8031

Open
thisismyurl wants to merge 5 commits into
Automattic:trunkfrom
thisismyurl:fix/7861-lesson-quiz-response-isolation
Open

Fix Gutenberg infinite save loop when Polylang Pro is active#8031
thisismyurl wants to merge 5 commits into
Automattic:trunkfrom
thisismyurl:fix/7861-lesson-quiz-response-isolation

Conversation

@thisismyurl

Copy link
Copy Markdown
Contributor

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 injects lang and translations into any response it processes, including Sensei's lesson-quiz endpoint.

Gutenberg's structure-store.js uses a deep equality check (isEqual(newStructure, state.editorStructure)) to detect unsaved changes. The injected fields change the response shape so the check fails, hasUnsavedServerUpdates is set to true, another save fires — and the cycle repeats indefinitely.

Fix

isolate_lesson_quiz_response() is registered on rest_pre_echo_response at PHP_INT_MAX priority. PHP_INT_MAX is 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 by response_to_data() before rest_pre_echo_response fires, and stripping them would break REST clients that follow links.

Why rest_pre_echo_response rather than a per-route filter? Sensei exposes both a GET (get_quiz) and a POST (save_quiz) on the same route. rest_pre_echo_response covers both in one registration. The callback self-limits via a route-prefix strpos guard, so it returns $result unchanged 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_response can declare those keys on the sensei_lesson_quiz_rest_response_keys filter and they will not be stripped.

Testing

  • With Polylang Pro active, open a Lesson in the block editor — the editor no longer enters an infinite save loop.
  • Without Polylang Pro, the lesson-quiz endpoint returns exactly the same fields as before this patch.
  • A third-party extension using add_filter('sensei_lesson_quiz_rest_response_keys', fn($k)=>[...$k,'my_field']) has its field preserved.

Checklist

  • Changelog entry added (changelog/fix-lesson-quiz-rest-response-isolation)
  • 4 tests, 16 assertions, all passing
  • PHPCS clean on all modified files
  • Delete-the-fix confirmed: removing add_filter() from register_routes() fails testGetQuizEndpoint_PolylangInjectedFieldsAreStrippedBeforeSerialization; direct unit tests still pass

props @thisismyurl

(full disclosure: AI helped me identify the issue and verify my work)

thisismyurl and others added 5 commits June 24, 2026 13:49
…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>
@donnapep

donnapep commented Jun 29, 2026

Copy link
Copy Markdown
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? 🙏🏻

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.

Quiz block: saving lesson post gets stuck/loop

2 participants