diff --git a/.distignore b/.distignore
index 9b0ffd9..8fa9daa 100644
--- a/.distignore
+++ b/.distignore
@@ -37,6 +37,7 @@
.nvmrc
.node-version
.wp-env.json
+.wp-env.override.json
AGENTS.md
DESIGN.md
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 2b5ad78..f0b4846 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -75,6 +75,14 @@ jobs:
- name: Build frontend assets
run: npm run build
+ - name: Install the packaged candidate into the test environment
+ run: |
+ npm run plugin-zip
+ node tests/package/validate-package.mjs
+ mkdir -p build/e2e
+ unzip -q perform.zip -d build/e2e
+ node -e 'require("fs").writeFileSync(".wp-env.override.json", JSON.stringify({plugins:["./build/e2e/perform"]}))'
+
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index 152c63e..b6b8aa0 100644
--- a/.github/workflows/frontend.yml
+++ b/.github/workflows/frontend.yml
@@ -9,6 +9,8 @@ on:
- 'hotfix/[0-9]+.[0-9]+*'
paths:
- 'assets/src/**'
+ - 'tests/package/**'
+ - '.distignore'
- 'package.json'
- 'package-lock.json'
- 'postcss.config.js'
@@ -19,6 +21,8 @@ on:
pull_request:
paths:
- 'assets/src/**'
+ - 'tests/package/**'
+ - '.distignore'
- 'package.json'
- 'package-lock.json'
- 'postcss.config.js'
@@ -71,8 +75,21 @@ jobs:
- name: Lint CSS
run: npm run lint:css
+ - name: Run JavaScript unit tests
+ run: npm run test:unit -- --watch=false
+
- name: Build production assets
run: npm run build
- name: Validate plugin zip generation
run: npm run plugin-zip
+
+ - name: Validate packaged runtime and metadata
+ run: node tests/package/validate-package.mjs
+
+ - name: Upload candidate package
+ uses: actions/upload-artifact@v4
+ with:
+ name: perform-candidate-${{ github.event.pull_request.head.sha || github.sha }}
+ path: perform.zip
+ retention-days: 14
diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml
index 2b7ecc8..5bbf31a 100644
--- a/.github/workflows/phpunit.yml
+++ b/.github/workflows/phpunit.yml
@@ -39,7 +39,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- php_version: [ '8.1', '8.2', '8.3' ]
+ php_version: [ '7.4', '8.1', '8.2', '8.3' ]
steps:
- name: Checkout code
diff --git a/.wordpress-org/blueprints/pr-preview.json b/.wordpress-org/blueprints/pr-preview.json
index 52a6dcd..084b39e 100644
--- a/.wordpress-org/blueprints/pr-preview.json
+++ b/.wordpress-org/blueprints/pr-preview.json
@@ -6,7 +6,7 @@
},
"preferredVersions": {
"php": "8.4",
- "wp": "6.9"
+ "wp": "7.1.3"
},
"steps": [
{
diff --git a/.wp-env.json b/.wp-env.json
index f079bc9..18cd433 100644
--- a/.wp-env.json
+++ b/.wp-env.json
@@ -1,4 +1,6 @@
{
+ "core": "https://wordpress.org/wordpress-7.1.3.zip",
+ "phpVersion": "8.3",
"plugins": [
"."
],
diff --git a/assets/src/js/admin/SettingsApp.jsx b/assets/src/js/admin/SettingsApp.jsx
index e1899c4..7177ac9 100644
--- a/assets/src/js/admin/SettingsApp.jsx
+++ b/assets/src/js/admin/SettingsApp.jsx
@@ -37,10 +37,14 @@ const SettingsApp = () => {
SAVED_SETTINGS && Object.prototype.hasOwnProperty.call( SAVED_SETTINGS, f.id )
? SAVED_SETTINGS[ f.id ]
: undefined;
- if ( typeof savedVal !== 'undefined' ) {
+ if ( typeof savedVal !== 'undefined' && ! ( 'select' === f.type && '' === savedVal ) ) {
values[ f.id ] = savedVal;
+ } else if ( typeof f.default !== 'undefined' ) {
+ values[ f.id ] = f.default;
+ } else if ( 'select' === f.type ) {
+ values[ f.id ] = Object.keys( f.options || {} )[ 0 ] || '';
} else {
- values[ f.id ] = f.default ?? ( f.type === 'toggle' ? false : '' );
+ values[ f.id ] = f.type === 'toggle' ? false : '';
}
} );
} );
@@ -49,6 +53,7 @@ const SettingsApp = () => {
}, [ fields ] );
const [ fieldValues, setFieldValues ] = useState( initialValues );
+ const [ savedSnapshot, setSavedSnapshot ] = useState( initialValues );
const [ saving, setSaving ] = useState( false );
const [ message, setMessage ] = useState( null );
const [ diagnostics, setDiagnostics ] = useState( INITIAL_DIAGNOSTICS );
@@ -75,6 +80,7 @@ const SettingsApp = () => {
};
const handleSave = async () => {
+ const submittedSnapshot = { ...fieldValues };
setSaving( true );
setMessage( null );
try {
@@ -86,20 +92,17 @@ const SettingsApp = () => {
body: new URLSearchParams( {
action: 'perform_save_settings',
nonce: SETTINGS.nonce || '',
- data: JSON.stringify( fieldValues ),
+ data: JSON.stringify( submittedSnapshot ),
} ),
} );
const json = await res.json();
if ( json && json.success ) {
+ // Later edits remain dirty until their own submission is acknowledged.
+ setSavedSnapshot( submittedSnapshot );
setMessage( { text: json.data?.message || 'Settings saved.', type: 'success' } );
if ( json.data?.diagnostics ) {
setDiagnostics( json.data.diagnostics );
}
- // update initialValues snapshot
- // mutate initialValues object won't update memo, so reset by rebuild: setFieldValues equals current, but we need to reset initialValues - simplest approach: set initial snapshot to current by resetting via a state.
- // We'll set the initialValues by replacing the state used for comparison: emulate by setting all initialValues to current values via a ref - but here we'll just clear dirty by resetting initialValues via resetting fieldValues baseline.
- // For simplicity, update initialValues by assigning to window.performwpSettings._initial = fieldValues (not ideal), but we can update local initialValues via a small trick: setFieldValues to same and update a savedSnapshot state.
- // Implement savedSnapshot state instead.
} else {
setMessage( { text: ( json && json.data && json.data.message ) || 'Save failed.', type: 'error' } );
}
@@ -110,9 +113,6 @@ const SettingsApp = () => {
}
};
- // Add a savedSnapshot state to serve as baseline for dirty calculation
- const [ savedSnapshot, setSavedSnapshot ] = useState( initialValues );
-
useEffect( () => {
// when initialValues changes (first render) set snapshot
setSavedSnapshot( initialValues );
@@ -125,13 +125,6 @@ const SettingsApp = () => {
return Object.keys( fieldValues ).some( ( k ) => fieldValues[ k ] !== savedSnapshot[ k ] );
}, [ fieldValues, savedSnapshot ] );
- // update savedSnapshot on successful save by watching message success
- useEffect( () => {
- if ( message && message.type === 'success' ) {
- setSavedSnapshot( fieldValues );
- }
- }, [ message, fieldValues ] );
-
// Auto-dismiss message after 5 seconds
useEffect( () => {
if ( ! message || ! message.text ) {
diff --git a/assets/src/js/admin/SettingsApp.test.jsx b/assets/src/js/admin/SettingsApp.test.jsx
new file mode 100644
index 0000000..c016425
--- /dev/null
+++ b/assets/src/js/admin/SettingsApp.test.jsx
@@ -0,0 +1,126 @@
+/* eslint-env jest */
+import '@testing-library/jest-dom';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+jest.mock( './SettingsHeader', () => () => null );
+jest.mock( './DiagnosticsPanel', () => () => null );
+jest.mock( './SettingsNav', () => ( { fieldValues, onFieldChange, diagnostics } ) => (
+ <>
+
+ { diagnostics.summary }
+ >
+) );
+
+window.performwpSettings = {
+ activeTab: 'assets',
+ tabs: { assets: 'Assets' },
+ fields: {
+ assets: [
+ {
+ fields: [
+ { id: 'preconnect', type: 'textarea' },
+ {
+ id: 'page_cache_ttl',
+ type: 'select',
+ default: '3600',
+ options: { 300: '5 minutes', 3600: '1 hour' },
+ },
+ {
+ id: 'page_cache_swr_ttl',
+ type: 'select',
+ default: '21600',
+ options: { 900: '15 minutes', 21600: '6 hours' },
+ },
+ ],
+ },
+ ],
+ },
+ saved: { preconnect: '//original.example.test', page_cache_swr_ttl: '' },
+};
+const SettingsApp = require( './SettingsApp' ).default;
+
+const edit = ( value ) => fireEvent.change( screen.getByLabelText( 'Preconnect' ), { target: { value } } );
+const save = () => screen.getByRole( 'button', { name: 'Save Settings' } );
+const success = {
+ success: true,
+ data: { message: 'Settings saved.', diagnostics: { summary: 'Refreshed diagnostics' } },
+};
+
+describe( 'settings acknowledged baseline', () => {
+ beforeEach( () => {
+ window.fetch = jest.fn().mockResolvedValue( { json: async () => success } );
+ } );
+
+ it( 'disables Save only when current values match the acknowledged submission', async () => {
+ render( );
+ expect( save() ).toBeDisabled();
+ edit( '//submitted.example.test' );
+ fireEvent.click( save() );
+ await screen.findByText( 'Settings saved.' );
+ expect( save() ).toBeDisabled();
+ expect( screen.getByText( 'Refreshed diagnostics' ) ).toBeInTheDocument();
+ const submitted = JSON.parse( window.fetch.mock.calls[ 0 ][ 1 ].body.get( 'data' ) );
+ expect( submitted.page_cache_ttl ).toBe( '3600' );
+ expect( submitted.page_cache_swr_ttl ).toBe( '21600' );
+ edit( '//later.example.test' );
+ expect( save() ).toBeEnabled();
+ edit( '//submitted.example.test' );
+ expect( save() ).toBeDisabled();
+ } );
+
+ it( 'retains edits made while a request is pending and submits them on the next save', async () => {
+ let finish;
+ window.fetch.mockReturnValueOnce(
+ new Promise( ( resolve ) => {
+ finish = resolve;
+ } )
+ );
+ render( );
+ edit( '//submitted.example.test' );
+ fireEvent.click( save() );
+ expect( screen.getByRole( 'button', { name: /Saving/ } ) ).toBeDisabled();
+ edit( '//pending.example.test' );
+ await act( async () => finish( { json: async () => success } ) );
+ expect( save() ).toBeEnabled();
+ expect( JSON.parse( window.fetch.mock.calls[ 0 ][ 1 ].body.get( 'data' ) ).preconnect ).toBe(
+ '//submitted.example.test'
+ );
+ fireEvent.click( save() );
+ await waitFor( () => expect( save() ).toBeDisabled() );
+ expect( JSON.parse( window.fetch.mock.calls[ 1 ][ 1 ].body.get( 'data' ) ).preconnect ).toBe(
+ '//pending.example.test'
+ );
+ } );
+
+ it( 'keeps edits dirty after a rejected save and permits retry', async () => {
+ window.fetch.mockResolvedValueOnce( {
+ json: async () => ( { success: false, data: { message: 'Rejected save.' } } ),
+ } );
+ render( );
+ edit( '//rejected.example.test' );
+ fireEvent.click( save() );
+ await screen.findByRole( 'alert' );
+ expect( save() ).toBeEnabled();
+ fireEvent.click( save() );
+ await screen.findByText( 'Settings saved.' );
+ expect( save() ).toBeDisabled();
+ } );
+
+ it( 'keeps edits dirty after an interrupted response', async () => {
+ window.fetch.mockRejectedValueOnce( new Error( 'Connection interrupted.' ) );
+ render( );
+ edit( '//interrupted.example.test' );
+ fireEvent.click( save() );
+ await screen.findByText( 'Connection interrupted.' );
+ expect( save() ).toBeEnabled();
+ edit( '//original.example.test' );
+ expect( save() ).toBeDisabled();
+ } );
+} );
diff --git a/languages/perform.pot b/languages/perform.pot
index 21da56c..5e03ed5 100644
--- a/languages/perform.pot
+++ b/languages/perform.pot
@@ -7,7 +7,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language-Team: PerformWP \n"
-"POT-Creation-Date: 2026-08-11 05:16+0000\n"
+"POT-Creation-Date: 2026-10-07 11:28+0000\n"
"Report-Msgid-Bugs-To: https://github.com/performwp/perform/issues/new\n"
"X-Poedit-Basepath: ..\n"
"X-Poedit-KeywordsList: __;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n"
@@ -384,7 +384,7 @@ msgstr ""
msgid "4 Minutes"
msgstr ""
-#: src/Includes/Helpers.php:612, src/Includes/Helpers.php:747
+#: src/Includes/Helpers.php:612, src/Includes/Helpers.php:748
msgid "5 Minutes"
msgstr ""
@@ -480,219 +480,219 @@ msgstr ""
msgid "Caches full HTML responses for anonymous visitors."
msgstr ""
-#: src/Includes/Helpers.php:745
+#: src/Includes/Helpers.php:746
msgid "Cache TTL"
msgstr ""
-#: src/Includes/Helpers.php:748, src/Includes/Helpers.php:767
+#: src/Includes/Helpers.php:749, src/Includes/Helpers.php:769
msgid "15 Minutes"
msgstr ""
-#: src/Includes/Helpers.php:749, src/Includes/Helpers.php:768
+#: src/Includes/Helpers.php:750, src/Includes/Helpers.php:770
msgid "30 Minutes"
msgstr ""
-#: src/Includes/Helpers.php:750, src/Includes/Helpers.php:769
+#: src/Includes/Helpers.php:751, src/Includes/Helpers.php:771
msgid "1 Hour"
msgstr ""
-#: src/Includes/Helpers.php:751, src/Includes/Helpers.php:770
+#: src/Includes/Helpers.php:752, src/Includes/Helpers.php:772
msgid "6 Hours"
msgstr ""
-#: src/Includes/Helpers.php:752, src/Includes/Helpers.php:772
+#: src/Includes/Helpers.php:753, src/Includes/Helpers.php:774
msgid "24 Hours"
msgstr ""
-#: src/Includes/Helpers.php:754
+#: src/Includes/Helpers.php:755
msgid "How long a page remains fresh before becoming stale."
msgstr ""
-#: src/Includes/Helpers.php:765
+#: src/Includes/Helpers.php:767
msgid "Stale Revalidate Window"
msgstr ""
-#: src/Includes/Helpers.php:771
+#: src/Includes/Helpers.php:773
msgid "12 Hours"
msgstr ""
-#: src/Includes/Helpers.php:774
+#: src/Includes/Helpers.php:776
msgid "Serve stale pages while refreshing them in the background."
msgstr ""
-#: src/Includes/Helpers.php:785
+#: src/Includes/Helpers.php:787
msgid "Separate Query Params"
msgstr ""
-#: src/Includes/Helpers.php:786
+#: src/Includes/Helpers.php:788
msgid "Comma-separated query keys that should generate separate cache entries. Tracking params are ignored automatically."
msgstr ""
-#: src/Includes/Helpers.php:797
+#: src/Includes/Helpers.php:799
msgid "Bypass Exact Paths"
msgstr ""
-#: src/Includes/Helpers.php:798
+#: src/Includes/Helpers.php:800
msgid "One URL path per line that should bypass full-page cache exactly, such as /account/dashboard."
msgstr ""
-#: src/Includes/Helpers.php:811
+#: src/Includes/Helpers.php:813
msgid "Bypass Path Prefixes"
msgstr ""
-#: src/Includes/Helpers.php:812
+#: src/Includes/Helpers.php:814
msgid "One URL path prefix per line that should bypass full-page cache, such as /checkout or /private."
msgstr ""
-#: src/Includes/Helpers.php:825
+#: src/Includes/Helpers.php:827
msgid "Bypass Query Keys"
msgstr ""
-#: src/Includes/Helpers.php:826
+#: src/Includes/Helpers.php:828
msgid "Query keys that should bypass cache reads and writes entirely. This is separate from query keys that create separate cache entries."
msgstr ""
-#: src/Includes/Helpers.php:839
+#: src/Includes/Helpers.php:841
msgid "Bypass Cookie Names"
msgstr ""
-#: src/Includes/Helpers.php:840
+#: src/Includes/Helpers.php:842
msgid "Cookie names that should bypass cache reads and writes. Cookie values are never needed for matching."
msgstr ""
-#: src/Includes/Helpers.php:853
+#: src/Includes/Helpers.php:855
msgid "Bypass Cookie Prefixes"
msgstr ""
-#: src/Includes/Helpers.php:854
+#: src/Includes/Helpers.php:856
msgid "Cookie name prefixes that should bypass cache reads and writes, such as wp-postpass_ or custom_session_."
msgstr ""
-#: src/Includes/Helpers.php:867
+#: src/Includes/Helpers.php:869
msgid "Preload and Observability"
msgstr ""
-#: src/Includes/Helpers.php:868
+#: src/Includes/Helpers.php:870
msgid "Warm cache from sitemaps and monitor cache effectiveness."
msgstr ""
-#: src/Includes/Helpers.php:873
+#: src/Includes/Helpers.php:875
msgid "Enable Adaptive Preloader"
msgstr ""
-#: src/Includes/Helpers.php:874
+#: src/Includes/Helpers.php:876
msgid "Preloads cache from sitemap and high-miss URLs using adaptive request rate."
msgstr ""
-#: src/Includes/Helpers.php:885
+#: src/Includes/Helpers.php:888
msgid "Slow Request Threshold"
msgstr ""
-#: src/Includes/Helpers.php:887
+#: src/Includes/Helpers.php:890
msgid "500ms"
msgstr ""
-#: src/Includes/Helpers.php:888
+#: src/Includes/Helpers.php:891
msgid "800ms"
msgstr ""
-#: src/Includes/Helpers.php:889
+#: src/Includes/Helpers.php:892
msgid "1200ms"
msgstr ""
-#: src/Includes/Helpers.php:890
+#: src/Includes/Helpers.php:893
msgid "2000ms"
msgstr ""
-#: src/Includes/Helpers.php:892
+#: src/Includes/Helpers.php:895
msgid "Tracks uncached requests slower than this value in observability stats."
msgstr ""
-#: src/Includes/Helpers.php:903
+#: src/Includes/Helpers.php:906
msgid "Cloudflare"
msgstr ""
-#: src/Includes/Helpers.php:904
+#: src/Includes/Helpers.php:907
msgid "Sync local cache purge events to Cloudflare (free-tier friendly)."
msgstr ""
-#: src/Includes/Helpers.php:909
+#: src/Includes/Helpers.php:912
msgid "Enable Cloudflare Cache Sync"
msgstr ""
-#: src/Includes/Helpers.php:910
+#: src/Includes/Helpers.php:913
msgid "Purge Cloudflare URLs when Perform purges local cache."
msgstr ""
-#: src/Includes/Helpers.php:921
+#: src/Includes/Helpers.php:924
msgid "Cloudflare Zone ID"
msgstr ""
-#: src/Includes/Helpers.php:922
+#: src/Includes/Helpers.php:925
msgid "Your Cloudflare Zone ID."
msgstr ""
-#: src/Includes/Helpers.php:933
+#: src/Includes/Helpers.php:936
msgid "Cloudflare API Token"
msgstr ""
-#: src/Includes/Helpers.php:934
+#: src/Includes/Helpers.php:937
msgid "API token with Zone:Cache Purge permission."
msgstr ""
-#: src/Includes/Helpers.php:947
+#: src/Includes/Helpers.php:950
msgid "Advanced Settings"
msgstr ""
-#: src/Includes/Helpers.php:948
+#: src/Includes/Helpers.php:951
msgid "Settings for advanced configurations."
msgstr ""
-#: src/Includes/Helpers.php:953
+#: src/Includes/Helpers.php:956
msgid "Admin Performance Monitor"
msgstr ""
-#: src/Includes/Helpers.php:954
+#: src/Includes/Helpers.php:957
msgid "Collect bounded, private aggregate timing, memory, and query-count diagnostics for WordPress admin requests."
msgstr ""
-#: src/Includes/Helpers.php:959
+#: src/Includes/Helpers.php:962
msgid "Admin Asset Audit"
msgstr ""
-#: src/Includes/Helpers.php:960
+#: src/Includes/Helpers.php:963
msgid "Temporarily inventory script and style handles across sampled WordPress admin screens without disabling anything."
msgstr ""
-#: src/Includes/Helpers.php:965
+#: src/Includes/Helpers.php:968
msgid "Remove Data on Uninstall"
msgstr ""
-#: src/Includes/Helpers.php:966
+#: src/Includes/Helpers.php:969
msgid "Enabling this will remove all plugin data upon uninstallation."
msgstr ""
-#: src/Includes/Helpers.php:979
+#: src/Includes/Helpers.php:982
msgid "WooCommerce Settings"
msgstr ""
-#: src/Includes/Helpers.php:980
+#: src/Includes/Helpers.php:983
msgid "Settings specific to WooCommerce."
msgstr ""
-#: src/Includes/Helpers.php:985
+#: src/Includes/Helpers.php:988
msgid "Enable WooCommerce Manager"
msgstr ""
-#: src/Includes/Helpers.php:986
+#: src/Includes/Helpers.php:989
msgid "Enabling this will allow you to manage WooCommerce specific settings."
msgstr ""
-#: src/Includes/Helpers.php:997
+#: src/Includes/Helpers.php:1000
msgid "Enable WooCommerce Cache"
msgstr ""
-#: src/Includes/Helpers.php:998
+#: src/Includes/Helpers.php:1001
msgid "Enabling this will cache WooCommerce pages for better performance."
msgstr ""
@@ -880,11 +880,15 @@ msgstr ""
msgid "Cache Stats"
msgstr ""
-#: src/Admin/Settings/Menu.php:243
+#: src/Admin/Settings/Menu.php:199
+msgid "Settings data is invalid. Please try again."
+msgstr ""
+
+#: src/Admin/Settings/Menu.php:245
msgid "Unable to save the settings. Please try again."
msgstr ""
-#: src/Admin/Settings/Menu.php:235
+#: src/Admin/Settings/Menu.php:237
msgid "Settings saved successfully."
msgstr ""
diff --git a/readme.txt b/readme.txt
index 11ccd28..9ca1e34 100644
--- a/readme.txt
+++ b/readme.txt
@@ -68,6 +68,8 @@ Contributions and bug reports welcome on GitHub: https://github.com/performwp/pe
== Changelog ==
= 1.8.0 =
+- Preserved unsaved settings edits during saving and success notifications.
+- Preserved numeric cache settings and their established defaults when saving the whole form.
- Added privacy-conscious site inventory and system-health guidance for performance troubleshooting.
- Added bounded diagnostics for autoloaded options, object cache status, slow admin screens, scheduled tasks, admin assets, plugin impact, and Action Scheduler queues.
- Added a performance health dashboard that reports observed evidence without claiming unmeasured savings.
diff --git a/src/Admin/Settings/ActionSchedulerAudit.php b/src/Admin/Settings/ActionSchedulerAudit.php
index 9a8de4b..0aaec8d 100644
--- a/src/Admin/Settings/ActionSchedulerAudit.php
+++ b/src/Admin/Settings/ActionSchedulerAudit.php
@@ -50,7 +50,7 @@ public static function refresh() {
global $wpdb;
if ( ! is_object( $wpdb ) || empty( $wpdb->prefix ) ) {
- throw new RuntimeException( __( 'The database is unavailable.', 'perform' ) );
+ throw new RuntimeException( esc_html__( 'The database is unavailable.', 'perform' ) );
}
$actions_table = $wpdb->prefix . 'actionscheduler_actions';
@@ -101,7 +101,7 @@ public static function refresh() {
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( ! is_array( $status_rows ) || ! is_array( $hook_rows ) || ! is_array( $group_rows ) || ! empty( $wpdb->last_error ) ) {
- throw new RuntimeException( __( 'The Action Scheduler diagnostic could not be completed.', 'perform' ) );
+ throw new RuntimeException( esc_html__( 'The Action Scheduler diagnostic could not be completed.', 'perform' ) );
}
$counts = [
@@ -278,7 +278,7 @@ private static function persist( array $snapshot ) {
$snapshot['isStale'] = false;
$is_saved = update_option( self::OPTION_NAME, $snapshot, false );
if ( ! $is_saved && get_option( self::OPTION_NAME ) !== $snapshot ) {
- throw new RuntimeException( __( 'The Action Scheduler diagnostic could not be saved.', 'perform' ) );
+ throw new RuntimeException( esc_html__( 'The Action Scheduler diagnostic could not be saved.', 'perform' ) );
}
return $snapshot;
}
diff --git a/src/Admin/Settings/AutoloadOptionsAudit.php b/src/Admin/Settings/AutoloadOptionsAudit.php
index 9f78948..79d4153 100644
--- a/src/Admin/Settings/AutoloadOptionsAudit.php
+++ b/src/Admin/Settings/AutoloadOptionsAudit.php
@@ -52,7 +52,7 @@ public static function refresh() {
global $wpdb;
if ( ! is_object( $wpdb ) || empty( $wpdb->options ) ) {
- throw new RuntimeException( __( 'The options table is unavailable.', 'perform' ) );
+ throw new RuntimeException( esc_html__( 'The options table is unavailable.', 'perform' ) );
}
$autoload_values = self::get_autoload_values();
@@ -78,7 +78,7 @@ public static function refresh() {
$options = $wpdb->get_results( $options_query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared above; manual diagnostic persisted below.
if ( ! is_array( $summary ) || ! is_array( $options ) || ! empty( $wpdb->last_error ) ) {
- throw new RuntimeException( __( 'The options audit could not be completed.', 'perform' ) );
+ throw new RuntimeException( esc_html__( 'The options audit could not be completed.', 'perform' ) );
}
$generated_at = time();
@@ -104,7 +104,7 @@ public static function refresh() {
$is_saved = update_option( self::OPTION_NAME, $snapshot, false );
if ( ! $is_saved && get_option( self::OPTION_NAME ) !== $snapshot ) {
- throw new RuntimeException( __( 'The audit result could not be saved.', 'perform' ) );
+ throw new RuntimeException( esc_html__( 'The audit result could not be saved.', 'perform' ) );
}
return $snapshot;
diff --git a/src/Admin/Settings/CronPressureAudit.php b/src/Admin/Settings/CronPressureAudit.php
index 57c5e4e..c3ac314 100644
--- a/src/Admin/Settings/CronPressureAudit.php
+++ b/src/Admin/Settings/CronPressureAudit.php
@@ -150,7 +150,7 @@ public static function refresh() {
$is_saved = update_option( self::OPTION_NAME, $snapshot, false );
if ( ! $is_saved && get_option( self::OPTION_NAME ) !== $snapshot ) {
- throw new RuntimeException( __( 'The scheduled-task diagnostic could not be saved.', 'perform' ) );
+ throw new RuntimeException( esc_html__( 'The scheduled-task diagnostic could not be saved.', 'perform' ) );
}
return $snapshot;
diff --git a/src/Admin/Settings/Menu.php b/src/Admin/Settings/Menu.php
index 5d56453..21a8bf2 100644
--- a/src/Admin/Settings/Menu.php
+++ b/src/Admin/Settings/Menu.php
@@ -185,20 +185,22 @@ public function save_settings() {
$sanitized_post[ $key ] = esc_url_raw( $raw_val );
break;
case 'select':
- // Ensure value is one of allowed options when provided.
- $opts = $field_def['options'] ?? [];
- $is_ok = false;
+ // PHP converts numeric option keys to integers; the browser posts strings.
+ $opts = $field_def['options'] ?? [];
+ $allowed_values = [];
if ( is_array( $opts ) && ! empty( $opts ) ) {
- // If associative array (value=>label) check keys, otherwise check values.
- $keys = array_keys( $opts );
- $vals = array_values( $opts );
- if ( array_diff_key( $opts, array_values( $opts ) ) ) {
- $is_ok = in_array( $raw_val, $keys, true );
- } else {
- $is_ok = in_array( $raw_val, $vals, true );
- }
+ $is_list = array_keys( $opts ) === range( 0, count( $opts ) - 1 );
+ $allowed_values = array_map( 'strval', $is_list ? $opts : array_keys( $opts ) );
}
- $sanitized_post[ $key ] = $is_ok ? sanitize_text_field( $raw_val ) : '';
+ if ( ! in_array( (string) $raw_val, $allowed_values, true ) ) {
+ wp_send_json_error(
+ [
+ 'type' => 'error',
+ 'message' => esc_html__( 'Settings data is invalid. Please try again.', 'perform' ),
+ ]
+ );
+ }
+ $sanitized_post[ $key ] = sanitize_text_field( $raw_val );
break;
case 'number':
$sanitized_post[ $key ] = is_numeric( $raw_val ) ? intval( $raw_val ) : 0;
diff --git a/src/Admin/Settings/SiteInventory.php b/src/Admin/Settings/SiteInventory.php
index e6737bf..e6a40d9 100644
--- a/src/Admin/Settings/SiteInventory.php
+++ b/src/Admin/Settings/SiteInventory.php
@@ -85,7 +85,7 @@ public static function refresh() {
$is_saved = update_option( self::OPTION_NAME, $snapshot, false );
if ( ! $is_saved && get_option( self::OPTION_NAME ) !== $snapshot ) {
- throw new RuntimeException( __( 'The site inventory could not be saved.', 'perform' ) );
+ throw new RuntimeException( esc_html__( 'The site inventory could not be saved.', 'perform' ) );
}
return $snapshot;
diff --git a/src/Admin/Settings/SystemHealth.php b/src/Admin/Settings/SystemHealth.php
index 572d7cb..b1a3049 100644
--- a/src/Admin/Settings/SystemHealth.php
+++ b/src/Admin/Settings/SystemHealth.php
@@ -146,7 +146,7 @@ private static function execution_time_signal() {
? __( 'The limit supports ordinary WordPress maintenance tasks.', 'perform' )
: __( 'Short limits can interrupt imports, updates, or maintenance; review with your host.', 'perform' );
- return self::signal( 'execution-time', __( 'Maximum execution time', 'perform' ), sprintf( __( '%d seconds', 'perform' ), $seconds ), $status, $copy );
+ return self::signal( 'execution-time', __( 'Maximum execution time', 'perform' ), sprintf( /* translators: %d: Maximum execution time in seconds. */ __( '%d seconds', 'perform' ), $seconds ), $status, $copy );
}
/**
diff --git a/src/Includes/Helpers.php b/src/Includes/Helpers.php
index 819dba6..c326e5d 100644
--- a/src/Includes/Helpers.php
+++ b/src/Includes/Helpers.php
@@ -742,6 +742,7 @@ public static function get_settings_fields() {
[
'id' => 'page_cache_ttl',
'type' => 'select',
+ 'default' => '3600',
'name' => esc_html__( 'Cache TTL', 'perform' ),
'options' => [
'300' => esc_html__( '5 Minutes', 'perform' ),
@@ -762,6 +763,7 @@ public static function get_settings_fields() {
[
'id' => 'page_cache_swr_ttl',
'type' => 'select',
+ 'default' => '21600',
'name' => esc_html__( 'Stale Revalidate Window', 'perform' ),
'options' => [
'900' => esc_html__( '15 Minutes', 'perform' ),
@@ -882,6 +884,7 @@ public static function get_settings_fields() {
[
'id' => 'cache_slow_request_threshold_ms',
'type' => 'select',
+ 'default' => '1200',
'name' => esc_html__( 'Slow Request Threshold', 'perform' ),
'options' => [
'500' => esc_html__( '500ms', 'perform' ),
diff --git a/src/Modules/Cache/PageCache.php b/src/Modules/Cache/PageCache.php
index abca636..c2d59d0 100644
--- a/src/Modules/Cache/PageCache.php
+++ b/src/Modules/Cache/PageCache.php
@@ -833,7 +833,7 @@ public function render_observability_page() {
-
+