Skip to content

1353 bulk editor guard unsaved edits against hard navigation paths#23449

Merged
JorPV merged 10 commits into
release/28.1from
1353-bulk-editor-guard-unsaved-edits-against-hard-navigation-paths
Jul 14, 2026
Merged

1353 bulk editor guard unsaved edits against hard navigation paths#23449
JorPV merged 10 commits into
release/28.1from
1353-bulk-editor-guard-unsaved-edits-against-hard-navigation-paths

Conversation

@FAMarfuaty

@FAMarfuaty FAMarfuaty commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Context

Follow-up to #23447, which guarded unsaved inline edits and pending AI suggestions only for the two in-app view switches (appearance tabs and content types). Paths that leave the page entirely still dropped edits silently: the Yoast logo link, the "Back to Tools" link, and browser refresh / tab close / back button. This PR guards those.

It also fixes a switch-back regression from #23447: the content-type click handler compared against a stale active id, so returning to the initially selected content type did nothing.

Summary

This PR can be summarized in the following changelog entries:

  • Guards unsaved bulk editor edits against page navigations that leave the editor.
  • Fixes an unreleased bug where returning to the initially selected bulk editor content type did nothing after switching away from it.

Relevant technical choices:

  • The two in-app links reuse the existing requestSwitch/commitSwitch guard via a new navigate kind, so the styled modal, the self-heal effect, and Premium's pending-changes slot all apply unchanged (including for pending AI suggestions).
  • Refresh / close / back use a beforeunload listener, since the browser's native dialog can't route through the styled modal. It arms on Free's own inline edits only: an external plugin's pending changes (Premium AI) are guarded by that plugin's own modal, and Premium owns any unload guard for its state — reading its flag here would fire a native prompt on top of a navigation the user already confirmed.
  • Modified clicks (open in new tab/window) pass through unguarded, as they don't leave the page.
  • The switch-back fix keeps the content-type handler referentially stable by reading the active id from a ref: the sidebar navigation freezes the first click handler it receives, so a handler that closed over the active id went stale.

Test instructions

Test instructions for the acceptance test before the PR gets merged

This PR can be acceptance tested by following these steps:

Hard-navigation links — manual edits (Free):

  1. Open Yoast SEO → Tools → Bulk editor → Posts.
  2. Click Edit on a row and change a field value.
  3. Click the Yoast logo (or Back to Tools). The Unsaved changes modal appears and you stay on the page. Test each button, re-opening the edit before each:
    • Cancel → modal closes, you stay, the edit is intact.
    • Continue without saving → navigates away, the edit is discarded.
    • Save changes → the edit is saved (POST to the update endpoint), then navigates.
  4. Re-open an edit and Cmd/Ctrl-click (or middle-click) the logo or Back to Tools → it opens in a new tab, no modal, the edit stays.

Refresh / tab close / browser back (Free):

  1. With an open inline edit, refresh the page (or close the tab, or press the browser back button).
  2. The browser's native "leave site?" dialog appears. Leave → navigates (edit lost); Stay/Cancel → remains, edit intact.
  3. With no open edits, refresh → no dialog.

In-app view switches still guarded (regression check):

  1. With an open edit, switch the Search/Social appearance tab, or switch content type → the Unsaved changes modal appears; Cancel keeps the edit, Continue without saving / Save changes proceed as above.

Content-type switch-back:

  1. On the default content type (e.g. Posts), click Edit and change a value.
  2. Switch to another content type and choose Continue without saving; you land on the new type.
  3. Click the original content type again → it switches back (before this fix it did nothing).

Pending AI suggestions (Premium — requires the Premium counterpart on branch 1353-bulk-editor-guard-unsaved-edits-against-hard-navigation-paths, Premium active with a valid subscription):

  1. On Posts, select a row with a focus keyphrase and click Generate meta descriptions (or SEO titles). Wait for the suggestion; do not apply it.
  2. Click the Yoast logo (or Back to Tools). Premium's "Pending AI suggestions" modal appears (not the Free one). Test each button, re-generating a suggestion before each:
    • Cancel → stays, the suggestion is kept.
    • Continue without applying → the suggestion is discarded, then navigates.
    • Apply all suggestions → the suggestions are applied (saved), then navigates.
  3. In every step-2 case, confirm no second, native "leave site?" dialog appears on top of the modal.
  4. With a pending suggestion, refresh / close / back → the native dialog warns; with nothing pending → no dialog.
  5. With both a manual edit and a pending suggestion, refresh → only a single native dialog appears.

Relevant test scenarios

  • Changes should be tested with the browser console open
  • Changes should be tested on different posts/pages/taxonomies/custom post types/custom taxonomies
  • Changes should be tested on different editors (Default Block/Gutenberg/Classic/Elementor/other)
  • Changes should be tested on different browsers
  • Changes should be tested on multisite

The beforeunload dialog is browser-native, so its wording and behaviour vary per browser. In Free, the native refresh/close/back guard covers manual edits only; the pending-AI-suggestions equivalent lives in the Premium counterpart branch referenced above, which the Premium test steps require.

Test instructions for QA when the code is in the RC

  • QA should use the same steps as above.

QA can test this PR by following these steps:

  • Same steps as above.

Impact check

This PR affects the following parts of the plugin, which may require extra testing:

  • The bulk editor navigation (logo and Back to Tools links), the unsaved-changes and pending-AI modals, browser unload handling, and content-type switching.

Other environments

  • This PR also affects Shopify.
  • This PR also affects Yoast SEO for Google Docs.

Documentation

  • I have written documentation for this change.

Quality assurance

  • I have tested this code to the best of my abilities.
  • During testing, I had activated all plugins that Yoast SEO provides integrations for.
  • I have added unit tests to verify the code works as intended.
  • If any part of the code is behind a feature flag, my test instructions also cover cases where the feature flag is switched off.
  • I have written this PR in accordance with my team's definition of done.
  • I have checked that the base branch is correctly set.
  • I have run grunt build:images and committed the results, if my PR introduces or edits images or SVGs.

Innovation

  • No innovation project is applicable for this PR.
  • This PR falls under an innovation project. I have attached the innovation label.
  • I have added my hours to the WBSO document.

Fixes Yoast/reserved-tasks#1353

FAMarfuaty and others added 4 commits July 13, 2026 14:20
Extends requestSwitch/commitSwitch with a "navigate" kind whose target is a
URL, so a full page navigation can be deferred to the same confirmation flow as
tab and content-type switches. The navigate kind skips the no-op current-view
check and, on commit, hands off to window.location instead of mutating view
state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… guard

The Yoast logo and "Back to Tools" links rendered plain anchors that navigated
away and silently dropped pending edits. Their clicks now dispatch a guarded
navigate switch, deferring to the same confirmation modal (and Premium's pending-AI
slot) as the tab and content-type switches. Modified clicks (open in new tab or
window) still pass through to the browser unguarded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refresh, tab close, and the browser back button leave the page without touching
the in-app links, so the styled modal cannot catch them. A beforeunload listener
now triggers the browser's native confirm dialog whenever there are unsaved inline
edits or an external plugin (Premium AI) reports pending changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… away

Switching content type, then trying to switch back to the earlier type did
nothing when the earlier switch left the stored active name resolved from empty.
The content-type click handler closed over the resolved active id, and the sidebar
navigation freezes the first handler it is given, so the comparison ran against a
stale active id and treated the switch-back as a no-op. The handler now reads the
active id from a ref, staying referentially stable while comparing against the
current value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FAMarfuaty FAMarfuaty added this to the 28.1 milestone Jul 13, 2026
@FAMarfuaty FAMarfuaty added changelog: non-user-facing Needs to be included in the 'Non-userfacing' category in the changelog innovation Innovative issue. Relating to performance, memory or data-flow. labels Jul 13, 2026
…ouble prompt

The beforeunload guard also read the external pending-changes flag (Premium AI
suggestions). Confirming Premium's own modal to leave via an in-app link then fired
the browser's native prompt on top of it, and the navigate commit left the deferred
switch in place so the self-heal effect re-fired the navigation even after the user
chose to stay. The guard now arms on Free's own inline edits only — Premium owns the
unload guard for its state — and the navigate commit clears the deferred switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coveralls

coveralls commented Jul 13, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 8

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Warning

No base build found for commit ad7894f on release/28.1.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 46.112%

Details

  • Patch coverage: 1 uncovered change across 1 file (25 of 26 lines covered, 96.15%).

Uncovered Changes

File Changed Covered %
packages/js/src/bulk-editor/app.js 11 10 90.91%
Total (4 files) 26 25 96.15%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 61857
Covered Lines: 29880
Line Coverage: 48.3%
Relevant Branches: 12186
Covered Branches: 4263
Branch Coverage: 34.98%
Branches in Coverage %: Yes
Coverage Strength: 7.96 hits per line

💛 - Coveralls

@FAMarfuaty FAMarfuaty marked this pull request as ready for review July 14, 2026 08:57
@FAMarfuaty FAMarfuaty requested a review from Copilot July 14, 2026 09:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the bulk editor’s existing pending-changes guard to cover “hard” navigation paths that leave the page (Yoast logo, “Back to Tools”, refresh/close/back via beforeunload), and fixes a regression where switching back to the initially selected content type could be ignored due to a stale click handler.

Changes:

  • Adds a new guarded switch kind (navigate) that routes hard navigations through the existing pending-switch / modal flow.
  • Arms a native beforeunload prompt only for Free’s own unsaved inline edits (not external/premium pending changes).
  • Fixes content-type switch-back by reading the active content type id from a ref to avoid stale handler closures.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/js/src/bulk-editor/store/pending-switch.js Adds navigate switch kind and commit behavior to redirect via window.location.href, reusing existing guard logic.
packages/js/src/bulk-editor/app.js Adds useBeforeUnload for unsaved inline edits; intercepts logo/Back-to-Tools clicks to request guarded navigation; fixes stale content-type handler using a ref.
packages/js/src/bulk-editor/components/bulk-editor-nav.js Routes Yoast logo clicks through a new onNavigate prop so hard navigation can be guarded.
packages/js/src/bulk-editor/components/back-to-tools-link.js Adds onNavigate support so “Back to Tools” click can be intercepted/guarded.
packages/js/tests/bulk-editor/store/pending-switch.test.js Adds unit tests covering navigate request/deferral and navigation commit behavior.
packages/js/tests/bulk-editor/bulk-editor-nav.test.js Adds tests ensuring logo and Back-to-Tools clicks are routed through onNavigate with correct hrefs.
packages/js/tests/bulk-editor/app.test.js Adds integration-style tests for switch-back regression and hard-navigation guarding / beforeunload arming behavior.

Comment thread packages/js/src/bulk-editor/store/pending-switch.js
Comment thread packages/js/src/bulk-editor/store/pending-switch.js Outdated
dispatch.clearPendingSwitch();
// Security: target flows unvalidated into location.href, so it must stay a server-generated URL (the localized
// links in bulk-editor-integration.php). If a link ever derives from request input, validate it here first
// (same-origin and an http(s) scheme) to avoid an open redirect or a javascript: URI.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// (same-origin and an http(s) scheme) to avoid an open redirect or a javascript: URI.
// Always validate server-generated URLs before using them in location.href to prevent open redirects or malicious URIs.

* pending changes: the switch is then held until the user resolves it, otherwise it commits immediately.
* pending changes: the switch is then held until the user resolves it, otherwise it commits immediately. A
* "navigate" switch targets a URL rather than a view value, so it skips the no-op check that compares against the
* current view.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* current view.
* Switch requests are blocked during manual edits or pending plugin changes; otherwise, they commit immediately. A 'navigate' switch targets a URL, bypassing the current view check.

Comment thread packages/js/src/bulk-editor/app.js Outdated
const isPremium = useSelect( ( select ) => select( STORE_NAME ).selectPreference( "isPremium", false ), [] );
// Only Free's own unsaved inline edits arm the native unload guard. An external plugin's pending changes
// (Premium's AI suggestions) are guarded by that plugin's own modal, and it owns any unload guard for its state;
// reading its flag here would double-prompt on a navigation the user has already confirmed through that modal.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// reading its flag here would double-prompt on a navigation the user has already confirmed through that modal.
// Free's unsaved inline edits trigger the native unload guard; Premium handles its own pending changes and unload prompts.

Comment thread packages/js/src/bulk-editor/app.js Outdated

// The resolved active id is read from a ref so the handler below can stay referentially stable: the sidebar
// navigation freezes the first click handler it receives, so a handler closing over activeContentTypeId would
// keep comparing against a stale value and silently drop a switch back to an earlier content type.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// keep comparing against a stale value and silently drop a switch back to an earlier content type.
// The active ID is read from a ref to keep the click handler referentially stable, preventing stale comparisons that could silently drop a switch to an earlier content type.

@JorPV

JorPV commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

CR + ACC ✅

@JorPV JorPV merged commit 8b22df3 into release/28.1 Jul 14, 2026
38 checks passed
@JorPV JorPV deleted the 1353-bulk-editor-guard-unsaved-edits-against-hard-navigation-paths branch July 14, 2026 16:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog: non-user-facing Needs to be included in the 'Non-userfacing' category in the changelog innovation Innovative issue. Relating to performance, memory or data-flow.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants