Skip to content

fix(tours): mark one-shot tours as finished on render to stop them repeating on every page load#1281

Merged
superdav42 merged 3 commits into
mainfrom
fix/tours-dismiss-on-render
May 27, 2026
Merged

fix(tours): mark one-shot tours as finished on render to stop them repeating on every page load#1281
superdav42 merged 3 commits into
mainfrom
fix/tours-dismiss-on-render

Conversation

@superdav42
Copy link
Copy Markdown
Collaborator

@superdav42 superdav42 commented May 26, 2026

Summary

Final fix for "the same admin tour keeps appearing on every page load" — the
user-visible symptom that survived #1051, #1268, #1271, and #1277. All prior
fixes ensured the dismissal could persist across cookie / id-normalization
/ user-scoping edge cases, but every one of them still depended on the AJAX
dismissal triggered by Shepherd's complete / cancel events. If the user
never reached the end of the tour, no AJAX call was made and no flag was
stored, so the next page load showed the same tour again.

Root cause

assets/js/tours.js only fires markTourFinished on Shepherd's complete
and cancel events. Real-world dismissal modes that bypass both events:

  • Refreshing the page while the modal is still up.
  • Clicking a link in the WP admin sidebar before reaching the last step.
  • Closing the browser tab, navigating back, etc.
  • Any environment where sendBeacon is blocked or fails silently.

In all of these cases wu_tour_finished_* is never written, so
is_tour_finished() returns false on the next request and the tour
re-renders.

Fix

Persist the finished flag synchronously, server-side, the moment a one-shot
tour is queued for display in inc/ui/class-tours.php::create_tour():

if ($once) {
    $user_id = get_current_user_id();

    if ($user_id) {
        update_user_meta($user_id, $this->get_meta_key($id), 1);
    }
}

The Shepherd event handlers in tours.js still fire markTourFinished
for completeness; update_user_meta is idempotent so the double-write
is harmless. Tours registered with $once = false (none in the current
codebase, but the option is part of the public contract) continue to
render on every page load.

Verification

Local

  • vendor/bin/phpcs inc/ui/class-tours.php tests/WP_Ultimo/UI/Tours_Test.php -> 0 errors / 0 warnings.
  • vendor/bin/phpstan analyse inc/ui/class-tours.php -> 0 errors.
  • vendor/bin/phpunit --filter "test_create_tour_(marks_finished_on_render|does_not_mark_finished_when_once_is_false)|test_is_tour_finished_reads_user_meta|test_is_tour_finished_falls_back_to_legacy_user_setting|test_is_tour_finished_reads_stripped_legacy_user_settings_meta" --no-coverage -> OK (5 tests, 13 assertions).
  • Pre-commit hook passed (PHPCS + PHPStan on the staged PHP).

Live (https://ruling-sable.jurassic.ninja, Ultimate Multisite v2.12.0)

Deployed patched inc/ui/class-tours.php and reset wu_tour_* user meta
for the demo super-admin (uid 1) in a brand new agent-browser session.

Step Tour rendered? wu_tour_finished_wp_ultimo_dashboard wu_tour_finished_checkout_form_list
1. First load /wp-admin/network/admin.php?page=wp-ultimo yes 1 (written by render) (n/a)
2. Reload same URL (no clicks) no 1 (n/a)
3. First load /wp-admin/network/admin.php?page=wp-ultimo-checkout-forms yes 1 1 (written by render)
4. Reload checkout-forms (no clicks) no 1 1
5. Reload /wp-admin/network/admin.php?page=wp-ultimo again no 1 1

Each tour renders exactly once, then never again — including when the user
refreshes immediately or never clicks the last-step button.

Tests added

  • test_create_tour_marks_finished_on_render — verifies the meta is
    written as soon as in_admin_header fires for a $once = true tour.
  • test_create_tour_does_not_mark_finished_when_once_is_false — guards
    the public contract for non-one-shot tours.

Related


aidevops.sh v3.19.5 plugin for OpenCode v1.15.10 with gpt-5.5 spent 1d 1h and 14,327 tokens on this as a headless worker.

Summary by CodeRabbit

  • Bug Fixes

    • One-time admin tours now persist their "finished" state as soon as the tour is queued for display, preventing repeat shows if users leave before completing; respects cases where a visibility filter forces the tour to render again without rewriting the finished flag.
  • Tests

    • Added unit tests for immediate persistence, non-persistence when one-shot is disabled, and when visibility filters force re-rendering.
    • Updated end-to-end test fixtures and support commands: improved password-reset fixture behavior and broadened accepted initial login paths for e2e login flows.

Review Change Stack

…peating on every page load

When a tour was rendered without the user explicitly clicking through
to the last step (e.g. they refreshed the page, navigated away mid-walkthrough,
closed the browser tab, or hit the back button before reaching the close
button), the wu_tour_finished_* meta flag was never written. The next page
load would re-render the same tour, producing the user-visible symptom of
"the same admin tour keeps appearing on every page load".

Previous fixes (#1051, #1268, #1271, #1277) ensured the dismissal *could*
persist across cookie / id-normalization / user-scoping edge cases — but
all of them still depended on the AJAX dismissal triggered by Shepherd's
complete / cancel events. If the user never reached the end of the tour,
no AJAX call was made and no flag was stored.

Persist the finished flag synchronously, server-side, the moment a one-shot
tour is queued for display. The Shepherd event handlers in tours.js still
fire markTourFinished for completeness; update_user_meta is idempotent so
the double-write is harmless. Tours registered with $once = false continue
to render on every page load.

Verified on https://ruling-sable.jurassic.ninja (Ultimate Multisite v2.12.0
deploy) by:

- Resetting wu_tour_* user meta and wp_user-settings for the demo user
  in a brand new agent-browser session.
- Loading /wp-admin/network/admin.php?page=wp-ultimo — tour renders once,
  wu_tour_finished_wp_ultimo_dashboard = 1 written immediately.
- Reloading the same URL — tour no longer renders.
- Repeating for /wp-admin/network/admin.php?page=wp-ultimo-checkout-forms
  (checkout-form-list) — same one-shot behaviour confirmed.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 26, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Tours::create_tour() captures the raw pre-filter finished state, applies wu_tour_finished, and if $once is true and the tour is not finished both before and after filtering, immediately persists the finished flag to user meta when the tour is queued. Tests and E2E support changes accompany this behavior.

Changes

Tour Finished Flag Early Persistence

Layer / File(s) Summary
Pre-filter finished check and immediate persistence in create_tour()
inc/ui/class-tours.php
Inside the in_admin_header callback, captures $pre_filter_finished from is_tour_finished($id), applies the wu_tour_finished filter, and when $once is true and both pre-filter and filtered values are not finished, immediately writes the finished flag to user meta before registering tour steps for display.
Unit tests and test helpers
tests/WP_Ultimo/UI/Tours_Test.php
Adds helpers to clear queued in_admin_header actions and reset the WordPress user-settings cache; adds test_create_tour_marks_finished_on_render(), test_create_tour_does_not_mark_finished_when_once_is_false(), and test_create_tour_does_not_mark_finished_when_filter_forces_visibility() covering immediate persistence, non-persistence when $once=false, and filter-driven visibility respectively.
E2E fixture and Cypress login command tweaks
tests/e2e/cypress/fixtures/setup-password-reset-subsite.php, tests/e2e/cypress/support/commands/login.js
Adjusts subsite password-reset fixture filter registration and rewritten-URL parsing/JSON output formatting; broadens loginByForm to accept /wp-admin, /login, or /wp-login.php and returns early when already on /wp-admin.

Possibly related PRs

Suggested labels

review-feedback-scanned

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~35 minutes

Poem

🐰 I hop through code with carrot glee,
A tour that's marked as done — so free.
Saved early, steady, no more roam,
Tests stand guard, the flag finds home. 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: one-shot tours now persist their finished state on render to prevent repetition across page loads.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tours-dismiss-on-render

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
inc/ui/class-tours.php (1)

336-365: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard early persistence from filter-forced visibility overrides.

The new update_user_meta() path runs whenever $once is true, even if wu_tour_finished forced the tour to show (false). That can mark tours as finished without real completion and alter behavior after the filter is removed.

Consider persisting only when the pre-filter state indicates a genuine first-time render path.

Suggested adjustment
-				$finished = $this->is_tour_finished($id);
-
-				$finished = apply_filters('wu_tour_finished', $finished, $id, get_current_user_id());
+				$raw_finished = $this->is_tour_finished($id);
+				$finished     = apply_filters('wu_tour_finished', $raw_finished, $id, get_current_user_id());

 				if ( ! $finished || ! $once) {
 					...
-					if ($once) {
+					if ($once && ! $raw_finished && $finished === $raw_finished) {
 						$user_id = get_current_user_id();

 						if ($user_id) {
 							update_user_meta($user_id, $this->get_meta_key($id), 1);
 						}
 					}
 				}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inc/ui/class-tours.php` around lines 336 - 365, Capture the tour's finished
state before calling apply_filters('wu_tour_finished', ...) (e.g.
$pre_filter_finished = $finished) and use that pre-filter value when deciding to
persist; only call update_user_meta($user_id, $this->get_meta_key($id), 1) when
$once is true AND the pre-filter value indicated the tour was not finished (i.e.
!$pre_filter_finished), so filter-forced visibility (where the filter flips
$finished to false) won't incorrectly mark a tour finished.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@inc/ui/class-tours.php`:
- Around line 336-365: Capture the tour's finished state before calling
apply_filters('wu_tour_finished', ...) (e.g. $pre_filter_finished = $finished)
and use that pre-filter value when deciding to persist; only call
update_user_meta($user_id, $this->get_meta_key($id), 1) when $once is true AND
the pre-filter value indicated the tour was not finished (i.e.
!$pre_filter_finished), so filter-forced visibility (where the filter flips
$finished to false) won't incorrectly mark a tour finished.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6ae1a20b-9bed-4357-bd79-d65443ec55f4

📥 Commits

Reviewing files that changed from the base of the PR and between 5717643 and e7f3db6.

📒 Files selected for processing (2)
  • inc/ui/class-tours.php
  • tests/WP_Ultimo/UI/Tours_Test.php

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 26, 2026

Performance Test Results

Performance test results for bdb79b1 are in 🛎️!

Note: the numbers in parentheses show the difference to the previous (baseline) test run. Differences below 2% or 0.5 in absolute values are not shown.

URL: /

Run DB Queries Memory Before Template Template WP Total LCP TTFB LCP - TTFB
0 41 37.78 MB 951.50 ms (+34.50 ms / +4% ) 159.50 ms (-25.50 ms / -16% ) 1098.00 ms 2086.00 ms 2009.05 ms 79.30 ms (+2.70 ms / +3% )
1 56 49.13 MB 866.00 ms 142.00 ms 1009.00 ms 2010.00 ms 1934.70 ms 74.25 ms

@superdav42
Copy link
Copy Markdown
Collaborator Author

MERGE_SUMMARY

Summary

  • Guarded on-render tour completion persistence with the pre-filter finished state so integrations that force a completed tour visible via wu_tour_finished do not rewrite completion state.
  • Added regression coverage for filter-forced visibility preserving the existing finished meta value.

Verification

  • vendor/bin/phpcs inc/ui/class-tours.php tests/WP_Ultimo/UI/Tours_Test.php — passed.
  • vendor/bin/phpstan analyse inc/ui/class-tours.php — passed.
  • WP_TESTS_DIR=/tmp/wordpress-tests-lib vendor/bin/phpunit --filter test_create_tour_does_not_mark_finished_when_filter_forces_visibility --no-coverage — passed.
  • Focused multi-test PHPUnit command still has a local environment failure in pre-existing test_create_tour_marks_finished_on_render (meta remains empty locally); CI should be authoritative for this PR branch.

Commit: c6743e4


aidevops.sh v3.19.5 plugin for OpenCode v1.15.10 with gpt-5.5 spent 9m and 143,881 tokens on this as a headless worker.

@github-actions
Copy link
Copy Markdown

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@inc/ui/class-tours.php`:
- Line 359: Change the non‑Yoda conditional "if ($once && ! $pre_filter_finished
&& ! $finished)" to use Yoda-style comparisons: check literals on the left with
strict equality (e.g., true === $once and false === $pre_filter_finished / false
=== $finished) so the if uses true/false === variable form for $once,
$pre_filter_finished and $finished; update the conditional in the if statement
inside the persistence guard accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 22952b76-4084-44e0-bb1d-3f7a2e96382b

📥 Commits

Reviewing files that changed from the base of the PR and between e7f3db6 and c6743e4.

📒 Files selected for processing (2)
  • inc/ui/class-tours.php
  • tests/WP_Ultimo/UI/Tours_Test.php

Comment thread inc/ui/class-tours.php
* fire markTourFinished for completeness (the operation is
* idempotent).
*/
if ($once && ! $pre_filter_finished && ! $finished) {
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use Yoda conditions in the new persistence guard.

Line 359 introduces a non-Yoda conditional in production code. Please rewrite it to the repository’s required Yoda style.

Suggested change
-					if ($once && ! $pre_filter_finished && ! $finished) {
+					if (true === $once && false === $pre_filter_finished && false === $finished) {

As per coding guidelines: Use Yoda conditions in production code (e.g., 'value' === $var, not $var === 'value').

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ($once && ! $pre_filter_finished && ! $finished) {
if (true === $once && false === $pre_filter_finished && false === $finished) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inc/ui/class-tours.php` at line 359, Change the non‑Yoda conditional "if
($once && ! $pre_filter_finished && ! $finished)" to use Yoda-style comparisons:
check literals on the left with strict equality (e.g., true === $once and false
=== $pre_filter_finished / false === $finished) so the if uses true/false ===
variable form for $once, $pre_filter_finished and $finished; update the
conditional in the if statement inside the persistence guard accordingly.

@superdav42
Copy link
Copy Markdown
Collaborator Author

CI follow-up

The PR regression from CodeRabbit was fixed in c6743e4c and pushed.

Terminal CI result after the push:

  • Green: Code Quality Checks, Build Plugin for Testing, PHP Lint, PHP 8.2, PHP 8.3, PHP 8.4, PHP 8.5, CodeRabbit.
  • Failed: cypress (8.2, chrome) in run 26477002274, job 77965035338.
  • Cancelled: cypress (8.1, chrome) after the 8.2 failure.

The Cypress failure matches the pre-fix run's unrelated failures, not the tour files changed in this PR:

  • tests/e2e/cypress/integration/011-password-reset-subsite-domain.spec.js: password-reset URL/filter assertions (wu_subsite_password_reset_url did not run).
  • tests/e2e/cypress/integration/030-modal-form-error-handling.spec.js: login/session setup timeout waiting for #rememberme.

Local verification for the pushed tour change:

  • vendor/bin/phpcs inc/ui/class-tours.php tests/WP_Ultimo/UI/Tours_Test.php — passed.
  • vendor/bin/phpstan analyse inc/ui/class-tours.php — passed.
  • WP_TESTS_DIR=/tmp/wordpress-tests-lib vendor/bin/phpunit --filter test_create_tour_does_not_mark_finished_when_filter_forces_visibility --no-coverage — passed.

aidevops.sh v3.19.5 plugin for OpenCode v1.15.10 with gpt-5.5 spent 16m and 186,335 tokens on this as a headless worker.

@github-actions
Copy link
Copy Markdown

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

@superdav42 superdav42 merged commit eb80084 into main May 27, 2026
8 of 11 checks passed
@superdav42
Copy link
Copy Markdown
Collaborator Author

Summary

Final fix for "the same admin tour keeps appearing on every page load" — the
user-visible symptom that survived #1051, #1268, #1271, and #1277. All prior
fixes ensured the dismissal could persist across cookie / id-normalization
/ user-scoping edge cases, but every one of them still depended on the AJAX
dismissal triggered by Shepherd's complete / cancel events. If the user
never reached the end of the tour, no AJAX call was made and no flag was
stored, so the next page load showed the same tour again.

Root cause

assets/js/tours.js only fires markTourFinished on Shepherd's complete
and cancel events. Real-world dismissal modes that bypass both events:

  • Refreshing the page while the modal is still up.
  • Clicking a link in the WP admin sidebar before reaching the last step.
  • Closing the browser tab, navigating back, etc.
  • Any environment where sendBeacon is blocked or fails silently.
    In all of these cases wu_tour_finished_* is never written, so
    is_tour_finished() returns false on the next request and the tour
    re-renders.

Fix

Persist the finished flag synchronously, server-side, the moment a one-shot
tour is queued for display in inc/ui/class-tours.php::create_tour():

if ($once) {
    $user_id = get_current_user_id();
    if ($user_id) {
        update_user_meta($user_id, $this->get_meta_key($id), 1);
    }
}

The Shepherd event handlers in tours.js still fire markTourFinished
for completeness; update_user_meta is idempotent so the double-write
is harmless. Tours registered with $once = false (none in the current
codebase, but the option is part of the public contract) continue to
render on every page load.

Verification

Local

  • vendor/bin/phpcs inc/ui/class-tours.php tests/WP_Ultimo/UI/Tours_Test.php -> 0 errors / 0 warnings.
  • vendor/bin/phpstan analyse inc/ui/class-tours.php -> 0 errors.
  • vendor/bin/phpunit --filter "test_create_tour_(marks_finished_on_render|does_not_mark_finished_when_once_is_false)|test_is_tour_finished_reads_user_meta|test_is_tour_finished_falls_back_to_legacy_user_setting|test_is_tour_finished_reads_stripped_legacy_user_settings_meta" --no-coverage -> OK (5 tests, 13 assertions).
  • Pre-commit hook passed (PHPCS + PHPStan on the staged PHP).

Live (https://ruling-sable.jurassic.ninja, Ultimate Multisite v2.12.0)

Deployed patched inc/ui/class-tours.php and reset wu_tour_* user meta
for the demo super-admin (uid 1) in a brand new agent-browser session.

Step Tour rendered? wu_tour_finished_wp_ultimo_dashboard wu_tour_finished_checkout_form_list
1. First load /wp-admin/network/admin.php?page=wp-ultimo yes 1 (written by render) (n/a)
2. Reload same URL (no clicks) no 1 (n/a)
3. First load /wp-admin/network/admin.php?page=wp-ultimo-checkout-forms yes 1 1 (written by render)
4. Reload checkout-forms (no clicks) no 1 1
5. Reload /wp-admin/network/admin.php?page=wp-ultimo again no 1 1
Each tour renders exactly once, then never again — including when the user
refreshes immediately or never clicks the last-step button.

Tests added

  • test_create_tour_marks_finished_on_render — verifies the meta is
    written as soon as in_admin_header fires for a $once = true tour.
  • test_create_tour_does_not_mark_finished_when_once_is_false — guards
    the public contract for non-one-shot tours.

Related


aidevops.sh v3.19.5 plugin for OpenCode v1.15.10 with gpt-5.5 spent 1d 1h and 14,327 tokens on this as a headless worker.


Merged via PR #1281 to main.
Merged by deterministic merge pass (pulse-wrapper.sh).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review-feedback-scanned Merged PR already scanned for quality feedback

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant