Developers

Hook your plugin into the save cycle

Gutenberg For WC Products is a framework. If your plugin saves product data through its own AJAX request, register a "saver" and your data will persist when the user presses Update in the block editor — no forking, no REST endpoints, no fields serialized by hand. The bundled WooCommerce variations fix is itself just a saver registered through this same public API.

02

Do you even need this?

A standard meta box whose fields are saved on save_post by reading $_POST already persists on Update through WordPress' meta box compatibility layer — it needs nothing from this plugin. You only need a saver when your save is bound to the classic form submit through a private AJAX channel (its own action, nonce and dirty-tracking), exactly like WooCommerce variations.

Already persists — do nothingNeeds a saver
Meta box fields saved on save_post via $_POST.Saves bound to the classic form submit through your own AJAX (custom tab/panel with a Save button, dirty-tracking, admin-ajax/REST handler).

03

Why your AJAX save breaks in Gutenberg

In the classic editor, your save runs because it is bound to the #post form's submit event. The block editor has no such form: "Update" saves the post over REST, your submit handler never fires, and your AJAX call is never sent. The reliable fix is to re-trigger your own save on the Gutenberg save cycle — which is exactly what a saver does. This plugin cannot auto-detect and fire your private AJAX channel (it has its own action, nonce and dirty-tracking), so you opt in by registering a saver.

04

The pattern, in three parts

  1. A dirty marker your UI adds when the user changes something.
  2. Your own save (a button that triggers your AJAX), unchanged from the classic editor.
  3. A saver that clicks that button when the marker is present.

You already have parts 1 and 2. Part 3 is the single line that makes it work in Gutenberg.

05

JavaScript API — the savers registry

A saver runs every time Gutenberg finishes saving a product (autosaves excluded). It re-triggers a native save routine the block editor would otherwise skip. Enqueue your script with gfwcp-save-bridge as a dependency, so window.gfwcp exists before your code runs. There are two ways to register a saver: the declarative registerSaver API (recommended) and the low-level gfwcp.productSavers filter (escape hatch).

Declarative: dirty rows + a button

The most common case — "when these rows are dirty, click this button" — needs no logic. The generated saver is idempotent: if no node matches dirtySelector it does nothing (returns false); otherwise it enables and clicks buttonSelector (returns true).

register-saver.js
window.gfwcp.registerSaver( {
    id: 'my-plugin/my-panel',            // unique; duplicate ids are ignored
    dirtySelector: '#my_panel .my-row.needs-update',
    buttonSelector: 'button.my-plugin-save'
} );

Full control: your own callback

When your save is not a simple button click (a custom AJAX call, several steps), pass run. Return true only when you acted; return false to stay idempotent.

custom-saver.js
window.gfwcp.registerSaver( {
    id: 'my-plugin/custom',
    run: function () {
        if ( ! jQuery( '#my_panel .dirty' ).length ) {
            return false; // nothing to do — stay idempotent
        }
        // trigger your own save here; return true if you acted.
        return true;
    }
} );

The gfwcp.productSavers filter (low level)

The registry is applyFilters( 'gfwcp.productSavers', savers ) on every save, where savers already contains everything registered with registerSaver. Use the filter to add a plain function saver or to inspect/modify the list. The filter must return an array; a non-array return is treated as "no savers".

product-savers-filter.js
wp.hooks.addFilter(
    'gfwcp.productSavers',
    'my-plugin/my-saver',
    function ( savers ) {
        savers.push( function () {
            if ( ! jQuery( '#my_plugin_panel .my-row.needs-update' ).length ) {
                return false; // nothing to do — stay idempotent
            }
            jQuery( 'button.my-plugin-save' ).trigger( 'click' );
            return true;
        } );
        return savers;
    }
);

Notes

  • The registry is re-evaluated on every save, so savers added after page load still run.
  • Keep savers idempotent: act only when there is dirty state, mirroring WooCommerce’s native behavior. A panel that was never opened has no rows, so the saver is a safe no-op.
  • A saver that throws is caught and does not stop the others; set window.GFWCP_DEBUG = true to surface those errors via console.warn. Still handle your own errors.

06

Integrating a plugin that saves via AJAX

This is the complete, runnable pattern for plugins whose product data is saved through their own AJAX request. It mirrors the bundled demo extension used by the test suite, so it is known to work. Three parts: render the panel with a dirty row and your own save button; handle the AJAX save with nonce + capability + sanitize; enqueue the saver after the save-bridge.

PHP — render the panel, AJAX save, enqueue the saver

plugin-ajax.php
<?php
defined( 'ABSPATH' ) || exit;

// 1) Render your panel (a meta box here) with a dirty row and your own save button.
add_action( 'add_meta_boxes_product', function () {
    add_meta_box(
        'myplugin_panel_box',
        'My Plugin Panel',
        function ( $post ) {
            $value = get_post_meta( $post->ID, '_myplugin_value', true );
            echo '<div id="myplugin_panel">';
            printf(
                '<input type="text" id="myplugin_value" class="widefat" value="%s" />',
                esc_attr( $value )
            );
            echo '<p class="myplugin-row">';
            echo '<button type="button" class="button myplugin-save">Save value</button>';
            echo '</p></div>';
        },
        'product',
        'side'
    );
} );

// 2) Your own AJAX save: nonce + capability + sanitize input + (escape on output).
add_action( 'wp_ajax_myplugin_save', function () {
    check_ajax_referer( 'myplugin_ajax', 'nonce' );

    $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
    if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
        wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
    }

    $value = isset( $_POST['value'] ) ? sanitize_text_field( wp_unslash( $_POST['value'] ) ) : '';
    update_post_meta( $post_id, '_myplugin_value', $value );

    wp_send_json_success( array( 'value' => $value ) );
} );

// 3) Enqueue your client logic after the save-bridge, using the documented action so that
//    window.gfwcp already exists. gfwcp_after_enqueue only fires on product block-editor
//    screens under the default bridge mode.
add_action( 'gfwcp_after_enqueue', function ( $screen ) {
    if ( ! $screen || 'product' !== $screen->post_type ) {
        return;
    }

    wp_add_inline_script(
        'gfwcp-save-bridge',
        'window.myPluginData = ' . wp_json_encode(
            array( 'nonce' => wp_create_nonce( 'myplugin_ajax' ) )
        ) . ';',
        'after'
    );
    wp_add_inline_script( 'gfwcp-save-bridge', myplugin_inline_js(), 'after' );
} );

Client JavaScript (inline)

The client logic: mark the panel dirty on change, run your own AJAX save on your button, and register the saver so the bridge clicks your button on Update.

inline-js.php
function myplugin_inline_js() {
    return <<<'JS'
( function ( wp, $ ) {
    if ( ! window.gfwcp || ! wp || ! wp.data || ! $ ) {
        return;
    }
    var data = window.myPluginData || {};

    // 1) Mark the panel dirty on change.
    $( document ).on( 'input', '#myplugin_value', function () {
        $( '#myplugin_panel .myplugin-row' ).addClass( 'myplugin-needs-update' );
    } );

    // 2) Your own AJAX save, triggered by your button.
    $( document ).on( 'click', '#myplugin_panel button.myplugin-save', function () {
        var postId = wp.data.select( 'core/editor' ).getCurrentPostId();
        $.post( window.ajaxurl, {
            action: 'myplugin_save',
            nonce: data.nonce,
            post_id: postId,
            value: $( '#myplugin_value' ).val()
        } ).done( function () {
            $( '#myplugin_panel .myplugin-row' ).removeClass( 'myplugin-needs-update' );
        } );
    } );

    // 3) Register the saver: when dirty, the bridge clicks your button on "Update".
    window.gfwcp.registerSaver( {
        id: 'my-plugin/panel',
        dirtySelector: '#myplugin_panel .myplugin-row.myplugin-needs-update',
        buttonSelector: '#myplugin_panel button.myplugin-save'
    } );
} )( window.wp, window.jQuery );
JS;
}

That is all. When the user edits the field and presses Update, the bridge runs your saver, which clicks your button, which fires your existing AJAX save.

If you ship a separate JS file instead of inline

Enqueue it with gfwcp-save-bridge (and jquery if you use it) as a dependency so window.gfwcp is defined before your code runs.

enqueue-saver.php
add_action( 'gfwcp_after_enqueue', function ( $screen ) {
    if ( ! $screen || 'product' !== $screen->post_type ) {
        return;
    }
    wp_enqueue_script(
        'my-plugin-saver',
        plugins_url( 'js/saver.js', __FILE__ ),
        array( 'gfwcp-save-bridge', 'wp-data', 'jquery' ),
        '1.0.0',
        true
    );
    wp_localize_script( 'my-plugin-saver', 'myPluginData', array(
        'nonce' => wp_create_nonce( 'myplugin_ajax' ),
    ) );
} );

07

PHP API — filters, actions and constants

Prefix: gfwcp_. Add these to your theme's functions.php or your own plugin.

Constant GFWCP_MODE

Select the strategy without code at runtime (e.g. in wp-config.php). The gfwcp_mode filter wins over the constant.

mode-constant.php
define( 'GFWCP_MODE', 'classic' ); // 'bridge' (default) or 'classic'

Filter gfwcp_mode

Override the strategy programmatically.

mode-filter.php
add_filter( 'gfwcp_mode', function ( $mode ) {
    return 'classic'; // force Strategy A (variable products use the classic editor)
} );

Filter gfwcp_post_types

Change which post types get the block editor + save bridge (default array( 'product' )).

post-types.php
add_filter( 'gfwcp_post_types', function ( $post_types ) {
    $post_types[] = 'my_custom_product';
    return $post_types;
} );

Filter gfwcp_route_to_classic

Strategy A only. Decide per post whether it routes to the classic editor.

route-to-classic.php
add_filter( 'gfwcp_route_to_classic', function ( $route_to_classic, $post ) {
    // Keep ALL products in Gutenberg even under classic mode:
    return false;
}, 10, 2 );

Actions gfwcp_before_enqueue / gfwcp_after_enqueue

Strategy B only. Fire around the save-bridge enqueue; receive the current WP_Screen. gfwcp_after_enqueue is the recommended place to enqueue a script that registers a JS saver, because it runs only on product block-editor screens and after gfwcp-save-bridge (so window.gfwcp exists).

after-enqueue.php
add_action( 'gfwcp_after_enqueue', function ( $screen ) {
    wp_enqueue_script( 'my-extra-saver', /* ... */ );
} );

08

Choosing the strategy (bridge / classic)

Default is Strategy B (bridge): Gutenberg on all products + the variation save bridge. To use Strategy A (classic fallback) — variable products edit in the classic editor instead — set the constant or the filter.

wp-config.php
// wp-config.php
define( 'GFWCP_MODE', 'classic' );
functions.php
// functions.php
add_filter( 'gfwcp_mode', fn() => 'classic' );

09

Extending to other post types

Add the block editor + save bridge to your own post type.

extend-post-type.php
add_filter( 'gfwcp_post_types', function ( $types ) {
    $types[] = 'my_custom_product';
    return $types;
} );

10

Rules to follow

  • Idempotent saver: act only when your dirty marker is present, and remove the marker when your AJAX completes. A saver that always fires would send spurious requests on every save.
  • Security on the AJAX handler: verify the nonce, check current_user_can(), sanitize all input, and escape any output. Never put secrets in JS.
  • Unique id: registerSaver ignores a duplicate id, so namespace it (my-plugin/...).
  • Errors: a throwing saver is caught and does not stop others. Set window.GFWCP_DEBUG = true while developing to see those errors in the console.

11

How to verify it works

  1. Edit your field, and without clicking your own button, press Update.
  2. Reload the screen — your value must persist.
  3. Press Update again without editing — your AJAX must NOT be sent (idempotency).

The bundled test suite exercises exactly this flow for the demo panel (tests/e2e/declarative-api.spec.js).

12

Requirements

  • WordPress 6.4+
  • PHP 7.4+
  • WooCommerce 7.0+ (required)

Uses only long-stable WordPress APIs (use_block_editor_for_post_type, wp.data, wp.hooks), available since WordPress 5.0.

Source code and issue tracker on GitHub