This is a technical deep-dive into Customize Changesets (#30937), a proposed feature formerly known Customizer Transactions. Because changesets make some very low-level changes to how the customizer works, I felt it was important for these technical details to be shared concurrently with a 4.7 merge proposal since developers of themes and plugins that extend the customizer heavily will need to be well advised of the changes. As such, again, this is a long and technical post. Consider it a pre-merge dev note.
Feedback on the transactions/changesets idea has been positive although somewhat quiet. After the initial patch and the Customize Snapshots feature plugin, there is now a third iteration on the patch, which is currently in testing and review, and I believe it will be ready for inclusion in WordPress 4.7. I’ll first recap the customizer in general and then explain how changesets will open up a lot of new possibilities for live preview and also fix a lot of defects while we’re at it.
The customizer is WordPress’s framework for doing live previews of any change on your site. Its purpose is to eliminate the “save and surprise” behavior that happens when making changes in the WP admin. All changes you make in the customizer are transient and nothing impacts the live site until you hit the Save & Publish button. A user can feel free to experiment with making changes to their site and be secure in the knowledge that they aren’t going to break their site for visitors since they can preview the impacts of their changes. A very powerful aspect of the customizer is that you can make as many changes to as many settings as you want, including changing the site title, header image, background image, nav menu, widgets, and other aspects of your site, and all of these changes are previewed together and will go live together when published: changes in the customizer are bundled/batched together.
The TL;DR for this post is that customize changesets make changes in the customizer persistent, like autosave drafts. For users, the customizer tab can be closed and re-opened and the changes will persist. Users can make changes to one theme and switch to another in the customizer without losing the changes upon switching. A customizer session can be bookmarked to come back to later or this URL can be shared with someone else to review and make additional changes (the URLs expire after a week without changes). The new APIs make possible many new user-facing features in future releases and feature plugins, including saving drafts, submitting changesets as pending for review, scheduling changes, and more.
Limitations before Changesets (WP≤4.6)
In saying that unsaved changes made in the customizer (before changesets) are transient, I did not mean that they somehow get saved in an actual transient
. No, they are much more ephemeral than that. Changes in the customizer currently only exist in your browser’s memory. If you try leaving the customizer with unsaved changes you get prompted with an “Are you sure?” (AYS) dialog because once you leave customize.php
all of the changes you made will be permanently lost. This issue is especially painful in the context of switching themes in the current customizer: if you modify the site’s title and static front page and then try switching to a different theme, that AYS dialog will appear and warn you that your changes will be lost (since switching a theme currently requires the customizer reload). Not a great user experience. (See #36485.)
Since none of the changes made in the current customizer are persistent in any way, this also has implications for how changes are previewed. When a full refresh or selective refresh is done in the preview, all of the changed settings have to be sent with each request so that the customized state is available to the preview filters to apply in the response, and as such each request has needed to use the POST
method. What this in turn has meant is that links in the preview can’t work normally. When you click a link the URL gets sent via postMessage
from the preview to the parent frame and where an Ajax POST
request is then made to that URL and the response is then written into an about:blank
iframe window via document.write()
. This causes a couple issues: JavaScript running in the preview will return /wp-admin/customize.php
when looking at window.location
as opposed to the expected URL; this largely prevents using JavaScript for URL routing, and it causes problems with JS libraries like jQuery UI (see #23225, #30028). Additionally, any REST API calls to GET
data will not include the customized state applied in the responses (without hacky workarounds with _method
).
Fundamentals for Changesets
The core concept being introduced in Customize Changesets is that the customized state for a given live preview session should be persistent in the database. Each time the customizer is opened a random UUID is generated to serve as the identifier for the changes in that session: the changeset. As soon as a change is made, the change setting value gets sent in an Ajax request to be written into a customize_changeset
custom post type whose post_name
is the UUID for the customizer session. Once the changes have been written into the changeset post, then any request to WordPress (including the the REST API) can be made with a customize_changeset_uuid
query param with the desired UUID and this will result in the customizer being bootstrapped and the changes from that changeset being applied for preview in the response.
The data from changesets is designed to be incorporated into the existing customizer API to maximize backwards compatibility. Namely, the way that the customizer obtains the customized state is by making calls to WP_Customize_Manager::unsanitized_post_values()
. When there is a changeset associated with the given UUID, then the values from the changeset will serve as the base array that is returned by this method. If there is any $_POST['customized']
in the request, then it will be merged on top of this base array. Likewise, calls to $wp_customize->set_post_value()
will also result in the supplied values being merged on top of underlying changeset data.
Since a UUID query param is all that is needed to apply the customized state, this means that the customizer iframe can now just use the frontend preview URL with UUID query param directly in its src
attribute and load the iframe window naturally, as opposed to using about:blank
with a document.write()
. Likewise, the UUID will also get added as a query parameter to customize.php
itself via history.replaceState()
which means you can freely reload the customizer and your changes will persist.
The customize_changeset
post uses the post_content
to store the changed values in a JSON-encoded object, with the setting IDs as keys mapping to objects that contain the value
and other setting params like type
.A submitted setting value will only be written into the changeset if it passes the validation constraints, if the setting is recognized, and if the user can do the capability
associated with the setting. An example changeset:
{
"blogname": {
"value": "My Blog",
"type": "option"
},
"twentysixteen::header_textcolor": {
"value": "blank",
"type": "theme_mod"
}
}
You’ll note that the header_textcolor
setting has a theme slug as a prefix. This is done so that changes to the theme mods won’t be carried over from theme to theme when switching between them in a customizer session. If you set the text color in Twenty Sixteen but then switch over to Twenty Seventeen to try changing colors there, you can then switch back to Twenty Sixteen in the same customizer session and the text color you set before the theme switch will be restored. Additionally, when activating a theme in the customizer and there are theme mods for other themes that do not get saved, these theme mods will get stashed in a customize_stashed_theme_mods
option to then be restored the next time the theme is previewed in the customizer.
The customize_changeset
posts by default get created with the auto-draft
status so that they will get automatically garbage-collected after a week of no modifications. When the Save & Publish button is pressed, a customize_save
Ajax request will be sent with a status of publish
for that post. When a customize_changeset
post transitions to publish
, the customizer will hook in to load the values from the changeset post into an active changeset and then iterate over each of the settings and save
each one. Upon publishing, a changeset post will by default then get immediately trashed so that it will also get garbage-collected.
Since transitioning a changeset post to the publish
status is what causes its setting values to be saved, this means that setting changes can be scheduled (see #28721). Since setting values are only written into the changeset if they pass validation and authorization checks, the values can be saved asynchronously without a user with the required capabilities being logged in. An update to the site title can be scheduled for publishing even though no user with edit_theme_options
will be logged-in when WP Cron runs. Here’s how this can be done with WP-CLI:
wp post create \
--post_type=customize_changeset \
--post_name=$( uuidgen ) \
--post_status=future \
--post_date="2017-01-01 00:00:00" \
--post_content='{"blogname":{"value":"Happy New Year!"}}'
There is no new UI for changesets being proposed for 4.7 (other than the addition of the customize_uuid
query param) and the removal of the theme switch AYS dialog. (Note that the AYS dialog remains for the customizer otherwise since there is no UI that lists changesets to allow the user to navigate back to that state.) Adding a UI for scheduling changesets to core can be explored in future releases.
Extension Opportunities for Plugins
There are also exciting new APIs that plugins will be able to leverage to create new UIs and extend functionality.
- The
show_ui
flag on the custom post type can be turned on so that all changeset posts can be listed. When viewing the edit post screen for given changeset, a metabox can be added to show the contents of the changeset.
- By adding adding support for
revisions
to the customize_changeset
post type, published changesets will no longer be automatically trashed (and garbage-collected). With this there is automatic revisions and an audit trail for changes made in the customizer (see #31089).
- A “Save Draft” button can be added next to “Save & Publish” that calls
wp.customize.previewer.save({status: 'draft'})
. This will prevent the changeset from being an auto-draft
and thus garbage-collected.
- Users could collaborate together on a single changeset with setting-level concurrency locking.
- Normally when updating a changeset post, a revision is prevented from being created even when they are enabled. However, when making an update to a changeset via
wp.customize.previewer.save()
where the status
is supplied (such as draft
) then a new revision will be made. These revisions can be browsed from the edit post screen. Since the JSON in the post_content
is pretty-printed, the diffs are easy to read.
- The
publish_posts
meta capability for customize_snapshots
post type can be overridden to something other than customize
, and for users without the new capability a “Submit” button can be added to the UI to submit a changeset for review via wp.customize.previewer.save({status: 'pending'})
.
- Changesets can be scheduled for publishing from the customizer by hooking up a button to make a call like to
wp.customize.previewer.save({status: 'future', date: '2017-01-01 00:00:00'})
.
- The
customize_changeset
post type supports title
even though it is not displayed anywhere by default. A plugin could add a “commit message” UI for changesets where there could be a title could be entered in an input field and sent via wp.customize.previewer.save({status: 'draft', title: 'Add 2017 greeting'})
.
- The
can_export
flag can be turned on for the registered post type so that customize_changeset
posts can be exported.
Several of these features already exist in the Customize Snapshots plugin among several others, but the plugin is currently using its own standalone snapshots API with a customize_snapshot
post type rather than re-using what is being proposed in 4.7 for core, as the plugin is a prototype for changesets in core. The plugin will be able to be refactored after changesets are committed to core, allowing a lot of code to be removed from it. I’ve also put together a couple of these UI features in a little Gist plugin.
Selective Refresh and Seamless Refresh
In the original proposal for “transactions”, I said that selective refresh was prerequisite for getting transactions/changesets into core. The reason for this is that the initial patch got rid of the PreviewFrame
construct in the customizer JS API since I thought it was no longer necessary. I thought that a refresh should just be invoked simply with location.reload()
in the iframe. The reason why I thought selective refresh was a prerequisite was that full page refreshes via location.reload()
were not seamless due to a “flash of reloading content”. However, when working on the latest patch I grew to appreciate the approach of loading a new iframe in the background and hot-swapping with the foreground once loaded. And anyway, I realized there was no need to remove the “seamless refresh” functionality.
Aside: Now that the iframe is loaded with a regular URL supplied in its src
attribute, you can now manually refresh the preview iframe just like you would refresh any other iframe. Just context click (right click) on the preview frame and select “Reload Frame” from the context window.
Frontend Preview
Again, now that the customizer preview is loaded into the iframe via a regular URL, you can grab the value of the iframe[src]
and actually open it in a separate window altogether and not have the customizer controls pane present at all. When you look at the preview URL from the iframe you’ll see the normal frontend URL you’re previewing along with three additional query parameters such as:
customize_changeset_uuid=598cdda1-b785-431d-8c6d-6c421300ed9f
customize_theme=twentysixteen
customize_messenger_channel=preview-1
Only the first one, customize_changeset_uuid
, is required to bootstrap the customizer and preview the state. The second one, customize_theme
, must be supplied when previewing a theme switch. Otherwise, it can be omitted. And lastly, when the customize_messenger_channel
param is present, the customizer preview will hide the admin bar and yield control of navigation to the parent frame: clicks on links and submitting forms will continue to preventDefault
with the intended url destination being sent to the parent frame to populate wp.customize.previewer.previewUrl
which then causes the iframe[src]
to be set to that URL. Maintaining this method of navigation from the preview is important for compatibility with plugins that add custom params to the URL being previewed by intercepting updates to the previewUrl
. When a changeset is published, a new UUID is sent in the response and it is sent to the preview in the saved
message and it replaces the old UUID in the URL via history.replaceState()
. Also note that calls to history.replaceState()
and history.pushState()
are wrapped so that the customize state query prams will be injected into whatever url
is supplied. Whenever the URL is changed with JavaScript, the new URL will be sent to the parent frame along with the wp.customize.settings
for the autofocused panels, sections, and controls. This allows for JS-routed apps to take control over contextual panels, sections, and controls.
When the customize_messenger_channel
query param is absent, then links can be clicked and forms submitted without any preventDefault
. The admin bar will be shown and the Customize link will include the changeset UUID param. The customizer preview JS will ensure that the customizer state query params above will persist by injecting them into all site links and by adding them as hidden inputs to all site forms. If a link or form points to somewhere outside of the site (and is thus not previewable) then the mouse will get a cursor:not-allowed
and wp.a11y.speak()
will explain that it is not previewable (see #31517). The customizer preview will keep sending keep-alive
messages up to the controls pane parent window so that if a user does end up getting routed to an non-previewable URL, the customizer controls pane will know that the preview is no longer connected and so when a setting with postMessage
transport is modified it will intelligently fallback to using refresh
instead.
The way that REST API requests get the customized state included is via the jQuery.ajaxPrefilter()
. If an Ajax request is made with a URL to somewhere on the site, then the customize state query params will be appended to the URL.
Since changesets can only modified by an authorized user and since previewing is a read-only operation, the URL for the customize preview including the UUID can be shared with unauthenticated users to preview those changes. Since a UUID is random it serves not only as a unique identifier but it also effectively serves as a secret key so that such customizer previews cannot be guessed. Note that unauthenticated users would still be forbidden from accessing the preview URL if they supply a customize_theme
that differs from the active theme, as they do not have switch_themes
capability.
Aside: Since the customizer preview can be loaded by itself in its own window, this opens the door to being able to use the customizer for frontend editing and bootstrapping the customizer while on the frontend.
Another aside: The customizer should be able to point to a headless frontend to preview, as long as the frontend looks for the customize_changeset_uuid
query param and passed it along in any REST API calls, while also including the customize-preview.js
file.
REST API Endpoints
The initial changesets patch does not include REST API endpoints for the 4.7 release. However, there should be a feature plugin developed that adds endpoints for listing all changesets, inspecting the contents of a changeset, and the other CRUD operations. With these endpoints in place, entirely new customizer interfaces can be developed that have nothing to do with customize.php
at all. Namely, mobile apps could create changesets and include the associated UUID in REST API requests for various endpoints to preview changes to the app itself. Some initial read-only endpoints were added to the Customize Snapshots plugin.
Summary of API Changes
PHP:
- Added: The
WP_Customize_Manager
class’s constructor now takes an $args
param for passing in the changeset UUID, previewed theme, and messenger channel. This $args
param is intended to be used instead of global variables, although the globals will still be read as a fallback for backwards compatibility.
- Changed: The query param for theme switches has been changed from
theme
to a prefixed customize_theme
. This will reduce the chance of a conflict, although theme
will still be read as a fallback.
- Added: New custom post type:
customize_changeset
.
- Changed: Modified semantics of
WP_Customize_Manager::save()
to handle updating a changeset in addition to the previous behavior of persisting setting values into the database.
- Added:
WP_Customize_Manager::changeset_uuid()
, which returns the UUID for the current session.
- Added:
WP_Customize_Manager::changeset_post_id()
, which returns the post ID for a given customize_changeset
post if one yet exists for the current UUID.
- Added:
WP_Customize_Manager::changeset_data()
which returns the array of data stored in the changeset.
- Changed:
WP_Customize_Manager::unsanitized_post_values()
now takes parameters for whether to exclude changeset data or post input data. Also, post input data will be ignored by default if the current user cannot customize
.
- Added:
customize_changeset_save
filter to modify the data being persisted into the changeset.
- Added:
WP_Customize_Manager::publish_changeset_values()
which is called when a customize_changeset
post transitions to a publish
status.
- Added: Theme mods in a changeset which are not associated with the activated theme will get stored in a
customize_stashed_theme_mods
option so that they can be restored the next time the inactive theme is previewed.
- Added:
WP_Customize_Manager::is_cross_domain()
.
- Added:
WP_Customize_Manager::get_allowed_urls()
.
- Added:
wp_generate_uuid4()
, a new global function for generating random UUIDs.
- Changed: Settings are registered for nav menus and widgets even if the user cannot
edit_theme_options
so that a changeset containing values for them can be previewed.
- Deprecated:
WP_Customzie_Manager::wp_redirect_status()
.
- Deprecated:
WP_Customize_Manager::wp_die_handler()
.
- Deprecated:
WP_Customize_Manager::remove_preview_signature()
.
JavaScript:
- Added:
wp.customize.state('previewerAlive')
state containing a boolean indicating whether the preview is sending keep-alive
messages.
- Added:
wp.customize.state('saving')
state containing a boolean indicating whether wp.customize.preview.save()
is currently making a request.
- Added:
wp.customize.state('changesetStatus')
state containing the current post status for the customize_changeset
post.
- Added:
wp.customize.requestChangesetUpdate()
method which allows a plugin to programmatically push changes into a changeset. This allows additional params to be attached to a given setting in a changeset aside from value
, and it also allows settings to be removed from a changeset altogether by sending null
as the setting params object.
- Added:
changeset-save
event is triggered on wp.customize
with the pending changes object being sent to to the server.
- Added:
changeset-saved
event is triggered on wp.customize
when a changeset update request completes successfully, with a changeset-error
event triggered on a failure.
- Added:
changeset-saved
message is sent to the preview once a changeset has been saved so that JS can safely make make REST API request with the customized state being available.
- Added: The millisecond values used in
debounce
and setInterval
calls are now stored in wp.customize.settings.timeouts
rather than being hard-coded in the JS.
- Changed: Seamless refreshes will wait for any pending changeset updates to complete before initiating.
- Changed: The
beforeunload
event handler for the AYS dialog now has a customize-confirm
namespace.
- Added: Settings for the changeset are exposed under
wp.customize.settings.changeset
, including the current UUID and the post status.
- Moved:
wp.customize.utils
has been moved from customize-controls
to customize-base
.
- Added:
wp.customize.utils.parseQueryString()
to parse a query string into an object of query params.
- Fixed: Prevent modified nav menus from initiating selective refresh with each page load when customizer is loaded via HTTPS.
- Fixed: Links in the preview that are just internal jump links no longer have
preventDefault
called. See #34142.
- Added:
wp.customize.isLinkPreviewable()
to customize-preview
which returns whether a given link can be previewed.
Thanks
The work on transactions/snapshots/changesets has been going on for almost 2 years now. I want to thank Derek Herman (@valendesigns) for a lot of his work on the Customize Snapshots plugin that took the key concepts from the initial transactions proposal and started to combine them with a lot of compelling user-facing features.
I hope that changesets will make the Customize API all the more powerful to build compelling cutting-edge applications in WordPress.
Philip Ingram 3:30 pm on October 15, 2016 Permalink | Log in to Reply
I won’t be able to make the chat but one thing that struck me again yesterday as a frustration (and not sure this actually falls under shiny updates) is why are we still not opening screenshot images for plugins in some type of light box modal vs forcing browsers to download the images. It’s not very convenient when one simply wants to view the image in larger form and I don’t think this would be that hard to pull off either.
Pascal Birchler 3:42 pm on October 15, 2016 Permalink | Log in to Reply
Are you referring to the plugin directory? If so, the images should open in a new tab right now. If the images is being downloaded, it has the wrong mime type associated with it and the developer should fix this in Subversion (at least that’s required right now).
For adding a light box, I’d suggest requesting that in #meta or https://make.wordpress.org/meta/. The new plugin directory is currently in the works and I’m sure the team will happily consider it.