Search: Reliably sync experience picker changes when Instant Search is off#48745
Conversation
Drops the `is_instant_search_enabled()` gate in initialize_search() so the Search options whitelist (instant_search_enabled, jetpack_search_experience, and the other Search settings) is in place during requests that toggle those options. Without this fix, the request that flipped instant_search_enabled from false to true never had Search sync registered, so the very option write that should have synced — plus the lockstep jetpack_search_experience write — were filtered out before they hit the local sync queue. The class_exists() guard on Module_Control still skips loading on sites that do not have the Search package installed.
|
Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.
Interested in more tips and information?
|
|
@claude please review this PR. |
|
Thank you for your PR! When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:
This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖 Follow this PR Review Process:
If you have questions about anything, reach out in #jetpack-developers for guidance! Jetpack plugin: The Jetpack plugin has different release cadences depending on the platform:
If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack. |
|
Claude finished @kangzj's task in 1m 27s —— View job PR Review
Overall: ✅ Looks good — correct fix for a real chicken-and-egg bug. Minor suggestion below. SummaryThe bug is real and the fix is correct. Removing the // initialize_woocommerce() — the established pattern
if ( false === class_exists( 'WooCommerce' ) ) {
return;
}
add_filter( 'jetpack_sync_modules', array( __CLASS__, 'add_woocommerce_sync_module' ) );Code review
The change is correct and the docblock accurately explains the Scope of behavior change Before this fix, the entire Changelog
One suggestion — add a regression testThe AGENTS.md guidance says "When adding new behaviour, always add a corresponding test." More importantly, this particular chicken-and-egg bug is the kind that could easily be re-introduced. A targeted test in public function test_search_sync_module_registered_when_instant_search_disabled() {
// Simulate instant_search_enabled = false at request boot
update_option( 'instant_search_enabled', false );
// Re-run initialize_search (or test the filter directly)
Actions::initialize_search();
$modules = apply_filters( 'jetpack_sync_modules', array() );
$this->assertContains(
'Automattic\\Jetpack\\Sync\\Modules\\Search',
$modules,
'Search sync module must be registered even when instant_search_enabled is false'
);
}This is a suggestion, not a blocker — the manual verification described in the PR is solid and the fix itself is clean. |
Locks in the SEARCH-186 fix — verifies the Search sync module is registered regardless of `instant_search_enabled`, so the option whitelist is in place during the request that flips that flag. Addressing review: claude[bot] suggested a targeted unit test.
|
Addressing claude[bot]'s review (comment #IC_kwDOAOho7M8AAAABCGFBvg): Thanks for the careful read. Added a targeted regression test in Also expanded the docblock in |
|
@claude please re-review. |
|
Claude finished @kangzj's task in 56s —— View job PR Re-Review
Overall: ✅ All previous feedback addressed — this is ready to merge. What changed since the last reviewRegression test ( The new
Expanded docblock ( The updated docblock is precise and durable:
No issues found anywhere in the diff. |
…the option Switching the experience picker to 'inline' now calls update_option(..., '') instead of delete_option(). On a site that has never written jetpack_search_experience, delete_option is a no-op (it returns false and does not fire deleted_option), so Sync had no event to queue and the WPcom cache site could stay stuck on a stale 'overlay' / 'embedded' from a previous transition. get_experience() still resolves '' (and a missing option) as INLINE via the existing fall-through to the instant_search_enabled legacy check — no reader change is needed.
…ce writes Adds option-hook handlers (`add_option_jetpack_search_experience`, `update_option_jetpack_search_experience`) that mirror the new value onto the legacy `instant_search_enabled` boolean. update_experience() already maintained this lockstep explicitly, but a direct update_option() that bypasses update_experience() — wp-cli, another plugin, or sync-side replication on a third-party setup — would otherwise drift the two values. The hook is idempotent: when update_experience() has already set instant_search_enabled to the right value, the mirror's update_option call is a no-op (WP returns false without firing the action again).
…ty-string sentinel Drops the '' sentinel and the 'absence-means-inline' indirection in update_experience() — every active branch now writes both instant_search_enabled and jetpack_search_experience to their literal values for the chosen experience. get_experience() recognises 'inline' directly; the legacy is_instant_search_enabled() fallback is kept for sites that have never saved via the new UI. Sync still fires on inline writes because the literal value is just as visible to updated_option as '' was, without the sentinel cost.
…_experience() are sufficient
Removes the `add_option_jetpack_search_experience` /
`update_option_jetpack_search_experience` action mirror that wrote
`instant_search_enabled` from the experience value, plus its
initializer wiring, lockstep test, and changelog entry.
`update_experience()` already calls disable_instant_search() /
enable_instant_search() inline on every active branch, so both options
are written to their correct values without a hook-based indirection.
The hook only protected against direct `update_option('jetpack_search_experience', ...)`
writes that bypass `update_experience()` — a hypothetical caller we
don't have, and not a real concern at this layer.
|
@claude please review this PR. |
|
Claude finished @kangzj's task in 1m 38s —— View job PR Review
Overall: ✅ Correct, minimal, and well-tested — ready to merge. What this PR doesTwo independent fixes that together close a sync gap:
Code review
The fix is a clean 3-line deletion. The updated docblock accurately explains:
No issues.
The refactored reader is correct.
The consolidated legacy fallback (
The switch-to-if/else refactor preserves all the original error-return semantics:
Well-structured:
Changelogs Both No issues foundThe fix is surgical, the docblocks make the why durable, and the test suite covers both the bug case and the regression guard. No concerns. |
Restores the switch in update_experience() and tightens the
instant_search_enabled writes to the two transitions that actually need
them: 'off' disables it (so the legacy boolean doesn't drift true after
the user turns Search off from overlay), and 'overlay' enables it via
the existing enable_instant_search() guard. 'inline' and 'embedded'
no longer touch the boolean — they only need the experience option.
get_experience() picks up an explicit 'inline' branch so a value
written by update_experience('inline') reads back correctly without
falling through to the legacy is_instant_search_enabled() inference.
|
Heads-up: pushed two follow-up commits since the prior ping (`14b39e944bbe17565ce180662214d349ca17fc59`). Latest design only writes `instant_search_enabled` on OFF/OVERLAY — please re-review against the new HEAD. |
- Search REST `test_update_settings_experience_off` now asserts `instant_search_enabled === false` after `experience=off` (matches the new design where OFF disables the legacy boolean). - Plugin sync `test_sync_default_options` covers the twelve options the Search sync module whitelists; the meta-test fails fast on any whitelisted option that lacks a matching test entry.
Code Coverage SummaryCoverage changed in 2 files.
|
|
@claude please re-review at HEAD `cd98390f438725b032c9e348d720252a00c3bddb` — the design has been simplified since your last pass:
CI is green. |
|
Claude finished @kangzj's task in 2m 31s —— View job PR Re-Review (HEAD
|
| Branch | Inline | Embedded | Off |
|---|---|---|---|
| old | activate → disable_instant_search → delete_option | activate → disable_instant_search → update_option('embedded') | Modules::deactivate (no instant_search write) |
| HEAD | activate → update_option('inline') | activate → update_option('embedded') | disable_instant_search → Modules::deactivate |
Three concrete changes:
inlineandembeddedno longer calldisable_instant_search()— the lockstep with the legacy boolean is intentionally dropped for these two experiences.offnow explicitly callsdisable_instant_search()before deactivating — previously it bypassed$this->deactivate()and left the boolean alone.inlinewrites the literal'inline'string instead of deleting the option (fixes the Sync no-op on fresh sites).
Code review
class-actions.php — unchanged from the last pass. Still the correct minimal fix.
class-module-control.php — update_experience()
The off branch change is correct: calling disable_instant_search() before Modules::deactivate() prevents the legacy boolean from "drifting true" when a site disables Search and then re-enables it on a non-overlay experience. REST_Controller_Test.php is updated to match (assertFalse($data['instant_search_enabled']) for the off case) and test_update_experience_off_disables_instant_search_and_preserves_experience covers it cleanly.
The inline branch fix (write 'inline' instead of delete) is the right call — update_option() always fires added_option / updated_option, so Sync has something to queue even on a fresh site. test_update_experience_inline_fires_action_when_option_missing locks in the contract.
Jetpack_Sync_Options_Test.php
Good regression coverage: the 12 Search options are now exercised in the integration whitelist test, confirming they land in the queue end-to-end after the gate removal.
Observation 1 — legacy instant_search_enabled readers (confirm-before-ship)
The old docblock noted:
Legacy
module_active/instant_search_enabledare kept in lockstep so unmigrated readers (Initializer, Options, sidebar registration) continue to see the right state until they're migrated to consultget_experience().
That comment is now gone, and the lockstep is intentionally dropped for inline / embedded. get_experience() short-circuits to the literal stored value before it reaches the legacy fallback, so the reader in this package is correct regardless. However, if any code outside this package (Jetpack core, WPcom) still reads instant_search_enabled directly and uses it to decide whether to load the overlay frontend, a user switching from overlay → embedded / overlay → inline would leave instant_search_enabled = true and those paths would see a stale value.
Worth confirming: are the "Initializer, Options, sidebar registration" readers now migrated to get_experience(), or is leaving instant_search_enabled = true safe because they no longer gate on it? If they're migrated, no issue. If not, this is a latent regression for the overlay → embedded/inline transition.
Observation 2 — stale assertions in test_update_experience_embedded / test_update_experience_inline
Both tests assert assertFalse( static::$search_module->is_instant_search_enabled() ). In the new design neither branch calls disable_instant_search(), so this assertion passes vacuously — instant_search_enabled was never set to true in the test setup. The assertion doesn't test behavior anymore; a reader will assume the code still disables it. This is pre-existing for embedded (test wasn't touched), introduced by the rename for inline.
Not a blocker — no wrong behavior, just a mildly misleading assertion. Could be converted to a comment or the setup could pre-set instant_search_enabled = true to document the "we intentionally leave it alone" contract, but that's cosmetic.
…legacy boolean Address review feedback (claude obs 1): with the new write semantics that only flip `instant_search_enabled` on OFF / OVERLAY, `Initializer::init_search()` was still using the boolean to decide between the Instant Search and Classic Search frontends. An overlay → embedded / overlay → inline transition would have left the boolean true and the overlay JS would have kept loading. Switch the gate to `get_experience() === 'overlay'`, which now reads the canonical experience option directly. No reader downstream of `is_instant_search_enabled()` is touched here — other callers (debug-bar, ai-chat editor state, etc.) still consult the legacy boolean and remain candidates for a follow-up migration. Also strengthen the embedded/inline tests (claude obs 2) by pre-setting `instant_search_enabled = true` so the assertion proves those branches intentionally leave the boolean alone, instead of passing vacuously on the default false.
|
Addressing claude[bot]'s re-review (HEAD Obs 1 (Initializer drift): real regression — Obs 2 (vacuous test assertions): strengthened both |
|
@claude please re-review at HEAD `d54ff2e14662eb9cc8efe4c980e3f309103e712f` — addressed both observations from the prior pass:
|
|
Claude finished @kangzj's task in 1m 30s —— View job PR Re-Review (HEAD
|
…tion first The legacy boolean is no longer the canonical source of truth — the experience option is. The rewrite reads `jetpack_search_experience` first: `'overlay'` ⇒ on, `'inline'` / `'embedded'` ⇒ off (an explicit opt-out wins over the boolean), and falls back to the legacy `instant_search_enabled` option only when the experience option has never been written (pre-existing sites). Also gates on `is_active()` so the preserve-on-OFF behavior doesn't make a stale `'overlay'` read as on. Restores `disable_instant_search()` in `update_experience()` for `inline` / `embedded` so writes stay in lockstep too — belt-and- suspenders for any caller that still reads the boolean directly. Adds data-provided coverage for the new precedence rules.
|
@claude please re-review at HEAD Read side: Write side: restored Net effect: every reader of the boolean (debug-bar, AI Chat editor state, dashboard, REST settings response) is now correct without per-call-site migration; the Initializer still uses the explicit Coverage: data-provided test for the experience-precedence rule, separate test for the module-inactive case, and the embedded/inline tests now actively prove disable runs (pre-set boolean to true, then assert false). |
|
Claude finished @kangzj's task in 2m 14s —— View job PR Re-Review (HEAD
|
| Experience | disable_instant_search | enable_instant_search | update_option |
|---|---|---|---|
off |
✓ | — | (preserved) |
inline |
✓ | — | 'inline' |
embedded |
✓ | — | 'embedded' |
overlay |
— | ✓ | 'overlay' |
Belt-and-suspenders is the right call: is_instant_search_enabled() would resolve correctly from the experience option alone, but the lockstep keeps every legacy reader correct without individual migration.
Tests
test_is_instant_search_enabled_experience_precedes_legacy_boolean (lines 176–189)
The key design decision in the test — setting the legacy boolean to !$expected — proves the experience option is what's consulted, not the boolean. All three live values are covered with dual PHPUnit annotation. ✓
test_is_instant_search_enabled_false_when_module_inactive (lines 149–158)
Pre-seeds 'overlay' in the experience option and the legacy boolean to true, with no active-modules filter. assertFalse proves the is_active() gate fires before either option is read. ✓
test_update_experience_embedded/inline (lines 304–343)
Both tests now pre-set instant_search_enabled = true and assert false after the call — proves disable_instant_search() actually ran rather than asserting on the default false. ✓
test_update_experience_overlay comment (lines 289–290)
The added note explaining why return_search_active_array must stay live across the is_instant_search_enabled() assertion is accurate and will prevent a future maintainer from "simplifying" the test into a false green. ✓
No issues found
The design is now self-contained: is_instant_search_enabled() is correct for all callers without requiring per-call-site migration, the write side is belt-and-suspenders, and every key contract has a test. The sync gate fix (initialize_search() unconditional registration) from earlier passes is unchanged and still correct.
…write covers it The prior migration of `init_search()` to `get_experience() === 'overlay'` was a per-call-site fix for the overlay→inline/embedded drift. The subsequent rewrite of `is_instant_search_enabled()` to read the experience option (with a legacy boolean fallback) makes the boolean itself correct, so the explicit migration is now redundant. Restore the original `is_instant_search_enabled()` call and drop its changelog entry.
|
Final tweak: reverted the Initializer migration at HEAD `55fd66cbe519823089dc01d77f4d472cf721ee26` since the |
…e_instant_search() Per owner review: the empty-string sentinel and the literal 'inline' write were over-engineering — Sync hooks `deleted_option` so a true delete on an existing option still replicates, and a delete on a missing option correctly no-ops (nothing to sync because nothing was different). Restore the trunk switch verbatim — inline goes back to delete_option(), no docstring rewrite, no scope changes — except for adding $this->disable_instant_search() to the OFF case so the legacy boolean doesn't drift true after a non-overlay re-enable. Drops the search-186-experience-write-inline-value changelog and the test_update_experience_inline_fires_action_when_option_missing test; both described behavior that no longer happens. The remaining inline test renames back to test_update_experience_inline_deletes_option and asserts the option is deleted.
…hange
Per owner: switching to inline now writes `update_option('')` instead
of `delete_option()` so the action always fires — `deleted_option`
hooks won't catch a delete-on-missing on a fresh site, but
`added_option` / `updated_option` always do.
Drops the now-dead `if ('inline' === $saved) return INLINE` branch in
`get_experience()` (we never write the literal 'inline' anymore — '' falls
through to the legacy fallback which already returns INLINE when the
boolean is false).
Test updates: rename the inline test back to writes_empty_string,
restore the fires-action-when-option-missing regression test (the
empty-string write is what makes that case fire), and switch the
option assertion from assertFalse to assertSame('', ...).
Fixes https://linear.app/a8c/issue/SEARCH-186/search-sync-module-not-registered-when-instant-search-is-disabled
Why
When Instant Search is off, flipping
jetpack_search_experienceor turning Instant Search back on never reached WPcom — those writes were silently dropped by Sync on the source site. As a result, the experience picker in the dashboard would appear to save successfully but the cache-site option stayed stale, breaking the round-trip wired up in #48540 + Automattic/wpcom#215433.Two issues conspired:
Actions::initialize_search()skipped registering the Search sync module on requests whereinstant_search_enabledwas false atplugins_loadedtime — so the option whitelist was missing exactly when it was needed.Module_Control::update_experience('inline')deletedjetpack_search_experience, butdelete_option()is a no-op when the option doesn't exist (it does not firedeleted_option), so a fresh site toggling to inline produced no sync event at all.Proposed changes
packages/sync— drop theis_instant_search_enabled()gate inActions::initialize_search().instant_search_enabled,jetpack_search_experience, and the rest of$options_to_syncto the local sync options whitelist. Gating its registration on the very flag it owns is a chicken-and-egg — the request that flips Instant Search from off → on never sees Search sync loaded, so the option write is filtered out of the queue.is_active()has the same hole at the off → embedded / off → overlay boundary, so registration is now unconditional.class_exists( Module_Control::class )guard is unchanged — it tracks package presence (autoloader concern), not module activation, so sites without the Search package still skip Search sync entirely.Actions_Test::test_initialize_search_registers_module_regardless_of_instant_search_flagcovering bothinstant_search_enabledstates.packages/search—update_experience('inline')now writes''tojetpack_search_experienceinstead of deleting it.added_option/updated_optionfires on every transition into inline (including from the "option missing" state), so Sync always has something to queue.get_experience()already resolves''(and missing) toEXPERIENCE_INLINEvia the existing fall-through to theinstant_search_enabledlegacy check — no reader migration needed.test_update_experience_inline_*to assert the new storage shape and addedtest_update_experience_inline_fires_action_when_option_missingto lock in the action-firing contract.Related product discussion/links
experiencefield in settings API (RSM-2291) #48540 + 215433-ghe-Automattic/wpcom (RSM-2291 / RSM-2535)Does this pull request change what data or activity we track or use?
No. The Search options were already whitelisted in this package and in the WPcom shadow replicastore. This PR only ensures the existing whitelist is in effect during requests where
instant_search_enabledhappens to be off whenplugins_loadedfires, and that the inline transition always produces a sync-able write. No new keys, no schema changes.Verification
Reproduced the diagnostic from the linked issue on a connected, online site (
instant_search_enabled = falseat request start). Trunk drops the search option keys from the whitelist; this branch keeps them.Trunk (bug)
This branch (fixed)
Same result with
instant_search_enabled = true(no regression on the previously-working path).Unit tests for both packages pass locally:
Testing instructions
On a Jetpack-connected, non-offline-mode site running this branch + Automattic/wpcom#215433:
jetpack_search_experienceviaPOST /wp-json/jetpack/v4/search/settings(or the dashboard's experience picker). Confirmwp option get jetpack_search_experiencematches on the source and on the WPcom cache-site option.instant_search_enabledtotruevia the same endpoint. Confirm bothinstant_search_enabledand the lockstepjetpack_search_experiencewrite land on WPcom (the WPcom audit log should now showupdated_optionevents for both keys).embedded → overlay(starting withinstant_search_enabled=false)inline → embedded(regression check)overlay → inline(delete — regression check)wp option delete jetpack_search_experiencefirst), switch to inline via the dashboard. Confirmwp option get jetpack_search_experiencereturns the empty string locally, the WPcom audit log shows anadded_option/updated_optionevent, and the cache-site option is updated.Modules::get_module( 'search' )still returns false (theclass_existsguard is unchanged).