From 18ccc255f2f664849768b365ddfdc7b7e4730a08 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 13 Aug 2026 15:52:54 -0600 Subject: [PATCH 1/7] perf: defer Gutenberg work outside editors --- .../class-block-editor-widget-manager.php | 17 +++++----- inc/compat/class-gutenberg-support.php | 6 ++++ inc/functions/pages.php | 28 +++++++++++++++ inc/ui/class-current-site-element.php | 16 +-------- inc/ui/class-my-sites-element.php | 16 +-------- inc/ui/class-site-actions-element.php | 16 +-------- .../Block_Editor_Widget_Manager_Test.php | 28 +++++++++++++++ .../Functions/Pages_Functions_Test.php | 21 ++++++++++++ tests/WP_Ultimo/General_Compat_Test.php | 34 +++++++++++++++++++ 9 files changed, 128 insertions(+), 54 deletions(-) diff --git a/inc/builders/block-editor/class-block-editor-widget-manager.php b/inc/builders/block-editor/class-block-editor-widget-manager.php index f18ec29ee..d3ffb82eb 100644 --- a/inc/builders/block-editor/class-block-editor-widget-manager.php +++ b/inc/builders/block-editor/class-block-editor-widget-manager.php @@ -198,35 +198,34 @@ public function load_block_settings($blocks, $element) { } /** - * Generates the list of attributes supported based on the fields. + * Generates the list of attributes supported based on element defaults. * * @since 2.0.0 + * This deliberately avoids fields(), whose option providers only belong to + * the block editor settings payload. + * * @param \WP_Ultimo\UI\Base_Element $element The element being registered. * @return array */ public function get_attributes_from_fields($element) { - $fields = $element->fields(); - $defaults = $element->defaults(); $_fields = []; - foreach ($fields as $field_id => $field) { + foreach ($defaults as $field_id => $default_value) { $type = 'string'; - if ('toggle' === $field['type']) { + if (is_bool($default_value)) { $type = 'boolean'; } - if ('number' === $field['type']) { + if (is_int($default_value)) { $type = 'integer'; } - $default_value = wu_get_isset($defaults, $field_id, ''); - $_fields[ $field_id ] = [ - 'default' => wu_get_isset($field, 'value', $default_value), + 'default' => $default_value, 'type' => $type, ]; } diff --git a/inc/compat/class-gutenberg-support.php b/inc/compat/class-gutenberg-support.php index f9a2e5c73..df8c4b6ff 100644 --- a/inc/compat/class-gutenberg-support.php +++ b/inc/compat/class-gutenberg-support.php @@ -62,6 +62,12 @@ public function init(): void { */ public function add_scripts(): void { + $screen = get_current_screen(); + + if ( ! $screen || ! $screen->is_block_editor()) { + return; + } + wp_register_script('wu-gutenberg-support', wu_get_asset('gutenberg-support.js', 'js'), ['jquery'], wu_get_version(), true); // translators: the placeholder is replaced with the network name. diff --git a/inc/functions/pages.php b/inc/functions/pages.php index 85478fa2b..15f7c8170 100644 --- a/inc/functions/pages.php +++ b/inc/functions/pages.php @@ -107,6 +107,34 @@ function wu_is_new_site_page() { return absint(wu_get_setting('default_new_site_page', 0)) === $post->ID; } +/** + * Returns a request-memoized list of pages for select field options. + * + * @since 2.0.0 + * @param string $default_label The label for the current page option. + * @return array + */ +function wu_get_pages_as_options($default_label) { + + static $pages = null; + + if (null === $pages) { + $pages = get_pages( + [ + 'exclude' => [get_the_ID()], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude + ] + ) ?: []; + } + + $pages_list = [0 => $default_label]; + + foreach ($pages as $page) { + $pages_list[ $page->ID ] = $page->post_title; + } + + return $pages_list; +} + /** * Checks if the current page is a login page. * diff --git a/inc/ui/class-current-site-element.php b/inc/ui/class-current-site-element.php index 197ed7c77..73603b5a8 100644 --- a/inc/ui/class-current-site-element.php +++ b/inc/ui/class-current-site-element.php @@ -162,26 +162,12 @@ public function fields() { 'value' => 1, ]; - $pages = get_pages( - [ - 'exclude' => [get_the_ID()], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude - ] - ); - - $pages = $pages ?: []; - - $pages_list = [0 => __('Current Page', 'ultimate-multisite')]; - - foreach ($pages as $page) { - $pages_list[ $page->ID ] = $page->post_title; - } - $fields['breadcrumbs_my_sites_page'] = [ 'type' => 'select', 'title' => __('My Sites Page', 'ultimate-multisite'), 'value' => 0, 'desc' => __('The page with the customer sites list.', 'ultimate-multisite'), - 'options' => $pages_list, + 'options' => fn() => wu_get_pages_as_options(__('Current Page', 'ultimate-multisite')), ]; $fields['display_description'] = [ diff --git a/inc/ui/class-my-sites-element.php b/inc/ui/class-my-sites-element.php index b42f125b4..22de4d43f 100644 --- a/inc/ui/class-my-sites-element.php +++ b/inc/ui/class-my-sites-element.php @@ -162,20 +162,6 @@ public function fields() { ], ]; - $pages = get_pages( - [ - 'exclude' => [get_the_ID()], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude - ] - ); - - $pages = $pages ?: []; - - $pages_list = [0 => __('Current Page', 'ultimate-multisite')]; - - foreach ($pages as $page) { - $pages_list[ $page->ID ] = $page->post_title; - } - $fields['custom_manage_page'] = [ 'type' => 'select', 'title' => __('Manage Redirect Page', 'ultimate-multisite'), @@ -185,7 +171,7 @@ public function fields() { 'required' => [ 'site_manage_type' => 'custom_page', ], - 'options' => $pages_list, + 'options' => fn() => wu_get_pages_as_options(__('Current Page', 'ultimate-multisite')), ]; $fields['columns'] = [ diff --git a/inc/ui/class-site-actions-element.php b/inc/ui/class-site-actions-element.php index 62d361f44..9d0c9dcc4 100644 --- a/inc/ui/class-site-actions-element.php +++ b/inc/ui/class-site-actions-element.php @@ -182,27 +182,13 @@ public function fields() { 'value' => 1, ]; - $pages = get_pages( - [ - 'exclude' => [get_the_ID()], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude - ] - ); - - $pages = $pages ?: []; - - $pages_list = [0 => __('Default', 'ultimate-multisite')]; - - foreach ($pages as $page) { - $pages_list[ $page->ID ] = $page->post_title; - } - $fields['redirect_after_delete'] = [ 'type' => 'select', 'title' => __('Redirect After Delete', 'ultimate-multisite'), 'value' => 0, 'desc' => __('The page to redirect user after delete current site.', 'ultimate-multisite'), 'tooltip' => '', - 'options' => $pages_list, + 'options' => fn() => wu_get_pages_as_options(__('Default', 'ultimate-multisite')), ]; return $fields; diff --git a/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php b/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php index c6ddaf3e2..6c01307a4 100644 --- a/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php +++ b/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php @@ -116,4 +116,32 @@ public function test_is_block_preview_passes_through_outside_rest(): void { $this->assertFalse($result, 'Should return false when not in REST edit context.'); } + + /** + * Test attributes use defaults without evaluating field option providers. + */ + public function test_get_attributes_from_fields_does_not_evaluate_field_options(): void { + + $element = $this->getMockBuilder('\WP_Ultimo\UI\Simple_Text_Element') + ->disableOriginalConstructor() + ->onlyMethods(['defaults', 'fields']) + ->getMock(); + + $element->expects($this->once()) + ->method('defaults') + ->willReturn(['enabled' => true]); + + $element->expects($this->never()) + ->method('fields'); + + $this->assertSame( + [ + 'enabled' => [ + 'default' => true, + 'type' => 'boolean', + ], + ], + $this->manager->get_attributes_from_fields($element) + ); + } } diff --git a/tests/WP_Ultimo/Functions/Pages_Functions_Test.php b/tests/WP_Ultimo/Functions/Pages_Functions_Test.php index 43b0d00cd..5a5b8d349 100644 --- a/tests/WP_Ultimo/Functions/Pages_Functions_Test.php +++ b/tests/WP_Ultimo/Functions/Pages_Functions_Test.php @@ -75,6 +75,27 @@ public function test_is_new_site_page_false_without_post(): void { $this->assertFalse($result); } + /** + * Test page options are memoized per request with context-specific defaults. + */ + public function test_get_pages_as_options_memoizes_pages(): void { + + $page_id = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_title' => 'Example page', + ] + ); + + $first = wu_get_pages_as_options('Current Page'); + $second = wu_get_pages_as_options('Default'); + + $this->assertSame('Current Page', $first[0]); + $this->assertSame('Default', $second[0]); + $this->assertSame('Example page', $first[ $page_id ]); + $this->assertSame($first[ $page_id ], $second[ $page_id ]); + } + /** * Test wu_is_login_page returns bool. */ diff --git a/tests/WP_Ultimo/General_Compat_Test.php b/tests/WP_Ultimo/General_Compat_Test.php index 0655e8b6d..9094a7f7b 100644 --- a/tests/WP_Ultimo/General_Compat_Test.php +++ b/tests/WP_Ultimo/General_Compat_Test.php @@ -47,6 +47,40 @@ public function test_init_registers_divi_cache_purge_hook(): void { $this->assertNotFalse(has_action('wu_duplicate_site', [$instance, 'clear_divi_static_css_cache'])); } + /** + * Test Gutenberg support skips ordinary admin screens. + */ + public function test_gutenberg_support_skips_ordinary_admin_screens(): void { + + set_current_screen('dashboard'); + wp_deregister_script('wu-gutenberg-support'); + + Gutenberg_Support::get_instance()->add_scripts(); + + $this->assertFalse(wp_script_is('wu-gutenberg-support', 'registered')); + $this->assertFalse(wp_script_is('wu-gutenberg-support', 'enqueued')); + } + + /** + * Test Gutenberg support enqueues and localizes assets in the block editor. + */ + public function test_gutenberg_support_loads_on_block_editor_screens(): void { + + set_current_screen('post-post'); + $screen = get_current_screen(); + + if ( ! $screen || ! method_exists($screen, 'is_block_editor') || ! $screen->is_block_editor()) { + $this->markTestSkipped('The installed WordPress version does not support block editor screens.'); + } + + wp_deregister_script('wu-gutenberg-support'); + + Gutenberg_Support::get_instance()->add_scripts(); + + $this->assertTrue(wp_script_is('wu-gutenberg-support', 'enqueued')); + $this->assertNotEmpty(wp_scripts()->get_data('wu-gutenberg-support', 'data')); + } + /** * Test Divi et-cache files are deleted only for the cloned site. */ From 7e9a4aead5a991854405bced1644848dfac15a28 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 13 Aug 2026 16:24:08 -0600 Subject: [PATCH 2/7] test: exercise Gutenberg block editor screen --- tests/WP_Ultimo/General_Compat_Test.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/WP_Ultimo/General_Compat_Test.php b/tests/WP_Ultimo/General_Compat_Test.php index 9094a7f7b..1e0127119 100644 --- a/tests/WP_Ultimo/General_Compat_Test.php +++ b/tests/WP_Ultimo/General_Compat_Test.php @@ -66,12 +66,10 @@ public function test_gutenberg_support_skips_ordinary_admin_screens(): void { */ public function test_gutenberg_support_loads_on_block_editor_screens(): void { - set_current_screen('post-post'); + set_current_screen('post'); $screen = get_current_screen(); - if ( ! $screen || ! method_exists($screen, 'is_block_editor') || ! $screen->is_block_editor()) { - $this->markTestSkipped('The installed WordPress version does not support block editor screens.'); - } + $screen->is_block_editor(true); wp_deregister_script('wu-gutenberg-support'); From 1c9e2f9e5adbda55f1537ae64688b06727cac5d8 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 13 Aug 2026 17:58:56 -0600 Subject: [PATCH 3/7] wip: preserve block attribute types --- .../class-shortcodes-admin-page.php | 8 ++++- .../class-block-editor-widget-manager.php | 12 +++---- .../Block_Editor_Widget_Manager_Test.php | 34 ++++++++++++++++--- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/inc/admin-pages/class-shortcodes-admin-page.php b/inc/admin-pages/class-shortcodes-admin-page.php index e29466751..b98dc6eaa 100644 --- a/inc/admin-pages/class-shortcodes-admin-page.php +++ b/inc/admin-pages/class-shortcodes-admin-page.php @@ -151,7 +151,13 @@ public function get_data() { $params[ $key ]['options'] = '0 | 1'; break; case 'select': - $params[ $key ]['options'] = implode(' | ', array_keys(wu_get_isset($value, 'options', []))); + $options = wu_get_isset($value, 'options', []); + + if (is_callable($options)) { + $options = call_user_func($options); + } + + $params[ $key ]['options'] = implode(' | ', array_keys($options)); break; case 'int': $params[ $key ]['options'] = __('integer', 'ultimate-multisite'); diff --git a/inc/builders/block-editor/class-block-editor-widget-manager.php b/inc/builders/block-editor/class-block-editor-widget-manager.php index d3ffb82eb..b640c108c 100644 --- a/inc/builders/block-editor/class-block-editor-widget-manager.php +++ b/inc/builders/block-editor/class-block-editor-widget-manager.php @@ -198,11 +198,10 @@ public function load_block_settings($blocks, $element) { } /** - * Generates the list of attributes supported based on element defaults. + * Generates the list of attributes supported based on element field types and defaults. * * @since 2.0.0 - * This deliberately avoids fields(), whose option providers only belong to - * the block editor settings payload. + * Field definitions are read as metadata only; option providers are not evaluated. * * @param \WP_Ultimo\UI\Base_Element $element The element being registered. * @return array @@ -210,17 +209,18 @@ public function load_block_settings($blocks, $element) { public function get_attributes_from_fields($element) { $defaults = $element->defaults(); + $fields = $element->fields(); $_fields = []; foreach ($defaults as $field_id => $default_value) { - $type = 'string'; + $type = $fields[ $field_id ]['type'] ?? 'string'; - if (is_bool($default_value)) { + if ('toggle' === $type) { $type = 'boolean'; } - if (is_int($default_value)) { + if ('number' === $type) { $type = 'integer'; } diff --git a/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php b/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php index 6c01307a4..7a465702e 100644 --- a/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php +++ b/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php @@ -129,17 +129,43 @@ public function test_get_attributes_from_fields_does_not_evaluate_field_options( $element->expects($this->once()) ->method('defaults') - ->willReturn(['enabled' => true]); + ->willReturn( + [ + 'enabled' => true, + 'columns' => 4, + 'site_manage_type' => 'default', + ] + ); - $element->expects($this->never()) - ->method('fields'); + $element->expects($this->once()) + ->method('fields') + ->willReturn( + [ + 'enabled' => [ + 'type' => 'toggle', + 'options' => static function () { + throw new \RuntimeException('Block attribute registration must not evaluate options.'); + }, + ], + 'columns' => ['type' => 'number'], + 'site_manage_type' => ['type' => 'select'], + ] + ); $this->assertSame( [ - 'enabled' => [ + 'enabled' => [ 'default' => true, 'type' => 'boolean', ], + 'columns' => [ + 'default' => 4, + 'type' => 'integer', + ], + 'site_manage_type' => [ + 'default' => 'default', + 'type' => 'select', + ], ], $this->manager->get_attributes_from_fields($element) ); From 4df379d434314273fb8d37039a32ad47bc62cf05 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 30 Aug 2026 13:48:13 -0600 Subject: [PATCH 4/7] fix: preserve Gutenberg option contexts --- .../class-block-editor-widget-manager.php | 7 +- inc/functions/pages.php | 13 ++-- .../Block_Editor_Widget_Manager_Test.php | 2 +- .../Functions/Pages_Functions_Test.php | 65 +++++++++++++++++-- 4 files changed, 72 insertions(+), 15 deletions(-) diff --git a/inc/builders/block-editor/class-block-editor-widget-manager.php b/inc/builders/block-editor/class-block-editor-widget-manager.php index b640c108c..9cb8a2f5b 100644 --- a/inc/builders/block-editor/class-block-editor-widget-manager.php +++ b/inc/builders/block-editor/class-block-editor-widget-manager.php @@ -214,13 +214,14 @@ public function get_attributes_from_fields($element) { $_fields = []; foreach ($defaults as $field_id => $default_value) { - $type = $fields[ $field_id ]['type'] ?? 'string'; + $field_type = $fields[ $field_id ]['type'] ?? 'string'; + $type = 'string'; - if ('toggle' === $type) { + if ('toggle' === $field_type) { $type = 'boolean'; } - if ('number' === $type) { + if ('number' === $field_type) { $type = 'integer'; } diff --git a/inc/functions/pages.php b/inc/functions/pages.php index 15f7c8170..fceb63f73 100644 --- a/inc/functions/pages.php +++ b/inc/functions/pages.php @@ -116,19 +116,22 @@ function wu_is_new_site_page() { */ function wu_get_pages_as_options($default_label) { - static $pages = null; + static $pages_by_context = []; - if (null === $pages) { - $pages = get_pages( + $current_page_id = get_the_ID(); + $context_key = get_current_blog_id() . ':' . $current_page_id; + + if ( ! array_key_exists($context_key, $pages_by_context)) { + $pages_by_context[ $context_key ] = get_pages( [ - 'exclude' => [get_the_ID()], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude + 'exclude' => [$current_page_id], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude ] ) ?: []; } $pages_list = [0 => $default_label]; - foreach ($pages as $page) { + foreach ($pages_by_context[ $context_key ] as $page) { $pages_list[ $page->ID ] = $page->post_title; } diff --git a/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php b/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php index 7a465702e..35aa90983 100644 --- a/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php +++ b/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php @@ -164,7 +164,7 @@ public function test_get_attributes_from_fields_does_not_evaluate_field_options( ], 'site_manage_type' => [ 'default' => 'default', - 'type' => 'select', + 'type' => 'string', ], ], $this->manager->get_attributes_from_fields($element) diff --git a/tests/WP_Ultimo/Functions/Pages_Functions_Test.php b/tests/WP_Ultimo/Functions/Pages_Functions_Test.php index 5a5b8d349..1b85327da 100644 --- a/tests/WP_Ultimo/Functions/Pages_Functions_Test.php +++ b/tests/WP_Ultimo/Functions/Pages_Functions_Test.php @@ -80,6 +80,16 @@ public function test_is_new_site_page_false_without_post(): void { */ public function test_get_pages_as_options_memoizes_pages(): void { + global $post; + + $original_post = $post; + $current_page = self::factory()->post->create_and_get( + [ + 'post_type' => 'page', + 'post_title' => 'Current page', + ] + ); + $page_id = self::factory()->post->create( [ 'post_type' => 'page', @@ -87,13 +97,56 @@ public function test_get_pages_as_options_memoizes_pages(): void { ] ); - $first = wu_get_pages_as_options('Current Page'); - $second = wu_get_pages_as_options('Default'); + $post = $current_page; + + try { + $first = wu_get_pages_as_options('Current Page'); + $second = wu_get_pages_as_options('Default'); + + $this->assertSame('Current Page', $first[0]); + $this->assertSame('Default', $second[0]); + $this->assertSame('Example page', $first[ $page_id ]); + $this->assertSame($first[ $page_id ], $second[ $page_id ]); + } finally { + $post = $original_post; + } + } + + /** + * Test page options are memoized separately for each current page. + */ + public function test_get_pages_as_options_isolates_current_page_context(): void { + + global $post; + + $original_post = $post; + $first_page = self::factory()->post->create_and_get( + [ + 'post_type' => 'page', + 'post_title' => 'First page', + ] + ); + $second_page = self::factory()->post->create_and_get( + [ + 'post_type' => 'page', + 'post_title' => 'Second page', + ] + ); + + try { + $post = $first_page; + $first_options = wu_get_pages_as_options('Current Page'); + + $post = $second_page; + $second_options = wu_get_pages_as_options('Current Page'); - $this->assertSame('Current Page', $first[0]); - $this->assertSame('Default', $second[0]); - $this->assertSame('Example page', $first[ $page_id ]); - $this->assertSame($first[ $page_id ], $second[ $page_id ]); + $this->assertArrayNotHasKey($first_page->ID, $first_options); + $this->assertSame('Second page', $first_options[ $second_page->ID ]); + $this->assertSame('First page', $second_options[ $first_page->ID ]); + $this->assertArrayNotHasKey($second_page->ID, $second_options); + } finally { + $post = $original_post; + } } /** From d7ba3b7fbf67eb760376daed2cdfdb177ce72a9f Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 30 Aug 2026 14:11:42 -0600 Subject: [PATCH 5/7] test: isolate SSO settings and redirect hooks --- tests/WP_Ultimo/SSO/SSO_Coverage_Test.php | 72 ++++++++++++++--------- tests/WP_Ultimo/SSO/SSO_Extended_Test.php | 23 +++++--- 2 files changed, 58 insertions(+), 37 deletions(-) diff --git a/tests/WP_Ultimo/SSO/SSO_Coverage_Test.php b/tests/WP_Ultimo/SSO/SSO_Coverage_Test.php index 9cf8821d0..7b8add154 100644 --- a/tests/WP_Ultimo/SSO/SSO_Coverage_Test.php +++ b/tests/WP_Ultimo/SSO/SSO_Coverage_Test.php @@ -59,7 +59,6 @@ protected function tearDown(): void { remove_all_filters('http_origin'); remove_all_filters('login_url'); remove_all_filters('wp_redirect'); - remove_all_filters('allowed_redirect_hosts'); remove_all_filters('subdomain_install'); remove_all_filters('wu_is_same_domain'); @@ -2093,13 +2092,12 @@ public function test_handle_server_anonymous_denial_preserves_admin_redirect_tar $redirect_url = null; - add_filter( - 'allowed_redirect_hosts', - function ($hosts) { - $hosts[] = 'customer.example.com'; - return $hosts; - } - ); + $allow_customer_host = function ($hosts) { + $hosts[] = 'customer.example.com'; + return $hosts; + }; + + add_filter('allowed_redirect_hosts', $allow_customer_host); add_filter( 'wp_redirect', @@ -2111,13 +2109,16 @@ function ($location) use (&$redirect_url) { ); try { - $sso->handle_server('redirect'); - } catch (\RuntimeException $e) { - $this->assertSame('redirect_intercepted', $e->getMessage()); + try { + $sso->handle_server('redirect'); + } catch (\RuntimeException $e) { + $this->assertSame('redirect_intercepted', $e->getMessage()); + } + } finally { + remove_filter('allowed_redirect_hosts', $allow_customer_host); + unset($_REQUEST['return_url'], $_REQUEST['redirect_to']); } - unset($_REQUEST['return_url'], $_REQUEST['redirect_to']); - $this->assertNotNull($redirect_url, 'handle_server() must redirect anonymous SSO grant requests back to the broker'); $this->assertStringContainsString('sso_verify=invalid', $redirect_url); $this->assertStringContainsString('redirect_to=', $redirect_url); @@ -2183,13 +2184,12 @@ public function test_handle_server_redirects_logged_in_user_with_cookie_less_tok $_REQUEST['return_url'] = 'https://customer.example.net/page'; - add_filter( - 'allowed_redirect_hosts', - function ($hosts) { - $hosts[] = 'customer.example.net'; - return $hosts; - } - ); + $allow_customer_host = function ($hosts) { + $hosts[] = 'customer.example.net'; + return $hosts; + }; + + add_filter('allowed_redirect_hosts', $allow_customer_host); $redirect_url = null; add_filter( @@ -2202,14 +2202,17 @@ function ($location) use (&$redirect_url) { ); try { - $sso->handle_server('redirect'); - } catch (\RuntimeException $e) { - $this->assertSame('redirect_intercepted', $e->getMessage()); + try { + $sso->handle_server('redirect'); + } catch (\RuntimeException $e) { + $this->assertSame('redirect_intercepted', $e->getMessage()); + } + } finally { + remove_filter('allowed_redirect_hosts', $allow_customer_host); + wp_set_current_user(0); + unset($_REQUEST['return_url']); } - wp_set_current_user(0); - unset($_REQUEST['return_url']); - $this->assertNotNull($redirect_url, 'handle_server() must redirect logged-in users back to the broker'); $this->assertStringContainsString('wu_sso_token=', $redirect_url); } @@ -2218,9 +2221,11 @@ function ($location) use (&$redirect_url) { * Test SSO allows safe redirects back to mapped broker domains. */ public function test_startup_loads_domain_mapping_redirect_host_filter(): void { - $sso = SSO::get_instance(); - $domain_name = 'sso-redirect-mapped.example.com'; - $domain = wu_create_domain( + $sso = SSO::get_instance(); + $original_domain_mapping = \WP_Ultimo\Domain_Mapping::get_instance(); + $domain_mapping = null; + $domain_name = 'sso-redirect-mapped.example.com'; + $domain = wu_create_domain( [ 'blog_id' => 1, 'domain' => $domain_name, @@ -2239,12 +2244,21 @@ public function test_startup_loads_domain_mapping_redirect_host_filter(): void { wp_cache_delete('domain:www.' . $domain_name, 'domain_mappings'); try { + // Simulate SSO starting before Domain Mapping has been instantiated. + \WP_Ultimo\Domain_Mapping::$instance = new \stdClass(); + $sso->startup(); + $domain_mapping = \WP_Ultimo\Domain_Mapping::get_instance(); $result = apply_filters('allowed_redirect_hosts', ['mygratis.site'], strtoupper($domain_name)); $this->assertContains($domain_name, $result); } finally { + if ($domain_mapping) { + remove_filter('allowed_redirect_hosts', [$domain_mapping, 'allow_network_redirect_hosts'], 20); + } + + \WP_Ultimo\Domain_Mapping::$instance = $original_domain_mapping; $domain->delete(); wp_cache_delete('domain:' . $domain_name, 'domain_mappings'); wp_cache_delete('domain:www.' . $domain_name, 'domain_mappings'); diff --git a/tests/WP_Ultimo/SSO/SSO_Extended_Test.php b/tests/WP_Ultimo/SSO/SSO_Extended_Test.php index 84970add9..67578ab7e 100644 --- a/tests/WP_Ultimo/SSO/SSO_Extended_Test.php +++ b/tests/WP_Ultimo/SSO/SSO_Extended_Test.php @@ -1637,19 +1637,26 @@ public function test_is_enabled_source_uses_apply_filters_deprecated_for_mercato } // ------------------------------------------------------------------ - // get_setting — with actual setting + // get_setting — filtered setting // ------------------------------------------------------------------ /** - * Test get_setting returns true for enable_sso when set. + * Test get_setting returns a filtered enable_sso value. */ - public function test_get_setting_returns_true_for_enable_sso(): void { - $sso = SSO::get_instance(); - $result = $sso->get_setting('enable_sso', true); + public function test_get_setting_returns_filtered_enable_sso_value(): void { + $filter = function ($value, $setting) { + return 'enable_sso' === $setting ? true : $value; + }; + + add_filter('wu_get_setting', $filter, 10, 2); + + try { + $result = SSO::get_instance()->get_setting('enable_sso'); - // WordPress stores checkbox-like settings as scalars such as '1'; - // SSO treats enable_sso via normal option truthiness. - $this->assertTrue(wu_string_to_bool($result)); + $this->assertTrue($result); + } finally { + remove_filter('wu_get_setting', $filter, 10); + } } // ------------------------------------------------------------------ From 790bef1d7c6a80df65cf2adb44070f8725af556f Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 3 Sep 2026 12:11:06 -0600 Subject: [PATCH 6/7] fix: preserve valid block attribute schemas --- .../class-block-editor-widget-manager.php | 55 +++++++++++++++--- inc/functions/pages.php | 16 ++++-- .../Block_Editor_Widget_Manager_Test.php | 56 ++++++++++++++++--- .../Functions/Pages_Functions_Test.php | 50 +++++++++++++++++ 4 files changed, 154 insertions(+), 23 deletions(-) diff --git a/inc/builders/block-editor/class-block-editor-widget-manager.php b/inc/builders/block-editor/class-block-editor-widget-manager.php index 9cb8a2f5b..0b1e19b74 100644 --- a/inc/builders/block-editor/class-block-editor-widget-manager.php +++ b/inc/builders/block-editor/class-block-editor-widget-manager.php @@ -211,26 +211,65 @@ public function get_attributes_from_fields($element) { $defaults = $element->defaults(); $fields = $element->fields(); - $_fields = []; + $attribute_fields = []; - foreach ($defaults as $field_id => $default_value) { - $field_type = $fields[ $field_id ]['type'] ?? 'string'; + foreach ($fields as $field_id => $field) { + if ( ! is_array($field)) { + continue; + } + + $field_type = $field['type'] ?? 'text'; + + if ('group' === $field_type && ! empty($field['fields']) && is_array($field['fields'])) { + foreach ($field['fields'] as $sub_field_id => $sub_field) { + if (is_array($sub_field)) { + $attribute_fields[ $sub_field_id ] = $sub_field; + } + } + + continue; + } + + if (in_array($field_type, ['header', 'note'], true)) { + continue; + } + + $attribute_fields[ $field_id ] = $field; + } + + $attributes = []; + + foreach ($attribute_fields as $field_id => $field) { + $has_default = array_key_exists($field_id, $defaults); + $default_value = $has_default ? $defaults[ $field_id ] : ($field['value'] ?? ''); + + if ( ! $has_default && ! is_string($default_value) && is_callable($default_value)) { + $default_value = ''; + } + + $field_type = $field['type'] ?? 'text'; $type = 'string'; if ('toggle' === $field_type) { - $type = 'boolean'; + $type = 'boolean'; + $default_value = wu_string_to_bool($default_value); + } + + if (in_array($field_type, ['int', 'number'], true)) { + $type = is_float($default_value) ? 'number' : 'integer'; + $default_value = 'number' === $type ? (float) $default_value : (int) $default_value; } - if ('number' === $field_type) { - $type = 'integer'; + if ('string' === $type && is_scalar($default_value)) { + $default_value = (string) $default_value; } - $_fields[ $field_id ] = [ + $attributes[ $field_id ] = [ 'default' => $default_value, 'type' => $type, ]; } - return $_fields; + return $attributes; } } diff --git a/inc/functions/pages.php b/inc/functions/pages.php index fceb63f73..d7193b204 100644 --- a/inc/functions/pages.php +++ b/inc/functions/pages.php @@ -119,14 +119,18 @@ function wu_get_pages_as_options($default_label) { static $pages_by_context = []; $current_page_id = get_the_ID(); - $context_key = get_current_blog_id() . ':' . $current_page_id; + + if ( ! $current_page_id && is_admin()) { + $requested_page_id = wu_request('post', 0); + $current_page_id = is_scalar($requested_page_id) ? absint($requested_page_id) : 0; + } + + $context_key = implode(':', [get_current_blog_id(), $current_page_id, get_current_user_id(), determine_locale()]); if ( ! array_key_exists($context_key, $pages_by_context)) { - $pages_by_context[ $context_key ] = get_pages( - [ - 'exclude' => [$current_page_id], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude - ] - ) ?: []; + $page_query_args = $current_page_id ? ['exclude' => [$current_page_id]] : []; // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude + + $pages_by_context[ $context_key ] = get_pages($page_query_args) ?: []; } $pages_list = [0 => $default_label]; diff --git a/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php b/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php index 35aa90983..1a5a42e55 100644 --- a/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php +++ b/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php @@ -131,9 +131,13 @@ public function test_get_attributes_from_fields_does_not_evaluate_field_options( ->method('defaults') ->willReturn( [ - 'enabled' => true, - 'columns' => 4, - 'site_manage_type' => 'default', + 'enabled' => 1, + 'columns' => 4, + 'site_manage_type' => 'default', + 'page_id' => 0, + 'limit' => 0, + 'template_selection_template' => 'clean', + 'internal_state' => [], ] ); @@ -141,31 +145,65 @@ public function test_get_attributes_from_fields_does_not_evaluate_field_options( ->method('fields') ->willReturn( [ - 'enabled' => [ + 'enabled' => [ 'type' => 'toggle', 'options' => static function () { throw new \RuntimeException('Block attribute registration must not evaluate options.'); }, ], - 'columns' => ['type' => 'number'], - 'site_manage_type' => ['type' => 'select'], + 'columns' => ['type' => 'number'], + 'site_manage_type' => ['type' => 'select'], + 'page_id' => [ + 'type' => 'select', + 'options' => static function () { + throw new \RuntimeException('Block attribute registration must not evaluate options.'); + }, + ], + 'limit' => [ + 'type' => 'int', + 'value' => 10, + ], + 'template_selection' => [ + 'type' => 'group', + 'fields' => [ + 'template_selection_template' => [ + 'type' => 'select', + 'options' => static function () { + throw new \RuntimeException('Block attribute registration must not evaluate grouped options.'); + }, + ], + ], + ], + '_heading' => ['type' => 'header'], ] ); $this->assertSame( [ - 'enabled' => [ + 'enabled' => [ 'default' => true, 'type' => 'boolean', ], - 'columns' => [ + 'columns' => [ 'default' => 4, 'type' => 'integer', ], - 'site_manage_type' => [ + 'site_manage_type' => [ 'default' => 'default', 'type' => 'string', ], + 'page_id' => [ + 'default' => '0', + 'type' => 'string', + ], + 'limit' => [ + 'default' => 0, + 'type' => 'integer', + ], + 'template_selection_template' => [ + 'default' => 'clean', + 'type' => 'string', + ], ], $this->manager->get_attributes_from_fields($element) ); diff --git a/tests/WP_Ultimo/Functions/Pages_Functions_Test.php b/tests/WP_Ultimo/Functions/Pages_Functions_Test.php index 1b85327da..5404d6a5c 100644 --- a/tests/WP_Ultimo/Functions/Pages_Functions_Test.php +++ b/tests/WP_Ultimo/Functions/Pages_Functions_Test.php @@ -99,6 +99,14 @@ public function test_get_pages_as_options_memoizes_pages(): void { $post = $current_page; + $get_pages_calls = 0; + $get_pages_filter = function ($pages) use (&$get_pages_calls) { + ++$get_pages_calls; + return $pages; + }; + + add_filter('get_pages', $get_pages_filter); + try { $first = wu_get_pages_as_options('Current Page'); $second = wu_get_pages_as_options('Default'); @@ -107,8 +115,50 @@ public function test_get_pages_as_options_memoizes_pages(): void { $this->assertSame('Default', $second[0]); $this->assertSame('Example page', $first[ $page_id ]); $this->assertSame($first[ $page_id ], $second[ $page_id ]); + } finally { + remove_filter('get_pages', $get_pages_filter); + $post = $original_post; + } + + $this->assertSame(1, $get_pages_calls); + } + + /** + * Test the edited page is excluded before the admin global post is available. + */ + public function test_get_pages_as_options_uses_admin_request_page_context(): void { + + global $post; + + $original_post = $post; + $original_screen = get_current_screen(); + $current_page = self::factory()->post->create_and_get( + [ + 'post_type' => 'page', + 'post_title' => 'Edited page', + ] + ); + $other_page_id = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_title' => 'Other page', + ] + ); + + $post = null; + $_REQUEST['post'] = (string) $current_page->ID; + set_current_screen('post'); + + try { + $options = wu_get_pages_as_options('Current Page'); + + $this->assertArrayNotHasKey($current_page->ID, $options); + $this->assertSame('Other page', $options[ $other_page_id ]); } finally { $post = $original_post; + unset($_REQUEST['post']); + + set_current_screen($original_screen ? $original_screen->id : 'front'); } } From ec72b7721a44b71b1b28c12cda80470c62195c32 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 3 Sep 2026 12:44:29 -0600 Subject: [PATCH 7/7] fix: preserve callable shortcode option context --- .../class-shortcodes-admin-page.php | 3 +- .../Shortcodes_Admin_Page_Test.php | 90 +++++++++++++++++-- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/inc/admin-pages/class-shortcodes-admin-page.php b/inc/admin-pages/class-shortcodes-admin-page.php index b98dc6eaa..05a1bafe3 100644 --- a/inc/admin-pages/class-shortcodes-admin-page.php +++ b/inc/admin-pages/class-shortcodes-admin-page.php @@ -10,6 +10,7 @@ namespace WP_Ultimo\Admin_Pages; use WP_Ultimo\UI\Base_Element; +use WP_Ultimo\UI\Field; // Exit if accessed directly defined('ABSPATH') || exit; @@ -154,7 +155,7 @@ public function get_data() { $options = wu_get_isset($value, 'options', []); if (is_callable($options)) { - $options = call_user_func($options); + $options = call_user_func($options, new Field($key, $value)); } $params[ $key ]['options'] = implode(' | ', array_keys($options)); diff --git a/tests/WP_Ultimo/Admin_Pages/Shortcodes_Admin_Page_Test.php b/tests/WP_Ultimo/Admin_Pages/Shortcodes_Admin_Page_Test.php index a2c4b6e89..b59eeec6e 100644 --- a/tests/WP_Ultimo/Admin_Pages/Shortcodes_Admin_Page_Test.php +++ b/tests/WP_Ultimo/Admin_Pages/Shortcodes_Admin_Page_Test.php @@ -8,6 +8,8 @@ namespace WP_Ultimo\Admin_Pages; use WP_UnitTestCase; +use WP_Ultimo\UI\Base_Element; +use WP_Ultimo\UI\Field; /** * Test class for Shortcodes_Admin_Page. @@ -114,7 +116,7 @@ public function test_supported_panels(): void { // ------------------------------------------------------------------------- /** - * get_title returns Available Shortcodes. + * Get_title returns Available Shortcodes. */ public function test_get_title(): void { @@ -129,7 +131,7 @@ public function test_get_title(): void { // ------------------------------------------------------------------------- /** - * get_menu_title returns Available Shortcodes. + * Get_menu_title returns Available Shortcodes. */ public function test_get_menu_title(): void { @@ -144,7 +146,7 @@ public function test_get_menu_title(): void { // ------------------------------------------------------------------------- /** - * get_submenu_title returns Dashboard. + * Get_submenu_title returns Dashboard. */ public function test_get_submenu_title(): void { @@ -159,7 +161,7 @@ public function test_get_submenu_title(): void { // ------------------------------------------------------------------------- /** - * get_data returns an array. + * Get_data returns an array. */ public function test_get_data_returns_array(): void { @@ -169,7 +171,7 @@ public function test_get_data_returns_array(): void { } /** - * get_data array items have required keys. + * Get_data array items have required keys. */ public function test_get_data_items_have_required_keys(): void { @@ -189,7 +191,7 @@ public function test_get_data_items_have_required_keys(): void { } /** - * get_data params is an array. + * Get_data params is an array. */ public function test_get_data_params_is_array(): void { @@ -204,12 +206,86 @@ public function test_get_data_params_is_array(): void { $this->assertIsArray($first_item['params']); } + /** + * Callable select options receive the field instance used by normal form rendering. + */ + public function test_get_data_passes_field_to_callable_select_options(): void { + + $resolved_field = null; + $options = function (Field $field) use (&$resolved_field) { + $resolved_field = $field; + + return [ + 'first' => 'First', + 'second' => 'Second', + ]; + }; + + $element = new class($options) extends Base_Element { + + private $options; + + public function __construct($options) { + $this->id = 'callable-options-test'; + $this->options = $options; + } + + public function get_icon($context = 'block') { + return ''; + } + + public function get_title() { + return 'Callable Options Test'; + } + + public function get_description() { + return ''; + } + + public function fields() { + return [ + 'destination' => [ + 'type' => 'select', + 'options' => $this->options, + ], + ]; + } + + public function keywords() { + return []; + } + + public function defaults() { + return []; + } + + public function output($atts, $content = null) { + } + }; + + $property = new \ReflectionProperty(Base_Element::class, 'public_elements'); + $property->setAccessible(true); + $original_elements = $property->getValue(); + + try { + Base_Element::register_public_element($element); + + $data = $this->page->get_data(); + $test_data = end($data); + + $this->assertInstanceOf(Field::class, $resolved_field); + $this->assertSame('first | second', $test_data['params']['destination']['options']); + } finally { + $property->setValue(null, $original_elements); + } + } + // ------------------------------------------------------------------------- // output() // ------------------------------------------------------------------------- /** - * output renders template. + * Output renders template. */ public function test_output_renders_template(): void {