Skip to content

fix(tours): persist tour-finished flag in user meta to avoid wp-settings cookie desync#1268

Merged
superdav42 merged 1 commit into
mainfrom
fix/tours-persist-via-user-meta
May 25, 2026
Merged

fix(tours): persist tour-finished flag in user meta to avoid wp-settings cookie desync#1268
superdav42 merged 1 commit into
mainfrom
fix/tours-persist-via-user-meta

Conversation

@superdav42
Copy link
Copy Markdown
Collaborator

@superdav42 superdav42 commented May 25, 2026

Summary

Fixes the Ultimate Multisite tour for the checkout-form editor (and any
other WU tour dismissed via AJAX) re-appearing on every visit.

Reproducible at
wp-admin/network/admin.php?page=wp-ultimo-edit-checkout-form&id=1&slug=main-form&model=checkout_form:
complete the tour, click Save Checkout Form, revisit the page — the
tour shows again.

Why this isn't a duplicate of prior tour fixes

Three earlier PRs touched the same area — each fixed an adjacent
failure mode but left the underlying cookie-sync gap intact:

PR Fixed Did not fix
#365 (2021, a3e62958) Introduced once-only tracking via set_user_setting() Cookie-sync gap (didn't exist on WP of the day)
#1051 (06aa90a8) Hyphenated tour IDs (e.g. checkout-form-editor) silently corrupted the wp_user-settings string — added get_setting_key() to normalise -_ Cookie-sync gap
#1161 (99abf629, May 2026) (a) wu_tours JS global undefined (script-order race); (b) $.ajax() dismissal killed by page navigation — switched to navigator.sendBeacon() so the request reliably reaches the server Cookie-sync gap — after the request lands, set_user_setting() writes to user-meta, but wp_user_settings() short-circuits on AJAX so the browser cookie never updates and get_user_setting() still reads stale $_COOKIE on the next request

This PR is the server-side persistence layer: once the dismissal reaches
the server (now reliable thanks to #1161), we must read it back without
depending on a cookie that AJAX cannot set.

Root cause

Tours::mark_as_finished() only wrote the dismissal flag via
set_user_setting(), which stores the value in the wp_user-settings
user-meta string and in the per-user wp-settings-{uid} cookie. In
modern WordPress, the cookie is only synced from user meta by
wp_user_settings() — and that function short-circuits during AJAX
requests. Because the dismissal is sent through the AJAX action
wu_mark_tour_as_finished, no Set-Cookie header is ever emitted, so:

  1. The browser keeps a stale or missing wp-settings-{uid} cookie.
  2. On the next page load get_user_setting() reads $_COOKIE and
    returns false.
  3. The closure registered in Tours::create_tour() re-renders the
    tour.

Fix

Persist the finished flag in a dedicated user-meta key
wu_tour_finished_{snake_id} from the AJAX handler. The closure in
create_tour() now calls a new is_tour_finished() helper that
reads:

  1. get_user_meta($user_id, 'wu_tour_finished_{snake_id}', true)
    the new, cookie-independent source of truth.
  2. get_user_setting() — legacy fallback so users who dismissed a
    tour before this release are not re-prompted.

The legacy set_user_setting() write is retained for backward
compatibility with downstream code that may read the older key.

Verification

  • New unit tests in tests/WP_Ultimo/UI/Tours_Test.php:
    • test_get_meta_key_uses_wu_tour_finished_prefix
    • test_is_tour_finished_reads_user_meta
    • test_is_tour_finished_falls_back_to_legacy_user_setting
  • Local: vendor/bin/phpunit --filter Tours_Test → all new tests pass.
    (The pre-existing failure
    test_enqueue_scripts_inlines_data_on_underscore_not_wu_admin
    reproduces on unmodified main and is unrelated to this change.)
  • vendor/bin/phpcs inc/ui/class-tours.php clean; pre-existing
    NoExplicitVersion warning on wp_register_script (line 339 of the
    test file) is unrelated.
  • vendor/bin/phpstan analyse inc/ui/class-tours.php clean.
  • Browser repro on local WP 7.0-RC2 with admin/admin: with all tour
    state cleared, completed the tour, clicked Save Checkout Form,
    revisited the page — window.wu_tours is now undefined, no
    .shepherd-element is rendered, and wp_usermeta contains
    wu_tour_finished_checkout_form_editor = 1.

Compatibility

  • No breaking changes; legacy set_user_setting() write retained.
  • New helper methods are protected; no public API change.
  • Schema unchanged (uses generic wp_usermeta).

…ngs cookie desync

The checkout-form editor tour (and any other WU tour dismissed via the
AJAX `wu_mark_tour_as_finished` handler) re-appeared on every visit even
after the user clicked through and closed it. Reproducible at
`wp-admin/network/admin.php?page=wp-ultimo-edit-checkout-form&id=1&slug=main-form&model=checkout_form`:
complete the tour, click "Save Checkout Form", revisit the page, the
tour reappears.

Root cause: `Tours::mark_as_finished()` writes the dismissal flag with
`set_user_setting()`, which stores it both in the `wp_user-settings`
user meta and in the per-user `wp-settings-{uid}` cookie. In modern
WordPress, the cookie is only synced from user meta by
`wp_user_settings()` on regular admin page loads — that function
short-circuits during AJAX. The AJAX dismissal therefore never sends a
`Set-Cookie` header, so the browser keeps a stale (or missing)
`wp-settings-{uid}` cookie, `get_user_setting()` returns false on the
next page, and the closure registered in `Tours::create_tour()`
re-renders the tour.

Fix: persist the finished flag in a dedicated user meta key
`wu_tour_finished_{snake_id}` from the AJAX handler. Add
`Tours::is_tour_finished()` which reads that meta first and falls back
to the legacy `get_user_setting()` for users who dismissed tours before
this release. `create_tour()` now consults `is_tour_finished()` instead
of `get_user_setting()` directly. The legacy `set_user_setting()` write
is retained for backward compatibility.

Verification:
- New unit tests cover `get_meta_key()`, the user-meta read path, and
  the legacy cookie fallback (`vendor/bin/phpunit --filter Tours_Test`).
- Browser repro on the affected page (admin/admin against the local
  WP 7.0-RC2 dev install): with state cleared, complete the tour,
  click "Save Checkout Form", revisit — `window.wu_tours` is now
  `undefined` and no `.shepherd-element` is rendered; `wp_usermeta`
  contains `wu_tour_finished_checkout_form_editor = 1`.

`vendor/bin/phpcs inc/ui/class-tours.php` clean; pre-existing
`NoExplicitVersion` warning on `wp_register_script` (line 339) is
unrelated to this change. `vendor/bin/phpstan analyse inc/ui/class-tours.php`
clean.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 25, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aea62514-c823-411f-bf60-791993768922

📥 Commits

Reviewing files that changed from the base of the PR and between f8fc41b and 67b748d.

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

📝 Walkthrough

Walkthrough

The Tours UI now persists tour completion state in user meta instead of relying solely on WordPress user-settings cookies. A new user-meta key is computed from the tour ID with prefix and hyphen normalization. Tour finished checks read user meta first, falling back to legacy user settings for backward compatibility. The AJAX handler writes completed flags to user meta before the legacy cookie path, and tour display logic uses the new finished-state helper.

Changes

Tour Finished State Persistence

Layer / File(s) Summary
Tour finished state helpers
inc/ui/class-tours.php
New get_meta_key() computes a wu_tour_finished_-prefixed user-meta key from tour ID (normalizing hyphens to underscores). New is_tour_finished() reads the user meta value first, then falls back to legacy get_user_setting() for existing dismissals.
AJAX handler and tour display integration
inc/ui/class-tours.php
AJAX handler for marking tours finished now writes to user meta for the current user while preserving the legacy cookie-based write path. Tour registration logic calls is_tour_finished() instead of directly reading the legacy user setting.
Test coverage for finished state persistence
tests/WP_Ultimo/UI/Tours_Test.php
New tests validate get_meta_key() hyphen-to-underscore normalization and wu_tour_finished_ prefix, confirm is_tour_finished() reads user meta values, and verify fallback to legacy wp-settings-{uid} cookie when meta is absent. Existing test setup array literals are reformatted for consistency.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • Ultimate-Multisite/ultimate-multisite#365: Updates client-side tour finish handling (assets/js/tours.js) and the server-side mark_as_finished method, directly overlapping the same tour-finish code paths modified in this PR.

Suggested labels

bug, origin:interactive

Poem

🐰 A tour's completion now finds a home,
No longer relying on cookies to roam,
User meta stores the state with care,
Yet legacy paths remain anchored there,
Backward compatible, tested, and fine!

🚥 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 summarizes the main change: persisting the tour-finished flag in user meta to fix wp-settings cookie desynchronization issues.
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-persist-via-user-meta

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

@github-actions
Copy link
Copy Markdown

Performance Test Results

Performance test results for c5f78d5 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 39 (-2 / -4% ) 37.72 MB 623.50 ms (-73.00 ms / -12% ) 112.00 ms (-7.00 ms / -6% ) 810.00 ms (-31.00 ms / -4% ) 1596.00 ms 1504.75 ms (-48.90 ms / -3% ) 70.20 ms (+3.30 ms / +5% )
1 56 49.13 MB 737.50 ms 109.00 ms (+3.00 ms / +3% ) 847.00 ms 1626.00 ms 1563.40 ms 61.30 ms

@superdav42 superdav42 merged commit 099e845 into main May 25, 2026
11 checks passed
@superdav42
Copy link
Copy Markdown
Collaborator Author

Summary

Fixes the Ultimate Multisite tour for the checkout-form editor (and any
other WU tour dismissed via AJAX) re-appearing on every visit.
Reproducible at
wp-admin/network/admin.php?page=wp-ultimo-edit-checkout-form&id=1&slug=main-form&model=checkout_form:
complete the tour, click Save Checkout Form, revisit the page — the
tour shows again.

Root cause

Tours::mark_as_finished() only wrote the dismissal flag via
set_user_setting(), which stores the value in the wp_user-settings
user-meta string and in the per-user wp-settings-{uid} cookie. In
modern WordPress, the cookie is only synced from user meta by
wp_user_settings() — and that function short-circuits during AJAX
requests. Because the dismissal is sent through the AJAX action
wu_mark_tour_as_finished, no Set-Cookie header is ever emitted, so:

  1. The browser keeps a stale or missing wp-settings-{uid} cookie.
  2. On the next page load get_user_setting() reads $_COOKIE and
    returns false.
  3. The closure registered in Tours::create_tour() re-renders the
    tour.

Fix

Persist the finished flag in a dedicated user-meta key
wu_tour_finished_{snake_id} from the AJAX handler. The closure in
create_tour() now calls a new is_tour_finished() helper that
reads:

  1. get_user_meta($user_id, 'wu_tour_finished_{snake_id}', true)
    the new, cookie-independent source of truth.
  2. get_user_setting() — legacy fallback so users who dismissed a
    tour before this release are not re-prompted.
    The legacy set_user_setting() write is retained for backward
    compatibility with downstream code that may read the older key.

Verification

  • New unit tests in tests/WP_Ultimo/UI/Tours_Test.php:
    • test_get_meta_key_uses_wu_tour_finished_prefix
    • test_is_tour_finished_reads_user_meta
    • test_is_tour_finished_falls_back_to_legacy_user_setting
  • Local: vendor/bin/phpunit --filter Tours_Test → all new tests pass.
    (The pre-existing failure
    test_enqueue_scripts_inlines_data_on_underscore_not_wu_admin
    reproduces on unmodified main and is unrelated to this change.)
  • vendor/bin/phpcs inc/ui/class-tours.php clean; pre-existing
    NoExplicitVersion warning on wp_register_script (line 339 of the
    test file) is unrelated.
  • vendor/bin/phpstan analyse inc/ui/class-tours.php clean.
  • Browser repro on local WP 7.0-RC2 with admin/admin: with all tour
    state cleared, completed the tour, clicked Save Checkout Form,
    revisited the page — window.wu_tours is now undefined, no
    .shepherd-element is rendered, and wp_usermeta contains
    wu_tour_finished_checkout_form_editor = 1.

Compatibility

  • No breaking changes; legacy set_user_setting() write retained.
  • New helper methods are protected; no public API change.
  • Schema unchanged (uses generic wp_usermeta).

aidevops.sh v3.18.1 plugin for OpenCode v1.15.10 with claude-opus-4-7 spent 2d 12h and 54 tokens on this as a headless worker.


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

@superdav42
Copy link
Copy Markdown
Collaborator Author

Permission check failed for this PR (HTTP unknown from collaborator permission API). Unable to determine if @superdav42 is a maintainer or external contributor. A maintainer must review and merge this PR manually. This is a fail-closed safety measure — the pulse will not auto-merge until the permission API succeeds.


aidevops.sh v3.19.1 plugin for OpenCode v1.15.10 with gpt-5.5 spent 1m and 39,362 tokens on this as a headless worker.

@superdav42 superdav42 added the review-feedback-scanned Merged PR already scanned for quality feedback label May 26, 2026
superdav42 added a commit that referenced this pull request May 27, 2026
…peating on every page load (#1281)

* fix(tours): mark one-shot tours as finished on render to stop them repeating 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.

* fix(tours): avoid rewriting filter-forced tour state

* fix(e2e): stabilize Cypress login and password reset fixture
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