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 nothing
Needs 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
A dirty marker your UI adds when the user changes something.
Your own save (a button that triggers your AJAX), unchanged from the classic editor.
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).
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 ) {
returnfalse; // nothing to do — stay idempotent
}
// trigger your own save here; return true if you acted.returntrue;
}
} );
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 ) {
returnfalse; // nothing to do — stay idempotent
}
jQuery( 'button.my-plugin-save' ).trigger( 'click' );
returntrue;
} );
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
functionmyplugin_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.
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:returnfalse;
}, 10, 2 );
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).
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.
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
Edit your field, and without clicking your own button, press Update.
Reload the screen — your value must persist.
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.