PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 All 32 releases
← All changes | includes/class-admin.php +129 -29 1.1.3 → 1.3.6 View file →
@@ -207,38 +207,41 @@
207 207 }
208 208 }
209 209
210 210 /**
211 - * Section deep-links rendered under the xSpeed menu. Mirrors the
212 - * React sidebar's group manifest (see src/components/sidebarGroups.ts)
213 - * so the WP admin rail reads as the same map as the in-app sidebar.
214 - * Each entry points at the first module slug in that group; the
215 - * React app's hash router lands the user on that module, which is
216 - * the first row of that group's sidebar section — no manual scrolling.
211 + * Section deep-links rendered under the xSpeed menu.
217 212 *
218 - * If you add a group to React's SIDEBAR_GROUPS, add it here too. The
219 - * two arrays are intentionally co-located in PR review (same change
220 - * touches both) rather than DRY'd through a generated config file —
221 - * this is the only PHP↔TS coupling and it's tiny.
213 + * This is deliberately a SHORT LIST, not a mirror of React's
214 + * SIDEBAR_GROUPS. It used to mirror all eight groups 1:1, which made the
215 + * WP admin rail a second, competing copy of the in-app sidebar — the same
216 + * map rendered twice, one of them permanently expanded and pushing every
217 + * other plugin's menu down the page.
222 218 *
223 - * @return array<string,string> map of first-module-hash → group label.
219 + * What stays are the entry points a user navigates to from OUTSIDE the
220 + * app: the dashboard itself, the two areas people arrive at with a
221 + * specific errand (AI & agents to connect an assistant, Settings), and the
222 + * wizard. Everything else — Cache, Optimization, Network, Health &
223 + * insights, Tools — is one click away in the app's own sidebar, which is
224 + * where in-app navigation belongs.
225 + *
226 + * So adding a group to React's SIDEBAR_GROUPS no longer means adding it
227 + * here. The two lists are intentionally different lengths now.
228 + *
229 + * Setup Wizard is NOT in this array — it's a real submenu page registered
230 + * by Onboarding::register_menu() on `admin_menu` at priority 20, so it
231 + * lands after these entries.
232 + *
233 + * @return array<string,string> map of route hash → menu label.
224 234 */
225 235 private static function deep_link_items() {
226 - // Mirrors SIDEBAR_GROUPS (sidebarGroups.ts) 1:1, in the same order —
227 - // the only PHP↔TS coupling. Redesign v2: groups are LANDING pages, so
228 - // each key is the group's route hash (`/<group-id>`), not a module
229 - // slug; the submenu loop prepends '#'. Overview is pinned first. React
230 - // (nav.ts parseRoute) resolves `#/<group-id>` to that landing; legacy
231 - // `#slug` links still redirect, so old bookmarks keep working.
236 + // Keys are route hashes (`/<group-id>`); the submenu loop prepends '#'.
237 + // React (nav.ts parseRoute) resolves `#/<group-id>` to that landing;
238 + // legacy `#slug` links still redirect, so old bookmarks keep working —
239 + // including bookmarks to the groups no longer listed here.
232 240 return array(
233 - '/overview' => __( 'Overview', 'xspeed' ),
234 - '/cache' => __( 'Cache', 'xspeed' ),
235 - '/performance' => __( 'Optimization', 'xspeed' ),
236 - '/network' => __( 'Network', 'xspeed' ),
237 - '/insights' => __( 'Health & insights', 'xspeed' ),
238 - '/tools' => __( 'Tools', 'xspeed' ),
239 - '/ai-agents' => __( 'AI & agents', 'xspeed' ),
240 - '/settings' => __( 'Settings', 'xspeed' ),
241 + '/overview' => __( 'Overview', 'xspeed' ),
242 + '/ai-agents' => __( 'AI & agents', 'xspeed' ),
243 + '/settings' => __( 'Settings', 'xspeed' ),
241 244 );
242 245 }
243 246
244 247 /**
@@ -393,10 +396,23 @@
393 396 * @since 1.5.0
394 397 */
395 398 do_action( 'xspeed_admin_enqueue', $hook );
396 399
397 - wp_localize_script(
398 - 'xspeed-admin',
400 + // NOT wp_localize_script(). WP_Scripts::localize() casts every scalar
401 + // to a string (wp-includes/class-wp-scripts.php: `(string) $value`),
402 + // so an int arrives in JS as "21600" and a bool as "1" or "".
403 + //
404 + // That silently broke the Pro prewarm scheduler: it guards with
405 + // `typeof gmtOffset === 'number'`, which a string fails, so the site's
406 + // UTC offset was treated as 0 and every one-off warm was scheduled
407 + // against UTC instead of site time — hours late on any non-UTC site,
408 + // under a label that confidently read "Site time (UTC)".
409 + //
410 + // wp_add_inline_script() with wp_json_encode() preserves types, so
411 + // numbers stay numbers and booleans stay booleans. Worth doing beyond
412 + // the one field: every future numeric or boolean config value would
413 + // hit the same trap. (#105)
414 + self::print_config(
399 415 'XSpeedConfig',
400 416 array(
401 417 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
402 418 'nonce' => wp_create_nonce( 'wp_rest' ),
@@ -426,8 +442,36 @@
426 442 );
427 443 }
428 444
429 445 /**
446 + * Emit a JS global for the admin bundle with types intact.
447 + *
448 + * The type-preserving replacement for wp_localize_script(), which
449 + * stringifies every scalar. Attached to the `xspeed-admin` handle as a
450 + * `before` script so it is defined by the time the bundle executes —
451 + * exactly the ordering guarantee localize gave us.
452 + *
453 + * Shared by the dashboard and the onboarding wizard so neither can drift
454 + * back to the stringifying path.
455 + *
456 + * @param string $var_name JS global to define.
457 + * @param array $data Payload; encoded with wp_json_encode().
458 + */
459 + public static function print_config( string $var_name, array $data ): void {
460 + $json = wp_json_encode( $data );
461 + if ( false === $json ) {
462 + // Never emit a broken assignment — the bundle reads this global
463 + // on mount and a syntax error here blanks the whole screen.
464 + $json = '{}';
465 + }
466 + wp_add_inline_script(
467 + 'xspeed-admin',
468 + 'var ' . $var_name . ' = ' . $json . ';',
469 + 'before'
470 + );
471 + }
472 +
473 + /**
430 474 * Pre-rendered settings + status payload, baked into the page so the
431 475 * React app can mount with real values instead of showing a loading state
432 476 * while it waits for /settings and /status REST calls.
433 477 */
@@ -507,9 +551,18 @@
507 551 // /status block so the dashboard callout renders on first paint
508 552 // without waiting for a status re-fetch.
509 553 'mobile_separate' => array(
510 554 'enabled' => (bool) ( $opts['cache_enabled'] ? ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false ),
511 - 'blocking' => $rewrite_capable && 'mobile_separate' === Cache::static_rewrite_block_reason(),
555 + // Gated to servers that HAVE a static fast path — on IIS or
556 + // an undetected server block_reason still falls through to
557 + // mobile_separate, and reporting that as "blocking" would
558 + // nag about a rewrite that does not exist there (#108).
559 + // LiteSpeed joined the capable set with the Static Fast
560 + // Path opt-in (#509); its own refusal (litespeed_dropin)
561 + // outranks mobile_separate, so this stays false there
562 + // until the opt-in is on.
563 + 'blocking' => ( $rewrite_capable || Server::LITESPEED === $server_type )
564 + && 'mobile_separate' === Cache::static_rewrite_block_reason(),
512 565 'needs_review' => Cache::mobile_separate_needs_review(),
513 566 ),
514 567 // One consolidated nginx server-block snippet aggregating
515 568 // every enabled module's directives (Cache static-rewrite,
@@ -516,8 +569,14 @@
516 569 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
517 570 // or when no module contributes directives. Replaces the
518 571 // per-module "paste this snippet" notices.
519 572 'nginx_server_block' => Cache::full_nginx_server_block(),
573 + // Mirrors the /status block so the enable-time disclosure is
574 + // correct on FIRST PAINT. Without it the top-bar switch can be
575 + // clicked before a status fetch lands, and the one moment the
576 + // warning exists for — a leftover drop-in about to be
577 + // replaced — is exactly when it would be missing.
578 + 'dropin' => Page_Cache_Detector::dropin_disclosure(),
520 579 ),
521 580 // Registered Modules (Free + Pro). The React app discovers them
522 581 // here and renders one sidebar item + one panel per module that
523 582 // declares a settings schema. Hidden modules are filtered.
@@ -568,12 +627,43 @@
568 627 // panel — i.e., truly nothing to render in the dashboard.
569 628 if ( empty( $schema ) && empty( $custom_panel ) ) {
570 629 continue;
571 630 }
631 + $settings = Settings_Manager::get_public( $slug );
632 +
572 633 $entry = array(
573 634 'slug' => $slug,
574 635 'tier' => $module->tier(),
575 636 'version' => $module->version(),
637 + // Promoted from inside `settings` so the payload is
638 + // self-describing. Consumers kept tripping on this — the Hub
639 + // rendered every module "Inactive" until it learned to look
640 + // inside the settings bag. `settings.enabled` is kept below
641 + // for back-compat; this is the same value, not a second
642 + // source of truth. Modules with no `enabled` key (status
643 + // panels like Health) report null rather than a misleading
644 + // false. (#146)
645 + 'enabled' => array_key_exists( 'enabled', $settings )
646 + ? (bool) $settings['enabled']
647 + : null,
648 + // Whether the module is actually DOING something, which is not
649 + // the same question as `enabled` above. "On" has several shapes
650 + // -- page caching lives in the global option, Minify and Lazy
651 + // are on when any flag is set, MCP when it is connected -- so
652 + // each module answers for itself via is_active(). Consumers
653 + // that want "what is switched on?" (the sidebar's "N on" badge)
654 + // must read THIS, not `enabled`, which only ever described the
655 + // modules that happen to store that one key. null means the
656 + // module has no meaningful on/off and should be excluded from
657 + // any count rather than treated as off. (#363)
658 + 'active' => $module->is_active(),
659 + // One sentence explaining the line above, computed next to it
660 + // so the two cannot disagree. The UI shows it behind an (i)
661 + // beside the status pill: "On" is a bare assertion otherwise,
662 + // and least obvious exactly where it matters -- Media
663 + // Optimization reads On while its two most prominent switches
664 + // are off, because three other flags are on. (#363)
665 + 'active_reason' => $module->active_reason(),
576 666 'label' => $meta['label'] ?? ucfirst( $slug ),
577 667 'icon' => $meta['icon'] ?? 'Square',
578 668 'description' => $meta['description'] ?? '',
579 669 // Short label for the module's own tab when it hosts a tabbed
@@ -578,9 +668,19 @@
578 668 'description' => $meta['description'] ?? '',
579 669 // Short label for the module's own tab when it hosts a tabbed
580 670 // page (FBS-83633). Only set on host modules.
581 671 'tab_label' => $meta['tab_label'] ?? null,
582 - 'settings' => Settings_Manager::get( $slug ),
672 + // Public view: real values except secret fields, which are masked.
673 + // The dashboard bundle localizes this into page HTML, so a raw
674 + // credential here would be readable from view-source. (#115)
675 + 'settings' => $settings,
676 + // Where each value actually came from: a wp-config.php constant,
677 + // the option row, or the schema default. The panel renders a
678 + // constant-sourced field read-only and names the constant, so it
679 + // can never present an editable box over a value the site is not
680 + // using. Every module gets this, not just the ones that declare
681 + // constants today. (#398)
682 + 'setting_origins' => Settings_Manager::origins( $slug ),
583 683 'schema' => $schema,
584 684 'notices' => $module->ui_notices(),
585 685 'custom_panel' => $meta['custom_panel'] ?? null,
586 686 );