Customize Changesets Technical Design Decisions

2016-11-15: Updated the description for how and when setting changes are written into the changeset, and also how the iframe window is loaded during a refresh when there are pending changeset yet to be written into the changeset. For more see comment. Also included information about storing modifying user with each setting change, among other tweaks.

This is a technical deep-dive into Customize Changesets (#30937), a proposed feature formerly known as 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. When changes are made to settings the values will get 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 to 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. Changes are written into the changeset post at the AUTOSAVE_INTERVAL or whenever focus is removed from the controls window or at beforeunload.

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(). When there are pending changes not yet written into the changeset, then the iframe window is loaded by programmatically submitting a form with a POST method containing the pending customized data. 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",
        "user_id": 1
    },
    "twentysixteen::header_textcolor": {
        "value": "blank",
        "type": "theme_mod",
        "user_id": 2
    }
}

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. Lastly, the user_id for the user who last modified a given setting is stored with the setting’s params in the changeset so that at the time of saving (publishing) the changes into the database the user can be switched-to so that filters (such as Kses) apply as expected.

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 changeset_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 problem with full page refreshes via location.reload() is that they are 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. Creating a new iframe for each change to the previewed URL also prevents the browser history from unexpectedly being amended.

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] (or iframe[data-src] in the case of loading a preview with changes not yet written into the changeset) 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 be 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 the 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.