Skip to content

fix: preserve selected page state across the maintenance mode lifecycle - #524

Merged
selul merged 15 commits into
developmentfrom
fix/523-preserve-selected-page-state
Jul 29, 2026
Merged

fix: preserve selected page state across the maintenance mode lifecycle#524
selul merged 15 commits into
developmentfrom
fix/523-preserve-selected-page-state

Conversation

@Alexia-Soare

@Alexia-Soare Alexia-Soare commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Selecting an existing published page (e.g. the homepage) as the maintenance page could permanently break it (#523): the plugin forced it private on every load with maintenance mode disabled, and applying a gallery template overwrote its content. The plugin now records a selected page's original state and restores it, and only writes templates into pages it created itself.

What changed

  • select_page() (or first enable, for pre-existing selections) records the page's original status and template in a single post meta value, claimed first-write-wins, so a racing request can neither capture plugin-made changes as "original" nor observe half a record. The record is cleared after each restore, so later user edits become the new baseline; a maintenance template carried over from an old release is recorded as "no template" rather than as user state.
  • Disabling maintenance mode used to force the page private; disabling and plugin deactivation now restore the recorded status and template, and leave pages with no record untouched.
  • The plugin publishes the selected page exactly once per takeover, and only when the page was already private at takeover; it used to force-publish on every active-mode request. Together with restore only reverting that plugin-made publish, any status the user chooses — draft, private, or trash, during or after maintenance — is kept; the page just gets its template handed back and the record cleared. Enable likewise skips deleted and trashed pages entirely.
  • Changing or clearing the page selection restores the previously selected page before switching, so an abandoned page doesn't keep the maintenance template or a stale record. Both writers — the AJAX select and a plain Design-form save — go through the same take-over lifecycle, and a template import hands back even a trashed selection.
  • Template imports only overwrite pages the plugin created (marked _wpmm_generated), and overwriting keeps the page's current status — importing mid-maintenance no longer unpublishes the maintenance page. A selected user-owned page is restored and the template lands on a newly created "Maintenance Page", which becomes the selection. The "content will be replaced" warning in settings now shows only in that overwrite case.
  • Template imports reject slugs/categories that don't resolve to a bundled template (previously created an empty page; both are sanitized before filesystem use), and the Otter CSS_Handler call is skipped when Otter Blocks isn't installed (previously a fatal error).

The CI job also moves from PHP 7.2 to 7.4: the latest WordPress test suite requires 7.4, so PHPUnit aborted before running any test. It's bundled here because this PR's tests depend on it.

Lifecycle after this PR

flowchart TD
    A[User selects a page] --> P[Restore previously<br/>selected page, if any]:::added
    P --> B[Record original status + template]:::added
    B --> C[Enable: apply wpmm template; publish an<br/>originally private page once, marked plugin-made]:::changed
    C --> D{Disable / deactivate}
    D --> E[Undo the plugin-made publish only,<br/>restore template, clear record;<br/>user status changes stay]:::added

    F[Template import] --> G{Page marked<br/>_wpmm_generated?}:::added
    G -- yes --> H[Overwrite it with the template]
    G -- no --> I[Restore user page; create new<br/>generated page and select it]:::added

    classDef added fill:#1a7f37,color:#fff,stroke:#1a7f37
    classDef changed fill:#9a6700,color:#fff,stroke:#9a6700
Loading

🟩 added by this PR (restore on switch, record/restore, ownership check, new-page branch) · 🟨 changed behavior (enable now records state, marks its publish, and skips deleted/trashed pages) · gray = unchanged

Data changes

Before: no stored record — disable ran post_status → private unconditionally. After: state is recorded on takeover and consumed on restore.

Meta key Value Owner / lifetime
_wpmm_original_state Status + template before the plugin touched the page, as one atomic value Plugin; takeover → restore
_wpmm_applied_status The status the plugin set (publish, when it publishes a private page) Plugin; publish → restore
_wpmm_generated 1 on pages the plugin created Plugin; permanent

QA

  1. Regression (Disabling maintenance mode makes the selected homepage private and returns 404 #523): select an existing published page as the maintenance page, enable maintenance mode, then disable it. The page stays public with its original template — previously it went private and visitors got a 404.
  2. With maintenance mode active and your page selected, switch the page to Draft in the editor, then disable maintenance mode. The page stays a draft instead of being republished.
  3. With maintenance mode active, switch the selected page to Private and reload the site front end. The page stays private — both while the mode is on and after disabling it. (Visitors won't see a maintenance screen from that page anymore; picking an unusable page is treated as the user's call.)
  4. With page A selected, pick a different page B (or clear the selection) in Design → Select page. Page A returns to its original status and template; B becomes the maintenance page.
  5. Select a page, trash it, then deactivate the plugin and restore the page from the trash. It comes back without the maintenance template.
  6. With a user-owned page selected, open Design → Templates: no "content will be replaced" warning. Select a plugin-generated page: the warning appears.
  7. On a site without Otter Blocks, apply a gallery template: it applies without a fatal error.

Alexia-Soare and others added 5 commits July 16, 2026 16:55
…de lifecycle

Selecting an existing published page as the maintenance page no longer
leaves it private (404 for visitors) after maintenance mode is disabled
or the plugin is deactivated.

The page's original status and template are now recorded in post meta
when the page is selected (or on first enable, for pages selected before
this fix) and restored when maintenance mode is disabled and on plugin
deactivation. Pages with no recorded state are left untouched instead of
being forced private.

Fixes #523

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…plate

Applying a gallery template while an existing site page was selected
overwrote that page's content and forced it private. Templates are now
written only to pages the plugin created (marked with _wpmm_generated);
when a user-owned page is selected, it is handed back in its original
state and the template lands on a newly created maintenance page.

Recording a page's original state is now first-write-wins and the record
is cleared on restore, so re-selecting a page the plugin already modified
can no longer poison the saved original state.

Also guards the Otter CSS_Handler call, which fataled on sites without
Otter Blocks.

Refs #523

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Skip the enable-mode page takeover when the selected page no longer
exists (previously wrote orphan post meta), and reject template imports
whose slug/category do not resolve to a bundled template instead of
creating an empty maintenance page. Template slug and category are
sanitized before being used in a filesystem path.

Refs #523

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restoring the selected page's recorded state now leaves trashed pages
alone (trashing is always user intent; the plugin never trashes), and
enabling maintenance mode with a trashed page selected records nothing,
so untrashing the page later cannot snap it back to trash.

Refs #523

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Selecting an existing published page via the real AJAX handler, enabling
and disabling maintenance mode must leave the page public with its
original content and template. Verified to fail against the unfixed
code.

Refs #523

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pirate-bot

pirate-bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Plugin build for 66f5f3d is ready 🛎️!

@Alexia-Soare Alexia-Soare added the pr-checklist-skip Allow this Pull Request to skip checklist. label Jul 17, 2026
@pirate-bot pirate-bot added the pr-checklist-complete The Pull Request checklist is complete. (automatic label) label Jul 17, 2026
The job installed the latest WordPress test suite (currently 7.0.1,
which requires PHP 7.4+) on PHP 7.2, so the suite aborted before running
any test. The phpunit:7.5.20 tool pin was unused: composer run-script
resolves vendor/bin/phpunit from the lockfile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread tests/page-state-test.php Outdated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Preserves selected maintenance-page state and prevents user-owned pages from being overwritten.

Changes:

  • Records and restores page status/template across maintenance lifecycle events.
  • Restricts template overwrites to plugin-generated pages and validates imports.
  • Adds lifecycle tests and updates CI to PHP 7.4.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
views/settings.php Limits overwrite warnings to generated pages.
tests/page-state-test.php Adds lifecycle and template-import tests.
includes/functions/helpers.php Adds page-state record/restore helpers.
includes/classes/wp-maintenance-mode.php Integrates restoration with enable, disable, and deactivation.
includes/classes/wp-maintenance-mode-admin.php Adds ownership-aware selection and template importing.
.github/workflows/test-php.yml Updates PHPUnit CI to PHP 7.4.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread includes/classes/wp-maintenance-mode-admin.php
Comment thread includes/functions/helpers.php
Comment thread includes/functions/helpers.php Outdated
Comment thread includes/classes/wp-maintenance-mode.php
…mically

Addresses the Copilot review on #524:
- select_page() restores the previously selected page before switching
  away from it, so the old page never keeps the maintenance template
- restoring a trashed page keeps it in the trash but hands back the
  template and clears the record, so untrashing brings it back clean
- recording claims the record with add_post_meta( ..., true ) so a
  racing request cannot capture the plugin-modified state as original

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

includes/classes/wp-maintenance-mode-admin.php:696

  • The previous page is restored while wpmm_settings still selects it until line 713. If maintenance mode is active, a frontend request in that interval boots from the old option and takes that page over again; this callback then commits the new ID, leaving the abandoned page with the maintenance template and stale state metadata. Commit/gate the selection transition before restoring the old page, or serialize it with frontend takeover.
			if ( $previous_page_id && $previous_page_id !== $page_id ) {
				// hand the previously selected page back before abandoning it,
				// otherwise nothing would ever restore it
				wpmm_restore_page_state( $previous_page_id );

includes/functions/helpers.php:542

  • add_post_meta(..., true) is not an atomic first-write-wins claim: WordPress checks for existing metadata before inserting, and wp_postmeta has no uniqueness constraint, so racing requests can both insert records. The template is also written separately afterward, allowing restore to consume a partial status-only record. Use storage with an actual unique constraint/compare-and-set or a lock, and persist status plus template atomically.
	// the unique flag makes the status key a first-write-wins claim on the
	// record, so a racing request cannot save the plugin-modified state
	if ( ! add_post_meta( $page_id, '_wpmm_original_status', $status, true ) ) {
		return;
	}

	update_post_meta( $page_id, '_wpmm_original_template', $template );

Comment thread includes/classes/wp-maintenance-mode-admin.php Outdated
Comment thread includes/classes/wp-maintenance-mode-admin.php Outdated
Alexia-Soare added a commit that referenced this pull request Jul 23, 2026
The legacy eslint toolchain caps its node engines below current node
versions while the new test tooling requires node 18+, so every yarn
install now runs with --ignore-engines and the zip/deploy workflows move
from node 14 to 20. The PHPUnit job moves to PHP 7.4, required by the
latest WordPress test suite (same fix as #524; trivial conflict).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Alexia-Soare
Alexia-Soare requested a review from Copilot July 28, 2026 12:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

includes/functions/helpers.php:542

  • The unique status write does not make the two-key state record atomic. A concurrent disable/switch can restore after _wpmm_original_status is added but before _wpmm_original_template is written, deleting the real template and clearing the claim; conversely, restore's separate deletes can remove a new takeover's record. Store status and template together in one first-write-wins meta value and consume that record atomically (or add equivalent synchronization).
	// the unique flag makes the status key a first-write-wins claim on the
	// record, so a racing request cannot save the plugin-modified state
	if ( ! add_post_meta( $page_id, '_wpmm_original_status', $status, true ) ) {
		return;
	}

	update_post_meta( $page_id, '_wpmm_original_template', $template );

Comment thread includes/functions/helpers.php Outdated
Alexia-Soare and others added 2 commits July 28, 2026 16:04
…ve-selected-page-state

# Conflicts:
#	.github/workflows/test-php.yml
wp_insert_post() without the wp_error flag returns an int, so the
instanceof WP_Error guard was dead code; the page-state helpers also
document their void return now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses the follow-up Copilot review on #524:
- the plugin marks the publish it applies to a private page, and restore
  only reverts the status while the page still carries that plugin-made
  publish; a draft/private/trash the user chose in the meantime wins
- the original state lives in a single first-write-wins meta value, so a
  racing request can neither poison the record nor observe half of it
- a maintenance template carried over from an old release is recorded as
  no template, so restore removes it instead of making it permanent

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

Comments suppressed due to low confidence (2)

includes/classes/wp-maintenance-mode.php:110

  • A user-selected private status is not preserved here. After a page has been taken over, any later request while maintenance mode is active sees private and publishes it again; this also happens when the originally published page was made private by the user, because there is no applied-status marker yet. Gate publishing on the recorded original status being private and on publish not having already been applied, so later user changes are not undone.
					if ( get_post_status( $this->plugin_settings['design']['page_id'] ) === 'private' ) {

includes/classes/wp-maintenance-mode-admin.php:773

  • This condition excludes trashed pages because $page_exists is false for trash. If a selected user page is trashed and a gallery template is imported, the selection moves to the new page without restoring the old page, leaving its maintenance template and state metadata behind; restoring it from trash later exposes that stale plugin state. Restore any still-existing selected post here, including trash.
				if ( $page_exists ) {
					// hand the user's page back before switching to a generated one
					wpmm_restore_page_state( $selected_page_id );

@Soare-Robert-Daniel

Copy link
Copy Markdown
Contributor

@Alexia-Soare let's see if the first statement in #524 (review) is true

The active-mode constructor used to publish the selected page on every
request it found it private, silently undoing a deliberate user change
and mislabeling that publish as the original takeover. Publishing is now
gated on the recorded original status being private and on the plugin
not having published already, so it happens exactly once per takeover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment thread includes/classes/wp-maintenance-mode-admin.php
…mplate

The import reused the creation post_arr, whose private status unpublished
a generated page maintenance mode had already made public — and since the
plugin's publish is one-shot now, nothing ever republished it, leaving
visitors on a 404 instead of the maintenance screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

Comments suppressed due to low confidence (2)

includes/classes/wp-maintenance-mode-admin.php:778

  • A selected trashed page is excluded by $page_exists, so this branch skips restoration before switching to the newly generated page. Its original-state record and maintenance template are then orphaned; restoring that page from Trash later can bring it back with the plugin template. Restore any selected post here (including trash), while continuing to restrict in-place overwrites to non-trashed generated pages.
				if ( $page_exists ) {
					// hand the user's page back before switching to a generated one
					wpmm_restore_page_state( $selected_page_id );
				}

includes/classes/wp-maintenance-mode-admin.php:703

  • This lifecycle runs only through the AJAX endpoint, but the same dropdown is part of the Design form and save_plugin_settings() still persists the posted design.page_id directly (lines 598–613). A normal form submission—for example with JavaScript unavailable or its AJAX request aborted by Save/navigation—can therefore switch the selection without restoring the old page or recording/applying state on the new one. Route both persistence paths through one page-transition operation, or prevent the form handler from changing page_id.
			$this->plugin_settings['design']['page_id'] = $page_id;

			if ( $page_id ) {
				// remember the page's original state before taking it over as a maintenance page
				wpmm_record_page_state( $page_id );

The Design form posts page_id too, so a plain (non-AJAX) save could
switch the selection without restoring the old page or recording the
new one. Both writers now share switch_selected_page(). The template
import also hands back a trashed selection now, instead of orphaning
its record and maintenance template.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

Comments suppressed due to low confidence (2)

includes/classes/wp-maintenance-mode-admin.php:706

  • Resetting the Design tab still bypasses this lifecycle: reset_plugin_settings() replaces the entire design array with defaults at lines 668–670, removing page_id without calling wpmm_restore_page_state(). If a user resets while a page is selected, that page is orphaned with the maintenance template and _wpmm_original_state (and may remain plugin-published), because no later disable/deactivation path can find its ID. Route the Design reset through switch_selected_page( 0 ) before saving the defaults, and cover that reset flow.
		/**
		 * Switch the selected maintenance page, handing the previous page back
		 * and taking the new one over. Every path that changes the selection
		 * (AJAX select, settings form) must go through here.

includes/functions/helpers.php:579

  • _wpmm_applied_status only proves that the plugin published the page once; it does not prove that the current publish status is still that plugin-made transition. For an originally private page, a user can change it to Draft and then deliberately publish it again while maintenance mode remains active. On disable, all three operands here match and the user's publish is reverted to private. Track whether a later user status transition superseded the plugin transition (separately from the one-shot-publish marker) before restoring the status.
	$original_status = isset( $state['status'] ) ? $state['status'] : '';
	$applied_status  = get_post_meta( $page_id, '_wpmm_applied_status', true );
	$current_status  = get_post_status( $page_id );

	if ( $original_status && $applied_status && $current_status === $applied_status && $current_status !== $original_status ) {

Resetting design replaced the whole tab with defaults, dropping page_id
without restoring the page — the last selection writer outside the
take-over lifecycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Alexia-Soare

Copy link
Copy Markdown
Contributor Author

Re the suppressed low-confidence comment on helpers.php (draft→publish flip while maintenance is active gets reverted to private on disable): declining, known and accepted edge.

  • Triggering it needs an originally-private page + two deliberate user status flips, mid-maintenance.
  • The "wrong" outcome is the page returning to its recorded original state — no data loss, no broken homepage.
  • Fixing it means hooking transition_post_status to arbitrate user intent — more state and a persistent listener for an ambiguous edge.

The other finding from that batch (Design reset orphaning the selection) was real and is fixed in 66f5f3d.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

Comments suppressed due to low confidence (1)

includes/classes/wp-maintenance-mode.php:104

  • Rolling back from the block-based “new look” while maintenance mode is active leaves the takeover state orphaned. toggle_gutenberg() can set wpmm_new_look to false, after which this branch stops running, but the disabled-mode restore branch is not entered because maintenance mode is still enabled. The selected page therefore keeps the maintenance template and an originally private page can remain plugin-published while the legacy page is being served. Restore the selected page during the true-to-false transition (and cover that lifecycle path).
				if ( $maintenance_page_status && $maintenance_page_status !== 'trash' && get_option( 'wpmm_new_look' ) ) {
					// remember the page's original state so it can be restored when maintenance mode is disabled
					wpmm_record_page_state( (int) $this->plugin_settings['design']['page_id'] );

@Alexia-Soare

Copy link
Copy Markdown
Contributor Author

Re the suppressed comment on the wpmm_new_look rollback (switching to the legacy look mid-maintenance leaves the takeover state on the page): declining.

  • The state self-heals: the disable and deactivation restore paths are not gated on wpmm_new_look, so the page is fully restored at the next lifecycle event — both paths are covered by tests.
  • During the window, the legacy look intercepts all frontend requests, so the leftover published page is behind the maintenance wall and invisible to visitors.
  • Trigger is an admin flipping the look mode while maintenance is running — rare, temporary, no user-visible effect.

@selul
selul merged commit d8c833d into development Jul 29, 2026
8 checks passed
@selul
selul deleted the fix/523-preserve-selected-page-state branch July 29, 2026 11:52
@pirate-bot

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.6.23 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

@pirate-bot pirate-bot added the released Indicate that an issue has been resolved and released in a particular version of the product. label Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-checklist-complete The Pull Request checklist is complete. (automatic label) pr-checklist-skip Allow this Pull Request to skip checklist. released Indicate that an issue has been resolved and released in a particular version of the product.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Disabling maintenance mode makes the selected homepage private and returns 404

5 participants