Open Source WordPress Contribution: My July 2026 Recap
July felt different from the start. I opened the month with a WooCommerce logging bug and closed it 24 pull requests later, spread across 9 different WordPress repositories. Some fixes took ten minutes. One took a full week of back and forth with a maintainer over a single line of code. This post is my open source WordPress contribution recap for July 2026. I am sharing the actual code from each pull request, not just a list of links, so you can see what changed and why it mattered. Fixing WooCommerce, One Bug at a Time WooCommerce took up the biggest chunk of my month. I merged 10 pull requests into the core plugin, ranging from a one-line performance tweak to a new REST endpoint. I will walk through each one. Log Cleanup Was Silently Leaving Files Behind The month opened with PR #66073. LogHandlerFileV2::delete_logs_before_timestamp() fetched expired log files without setting a per_page value, so it inherited the admin UI default of 20. On sites with more than 20 expired log sources, the daily cleanup cron deleted only the first 20 and left the rest sitting in wp-content/uploads/wc-logs/ forever. The fix batches the deletion instead of trusting a single page: // BEFORE: $files = $this->file_controller->get_files( [ 'before' => $timestamp ] ); foreach ( $files as $file ) { $this->delete_log_file( $file ); } // AFTER (batched with a no-progress guard): do { $files = $this->file_controller->get_files( [ 'before' => $timestamp, 'per_page' => 100, ] ); $deleted_this_pass = 0; foreach ( $files as $file ) { if ( $this->delete_log_file( $file ) ) { $deleted_this_pass++; } } } while ( $files && $deleted_this_pass > 0 ); A site with 101 leftover log files now gets all of them removed instead of just 20. I added a no-progress guard so the loop stops if a batch cannot be deleted, which prevents an infinite loop on a stuck file. The Order List Table Cache Ignored Custom Filters PR #66207 fixed a subtler bug. ListTable::prepare_items() decides whether to skip SQL_CALC_FOUND_ROWS by checking the query args before any filters run. If a developer hooked woocommerce_order_list_table_prepare_items_query_args to add a meta_query , the cache fast path stayed blind to it and returned a stale total. // BEFORE (checked pre-filter args): if ( empty( array_diff( array_keys( $this->order_query_args ), $safe_keys ) ) ) { $args['no_found_rows'] = true; } // AFTER (checks post-filter args): if ( empty( array_diff( array_keys( $order_query_args ), $safe_keys ) ) ) { $args['no_found_rows'] = true; } Moving the check to run after the filter means any key a plugin adds correctly disables the cache shortcut. Small change, but it stopped merchants from seeing wrong order counts whenever a custom query filter was active. Combining Six Requests Into One The most involved WooCommerce PR of the month was PR #66276. Every wp-admin page load fired 6 separate wc-analytics REST requests just to populate the Activity Panel bell icon and the "Things to do next" homescreen widget. Three endpoints, each called twice by two different components, with no shared cache between them. I added a single combined endpoint instead: class ActivityPanelCounts extends \WC_REST_Data_Controller { protected $namespace = 'wc-analytics'; protected $rest_base = 'activity-panel/counts'; public function get_counts( $request ) { return rest_ensure_response( [ 'orders_to_fulfill_count' => $this->get_count_via( '/wc-analytics/orders', [ 'page' => 1, 'per_page' => 1, 'status' => $request->get_param( 'order_statuses' ), '_fields' => [ 'id' ], ] ), 'reviews_to_moderate_count' => $this->get_count_via( '/wc-analytics/products/reviews', [ 'page' => 1, 'per_page' => 1, 'status' => $request->get_param( 'review_status' ), ] ), ] ); } } A matching activityPanelStore selector in @woocommerce/data means all three Activity Panel components now read from one place. The resolution cache collapses six network requests into one per page load, and nothing about the counting logic changed since the new endpoint just delegates to the existing ones internally. If you want the fuller backstory on how I approach WooCommerce internals like this, my June 2026 open source recap covers a similar REST endpoint consolidation from the month before. Guest Orders Finally Show a Name PR #66279 fixed something that bugged store owners for a while. The WooCommerce Home Orders panel shows "Order #123 Customer Name" for actionable orders, but guest checkouts have no customer_id to look up, so the name always came back blank. // BEFORE: only checked the registered customer record const customerName = order.customer ? order.customer.name : ''; // AFTER: falls back to billing details for guest orders const customerName = order.customer ? order.customer.name : [ order.billing?.first_name, order.billing?.last_name ] .filter( Boolean ) .join( ' ' ); The order response already carried the billing address. I just added billing to the requested fields and used it as a fallback. Registered customers still get their name linked to their profile, guests just show as plain text like the rest of the row. Product Taxonomy Boxes Hidden by Default PR #65990 addressed a first-run annoyance. On a user's first visit to Appearance โ Menus, WordPress hides every meta box except Pages, Posts, Custom Links, and Categories. That default list swallowed the Product Categories, Product Tags, and Brands boxes too, so new users had to dig into Screen Options before they could add these to a menu. public function filter_default_nav_menu_hidden_meta_boxes( $result, $option, $user ) { global $wp_meta_boxes; if ( false !== $result || ! $user || ! isset( $wp_meta_boxes['nav-menus'] ) ) { return $result; } $visible = [ 'add-post-type-page', 'add-post-type-post', 'add-custom-links', 'add-category', 'add-product_cat', 'add-product_tag', 'woocommerce_endpoints_nav_link', ]; if ( taxonomy_exists( 'product_brand' ) ) { $visible[] = 'add-product_brand'; } $hidden = []; foreach ( $wp_meta_boxes['nav-menus'] as $priorities ) { foreach ( (array) $priorities as $boxes ) { foreach ( (array) $boxes as $box ) { if ( isset( $box['id'] ) && ! in_array( $box['id'], $visible, true ) ) { $hidden[] = $box['id']; } } } } return $hidden; } This hooks get_user_option_metaboxhidden_nav-menus so it only fires when a user has no saved preference yet. Existing users with a saved Screen Options choice are never touched. A Cache Poisoning Bug in the Brand Nav Widget PR #65947 was the trickiest bug to trace this month. The Brand Nav widget's filter_out_cats() method hooks woocommerce_product_subcategories_args and returns an empty taxonomy when a brand filter is active in the URL. That empty-taxonomy query returns zero rows, and WooCommerce cached those zero rows under the same key used by regular, non-filtered requests. Every visitor after that, brand filter or not, read the poisoned cache and saw no subcategories at all. // BEFORE: always cached the result, even an empty taxonomy query wp_cache_set( $cache_key, $result, 'product_cat' ); // AFTER: only cache when the query actually resolved to a taxonomy if ( ! empty( $args['taxonomy'] ) ) { wp_cache_set( $cache_key, $result, 'product_cat' ); } An empty-taxonomy query never produces a meaningful result, so storing it just poisoned the shared cache for no benefit. Skipping the cache write when taxonomy is empty fixed it for every visitor, not just the one who triggered the brand filter. Nine Default Colors for Visual Attributes PR #65923 added a small quality-of-life feature. When a merchant creates a new "Color / image" attribute, the terms list starts empty and they have to add each color by hand. This seeds 9 common colors automatically: private static function get_default_color_terms(): array { return [ 'black' => [ 'label' => __( 'Black', 'woocommerce' ), 'color' => '#121212' ], 'white' => [ 'label' => __( 'White', 'woocommerce' ), 'color' => '#FFFFFF' ], 'red' => [ 'label' => __( 'Red', 'woocommerce' ), 'color' => '#D32F2F' ], 'blue' => [ 'label' => __( 'Blue', 'woocommerce' ), 'color' => '#1976D2' ], 'green' => [ 'label' => __( 'Green', 'woocommerce' ), 'color' => '#388E3C' ], // gray, yellow, pink, and brown follow the same pattern ]; } The seeder only runs from WC_Admin_Attributes::process_add_attribute() , so it fires when a merchant creates an attribute through the UI, not through programmatic wc_create_attribute() calls or CSV imports. That distinction mattered during review since nobody wants a bulk import silently injecting terms nobody asked for. Rounding Out the Month: Layout, Cron Status, and jQuery Cleanup Three smaller WooCommerce fixes closed out the batch: - PR #66280 fixed missing layout styles on the product_brand_thumbnails_description shortcode. Its stylesheet never definedlist-style: none or a clearfix, so the shortcode rendered as a bare bulleted list instead of a grid. I also clamped thecolumns argument withmax( 1, absint( $args['columns'] ) ) so an invalid value likecolumns="abc" cannot throw aDivisionByZeroError . - PR #66188 fixed a false "Not scheduled" message on the WooCommerce Status page. The Daily Cron check looked for the old wp_next_scheduled('wc_admin_daily') hook, but that migrated to Action Scheduler aswc_admin_daily_wrapper a while back. Swapping the check toas_next_scheduled_action('wc_admin_daily_wrapper') fixed the false alarm on fresh installs. - PR #66273 replaced deprecated jQuery .focus() shorthand calls with.trigger( focus ) across six legacy JS files, clearing theJQMIGRATE: jQuery.fn.focus() event shorthand is deprecated warning that showed up on classic checkout. If you want to try submitting a fix like these yourself, the WooCommerce Contributing Guidelines walk through the coding standards and PR process the core team expects. Security Fixes for LifterLMS LifterLMS gave me 7 merged pull requests in July, and most of them came from running the WordPress Plugin Check tool against the plugin and working through what it flagged. Two Open Redirect Fixes for the Same Bug PR #3201
Comments
No comments yet. Start the discussion.