WordPress.org

Make WordPress Core

Changeset 38810


Ignore:
Timestamp:
10/18/16 20:04:36 (7 months ago)
Author:
westonruter
Message:

Customize: Implement customized state persistence with changesets.

Includes infrastructure developed in the Customize Snapshots feature plugin.

See https://make.wordpress.org/core/2016/10/12/customize-changesets-technical-design-decisions/

Props westonruter, valendesigns, utkarshpatel, stubgo, lgedeon, ocean90, ryankienstra, mihai2u, dlh, aaroncampbell, jonathanbardo, jorbin.
See #28721.
See #31089.
Fixes #30937.
Fixes #31517.
Fixes #30028.
Fixes #23225.
Fixes #34142.
Fixes #36485.

Location:
trunk
Files:
1 added
31 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/customize.php

    r38672 r38810  
    2121} 
    2222 
     23/** 
     24 * @global WP_Scripts           $wp_scripts 
     25 * @global WP_Customize_Manager $wp_customize 
     26 */ 
     27global $wp_scripts, $wp_customize; 
     28 
     29if ( $wp_customize->changeset_post_id() ) { 
     30    if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $wp_customize->changeset_post_id() ) ) { 
     31        wp_die( 
     32            '<h1>' . __( 'Cheatin&#8217; uh?' ) . '</h1>' . 
     33            '<p>' . __( 'Sorry, you are not allowed to edit this changeset.' ) . '</p>', 
     34            403 
     35        ); 
     36    } 
     37    if ( in_array( get_post_status( $wp_customize->changeset_post_id() ), array( 'publish', 'trash' ), true ) ) { 
     38        wp_die( 
     39            '<h1>' . __( 'Cheatin&#8217; uh?' ) . '</h1>' . 
     40            '<p>' . __( 'This changeset has already been published and cannot be further modified.' ) . '</p>' . 
     41            '<p><a href="' . esc_url( remove_query_arg( 'changeset_uuid' ) ) . '">' . __( 'Customize New Changes' ) . '</a></p>', 
     42            403 
     43        ); 
     44    } 
     45} 
     46 
     47 
    2348wp_reset_vars( array( 'url', 'return', 'autofocus' ) ); 
    2449if ( ! empty( $url ) ) { 
     
    3156    $wp_customize->set_autofocus( wp_unslash( $autofocus ) ); 
    3257} 
    33  
    34 /** 
    35  * @global WP_Scripts           $wp_scripts 
    36  * @global WP_Customize_Manager $wp_customize 
    37  */ 
    38 global $wp_scripts, $wp_customize; 
    3958 
    4059$registered = $wp_scripts->registered; 
     
    116135            <?php 
    117136            $save_text = $wp_customize->is_theme_active() ? __( 'Save &amp; Publish' ) : __( 'Save &amp; Activate' ); 
    118             submit_button( $save_text, 'primary save', 'save', false ); 
     137            $save_attrs = array(); 
     138            if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->publish_posts ) ) { 
     139                $save_attrs['style'] = 'display: none'; 
     140            } 
     141            submit_button( $save_text, 'primary save', 'save', false, $save_attrs ); 
    119142            ?> 
    120143            <span class="spinner"></span> 
  • trunk/src/wp-admin/js/customize-controls.js

    r38742 r38810  
    2323    api.Setting = api.Value.extend({ 
    2424        initialize: function( id, value, options ) { 
    25             api.Value.prototype.initialize.call( this, value, options ); 
    26  
    27             this.id = id; 
    28             this.transport = this.transport || 'refresh'; 
    29             this._dirty = options.dirty || false; 
    30             this.notifications = new api.Values({ defaultConstructor: api.Notification }); 
     25            var setting = this; 
     26            api.Value.prototype.initialize.call( setting, value, options ); 
     27 
     28            setting.id = id; 
     29            setting.transport = setting.transport || 'refresh'; 
     30            setting._dirty = options.dirty || false; 
     31            setting.notifications = new api.Values({ defaultConstructor: api.Notification }); 
    3132 
    3233            // Whenever the setting's value changes, refresh the preview. 
    33             this.bind( this.preview ); 
     34            setting.bind( setting.preview ); 
    3435        }, 
    3536 
    3637        /** 
    3738         * Refresh the preview, respective of the setting's refresh policy. 
     39         * 
     40         * If the preview hasn't sent a keep-alive message and is likely 
     41         * disconnected by having navigated to a non-allowed URL, then the 
     42         * refresh transport will be forced when postMessage is the transport. 
     43         * Note that postMessage does not throw an error when the recipient window 
     44         * fails to match the origin window, so using try/catch around the 
     45         * previewer.send() call to then fallback to refresh will not work. 
     46         * 
     47         * @since 3.4.0 
    3848         */ 
    3949        preview: function() { 
    40             switch ( this.transport ) { 
    41                 case 'refresh': 
    42                     return this.previewer.refresh(); 
    43                 case 'postMessage': 
    44                     return this.previewer.send( 'setting', [ this.id, this() ] ); 
     50            var setting = this, transport; 
     51            transport = setting.transport; 
     52 
     53            if ( 'postMessage' === transport && ! api.state( 'previewerAlive' ).get() ) { 
     54                transport = 'refresh'; 
     55            } 
     56 
     57            if ( 'postMessage' === transport ) { 
     58                return setting.previewer.send( 'setting', [ setting.id, setting() ] ); 
     59            } else if ( 'refresh' === transport ) { 
     60                return setting.previewer.refresh(); 
    4561            } 
    4662        }, 
     
    6682 
    6783    /** 
    68      * Utility function namespace 
     84     * Current change count. 
     85     * 
     86     * @since 4.7.0 
     87     * @type {number} 
     88     * @protected 
    6989     */ 
    70     api.utils = {}; 
     90    api._latestRevision = 0; 
     91 
     92    /** 
     93     * Last revision that was saved. 
     94     * 
     95     * @since 4.7.0 
     96     * @type {number} 
     97     * @protected 
     98     */ 
     99    api._lastSavedRevision = 0; 
     100 
     101    /** 
     102     * Latest revisions associated with the updated setting. 
     103     * 
     104     * @since 4.7.0 
     105     * @type {object} 
     106     * @protected 
     107     */ 
     108    api._latestSettingRevisions = {}; 
     109 
     110    /* 
     111     * Keep track of the revision associated with each updated setting so that 
     112     * requestChangesetUpdate knows which dirty settings to include. Also, once 
     113     * ready is triggered and all initial settings have been added, increment 
     114     * revision for each newly-created initially-dirty setting so that it will 
     115     * also be included in changeset update requests. 
     116     */ 
     117    api.bind( 'change', function incrementChangedSettingRevision( setting ) { 
     118        api._latestRevision += 1; 
     119        api._latestSettingRevisions[ setting.id ] = api._latestRevision; 
     120    } ); 
     121    api.bind( 'ready', function() { 
     122        api.bind( 'add', function incrementCreatedSettingRevision( setting ) { 
     123            if ( setting._dirty ) { 
     124                api._latestRevision += 1; 
     125                api._latestSettingRevisions[ setting.id ] = api._latestRevision; 
     126            } 
     127        } ); 
     128    } ); 
     129 
     130    /** 
     131     * Get the dirty setting values. 
     132     * 
     133     * @param {object} [options] Options. 
     134     * @param {boolean} [options.unsaved=false] Whether only values not saved yet into a changeset will be returned (differential changes). 
     135     * @returns {object} Dirty setting values. 
     136     */ 
     137    api.dirtyValues = function dirtyValues( options ) { 
     138        var values = {}; 
     139        api.each( function( setting ) { 
     140            var settingRevision; 
     141 
     142            if ( ! setting._dirty ) { 
     143                return; 
     144            } 
     145 
     146            settingRevision = api._latestSettingRevisions[ setting.id ]; 
     147 
     148            // Skip including settings that have already been included in the changeset, if only requesting unsaved. 
     149            if ( ( options && options.unsaved ) && ( _.isUndefined( settingRevision ) || settingRevision <= api._lastSavedRevision ) ) { 
     150                return; 
     151            } 
     152 
     153            values[ setting.id ] = setting.get(); 
     154        } ); 
     155        return values; 
     156    }; 
     157 
     158    /** 
     159     * Request updates to the changeset. 
     160     * 
     161     * @param {object} [changes] Mapping of setting IDs to setting params each normally including a value property, or mapping to null. 
     162     *                           If not provided, then the changes will still be obtained from unsaved dirty settings. 
     163     * @returns {jQuery.Promise} 
     164     */ 
     165    api.requestChangesetUpdate = function requestChangesetUpdate( changes ) { 
     166        var deferred, request, submittedChanges = {}, data; 
     167        deferred = new $.Deferred(); 
     168 
     169        if ( changes ) { 
     170            _.extend( submittedChanges, changes ); 
     171        } 
     172 
     173        // Ensure all revised settings (changes pending save) are also included, but not if marked for deletion in changes. 
     174        _.each( api.dirtyValues( { unsaved: true } ), function( dirtyValue, settingId ) { 
     175            if ( ! changes || null !== changes[ settingId ] ) { 
     176                submittedChanges[ settingId ] = _.extend( 
     177                    {}, 
     178                    submittedChanges[ settingId ] || {}, 
     179                    { value: dirtyValue } 
     180                ); 
     181            } 
     182        } ); 
     183 
     184        // Short-circuit when there are no pending changes. 
     185        if ( _.isEmpty( submittedChanges ) ) { 
     186            deferred.resolve( {} ); 
     187            return deferred.promise(); 
     188        } 
     189 
     190        // Make sure that publishing a changeset waits for all changeset update requests to complete. 
     191        api.state( 'processing' ).set( api.state( 'processing' ).get() + 1 ); 
     192        deferred.always( function() { 
     193            api.state( 'processing' ).set( api.state( 'processing' ).get() - 1 ); 
     194        } ); 
     195 
     196        // Allow plugins to attach additional params to the settings. 
     197        api.trigger( 'changeset-save', submittedChanges ); 
     198 
     199        // Ensure that if any plugins add data to save requests by extending query() that they get included here. 
     200        data = api.previewer.query( { excludeCustomizedSaved: true } ); 
     201        delete data.customized; // Being sent in customize_changeset_data instead. 
     202        _.extend( data, { 
     203            nonce: api.settings.nonce.save, 
     204            customize_theme: api.settings.theme.stylesheet, 
     205            customize_changeset_data: JSON.stringify( submittedChanges ) 
     206        } ); 
     207 
     208        request = wp.ajax.post( 'customize_save', data ); 
     209 
     210        request.done( function requestChangesetUpdateDone( data ) { 
     211            var savedChangesetValues = {}; 
     212 
     213            // Ensure that all settings updated subsequently will be included in the next changeset update request. 
     214            api._lastSavedRevision = Math.max( api._latestRevision, api._lastSavedRevision ); 
     215 
     216            api.state( 'changesetStatus' ).set( data.changeset_status ); 
     217            deferred.resolve( data ); 
     218            api.trigger( 'changeset-saved', data ); 
     219 
     220            if ( data.setting_validities ) { 
     221                _.each( data.setting_validities, function( validity, settingId ) { 
     222                    if ( true === validity && _.isObject( submittedChanges[ settingId ] ) && ! _.isUndefined( submittedChanges[ settingId ].value ) ) { 
     223                        savedChangesetValues[ settingId ] = submittedChanges[ settingId ].value; 
     224                    } 
     225                } ); 
     226            } 
     227 
     228            api.previewer.send( 'changeset-saved', _.extend( {}, data, { saved_changeset_values: savedChangesetValues } ) ); 
     229        } ); 
     230        request.fail( function requestChangesetUpdateFail( data ) { 
     231            deferred.reject( data ); 
     232            api.trigger( 'changeset-error', data ); 
     233        } ); 
     234        request.always( function( data ) { 
     235            if ( data.setting_validities ) { 
     236                api._handleSettingValidities( { 
     237                    settingValidities: data.setting_validities 
     238                } ); 
     239            } 
     240        } ); 
     241 
     242        return deferred.promise(); 
     243    }; 
    71244 
    72245    /** 
     
    12171390 
    12181391        /** 
     1392         * Load theme preview. 
     1393         * 
     1394         * @since 4.7.0 
     1395         * 
     1396         * @param {string} themeId Theme ID. 
     1397         * @returns {jQuery.promise} Promise. 
     1398         */ 
     1399        loadThemePreview: function( themeId ) { 
     1400            var deferred = $.Deferred(), onceProcessingComplete, overlay, urlParser; 
     1401 
     1402            urlParser = document.createElement( 'a' ); 
     1403            urlParser.href = location.href; 
     1404            urlParser.search = $.param( _.extend( 
     1405                api.utils.parseQueryString( urlParser.search.substr( 1 ) ), 
     1406                { 
     1407                    theme: themeId, 
     1408                    changeset_uuid: api.settings.changeset.uuid 
     1409                } 
     1410            ) ); 
     1411 
     1412            overlay = $( '.wp-full-overlay' ); 
     1413            overlay.addClass( 'customize-loading' ); 
     1414 
     1415            onceProcessingComplete = function() { 
     1416                var request; 
     1417                if ( api.state( 'processing' ).get() > 0 ) { 
     1418                    return; 
     1419                } 
     1420 
     1421                api.state( 'processing' ).unbind( onceProcessingComplete ); 
     1422 
     1423                request = api.requestChangesetUpdate(); 
     1424                request.done( function() { 
     1425                    $( window ).off( 'beforeunload.customize-confirm' ); 
     1426                    window.location.href = urlParser.href; 
     1427                } ); 
     1428                request.fail( function() { 
     1429                    overlay.removeClass( 'customize-loading' ); 
     1430                } ); 
     1431            }; 
     1432 
     1433            if ( 0 === api.state( 'processing' ).get() ) { 
     1434                onceProcessingComplete(); 
     1435            } else { 
     1436                api.state( 'processing' ).bind( onceProcessingComplete ); 
     1437            } 
     1438 
     1439            return deferred.promise(); 
     1440        }, 
     1441 
     1442        /** 
    12191443         * Render & show the theme details for a given theme model. 
    12201444         * 
     
    12241448         */ 
    12251449        showDetails: function ( theme, callback ) { 
    1226             var section = this; 
     1450            var section = this, link; 
    12271451            callback = callback || function(){}; 
    12281452            section.currentTheme = theme.id; 
     
    12331457            section.containFocus( section.overlay ); 
    12341458            section.updateLimits(); 
     1459 
     1460            link = section.overlay.find( '.inactive-theme > a' ); 
     1461 
     1462            link.on( 'click', function( event ) { 
     1463                event.preventDefault(); 
     1464 
     1465                // Short-circuit if request is currently being made. 
     1466                if ( link.hasClass( 'disabled' ) ) { 
     1467                    return; 
     1468                } 
     1469                link.addClass( 'disabled' ); 
     1470 
     1471                section.loadThemePreview( theme.id ).fail( function() { 
     1472                    link.removeClass( 'disabled' ); 
     1473                } ); 
     1474            } ); 
    12351475            callback(); 
    12361476        }, 
     
    22282468                nonce: _wpCustomizeBackground.nonces.add, 
    22292469                wp_customize: 'on', 
    2230                 theme: api.settings.theme.stylesheet, 
     2470                customize_theme: api.settings.theme.stylesheet, 
    22312471                attachment_id: this.params.attachment.id 
    22322472            } ); 
     
    25932833            // Ensure custom-header-crop Ajax requests bootstrap the Customizer to activate the previewed theme. 
    25942834            wp.media.controller.Cropper.prototype.defaults.doCropArgs.wp_customize = 'on'; 
    2595             wp.media.controller.Cropper.prototype.defaults.doCropArgs.theme = api.settings.theme.stylesheet; 
     2835            wp.media.controller.Cropper.prototype.defaults.doCropArgs.customize_theme = api.settings.theme.stylesheet; 
    25962836        }, 
    25972837 
     
    28843124                } 
    28853125 
    2886                 var previewUrl = $( this ).data( 'previewUrl' ); 
    2887  
    2888                 $( '.wp-full-overlay' ).addClass( 'customize-loading' ); 
    2889  
    2890                 window.parent.location = previewUrl; 
     3126                api.section( control.section() ).loadThemePreview( control.params.theme.id ); 
    28913127            }); 
    28923128 
     
    29493185     */ 
    29503186    api.PreviewFrame = api.Messenger.extend({ 
    2951         sensitivity: 2000, 
     3187        sensitivity: null, // Will get set to api.settings.timeouts.previewFrameSensitivity. 
    29523188 
    29533189        /** 
     
    29553191         * 
    29563192         * @param {object} params.container 
    2957          * @param {object} params.signature 
    29583193         * @param {object} params.previewUrl 
    29593194         * @param {object} params.query 
     
    29703205 
    29713206            this.container = params.container; 
    2972             this.signature = params.signature; 
    29733207 
    29743208            $.extend( params, { channel: api.PreviewFrame.uuid() }); 
     
    29903224         */ 
    29913225        run: function( deferred ) { 
    2992             var self  = this, 
     3226            var previewFrame = this, 
    29933227                loaded = false, 
    2994                 ready  = false; 
    2995  
    2996             if ( this._ready ) { 
    2997                 this.unbind( 'ready', this._ready ); 
    2998             } 
    2999  
    3000             this._ready = function() { 
     3228                ready = false, 
     3229                readyData = null, 
     3230                hasPendingChangesetUpdate = '{}' !== previewFrame.query.customized, 
     3231                urlParser, 
     3232                params, 
     3233                form; 
     3234 
     3235            if ( previewFrame._ready ) { 
     3236                previewFrame.unbind( 'ready', previewFrame._ready ); 
     3237            } 
     3238 
     3239            previewFrame._ready = function( data ) { 
    30013240                ready = true; 
    3002  
    3003                 if ( loaded ) { 
    3004                     deferred.resolveWith( self ); 
    3005                 } 
    3006             }; 
    3007  
    3008             this.bind( 'ready', this._ready ); 
    3009  
    3010             this.bind( 'ready', function ( data ) { 
    3011  
    3012                 this.container.addClass( 'iframe-ready' ); 
    3013  
     3241                readyData = data; 
     3242                previewFrame.container.addClass( 'iframe-ready' ); 
    30143243                if ( ! data ) { 
    30153244                    return; 
    30163245                } 
    30173246 
    3018                 /* 
    3019                  * Walk over all panels, sections, and controls and set their 
    3020                  * respective active states to true if the preview explicitly 
    3021                  * indicates as such. 
    3022                  */ 
    3023                 var constructs = { 
    3024                     panel: data.activePanels, 
    3025                     section: data.activeSections, 
    3026                     control: data.activeControls 
    3027                 }; 
    3028                 _( constructs ).each( function ( activeConstructs, type ) { 
    3029                     api[ type ].each( function ( construct, id ) { 
    3030                         var isDynamicallyCreated = _.isUndefined( api.settings[ type + 's' ][ id ] ); 
    3031  
    3032                         /* 
    3033                          * If the construct was created statically in PHP (not dynamically in JS) 
    3034                          * then consider a missing (undefined) value in the activeConstructs to 
    3035                          * mean it should be deactivated (since it is gone). But if it is 
    3036                          * dynamically created then only toggle activation if the value is defined, 
    3037                          * as this means that the construct was also then correspondingly 
    3038                          * created statically in PHP and the active callback is available. 
    3039                          * Otherwise, dynamically-created constructs should normally have 
    3040                          * their active states toggled in JS rather than from PHP. 
    3041                          */ 
    3042                         if ( ! isDynamicallyCreated || ! _.isUndefined( activeConstructs[ id ] ) ) { 
    3043                             if ( activeConstructs[ id ] ) { 
    3044                                 construct.activate(); 
    3045                             } else { 
    3046                                 construct.deactivate(); 
    3047                             } 
    3048                         } 
    3049                     } ); 
     3247                if ( loaded ) { 
     3248                    deferred.resolveWith( previewFrame, [ data ] ); 
     3249                } 
     3250            }; 
     3251 
     3252            previewFrame.bind( 'ready', previewFrame._ready ); 
     3253 
     3254            urlParser = document.createElement( 'a' ); 
     3255            urlParser.href = previewFrame.previewUrl(); 
     3256 
     3257            params = _.extend( 
     3258                api.utils.parseQueryString( urlParser.search.substr( 1 ) ), 
     3259                { 
     3260                    customize_changeset_uuid: previewFrame.query.customize_changeset_uuid, 
     3261                    customize_theme: previewFrame.query.customize_theme, 
     3262                    customize_messenger_channel: previewFrame.query.customize_messenger_channel 
     3263                } 
     3264            ); 
     3265 
     3266            urlParser.search = $.param( params ); 
     3267            previewFrame.iframe = $( '<iframe />', { 
     3268                title: api.l10n.previewIframeTitle, 
     3269                name: 'customize-' + previewFrame.channel() 
     3270            } ); 
     3271            previewFrame.iframe.attr( 'onmousewheel', '' ); // Workaround for Safari bug. See WP Trac #38149. 
     3272 
     3273            if ( ! hasPendingChangesetUpdate ) { 
     3274                previewFrame.iframe.attr( 'src', urlParser.href ); 
     3275            } else { 
     3276                previewFrame.iframe.attr( 'data-src', urlParser.href ); // For debugging purposes. 
     3277            } 
     3278 
     3279            previewFrame.iframe.appendTo( previewFrame.container ); 
     3280            previewFrame.targetWindow( previewFrame.iframe[0].contentWindow ); 
     3281 
     3282            /* 
     3283             * Submit customized data in POST request to preview frame window since 
     3284             * there are setting value changes not yet written to changeset. 
     3285             */ 
     3286            if ( hasPendingChangesetUpdate ) { 
     3287                form = $( '<form>', { 
     3288                    action: urlParser.href, 
     3289                    target: previewFrame.iframe.attr( 'name' ), 
     3290                    method: 'post', 
     3291                    hidden: 'hidden' 
    30503292                } ); 
    3051  
    3052                 if ( data.settingValidities ) { 
    3053                     api._handleSettingValidities( { 
    3054                         settingValidities: data.settingValidities, 
    3055                         focusInvalidControl: false 
    3056                     } ); 
    3057                 } 
     3293                form.append( $( '<input>', { 
     3294                    type: 'hidden', 
     3295                    name: '_method', 
     3296                    value: 'GET' 
     3297                } ) ); 
     3298                _.each( previewFrame.query, function( value, key ) { 
     3299                    form.append( $( '<input>', { 
     3300                        type: 'hidden', 
     3301                        name: key, 
     3302                        value: value 
     3303                    } ) ); 
     3304                } ); 
     3305                previewFrame.container.append( form ); 
     3306                form.submit(); 
     3307                form.remove(); // No need to keep the form around after submitted. 
     3308            } 
     3309 
     3310            previewFrame.bind( 'iframe-loading-error', function( error ) { 
     3311                previewFrame.iframe.remove(); 
     3312 
     3313                // Check if the user is not logged in. 
     3314                if ( 0 === error ) { 
     3315                    previewFrame.login( deferred ); 
     3316                    return; 
     3317                } 
     3318 
     3319                // Check for cheaters. 
     3320                if ( -1 === error ) { 
     3321                    deferred.rejectWith( previewFrame, [ 'cheatin' ] ); 
     3322                    return; 
     3323                } 
     3324 
     3325                deferred.rejectWith( previewFrame, [ 'request failure' ] ); 
    30583326            } ); 
    30593327 
    3060             this.request = $.ajax( this.previewUrl(), { 
    3061                 type: 'POST', 
    3062                 data: this.query, 
    3063                 xhrFields: { 
    3064                     withCredentials: true 
    3065                 } 
    3066             } ); 
    3067  
    3068             this.request.fail( function() { 
    3069                 deferred.rejectWith( self, [ 'request failure' ] ); 
    3070             }); 
    3071  
    3072             this.request.done( function( response ) { 
    3073                 var location = self.request.getResponseHeader('Location'), 
    3074                     signature = self.signature, 
    3075                     index; 
    3076  
    3077                 // Check if the location response header differs from the current URL. 
    3078                 // If so, the request was redirected; try loading the requested page. 
    3079                 if ( location && location !== self.previewUrl() ) { 
    3080                     deferred.rejectWith( self, [ 'redirect', location ] ); 
    3081                     return; 
    3082                 } 
    3083  
    3084                 // Check if the user is not logged in. 
    3085                 if ( '0' === response ) { 
    3086                     self.login( deferred ); 
    3087                     return; 
    3088                 } 
    3089  
    3090                 // Check for cheaters. 
    3091                 if ( '-1' === response ) { 
    3092                     deferred.rejectWith( self, [ 'cheatin' ] ); 
    3093                     return; 
    3094                 } 
    3095  
    3096                 // Check for a signature in the request. 
    3097                 index = response.lastIndexOf( signature ); 
    3098                 if ( -1 === index || index < response.lastIndexOf('</html>') ) { 
    3099                     deferred.rejectWith( self, [ 'unsigned' ] ); 
    3100                     return; 
    3101                 } 
    3102  
    3103                 // Strip the signature from the request. 
    3104                 response = response.slice( 0, index ) + response.slice( index + signature.length ); 
    3105  
    3106                 // Create the iframe and inject the html content. 
    3107                 self.iframe = $( '<iframe />', { 'title': api.l10n.previewIframeTitle } ).appendTo( self.container ); 
    3108                 self.iframe.attr( 'onmousewheel', '' ); // Workaround for Safari bug. See WP Trac #38149. 
    3109  
    3110                 // Bind load event after the iframe has been added to the page; 
    3111                 // otherwise it will fire when injected into the DOM. 
    3112                 self.iframe.one( 'load', function() { 
    3113                     loaded = true; 
    3114  
    3115                     if ( ready ) { 
    3116                         deferred.resolveWith( self ); 
    3117                     } else { 
    3118                         setTimeout( function() { 
    3119                             deferred.rejectWith( self, [ 'ready timeout' ] ); 
    3120                         }, self.sensitivity ); 
    3121                     } 
    3122                 }); 
    3123  
    3124                 self.targetWindow( self.iframe[0].contentWindow ); 
    3125  
    3126                 self.targetWindow().document.open(); 
    3127                 self.targetWindow().document.write( response ); 
    3128                 self.targetWindow().document.close(); 
     3328            previewFrame.iframe.one( 'load', function() { 
     3329                loaded = true; 
     3330 
     3331                if ( ready ) { 
     3332                    deferred.resolveWith( previewFrame, [ readyData ] ); 
     3333                } else { 
     3334                    setTimeout( function() { 
     3335                        deferred.rejectWith( previewFrame, [ 'ready timeout' ] ); 
     3336                    }, previewFrame.sensitivity ); 
     3337                } 
    31293338            }); 
    31303339        }, 
     
    31653374        destroy: function() { 
    31663375            api.Messenger.prototype.destroy.call( this ); 
    3167             this.request.abort(); 
    3168  
    3169             if ( this.iframe ) 
     3376 
     3377            if ( this.iframe ) { 
    31703378                this.iframe.remove(); 
    3171  
    3172             delete this.request; 
     3379            } 
     3380 
    31733381            delete this.iframe; 
    31743382            delete this.targetWindow; 
     
    31773385 
    31783386    (function(){ 
    3179         var uuid = 0; 
    3180         /** 
    3181          * Create a universally unique identifier. 
    3182          * 
    3183          * @return {int} 
     3387        var id = 0; 
     3388        /** 
     3389         * Return an incremented ID for a preview messenger channel. 
     3390         * 
     3391         * This function is named "uuid" for historical reasons, but it is a 
     3392         * misnomer as it is not an actual UUID, and it is not universally unique. 
     3393         * This is not to be confused with `api.settings.changeset.uuid`. 
     3394         * 
     3395         * @return {string} 
    31843396         */ 
    31853397        api.PreviewFrame.uuid = function() { 
    3186             return 'preview-' + uuid++; 
     3398            return 'preview-' + String( id++ ); 
    31873399        }; 
    31883400    }()); 
     
    32103422     */ 
    32113423    api.Previewer = api.Messenger.extend({ 
    3212         refreshBuffer: 250, 
     3424        refreshBuffer: null, // Will get set to api.settings.timeouts.windowRefresh. 
    32133425 
    32143426        /** 
     
    32183430         * @param {string} params.form 
    32193431         * @param {string} params.previewUrl  The URL to preview. 
    3220          * @param {string} params.signature 
    32213432         * @param {object} options 
    32223433         */ 
    32233434        initialize: function( params, options ) { 
    3224             var self = this, 
    3225                 rscheme = /^https?/; 
    3226  
    3227             $.extend( this, options || {} ); 
    3228             this.deferred = { 
     3435            var previewer = this, 
     3436                urlParser = document.createElement( 'a' ); 
     3437 
     3438            $.extend( previewer, options || {} ); 
     3439            previewer.deferred = { 
    32293440                active: $.Deferred() 
    32303441            }; 
    32313442 
    3232             /* 
    3233              * Wrap this.refresh to prevent it from hammering the servers: 
    3234              * 
    3235              * If refresh is called once and no other refresh requests are 
    3236              * loading, trigger the request immediately. 
    3237              * 
    3238              * If refresh is called while another refresh request is loading, 
    3239              * debounce the refresh requests: 
    3240              * 1. Stop the loading request (as it is instantly outdated). 
    3241              * 2. Trigger the new request once refresh hasn't been called for 
    3242              *    self.refreshBuffer milliseconds. 
    3243              */ 
    3244             this.refresh = (function( self ) { 
    3245                 var refresh  = self.refresh, 
    3246                     callback = function() { 
    3247                         timeout = null; 
    3248                         refresh.call( self ); 
    3249                     }, 
    3250                     timeout; 
    3251  
    3252                 return function() { 
    3253                     if ( typeof timeout !== 'number' ) { 
    3254                         if ( self.loading ) { 
    3255                             self.abort(); 
     3443            // Debounce to prevent hammering server and then wait for any pending update requests. 
     3444            previewer.refresh = _.debounce( 
     3445                ( function( originalRefresh ) { 
     3446                    return function() { 
     3447                        var isProcessingComplete, refreshOnceProcessingComplete; 
     3448                        isProcessingComplete = function() { 
     3449                            return 0 === api.state( 'processing' ).get(); 
     3450                        }; 
     3451                        if ( isProcessingComplete() ) { 
     3452                            originalRefresh.call( previewer ); 
    32563453                        } else { 
    3257                             return callback(); 
     3454                            refreshOnceProcessingComplete = function() { 
     3455                                if ( isProcessingComplete() ) { 
     3456                                    originalRefresh.call( previewer ); 
     3457                                    api.state( 'processing' ).unbind( refreshOnceProcessingComplete ); 
     3458                                } 
     3459                            }; 
     3460                            api.state( 'processing' ).bind( refreshOnceProcessingComplete ); 
    32583461                        } 
    3259                     } 
    3260  
    3261                     clearTimeout( timeout ); 
    3262                     timeout = setTimeout( callback, self.refreshBuffer ); 
    3263                 }; 
    3264             })( this ); 
    3265  
    3266             this.container   = api.ensure( params.container ); 
    3267             this.allowedUrls = params.allowedUrls; 
    3268             this.signature   = params.signature; 
     3462                    }; 
     3463                }( previewer.refresh ) ), 
     3464                previewer.refreshBuffer 
     3465            ); 
     3466 
     3467            previewer.container   = api.ensure( params.container ); 
     3468            previewer.allowedUrls = params.allowedUrls; 
    32693469 
    32703470            params.url = window.location.href; 
    32713471 
    3272             api.Messenger.prototype.initialize.call( this, params ); 
    3273  
    3274             this.add( 'scheme', this.origin() ).link( this.origin ).setter( function( to ) { 
    3275                 var match = to.match( rscheme ); 
    3276                 return match ? match[0] : ''; 
    3277             }); 
     3472            api.Messenger.prototype.initialize.call( previewer, params ); 
     3473 
     3474            urlParser.href = previewer.origin(); 
     3475            previewer.add( 'scheme', urlParser.protocol.replace( /:$/, '' ) ); 
    32783476 
    32793477            // Limit the URL to internal, front-end links. 
     
    32853483            // ssl certs. 
    32863484 
    3287             this.add( 'previewUrl', params.previewUrl ).setter( function( to ) { 
    3288                 var result, urlParser; 
     3485            previewer.add( 'previewUrl', params.previewUrl ).setter( function( to ) { 
     3486                var result, urlParser, newPreviewUrl, schemeMatchingPreviewUrl, queryParams; 
    32893487                urlParser = document.createElement( 'a' ); 
    32903488                urlParser.href = to; 
     
    32953493                } 
    32963494 
     3495                // Remove state query params. 
     3496                if ( urlParser.search.length > 1 ) { 
     3497                    queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 
     3498                    delete queryParams.customize_changeset_uuid; 
     3499                    delete queryParams.customize_theme; 
     3500                    delete queryParams.customize_messenger_channel; 
     3501                    if ( _.isEmpty( queryParams ) ) { 
     3502                        urlParser.search = ''; 
     3503                    } else { 
     3504                        urlParser.search = $.param( queryParams ); 
     3505                    } 
     3506                } 
     3507 
     3508                newPreviewUrl = urlParser.href; 
     3509                urlParser.protocol = previewer.scheme.get() + ':'; 
     3510                schemeMatchingPreviewUrl = urlParser.href; 
     3511 
    32973512                // Attempt to match the URL to the control frame's scheme 
    32983513                // and check if it's allowed. If not, try the original URL. 
    3299                 $.each([ to.replace( rscheme, self.scheme() ), to ], function( i, url ) { 
    3300                     $.each( self.allowedUrls, function( i, allowed ) { 
     3514                $.each( [ schemeMatchingPreviewUrl, newPreviewUrl ], function( i, url ) { 
     3515                    $.each( previewer.allowedUrls, function( i, allowed ) { 
    33013516                        var path; 
    33023517 
     
    33093524                        } 
    33103525                    }); 
    3311                     if ( result ) 
     3526                    if ( result ) { 
    33123527                        return false; 
     3528                    } 
    33133529                }); 
    33143530 
     
    33173533            }); 
    33183534 
     3535            previewer.bind( 'ready', previewer.ready ); 
     3536 
     3537            // Start listening for keep-alive messages when iframe first loads. 
     3538            previewer.deferred.active.done( _.bind( previewer.keepPreviewAlive, previewer ) ); 
     3539 
     3540            previewer.bind( 'synced', function() { 
     3541                previewer.send( 'active' ); 
     3542            } ); 
     3543 
    33193544            // Refresh the preview when the URL is changed (but not yet). 
    3320             this.previewUrl.bind( this.refresh ); 
    3321  
    3322             this.scroll = 0; 
    3323             this.bind( 'scroll', function( distance ) { 
    3324                 this.scroll = distance; 
    3325             }); 
    3326  
    3327             // Update the URL when the iframe sends a URL message. 
    3328             this.bind( 'url', this.previewUrl ); 
     3545            previewer.previewUrl.bind( previewer.refresh ); 
     3546 
     3547            previewer.scroll = 0; 
     3548            previewer.bind( 'scroll', function( distance ) { 
     3549                previewer.scroll = distance; 
     3550            }); 
     3551 
     3552            // Update the URL when the iframe sends a URL message, resetting scroll position. If URL is unchanged, then refresh. 
     3553            previewer.bind( 'url', function( url ) { 
     3554                var onUrlChange, urlChanged = false; 
     3555                previewer.scroll = 0; 
     3556                onUrlChange = function() { 
     3557                    urlChanged = true; 
     3558                }; 
     3559                previewer.previewUrl.bind( onUrlChange ); 
     3560                previewer.previewUrl.set( url ); 
     3561                previewer.previewUrl.unbind( onUrlChange ); 
     3562                if ( ! urlChanged ) { 
     3563                    previewer.refresh(); 
     3564                } 
     3565            } ); 
    33293566 
    33303567            // Update the document title when the preview changes. 
    3331             this.bind( 'documentTitle', function ( title ) { 
     3568            previewer.bind( 'documentTitle', function ( title ) { 
    33323569                api.setDocumentTitle( title ); 
    33333570            } ); 
     3571        }, 
     3572 
     3573        /** 
     3574         * Handle the preview receiving the ready message. 
     3575         * 
     3576         * @since 4.7.0 
     3577         * 
     3578         * @param {object} data - Data from preview. 
     3579         * @param {string} data.currentUrl - Current URL. 
     3580         * @param {object} data.activePanels - Active panels. 
     3581         * @param {object} data.activeSections Active sections. 
     3582         * @param {object} data.activeControls Active controls. 
     3583         * @returns {void} 
     3584         */ 
     3585        ready: function( data ) { 
     3586            var previewer = this, synced = {}, constructs; 
     3587 
     3588            synced.settings = api.get(); 
     3589            if ( 'resolved' !== previewer.deferred.active.state() || previewer.loading ) { 
     3590                synced.scroll = previewer.scroll; 
     3591            } 
     3592            previewer.send( 'sync', synced ); 
     3593 
     3594            // Set the previewUrl without causing the url to set the iframe. 
     3595            if ( data.currentUrl ) { 
     3596                previewer.previewUrl.unbind( previewer.refresh ); 
     3597                previewer.previewUrl.set( data.currentUrl ); 
     3598                previewer.previewUrl.bind( previewer.refresh ); 
     3599            } 
     3600 
     3601            /* 
     3602             * Walk over all panels, sections, and controls and set their 
     3603             * respective active states to true if the preview explicitly 
     3604             * indicates as such. 
     3605             */ 
     3606            constructs = { 
     3607                panel: data.activePanels, 
     3608                section: data.activeSections, 
     3609                control: data.activeControls 
     3610            }; 
     3611            _( constructs ).each( function ( activeConstructs, type ) { 
     3612                api[ type ].each( function ( construct, id ) { 
     3613                    var isDynamicallyCreated = _.isUndefined( api.settings[ type + 's' ][ id ] ); 
     3614 
     3615                    /* 
     3616                     * If the construct was created statically in PHP (not dynamically in JS) 
     3617                     * then consider a missing (undefined) value in the activeConstructs to 
     3618                     * mean it should be deactivated (since it is gone). But if it is 
     3619                     * dynamically created then only toggle activation if the value is defined, 
     3620                     * as this means that the construct was also then correspondingly 
     3621                     * created statically in PHP and the active callback is available. 
     3622                     * Otherwise, dynamically-created constructs should normally have 
     3623                     * their active states toggled in JS rather than from PHP. 
     3624                     */ 
     3625                    if ( ! isDynamicallyCreated || ! _.isUndefined( activeConstructs[ id ] ) ) { 
     3626                        if ( activeConstructs[ id ] ) { 
     3627                            construct.activate(); 
     3628                        } else { 
     3629                            construct.deactivate(); 
     3630                        } 
     3631                    } 
     3632                } ); 
     3633            } ); 
     3634 
     3635            if ( data.settingValidities ) { 
     3636                api._handleSettingValidities( { 
     3637                    settingValidities: data.settingValidities, 
     3638                    focusInvalidControl: false 
     3639                } ); 
     3640            } 
     3641        }, 
     3642 
     3643        /** 
     3644         * Keep the preview alive by listening for ready and keep-alive messages. 
     3645         * 
     3646         * If a message is not received in the allotted time then the iframe will be set back to the last known valid URL. 
     3647         * 
     3648         * @since 4.7.0 
     3649         * 
     3650         * @returns {void} 
     3651         */ 
     3652        keepPreviewAlive: function keepPreviewAlive() { 
     3653            var previewer = this, keepAliveTick, timeoutId, handleMissingKeepAlive, scheduleKeepAliveCheck; 
     3654 
     3655            /** 
     3656             * Schedule a preview keep-alive check. 
     3657             * 
     3658             * Note that if a page load takes longer than keepAliveCheck milliseconds, 
     3659             * the keep-alive messages will still be getting sent from the previous 
     3660             * URL. 
     3661             */ 
     3662            scheduleKeepAliveCheck = function() { 
     3663                timeoutId = setTimeout( handleMissingKeepAlive, api.settings.timeouts.keepAliveCheck ); 
     3664            }; 
     3665 
     3666            /** 
     3667             * Set the previewerAlive state to true when receiving a message from the preview. 
     3668             */ 
     3669            keepAliveTick = function() { 
     3670                api.state( 'previewerAlive' ).set( true ); 
     3671                clearTimeout( timeoutId ); 
     3672                scheduleKeepAliveCheck(); 
     3673            }; 
     3674 
     3675            /** 
     3676             * Set the previewerAlive state to false if keepAliveCheck milliseconds have transpired without a message. 
     3677             * 
     3678             * This is most likely to happen in the case of a connectivity error, or if the theme causes the browser 
     3679             * to navigate to a non-allowed URL. Setting this state to false will force settings with a postMessage 
     3680             * transport to use refresh instead, causing the preview frame also to be replaced with the current 
     3681             * allowed preview URL. 
     3682             */ 
     3683            handleMissingKeepAlive = function() { 
     3684                api.state( 'previewerAlive' ).set( false ); 
     3685            }; 
     3686            scheduleKeepAliveCheck(); 
     3687 
     3688            previewer.bind( 'ready', keepAliveTick ); 
     3689            previewer.bind( 'keep-alive', keepAliveTick ); 
    33343690        }, 
    33353691 
     
    33493705 
    33503706        /** 
    3351          * Refresh the preview. 
     3707         * Refresh the preview seamlessly. 
    33523708         */ 
    33533709        refresh: function() { 
    3354             var self = this; 
     3710            var previewer = this; 
    33553711 
    33563712            // Display loading indicator 
    3357             this.send( 'loading-initiated' ); 
    3358  
    3359             this.abort(); 
    3360  
    3361             this.loading = new api.PreviewFrame({ 
    3362                 url:        this.url(), 
    3363                 previewUrl: this.previewUrl(), 
    3364                 query:      this.query() || {}, 
    3365                 container:  this.container, 
    3366                 signature:  this.signature 
    3367             }); 
    3368  
    3369             this.loading.done( function() { 
    3370                 // 'this' is the loading frame 
    3371                 this.bind( 'synced', function() { 
    3372                     if ( self.preview ) 
    3373                         self.preview.destroy(); 
    3374                     self.preview = this; 
    3375                     delete self.loading; 
    3376  
    3377                     self.targetWindow( this.targetWindow() ); 
    3378                     self.channel( this.channel() ); 
    3379  
    3380                     self.deferred.active.resolve(); 
    3381                     self.send( 'active' ); 
    3382                 }); 
    3383  
    3384                 this.send( 'sync', { 
    3385                     scroll:   self.scroll, 
    3386                     settings: api.get() 
    3387                 }); 
    3388             }); 
    3389  
    3390             this.loading.fail( function( reason, location ) { 
    3391                 self.send( 'loading-failed' ); 
    3392                 if ( 'redirect' === reason && location ) { 
    3393                     self.previewUrl( location ); 
    3394                 } 
     3713            previewer.send( 'loading-initiated' ); 
     3714 
     3715            previewer.abort(); 
     3716 
     3717            previewer.loading = new api.PreviewFrame({ 
     3718                url:        previewer.url(), 
     3719                previewUrl: previewer.previewUrl(), 
     3720                query:      previewer.query( { excludeCustomizedSaved: true } ) || {}, 
     3721                container:  previewer.container 
     3722            }); 
     3723 
     3724            previewer.loading.done( function( readyData ) { 
     3725                var loadingFrame = this, previousPreview, onceSynced; 
     3726 
     3727                previousPreview = previewer.preview; 
     3728                previewer.preview = loadingFrame; 
     3729                previewer.targetWindow( loadingFrame.targetWindow() ); 
     3730                previewer.channel( loadingFrame.channel() ); 
     3731 
     3732                onceSynced = function() { 
     3733                    loadingFrame.unbind( 'synced', onceSynced ); 
     3734                    if ( previousPreview ) { 
     3735                        previousPreview.destroy(); 
     3736                    } 
     3737                    previewer.deferred.active.resolve(); 
     3738                    delete previewer.loading; 
     3739                }; 
     3740                loadingFrame.bind( 'synced', onceSynced ); 
     3741 
     3742                // This event will be received directly by the previewer in normal navigation; this is only needed for seamless refresh. 
     3743                previewer.trigger( 'ready', readyData ); 
     3744            }); 
     3745 
     3746            previewer.loading.fail( function( reason ) { 
     3747                previewer.send( 'loading-failed' ); 
    33953748 
    33963749                if ( 'logged out' === reason ) { 
    3397                     if ( self.preview ) { 
    3398                         self.preview.destroy(); 
    3399                         delete self.preview; 
     3750                    if ( previewer.preview ) { 
     3751                        previewer.preview.destroy(); 
     3752                        delete previewer.preview; 
    34003753                    } 
    34013754 
    3402                     self.login().done( self.refresh ); 
     3755                    previewer.login().done( previewer.refresh ); 
    34033756                } 
    34043757 
    34053758                if ( 'cheatin' === reason ) { 
    3406                     self.cheatin(); 
     3759                    previewer.cheatin(); 
    34073760                } 
    34083761            }); 
     
    34643817            request = wp.ajax.post( 'customize_refresh_nonces', { 
    34653818                wp_customize: 'on', 
    3466                 theme: api.settings.theme.stylesheet 
     3819                customize_theme: api.settings.theme.stylesheet 
    34673820            }); 
    34683821 
     
    36804033        } 
    36814034 
     4035        if ( null === api.PreviewFrame.prototype.sensitivity ) { 
     4036            api.PreviewFrame.prototype.sensitivity = api.settings.timeouts.previewFrameSensitivity; 
     4037        } 
     4038        if ( null === api.Previewer.prototype.refreshBuffer ) { 
     4039            api.Previewer.prototype.refreshBuffer = api.settings.timeouts.windowRefresh; 
     4040        } 
     4041 
    36824042        var parent, 
    36834043            body = $( document.body ), 
     
    37234083            form:        '#customize-controls', 
    37244084            previewUrl:  api.settings.url.preview, 
    3725             allowedUrls: api.settings.url.allowed, 
    3726             signature:   'WP_CUSTOMIZER_SIGNATURE' 
     4085            allowedUrls: api.settings.url.allowed 
    37274086        }, { 
    37284087 
     
    37324091             * Build the query to send along with the Preview request. 
    37334092             * 
    3734              * @return {object} 
     4093             * @since 4.7.0 Added options param. 
     4094             * 
     4095             * @param {object}  [options] Options. 
     4096             * @param {boolean} [options.excludeCustomizedSaved=false] Exclude saved settings in customized response (values pending writing to changeset). 
     4097             * @return {object} Query vars. 
    37354098             */ 
    3736             query: function() { 
    3737                 var dirtyCustomized = {}; 
    3738                 api.each( function ( value, key ) { 
    3739                     if ( value._dirty ) { 
    3740                         dirtyCustomized[ key ] = value(); 
    3741                     } 
    3742                 } ); 
    3743  
    3744                 return { 
     4099            query: function( options ) { 
     4100                var queryVars = { 
    37454101                    wp_customize: 'on', 
    3746                     theme:      api.settings.theme.stylesheet, 
    3747                     customized: JSON.stringify( dirtyCustomized ), 
    3748                     nonce:      this.nonce.preview 
     4102                    customize_theme: api.settings.theme.stylesheet, 
     4103                    nonce: this.nonce.preview, 
     4104                    customize_changeset_uuid: api.settings.changeset.uuid 
    37494105                }; 
     4106 
     4107                /* 
     4108                 * Exclude customized data if requested especially for calls to requestChangesetUpdate. 
     4109                 * Changeset updates are differential and so it is a performance waste to send all of 
     4110                 * the dirty settings with each update. 
     4111                 */ 
     4112                queryVars.customized = JSON.stringify( api.dirtyValues( { 
     4113                    unsaved: options && options.excludeCustomizedSaved 
     4114                } ) ); 
     4115 
     4116                return queryVars; 
    37504117            }, 
    37514118 
    3752             save: function() { 
    3753                 var self = this, 
     4119            /** 
     4120             * Save (and publish) the customizer changeset. 
     4121             * 
     4122             * Updates to the changeset are transactional. If any of the settings 
     4123             * are invalid then none of them will be written into the changeset. 
     4124             * A revision will be made for the changeset post if revisions support 
     4125             * has been added to the post type. 
     4126             * 
     4127             * @param {object} [args] Args. 
     4128             * @param {string} [args.status=publish] Status. 
     4129             * @param {string} [args.date] Date, in local time in MySQL format. 
     4130             * @param {string} [args.title] Title 
     4131             * 
     4132             * @returns {jQuery.promise} 
     4133             */ 
     4134            save: function( args ) { 
     4135                var previewer = this, 
     4136                    deferred = $.Deferred(), 
     4137                    changesetStatus = 'publish', 
    37544138                    processing = api.state( 'processing' ), 
    37554139                    submitWhenDoneProcessing, 
     
    37594143                    invalidControls; 
    37604144 
    3761                 body.addClass( 'saving' ); 
     4145                if ( args && args.status ) { 
     4146                    changesetStatus = args.status; 
     4147                } 
     4148 
     4149                if ( api.state( 'saving' ).get() ) { 
     4150                    deferred.reject( 'already_saving' ); 
     4151                    deferred.promise(); 
     4152                } 
     4153 
     4154                api.state( 'saving' ).set( true ); 
    37624155 
    37634156                function captureSettingModifiedDuringSave( setting ) { 
     
    37674160 
    37684161                submit = function () { 
    3769                     var request, query; 
     4162                    var request, query, settingInvalidities = {}; 
    37704163 
    37714164                    /* 
     
    37784171                            if ( 'error' === notification.type && ! notification.fromServer ) { 
    37794172                                invalidSettings.push( setting.id ); 
     4173                                if ( ! settingInvalidities[ setting.id ] ) { 
     4174                                    settingInvalidities[ setting.id ] = {}; 
     4175                                } 
     4176                                settingInvalidities[ setting.id ][ notification.code ] = notification; 
    37804177                            } 
    37814178                        } ); 
     
    37844181                    if ( ! _.isEmpty( invalidControls ) ) { 
    37854182                        _.values( invalidControls )[0][0].focus(); 
    3786                         body.removeClass( 'saving' ); 
    37874183                        api.unbind( 'change', captureSettingModifiedDuringSave ); 
    3788                         return; 
     4184                        deferred.rejectWith( previewer, [ 
     4185                            { setting_invalidities: settingInvalidities } 
     4186                        ] ); 
     4187                        api.state( 'saving' ).set( false ); 
     4188                        return deferred.promise(); 
    37894189                    } 
    37904190 
    3791                     query = $.extend( self.query(), { 
    3792                         nonce:  self.nonce.save 
     4191                    /* 
     4192                     * Note that excludeCustomizedSaved is intentionally false so that the entire 
     4193                     * set of customized data will be included if bypassed changeset update. 
     4194                     */ 
     4195                    query = $.extend( previewer.query( { excludeCustomizedSaved: false } ), { 
     4196                        nonce: previewer.nonce.save, 
     4197                        customize_changeset_status: changesetStatus 
    37934198                    } ); 
     4199                    if ( args && args.date ) { 
     4200                        query.customize_changeset_date = args.date; 
     4201                    } 
     4202                    if ( args && args.title ) { 
     4203                        query.customize_changeset_title = args.title; 
     4204                    } 
     4205 
     4206                    /* 
     4207                     * Note that the dirty customized values will have already been set in the 
     4208                     * changeset and so technically query.customized could be deleted. However, 
     4209                     * it is remaining here to make sure that any settings that got updated 
     4210                     * quietly which may have not triggered an update request will also get 
     4211                     * included in the values that get saved to the changeset. This will ensure 
     4212                     * that values that get injected via the saved event will be included in 
     4213                     * the changeset. This also ensures that setting values that were invalid 
     4214                     * will get re-validated, perhaps in the case of settings that are invalid 
     4215                     * due to dependencies on other settings. 
     4216                     */ 
    37944217                    request = wp.ajax.post( 'customize_save', query ); 
    37954218 
     
    38004223 
    38014224                    request.always( function () { 
    3802                         body.removeClass( 'saving' ); 
     4225                        api.state( 'saving' ).set( false ); 
    38034226                        saveBtn.prop( 'disabled', false ); 
    38044227                        api.unbind( 'change', captureSettingModifiedDuringSave ); 
     
    38064229 
    38074230                    request.fail( function ( response ) { 
     4231 
    38084232                        if ( '0' === response ) { 
    38094233                            response = 'not_logged_in'; 
     
    38144238 
    38154239                        if ( 'invalid_nonce' === response ) { 
    3816                             self.cheatin(); 
     4240                            previewer.cheatin(); 
    38174241                        } else if ( 'not_logged_in' === response ) { 
    3818                             self.preview.iframe.hide(); 
    3819                             self.login().done( function() { 
    3820                                 self.save(); 
    3821                                 self.preview.iframe.show(); 
     4242                            previewer.preview.iframe.hide(); 
     4243                            previewer.login().done( function() { 
     4244                                previewer.save(); 
     4245                                previewer.preview.iframe.show(); 
    38224246                            } ); 
    38234247                        } 
     
    38304254                        } 
    38314255 
     4256                        deferred.rejectWith( previewer, [ response ] ); 
    38324257                        api.trigger( 'error', response ); 
    38334258                    } ); 
     
    38354260                    request.done( function( response ) { 
    38364261 
    3837                         // Clear setting dirty states, if setting wasn't modified while saving. 
    3838                         api.each( function( setting ) { 
    3839                             if ( ! modifiedWhileSaving[ setting.id ] ) { 
    3840                                 setting._dirty = false; 
    3841                             } 
    3842                         } ); 
    3843  
    3844                         api.previewer.send( 'saved', response ); 
     4262                        previewer.send( 'saved', response ); 
     4263 
     4264                        api.state( 'changesetStatus' ).set( response.changeset_status ); 
     4265                        if ( 'publish' === response.changeset_status ) { 
     4266                            api.state( 'changesetStatus' ).set( '' ); 
     4267                            api.settings.changeset.uuid = response.next_changeset_uuid; 
     4268                            parent.send( 'changeset-uuid', api.settings.changeset.uuid ); 
     4269                        } 
    38454270 
    38464271                        if ( response.setting_validities ) { 
     
    38514276                        } 
    38524277 
     4278                        deferred.resolveWith( previewer, [ response ] ); 
    38534279                        api.trigger( 'saved', response ); 
    38544280 
     
    38724298                } 
    38734299 
     4300                return deferred.promise(); 
    38744301            } 
    38754302        }); 
     
    39584385        api.bind( 'ready', api.reflowPaneContents ); 
    39594386        $( [ api.panel, api.section, api.control ] ).each( function ( i, values ) { 
    3960             var debouncedReflowPaneContents = _.debounce( api.reflowPaneContents, 100 ); 
     4387            var debouncedReflowPaneContents = _.debounce( api.reflowPaneContents, api.settings.timeouts.reflowPaneContents ); 
    39614388            values.bind( 'add', debouncedReflowPaneContents ); 
    39624389            values.bind( 'change', debouncedReflowPaneContents ); 
    39634390            values.bind( 'remove', debouncedReflowPaneContents ); 
    39644391        } ); 
     4392 
     4393        // Save and activated states 
     4394        (function() { 
     4395            var state = new api.Values(), 
     4396                saved = state.create( 'saved' ), 
     4397                saving = state.create( 'saving' ), 
     4398                activated = state.create( 'activated' ), 
     4399                processing = state.create( 'processing' ), 
     4400                paneVisible = state.create( 'paneVisible' ), 
     4401                changesetStatus = state.create( 'changesetStatus' ), 
     4402                previewerAlive = state.create( 'previewerAlive' ), 
     4403                populateChangesetUuidParam; 
     4404 
     4405            state.bind( 'change', function() { 
     4406                var canSave; 
     4407 
     4408                if ( ! activated() ) { 
     4409                    saveBtn.val( api.l10n.activate ); 
     4410                    closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel ); 
     4411 
     4412                } else if ( '' === changesetStatus.get() && saved() ) { 
     4413                    saveBtn.val( api.l10n.saved ); 
     4414                    closeBtn.find( '.screen-reader-text' ).text( api.l10n.close ); 
     4415 
     4416                } else { 
     4417                    saveBtn.val( api.l10n.save ); 
     4418                    closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel ); 
     4419                } 
     4420 
     4421                /* 
     4422                 * Save (publish) button should be enabled if saving is not currently happening, 
     4423                 * and if the theme is not active or the changeset exists but is not published. 
     4424                 */ 
     4425                canSave = ! saving() && ( ! activated() || ! saved() || ( '' !== changesetStatus() && 'publish' !== changesetStatus() ) ); 
     4426 
     4427                saveBtn.prop( 'disabled', ! canSave ); 
     4428            }); 
     4429 
     4430            // Set default states. 
     4431            saved( true ); 
     4432            saving( false ); 
     4433            activated( api.settings.theme.active ); 
     4434            processing( 0 ); 
     4435            paneVisible( true ); 
     4436            previewerAlive( true ); 
     4437            changesetStatus( api.settings.changeset.status ); 
     4438 
     4439            api.bind( 'change', function() { 
     4440                state('saved').set( false ); 
     4441            }); 
     4442 
     4443            saving.bind( function( isSaving ) { 
     4444                body.toggleClass( 'saving', isSaving ); 
     4445            } ); 
     4446 
     4447            api.bind( 'saved', function( response ) { 
     4448                state('saved').set( true ); 
     4449                if ( 'publish' === response.changeset_status ) { 
     4450                    state( 'activated' ).set( true ); 
     4451                } 
     4452            }); 
     4453 
     4454            activated.bind( function( to ) { 
     4455                if ( to ) { 
     4456                    api.trigger( 'activated' ); 
     4457                } 
     4458            }); 
     4459 
     4460            populateChangesetUuidParam = function( isIncluded ) { 
     4461                var urlParser, queryParams; 
     4462                urlParser = document.createElement( 'a' ); 
     4463                urlParser.href = location.href; 
     4464                queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 
     4465                if ( isIncluded ) { 
     4466                    if ( queryParams.changeset_uuid === api.settings.changeset.uuid ) { 
     4467                        return; 
     4468                    } 
     4469                    queryParams.changeset_uuid = api.settings.changeset.uuid; 
     4470                } else { 
     4471                    if ( ! queryParams.changeset_uuid ) { 
     4472                        return; 
     4473                    } 
     4474                    delete queryParams.changeset_uuid; 
     4475                } 
     4476                urlParser.search = $.param( queryParams ); 
     4477                history.replaceState( {}, document.title, urlParser.href ); 
     4478            }; 
     4479 
     4480            if ( history.replaceState ) { 
     4481                saved.bind( function( isSaved ) { 
     4482                    if ( ! isSaved ) { 
     4483                        populateChangesetUuidParam( true ); 
     4484                    } 
     4485                } ); 
     4486                changesetStatus.bind( function( newStatus ) { 
     4487                    populateChangesetUuidParam( '' !== newStatus ); 
     4488                } ); 
     4489            } 
     4490 
     4491            // Expose states to the API. 
     4492            api.state = state; 
     4493        }()); 
    39654494 
    39664495        // Check if preview url is valid and load the preview frame. 
     
    39704499            api.previewer.previewUrl( api.settings.url.home ); 
    39714500        } 
    3972  
    3973         // Save and activated states 
    3974         (function() { 
    3975             var state = new api.Values(), 
    3976                 saved = state.create( 'saved' ), 
    3977                 activated = state.create( 'activated' ), 
    3978                 processing = state.create( 'processing' ), 
    3979                 paneVisible = state.create( 'paneVisible' ); 
    3980  
    3981             state.bind( 'change', function() { 
    3982                 if ( ! activated() ) { 
    3983                     saveBtn.val( api.l10n.activate ).prop( 'disabled', false ); 
    3984                     closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel ); 
    3985  
    3986                 } else if ( saved() ) { 
    3987                     saveBtn.val( api.l10n.saved ).prop( 'disabled', true ); 
    3988                     closeBtn.find( '.screen-reader-text' ).text( api.l10n.close ); 
    3989  
    3990                 } else { 
    3991                     saveBtn.val( api.l10n.save ).prop( 'disabled', false ); 
    3992                     closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel ); 
    3993                 } 
    3994             }); 
    3995  
    3996             // Set default states. 
    3997             saved( true ); 
    3998             activated( api.settings.theme.active ); 
    3999             processing( 0 ); 
    4000             paneVisible( true ); 
    4001  
    4002             api.bind( 'change', function() { 
    4003                 state('saved').set( false ); 
    4004             }); 
    4005  
    4006             api.bind( 'saved', function() { 
    4007                 state('saved').set( true ); 
    4008                 state('activated').set( true ); 
    4009             }); 
    4010  
    4011             activated.bind( function( to ) { 
    4012                 if ( to ) { 
    4013                     api.trigger( 'activated' ); 
    4014                 } 
    4015             }); 
    4016  
    4017             // Expose states to the API. 
    4018             api.state = state; 
    4019         }()); 
    40204501 
    40214502        // Button bindings. 
     
    41704651 
    41714652        // Prompt user with AYS dialog if leaving the Customizer with unsaved changes 
    4172         $( window ).on( 'beforeunload', function () { 
     4653        $( window ).on( 'beforeunload.customize-confirm', function () { 
    41734654            if ( ! api.state( 'saved' )() ) { 
    41744655                setTimeout( function() { 
     
    41904671            parent.send( 'title', newTitle ); 
    41914672        }); 
     4673 
     4674        parent.send( 'changeset-uuid', api.settings.changeset.uuid ); 
    41924675 
    41934676        // Initialize the connection with the parent frame. 
     
    42934776        }); 
    42944777 
     4778        // Autosave changeset. 
     4779        ( function() { 
     4780            var timeoutId, updateChangesetWithReschedule, scheduleChangesetUpdate, updatePending = false; 
     4781 
     4782            /** 
     4783             * Request changeset update and then re-schedule the next changeset update time. 
     4784             * 
     4785             * @private 
     4786             */ 
     4787            updateChangesetWithReschedule = function() { 
     4788                if ( ! updatePending ) { 
     4789                    updatePending = true; 
     4790                    api.requestChangesetUpdate().always( function() { 
     4791                        updatePending = false; 
     4792                    } ); 
     4793                } 
     4794                scheduleChangesetUpdate(); 
     4795            }; 
     4796 
     4797            /** 
     4798             * Schedule changeset update. 
     4799             * 
     4800             * @private 
     4801             */ 
     4802            scheduleChangesetUpdate = function() { 
     4803                clearTimeout( timeoutId ); 
     4804                timeoutId = setTimeout( function() { 
     4805                    updateChangesetWithReschedule(); 
     4806                }, api.settings.timeouts.changesetAutoSave ); 
     4807            }; 
     4808 
     4809            // Start auto-save interval for updating changeset. 
     4810            scheduleChangesetUpdate(); 
     4811 
     4812            // Save changeset when focus removed from window. 
     4813            $( window ).on( 'blur.wp-customize-changeset-update', function() { 
     4814                updateChangesetWithReschedule(); 
     4815            } ); 
     4816 
     4817            // Save changeset before unloading window. 
     4818            $( window ).on( 'beforeunload.wp-customize-changeset-update', function() { 
     4819                updateChangesetWithReschedule(); 
     4820            } ); 
     4821        } ()); 
     4822 
    42954823        api.trigger( 'ready' ); 
    42964824    }); 
  • trunk/src/wp-admin/js/customize-widgets.js

    r38709 r38810  
    11551155            params.wp_customize = 'on'; 
    11561156            params.nonce = api.settings.nonce['update-widget']; 
    1157             params.theme = api.settings.theme.stylesheet; 
     1157            params.customize_theme = api.settings.theme.stylesheet; 
    11581158            params.customized = wp.customize.previewer.query().customized; 
    11591159 
  • trunk/src/wp-includes/admin-bar.php

    r38708 r38810  
    367367 * 
    368368 * @param WP_Admin_Bar $wp_admin_bar WP_Admin_Bar instance. 
     369 * @global WP_Customize_Manager $wp_customize 
    369370 */ 
    370371function wp_admin_bar_customize_menu( $wp_admin_bar ) { 
     372    global $wp_customize; 
     373 
    371374    // Don't show for users who can't access the customizer or when in the admin. 
    372375    if ( ! current_user_can( 'customize' ) || is_admin() ) { 
     
    374377    } 
    375378 
     379    // Don't show if the user cannot edit a given customize_changeset post currently being previewed. 
     380    if ( is_customize_preview() && $wp_customize->changeset_post_id() && ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $wp_customize->changeset_post_id() ) ) { 
     381        return; 
     382    } 
     383 
    376384    $current_url = ( is_ssl() ? 'https://' : 'http://' ) . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; 
     385    if ( is_customize_preview() && $wp_customize->changeset_uuid() ) { 
     386        $current_url = remove_query_arg( 'customize_changeset_uuid', $current_url ); 
     387    } 
     388 
    377389    $customize_url = add_query_arg( 'url', urlencode( $current_url ), wp_customize_url() ); 
     390    if ( is_customize_preview() ) { 
     391        $customize_url = add_query_arg( array( 'changeset_uuid' => $wp_customize->changeset_uuid() ), $customize_url ); 
     392    } 
    378393 
    379394    $wp_admin_bar->add_menu( array( 
  • trunk/src/wp-includes/class-wp-customize-manager.php

    r38765 r38810  
    131131 
    132132    /** 
    133      * Return value of check_ajax_referer() in customize_preview_init() method. 
    134      * 
    135      * @since 3.5.0 
    136      * @access protected 
    137      * @var false|int 
    138      */ 
    139     protected $nonce_tick; 
    140  
    141     /** 
    142133     * Panel types that may be rendered from JS templates. 
    143134     * 
     
    194185 
    195186    /** 
     187     * Messenger channel. 
     188     * 
     189     * @since 4.7.0 
     190     * @access protected 
     191     * @var string 
     192     */ 
     193    protected $messenger_channel; 
     194 
     195    /** 
    196196     * Unsanitized values for Customize Settings parsed from $_POST['customized']. 
    197197     * 
     
    201201 
    202202    /** 
     203     * Changeset UUID. 
     204     * 
     205     * @since 4.7.0 
     206     * @access private 
     207     * @var string 
     208     */ 
     209    private $_changeset_uuid; 
     210 
     211    /** 
     212     * Changeset post ID. 
     213     * 
     214     * @since 4.7.0 
     215     * @access private 
     216     * @var int|false 
     217     */ 
     218    private $_changeset_post_id; 
     219 
     220    /** 
     221     * Changeset data loaded from a customize_changeset post. 
     222     * 
     223     * @since 4.7.0 
     224     * @access private 
     225     * @var array 
     226     */ 
     227    private $_changeset_data; 
     228 
     229    /** 
    203230     * Constructor. 
    204231     * 
    205232     * @since 3.4.0 
    206      */ 
    207     public function __construct() { 
     233     * @since 4.7.0 Added $args param. 
     234     * 
     235     * @param array $args { 
     236     *     Args. 
     237     * 
     238     *     @type string $changeset_uuid    Changeset UUID, the post_name for the customize_changeset post containing the customized state. Defaults to new UUID. 
     239     *     @type string $theme             Theme to be previewed (for theme switch). Defaults to customize_theme or theme query params. 
     240     *     @type string $messenger_channel Messenger channel. Defaults to customize_messenger_channel query param. 
     241     * } 
     242     */ 
     243    public function __construct( $args = array() ) { 
     244 
     245        $args = array_merge( 
     246            array_fill_keys( array( 'changeset_uuid', 'theme', 'messenger_channel' ), null ), 
     247            $args 
     248        ); 
     249 
     250        // Note that the UUID format will be validated in the setup_theme() method. 
     251        if ( ! isset( $args['changeset_uuid'] ) ) { 
     252            $args['changeset_uuid'] = wp_generate_uuid4(); 
     253        } 
     254 
     255        // The theme and messenger_channel should be supplied via $args, but they are also looked at in the $_REQUEST global here for back-compat. 
     256        if ( ! isset( $args['theme'] ) ) { 
     257            if ( isset( $_REQUEST['customize_theme'] ) ) { 
     258                $args['theme'] = wp_unslash( $_REQUEST['customize_theme'] ); 
     259            } elseif ( isset( $_REQUEST['theme'] ) ) { // Deprecated. 
     260                $args['theme'] = wp_unslash( $_REQUEST['theme'] ); 
     261            } 
     262        } 
     263        if ( ! isset( $args['messenger_channel'] ) && isset( $_REQUEST['customize_messenger_channel'] ) ) { 
     264            $args['messenger_channel'] = sanitize_key( wp_unslash( $_REQUEST['customize_messenger_channel'] ) ); 
     265        } 
     266 
     267        $this->original_stylesheet = get_stylesheet(); 
     268        $this->theme = wp_get_theme( $args['theme'] ); 
     269        $this->messenger_channel = $args['messenger_channel']; 
     270        $this->_changeset_uuid = $args['changeset_uuid']; 
     271 
    208272        require_once( ABSPATH . WPINC . '/class-wp-customize-setting.php' ); 
    209273        require_once( ABSPATH . WPINC . '/class-wp-customize-panel.php' ); 
     
    272336        } 
    273337 
    274         add_filter( 'wp_die_handler', array( $this, 'wp_die_handler' ) ); 
    275  
    276338        add_action( 'setup_theme', array( $this, 'setup_theme' ) ); 
    277339        add_action( 'wp_loaded',   array( $this, 'wp_loaded' ) ); 
    278  
    279         // Run wp_redirect_status late to make sure we override the status last. 
    280         add_action( 'wp_redirect_status', array( $this, 'wp_redirect_status' ), 1000 ); 
    281340 
    282341        // Do not spawn cron (especially the alternate cron) while running the Customizer. 
     
    341400     */ 
    342401    protected function wp_die( $ajax_message, $message = null ) { 
    343         if ( $this->doing_ajax() || isset( $_POST['customized'] ) ) { 
     402        if ( $this->doing_ajax() ) { 
    344403            wp_die( $ajax_message ); 
    345404        } 
     
    349408        } 
    350409 
     410        if ( $this->messenger_channel ) { 
     411            ob_start(); 
     412            wp_enqueue_scripts(); 
     413            wp_print_scripts( array( 'customize-base' ) ); 
     414 
     415            $settings = array( 
     416                'messengerArgs' => array( 
     417                    'channel' => $this->messenger_channel, 
     418                    'url' => wp_customize_url(), 
     419                ), 
     420                'error' => $ajax_message, 
     421            ); 
     422            ?> 
     423            <script> 
     424            ( function( api, settings ) { 
     425                var preview = new api.Messenger( settings.messengerArgs ); 
     426                preview.send( 'iframe-loading-error', settings.error ); 
     427            } )( wp.customize, <?php echo wp_json_encode( $settings ) ?> ); 
     428            </script> 
     429            <?php 
     430            $message .= ob_get_clean(); 
     431        } 
     432 
    351433        wp_die( $message ); 
    352434    } 
     
    356438     * 
    357439     * @since 3.4.0 
    358      * 
    359      * @return string 
     440     * @deprecated 4.7.0 
     441     * 
     442     * @return callable Die handler. 
    360443     */ 
    361444    public function wp_die_handler() { 
     445        _deprecated_function( __METHOD__, '4.7.0' ); 
     446 
    362447        if ( $this->doing_ajax() || isset( $_POST['customized'] ) ) { 
    363448            return '_ajax_wp_die_handler'; 
     
    375460     */ 
    376461    public function setup_theme() { 
    377         send_origin_headers(); 
    378  
    379         $doing_ajax_or_is_customized = ( $this->doing_ajax() || isset( $_POST['customized'] ) ); 
    380         if ( is_admin() && ! $doing_ajax_or_is_customized ) { 
    381             auth_redirect(); 
    382         } elseif ( $doing_ajax_or_is_customized && ! is_user_logged_in() ) { 
    383             $this->wp_die( 0, __( 'You must be logged in to complete this action.' ) ); 
    384         } 
    385  
    386         show_admin_bar( false ); 
    387  
    388         if ( ! current_user_can( 'customize' ) ) { 
    389             $this->wp_die( -1, __( 'Sorry, you are not allowed to customize this site.' ) ); 
    390         } 
    391  
    392         $this->original_stylesheet = get_stylesheet(); 
    393  
    394         $this->theme = wp_get_theme( isset( $_REQUEST['theme'] ) ? $_REQUEST['theme'] : null ); 
     462        global $pagenow; 
     463 
     464        // Check permissions for customize.php access since this method is called before customize.php can run any code, 
     465        if ( 'customize.php' === $pagenow && ! current_user_can( 'customize' ) ) { 
     466            if ( ! is_user_logged_in() ) { 
     467                auth_redirect(); 
     468            } else { 
     469                wp_die( 
     470                    '<h1>' . __( 'Cheatin&#8217; uh?' ) . '</h1>' . 
     471                    '<p>' . __( 'Sorry, you are not allowed to customize this site.' ) . '</p>', 
     472                    403 
     473                ); 
     474            } 
     475            return; 
     476        } 
     477 
     478        if ( ! preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $this->_changeset_uuid ) ) { 
     479            $this->wp_die( -1, __( 'Invalid changeset UUID' ) ); 
     480        } 
     481 
     482        /* 
     483         * If unauthenticated then require a valid changeset UUID to load the preview. 
     484         * In this way, the UUID serves as a secret key. If the messenger channel is present, 
     485         * then send unauthenticated code to prompt re-auth. 
     486         */ 
     487        if ( ! current_user_can( 'customize' ) && ! $this->changeset_post_id() ) { 
     488            $this->wp_die( $this->messenger_channel ? 0 : -1, __( 'Non-existent changeset UUID.' ) ); 
     489        } 
     490 
     491        if ( ! headers_sent() ) { 
     492            send_origin_headers(); 
     493        } 
     494 
     495        // Hide the admin bar if we're embedded in the customizer iframe. 
     496        if ( $this->messenger_channel ) { 
     497            show_admin_bar( false ); 
     498        } 
    395499 
    396500        if ( $this->is_theme_active() ) { 
     
    508612 
    509613    /** 
     614     * Get the changeset UUID. 
     615     * 
     616     * @since 4.7.0 
     617     * @access public 
     618     * 
     619     * @return string UUID. 
     620     */ 
     621    public function changeset_uuid() { 
     622        return $this->_changeset_uuid; 
     623    } 
     624 
     625    /** 
    510626     * Get the theme being customized. 
    511627     * 
     
    604720        do_action( 'customize_register', $this ); 
    605721 
    606         if ( $this->is_preview() && ! is_admin() ) 
     722        /* 
     723         * Note that settings must be previewed here even outside the customizer preview 
     724         * and also in the customizer pane itself. This is to enable loading an existing 
     725         * changeset into the customizer. Previewing the settings only has to be prevented 
     726         * in the case of a customize_save action because then update_option() 
     727         * may short-circuit because it will detect that there are no changes to 
     728         * make. 
     729         */ 
     730        if ( ! $this->doing_ajax( 'customize_save' ) ) { 
     731            foreach ( $this->settings as $setting ) { 
     732                $setting->preview(); 
     733            } 
     734        } 
     735 
     736        if ( $this->is_preview() && ! is_admin() ) { 
    607737            $this->customize_preview_init(); 
     738        } 
    608739    } 
    609740 
     
    615746     * 
    616747     * @since 3.4.0 
    617      * 
    618      * @param $status 
     748     * @deprecated 4.7.0 
     749     * 
     750     * @param int $status Status. 
    619751     * @return int 
    620752     */ 
    621753    public function wp_redirect_status( $status ) { 
    622         if ( $this->is_preview() && ! is_admin() ) 
     754        _deprecated_function( __FUNCTION__, '4.7.0' ); 
     755 
     756        if ( $this->is_preview() && ! is_admin() ) { 
    623757            return 200; 
     758        } 
    624759 
    625760        return $status; 
     
    627762 
    628763    /** 
    629      * Parse the incoming $_POST['customized'] JSON data and store the unsanitized 
    630      * settings for subsequent post_value() lookups. 
     764     * Find the changeset post ID for a given changeset UUID. 
     765     * 
     766     * @since 4.7.0 
     767     * @access public 
     768     * 
     769     * @param string $uuid Changeset UUID. 
     770     * @return int|null Returns post ID on success and null on failure. 
     771     */ 
     772    public function find_changeset_post_id( $uuid ) { 
     773        $cache_group = 'customize_changeset_post'; 
     774        $changeset_post_id = wp_cache_get( $uuid, $cache_group ); 
     775        if ( $changeset_post_id && 'customize_changeset' === get_post_type( $changeset_post_id ) ) { 
     776            return $changeset_post_id; 
     777        } 
     778 
     779        $changeset_post_query = new WP_Query( array( 
     780            'post_type' => 'customize_changeset', 
     781            'post_status' => get_post_stati(), 
     782            'name' => $uuid, 
     783            'number' => 1, 
     784            'no_found_rows' => true, 
     785            'cache_results' => true, 
     786            'update_post_meta_cache' => false, 
     787            'update_term_meta_cache' => false, 
     788        ) ); 
     789        if ( ! empty( $changeset_post_query->posts ) ) { 
     790            // Note: 'fields'=>'ids' is not being used in order to cache the post object as it will be needed. 
     791            $changeset_post_id = $changeset_post_query->posts[0]->ID; 
     792            wp_cache_set( $this->_changeset_uuid, $changeset_post_id, $cache_group ); 
     793            return $changeset_post_id; 
     794        } 
     795 
     796        return null; 
     797    } 
     798 
     799    /** 
     800     * Get the changeset post id for the loaded changeset. 
     801     * 
     802     * @since 4.7.0 
     803     * @access public 
     804     * 
     805     * @return int|null Post ID on success or null if there is no post yet saved. 
     806     */ 
     807    public function changeset_post_id() { 
     808        if ( ! isset( $this->_changeset_post_id ) ) { 
     809            $post_id = $this->find_changeset_post_id( $this->_changeset_uuid ); 
     810            if ( ! $post_id ) { 
     811                $post_id = false; 
     812            } 
     813            $this->_changeset_post_id = $post_id; 
     814        } 
     815        if ( false === $this->_changeset_post_id ) { 
     816            return null; 
     817        } 
     818        return $this->_changeset_post_id; 
     819    } 
     820 
     821    /** 
     822     * Get the data stored in a changeset post. 
     823     * 
     824     * @since 4.7.0 
     825     * @access protected 
     826     * 
     827     * @param int $post_id Changeset post ID. 
     828     * @return array|WP_Error Changeset data or WP_Error on error. 
     829     */ 
     830    protected function get_changeset_post_data( $post_id ) { 
     831        if ( ! $post_id ) { 
     832            return new WP_Error( 'empty_post_id' ); 
     833        } 
     834        $changeset_post = get_post( $post_id ); 
     835        if ( ! $changeset_post ) { 
     836            return new WP_Error( 'missing_post' ); 
     837        } 
     838        if ( 'customize_changeset' !== $changeset_post->post_type ) { 
     839            return new WP_Error( 'wrong_post_type' ); 
     840        } 
     841        $changeset_data = json_decode( $changeset_post->post_content, true ); 
     842        if ( function_exists( 'json_last_error' ) && json_last_error() ) { 
     843            return new WP_Error( 'json_parse_error', '', json_last_error() ); 
     844        } 
     845        if ( ! is_array( $changeset_data ) ) { 
     846            return new WP_Error( 'expected_array' ); 
     847        } 
     848        return $changeset_data; 
     849    } 
     850 
     851    /** 
     852     * Get changeset data. 
     853     * 
     854     * @since 4.7.0 
     855     * @access public 
     856     * 
     857     * @return array Changeset data. 
     858     */ 
     859    public function changeset_data() { 
     860        if ( isset( $this->_changeset_data ) ) { 
     861            return $this->_changeset_data; 
     862        } 
     863        $changeset_post_id = $this->changeset_post_id(); 
     864        if ( ! $changeset_post_id ) { 
     865            $this->_changeset_data = array(); 
     866        } else { 
     867            $data = $this->get_changeset_post_data( $changeset_post_id ); 
     868            if ( ! is_wp_error( $data ) ) { 
     869                $this->_changeset_data = $data; 
     870            } else { 
     871                $this->_changeset_data = array(); 
     872            } 
     873        } 
     874        return $this->_changeset_data; 
     875    } 
     876 
     877    /** 
     878     * Get dirty pre-sanitized setting values in the current customized state. 
     879     * 
     880     * The returned array consists of a merge of three sources: 
     881     * 1. If the theme is not currently active, then the base array is any stashed 
     882     *    theme mods that were modified previously but never published. 
     883     * 2. The values from the current changeset, if it exists. 
     884     * 3. If the user can customize, the values parsed from the incoming 
     885     *    `$_POST['customized']` JSON data. 
     886     * 4. Any programmatically-set post values via `WP_Customize_Manager::set_post_value()`. 
     887     * 
     888     * The name "unsanitized_post_values" is a carry-over from when the customized 
     889     * state was exclusively sourced from `$_POST['customized']`. Nevertheless, 
     890     * the value returned will come from the current changeset post and from the 
     891     * incoming post data. 
    631892     * 
    632893     * @since 4.1.1 
    633      * 
     894     * @since 4.7.0 Added $args param and merging with changeset values and stashed theme mods. 
     895     * 
     896     * @param array $args { 
     897     *     Args. 
     898     * 
     899     *     @type bool $exclude_changeset Whether the changeset values should also be excluded. Defaults to false. 
     900     *     @type bool $exclude_post_data Whether the post input values should also be excluded. Defaults to false when lacking the customize capability. 
     901     * } 
    634902     * @return array 
    635903     */ 
    636     public function unsanitized_post_values() { 
    637         if ( ! isset( $this->_post_values ) ) { 
    638             if ( isset( $_POST['customized'] ) ) { 
    639                 $this->_post_values = json_decode( wp_unslash( $_POST['customized'] ), true ); 
    640             } 
    641             if ( empty( $this->_post_values ) ) { // if not isset or if JSON error 
    642                 $this->_post_values = array(); 
    643             } 
    644         } 
    645         if ( empty( $this->_post_values ) ) { 
    646             return array(); 
    647         } else { 
    648             return $this->_post_values; 
    649         } 
    650     } 
    651  
    652     /** 
    653      * Returns the sanitized value for a given setting from the request's POST data. 
     904    public function unsanitized_post_values( $args = array() ) { 
     905        $args = array_merge( 
     906            array( 
     907                'exclude_changeset' => false, 
     908                'exclude_post_data' => ! current_user_can( 'customize' ), 
     909            ), 
     910            $args 
     911        ); 
     912 
     913        $values = array(); 
     914 
     915        // Let default values be from the stashed theme mods if doing a theme switch and if no changeset is present. 
     916        if ( ! $this->is_theme_active() ) { 
     917            $stashed_theme_mods = get_option( 'customize_stashed_theme_mods' ); 
     918            $stylesheet = $this->get_stylesheet(); 
     919            if ( isset( $stashed_theme_mods[ $stylesheet ] ) ) { 
     920                $values = array_merge( $values, wp_list_pluck( $stashed_theme_mods[ $stylesheet ], 'value' ) ); 
     921            } 
     922        } 
     923 
     924        if ( ! $args['exclude_changeset'] ) { 
     925            foreach ( $this->changeset_data() as $setting_id => $setting_params ) { 
     926                if ( ! array_key_exists( 'value', $setting_params ) ) { 
     927                    continue; 
     928                } 
     929                if ( isset( $setting_params['type'] ) && 'theme_mod' === $setting_params['type'] ) { 
     930 
     931                    // Ensure that theme mods values are only used if they were saved under the current theme. 
     932                    $namespace_pattern = '/^(?P<stylesheet>.+?)::(?P<setting_id>.+)$/'; 
     933                    if ( preg_match( $namespace_pattern, $setting_id, $matches ) && $this->get_stylesheet() === $matches['stylesheet'] ) { 
     934                        $values[ $matches['setting_id'] ] = $setting_params['value']; 
     935                    } 
     936                } else { 
     937                    $values[ $setting_id ] = $setting_params['value']; 
     938                } 
     939            } 
     940        } 
     941 
     942        if ( ! $args['exclude_post_data'] ) { 
     943            if ( ! isset( $this->_post_values ) ) { 
     944                if ( isset( $_POST['customized'] ) ) { 
     945                    $post_values = json_decode( wp_unslash( $_POST['customized'] ), true ); 
     946                } else { 
     947                    $post_values = array(); 
     948                } 
     949                if ( is_array( $post_values ) ) { 
     950                    $this->_post_values = $post_values; 
     951                } else { 
     952                    $this->_post_values = array(); 
     953                } 
     954            } 
     955            $values = array_merge( $values, $this->_post_values ); 
     956        } 
     957        return $values; 
     958    } 
     959 
     960    /** 
     961     * Returns the sanitized value for a given setting from the current customized state. 
     962     * 
     963     * The name "post_value" is a carry-over from when the customized state was exclusively 
     964     * sourced from `$_POST['customized']`. Nevertheless, the value returned will come 
     965     * from the current changeset post and from the incoming post data. 
    654966     * 
    655967     * @since 3.4.0 
     
    685997 
    686998    /** 
    687      * Override a setting's (unsanitized) value as found in any incoming $_POST['customized']. 
     999     * Override a setting's value in the current customized state. 
     1000     * 
     1001     * The name "post_value" is a carry-over from when the customized state was 
     1002     * exclusively sourced from `$_POST['customized']`. 
    6881003     * 
    6891004     * @since 4.2.0 
     
    6941009     */ 
    6951010    public function set_post_value( $setting_id, $value ) { 
    696         $this->unsanitized_post_values(); 
     1011        $this->unsanitized_post_values(); // Populate _post_values from $_POST['customized']. 
    6971012        $this->_post_values[ $setting_id ] = $value; 
    6981013 
     
    7341049     */ 
    7351050    public function customize_preview_init() { 
    736         $this->nonce_tick = check_ajax_referer( 'preview-customize_' . $this->get_stylesheet(), 'nonce' ); 
     1051 
     1052        /* 
     1053         * Now that Customizer previews are loaded into iframes via GET requests 
     1054         * and natural URLs with transaction UUIDs added, we need to ensure that 
     1055         * the responses are never cached by proxies. In practice, this will not 
     1056         * be needed if the user is logged-in anyway. But if anonymous access is 
     1057         * allowed then the auth cookies would not be sent and WordPress would 
     1058         * not send no-cache headers by default. 
     1059         */ 
     1060        if ( ! headers_sent() ) { 
     1061            nocache_headers(); 
     1062            header( 'X-Robots: noindex, nofollow, noarchive' ); 
     1063        } 
     1064        add_action( 'wp_head', 'wp_no_robots' ); 
     1065        add_filter( 'wp_headers', array( $this, 'filter_iframe_security_headers' ) ); 
     1066 
     1067        /* 
     1068         * If preview is being served inside the customizer preview iframe, and 
     1069         * if the user doesn't have customize capability, then it is assumed 
     1070         * that the user's session has expired and they need to re-authenticate. 
     1071         */ 
     1072        if ( $this->messenger_channel && ! current_user_can( 'customize' ) ) { 
     1073            $this->wp_die( -1, __( 'Unauthorized. You may remove the customize_messenger_channel param to preview as frontend.' ) ); 
     1074            return; 
     1075        } 
    7371076 
    7381077        $this->prepare_controls(); 
    7391078 
     1079        add_filter( 'wp_redirect', array( $this, 'add_state_query_params' ) ); 
     1080 
    7401081        wp_enqueue_script( 'customize-preview' ); 
    741         add_action( 'wp', array( $this, 'customize_preview_override_404_status' ) ); 
    742         add_action( 'wp_head', array( $this, 'customize_preview_base' ) ); 
    7431082        add_action( 'wp_head', array( $this, 'customize_preview_loading_style' ) ); 
    7441083        add_action( 'wp_footer', array( $this, 'customize_preview_settings' ), 20 ); 
    745         add_action( 'shutdown', array( $this, 'customize_preview_signature' ), 1000 ); 
    746         add_filter( 'wp_die_handler', array( $this, 'remove_preview_signature' ) ); 
    747  
    748         foreach ( $this->settings as $setting ) { 
    749             $setting->preview(); 
    750         } 
    7511084 
    7521085        /** 
     
    7621095 
    7631096    /** 
     1097     * Filter the X-Frame-Options and Content-Security-Policy headers to ensure frontend can load in customizer. 
     1098     * 
     1099     * @since 4.7.0 
     1100     * @access public 
     1101     * 
     1102     * @param array $headers Headers. 
     1103     * @return array Headers. 
     1104     */ 
     1105    public function filter_iframe_security_headers( $headers ) { 
     1106        $customize_url = admin_url( 'customize.php' ); 
     1107        $headers['X-Frame-Options'] = 'ALLOW-FROM ' . $customize_url; 
     1108        $headers['Content-Security-Policy'] = 'frame-ancestors ' . preg_replace( '#^(\w+://[^/]+).+?$#', '$1', $customize_url ); 
     1109        return $headers; 
     1110    } 
     1111 
     1112    /** 
     1113     * Add customize state query params to a given URL if preview is allowed. 
     1114     * 
     1115     * @since 4.7.0 
     1116     * @access public 
     1117     * @see wp_redirect() 
     1118     * @see WP_Customize_Manager::get_allowed_url() 
     1119     * 
     1120     * @param string $url URL. 
     1121     * @return string URL. 
     1122     */ 
     1123    public function add_state_query_params( $url ) { 
     1124        $parsed_original_url = wp_parse_url( $url ); 
     1125        $is_allowed = false; 
     1126        foreach ( $this->get_allowed_urls() as $allowed_url ) { 
     1127            $parsed_allowed_url = wp_parse_url( $allowed_url ); 
     1128            $is_allowed = ( 
     1129                $parsed_allowed_url['scheme'] === $parsed_original_url['scheme'] 
     1130                && 
     1131                $parsed_allowed_url['host'] === $parsed_original_url['host'] 
     1132                && 
     1133                0 === strpos( $parsed_original_url['path'], $parsed_allowed_url['path'] ) 
     1134            ); 
     1135            if ( $is_allowed ) { 
     1136                break; 
     1137            } 
     1138        } 
     1139 
     1140        if ( $is_allowed ) { 
     1141            $query_params = array( 
     1142                'customize_changeset_uuid' => $this->changeset_uuid(), 
     1143            ); 
     1144            if ( ! $this->is_theme_active() ) { 
     1145                $query_params['customize_theme'] = $this->get_stylesheet(); 
     1146            } 
     1147            if ( $this->messenger_channel ) { 
     1148                $query_params['customize_messenger_channel'] = $this->messenger_channel; 
     1149            } 
     1150            $url = add_query_arg( $query_params, $url ); 
     1151        } 
     1152 
     1153        return $url; 
     1154    } 
     1155 
     1156    /** 
    7641157     * Prevent sending a 404 status when returning the response for the customize 
    7651158     * preview, since it causes the jQuery Ajax to fail. Send 200 instead. 
    7661159     * 
    7671160     * @since 4.0.0 
     1161     * @deprecated 4.7.0 
    7681162     * @access public 
    7691163     */ 
    7701164    public function customize_preview_override_404_status() { 
    771         if ( is_404() ) { 
    772             status_header( 200 ); 
    773         } 
     1165        _deprecated_function( __METHOD__, '4.7.0' ); 
    7741166    } 
    7751167 
     
    7781170     * 
    7791171     * @since 3.4.0 
     1172     * @deprecated 4.7.0 
    7801173     */ 
    7811174    public function customize_preview_base() { 
    782         ?><base href="<?php echo home_url( '/' ); ?>" /><?php 
     1175        _deprecated_function( __METHOD__, '4.7.0' ); 
    7831176    } 
    7841177 
     
    8101203                pointer-events: none !important; 
    8111204            } 
     1205            form.customize-unpreviewable, 
     1206            form.customize-unpreviewable input, 
     1207            form.customize-unpreviewable select, 
     1208            form.customize-unpreviewable button, 
     1209            a.customize-unpreviewable, 
     1210            area.customize-unpreviewable { 
     1211                cursor: not-allowed !important; 
     1212            } 
    8121213        </style><?php 
    8131214    } 
     
    8191220     */ 
    8201221    public function customize_preview_settings() { 
    821         $setting_validities = $this->validate_setting_values( $this->unsanitized_post_values() ); 
     1222        $post_values = $this->unsanitized_post_values( array( 'exclude_changeset' => true ) ); 
     1223        $setting_validities = $this->validate_setting_values( $post_values ); 
    8221224        $exported_setting_validities = array_map( array( $this, 'prepare_setting_validity_for_js' ), $setting_validities ); 
    8231225 
     1226        // Note that the REQUEST_URI is not passed into home_url() since this breaks subdirectory installs. 
     1227        $self_url = empty( $_SERVER['REQUEST_URI'] ) ? home_url( '/' ) : esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); 
     1228        $state_query_params = array( 
     1229            'customize_theme', 
     1230            'customize_changeset_uuid', 
     1231            'customize_messenger_channel', 
     1232        ); 
     1233        $self_url = remove_query_arg( $state_query_params, $self_url ); 
     1234 
     1235        $allowed_urls = $this->get_allowed_urls(); 
     1236        $allowed_hosts = array(); 
     1237        foreach ( $allowed_urls as $allowed_url ) { 
     1238            $parsed = wp_parse_url( $allowed_url ); 
     1239            if ( empty( $parsed['host'] ) ) { 
     1240                continue; 
     1241            } 
     1242            $host = $parsed['host']; 
     1243            if ( ! empty( $parsed['port'] ) ) { 
     1244                $host .= ':' . $parsed['port']; 
     1245            } 
     1246            $allowed_hosts[] = $host; 
     1247        } 
    8241248        $settings = array( 
     1249            'changeset' => array( 
     1250                'uuid' => $this->_changeset_uuid, 
     1251            ), 
     1252            'timeouts' => array( 
     1253                'selectiveRefresh' => 250, 
     1254                'keepAliveSend' => 1000, 
     1255            ), 
    8251256            'theme' => array( 
    8261257                'stylesheet' => $this->get_stylesheet(), 
     
    8281259            ), 
    8291260            'url' => array( 
    830                 'self' => empty( $_SERVER['REQUEST_URI'] ) ? home_url( '/' ) : esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ), 
     1261                'self' => $self_url, 
     1262                'allowed' => array_map( 'esc_url_raw', $this->get_allowed_urls() ), 
     1263                'allowedHosts' => array_unique( $allowed_hosts ), 
     1264                'isCrossDomain' => $this->is_cross_domain(), 
    8311265            ), 
    832             'channel' => wp_unslash( $_POST['customize_messenger_channel'] ), 
     1266            'channel' => $this->messenger_channel, 
    8331267            'activePanels' => array(), 
    8341268            'activeSections' => array(), 
    8351269            'activeControls' => array(), 
    8361270            'settingValidities' => $exported_setting_validities, 
    837             'nonce' => $this->get_nonces(), 
     1271            'nonce' => current_user_can( 'customize' ) ? $this->get_nonces() : array(), 
    8381272            'l10n' => array( 
    8391273                'shiftClickToEdit' => __( 'Shift-click to edit this element.' ), 
     1274                'linkUnpreviewable' => __( 'This link is not live-previewable.' ), 
     1275                'formUnpreviewable' => __( 'This form is not live-previewable.' ), 
    8401276            ), 
    841             '_dirty' => array_keys( $this->unsanitized_post_values() ), 
     1277            '_dirty' => array_keys( $post_values ), 
    8421278        ); 
    8431279 
     
    8931329     * 
    8941330     * @since 3.4.0 
     1331     * @deprecated 4.7.0 
    8951332     */ 
    8961333    public function customize_preview_signature() { 
    897         echo 'WP_CUSTOMIZER_SIGNATURE'; 
     1334        _deprecated_function( __METHOD__, '4.7.0' ); 
    8981335    } 
    8991336 
     
    9021339     * 
    9031340     * @since 3.4.0 
     1341     * @deprecated 4.7.0 
    9041342     * 
    9051343     * @param mixed $return Value passed through for {@see 'wp_die_handler'} filter. 
     
    9071345     */ 
    9081346    public function remove_preview_signature( $return = null ) { 
    909         remove_action( 'shutdown', array( $this, 'customize_preview_signature' ), 1000 ); 
     1347        _deprecated_function( __METHOD__, '4.7.0' ); 
    9101348 
    9111349        return $return; 
     
    9941432     * 
    9951433     * @param array $setting_values Mapping of setting IDs to values to validate and sanitize. 
     1434     * @param array $options { 
     1435     *     Options. 
     1436     * 
     1437     *     @type bool $validate_existence  Whether a setting's existence will be checked. 
     1438     *     @type bool $validate_capability Whether the setting capability will be checked. 
     1439     * } 
    9961440     * @return array Mapping of setting IDs to return value of validate method calls, either `true` or `WP_Error`. 
    9971441     */ 
    998     public function validate_setting_values( $setting_values ) { 
     1442    public function validate_setting_values( $setting_values, $options = array() ) { 
     1443        $options = wp_parse_args( $options, array( 
     1444            'validate_capability' => false, 
     1445            'validate_existence' => false, 
     1446        ) ); 
     1447 
    9991448        $validities = array(); 
    10001449        foreach ( $setting_values as $setting_id => $unsanitized_value ) { 
    10011450            $setting = $this->get_setting( $setting_id ); 
    1002             if ( ! $setting || is_null( $unsanitized_value ) ) { 
     1451            if ( ! $setting ) { 
     1452                if ( $options['validate_existence'] ) { 
     1453                    $validities[ $setting_id ] = new WP_Error( 'unrecognized', __( 'Setting does not exist or is unrecognized.' ) ); 
     1454                } 
    10031455                continue; 
    10041456            } 
    1005             $validity = $setting->validate( $unsanitized_value ); 
     1457            if ( is_null( $unsanitized_value ) ) { 
     1458                continue; 
     1459            } 
     1460            if ( $options['validate_capability'] && ! current_user_can( $setting->capability ) ) { 
     1461                $validity = new WP_Error( 'unauthorized', __( 'Unauthorized to modify setting due to capability.' ) ); 
     1462            } else { 
     1463                $validity = $setting->validate( $unsanitized_value ); 
     1464            } 
    10061465            if ( ! is_wp_error( $validity ) ) { 
    10071466                /** This filter is documented in wp-includes/class-wp-customize-setting.php */ 
     
    10571516 
    10581517    /** 
    1059      * Switch the theme and trigger the save() method on each setting. 
    1060      * 
    1061      * @since 3.4.0 
     1518     * Handle customize_save WP Ajax request to save/update a changeset. 
     1519     * 
     1520     * @since 3.4.0 
     1521     * @since 4.7.0 The semantics of this method have changed to update a changeset, optionally to also change the status and other attributes. 
    10621522     */ 
    10631523    public function save() { 
     1524        if ( ! is_user_logged_in() ) { 
     1525            wp_send_json_error( 'unauthenticated' ); 
     1526        } 
     1527 
    10641528        if ( ! $this->is_preview() ) { 
    10651529            wp_send_json_error( 'not_preview' ); 
     
    10711535        } 
    10721536 
     1537        $changeset_post_id = $this->changeset_post_id(); 
     1538        if ( $changeset_post_id && in_array( get_post_status( $changeset_post_id ), array( 'publish', 'trash' ) ) ) { 
     1539            wp_send_json_error( 'changeset_already_published' ); 
     1540        } 
     1541 
     1542        if ( empty( $changeset_post_id ) ) { 
     1543            if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->create_posts ) ) { 
     1544                wp_send_json_error( 'cannot_create_changeset_post' ); 
     1545            } 
     1546        } else { 
     1547            if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $changeset_post_id ) ) { 
     1548                wp_send_json_error( 'cannot_edit_changeset_post' ); 
     1549            } 
     1550        } 
     1551 
     1552        if ( ! empty( $_POST['customize_changeset_data'] ) ) { 
     1553            $input_changeset_data = json_decode( wp_unslash( $_POST['customize_changeset_data'] ), true ); 
     1554            if ( ! is_array( $input_changeset_data ) ) { 
     1555                wp_send_json_error( 'invalid_customize_changeset_data' ); 
     1556            } 
     1557        } else { 
     1558            $input_changeset_data = array(); 
     1559        } 
     1560 
     1561        // Validate title. 
     1562        $changeset_title = null; 
     1563        if ( isset( $_POST['customize_changeset_title'] ) ) { 
     1564            $changeset_title = sanitize_text_field( wp_unslash( $_POST['customize_changeset_title'] ) ); 
     1565        } 
     1566 
     1567        // Validate changeset status param. 
     1568        $is_publish = null; 
     1569        $changeset_status = null; 
     1570        if ( isset( $_POST['customize_changeset_status'] ) ) { 
     1571            $changeset_status = wp_unslash( $_POST['customize_changeset_status'] ); 
     1572            if ( ! get_post_status_object( $changeset_status ) || ! in_array( $changeset_status, array( 'draft', 'pending', 'publish', 'future' ), true ) ) { 
     1573                wp_send_json_error( 'bad_customize_changeset_status', 400 ); 
     1574            } 
     1575            $is_publish = ( 'publish' === $changeset_status || 'future' === $changeset_status ); 
     1576            if ( $is_publish ) { 
     1577                if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->publish_posts ) ) { 
     1578                    wp_send_json_error( 'changeset_publish_unauthorized', 403 ); 
     1579                } 
     1580                if ( false === has_action( 'transition_post_status', '_wp_customize_publish_changeset' ) ) { 
     1581                    wp_send_json_error( 'missing_publish_callback', 500 ); 
     1582                } 
     1583            } 
     1584        } 
     1585 
     1586        /* 
     1587         * Validate changeset date param. Date is assumed to be in local time for 
     1588         * the WP if in MySQL format (YYYY-MM-DD HH:MM:SS). Otherwise, the date 
     1589         * is parsed with strtotime() so that ISO date format may be supplied 
     1590         * or a string like "+10 minutes". 
     1591         */ 
     1592        $changeset_date_gmt = null; 
     1593        if ( isset( $_POST['customize_changeset_date'] ) ) { 
     1594            $changeset_date = wp_unslash( $_POST['customize_changeset_date'] ); 
     1595            if ( preg_match( '/^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d$/', $changeset_date ) ) { 
     1596                $mm = substr( $changeset_date, 5, 2 ); 
     1597                $jj = substr( $changeset_date, 8, 2 ); 
     1598                $aa = substr( $changeset_date, 0, 4 ); 
     1599                $valid_date = wp_checkdate( $mm, $jj, $aa, $changeset_date ); 
     1600                if ( ! $valid_date ) { 
     1601                    wp_send_json_error( 'bad_customize_changeset_date', 400 ); 
     1602                } 
     1603                $changeset_date_gmt = get_gmt_from_date( $changeset_date ); 
     1604            } else { 
     1605                $timestamp = strtotime( $changeset_date ); 
     1606                if ( ! $timestamp ) { 
     1607                    wp_send_json_error( 'bad_customize_changeset_date', 400 ); 
     1608                } 
     1609                $changeset_date_gmt = gmdate( 'Y-m-d H:i:s', $timestamp ); 
     1610            } 
     1611            $now = gmdate( 'Y-m-d H:i:59' ); 
     1612 
     1613            $is_future_dated = ( mysql2date( 'U', $changeset_date_gmt, false ) > mysql2date( 'U', $now, false ) ); 
     1614            if ( ! $is_future_dated ) { 
     1615                wp_send_json_error( 'not_future_date', 400 ); // Only future dates are allowed. 
     1616            } 
     1617 
     1618            if ( ! $this->is_theme_active() && ( 'future' === $changeset_status || $is_future_dated ) ) { 
     1619                wp_send_json_error( 'cannot_schedule_theme_switches', 400 ); // This should be allowed in the future, when theme is a regular setting. 
     1620            } 
     1621            $will_remain_auto_draft = ( ! $changeset_status && ( ! $changeset_post_id || 'auto-draft' === get_post_status( $changeset_post_id ) ) ); 
     1622            if ( $changeset_date && $will_remain_auto_draft ) { 
     1623                wp_send_json_error( 'cannot_supply_date_for_auto_draft_changeset', 400 ); 
     1624            } 
     1625        } 
     1626 
     1627        $r = $this->save_changeset_post( array( 
     1628            'status' => $changeset_status, 
     1629            'title' => $changeset_title, 
     1630            'date_gmt' => $changeset_date_gmt, 
     1631            'data' => $input_changeset_data, 
     1632        ) ); 
     1633        if ( is_wp_error( $r ) ) { 
     1634            $response = $r->get_error_data(); 
     1635        } else { 
     1636            $response = $r; 
     1637 
     1638            // Note that if the changeset status was publish, then it will get set to trash if revisions are not supported. 
     1639            $response['changeset_status'] = get_post_status( $this->changeset_post_id() ); 
     1640            if ( $is_publish && 'trash' === $response['changeset_status'] ) { 
     1641                $response['changeset_status'] = 'publish'; 
     1642            } 
     1643 
     1644            if ( 'publish' === $response['changeset_status'] ) { 
     1645                $response['next_changeset_uuid'] = wp_generate_uuid4(); 
     1646            } 
     1647        } 
     1648 
     1649        if ( isset( $response['setting_validities'] ) ) { 
     1650            $response['setting_validities'] = array_map( array( $this, 'prepare_setting_validity_for_js' ), $response['setting_validities'] ); 
     1651        } 
     1652 
     1653        /** 
     1654         * Filters response data for a successful customize_save Ajax request. 
     1655         * 
     1656         * This filter does not apply if there was a nonce or authentication failure. 
     1657         * 
     1658         * @since 4.2.0 
     1659         * 
     1660         * @param array                $response Additional information passed back to the 'saved' 
     1661         *                                       event on `wp.customize`. 
     1662         * @param WP_Customize_Manager $this     WP_Customize_Manager instance. 
     1663         */ 
     1664        $response = apply_filters( 'customize_save_response', $response, $this ); 
     1665 
     1666        if ( is_wp_error( $r ) ) { 
     1667            wp_send_json_error( $response ); 
     1668        } else { 
     1669            wp_send_json_success( $response ); 
     1670        } 
     1671    } 
     1672 
     1673    /** 
     1674     * Save the post for the loaded changeset. 
     1675     * 
     1676     * @since 4.7.0 
     1677     * @access public 
     1678     * 
     1679     * @param array $args { 
     1680     *     Args for changeset post. 
     1681     * 
     1682     *     @type array  $data     Optional additional changeset data. Values will be merged on top of any existing post values. 
     1683     *     @type string $status   Post status. Optional. If supplied, the save will be transactional and a post revision will be allowed. 
     1684     *     @type string $title    Post title. Optional. 
     1685     *     @type string $date_gmt Date in GMT. Optional. 
     1686     * } 
     1687     * 
     1688     * @return array|WP_Error Returns array on success and WP_Error with array data on error. 
     1689     */ 
     1690    function save_changeset_post( $args = array() ) { 
     1691 
     1692        $args = array_merge( 
     1693            array( 
     1694                'status' => null, 
     1695                'title' => null, 
     1696                'data' => array(), 
     1697                'date_gmt' => null, 
     1698            ), 
     1699            $args 
     1700        ); 
     1701 
     1702        $changeset_post_id = $this->changeset_post_id(); 
     1703 
     1704        // The request was made via wp.customize.previewer.save(). 
     1705        $update_transactionally = (bool) $args['status']; 
     1706        $allow_revision = (bool) $args['status']; 
     1707 
     1708        // Amend post values with any supplied data. 
     1709        foreach ( $args['data'] as $setting_id => $setting_params ) { 
     1710            if ( array_key_exists( 'value', $setting_params ) ) { 
     1711                $this->set_post_value( $setting_id, $setting_params['value'] ); // Add to post values so that they can be validated and sanitized. 
     1712            } 
     1713        } 
     1714 
     1715        // Note that in addition to post data, this will include any stashed theme mods. 
     1716        $post_values = $this->unsanitized_post_values( array( 
     1717            'exclude_changeset' => true, 
     1718            'exclude_post_data' => false, 
     1719        ) ); 
     1720        $this->add_dynamic_settings( array_keys( $post_values ) ); // Ensure settings get created even if they lack an input value. 
     1721 
    10731722        /** 
    10741723         * Fires before save validation happens. 
     
    10851734 
    10861735        // Validate settings. 
    1087         $setting_validities = $this->validate_setting_values( $this->unsanitized_post_values() ); 
     1736        $setting_validities = $this->validate_setting_values( $post_values, array( 
     1737            'validate_capability' => true, 
     1738            'validate_existence' => true, 
     1739        ) ); 
    10881740        $invalid_setting_count = count( array_filter( $setting_validities, 'is_wp_error' ) ); 
    1089         $exported_setting_validities = array_map( array( $this, 'prepare_setting_validity_for_js' ), $setting_validities ); 
    1090         if ( $invalid_setting_count > 0 ) { 
     1741 
     1742        /* 
     1743         * Short-circuit if there are invalid settings the update is transactional. 
     1744         * A changeset update is transactional when a status is supplied in the request. 
     1745         */ 
     1746        if ( $update_transactionally && $invalid_setting_count > 0 ) { 
    10911747            $response = array( 
    1092                 'setting_validities' => $exported_setting_validities, 
     1748                'setting_validities' => $setting_validities, 
    10931749                'message' => sprintf( _n( 'There is %s invalid setting.', 'There are %s invalid settings.', $invalid_setting_count ), number_format_i18n( $invalid_setting_count ) ), 
    10941750            ); 
    1095  
    1096             /** This filter is documented in wp-includes/class-wp-customize-manager.php */ 
    1097             $response = apply_filters( 'customize_save_response', $response, $this ); 
    1098             wp_send_json_error( $response ); 
    1099         } 
    1100  
    1101         // Do we have to switch themes? 
    1102         if ( ! $this->is_theme_active() ) { 
    1103             // Temporarily stop previewing the theme to allow switch_themes() 
    1104             // to operate properly. 
     1751            return new WP_Error( 'transaction_fail', '', $response ); 
     1752        } 
     1753 
     1754        $response = array( 
     1755            'setting_validities' => $setting_validities, 
     1756        ); 
     1757 
     1758        // Obtain/merge data for changeset. 
     1759        $original_changeset_data = $this->get_changeset_post_data( $changeset_post_id ); 
     1760        $data = $original_changeset_data; 
     1761        if ( is_wp_error( $data ) ) { 
     1762            $data = array(); 
     1763        } 
     1764 
     1765        // Ensure that all post values are included in the changeset data. 
     1766        foreach ( $post_values as $setting_id => $post_value ) { 
     1767            if ( ! isset( $args['data'][ $setting_id ] ) ) { 
     1768                $args['data'][ $setting_id ] = array(); 
     1769            } 
     1770            if ( ! isset( $args['data'][ $setting_id ]['value'] ) ) { 
     1771                $args['data'][ $setting_id ]['value'] = $post_value; 
     1772            } 
     1773        } 
     1774 
     1775        foreach ( $args['data'] as $setting_id => $setting_params ) { 
     1776            $setting = $this->get_setting( $setting_id ); 
     1777            if ( ! $setting || ! $setting->check_capabilities() ) { 
     1778                continue; 
     1779            } 
     1780 
     1781            // Skip updating changeset for invalid setting values. 
     1782            if ( isset( $setting_validities[ $setting_id ] ) && is_wp_error( $setting_validities[ $setting_id ] ) ) { 
     1783                continue; 
     1784            } 
     1785 
     1786            $changeset_setting_id = $setting_id; 
     1787            if ( 'theme_mod' === $setting->type ) { 
     1788                $changeset_setting_id = sprintf( '%s::%s', $this->get_stylesheet(), $setting_id ); 
     1789            } 
     1790 
     1791            if ( null === $setting_params ) { 
     1792                // Remove setting from changeset entirely. 
     1793                unset( $data[ $changeset_setting_id ] ); 
     1794            } else { 
     1795                // Merge any additional setting params that have been supplied with the existing params. 
     1796                if ( ! isset( $data[ $changeset_setting_id ] ) ) { 
     1797                    $data[ $changeset_setting_id ] = array(); 
     1798                } 
     1799                $data[ $changeset_setting_id ] = array_merge( 
     1800                    $data[ $changeset_setting_id ], 
     1801                    $setting_params, 
     1802                    array( 'type' => $setting->type ) 
     1803                ); 
     1804            } 
     1805        } 
     1806 
     1807        $filter_context = array( 
     1808            'uuid' => $this->changeset_uuid(), 
     1809            'title' => $args['title'], 
     1810            'status' => $args['status'], 
     1811            'date_gmt' => $args['date_gmt'], 
     1812            'post_id' => $changeset_post_id, 
     1813            'previous_data' => is_wp_error( $original_changeset_data ) ? array() : $original_changeset_data, 
     1814            'manager' => $this, 
     1815        ); 
     1816 
     1817        /** 
     1818         * Filters the settings' data that will be persisted into the changeset. 
     1819         * 
     1820         * Plugins may amend additional data (such as additional meta for settings) into the changeset with this filter. 
     1821         * 
     1822         * @since 4.7.0 
     1823         * 
     1824         * @param array $data Updated changeset data, mapping setting IDs to arrays containing a $value item and optionally other metadata. 
     1825         * @param array $context { 
     1826         *     Filter context. 
     1827         * 
     1828         *     @type string               $uuid          Changeset UUID. 
     1829         *     @type string               $title         Requested title for the changeset post. 
     1830         *     @type string               $status        Requested status for the changeset post. 
     1831         *     @type string               $date_gmt      Requested date for the changeset post in MySQL format and GMT timezone. 
     1832         *     @type int|false            $post_id       Post ID for the changeset, or false if it doesn't exist yet. 
     1833         *     @type array                $previous_data Previous data contained in the changeset. 
     1834         *     @type WP_Customize_Manager $manager       Manager instance. 
     1835         * } 
     1836         */ 
     1837        $data = apply_filters( 'customize_changeset_save_data', $data, $filter_context ); 
     1838 
     1839        // Switch theme if publishing changes now. 
     1840        if ( 'publish' === $args['status'] && ! $this->is_theme_active() ) { 
     1841            // Temporarily stop previewing the theme to allow switch_themes() to operate properly. 
    11051842            $this->stop_previewing_theme(); 
    11061843            switch_theme( $this->get_stylesheet() ); 
     
    11091846        } 
    11101847 
     1848        // Gather the data for wp_insert_post()/wp_update_post(). 
     1849        $json_options = 0; 
     1850        if ( defined( 'JSON_UNESCAPED_SLASHES' ) ) { 
     1851            $json_options |= JSON_UNESCAPED_SLASHES; // Introduced in PHP 5.4. This is only to improve readability as slashes needn't be escaped in storage. 
     1852        } 
     1853        $json_options |= JSON_PRETTY_PRINT; // Also introduced in PHP 5.4, but WP defines constant for back compat. See WP Trac #30139. 
     1854        $post_array = array( 
     1855            'post_content' => wp_json_encode( $data, $json_options ), 
     1856        ); 
     1857        if ( $args['title'] ) { 
     1858            $post_array['post_title'] = $args['title']; 
     1859        } 
     1860        if ( $changeset_post_id ) { 
     1861            $post_array['ID'] = $changeset_post_id; 
     1862        } else { 
     1863            $post_array['post_type'] = 'customize_changeset'; 
     1864            $post_array['post_name'] = $this->changeset_uuid(); 
     1865            $post_array['post_status'] = 'auto-draft'; 
     1866        } 
     1867        if ( $args['status'] ) { 
     1868            $post_array['post_status'] = $args['status']; 
     1869        } 
     1870        if ( $args['date_gmt'] ) { 
     1871            $post_array['post_date_gmt'] = $args['date_gmt']; 
     1872            $post_array['post_date'] = get_date_from_gmt( $args['date_gmt'] ); 
     1873        } 
     1874 
     1875        $this->store_changeset_revision = $allow_revision; 
     1876        add_filter( 'wp_save_post_revision_post_has_changed', array( $this, '_filter_revision_post_has_changed' ), 5, 3 ); 
     1877 
     1878        // Update the changeset post. The publish_customize_changeset action will cause the settings in the changeset to be saved via WP_Customize_Setting::save(). 
     1879        $has_kses = ( false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ) ); 
     1880        if ( $has_kses ) { 
     1881            kses_remove_filters(); // Prevent KSES from corrupting JSON in post_content. 
     1882        } 
     1883 
     1884        // Note that updating a post with publish status will trigger WP_Customize_Manager::publish_changeset_values(). 
     1885        if ( $changeset_post_id ) { 
     1886            $post_array['edit_date'] = true; // Prevent date clearing. 
     1887            $r = wp_update_post( wp_slash( $post_array ), true ); 
     1888        } else { 
     1889            $r = wp_insert_post( wp_slash( $post_array ), true ); 
     1890            if ( ! is_wp_error( $r ) ) { 
     1891                $this->_changeset_post_id = $r; // Update cached post ID for the loaded changeset. 
     1892            } 
     1893        } 
     1894        if ( $has_kses ) { 
     1895            kses_init_filters(); 
     1896        } 
     1897        $this->_changeset_data = null; // Reset so WP_Customize_Manager::changeset_data() will re-populate with updated contents. 
     1898 
     1899        remove_filter( 'wp_save_post_revision_post_has_changed', array( $this, '_filter_revision_post_has_changed' ) ); 
     1900 
     1901        if ( is_wp_error( $r ) ) { 
     1902            $response['changeset_post_save_failure'] = $r->get_error_code(); 
     1903            return new WP_Error( 'changeset_post_save_failure', '', $response ); 
     1904        } 
     1905 
     1906        return $response; 
     1907    } 
     1908 
     1909    /** 
     1910     * Whether a changeset revision should be made. 
     1911     * 
     1912     * @since 4.7.0 
     1913     * @access private 
     1914     * @var bool 
     1915     */ 
     1916    protected $store_changeset_revision; 
     1917 
     1918    /** 
     1919     * Filters whether a changeset has changed to create a new revision. 
     1920     * 
     1921     * Note that this will not be called while a changeset post remains in auto-draft status. 
     1922     * 
     1923     * @since 4.7.0 
     1924     * @access private 
     1925     * 
     1926     * @param bool    $post_has_changed Whether the post has changed. 
     1927     * @param WP_Post $last_revision    The last revision post object. 
     1928     * @param WP_Post $post             The post object. 
     1929     * 
     1930     * @return bool Whether a revision should be made. 
     1931     */ 
     1932    public function _filter_revision_post_has_changed( $post_has_changed, $last_revision, $post ) { 
     1933        unset( $last_revision ); 
     1934        if ( 'customize_changeset' === $post->post_type ) { 
     1935            $post_has_changed = $this->store_changeset_revision; 
     1936        } 
     1937        return $post_has_changed; 
     1938    } 
     1939 
     1940    /** 
     1941     * Publish changeset values. 
     1942     * 
     1943     * This will the values contained in a changeset, even changesets that do not 
     1944     * correspond to current manager instance. This is called by 
     1945     * `_wp_customize_publish_changeset()` when a customize_changeset post is 
     1946     * transitioned to the `publish` status. As such, this method should not be 
     1947     * called directly and instead `wp_publish_post()` should be used. 
     1948     * 
     1949     * Please note that if the settings in the changeset are for a non-activated 
     1950     * theme, the theme must first be switched to (via `switch_theme()`) before 
     1951     * invoking this method. 
     1952     * 
     1953     * @since 4.7.0 
     1954     * @access private 
     1955     * @see _wp_customize_publish_changeset() 
     1956     * 
     1957     * @param int $changeset_post_id ID for customize_changeset post. Defaults to the changeset for the current manager instance. 
     1958     * @return true|WP_Error True or error info. 
     1959     */ 
     1960    public function _publish_changeset_values( $changeset_post_id ) { 
     1961        $publishing_changeset_data = $this->get_changeset_post_data( $changeset_post_id ); 
     1962        if ( is_wp_error( $publishing_changeset_data ) ) { 
     1963            return $publishing_changeset_data; 
     1964        } 
     1965 
     1966        $changeset_post = get_post( $changeset_post_id ); 
     1967 
     1968        /* 
     1969         * Temporarily override the changeset context so that it will be read 
     1970         * in calls to unsanitized_post_values() and so that it will be available 
     1971         * on the $wp_customize object passed to hooks during the save logic. 
     1972         */ 
     1973        $previous_changeset_post_id = $this->_changeset_post_id; 
     1974        $this->_changeset_post_id   = $changeset_post_id; 
     1975        $previous_changeset_uuid    = $this->_changeset_uuid; 
     1976        $this->_changeset_uuid      = $changeset_post->post_name; 
     1977        $previous_changeset_data    = $this->_changeset_data; 
     1978        $this->_changeset_data      = $publishing_changeset_data; 
     1979 
     1980        // Ensure that other theme mods are stashed. 
     1981        $other_theme_mod_settings = array(); 
     1982        if ( did_action( 'switch_theme' ) ) { 
     1983            $namespace_pattern = '/^(?P<stylesheet>.+?)::(?P<setting_id>.+)$/'; 
     1984            $matches = array(); 
     1985            foreach ( $this->_changeset_data as $raw_setting_id => $setting_params ) { 
     1986                $is_other_theme_mod = ( 
     1987                    isset( $setting_params['value'] ) 
     1988                    && 
     1989                    isset( $setting_params['type'] ) 
     1990                    && 
     1991                    'theme_mod' === $setting_params['type'] 
     1992                    && 
     1993                    preg_match( $namespace_pattern, $raw_setting_id, $matches ) 
     1994                    && 
     1995                    $this->get_stylesheet() !== $matches['stylesheet'] 
     1996                ); 
     1997                if ( $is_other_theme_mod ) { 
     1998                    if ( ! isset( $other_theme_mod_settings[ $matches['stylesheet'] ] ) ) { 
     1999                        $other_theme_mod_settings[ $matches['stylesheet'] ] = array(); 
     2000                    } 
     2001                    $other_theme_mod_settings[ $matches['stylesheet'] ][ $matches['setting_id'] ] = $setting_params; 
     2002                } 
     2003            } 
     2004        } 
     2005 
     2006        $changeset_setting_values = $this->unsanitized_post_values( array( 
     2007            'exclude_post_data' => true, 
     2008            'exclude_changeset' => false, 
     2009        ) ); 
     2010        $changeset_setting_ids = array_keys( $changeset_setting_values ); 
     2011        $this->add_dynamic_settings( $changeset_setting_ids ); 
     2012 
    11112013        /** 
    11122014         * Fires once the theme has switched in the Customizer, but before settings 
     
    11152017         * @since 3.4.0 
    11162018         * 
    1117          * @param WP_Customize_Manager $this WP_Customize_Manager instance. 
     2019         * @param WP_Customize_Manager $manager WP_Customize_Manager instance. 
    11182020         */ 
    11192021        do_action( 'customize_save', $this ); 
    11202022 
    1121         foreach ( $this->settings as $setting ) { 
    1122             $setting->save(); 
     2023        /* 
     2024         * Ensure that all settings will allow themselves to be saved. Note that 
     2025         * this is safe because the setting would have checked the capability 
     2026         * when the setting value was written into the changeset. So this is why 
     2027         * an additional capability check is not required here. 
     2028         */ 
     2029        $original_setting_capabilities = array(); 
     2030        foreach ( $changeset_setting_ids as $setting_id ) { 
     2031            $setting = $this->get_setting( $setting_id ); 
     2032            if ( $setting ) { 
     2033                $original_setting_capabilities[ $setting->id ] = $setting->capability; 
     2034                $setting->capability = 'exist'; 
     2035            } 
     2036        } 
     2037 
     2038        foreach ( $changeset_setting_ids as $setting_id ) { 
     2039            $setting = $this->get_setting( $setting_id ); 
     2040            if ( $setting ) { 
     2041                $setting->save(); 
     2042            } 
     2043        } 
     2044 
     2045        // Update the stashed theme mod settings, removing the active theme's stashed settings, if activated. 
     2046        if ( did_action( 'switch_theme' ) ) { 
     2047            $this->update_stashed_theme_mod_settings( $other_theme_mod_settings ); 
    11232048        } 
    11242049 
     
    11282053         * @since 3.6.0 
    11292054         * 
    1130          * @param WP_Customize_Manager $this WP_Customize_Manager instance. 
     2055         * @param WP_Customize_Manager $manager WP_Customize_Manager instance. 
    11312056         */ 
    11322057        do_action( 'customize_save_after', $this ); 
    11332058 
    1134         $data = array( 
    1135             'setting_validities' => $exported_setting_validities, 
    1136         ); 
    1137  
    1138         /** 
    1139          * Filters response data for a successful customize_save Ajax request. 
    1140          * 
    1141          * This filter does not apply if there was a nonce or authentication failure. 
    1142          * 
    1143          * @since 4.2.0 
    1144          * 
    1145          * @param array                $data Additional information passed back to the 'saved' 
    1146          *                                   event on `wp.customize`. 
    1147          * @param WP_Customize_Manager $this WP_Customize_Manager instance. 
    1148          */ 
    1149         $response = apply_filters( 'customize_save_response', $data, $this ); 
    1150         wp_send_json_success( $response ); 
     2059        // Restore original capabilities. 
     2060        foreach ( $original_setting_capabilities as $setting_id => $capability ) { 
     2061            $setting = $this->get_setting( $setting_id ); 
     2062            if ( $setting ) { 
     2063                $setting->capability = $capability; 
     2064            } 
     2065        } 
     2066 
     2067        // Restore original changeset data. 
     2068        $this->_changeset_data    = $previous_changeset_data; 
     2069        $this->_changeset_post_id = $previous_changeset_post_id; 
     2070        $this->_changeset_uuid    = $previous_changeset_uuid; 
     2071 
     2072        return true; 
     2073    } 
     2074 
     2075    /** 
     2076     * Update stashed theme mod settings. 
     2077     * 
     2078     * @since 4.7.0 
     2079     * @access private 
     2080     * 
     2081     * @param array $inactive_theme_mod_settings Mapping of stylesheet to arrays of theme mod settings. 
     2082     * @return array|false Returns array of updated stashed theme mods or false if the update failed or there were no changes. 
     2083     */ 
     2084    protected function update_stashed_theme_mod_settings( $inactive_theme_mod_settings ) { 
     2085        $stashed_theme_mod_settings = get_option( 'customize_stashed_theme_mods' ); 
     2086        if ( empty( $stashed_theme_mod_settings ) ) { 
     2087            $stashed_theme_mod_settings = array(); 
     2088        } 
     2089 
     2090        // Delete any stashed theme mods for the active theme since since they would have been loaded and saved upon activation. 
     2091        unset( $stashed_theme_mod_settings[ $this->get_stylesheet() ] ); 
     2092 
     2093        // Merge inactive theme mods with the stashed theme mod settings. 
     2094        foreach ( $inactive_theme_mod_settings as $stylesheet => $theme_mod_settings ) { 
     2095            if ( ! isset( $stashed_theme_mod_settings[ $stylesheet ] ) ) { 
     2096                $stashed_theme_mod_settings[ $stylesheet ] = array(); 
     2097            } 
     2098 
     2099            $stashed_theme_mod_settings[ $stylesheet ] = array_merge( 
     2100                $stashed_theme_mod_settings[ $stylesheet ], 
     2101                $theme_mod_settings 
     2102            ); 
     2103        } 
     2104 
     2105        $autoload = false; 
     2106        $result = update_option( 'customize_stashed_theme_mods', $stashed_theme_mod_settings, $autoload ); 
     2107        if ( ! $result ) { 
     2108            return false; 
     2109        } 
     2110        return $stashed_theme_mod_settings; 
    11512111    } 
    11522112 
     
    16922652 
    16932653    /** 
     2654     * Determines whether the admin and the frontend are on different domains. 
     2655     * 
     2656     * @since 4.7.0 
     2657     * @access public 
     2658     * 
     2659     * @return bool Whether cross-domain. 
     2660     */ 
     2661    public function is_cross_domain() { 
     2662        $admin_origin = wp_parse_url( admin_url() ); 
     2663        $home_origin = wp_parse_url( home_url() ); 
     2664        $cross_domain = ( strtolower( $admin_origin['host'] ) !== strtolower( $home_origin['host'] ) ); 
     2665        return $cross_domain; 
     2666    } 
     2667 
     2668    /** 
     2669     * Get URLs allowed to be previewed. 
     2670     * 
     2671     * If the front end and the admin are served from the same domain, load the 
     2672     * preview over ssl if the Customizer is being loaded over ssl. This avoids 
     2673     * insecure content warnings. This is not attempted if the admin and front end 
     2674     * are on different domains to avoid the case where the front end doesn't have 
     2675     * ssl certs. Domain mapping plugins can allow other urls in these conditions 
     2676     * using the customize_allowed_urls filter. 
     2677     * 
     2678     * @since 4.7.0 
     2679     * @access public 
     2680     * 
     2681     * @returns array Allowed URLs. 
     2682     */ 
     2683    public function get_allowed_urls() { 
     2684        $allowed_urls = array( home_url( '/' ) ); 
     2685 
     2686        if ( is_ssl() && ! $this->is_cross_domain() ) { 
     2687            $allowed_urls[] = home_url( '/', 'https' ); 
     2688        } 
     2689 
     2690        /** 
     2691         * Filters the list of URLs allowed to be clicked and followed in the Customizer preview. 
     2692         * 
     2693         * @since 3.4.0 
     2694         * 
     2695         * @param array $allowed_urls An array of allowed URLs. 
     2696         */ 
     2697        $allowed_urls = array_unique( apply_filters( 'customize_allowed_urls', $allowed_urls ) ); 
     2698 
     2699        return $allowed_urls; 
     2700    } 
     2701 
     2702    /** 
     2703     * Get messenger channel. 
     2704     * 
     2705     * @since 4.7.0 
     2706     * @access public 
     2707     * 
     2708     * @return string Messenger channel. 
     2709     */ 
     2710    public function get_messenger_channel() { 
     2711        return $this->messenger_channel; 
     2712    } 
     2713 
     2714    /** 
    16942715     * Set URL to link the user to when closing the Customizer. 
    16952716     * 
     
    18002821     */ 
    18012822    public function customize_pane_settings() { 
    1802         /* 
    1803          * If the front end and the admin are served from the same domain, load the 
    1804          * preview over ssl if the Customizer is being loaded over ssl. This avoids 
    1805          * insecure content warnings. This is not attempted if the admin and front end 
    1806          * are on different domains to avoid the case where the front end doesn't have 
    1807          * ssl certs. Domain mapping plugins can allow other urls in these conditions 
    1808          * using the customize_allowed_urls filter. 
    1809          */ 
    1810  
    1811         $allowed_urls = array( home_url( '/' ) ); 
    1812         $admin_origin = parse_url( admin_url() ); 
    1813         $home_origin  = parse_url( home_url() ); 
    1814         $cross_domain = ( strtolower( $admin_origin['host'] ) !== strtolower( $home_origin['host'] ) ); 
    1815  
    1816         if ( is_ssl() && ! $cross_domain ) { 
    1817             $allowed_urls[] = home_url( '/', 'https' ); 
    1818         } 
    1819  
    1820         /** 
    1821          * Filters the list of URLs allowed to be clicked and followed in the Customizer preview. 
    1822          * 
    1823          * @since 3.4.0 
    1824          * 
    1825          * @param array $allowed_urls An array of allowed URLs. 
    1826          */ 
    1827         $allowed_urls = array_unique( apply_filters( 'customize_allowed_urls', $allowed_urls ) ); 
    18282823 
    18292824        $login_url = add_query_arg( array( 
     
    18322827        ), wp_login_url() ); 
    18332828 
     2829        // Ensure dirty flags are set for modified settings. 
     2830        foreach ( array_keys( $this->unsanitized_post_values() ) as $setting_id ) { 
     2831            $setting = $this->get_setting( $setting_id ); 
     2832            if ( $setting ) { 
     2833                $setting->dirty = true; 
     2834            } 
     2835        } 
     2836 
    18342837        // Prepare Customizer settings to pass to JavaScript. 
    18352838        $settings = array( 
     2839            'changeset' => array( 
     2840                'uuid' => $this->changeset_uuid(), 
     2841                'status' => $this->changeset_post_id() ? get_post_status( $this->changeset_post_id() ) : '', 
     2842            ), 
     2843            'timeouts' => array( 
     2844                'windowRefresh' => 250, 
     2845                'changesetAutoSave' => AUTOSAVE_INTERVAL * 1000, 
     2846                'keepAliveCheck' => 2500, 
     2847                'reflowPaneContents' => 100, 
     2848                'previewFrameSensitivity' => 2000, 
     2849            ), 
    18362850            'theme'    => array( 
    18372851                'stylesheet' => $this->get_stylesheet(), 
     
    18432857                'activated'     => esc_url_raw( home_url( '/' ) ), 
    18442858                'ajax'          => esc_url_raw( admin_url( 'admin-ajax.php', 'relative' ) ), 
    1845                 'allowed'       => array_map( 'esc_url_raw', $allowed_urls ), 
    1846                 'isCrossDomain' => $cross_domain, 
     2859                'allowed'       => array_map( 'esc_url_raw', $this->get_allowed_urls() ), 
     2860                'isCrossDomain' => $this->is_cross_domain(), 
    18472861                'home'          => esc_url_raw( home_url( '/' ) ), 
    18482862                'login'         => esc_url_raw( $login_url ), 
     
    23383352     */ 
    23393353    public function register_dynamic_settings() { 
    2340         $this->add_dynamic_settings( array_keys( $this->unsanitized_post_values() ) ); 
     3354        $setting_ids = array_keys( $this->unsanitized_post_values() ); 
     3355        $this->add_dynamic_settings( $setting_ids ); 
    23413356    } 
    23423357 
  • trunk/src/wp-includes/class-wp-customize-nav-menus.php

    r38794 r38810  
    4949        $this->manager         = $manager; 
    5050 
    51         // Skip useless hooks when the user can't manage nav menus anyway. 
     51        // See https://github.com/xwp/wp-customize-snapshots/blob/962586659688a5b1fd9ae93618b7ce2d4e7a421c/php/class-customize-snapshot-manager.php#L469-L499 
     52        add_action( 'customize_register', array( $this, 'customize_register' ), 11 ); 
     53        add_filter( 'customize_dynamic_setting_args', array( $this, 'filter_dynamic_setting_args' ), 10, 2 ); 
     54        add_filter( 'customize_dynamic_setting_class', array( $this, 'filter_dynamic_setting_class' ), 10, 3 ); 
     55 
     56        // Skip remaining hooks when the user can't manage nav menus anyway. 
    5257        if ( ! current_user_can( 'edit_theme_options' ) ) { 
    5358            return; 
     
    5964        add_action( 'wp_ajax_customize-nav-menus-insert-auto-draft', array( $this, 'ajax_insert_auto_draft_post' ) ); 
    6065        add_action( 'customize_controls_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); 
    61         add_action( 'customize_register', array( $this, 'customize_register' ), 11 ); 
    62         add_filter( 'customize_dynamic_setting_args', array( $this, 'filter_dynamic_setting_args' ), 10, 2 ); 
    63         add_filter( 'customize_dynamic_setting_class', array( $this, 'filter_dynamic_setting_class' ), 10, 3 ); 
    6466        add_action( 'customize_controls_print_footer_scripts', array( $this, 'print_templates' ) ); 
    6567        add_action( 'customize_controls_print_footer_scripts', array( $this, 'available_items_template' ) ); 
     
    487489    public function customize_register() { 
    488490 
     491        /* 
     492         * Preview settings for nav menus early so that the sections and controls will be added properly. 
     493         * See https://github.com/xwp/wp-customize-snapshots/blob/962586659688a5b1fd9ae93618b7ce2d4e7a421c/php/class-customize-snapshot-manager.php#L506-L543 
     494         */ 
     495        $nav_menus_setting_ids = array(); 
     496        foreach ( array_keys( $this->manager->unsanitized_post_values() ) as $setting_id ) { 
     497            if ( preg_match( '/^(nav_menu_locations|nav_menu|nav_menu_item)\[/', $setting_id ) ) { 
     498                $nav_menus_setting_ids[] = $setting_id; 
     499            } 
     500        } 
     501        foreach ( $nav_menus_setting_ids as $setting_id ) { 
     502            $setting = $this->manager->get_setting( $setting_id ); 
     503            if ( $setting ) { 
     504                $setting->preview(); 
     505            } 
     506        } 
     507 
    489508        // Require JS-rendered control types. 
    490509        $this->manager->register_panel_type( 'WP_Customize_Nav_Menus_Panel' ); 
  • trunk/src/wp-includes/class-wp-customize-widgets.php

    r38766 r38810  
    9494        $this->manager = $manager; 
    9595 
    96         // Skip useless hooks when the user can't manage widgets anyway. 
     96        // See https://github.com/xwp/wp-customize-snapshots/blob/962586659688a5b1fd9ae93618b7ce2d4e7a421c/php/class-customize-snapshot-manager.php#L420-L449 
     97        add_filter( 'customize_dynamic_setting_args',          array( $this, 'filter_customize_dynamic_setting_args' ), 10, 2 ); 
     98        add_action( 'widgets_init',                            array( $this, 'register_settings' ), 95 ); 
     99        add_action( 'customize_register',                      array( $this, 'schedule_customize_register' ), 1 ); 
     100 
     101        // Skip remaining hooks when the user can't manage widgets anyway. 
    97102        if ( ! current_user_can( 'edit_theme_options' ) ) { 
    98103            return; 
    99104        } 
    100105 
    101         add_filter( 'customize_dynamic_setting_args',          array( $this, 'filter_customize_dynamic_setting_args' ), 10, 2 ); 
    102         add_action( 'widgets_init',                            array( $this, 'register_settings' ), 95 ); 
    103106        add_action( 'wp_loaded',                               array( $this, 'override_sidebars_widgets_for_theme_switch' ) ); 
    104107        add_action( 'customize_controls_init',                 array( $this, 'customize_controls_init' ) ); 
    105         add_action( 'customize_register',                      array( $this, 'schedule_customize_register' ), 1 ); 
    106108        add_action( 'customize_controls_enqueue_scripts',      array( $this, 'enqueue_scripts' ) ); 
    107109        add_action( 'customize_controls_print_styles',         array( $this, 'print_styles' ) ); 
     
    277279        $this->old_sidebars_widgets = wp_get_sidebars_widgets(); 
    278280        add_filter( 'customize_value_old_sidebars_widgets_data', array( $this, 'filter_customize_value_old_sidebars_widgets_data' ) ); 
     281        $this->manager->set_post_value( 'old_sidebars_widgets_data', $this->old_sidebars_widgets ); // Override any value cached in changeset. 
    279282 
    280283        // retrieve_widgets() looks at the global $sidebars_widgets 
  • trunk/src/wp-includes/customize/class-wp-customize-selective-refresh.php

    r38478 r38810  
    307307            return; 
    308308        } 
    309  
    310         $this->manager->remove_preview_signature(); 
    311309 
    312310        /* 
  • trunk/src/wp-includes/customize/class-wp-customize-theme-control.php

    r35389 r38810  
    6464    public function content_template() { 
    6565        $current_url = set_url_scheme( 'http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] ); 
    66         $active_url  = esc_url( remove_query_arg( 'theme', $current_url ) ); 
    67         $preview_url = esc_url( add_query_arg( 'theme', '__THEME__', $current_url ) ); // Token because esc_url() strips curly braces. 
     66        $active_url  = esc_url( remove_query_arg( 'customize_theme', $current_url ) ); 
     67        $preview_url = esc_url( add_query_arg( 'customize_theme', '__THEME__', $current_url ) ); // Token because esc_url() strips curly braces. 
    6868        $preview_url = str_replace( '__THEME__', '{{ data.theme.id }}', $preview_url ); 
    6969        ?> 
  • trunk/src/wp-includes/default-filters.php

    r38778 r38810  
    7676// Slugs 
    7777add_filter( 'pre_term_slug', 'sanitize_title' ); 
     78add_filter( 'wp_insert_post_data', '_wp_customize_changeset_filter_insert_post_data', 10, 2 ); 
    7879 
    7980// Keys 
     
    383384add_action( 'wp_head', '_custom_logo_header_styles' ); 
    384385add_action( 'plugins_loaded', '_wp_customize_include' ); 
     386add_action( 'transition_post_status', '_wp_customize_publish_changeset', 10, 3 ); 
    385387add_action( 'admin_enqueue_scripts', '_wp_customize_loader_settings' ); 
    386388add_action( 'delete_attachment', '_delete_attachment_theme_mod' ); 
  • trunk/src/wp-includes/functions.php

    r38809 r38810  
    55245524    return false; 
    55255525} 
     5526 
     5527/** 
     5528 * Generate a random UUID (version 4). 
     5529 * 
     5530 * @since 4.7.0 
     5531 * 
     5532 * @return string UUID. 
     5533 */ 
     5534function wp_generate_uuid4() { 
     5535    return sprintf( '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', 
     5536        mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ), 
     5537        mt_rand( 0, 0xffff ), 
     5538        mt_rand( 0, 0x0fff ) | 0x4000, 
     5539        mt_rand( 0, 0x3fff ) | 0x8000, 
     5540        mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ) 
     5541    ); 
     5542} 
  • trunk/src/wp-includes/functions.wp-scripts.php

    r38519 r38810  
    3535 */ 
    3636function _wp_scripts_maybe_doing_it_wrong( $function ) { 
    37     if ( did_action( 'init' ) ) { 
     37    if ( did_action( 'init' ) || did_action( 'admin_enqueue_scripts' ) || did_action( 'wp_enqueue_scripts' ) || did_action( 'login_enqueue_scripts' ) ) { 
    3838        return; 
    3939    } 
  • trunk/src/wp-includes/js/customize-base.js

    r38513 r38810  
    638638         * Initialize Messenger. 
    639639         * 
    640          * @param  {object} params       Parameters to configure the messenger. 
    641          *         {string} .url          The URL to communicate with. 
    642          *         {window} .targetWindow The window instance to communicate with. Default window.parent. 
    643          *         {string} .channel      If provided, will send the channel with each message and only accept messages a matching channel. 
    644          * @param  {object} options       Extend any instance parameter or method with this object. 
     640         * @param  {object} params - Parameters to configure the messenger. 
     641         *         {string} params.url - The URL to communicate with. 
     642         *         {window} params.targetWindow - The window instance to communicate with. Default window.parent. 
     643         *         {string} params.channel - If provided, will send the channel with each message and only accept messages a matching channel. 
     644         * @param  {object} options - Extend any instance parameter or method with this object. 
    645645         */ 
    646646        initialize: function( params, options ) { 
    647647            // Target the parent frame by default, but only if a parent frame exists. 
    648             var defaultTarget = window.parent == window ? null : window.parent; 
     648            var defaultTarget = window.parent === window ? null : window.parent; 
    649649 
    650650            $.extend( this, options || {} ); 
     
    653653            this.add( 'url', params.url || '' ); 
    654654            this.add( 'origin', this.url() ).link( this.url ).setter( function( to ) { 
    655                 return to.replace( /([^:]+:\/\/[^\/]+).*/, '$1' ); 
     655                var urlParser = document.createElement( 'a' ); 
     656                urlParser.href = to; 
     657                return urlParser.protocol + '//' + urlParser.hostname; 
    656658            }); 
    657659 
     
    808810    }; 
    809811 
     812    /** 
     813     * Utility function namespace 
     814     */ 
     815    api.utils = {}; 
     816 
     817    /** 
     818     * Parse query string. 
     819     * 
     820     * @since 4.7.0 
     821     * @access public 
     822     * 
     823     * @param {string} queryString Query string. 
     824     * @returns {object} Parsed query string. 
     825     */ 
     826    api.utils.parseQueryString = function parseQueryString( queryString ) { 
     827        var queryParams = {}; 
     828        _.each( queryString.split( '&' ), function( pair ) { 
     829            var parts, key, value; 
     830            parts = pair.split( '=', 2 ); 
     831            if ( ! parts[0] ) { 
     832                return; 
     833            } 
     834            key = decodeURIComponent( parts[0].replace( /\+/g, ' ' ) ); 
     835            key = key.replace( / /g, '_' ); // What PHP does. 
     836            if ( _.isUndefined( parts[1] ) ) { 
     837                value = null; 
     838            } else { 
     839                value = decodeURIComponent( parts[1].replace( /\+/g, ' ' ) ); 
     840            } 
     841            queryParams[ key ] = value; 
     842        } ); 
     843        return queryParams; 
     844    }; 
     845 
    810846    // Expose the API publicly on window.wp.customize 
    811847    exports.customize = api; 
  • trunk/src/wp-includes/js/customize-loader.js

    r38520 r38810  
    132132                targetWindow: this.iframe[0].contentWindow 
    133133            }); 
     134 
     135            // Expose the changeset UUID on the parent window's URL so that the customized state can survive a refresh. 
     136            if ( history.replaceState ) { 
     137                this.messenger.bind( 'changeset-uuid', function( changesetUuid ) { 
     138                    var urlParser = document.createElement( 'a' ); 
     139                    urlParser.href = location.href; 
     140                    urlParser.search = $.param( _.extend( 
     141                        api.utils.parseQueryString( urlParser.search.substr( 1 ) ), 
     142                        { changeset_uuid: changesetUuid } 
     143                    ) ); 
     144                    history.replaceState( { customize: urlParser.href }, '', urlParser.href ); 
     145                } ); 
     146            } 
    134147 
    135148            // Wait for the connection from the iframe before sending any postMessage events. 
  • trunk/src/wp-includes/js/customize-preview-nav-menus.js

    r36889 r38810  
    107107             */ 
    108108            isRelatedSetting: function( setting, newValue, oldValue ) { 
    109                 var partial = this, navMenuLocationSetting, navMenuId, isNavMenuItemSetting; 
     109                var partial = this, navMenuLocationSetting, navMenuId, isNavMenuItemSetting, _newValue, _oldValue, urlParser; 
    110110                if ( _.isString( setting ) ) { 
    111111                    setting = api( setting ); 
     
    124124                isNavMenuItemSetting = /^nav_menu_item\[/.test( setting.id ); 
    125125                if ( isNavMenuItemSetting && _.isObject( newValue ) && _.isObject( oldValue ) ) { 
    126                     delete newValue.type_label; 
    127                     delete oldValue.type_label; 
    128                     if ( _.isEqual( oldValue, newValue ) ) { 
     126                    _newValue = _.clone( newValue ); 
     127                    _oldValue = _.clone( oldValue ); 
     128                    delete _newValue.type_label; 
     129                    delete _oldValue.type_label; 
     130 
     131                    // Normalize URL scheme when parent frame is HTTPS to prevent selective refresh upon initial page load. 
     132                    if ( 'https' === api.preview.scheme.get() ) { 
     133                        urlParser = document.createElement( 'a' ); 
     134                        urlParser.href = _newValue.url; 
     135                        urlParser.protocol = 'https:'; 
     136                        _newValue.url = urlParser.href; 
     137                        urlParser.href = _oldValue.url; 
     138                        urlParser.protocol = 'https:'; 
     139                        _oldValue.url = urlParser.href; 
     140                    } 
     141 
     142                    if ( _.isEqual( _oldValue, _newValue ) ) { 
    129143                        return false; 
    130144                    } 
     
    366380        var selector = '.menu-item'; 
    367381 
     382        // Skip adding highlights if not in the customizer preview iframe. 
     383        if ( ! api.settings.channel ) { 
     384            return; 
     385        } 
     386 
    368387        // Focus on the menu item control when shift+clicking the menu item. 
    369388        $( document ).on( 'click', selector, function( e ) { 
  • trunk/src/wp-includes/js/customize-preview-widgets.js

    r38577 r38810  
    573573            selector = this.widgetSelectors.join( ',' ); 
    574574 
     575        // Skip adding highlights if not in the customizer preview iframe. 
     576        if ( ! api.settings.channel ) { 
     577            return; 
     578        } 
     579 
    575580        $( selector ).attr( 'title', this.l10n.widgetTooltip ); 
    576581 
  • trunk/src/wp-includes/js/customize-preview.js

    r38588 r38810  
    44(function( exports, $ ){ 
    55    var api = wp.customize, 
    6         debounce; 
     6        debounce, 
     7        currentHistoryState = {}; 
     8 
     9    /* 
     10     * Capture the state that is passed into history.replaceState() and history.pushState() 
     11     * and also which is returned in the popstate event so that when the changeset_uuid 
     12     * gets updated when transitioning to a new changeset there the current state will 
     13     * be supplied in the call to history.replaceState(). 
     14     */ 
     15    ( function( history ) { 
     16        var injectUrlWithState; 
     17 
     18        if ( ! history.replaceState ) { 
     19            return; 
     20        } 
     21 
     22        /** 
     23         * Amend the supplied URL with the customized state. 
     24         * 
     25         * @since 4.7.0 
     26         * @access private 
     27         * 
     28         * @param {string} url URL. 
     29         * @returns {string} URL with customized state. 
     30         */ 
     31        injectUrlWithState = function( url ) { 
     32            var urlParser, queryParams; 
     33            urlParser = document.createElement( 'a' ); 
     34            urlParser.href = url; 
     35            queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 
     36 
     37            queryParams.customize_changeset_uuid = api.settings.changeset.uuid; 
     38            if ( ! api.settings.theme.active ) { 
     39                queryParams.customize_theme = api.settings.theme.stylesheet; 
     40            } 
     41            if ( api.settings.theme.channel ) { 
     42                queryParams.customize_messenger_channel = api.settings.channel; 
     43            } 
     44            urlParser.search = $.param( queryParams ); 
     45            return url; 
     46        }; 
     47 
     48        history.replaceState = ( function( nativeReplaceState ) { 
     49            return function historyReplaceState( data, title, url ) { 
     50                currentHistoryState = data; 
     51                return nativeReplaceState.call( history, data, title, injectUrlWithState( url ) ); 
     52            }; 
     53        } )( history.replaceState ); 
     54 
     55        history.pushState = ( function( nativePushState ) { 
     56            return function historyPushState( data, title, url ) { 
     57                currentHistoryState = data; 
     58                return nativePushState.call( history, data, title, injectUrlWithState( url ) ); 
     59            }; 
     60        } )( history.pushState ); 
     61 
     62        window.addEventListener( 'popstate', function( event ) { 
     63            currentHistoryState = event.state; 
     64        } ); 
     65 
     66    }( history ) ); 
    767 
    868    /** 
     
    3898         */ 
    3999        initialize: function( params, options ) { 
    40             var self = this; 
    41  
    42             api.Messenger.prototype.initialize.call( this, params, options ); 
    43  
    44             this.body = $( document.body ); 
    45             this.body.on( 'click.preview', 'a', function( event ) { 
     100            var preview = this, urlParser = document.createElement( 'a' ); 
     101 
     102            api.Messenger.prototype.initialize.call( preview, params, options ); 
     103 
     104            urlParser.href = preview.origin(); 
     105            preview.add( 'scheme', urlParser.protocol.replace( /:$/, '' ) ); 
     106 
     107            preview.body = $( document.body ); 
     108            preview.body.on( 'click.preview', 'a', function( event ) { 
    46109                var link, isInternalJumpLink; 
    47110                link = $( this ); 
     111 
     112                // No-op if the anchor is not a link. 
     113                if ( _.isUndefined( link.attr( 'href' ) ) ) { 
     114                    return; 
     115                } 
     116 
    48117                isInternalJumpLink = ( '#' === link.attr( 'href' ).substr( 0, 1 ) ); 
     118 
     119                // Allow internal jump links to behave normally without preventing default. 
     120                if ( isInternalJumpLink ) { 
     121                    return; 
     122                } 
     123 
     124                // If the link is not previewable, prevent the browser from navigating to it. 
     125                if ( ! api.isLinkPreviewable( link[0] ) ) { 
     126                    wp.a11y.speak( api.settings.l10n.linkUnpreviewable ); 
     127                    event.preventDefault(); 
     128                    return; 
     129                } 
     130 
     131                // If not in an iframe, then allow the link click to proceed normally since the state query params are added. 
     132                if ( ! api.settings.channel ) { 
     133                    return; 
     134                } 
     135 
     136                // Prevent initiating navigating from click and instead rely on sending url message to pane. 
    49137                event.preventDefault(); 
    50  
    51                 if ( isInternalJumpLink && '#' !== link.attr( 'href' ) ) { 
    52                     $( link.attr( 'href' ) ).each( function() { 
    53                         this.scrollIntoView(); 
    54                     } ); 
    55                 } 
    56138 
    57139                /* 
     
    60142                 * control instead of also navigating to the URL linked to. 
    61143                 */ 
    62                 if ( event.shiftKey || isInternalJumpLink ) { 
     144                if ( event.shiftKey ) { 
    63145                    return; 
    64146                } 
    65                 self.send( 'scroll', 0 ); 
    66                 self.send( 'url', link.prop( 'href' ) ); 
    67             }); 
    68  
    69             // You cannot submit forms. 
    70             this.body.on( 'submit.preview', 'form', function( event ) { 
    71                 var urlParser; 
     147 
     148                // Note: It's not relevant to send scroll because sending url message will have the same effect. 
     149                preview.send( 'url', link.prop( 'href' ) ); 
     150            } ); 
     151 
     152            preview.body.on( 'submit.preview', 'form', function( event ) { 
     153                var urlParser = document.createElement( 'a' ); 
     154                urlParser.href = this.action; 
     155 
     156                // If the link is not previewable, prevent the browser from navigating to it. 
     157                if ( 'GET' !== this.method.toUpperCase() || ! api.isLinkPreviewable( urlParser ) ) { 
     158                    wp.a11y.speak( api.settings.l10n.formUnpreviewable ); 
     159                    event.preventDefault(); 
     160                    return; 
     161                } 
     162 
     163                // If not in an iframe, then allow the form submission to proceed normally with the state inputs injected. 
     164                if ( ! api.settings.channel ) { 
     165                    return; 
     166                } 
    72167 
    73168                /* 
     
    82177                 * external site in the preview. 
    83178                 */ 
    84                 if ( ! event.isDefaultPrevented() && 'GET' === this.method.toUpperCase() ) { 
    85                     urlParser = document.createElement( 'a' ); 
    86                     urlParser.href = this.action; 
    87                     if ( urlParser.search.substr( 1 ).length > 1 ) { 
     179                if ( ! event.isDefaultPrevented() ) { 
     180                    if ( urlParser.search.length > 1 ) { 
    88181                        urlParser.search += '&'; 
    89182                    } 
     
    92185                } 
    93186 
     187                // Prevent default since navigation should be done via sending url message or via JS submit handler. 
    94188                event.preventDefault(); 
    95189            }); 
    96190 
    97             this.window = $( window ); 
    98             this.window.on( 'scroll.preview', debounce( function() { 
    99                 self.send( 'scroll', self.window.scrollTop() ); 
    100             }, 200 )); 
    101  
    102             this.bind( 'scroll', function( distance ) { 
    103                 self.window.scrollTop( distance ); 
    104             }); 
     191            preview.window = $( window ); 
     192 
     193            if ( api.settings.channel ) { 
     194                preview.window.on( 'scroll.preview', debounce( function() { 
     195                    preview.send( 'scroll', preview.window.scrollTop() ); 
     196                }, 200 ) ); 
     197 
     198                preview.bind( 'scroll', function( distance ) { 
     199                    preview.window.scrollTop( distance ); 
     200                }); 
     201            } 
    105202        } 
    106203    }); 
     204 
     205    /** 
     206     * Inject the changeset UUID into links in the document. 
     207     * 
     208     * @since 4.7.0 
     209     * @access protected 
     210     * 
     211     * @access private 
     212     * @returns {void} 
     213     */ 
     214    api.addLinkPreviewing = function addLinkPreviewing() { 
     215        var linkSelectors = 'a[href], area'; 
     216 
     217        // Inject links into initial document. 
     218        $( document.body ).find( linkSelectors ).each( function() { 
     219            api.prepareLinkPreview( this ); 
     220        } ); 
     221 
     222        // Inject links for new elements added to the page. 
     223        if ( 'undefined' !== typeof MutationObserver ) { 
     224            api.mutationObserver = new MutationObserver( function( mutations ) { 
     225                _.each( mutations, function( mutation ) { 
     226                    $( mutation.target ).find( linkSelectors ).each( function() { 
     227                        api.prepareLinkPreview( this ); 
     228                    } ); 
     229                } ); 
     230            } ); 
     231            api.mutationObserver.observe( document.documentElement, { 
     232                childList: true, 
     233                subtree: true 
     234            } ); 
     235        } else { 
     236 
     237            // If mutation observers aren't available, fallback to just-in-time injection. 
     238            $( document.documentElement ).on( 'click focus mouseover', linkSelectors, function() { 
     239                api.prepareLinkPreview( this ); 
     240            } ); 
     241        } 
     242    }; 
     243 
     244    /** 
     245     * Should the supplied link is previewable. 
     246     * 
     247     * @since 4.7.0 
     248     * @access public 
     249     * 
     250     * @param {HTMLAnchorElement|HTMLAreaElement} element Link element. 
     251     * @param {string} element.search Query string. 
     252     * @param {string} element.pathname Path. 
     253     * @param {string} element.hostname Hostname. 
     254     * @param {object} [options] 
     255     * @param {object} [options.allowAdminAjax=false] Allow admin-ajax.php requests. 
     256     * @returns {boolean} Is appropriate for changeset link. 
     257     */ 
     258    api.isLinkPreviewable = function isLinkPreviewable( element, options ) { 
     259        var hasMatchingHost, urlParser, args; 
     260 
     261        args = _.extend( {}, { allowAdminAjax: false }, options || {} ); 
     262 
     263        if ( 'javascript:' === element.protocol ) { // jshint ignore:line 
     264            return true; 
     265        } 
     266 
     267        // Only web URLs can be previewed. 
     268        if ( 'https:' !== element.protocol && 'http:' !== element.protocol ) { 
     269            return false; 
     270        } 
     271 
     272        urlParser = document.createElement( 'a' ); 
     273        hasMatchingHost = ! _.isUndefined( _.find( api.settings.url.allowed, function( allowedUrl ) { 
     274            urlParser.href = allowedUrl; 
     275            if ( urlParser.hostname === element.hostname && urlParser.protocol === element.protocol ) { 
     276                return true; 
     277            } 
     278            return false; 
     279        } ) ); 
     280        if ( ! hasMatchingHost ) { 
     281            return false; 
     282        } 
     283 
     284        // Skip wp login and signup pages. 
     285        if ( /\/wp-(login|signup)\.php$/.test( element.pathname ) ) { 
     286            return false; 
     287        } 
     288 
     289        // Allow links to admin ajax as faux frontend URLs. 
     290        if ( /\/wp-admin\/admin-ajax\.php$/.test( element.pathname ) ) { 
     291            return args.allowAdminAjax; 
     292        } 
     293 
     294        // Disallow links to admin, includes, and content. 
     295        if ( /\/wp-(admin|includes|content)(\/|$)/.test( element.pathname ) ) { 
     296            return false; 
     297        } 
     298 
     299        return true; 
     300    }; 
     301 
     302    /** 
     303     * Inject the customize_changeset_uuid query param into links on the frontend. 
     304     * 
     305     * @since 4.7.0 
     306     * @access protected 
     307     * 
     308     * @param {HTMLAnchorElement|HTMLAreaElement} element Link element. 
     309     * @param {object} element.search Query string. 
     310     * @returns {void} 
     311     */ 
     312    api.prepareLinkPreview = function prepareLinkPreview( element ) { 
     313        var queryParams; 
     314 
     315        // Skip links in admin bar. 
     316        if ( $( element ).closest( '#wpadminbar' ).length ) { 
     317            return; 
     318        } 
     319 
     320        // Ignore links with href="#" or href="#id". 
     321        if ( '#' === $( element ).attr( 'href' ).substr( 0, 1 ) ) { 
     322            return; 
     323        } 
     324 
     325        // Make sure links in preview use HTTPS if parent frame uses HTTPS. 
     326        if ( 'https' === api.preview.scheme.get() && 'http:' === element.protocol && -1 !== api.settings.url.allowedHosts.indexOf( element.hostname ) ) { 
     327            element.protocol = 'https:'; 
     328        } 
     329 
     330        if ( ! api.isLinkPreviewable( element ) ) { 
     331            $( element ).addClass( 'customize-unpreviewable' ); 
     332            return; 
     333        } 
     334        $( element ).removeClass( 'customize-unpreviewable' ); 
     335 
     336        queryParams = api.utils.parseQueryString( element.search.substring( 1 ) ); 
     337        queryParams.customize_changeset_uuid = api.settings.changeset.uuid; 
     338        if ( ! api.settings.theme.active ) { 
     339            queryParams.customize_theme = api.settings.theme.stylesheet; 
     340        } 
     341        if ( api.settings.channel ) { 
     342            queryParams.customize_messenger_channel = api.settings.channel; 
     343        } 
     344        element.search = $.param( queryParams ); 
     345 
     346        // Prevent links from breaking out of preview iframe. 
     347        if ( api.settings.channel ) { 
     348            element.target = '_self'; 
     349        } 
     350    }; 
     351 
     352    /** 
     353     * Inject the changeset UUID into Ajax requests. 
     354     * 
     355     * @since 4.7.0 
     356     * @access protected 
     357     * 
     358     * @return {void} 
     359     */ 
     360    api.addRequestPreviewing = function addRequestPreviewing() { 
     361 
     362        /** 
     363         * Rewrite Ajax requests to inject customizer state. 
     364         * 
     365         * @param {object} options Options. 
     366         * @param {string} options.type Type. 
     367         * @param {string} options.url URL. 
     368         * @param {object} originalOptions Original options. 
     369         * @param {XMLHttpRequest} xhr XHR. 
     370         * @returns {void} 
     371         */ 
     372        var prefilterAjax = function( options, originalOptions, xhr ) { 
     373            var urlParser, queryParams, requestMethod, dirtyValues = {}; 
     374            urlParser = document.createElement( 'a' ); 
     375            urlParser.href = options.url; 
     376 
     377            // Abort if the request is not for this site. 
     378            if ( ! api.isLinkPreviewable( urlParser, { allowAdminAjax: true } ) ) { 
     379                return; 
     380            } 
     381            queryParams = api.utils.parseQueryString( urlParser.search.substring( 1 ) ); 
     382 
     383            // Note that _dirty flag will be cleared with changeset updates. 
     384            api.each( function( setting ) { 
     385                if ( setting._dirty ) { 
     386                    dirtyValues[ setting.id ] = setting.get(); 
     387                } 
     388            } ); 
     389 
     390            if ( ! _.isEmpty( dirtyValues ) ) { 
     391                requestMethod = options.type.toUpperCase(); 
     392 
     393                // Override underlying request method to ensure unsaved changes to changeset can be included (force Backbone.emulateHTTP). 
     394                if ( 'POST' !== requestMethod ) { 
     395                    xhr.setRequestHeader( 'X-HTTP-Method-Override', requestMethod ); 
     396                    queryParams._method = requestMethod; 
     397                    options.type = 'POST'; 
     398                } 
     399 
     400                // Amend the post data with the customized values. 
     401                if ( options.data ) { 
     402                    options.data += '&'; 
     403                } else { 
     404                    options.data = ''; 
     405                } 
     406                options.data += $.param( { 
     407                    customized: JSON.stringify( dirtyValues ) 
     408                } ); 
     409            } 
     410 
     411            // Include customized state query params in URL. 
     412            queryParams.customize_changeset_uuid = api.settings.changeset.uuid; 
     413            if ( ! api.settings.theme.active ) { 
     414                queryParams.customize_theme = api.settings.theme.stylesheet; 
     415            } 
     416            urlParser.search = $.param( queryParams ); 
     417            options.url = urlParser.href; 
     418        }; 
     419 
     420        $.ajaxPrefilter( prefilterAjax ); 
     421    }; 
     422 
     423    /** 
     424     * Inject changeset UUID into forms, allowing preview to persist through submissions. 
     425     * 
     426     * @since 4.7.0 
     427     * @access protected 
     428     * 
     429     * @returns {void} 
     430     */ 
     431    api.addFormPreviewing = function addFormPreviewing() { 
     432 
     433        // Inject inputs for forms in initial document. 
     434        $( document.body ).find( 'form' ).each( function() { 
     435            api.prepareFormPreview( this ); 
     436        } ); 
     437 
     438        // Inject inputs for new forms added to the page. 
     439        if ( 'undefined' !== typeof MutationObserver ) { 
     440            api.mutationObserver = new MutationObserver( function( mutations ) { 
     441                _.each( mutations, function( mutation ) { 
     442                    $( mutation.target ).find( 'form' ).each( function() { 
     443                        api.prepareFormPreview( this ); 
     444                    } ); 
     445                } ); 
     446            } ); 
     447            api.mutationObserver.observe( document.documentElement, { 
     448                childList: true, 
     449                subtree: true 
     450            } ); 
     451        } 
     452    }; 
     453 
     454    /** 
     455     * Inject changeset into form inputs. 
     456     * 
     457     * @since 4.7.0 
     458     * @access protected 
     459     * 
     460     * @param {HTMLFormElement} form Form. 
     461     * @returns {void} 
     462     */ 
     463    api.prepareFormPreview = function prepareFormPreview( form ) { 
     464        var urlParser, stateParams = {}; 
     465 
     466        if ( ! form.action ) { 
     467            form.action = location.href; 
     468        } 
     469 
     470        urlParser = document.createElement( 'a' ); 
     471        urlParser.href = form.action; 
     472 
     473        // Make sure forms in preview use HTTPS if parent frame uses HTTPS. 
     474        if ( 'https' === api.preview.scheme.get() && 'http:' === urlParser.protocol && -1 !== api.settings.url.allowedHosts.indexOf( urlParser.hostname ) ) { 
     475            urlParser.protocol = 'https:'; 
     476            form.action = urlParser.href; 
     477        } 
     478 
     479        if ( 'GET' !== form.method.toUpperCase() || ! api.isLinkPreviewable( urlParser ) ) { 
     480            $( form ).addClass( 'customize-unpreviewable' ); 
     481            return; 
     482        } 
     483        $( form ).removeClass( 'customize-unpreviewable' ); 
     484 
     485        stateParams.customize_changeset_uuid = api.settings.changeset.uuid; 
     486        if ( ! api.settings.theme.active ) { 
     487            stateParams.customize_theme = api.settings.theme.stylesheet; 
     488        } 
     489        if ( api.settings.channel ) { 
     490            stateParams.customize_messenger_channel = api.settings.channel; 
     491        } 
     492 
     493        _.each( stateParams, function( value, name ) { 
     494            var input = $( form ).find( 'input[name="' + name + '"]' ); 
     495            if ( input.length ) { 
     496                input.val( value ); 
     497            } else { 
     498                $( form ).prepend( $( '<input>', { 
     499                    type: 'hidden', 
     500                    name: name, 
     501                    value: value 
     502                } ) ); 
     503            } 
     504        } ); 
     505 
     506        // Prevent links from breaking out of preview iframe. 
     507        if ( api.settings.channel ) { 
     508            form.target = '_self'; 
     509        } 
     510    }; 
     511 
     512    /** 
     513     * Watch current URL and send keep-alive (heartbeat) messages to the parent. 
     514     * 
     515     * Keep the customizer pane notified that the preview is still alive 
     516     * and that the user hasn't navigated to a non-customized URL. 
     517     * 
     518     * @since 4.7.0 
     519     * @access protected 
     520     */ 
     521    api.keepAliveCurrentUrl = ( function() { 
     522        var previousPathName = location.pathname, 
     523            previousQueryString = location.search.substr( 1 ), 
     524            previousQueryParams = null, 
     525            stateQueryParams = [ 'customize_theme', 'customize_changeset_uuid', 'customize_messenger_channel' ]; 
     526 
     527        return function keepAliveCurrentUrl() { 
     528            var urlParser, currentQueryParams; 
     529 
     530            // Short-circuit with keep-alive if previous URL is identical (as is normal case). 
     531            if ( previousQueryString === location.search.substr( 1 ) && previousPathName === location.pathname ) { 
     532                api.preview.send( 'keep-alive' ); 
     533                return; 
     534            } 
     535 
     536            urlParser = document.createElement( 'a' ); 
     537            if ( null === previousQueryParams ) { 
     538                urlParser.search = previousQueryString; 
     539                previousQueryParams = api.utils.parseQueryString( previousQueryString ); 
     540                _.each( stateQueryParams, function( name ) { 
     541                    delete previousQueryParams[ name ]; 
     542                } ); 
     543            } 
     544 
     545            // Determine if current URL minus customized state params and URL hash. 
     546            urlParser.href = location.href; 
     547            currentQueryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 
     548            _.each( stateQueryParams, function( name ) { 
     549                delete currentQueryParams[ name ]; 
     550            } ); 
     551 
     552            if ( previousPathName !== location.pathname || ! _.isEqual( previousQueryParams, currentQueryParams ) ) { 
     553                urlParser.search = $.param( currentQueryParams ); 
     554                urlParser.hash = ''; 
     555                api.settings.url.self = urlParser.href; 
     556                api.preview.send( 'ready', { 
     557                    currentUrl: api.settings.url.self, 
     558                    activePanels: api.settings.activePanels, 
     559                    activeSections: api.settings.activeSections, 
     560                    activeControls: api.settings.activeControls, 
     561                    settingValidities: api.settings.settingValidities 
     562                } ); 
     563            } else { 
     564                api.preview.send( 'keep-alive' ); 
     565            } 
     566            previousQueryParams = currentQueryParams; 
     567            previousQueryString = location.search.substr( 1 ); 
     568            previousPathName = location.pathname; 
     569        }; 
     570    } )(); 
    107571 
    108572    $( function() { 
     
    118582            channel: api.settings.channel 
    119583        }); 
     584 
     585        api.addLinkPreviewing(); 
     586        api.addRequestPreviewing(); 
     587        api.addFormPreviewing(); 
    120588 
    121589        /** 
     
    172640 
    173641            api.preview.send( 'documentTitle', document.title ); 
     642 
     643            // Send scroll in case of loading via non-refresh. 
     644            api.preview.send( 'scroll', $( window ).scrollTop() ); 
    174645        }); 
    175646 
    176647        api.preview.bind( 'saved', function( response ) { 
     648 
     649            if ( response.next_changeset_uuid ) { 
     650                api.settings.changeset.uuid = response.next_changeset_uuid; 
     651 
     652                // Update UUIDs in links and forms. 
     653                $( document.body ).find( 'a[href], area' ).each( function() { 
     654                    api.prepareLinkPreview( this ); 
     655                } ); 
     656                $( document.body ).find( 'form' ).each( function() { 
     657                    api.prepareFormPreview( this ); 
     658                } ); 
     659 
     660                /* 
     661                 * Replace the UUID in the URL. Note that the wrapped history.replaceState() 
     662                 * will handle injecting the current api.settings.changeset.uuid into the URL, 
     663                 * so this is merely to trigger that logic. 
     664                 */ 
     665                if ( history.replaceState ) { 
     666                    history.replaceState( currentHistoryState, '', location.href ); 
     667                } 
     668            } 
     669 
    177670            api.trigger( 'saved', response ); 
    178671        } ); 
    179672 
    180         api.bind( 'saved', function() { 
    181             api.each( function( setting ) { 
    182                 setting._dirty = false; 
     673        /* 
     674         * Clear dirty flag for settings when saved to changeset so that they 
     675         * won't be needlessly included in selective refresh or ajax requests. 
     676         */ 
     677        api.preview.bind( 'changeset-saved', function( data ) { 
     678            _.each( data.saved_changeset_values, function( value, settingId ) { 
     679                var setting = api( settingId ); 
     680                if ( setting && _.isEqual( setting.get(), value ) ) { 
     681                    setting._dirty = false; 
     682                } 
    183683            } ); 
    184684        } ); 
     
    193693         */ 
    194694        api.preview.send( 'ready', { 
     695            currentUrl: api.settings.url.self, 
    195696            activePanels: api.settings.activePanels, 
    196697            activeSections: api.settings.activeSections, 
     
    198699            settingValidities: api.settings.settingValidities 
    199700        } ); 
     701 
     702        // Send ready when URL changes via JS. 
     703        setInterval( api.keepAliveCurrentUrl, api.settings.timeouts.keepAliveSend ); 
    200704 
    201705        // Display a loading indicator when preview is reloading, and remove on failure. 
  • trunk/src/wp-includes/js/customize-selective-refresh.js

    r38649 r38810  
    1212            l10n: { 
    1313                shiftClickToEdit: '' 
    14             }, 
    15             refreshBuffer: 250 
     14            } 
    1615        }, 
    1716        currentRequest: null 
     
    486485            wp_customize: 'on', 
    487486            nonce: api.settings.nonce.preview, 
    488             theme: api.settings.theme.stylesheet, 
    489             customized: JSON.stringify( dirtyCustomized ) 
     487            customize_theme: api.settings.theme.stylesheet, 
     488            customized: JSON.stringify( dirtyCustomized ), 
     489            customize_changeset_uuid: api.settings.changeset.uuid 
    490490        }; 
    491491    }; 
     
    669669                } ); 
    670670            }, 
    671             self.data.refreshBuffer 
     671            api.settings.timeouts.selectiveRefresh 
    672672        ); 
    673673 
  • trunk/src/wp-includes/post.php

    r38798 r38810  
    110110        'delete_with_user' => false, 
    111111        'query_var' => false, 
     112    ) ); 
     113 
     114    register_post_type( 'customize_changeset', array( 
     115        'labels' => array( 
     116            'name'               => _x( 'Changesets', 'post type general name' ), 
     117            'singular_name'      => _x( 'Changeset', 'post type singular name' ), 
     118            'menu_name'          => _x( 'Changesets', 'admin menu' ), 
     119            'name_admin_bar'     => _x( 'Changeset', 'add new on admin bar' ), 
     120            'add_new'            => _x( 'Add New', 'Customize Changeset' ), 
     121            'add_new_item'       => __( 'Add New Changeset' ), 
     122            'new_item'           => __( 'New Changeset' ), 
     123            'edit_item'          => __( 'Edit Changeset' ), 
     124            'view_item'          => __( 'View Changeset' ), 
     125            'all_items'          => __( 'All Changesets' ), 
     126            'search_items'       => __( 'Search Changesets' ), 
     127            'not_found'          => __( 'No changesets found.' ), 
     128            'not_found_in_trash' => __( 'No changesets found in Trash.' ), 
     129        ), 
     130        'public' => false, 
     131        '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ 
     132        'map_meta_cap' => true, 
     133        'hierarchical' => false, 
     134        'rewrite' => false, 
     135        'query_var' => false, 
     136        'can_export' => false, 
     137        'delete_with_user' => false, 
     138        'supports' => array( 'title', 'author' ), 
     139        'capability_type' => 'customize_changeset', 
     140        'capabilities' => array( 
     141            'create_posts' => 'customize', 
     142            'delete_others_posts' => 'customize', 
     143            'delete_post' => 'customize', 
     144            'delete_posts' => 'customize', 
     145            'delete_private_posts' => 'customize', 
     146            'delete_published_posts' => 'customize', 
     147            'edit_others_posts' => 'customize', 
     148            'edit_post' => 'customize', 
     149            'edit_posts' => 'customize', 
     150            'edit_private_posts' => 'customize', 
     151            'edit_published_posts' => 'do_not_allow', 
     152            'publish_posts' => 'customize', 
     153            'read' => 'read', 
     154            'read_post' => 'customize', 
     155            'read_private_posts' => 'customize', 
     156        ), 
    112157    ) ); 
    113158 
  • trunk/src/wp-includes/script-loader.php

    r38797 r38810  
    451451    $scripts->add( 'customize-base',     "/wp-includes/js/customize-base$suffix.js",     array( 'jquery', 'json2', 'underscore' ), false, 1 ); 
    452452    $scripts->add( 'customize-loader',   "/wp-includes/js/customize-loader$suffix.js",   array( 'customize-base' ), false, 1 ); 
    453     $scripts->add( 'customize-preview',  "/wp-includes/js/customize-preview$suffix.js",  array( 'customize-base' ), false, 1 ); 
     453    $scripts->add( 'customize-preview',  "/wp-includes/js/customize-preview$suffix.js",  array( 'wp-a11y', 'customize-base' ), false, 1 ); 
    454454    $scripts->add( 'customize-models',   "/wp-includes/js/customize-models.js", array( 'underscore', 'backbone' ), false, 1 ); 
    455455    $scripts->add( 'customize-views',    "/wp-includes/js/customize-views.js",  array( 'jquery', 'underscore', 'imgareaselect', 'customize-models', 'media-editor', 'media-views' ), false, 1 ); 
  • trunk/src/wp-includes/theme.php

    r38649 r38810  
    20672067 * 
    20682068 * Loads the Customizer at plugins_loaded when accessing the customize.php admin 
    2069  * page or when any request includes a wp_customize=on param, either as a GET 
    2070  * query var or as POST data. This param is a signal for whether to bootstrap 
    2071  * the Customizer when WordPress is loading, especially in the Customizer preview 
     2069 * page or when any request includes a wp_customize=on param or a customize_changeset 
     2070 * param (a UUID). This param is a signal for whether to bootstrap the Customizer when 
     2071 * WordPress is loading, especially in the Customizer preview 
    20722072 * or when making Customizer Ajax requests for widgets or menus. 
    20732073 * 
     
    20772077 */ 
    20782078function _wp_customize_include() { 
    2079     if ( ! ( ( isset( $_REQUEST['wp_customize'] ) && 'on' == $_REQUEST['wp_customize'] ) 
    2080         || ( is_admin() && 'customize.php' == basename( $_SERVER['PHP_SELF'] ) ) 
    2081     ) ) { 
     2079 
     2080    $is_customize_admin_page = ( is_admin() && 'customize.php' == basename( $_SERVER['PHP_SELF'] ) ); 
     2081    $should_include = ( 
     2082        $is_customize_admin_page 
     2083        || 
     2084        ( isset( $_REQUEST['wp_customize'] ) && 'on' == $_REQUEST['wp_customize'] ) 
     2085        || 
     2086        ( ! empty( $_GET['customize_changeset_uuid'] ) || ! empty( $_POST['customize_changeset_uuid'] ) ) 
     2087    ); 
     2088 
     2089    if ( ! $should_include ) { 
    20822090        return; 
    20832091    } 
    20842092 
    2085     require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';  
    2086     $GLOBALS['wp_customize'] = new WP_Customize_Manager(); 
     2093    /* 
     2094     * Note that wp_unslash() is not being used on the input vars because it is 
     2095     * called before wp_magic_quotes() gets called. Besides this fact, none of 
     2096     * the values should contain any characters needing slashes anyway. 
     2097     */ 
     2098    $keys = array( 'changeset_uuid', 'customize_changeset_uuid', 'customize_theme', 'theme', 'customize_messenger_channel' ); 
     2099    $input_vars = array_merge( 
     2100        wp_array_slice_assoc( $_GET, $keys ), 
     2101        wp_array_slice_assoc( $_POST, $keys ) 
     2102    ); 
     2103 
     2104    $theme = null; 
     2105    $changeset_uuid = null; 
     2106    $messenger_channel = null; 
     2107 
     2108    if ( $is_customize_admin_page && isset( $input_vars['changeset_uuid'] ) ) { 
     2109        $changeset_uuid = sanitize_key( $input_vars['changeset_uuid'] ); 
     2110    } elseif ( ! empty( $input_vars['customize_changeset_uuid'] ) ) { 
     2111        $changeset_uuid = sanitize_key( $input_vars['customize_changeset_uuid'] ); 
     2112    } 
     2113 
     2114    // Note that theme will be sanitized via WP_Theme. 
     2115    if ( $is_customize_admin_page && isset( $input_vars['theme'] ) ) { 
     2116        $theme = $input_vars['theme']; 
     2117    } elseif ( isset( $input_vars['customize_theme'] ) ) { 
     2118        $theme = $input_vars['customize_theme']; 
     2119    } 
     2120    if ( isset( $input_vars['customize_messenger_channel'] ) ) { 
     2121        $messenger_channel = sanitize_key( $input_vars['customize_messenger_channel'] ); 
     2122    } 
     2123 
     2124    require_once ABSPATH . WPINC . '/class-wp-customize-manager.php'; 
     2125    $GLOBALS['wp_customize'] = new WP_Customize_Manager( compact( 'changeset_uuid', 'theme', 'messenger_channel' ) ); 
     2126} 
     2127 
     2128/** 
     2129 * Publish a snapshot's changes. 
     2130 * 
     2131 * @param string  $new_status     New post status. 
     2132 * @param string  $old_status     Old post status. 
     2133 * @param WP_Post $changeset_post Changeset post object. 
     2134 */ 
     2135function _wp_customize_publish_changeset( $new_status, $old_status, $changeset_post ) { 
     2136    global $wp_customize; 
     2137 
     2138    $is_publishing_changeset = ( 
     2139        'customize_changeset' === $changeset_post->post_type 
     2140        && 
     2141        'publish' === $new_status 
     2142        && 
     2143        'publish' !== $old_status 
     2144    ); 
     2145    if ( ! $is_publishing_changeset ) { 
     2146        return; 
     2147    } 
     2148 
     2149    if ( empty( $wp_customize ) ) { 
     2150        require_once ABSPATH . WPINC . '/class-wp-customize-manager.php'; 
     2151        $wp_customize = new WP_Customize_Manager( $changeset_post->post_name ); 
     2152    } 
     2153 
     2154    if ( ! did_action( 'customize_register' ) ) { 
     2155        /* 
     2156         * When running from CLI or Cron, the customize_register action will need 
     2157         * to be triggered in order for core, themes, and plugins to register their 
     2158         * settings. Normally core will add_action( 'customize_register' ) at 
     2159         * priority 10 to register the core settings, and if any themes/plugins 
     2160         * also add_action( 'customize_register' ) at the same priority, they 
     2161         * will have a $wp_customize with those settings registered since they 
     2162         * call add_action() afterward, normally. However, when manually doing 
     2163         * the customize_register action after the setup_theme, then the order 
     2164         * will be reversed for two actions added at priority 10, resulting in 
     2165         * the core settings no longer being available as expected to themes/plugins. 
     2166         * So the following manually calls the method that registers the core 
     2167         * settings up front before doing the action. 
     2168         */ 
     2169        remove_action( 'customize_register', array( $wp_customize, 'register_controls' ) ); 
     2170        $wp_customize->register_controls(); 
     2171 
     2172        /** This filter is documented in /wp-includes/class-wp-customize-manager.php */ 
     2173        do_action( 'customize_register', $wp_customize ); 
     2174    } 
     2175    $wp_customize->_publish_changeset_values( $changeset_post->ID ) ; 
     2176 
     2177    /* 
     2178     * Trash the changeset post if revisions are not enabled. Unpublished 
     2179     * changesets by default get garbage collected due to the auto-draft status. 
     2180     * When a changeset post is published, however, it would no longer get cleaned 
     2181     * out. Ths is a problem when the changeset posts are never displayed anywhere, 
     2182     * since they would just be endlessly piling up. So here we use the revisions 
     2183     * feature to indicate whether or not a published changeset should get trashed 
     2184     * and thus garbage collected. 
     2185     */ 
     2186    if ( ! wp_revisions_enabled( $changeset_post ) ) { 
     2187        wp_trash_post( $changeset_post->ID ); 
     2188    } 
     2189} 
     2190 
     2191/** 
     2192 * Filters changeset post data upon insert to ensure post_name is intact. 
     2193 * 
     2194 * This is needed to prevent the post_name from being dropped when the post is 
     2195 * transitioned into pending status by a contributor. 
     2196 * 
     2197 * @since 4.7.0 
     2198 * @see wp_insert_post() 
     2199 * 
     2200 * @param array $post_data          An array of slashed post data. 
     2201 * @param array $supplied_post_data An array of sanitized, but otherwise unmodified post data. 
     2202 * @returns array Filtered data. 
     2203 */ 
     2204function _wp_customize_changeset_filter_insert_post_data( $post_data, $supplied_post_data ) { 
     2205    if ( isset( $post_data['post_type'] ) && 'customize_changeset' === $post_data['post_type'] ) { 
     2206 
     2207        // Prevent post_name from being dropped, such as when contributor saves a changeset post as pending. 
     2208        if ( empty( $post_data['post_name'] ) && ! empty( $supplied_post_data['post_name'] ) ) { 
     2209            $post_data['post_name'] = $supplied_post_data['post_name']; 
     2210        } 
     2211    } 
     2212    return $post_data; 
    20872213} 
    20882214 
  • trunk/tests/phpunit/tests/adminbar.php

    r38708 r38810  
    551551        $this->assertNull( $node ); 
    552552    } 
     553 
     554    /** 
     555     * @ticket 30937 
     556     * @covers wp_admin_bar_customize_menu() 
     557     */ 
     558    public function test_customize_link() { 
     559        global $wp_customize; 
     560        require_once ABSPATH . WPINC . '/class-wp-customize-manager.php'; 
     561        $uuid = wp_generate_uuid4(); 
     562        $this->go_to( home_url( "/?customize_changeset_uuid=$uuid" ) ); 
     563        wp_set_current_user( self::$admin_id ); 
     564 
     565        $this->factory()->post->create( array( 
     566            'post_type' => 'customize_changeset', 
     567            'post_status' => 'auto-draft', 
     568            'post_name' => $uuid, 
     569        ) ); 
     570        $wp_customize = new WP_Customize_Manager( array( 
     571            'changeset_uuid' => $uuid, 
     572        ) ); 
     573        $wp_customize->start_previewing_theme(); 
     574 
     575        set_current_screen( 'front' ); 
     576        $wp_admin_bar = $this->get_standard_admin_bar(); 
     577        $node = $wp_admin_bar->get_node( 'customize' ); 
     578        $this->assertNotEmpty( $node ); 
     579 
     580        $parsed_url = wp_parse_url( $node->href ); 
     581        $query_params = array(); 
     582        wp_parse_str( $parsed_url['query'], $query_params ); 
     583        $this->assertEquals( $uuid, $query_params['changeset_uuid'] ); 
     584        $this->assertNotContains( 'changeset_uuid', $query_params['url'] ); 
     585    } 
    553586} 
  • trunk/tests/phpunit/tests/customize/manager.php

    r38765 r38810  
    2828 
    2929    /** 
     30     * Admin user ID. 
     31     * 
     32     * @var int 
     33     */ 
     34    protected static $admin_user_id; 
     35 
     36    /** 
     37     * Subscriber user ID. 
     38     * 
     39     * @var int 
     40     */ 
     41    protected static $subscriber_user_id; 
     42 
     43    /** 
     44     * Set up before class. 
     45     * 
     46     * @param WP_UnitTest_Factory $factory Factory. 
     47     */ 
     48    public static function wpSetUpBeforeClass( $factory ) { 
     49        self::$subscriber_user_id = $factory->user->create( array( 'role' => 'subscriber' ) ); 
     50        self::$admin_user_id = $factory->user->create( array( 'role' => 'administrator' ) ); 
     51    } 
     52 
     53    /** 
    3054     * Set up test. 
    3155     */ 
     
    4367        $this->manager = null; 
    4468        unset( $GLOBALS['wp_customize'] ); 
     69        $_REQUEST = array(); 
    4570        parent::tearDown(); 
    4671    } 
     
    5479        $GLOBALS['wp_customize'] = new WP_Customize_Manager(); 
    5580        return $GLOBALS['wp_customize']; 
     81    } 
     82 
     83    /** 
     84     * Test WP_Customize_Manager::__construct(). 
     85     * 
     86     * @covers WP_Customize_Manager::__construct() 
     87     */ 
     88    function test_constructor() { 
     89        $uuid = wp_generate_uuid4(); 
     90        $theme = 'twentyfifteen'; 
     91        $messenger_channel = 'preview-123'; 
     92        $wp_customize = new WP_Customize_Manager( array( 
     93            'changeset_uuid' => $uuid, 
     94            'theme' => $theme, 
     95            'messenger_channel' => $messenger_channel, 
     96        ) ); 
     97        $this->assertEquals( $uuid, $wp_customize->changeset_uuid() ); 
     98        $this->assertEquals( $theme, $wp_customize->get_stylesheet() ); 
     99        $this->assertEquals( $messenger_channel, $wp_customize->get_messenger_channel() ); 
     100 
     101        $theme = 'twentyfourteen'; 
     102        $messenger_channel = 'preview-456'; 
     103        $_REQUEST['theme'] = $theme; 
     104        $_REQUEST['customize_messenger_channel'] = $messenger_channel; 
     105        $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) ); 
     106        $this->assertEquals( $theme, $wp_customize->get_stylesheet() ); 
     107        $this->assertEquals( $messenger_channel, $wp_customize->get_messenger_channel() ); 
     108 
     109        $theme = 'twentyfourteen'; 
     110        $_REQUEST['customize_theme'] = $theme; 
     111        $wp_customize = new WP_Customize_Manager(); 
     112        $this->assertEquals( $theme, $wp_customize->get_stylesheet() ); 
     113        $this->assertNotEmpty( $wp_customize->changeset_uuid() ); 
     114    } 
     115 
     116    /** 
     117     * Test WP_Customize_Manager::setup_theme() for admin screen. 
     118     * 
     119     * @covers WP_Customize_Manager::setup_theme() 
     120     */ 
     121    function test_setup_theme_in_customize_admin() { 
     122        global $pagenow, $wp_customize; 
     123        $pagenow = 'customize.php'; 
     124        set_current_screen( 'customize' ); 
     125 
     126        // Unauthorized. 
     127        $exception = null; 
     128        $wp_customize = new WP_Customize_Manager(); 
     129        wp_set_current_user( self::$subscriber_user_id ); 
     130        try { 
     131            $wp_customize->setup_theme(); 
     132        } catch ( Exception $e ) { 
     133            $exception = $e; 
     134        } 
     135        $this->assertInstanceOf( 'WPDieException', $exception ); 
     136        $this->assertContains( 'you are not allowed to customize this site', $exception->getMessage() ); 
     137 
     138        // Bad changeset. 
     139        $exception = null; 
     140        wp_set_current_user( self::$admin_user_id ); 
     141        $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => 'bad' ) ); 
     142        try { 
     143            $wp_customize->setup_theme(); 
     144        } catch ( Exception $e ) { 
     145            $exception = $e; 
     146        } 
     147        $this->assertInstanceOf( 'WPDieException', $exception ); 
     148        $this->assertContains( 'Invalid changeset UUID', $exception->getMessage() ); 
     149 
     150        $wp_customize = new WP_Customize_Manager(); 
     151        $wp_customize->setup_theme(); 
     152    } 
     153 
     154    /** 
     155     * Test WP_Customize_Manager::setup_theme() for frontend. 
     156     * 
     157     * @covers WP_Customize_Manager::setup_theme() 
     158     */ 
     159    function test_setup_theme_in_frontend() { 
     160        global $wp_customize, $pagenow, $show_admin_bar; 
     161        $pagenow = 'front'; 
     162        set_current_screen( 'front' ); 
     163 
     164        wp_set_current_user( 0 ); 
     165        $exception = null; 
     166        $wp_customize = new WP_Customize_Manager(); 
     167        wp_set_current_user( self::$subscriber_user_id ); 
     168        try { 
     169            $wp_customize->setup_theme(); 
     170        } catch ( Exception $e ) { 
     171            $exception = $e; 
     172        } 
     173        $this->assertInstanceOf( 'WPDieException', $exception ); 
     174        $this->assertContains( 'Non-existent changeset UUID', $exception->getMessage() ); 
     175 
     176        wp_set_current_user( self::$admin_user_id ); 
     177        $wp_customize = new WP_Customize_Manager( array( 'messenger_channel' => 'preview-1' ) ); 
     178        $wp_customize->setup_theme(); 
     179        $this->assertFalse( $show_admin_bar ); 
     180 
     181        show_admin_bar( true ); 
     182        wp_set_current_user( self::$admin_user_id ); 
     183        $wp_customize = new WP_Customize_Manager( array( 'messenger_channel' => null ) ); 
     184        $wp_customize->setup_theme(); 
     185        $this->assertTrue( $show_admin_bar ); 
     186    } 
     187 
     188    /** 
     189     * Test WP_Customize_Manager::changeset_uuid(). 
     190     * 
     191     * @ticket 30937 
     192     * @covers WP_Customize_Manager::changeset_uuid() 
     193     */ 
     194    function test_changeset_uuid() { 
     195        $uuid = wp_generate_uuid4(); 
     196        $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) ); 
     197        $this->assertEquals( $uuid, $wp_customize->changeset_uuid() ); 
     198    } 
     199 
     200    /** 
     201     * Test WP_Customize_Manager::wp_loaded(). 
     202     * 
     203     * Ensure that post values are previewed even without being in preview. 
     204     * 
     205     * @ticket 30937 
     206     * @covers WP_Customize_Manager::wp_loaded() 
     207     */ 
     208    function test_wp_loaded() { 
     209        wp_set_current_user( self::$admin_user_id ); 
     210        $wp_customize = new WP_Customize_Manager(); 
     211        $title = 'Hello World'; 
     212        $wp_customize->set_post_value( 'blogname', $title ); 
     213        $this->assertNotEquals( $title, get_option( 'blogname' ) ); 
     214        $wp_customize->wp_loaded(); 
     215        $this->assertFalse( $wp_customize->is_preview() ); 
     216        $this->assertEquals( $title, $wp_customize->get_setting( 'blogname' )->value() ); 
     217        $this->assertEquals( $title, get_option( 'blogname' ) ); 
     218    } 
     219 
     220    /** 
     221     * Test WP_Customize_Manager::find_changeset_post_id(). 
     222     * 
     223     * @ticket 30937 
     224     * @covers WP_Customize_Manager::find_changeset_post_id() 
     225     */ 
     226    function test_find_changeset_post_id() { 
     227        $uuid = wp_generate_uuid4(); 
     228        $post_id = $this->factory()->post->create( array( 
     229            'post_name' => $uuid, 
     230            'post_type' => 'customize_changeset', 
     231            'post_status' => 'auto-draft', 
     232            'post_content' => '{}', 
     233        ) ); 
     234 
     235        $wp_customize = new WP_Customize_Manager(); 
     236        $this->assertNull( $wp_customize->find_changeset_post_id( wp_generate_uuid4() ) ); 
     237        $this->assertEquals( $post_id, $wp_customize->find_changeset_post_id( $uuid ) ); 
     238    } 
     239 
     240    /** 
     241     * Test WP_Customize_Manager::changeset_post_id(). 
     242     * 
     243     * @ticket 30937 
     244     * @covers WP_Customize_Manager::changeset_post_id() 
     245     */ 
     246    function test_changeset_post_id() { 
     247        $uuid = wp_generate_uuid4(); 
     248        $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) ); 
     249        $this->assertNull( $wp_customize->changeset_post_id() ); 
     250 
     251        $uuid = wp_generate_uuid4(); 
     252        $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) ); 
     253        $post_id = $this->factory()->post->create( array( 
     254            'post_name' => $uuid, 
     255            'post_type' => 'customize_changeset', 
     256            'post_status' => 'auto-draft', 
     257            'post_content' => '{}', 
     258        ) ); 
     259        $this->assertEquals( $post_id, $wp_customize->changeset_post_id() ); 
     260    } 
     261 
     262    /** 
     263     * Test WP_Customize_Manager::changeset_data(). 
     264     * 
     265     * @ticket 30937 
     266     * @covers WP_Customize_Manager::changeset_data() 
     267     */ 
     268    function test_changeset_data() { 
     269        $uuid = wp_generate_uuid4(); 
     270        $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) ); 
     271        $this->assertEquals( array(), $wp_customize->changeset_data() ); 
     272 
     273        $uuid = wp_generate_uuid4(); 
     274        $data = array( 'blogname' => array( 'value' => 'Hello World' ) ); 
     275        $this->factory()->post->create( array( 
     276            'post_name' => $uuid, 
     277            'post_type' => 'customize_changeset', 
     278            'post_status' => 'auto-draft', 
     279            'post_content' => wp_json_encode( $data ), 
     280        ) ); 
     281        $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) ); 
     282        $this->assertEquals( $data, $wp_customize->changeset_data() ); 
     283    } 
     284 
     285    /** 
     286     * Test WP_Customize_Manager::customize_preview_init(). 
     287     * 
     288     * @ticket 30937 
     289     * @covers WP_Customize_Manager::customize_preview_init() 
     290     */ 
     291    function test_customize_preview_init() { 
     292 
     293        // Test authorized admin user. 
     294        wp_set_current_user( self::$admin_user_id ); 
     295        $did_action_customize_preview_init = did_action( 'customize_preview_init' ); 
     296        $wp_customize = new WP_Customize_Manager(); 
     297        $wp_customize->customize_preview_init(); 
     298        $this->assertEquals( $did_action_customize_preview_init + 1, did_action( 'customize_preview_init' ) ); 
     299 
     300        $this->assertEquals( 10, has_action( 'wp_head', 'wp_no_robots' ) ); 
     301        $this->assertEquals( 10, has_filter( 'wp_headers', array( $wp_customize, 'filter_iframe_security_headers' ) ) ); 
     302        $this->assertEquals( 10, has_filter( 'wp_redirect', array( $wp_customize, 'add_state_query_params' ) ) ); 
     303        $this->assertTrue( wp_script_is( 'customize-preview', 'enqueued' ) ); 
     304        $this->assertEquals( 10, has_action( 'wp_head', array( $wp_customize, 'customize_preview_loading_style' ) ) ); 
     305        $this->assertEquals( 20, has_action( 'wp_footer', array( $wp_customize, 'customize_preview_settings' ) ) ); 
     306 
     307        // Test unauthorized user outside preview (no messenger_channel). 
     308        wp_set_current_user( self::$subscriber_user_id ); 
     309        $wp_customize = new WP_Customize_Manager(); 
     310        $wp_customize->register_controls(); 
     311        $this->assertNotEmpty( $wp_customize->controls() ); 
     312        $wp_customize->customize_preview_init(); 
     313        $this->assertEmpty( $wp_customize->controls() ); 
     314 
     315        // Test unauthorized user inside preview (with messenger_channel). 
     316        wp_set_current_user( self::$subscriber_user_id ); 
     317        $wp_customize = new WP_Customize_Manager( array( 'messenger_channel' => 'preview-0' ) ); 
     318        $exception = null; 
     319        try { 
     320            $wp_customize->customize_preview_init(); 
     321        } catch ( WPDieException $e ) { 
     322            $exception = $e; 
     323        } 
     324        $this->assertNotNull( $exception ); 
     325        $this->assertContains( 'Unauthorized', $exception->getMessage() ); 
     326    } 
     327 
     328    /** 
     329     * Test WP_Customize_Manager::filter_iframe_security_headers(). 
     330     * 
     331     * @ticket 30937 
     332     * @covers WP_Customize_Manager::filter_iframe_security_headers() 
     333     */ 
     334    function test_filter_iframe_security_headers() { 
     335        $customize_url = admin_url( 'customize.php' ); 
     336        $wp_customize = new WP_Customize_Manager(); 
     337        $headers = $wp_customize->filter_iframe_security_headers( array() ); 
     338        $this->assertArrayHasKey( 'X-Frame-Options', $headers ); 
     339        $this->assertArrayHasKey( 'Content-Security-Policy', $headers ); 
     340        $this->assertEquals( "ALLOW-FROM $customize_url", $headers['X-Frame-Options'] ); 
     341    } 
     342 
     343    /** 
     344     * Test WP_Customize_Manager::add_state_query_params(). 
     345     * 
     346     * @ticket 30937 
     347     * @covers WP_Customize_Manager::add_state_query_params() 
     348     */ 
     349    function test_add_state_query_params() { 
     350        $uuid = wp_generate_uuid4(); 
     351        $messenger_channel = 'preview-0'; 
     352        $wp_customize = new WP_Customize_Manager( array( 
     353            'changeset_uuid' => $uuid, 
     354            'messenger_channel' => $messenger_channel, 
     355        ) ); 
     356        $url = $wp_customize->add_state_query_params( home_url( '/' ) ); 
     357        $parsed_url = wp_parse_url( $url ); 
     358        parse_str( $parsed_url['query'], $query_params ); 
     359        $this->assertArrayHasKey( 'customize_messenger_channel', $query_params ); 
     360        $this->assertArrayHasKey( 'customize_changeset_uuid', $query_params ); 
     361        $this->assertArrayNotHasKey( 'customize_theme', $query_params ); 
     362        $this->assertEquals( $uuid, $query_params['customize_changeset_uuid'] ); 
     363        $this->assertEquals( $messenger_channel, $query_params['customize_messenger_channel'] ); 
     364 
     365        $uuid = wp_generate_uuid4(); 
     366        $wp_customize = new WP_Customize_Manager( array( 
     367            'changeset_uuid' => $uuid, 
     368            'messenger_channel' => null, 
     369            'theme' => 'twentyfifteen', 
     370        ) ); 
     371        $url = $wp_customize->add_state_query_params( home_url( '/' ) ); 
     372        $parsed_url = wp_parse_url( $url ); 
     373        parse_str( $parsed_url['query'], $query_params ); 
     374        $this->assertArrayNotHasKey( 'customize_messenger_channel', $query_params ); 
     375        $this->assertArrayHasKey( 'customize_changeset_uuid', $query_params ); 
     376        $this->assertArrayHasKey( 'customize_theme', $query_params ); 
     377        $this->assertEquals( $uuid, $query_params['customize_changeset_uuid'] ); 
     378        $this->assertEquals( 'twentyfifteen', $query_params['customize_theme'] ); 
     379 
     380        $uuid = wp_generate_uuid4(); 
     381        $wp_customize = new WP_Customize_Manager( array( 
     382            'changeset_uuid' => $uuid, 
     383            'messenger_channel' => null, 
     384            'theme' => 'twentyfifteen', 
     385        ) ); 
     386        $url = $wp_customize->add_state_query_params( 'http://not-allowed.example.com/?q=1' ); 
     387        $parsed_url = wp_parse_url( $url ); 
     388        parse_str( $parsed_url['query'], $query_params ); 
     389        $this->assertArrayNotHasKey( 'customize_messenger_channel', $query_params ); 
     390        $this->assertArrayNotHasKey( 'customize_changeset_uuid', $query_params ); 
     391        $this->assertArrayNotHasKey( 'customize_theme', $query_params ); 
     392    } 
     393 
     394    /** 
     395     * Test WP_Customize_Manager::save_changeset_post(). 
     396     * 
     397     * @ticket 30937 
     398     * @covers WP_Customize_Manager::save_changeset_post() 
     399     */ 
     400    function test_save_changeset_post_without_theme_activation() { 
     401        wp_set_current_user( self::$admin_user_id ); 
     402 
     403        $did_action = array( 
     404            'customize_save_validation_before' => did_action( 'customize_save_validation_before' ), 
     405            'customize_save' => did_action( 'customize_save' ), 
     406            'customize_save_after' => did_action( 'customize_save_after' ), 
     407        ); 
     408        $uuid = wp_generate_uuid4(); 
     409 
     410        $manager = new WP_Customize_Manager( array( 
     411            'changeset_uuid' => $uuid, 
     412        ) ); 
     413        $manager->register_controls(); 
     414        $manager->set_post_value( 'blogname', 'Changeset Title' ); 
     415        $manager->set_post_value( 'blogdescription', 'Changeset Tagline' ); 
     416 
     417        $r = $manager->save_changeset_post( array( 
     418            'status' => 'auto-draft', 
     419            'title' => 'Auto Draft', 
     420            'date_gmt' => '2010-01-01 00:00:00', 
     421            'data' => array( 
     422                'blogname' => array( 
     423                    'value' => 'Overridden Changeset Title', 
     424                ), 
     425                'blogdescription' => array( 
     426                    'custom' => 'something', 
     427                ), 
     428            ), 
     429        ) ); 
     430        $this->assertInternalType( 'array', $r ); 
     431 
     432        $this->assertEquals( $did_action['customize_save_validation_before'] + 1, did_action( 'customize_save_validation_before' ) ); 
     433 
     434        $post_id = $manager->find_changeset_post_id( $uuid ); 
     435        $this->assertNotNull( $post_id ); 
     436        $saved_data = json_decode( get_post( $post_id )->post_content, true ); 
     437        $this->assertEquals( $manager->unsanitized_post_values(), wp_list_pluck( $saved_data, 'value' ) ); 
     438        $this->assertEquals( 'Overridden Changeset Title', $saved_data['blogname']['value'] ); 
     439        $this->assertEquals( 'something', $saved_data['blogdescription']['custom'] ); 
     440        $this->assertEquals( 'Auto Draft', get_post( $post_id )->post_title ); 
     441        $this->assertEquals( 'auto-draft', get_post( $post_id )->post_status ); 
     442        $this->assertEquals( '2010-01-01 00:00:00', get_post( $post_id )->post_date_gmt ); 
     443        $this->assertNotEquals( 'Changeset Title', get_option( 'blogname' ) ); 
     444        $this->assertArrayHasKey( 'setting_validities', $r ); 
     445 
     446        // Test saving with invalid settings, ensuring transaction blocked. 
     447        $previous_saved_data = $saved_data; 
     448        $manager->add_setting( 'foo_unauthorized', array( 
     449            'capability' => 'do_not_allow', 
     450        ) ); 
     451        $manager->add_setting( 'baz_illegal', array( 
     452            'validate_callback' => array( $this, 'return_illegal_error' ), 
     453        ) ); 
     454        $r = $manager->save_changeset_post( array( 
     455            'status' => 'auto-draft', 
     456            'data' => array( 
     457                'blogname' => array( 
     458                    'value' => 'OK', 
     459                ), 
     460                'foo_unauthorized' => array( 
     461                    'value' => 'No', 
     462                ), 
     463                'bar_unknown' => array( 
     464                    'value' => 'No', 
     465                ), 
     466                'baz_illegal' => array( 
     467                    'value' => 'No', 
     468                ), 
     469            ), 
     470        ) ); 
     471        $this->assertInstanceOf( 'WP_Error', $r ); 
     472        $this->assertEquals( 'transaction_fail', $r->get_error_code() ); 
     473        $this->assertInternalType( 'array', $r->get_error_data() ); 
     474        $this->assertArrayHasKey( 'setting_validities', $r->get_error_data() ); 
     475        $error_data = $r->get_error_data(); 
     476        $this->assertArrayHasKey( 'blogname', $error_data['setting_validities'] ); 
     477        $this->assertTrue( $error_data['setting_validities']['blogname'] ); 
     478        $this->assertArrayHasKey( 'foo_unauthorized', $error_data['setting_validities'] ); 
     479        $this->assertInstanceOf( 'WP_Error', $error_data['setting_validities']['foo_unauthorized'] ); 
     480        $this->assertEquals( 'unauthorized', $error_data['setting_validities']['foo_unauthorized']->get_error_code() ); 
     481        $this->assertArrayHasKey( 'bar_unknown', $error_data['setting_validities'] ); 
     482        $this->assertInstanceOf( 'WP_Error', $error_data['setting_validities']['bar_unknown'] ); 
     483        $this->assertEquals( 'unrecognized', $error_data['setting_validities']['bar_unknown']->get_error_code() ); 
     484        $this->assertArrayHasKey( 'baz_illegal', $error_data['setting_validities'] ); 
     485        $this->assertInstanceOf( 'WP_Error', $error_data['setting_validities']['baz_illegal'] ); 
     486        $this->assertEquals( 'illegal', $error_data['setting_validities']['baz_illegal']->get_error_code() ); 
     487 
     488        // Since transactional, ensure no changes have been made. 
     489        $this->assertEquals( $previous_saved_data, json_decode( get_post( $post_id )->post_content, true ) ); 
     490 
     491        // Attempt a non-transactional/incremental update. 
     492        $manager = new WP_Customize_Manager( array( 
     493            'changeset_uuid' => $uuid, 
     494        ) ); 
     495        $manager->register_controls(); // That is, register settings. 
     496        $r = $manager->save_changeset_post( array( 
     497            'status' => null, 
     498            'data' => array( 
     499                'blogname' => array( 
     500                    'value' => 'Non-Transactional \o/ <script>unsanitized</script>', 
     501                ), 
     502                'bar_unknown' => array( 
     503                    'value' => 'No', 
     504                ), 
     505            ), 
     506        ) ); 
     507        $this->assertInternalType( 'array', $r ); 
     508        $this->assertArrayHasKey( 'setting_validities', $r ); 
     509        $this->assertTrue( $r['setting_validities']['blogname'] ); 
     510        $this->assertInstanceOf( 'WP_Error', $r['setting_validities']['bar_unknown'] ); 
     511        $saved_data = json_decode( get_post( $post_id )->post_content, true ); 
     512        $this->assertNotEquals( $previous_saved_data, $saved_data ); 
     513        $this->assertEquals( 'Non-Transactional \o/ <script>unsanitized</script>', $saved_data['blogname']['value'] ); 
     514 
     515        // Ensure the filter applies. 
     516        $customize_changeset_save_data_call_count = $this->customize_changeset_save_data_call_count; 
     517        add_filter( 'customize_changeset_save_data', array( $this, 'filter_customize_changeset_save_data' ), 10, 2 ); 
     518        $manager->save_changeset_post( array( 
     519            'status' => null, 
     520            'data' => array( 
     521                'blogname' => array( 
     522                    'value' => 'Filtered', 
     523                ), 
     524            ), 
     525        ) ); 
     526        $this->assertEquals( $customize_changeset_save_data_call_count + 1, $this->customize_changeset_save_data_call_count ); 
     527 
     528        // Publish the changeset. 
     529        $manager = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) ); 
     530        $manager->register_controls(); 
     531        $GLOBALS['wp_customize'] = $manager; 
     532        $r = $manager->save_changeset_post( array( 
     533            'status' => 'publish', 
     534            'data' => array( 
     535                'blogname' => array( 
     536                    'value' => 'Do it live \o/', 
     537                ), 
     538            ), 
     539        ) ); 
     540        $this->assertInternalType( 'array', $r ); 
     541        $this->assertEquals( 'Do it live \o/', get_option( 'blogname' ) ); 
     542        $this->assertEquals( 'trash', get_post_status( $post_id ) ); // Auto-trashed. 
     543 
     544        // Test revisions. 
     545        add_post_type_support( 'customize_changeset', 'revisions' ); 
     546        $uuid = wp_generate_uuid4(); 
     547        $manager = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) ); 
     548        $manager->register_controls(); 
     549        $GLOBALS['wp_customize'] = $manager; 
     550 
     551        $manager->set_post_value( 'blogname', 'Hello Surface' ); 
     552        $manager->save_changeset_post( array( 'status' => 'auto-draft' ) ); 
     553 
     554        $manager->set_post_value( 'blogname', 'Hello World' ); 
     555        $manager->save_changeset_post( array( 'status' => 'draft' ) ); 
     556        $this->assertTrue( wp_revisions_enabled( get_post( $manager->changeset_post_id() ) ) ); 
     557 
     558        $manager->set_post_value( 'blogname', 'Hello Solar System' ); 
     559        $manager->save_changeset_post( array( 'status' => 'draft' ) ); 
     560 
     561        $manager->set_post_value( 'blogname', 'Hello Galaxy' ); 
     562        $manager->save_changeset_post( array( 'status' => 'draft' ) ); 
     563        $this->assertCount( 3, wp_get_post_revisions( $manager->changeset_post_id() ) ); 
     564    } 
     565 
     566    /** 
     567     * Call count for customize_changeset_save_data filter. 
     568     * 
     569     * @var int 
     570     */ 
     571    protected $customize_changeset_save_data_call_count = 0; 
     572 
     573    /** 
     574     * Filter customize_changeset_save_data. 
     575     * 
     576     * @param array $data    Data. 
     577     * @param array $context Context. 
     578     * @returns array Data. 
     579     */ 
     580    function filter_customize_changeset_save_data( $data, $context ) { 
     581        $this->customize_changeset_save_data_call_count += 1; 
     582        $this->assertInternalType( 'array', $data ); 
     583        $this->assertInternalType( 'array', $context ); 
     584        $this->assertArrayHasKey( 'uuid', $context ); 
     585        $this->assertArrayHasKey( 'title', $context ); 
     586        $this->assertArrayHasKey( 'status', $context ); 
     587        $this->assertArrayHasKey( 'date_gmt', $context ); 
     588        $this->assertArrayHasKey( 'post_id', $context ); 
     589        $this->assertArrayHasKey( 'previous_data', $context ); 
     590        $this->assertArrayHasKey( 'manager', $context ); 
     591        return $data; 
     592    } 
     593 
     594    /** 
     595     * Return illegal error. 
     596     * 
     597     * @return WP_Error Error. 
     598     */ 
     599    function return_illegal_error() { 
     600        return new WP_Error( 'illegal' ); 
     601    } 
     602 
     603    /** 
     604     * Test WP_Customize_Manager::save_changeset_post(). 
     605     * 
     606     * @ticket 30937 
     607     * @covers WP_Customize_Manager::save_changeset_post() 
     608     * @covers WP_Customize_Manager::update_stashed_theme_mod_settings() 
     609     */ 
     610    function test_save_changeset_post_with_theme_activation() { 
     611        wp_set_current_user( self::$admin_user_id ); 
     612 
     613        $stashed_theme_mods = array( 
     614            'twentyfifteen' => array( 
     615                'background_color' => array( 
     616                    'value' => '#123456', 
     617                ), 
     618            ), 
     619        ); 
     620        update_option( 'customize_stashed_theme_mods', $stashed_theme_mods ); 
     621        $uuid = wp_generate_uuid4(); 
     622        $manager = new WP_Customize_Manager( array( 
     623            'changeset_uuid' => $uuid, 
     624            'theme' => 'twentyfifteen', 
     625        ) ); 
     626        $manager->register_controls(); 
     627        $GLOBALS['wp_customize'] = $manager; 
     628 
     629        $manager->set_post_value( 'blogname', 'Hello 2015' ); 
     630        $post_values = $manager->unsanitized_post_values(); 
     631        $manager->save_changeset_post( array( 'status' => 'publish' ) ); // Activate. 
     632 
     633        $this->assertEquals( '#123456', $post_values['background_color'] ); 
     634        $this->assertEquals( 'twentyfifteen', get_stylesheet() ); 
     635        $this->assertEquals( 'Hello 2015', get_option( 'blogname' ) ); 
     636    } 
     637 
     638    /** 
     639     * Test WP_Customize_Manager::is_cross_domain(). 
     640     * 
     641     * @ticket 30937 
     642     * @covers WP_Customize_Manager::is_cross_domain() 
     643     */ 
     644    function test_is_cross_domain() { 
     645        $wp_customize = new WP_Customize_Manager(); 
     646 
     647        update_option( 'home', 'http://example.com' ); 
     648        update_option( 'siteurl', 'http://example.com' ); 
     649        $this->assertFalse( $wp_customize->is_cross_domain() ); 
     650 
     651        update_option( 'home', 'http://example.com' ); 
     652        update_option( 'siteurl', 'https://admin.example.com' ); 
     653        $this->assertTrue( $wp_customize->is_cross_domain() ); 
     654    } 
     655 
     656    /** 
     657     * Test WP_Customize_Manager::get_allowed_urls(). 
     658     * 
     659     * @ticket 30937 
     660     * @covers WP_Customize_Manager::get_allowed_urls() 
     661     */ 
     662    function test_get_allowed_urls() { 
     663        $wp_customize = new WP_Customize_Manager(); 
     664        $this->assertFalse( is_ssl() ); 
     665        $this->assertFalse( $wp_customize->is_cross_domain() ); 
     666        $allowed = $wp_customize->get_allowed_urls(); 
     667        $this->assertEquals( $allowed, array( home_url( '/', 'http' ) ) ); 
     668 
     669        add_filter( 'customize_allowed_urls', array( $this, 'filter_customize_allowed_urls' ) ); 
     670        $allowed = $wp_customize->get_allowed_urls(); 
     671        $this->assertEqualSets( $allowed, array( 'http://headless.example.com/', home_url( '/', 'http' ) ) ); 
     672    } 
     673 
     674    /** 
     675     * Callback for customize_allowed_urls filter. 
     676     * 
     677     * @param array $urls URLs. 
     678     * @return array URLs. 
     679     */ 
     680    function filter_customize_allowed_urls( $urls ) { 
     681        $urls[] = 'http://headless.example.com/'; 
     682        return $urls; 
    56683    } 
    57684 
     
    91718     * @ticket 30988 
    92719     */ 
    93     function test_unsanitized_post_values() { 
     720    function test_unsanitized_post_values_from_input() { 
     721        wp_set_current_user( self::$admin_user_id ); 
    94722        $manager = $this->manager; 
    95723 
     
    101729        $post_values = $manager->unsanitized_post_values(); 
    102730        $this->assertEquals( $customized, $post_values ); 
     731        $this->assertEmpty( $manager->unsanitized_post_values( array( 'exclude_post_data' => true ) ) ); 
     732 
     733        $manager->set_post_value( 'foo', 'BAR' ); 
     734        $post_values = $manager->unsanitized_post_values(); 
     735        $this->assertEquals( 'BAR', $post_values['foo'] ); 
     736        $this->assertEmpty( $manager->unsanitized_post_values( array( 'exclude_post_data' => true ) ) ); 
     737 
     738        // If user is unprivileged, the post data is ignored. 
     739        wp_set_current_user( 0 ); 
     740        $this->assertEmpty( $manager->unsanitized_post_values() ); 
     741    } 
     742 
     743    /** 
     744     * Test WP_Customize_Manager::unsanitized_post_values(). 
     745     * 
     746     * @ticket 30937 
     747     * @covers WP_Customize_Manager::unsanitized_post_values() 
     748     */ 
     749    function test_unsanitized_post_values_with_changeset_and_stashed_theme_mods() { 
     750        wp_set_current_user( self::$admin_user_id ); 
     751 
     752        $stashed_theme_mods = array( 
     753            'twentyfifteen' => array( 
     754                'background_color' => array( 
     755                    'value' => '#000000', 
     756                ), 
     757            ), 
     758        ); 
     759        $stashed_theme_mods[ get_stylesheet() ] = array( 
     760            'background_color' => array( 
     761                'value' => '#FFFFFF', 
     762            ), 
     763        ); 
     764        update_option( 'customize_stashed_theme_mods', $stashed_theme_mods ); 
     765 
     766        $post_values = array( 
     767            'blogdescription' => 'Post Input Tagline', 
     768        ); 
     769        $_POST['customized'] = wp_slash( wp_json_encode( $post_values ) ); 
     770 
     771        $uuid = wp_generate_uuid4(); 
     772        $changeset_data = array( 
     773            'blogname' => array( 
     774                'value' => 'Changeset Title', 
     775            ), 
     776            'blogdescription' => array( 
     777                'value' => 'Changeset Tagline', 
     778            ), 
     779        ); 
     780        $this->factory()->post->create( array( 
     781            'post_type' => 'customize_changeset', 
     782            'post_status' => 'auto-draft', 
     783            'post_name' => $uuid, 
     784            'post_content' => wp_json_encode( $changeset_data ), 
     785        ) ); 
     786 
     787        $manager = new WP_Customize_Manager( array( 
     788            'changeset_uuid' => $uuid, 
     789        ) ); 
     790        $this->assertTrue( $manager->is_theme_active() ); 
     791 
     792        $this->assertArrayNotHasKey( 'background_color', $manager->unsanitized_post_values() ); 
     793 
     794        $this->assertEquals( 
     795            array( 
     796                'blogname' => 'Changeset Title', 
     797                'blogdescription' => 'Post Input Tagline', 
     798            ), 
     799            $manager->unsanitized_post_values() 
     800        ); 
     801        $this->assertEquals( 
     802            array( 
     803                'blogdescription' => 'Post Input Tagline', 
     804            ), 
     805            $manager->unsanitized_post_values( array( 'exclude_changeset' => true ) ) 
     806        ); 
     807 
     808        $manager->set_post_value( 'blogdescription', 'Post Override Tagline' ); 
     809        $this->assertEquals( 
     810            array( 
     811                'blogname' => 'Changeset Title', 
     812                'blogdescription' => 'Post Override Tagline', 
     813            ), 
     814            $manager->unsanitized_post_values() 
     815        ); 
     816 
     817        $this->assertEquals( 
     818            array( 
     819                'blogname' => 'Changeset Title', 
     820                'blogdescription' => 'Changeset Tagline', 
     821            ), 
     822            $manager->unsanitized_post_values( array( 'exclude_post_data' => true ) ) 
     823        ); 
     824 
     825        $this->assertEmpty( $manager->unsanitized_post_values( array( 'exclude_post_data' => true, 'exclude_changeset' => true ) ) ); 
     826 
     827        // Test unstashing theme mods. 
     828        $manager = new WP_Customize_Manager( array( 
     829            'changeset_uuid' => $uuid, 
     830            'theme' => 'twentyfifteen', 
     831        ) ); 
     832        $this->assertFalse( $manager->is_theme_active() ); 
     833        $values = $manager->unsanitized_post_values( array( 'exclude_post_data' => true, 'exclude_changeset' => true ) ); 
     834        $this->assertNotEmpty( $values ); 
     835        $this->assertArrayHasKey( 'background_color', $values ); 
     836        $this->assertEquals( '#000000', $values['background_color'] ); 
     837 
     838        $values = $manager->unsanitized_post_values( array( 'exclude_post_data' => false, 'exclude_changeset' => false ) ); 
     839        $this->assertArrayHasKey( 'background_color', $values ); 
     840        $this->assertArrayHasKey( 'blogname', $values ); 
     841        $this->assertArrayHasKey( 'blogdescription', $values ); 
    103842    } 
    104843 
     
    109848     */ 
    110849    function test_post_value() { 
     850        wp_set_current_user( self::$admin_user_id ); 
    111851        $posted_settings = array( 
    112852            'foo' => 'OOF', 
     
    132872     */ 
    133873    function test_invalid_post_value() { 
     874        wp_set_current_user( self::$admin_user_id ); 
    134875        $default_value = 'foo_default'; 
    135876        $setting = $this->manager->add_setting( 'foo', array( 
     
    197938     */ 
    198939    function test_post_value_validation_sanitization_order() { 
     940        wp_set_current_user( self::$admin_user_id ); 
    199941        $default_value = '0'; 
    200942        $setting = $this->manager->add_setting( 'numeric', array( 
     
    241983     */ 
    242984    function test_validate_setting_values() { 
     985        wp_set_current_user( self::$admin_user_id ); 
    243986        $setting = $this->manager->add_setting( 'foo', array( 
    244987            'validate_callback' => array( $this, 'filter_customize_validate_foo' ), 
     
    3061049 
    3071050    /** 
     1051     * Test WP_Customize_Manager::validate_setting_values(). 
     1052     * 
     1053     * @ticket 30937 
     1054     * @covers WP_Customize_Manager::validate_setting_values() 
     1055     */ 
     1056    function test_validate_setting_values_args() { 
     1057        wp_set_current_user( self::$admin_user_id ); 
     1058        $this->manager->register_controls(); 
     1059 
     1060        $validities = $this->manager->validate_setting_values( array( 'unknown' => 'X' ) ); 
     1061        $this->assertEmpty( $validities ); 
     1062 
     1063        $validities = $this->manager->validate_setting_values( array( 'unknown' => 'X' ), array( 'validate_existence' => false ) ); 
     1064        $this->assertEmpty( $validities ); 
     1065 
     1066        $validities = $this->manager->validate_setting_values( array( 'unknown' => 'X' ), array( 'validate_existence' => true ) ); 
     1067        $this->assertNotEmpty( $validities ); 
     1068        $this->assertArrayHasKey( 'unknown', $validities ); 
     1069        $error = $validities['unknown']; 
     1070        $this->assertInstanceOf( 'WP_Error', $error ); 
     1071        $this->assertEquals( 'unrecognized', $error->get_error_code() ); 
     1072 
     1073        $this->manager->get_setting( 'blogname' )->capability = 'do_not_allow'; 
     1074        $validities = $this->manager->validate_setting_values( array( 'blogname' => 'X' ), array( 'validate_capability' => false ) ); 
     1075        $this->assertArrayHasKey( 'blogname', $validities ); 
     1076        $this->assertTrue( $validities['blogname'] ); 
     1077        $validities = $this->manager->validate_setting_values( array( 'blogname' => 'X' ), array( 'validate_capability' => true ) ); 
     1078        $this->assertArrayHasKey( 'blogname', $validities ); 
     1079        $error = $validities['blogname']; 
     1080        $this->assertInstanceOf( 'WP_Error', $error ); 
     1081        $this->assertEquals( 'unauthorized', $error->get_error_code() ); 
     1082    } 
     1083 
     1084    /** 
    3081085     * Add a length constraint to a setting. 
    3091086     * 
     
    3291106     */ 
    3301107    function test_validate_setting_values_validation_sanitization_order() { 
     1108        wp_set_current_user( self::$admin_user_id ); 
    3311109        $setting = $this->manager->add_setting( 'numeric', array( 
    3321110            'validate_callback' => array( $this, 'filter_customize_validate_numeric' ), 
     
    3701148     */ 
    3711149    function test_set_post_value() { 
     1150        wp_set_current_user( self::$admin_user_id ); 
    3721151        $this->manager->add_setting( 'foo', array( 
    3731152            'sanitize_callback' => array( $this, 'sanitize_foo_for_test_set_post_value' ), 
     
    4741253        $this->assertFalse( $this->manager->has_published_pages() ); 
    4751254 
    476         wp_set_current_user( $this->factory()->user->create( array( 'role' => 'editor' ) ) ); 
     1255        wp_set_current_user( self::$admin_user_id ); 
    4771256        $this->manager->nav_menus->customize_register(); 
    4781257        $setting_id = 'nav_menus_created_posts'; 
     
    4931272     */ 
    4941273    function test_register_dynamic_settings() { 
     1274        wp_set_current_user( self::$admin_user_id ); 
    4951275        $posted_settings = array( 
    4961276            'foo' => 'OOF', 
     
    5921372        $this->assertEquals( home_url( '/' ), $this->manager->get_return_url() ); 
    5931373 
    594         wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); 
     1374        wp_set_current_user( self::$admin_user_id ); 
    5951375        $this->assertTrue( current_user_can( 'edit_theme_options' ) ); 
    5961376        $this->assertEquals( home_url( '/' ), $this->manager->get_return_url() ); 
     
    6851465     */ 
    6861466    function test_customize_pane_settings() { 
    687         wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); 
     1467        wp_set_current_user( self::$admin_user_id ); 
    6881468        $this->manager->register_controls(); 
    6891469        $this->manager->prepare_controls(); 
     
    7071487        $this->assertNotEmpty( $data ); 
    7081488 
    709         $this->assertEqualSets( array( 'theme', 'url', 'browser', 'panels', 'sections', 'nonce', 'autofocus', 'documentTitleTmpl', 'previewableDevices' ), array_keys( $data ) ); 
     1489        $this->assertEqualSets( array( 'theme', 'url', 'browser', 'panels', 'sections', 'nonce', 'autofocus', 'documentTitleTmpl', 'previewableDevices', 'changeset', 'timeouts' ), array_keys( $data ) ); 
    7101490        $this->assertEquals( $autofocus, $data['autofocus'] ); 
    7111491        $this->assertArrayHasKey( 'save', $data['nonce'] ); 
     
    7191499     */ 
    7201500    function test_customize_preview_settings() { 
    721         wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); 
     1501        wp_set_current_user( self::$admin_user_id ); 
    7221502        $this->manager->register_controls(); 
    7231503        $this->manager->prepare_controls(); 
     
    7411521        $this->assertArrayHasKey( 'nonce', $settings ); 
    7421522        $this->assertArrayHasKey( '_dirty', $settings ); 
     1523        $this->assertArrayHasKey( 'timeouts', $settings ); 
     1524        $this->assertArrayHasKey( 'changeset', $settings ); 
    7431525 
    7441526        $this->assertArrayHasKey( 'preview', $settings['nonce'] ); 
    745         $this->assertEquals( array( 'foo' ), $settings['_dirty'] ); 
    7461527    } 
    7471528 
     
    8151596        $manager->register_controls(); 
    8161597        $section_id = 'foo-section'; 
    817         wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); 
     1598        wp_set_current_user( self::$admin_user_id ); 
    8181599        $manager->add_section( $section_id, array( 
    8191600            'title'      => 'Section',