From a89e31681cdb6e45ffd579bcf4f9eb8252167bf5 Mon Sep 17 00:00:00 2001 From: Jeff Shaikh Date: Wed, 6 May 2026 20:08:12 -0700 Subject: [PATCH 1/5] fix(settings,wizard,fields): prevent Closure rendering fatals and fix credits_custom_html [object Object] Fixes multiple rendering paths where callable/Closure values could reach esc_html(), esc_attr(), or wp_kses() and cause: "Object of class Closure could not be converted to string". Changes include: - Normalize wizard section display values before rendering (titles, descriptions, labels, booleans). - Harden setup default wizard template against callable/non-string values. - Expand Field attribute resolution to safely resolve callable attributes and nested callable html attrs. - Ensure class/wrapper class returns use resolved values. - Add defensive normalization in textarea field template. - Resolve callable defaults in settings value/display_value fallbacks. - Fix credits_custom_html default/value/display_value handling so textarea no longer shows "[object Object]". - Add fallback recovery for previously stored "[object Object]" value in credits_custom_html rendering. Validation: - PHP lint passed for all modified files: - inc/admin-pages/class-wizard-admin-page.php - views/wizards/setup/default.php - inc/ui/class-field.php - views/admin-pages/fields/field-textarea.php - inc/class-settings.php - inc/class-credits.php --- inc/admin-pages/class-wizard-admin-page.php | 101 +++++++++++++++++++- inc/class-credits.php | 36 +++++-- inc/class-settings.php | 12 ++- inc/ui/class-field.php | 80 +++++++++++++--- views/admin-pages/fields/field-textarea.php | 50 +++++++++- views/wizards/setup/default.php | 86 +++++++++++++++-- 6 files changed, 329 insertions(+), 36 deletions(-) diff --git a/inc/admin-pages/class-wizard-admin-page.php b/inc/admin-pages/class-wizard-admin-page.php index 6efaec516..a49354142 100644 --- a/inc/admin-pages/class-wizard-admin-page.php +++ b/inc/admin-pages/class-wizard-admin-page.php @@ -85,7 +85,7 @@ public function page_loaded() { /* * Sets current section for future reference. */ - $this->current_section = $sections[ $this->get_current_section() ]; + $this->current_section = $this->prepare_section_for_display($sections[ $this->get_current_section() ]); /* * Process save, if necessary @@ -203,7 +203,7 @@ public function output() { 'page' => $this, 'logo' => $this->get_logo(), 'labels' => $this->get_labels(), - 'sections' => $this->get_sections(), + 'sections' => $this->prepare_sections_for_display($this->get_sections()), 'current_section' => $this->get_current_section(), 'classes' => $this->get_classes(), 'clickable_navigation' => $this->clickable_navigation, @@ -212,6 +212,103 @@ public function output() { ); } + /** + * Resolves a single display value for wizard sections. + * + * @param mixed $value The raw value. + * @param bool $cast_to_bool Whether the resolved value should be cast to boolean. + * @return mixed + */ + protected function resolve_section_display_value($value, bool $cast_to_bool = false) { + + if (is_callable($value)) { + $value = call_user_func($value); + } + + if ($cast_to_bool) { + return (bool) $value; + } + + if (null === $value) { + return ''; + } + + if (is_scalar($value)) { + return (string) $value; + } + + if (is_object($value) && method_exists($value, '__toString')) { + return (string) $value; + } + + return ''; + } + + /** + * Resolves dynamic section values used for display while preserving callbacks + * responsible for handling the view, save routine, and field generation. + * + * @param array $section The raw section definition. + * @return array + */ + protected function prepare_section_for_display(array $section): array { + + $display_keys = [ + 'title', + 'description', + 'content', + 'next_label', + 'back_label', + 'skip_label', + ]; + + foreach ($display_keys as $display_key) { + if (array_key_exists($display_key, $section)) { + $section[ $display_key ] = $this->resolve_section_display_value($section[ $display_key ]); + } + } + + $boolean_keys = [ + 'disable_next', + 'back', + 'skip', + 'next', + ]; + + foreach ($boolean_keys as $boolean_key) { + if (array_key_exists($boolean_key, $section)) { + $section[ $boolean_key ] = $this->resolve_section_display_value($section[ $boolean_key ], true); + } + } + + if ( ! empty($section['sub-sections']) && is_array($section['sub-sections'])) { + foreach ($section['sub-sections'] as $sub_section_key => $sub_section) { + if (is_array($sub_section)) { + $section['sub-sections'][ $sub_section_key ] = $this->prepare_section_for_display($sub_section); + } + } + } + + return $section; + } + + /** + * Resolves the display values of all wizard sections. + * + * @param array $sections Raw wizard sections. + * @return array + */ + protected function prepare_sections_for_display(array $sections): array { + + foreach ($sections as $section_key => $section) { + if (is_array($section)) { + $sections[ $section_key ] = $this->prepare_section_for_display($section); + } + } + + return $sections; + } + /** * Return the classes used in the main wrapper. * diff --git a/inc/class-credits.php b/inc/class-credits.php index 8a2af4732..6fdf1b889 100644 --- a/inc/class-credits.php +++ b/inc/class-credits.php @@ -100,16 +100,16 @@ public function register_settings(): void { 'desc' => __('HTML allowed. Use any text or link you prefer.', 'ultimate-multisite'), 'type' => 'textarea', 'allow_html' => true, - 'default' => function () { - $name = (string) get_network_option(null, 'site_name'); - $name = $name ?: __('this network', 'ultimate-multisite'); - $url = function_exists('get_main_site_id') ? get_site_url(get_main_site_id()) : network_home_url('/'); - return sprintf( - /* translators: 1: Opening anchor tag with URL to main site. 2: Network name. */ - __('Powered by %1$s%2$s', 'ultimate-multisite'), - '', - esc_html($name) - ); + 'default' => [$this, 'get_default_custom_credit_html'], + 'value' => function () { + $html = (string) wu_get_setting('credits_custom_html', $this->get_default_custom_credit_html()); + + return '[object Object]' === trim($html) ? $this->get_default_custom_credit_html() : $html; + }, + 'display_value' => function () { + $html = (string) wu_get_setting('credits_custom_html', $this->get_default_custom_credit_html()); + + return '[object Object]' === trim($html) ? $this->get_default_custom_credit_html() : $html; }, 'placeholder' => __('Powered by Your Company', 'ultimate-multisite'), 'require' => [ @@ -121,6 +121,22 @@ public function register_settings(): void { ); } + /** + * Returns the default custom credit HTML. + */ + protected function get_default_custom_credit_html(): string { + $name = (string) get_network_option(null, 'site_name'); + $name = $name ?: __('this network', 'ultimate-multisite'); + $url = function_exists('get_main_site_id') ? get_site_url(get_main_site_id()) : network_home_url('/'); + + return sprintf( + /* translators: 1: Opening anchor tag with URL to main site. 2: Network name. */ + __('Powered by %1$s%2$s', 'ultimate-multisite'), + '', + esc_html($name) + ); + } + /** * Build the credit text (HTML) based on settings. */ diff --git a/inc/class-settings.php b/inc/class-settings.php index 5e53efbdb..082243415 100644 --- a/inc/class-settings.php +++ b/inc/class-settings.php @@ -515,9 +515,17 @@ function ($fields) use ($field_slug, $atts) { */ $declared_default = $atts['default'] ?? null; + $get_resolved_default = static function () use ($declared_default) { + return is_callable($declared_default) ? call_user_func($declared_default) : $declared_default; + }; + if (null === $declared_default) { $type = $atts['type'] ?? 'text'; $declared_default = in_array($type, ['toggle', 'checkbox'], true) ? false : ''; + + $get_resolved_default = static function () use ($declared_default) { + return $declared_default; + }; } $atts = wp_parse_args( @@ -532,8 +540,8 @@ function ($fields) use ($field_slug, $atts) { 'wrapper_html_attr' => [], 'require' => [], 'html_attr' => [], - 'value' => fn() => wu_get_setting($field_slug, $declared_default), - 'display_value' => fn() => wu_get_setting($field_slug, $declared_default), + 'value' => fn() => wu_get_setting($field_slug, $get_resolved_default()), + 'display_value' => fn() => wu_get_setting($field_slug, $get_resolved_default()), 'img' => function () use ($field_slug) { $img_id = wu_get_setting($field_slug); diff --git a/inc/ui/class-field.php b/inc/ui/class-field.php index aa0de9e12..88e0be95a 100644 --- a/inc/ui/class-field.php +++ b/inc/ui/class-field.php @@ -64,7 +64,7 @@ * @property array suffix_html_attr * @since 2.0.0 */ -class Field implements \JsonSerializable { +class lField implements \JsonSerializable { /** * Holds the attributes of this field. @@ -277,21 +277,32 @@ public function __get($att) { 'require', 'validation', 'value', + 'placeholder', + 'classes', + 'wrapper_classes', 'html_attr', + 'wrapper_html_attr', + 'prefix_html_attr', + 'suffix_html_attr', + 'prefix', + 'suffix', + 'button', + 'href', 'img', ]; $attr = $this->atts[ $att ] ?? false; - $allow_callable_prefix = is_string($attr) && str_starts_with($attr, 'wu_get_') && is_callable($attr); - $allow_callable_method = is_array($attr) && is_callable($attr); - - if (in_array($att, $allowed_callable, true) && ($allow_callable_prefix || $allow_callable_method || is_a($attr, \Closure::class))) { - $attr = call_user_func($attr, $this); + if (in_array($att, $allowed_callable, true)) { + $attr = $this->resolve_attribute_value($attr); } - if ('wrapper_classes' === $att && isset($this->atts['wrapper_html_attr']['v-show'])) { - $this->atts['wrapper_classes'] .= ' wu-requires-other'; + if ('wrapper_classes' === $att) { + $attr = is_string($attr) ? $attr : ''; + + if (isset($this->atts['wrapper_html_attr']['v-show']) && ! str_contains($attr, 'wu-requires-other')) { + $attr .= ' wu-requires-other'; + } } if ('type' === $att && 'submit' === $this->atts[ $att ]) { @@ -303,11 +314,13 @@ public function __get($att) { } if ('wrapper_classes' === $att && is_a($this->form, '\\WP_Ultimo\\UI\\Form')) { - return $this->form->field_wrapper_classes . ' ' . $this->atts['wrapper_classes']; + return trim($this->form->field_wrapper_classes . ' ' . $attr); } if ('classes' === $att && is_a($this->form, '\\WP_Ultimo\\UI\\Form')) { - return $this->form->field_classes . ' ' . $this->atts['classes']; + $attr = is_string($attr) ? $attr : ''; + + return trim($this->form->field_classes . ' ' . $attr); } if ('title' === $att && false === $attr && isset($this->atts['name'])) { @@ -317,6 +330,47 @@ public function __get($att) { return $attr; } + /** + * Checks if the given attribute value should be resolved as a callable. + * + * @param mixed $attr The attribute value. + * @return bool + */ + protected function is_resolvable_callable($attr): bool { + + $allow_callable_prefix = is_string($attr) && str_starts_with($attr, 'wu_get_') && is_callable($attr); + $allow_callable_method = is_array($attr) && is_callable($attr); + + return $allow_callable_prefix || $allow_callable_method || is_a($attr, \Closure::class); + } + + /** + * Resolves dynamic attribute values and nested callable entries. + * + * @param mixed $attr The attribute value. + * @return mixed + */ + protected function resolve_attribute_value($attr) { + + if ($this->is_resolvable_callable($attr)) { + $attr = call_user_func($attr, $this); + } + + if (is_array($attr)) { + foreach ($attr as $key => $value) { + $attr[ $key ] = $this->resolve_attribute_value($value); + } + + return $attr; + } + + if (is_object($attr) && method_exists($attr, '__toString')) { + return (string) $attr; + } + + return $attr; + } + /** * Returns the list of sanitization callbacks for each field type * @@ -457,9 +511,7 @@ protected function validate_textarea_field($value) { */ public function print_html_attributes(): void { - if (is_callable($this->atts['html_attr'])) { - $this->atts['html_attr'] = call_user_func($this->atts['html_attr']); - } + $this->atts['html_attr'] = $this->resolve_attribute_value($this->atts['html_attr']); unset($this->atts['html_attr']['class']); $attributes = $this->atts['html_attr']; @@ -492,6 +544,8 @@ public function print_html_attributes(): void { */ public function print_wrapper_html_attributes(): void { + $this->atts['wrapper_html_attr'] = $this->resolve_attribute_value($this->atts['wrapper_html_attr']); + $attributes = $this->atts['wrapper_html_attr']; unset($this->atts['wrapper_html_attr']['class']); diff --git a/views/admin-pages/fields/field-textarea.php b/views/admin-pages/fields/field-textarea.php index 7f2a543a1..66dcb4758 100644 --- a/views/admin-pages/fields/field-textarea.php +++ b/views/admin-pages/fields/field-textarea.php @@ -6,8 +6,54 @@ */ defined('ABSPATH') || exit; +$field = $field ?? null; + +if ( ! $field instanceof \WP_Ultimo\UI\Field) { + return; +} + +$wrapper_classes = $field->wrapper_classes; + +if (is_callable($wrapper_classes)) { + $wrapper_classes = call_user_func($wrapper_classes, $field); +} + +$wrapper_classes = is_scalar($wrapper_classes) || (is_object($wrapper_classes) && method_exists($wrapper_classes, '__toString')) + ? (string) $wrapper_classes + : ''; + +$field_classes = $field->classes; + +if (is_callable($field_classes)) { + $field_classes = call_user_func($field_classes, $field); +} + +$field_classes = is_scalar($field_classes) || (is_object($field_classes) && method_exists($field_classes, '__toString')) + ? (string) $field_classes + : ''; + +$placeholder = $field->placeholder; + +if (is_callable($placeholder)) { + $placeholder = call_user_func($placeholder, $field); +} + +$placeholder = is_scalar($placeholder) || (is_object($placeholder) && method_exists($placeholder, '__toString')) + ? (string) $placeholder + : ''; + +$value = $field->value; + +if (is_callable($value)) { + $value = call_user_func($value, $field); +} + +$value = is_scalar($value) || (is_object($value) && method_exists($value, '__toString')) + ? (string) $value + : ''; + ?> -
  • print_wrapper_html_attributes(); ?>> +
  • print_wrapper_html_attributes(); ?>>
    @@ -27,7 +73,7 @@ ?> - +

    - +

    - +

    - []]); ?> + []]); ?>

    - +
    - +
    - +
    From 08742ee80b4169d50402223df4a27a60624446f0 Mon Sep 17 00:00:00 2001 From: Jeff Shaikh Date: Wed, 6 May 2026 21:53:16 -0700 Subject: [PATCH 2/5] fix(fields): correct lField class typo --- inc/ui/class-field.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/ui/class-field.php b/inc/ui/class-field.php index 88e0be95a..da562e1b1 100644 --- a/inc/ui/class-field.php +++ b/inc/ui/class-field.php @@ -64,7 +64,7 @@ * @property array suffix_html_attr * @since 2.0.0 */ -class lField implements \JsonSerializable { +class Field implements \JsonSerializable { /** * Holds the attributes of this field. From 1a31ec113e6891c2340e8f1e340c5733741f39b0 Mon Sep 17 00:00:00 2001 From: Jeff Shaikh Date: Wed, 6 May 2026 22:14:08 -0700 Subject: [PATCH 3/5] fix(fields,wizard,credits): address CodeRabbit nitpicks and cleanup --- inc/class-credits.php | 11 ++-- inc/functions/template.php | 21 +++++++ inc/ui/class-field.php | 3 + views/admin-pages/fields/field-textarea.php | 45 ++------------ views/wizards/setup/default.php | 65 ++------------------- 5 files changed, 41 insertions(+), 104 deletions(-) diff --git a/inc/class-credits.php b/inc/class-credits.php index 6fdf1b889..e109aa479 100644 --- a/inc/class-credits.php +++ b/inc/class-credits.php @@ -127,13 +127,14 @@ public function register_settings(): void { protected function get_default_custom_credit_html(): string { $name = (string) get_network_option(null, 'site_name'); $name = $name ?: __('this network', 'ultimate-multisite'); - $url = function_exists('get_main_site_id') ? get_site_url(get_main_site_id()) : network_home_url('/'); + $url = is_multisite() ? get_site_url(get_main_site_id()) : network_home_url('/'); return sprintf( - /* translators: 1: Opening anchor tag with URL to main site. 2: Network name. */ - __('Powered by %1$s%2$s', 'ultimate-multisite'), + /* translators: 1: Opening anchor tag, 2: Network name, 3: Closing anchor tag. */ + __('Powered by %1$s%2$s%3$s', 'ultimate-multisite'), '', - esc_html($name) + esc_html($name), + '' ); } @@ -186,7 +187,7 @@ protected function build_custom_credit(): string { $logo_html = $this->get_company_logo_html(); $network_name = (string) get_network_option(null, 'site_name'); $network_name = $network_name ?: __('this network', 'ultimate-multisite'); - $network_url = function_exists('get_main_site_id') ? get_site_url(get_main_site_id()) : network_home_url('/'); + $network_url = is_multisite() ? get_site_url(get_main_site_id()) : network_home_url('/'); $text = sprintf( '%s', diff --git a/inc/functions/template.php b/inc/functions/template.php index 0cbb213bf..9e6c99269 100644 --- a/inc/functions/template.php +++ b/inc/functions/template.php @@ -89,3 +89,24 @@ function wu_get_template_contents($view, $args = [], $default_view = false) { return ob_get_clean(); } + +/** + * Resolves template values to safe strings for rendering. + * + * @param mixed $value The raw value. + * @param mixed $context Optional context passed when invoking callables. + * @return string + */ +function wu_resolve_template_string($value, $context = null): string { + + if (is_callable($value)) { + $value = null !== $context ? call_user_func($value, $context) : call_user_func($value); + } + + if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) { + return (string) $value; + } + + return ''; +} + diff --git a/inc/ui/class-field.php b/inc/ui/class-field.php index da562e1b1..38b8b1cca 100644 --- a/inc/ui/class-field.php +++ b/inc/ui/class-field.php @@ -347,6 +347,9 @@ protected function is_resolvable_callable($attr): bool { /** * Resolves dynamic attribute values and nested callable entries. * + * Callable values are first validated by is_resolvable_callable() and, + * when invoked, receive the current Field instance as their first argument. + * * @param mixed $attr The attribute value. * @return mixed */ diff --git a/views/admin-pages/fields/field-textarea.php b/views/admin-pages/fields/field-textarea.php index 66dcb4758..330badaa0 100644 --- a/views/admin-pages/fields/field-textarea.php +++ b/views/admin-pages/fields/field-textarea.php @@ -12,45 +12,10 @@ return; } -$wrapper_classes = $field->wrapper_classes; - -if (is_callable($wrapper_classes)) { - $wrapper_classes = call_user_func($wrapper_classes, $field); -} - -$wrapper_classes = is_scalar($wrapper_classes) || (is_object($wrapper_classes) && method_exists($wrapper_classes, '__toString')) - ? (string) $wrapper_classes - : ''; - -$field_classes = $field->classes; - -if (is_callable($field_classes)) { - $field_classes = call_user_func($field_classes, $field); -} - -$field_classes = is_scalar($field_classes) || (is_object($field_classes) && method_exists($field_classes, '__toString')) - ? (string) $field_classes - : ''; - -$placeholder = $field->placeholder; - -if (is_callable($placeholder)) { - $placeholder = call_user_func($placeholder, $field); -} - -$placeholder = is_scalar($placeholder) || (is_object($placeholder) && method_exists($placeholder, '__toString')) - ? (string) $placeholder - : ''; - -$value = $field->value; - -if (is_callable($value)) { - $value = call_user_func($value, $field); -} - -$value = is_scalar($value) || (is_object($value) && method_exists($value, '__toString')) - ? (string) $value - : ''; +$wrapper_classes = wu_resolve_template_string($field->wrapper_classes, $field); +$field_classes = wu_resolve_template_string($field->classes, $field); +$placeholder = wu_resolve_template_string($field->placeholder, $field); +$value = wu_resolve_template_string($field->value, $field); ?>
  • print_wrapper_html_attributes(); ?>> @@ -73,7 +38,7 @@ ?> - +

    From 0d7949bacc1e7ef2ec96a55ff5d749b4400bb3a6 Mon Sep 17 00:00:00 2001 From: Jeff Shaikh Date: Thu, 7 May 2026 09:22:35 -0700 Subject: [PATCH 4/5] fix(field): guard wrapper_html_attr array access against Closure values Resolve `wrapper_html_attr` through `resolve_attribute_value()` before checking for a `v-show` key to prevent a fatal when the attribute is registered as a Closure. --- inc/ui/class-field.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/inc/ui/class-field.php b/inc/ui/class-field.php index 38b8b1cca..4efaa741b 100644 --- a/inc/ui/class-field.php +++ b/inc/ui/class-field.php @@ -299,8 +299,9 @@ public function __get($att) { if ('wrapper_classes' === $att) { $attr = is_string($attr) ? $attr : ''; + $wrapper_html_attr = $this->resolve_attribute_value($this->atts['wrapper_html_attr'] ?? []); - if (isset($this->atts['wrapper_html_attr']['v-show']) && ! str_contains($attr, 'wu-requires-other')) { + if (is_array($wrapper_html_attr) && isset($wrapper_html_attr['v-show']) && ! str_contains($attr, 'wu-requires-other')) { $attr .= ' wu-requires-other'; } } From 1e5b695568d45996a5647c0c33eb4ece5eea3c9b Mon Sep 17 00:00:00 2001 From: Jeff Shaikh Date: Thu, 7 May 2026 09:28:54 -0700 Subject: [PATCH 5/5] fix(credits): apply [object Object] normalisation in runtime credit rendering Extracted repeated corruption-guard logic into normalize_custom_credit_html() and applied it in build_credit_html() so footer output never prints the corrupted token even when the setting was stored before the Closure-leak fix. --- inc/class-credits.php | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/inc/class-credits.php b/inc/class-credits.php index e109aa479..097e7dbba 100644 --- a/inc/class-credits.php +++ b/inc/class-credits.php @@ -101,15 +101,15 @@ public function register_settings(): void { 'type' => 'textarea', 'allow_html' => true, 'default' => [$this, 'get_default_custom_credit_html'], - 'value' => function () { - $html = (string) wu_get_setting('credits_custom_html', $this->get_default_custom_credit_html()); - - return '[object Object]' === trim($html) ? $this->get_default_custom_credit_html() : $html; + 'value' => function () { + return $this->normalize_custom_credit_html( + wu_get_setting('credits_custom_html', $this->get_default_custom_credit_html()) + ); }, 'display_value' => function () { - $html = (string) wu_get_setting('credits_custom_html', $this->get_default_custom_credit_html()); - - return '[object Object]' === trim($html) ? $this->get_default_custom_credit_html() : $html; + return $this->normalize_custom_credit_html( + wu_get_setting('credits_custom_html', $this->get_default_custom_credit_html()) + ); }, 'placeholder' => __('Powered by Your Company', 'ultimate-multisite'), 'require' => [ @@ -121,6 +121,22 @@ public function register_settings(): void { ); } + /** + * Normalizes a stored custom credit HTML value. + * + * Returns the default credit HTML when the stored value is the literal + * string '[object Object]', which can appear when a Closure leaked into + * the Vue/settings JSON state before this bug was fixed. + * + * @param mixed $html The raw stored value. + * @return string + */ + protected function normalize_custom_credit_html($html): string { + $html = is_string($html) ? $html : (string) $html; + + return '[object Object]' === trim($html) ? $this->get_default_custom_credit_html() : $html; + } + /** * Returns the default custom credit HTML. */ @@ -154,7 +170,9 @@ protected function build_credit_html(): string { return $this->build_custom_credit(); case 'html': - $html = (string) wu_get_setting('credits_custom_html', ''); + $html = $this->normalize_custom_credit_html( + wu_get_setting('credits_custom_html', $this->get_default_custom_credit_html()) + ); return wp_kses_post($html); default: