Theme Customizer: Fix race condition in previewer and use message channels. Props koopersmith. fixes #20811

git-svn-id: http://core.svn.wordpress.org/trunk@20988 1a063a9b-81f0-0310-95a4-ce76da25c4cd
This commit is contained in:
ryan 2012-06-04 15:51:46 +00:00
parent bb905a7899
commit e69299af51
5 changed files with 235 additions and 85 deletions

View File

@ -281,6 +281,116 @@
// Create the collection of Control objects.
api.control = new api.Values({ defaultConstructor: api.Control });
api.PreviewFrame = api.Messenger.extend({
sensitivity: 2000,
initialize: function( params, options ) {
var loaded = false,
ready = false,
deferred = $.Deferred(),
self = this;
// This is the promise object.
deferred.promise( this );
this.previewer = params.previewer;
$.extend( params, { channel: api.PreviewFrame.uuid() });
api.Messenger.prototype.initialize.call( this, params, options );
this.bind( 'ready', function() {
ready = true;
if ( loaded )
deferred.resolveWith( self );
});
params.query = $.extend( params.query || {}, { customize_messenger_channel: this.channel() });
this.request = $.ajax( this.url(), {
type: 'POST',
data: params.query,
xhrFields: {
withCredentials: true
}
} );
this.request.fail( function() {
deferred.rejectWith( self, [ 'request failure' ] );
});
this.request.done( function( response ) {
var location = self.request.getResponseHeader('Location'),
signature = 'WP_CUSTOMIZER_SIGNATURE',
index;
// Check if the location response header differs from the current URL.
// If so, the request was redirected; try loading the requested page.
if ( location && location != self.url() ) {
deferred.rejectWith( self, [ 'redirect', location ] );
return;
}
// Check for a signature in the request.
index = response.lastIndexOf( signature );
if ( -1 === index || index < response.lastIndexOf('</html>') ) {
deferred.rejectWith( self, [ 'unsigned' ] );
return;
}
// Strip the signature from the request.
response = response.slice( 0, index ) + response.slice( index + signature.length );
// Create the iframe and inject the html content.
// Strip the signature from the request.
response = response.slice( 0, index ) + response.slice( index + signature.length );
// Create the iframe and inject the html content.
self.iframe = $('<iframe />').appendTo( self.previewer.container );
// Bind load event after the iframe has been added to the page;
// otherwise it will fire when injected into the DOM.
self.iframe.one( 'load', function() {
loaded = true;
if ( ready ) {
deferred.resolveWith( self );
} else {
setTimeout( function() {
deferred.rejectWith( self, [ 'ready timeout' ] );
}, self.sensitivity );
}
});
self.targetWindow( self.iframe[0].contentWindow );
self.targetWindow().document.open();
self.targetWindow().document.write( response );
self.targetWindow().document.close();
});
},
destroy: function() {
api.Messenger.prototype.destroy.call( this );
this.request.abort();
if ( this.iframe )
this.iframe.remove();
delete this.request;
delete this.iframe;
delete this.targetWindow;
}
});
(function(){
var uuid = 0;
api.PreviewFrame.uuid = function() {
return 'preview-' + uuid++;
};
}());
api.Previewer = api.Messenger.extend({
refreshBuffer: 250,
@ -295,8 +405,6 @@
$.extend( this, options || {} );
this.loaded = $.proxy( this.loaded, this );
/*
* Wrap this.refresh to prevent it from hammering the servers:
*
@ -320,9 +428,7 @@
return function() {
if ( typeof timeout !== 'number' ) {
if ( self.loading ) {
self.loading.remove();
delete self.loading;
self.loader();
self.abort();
} else {
return callback();
}
@ -336,7 +442,7 @@
this.container = api.ensure( params.container );
this.allowedUrls = params.allowedUrls;
api.Messenger.prototype.initialize.call( this, params.url );
api.Messenger.prototype.initialize.call( this, params );
// We're dynamically generating the iframe, so the origin is set
// to the current window's location, not the url's.
@ -391,64 +497,48 @@
// Update the URL when the iframe sends a URL message.
this.bind( 'url', this.url );
},
loader: function() {
if ( this.loading )
return this.loading;
this.loading = $('<iframe />').appendTo( this.container );
return this.loading;
},
loaded: function() {
if ( this.iframe )
this.iframe.remove();
this.iframe = this.loading;
delete this.loading;
this.targetWindow( this.iframe[0].contentWindow );
this.send( 'scroll', this.scroll );
},
query: function() {},
abort: function() {
if ( this.loading ) {
this.loading.destroy();
delete this.loading;
}
},
refresh: function() {
var self = this;
if ( this.request )
this.request.abort();
this.abort();
this.request = $.ajax( this.url(), {
type: 'POST',
data: this.query() || {},
success: function( response ) {
var iframe = self.loader()[0].contentWindow,
location = self.request.getResponseHeader('Location'),
signature = 'WP_CUSTOMIZER_SIGNATURE',
index;
this.loading = new api.PreviewFrame({
url: this.url(),
query: this.query() || {},
previewer: this
});
// Check if the location response header differs from the current URL.
// If so, the request was redirected; try loading the requested page.
if ( location && location != self.url() ) {
this.loading.done( function() {
// 'this' is the loading frame
this.bind( 'synced', function() {
if ( self.iframe )
self.iframe.destroy();
self.iframe = this;
delete self.loading;
self.targetWindow( this.targetWindow() );
self.channel( this.channel() );
});
this.send( 'sync', {
scroll: self.scroll,
settings: api.get()
});
});
this.loading.fail( function( reason, location ) {
if ( 'redirect' === reason && location )
self.url( location );
return;
}
// Check for a signature in the request.
index = response.lastIndexOf( signature );
if ( -1 === index || index < response.lastIndexOf('</html>') )
return;
// Strip the signature from the request.
response = response.slice( 0, index ) + response.slice( index + signature.length );
self.loader().one( 'load', self.loaded );
iframe.document.open();
iframe.document.write( response );
iframe.document.close();
},
xhrFields: {
withCredentials: true
}
});
}
});
@ -617,7 +707,10 @@
});
// Create a potential postMessage connection with the parent frame.
parent = new api.Messenger( api.settings.url.parent );
parent = new api.Messenger({
url: api.settings.url.parent,
channel: 'loader'
});
// If we receive a 'back' event, we're inside an iframe.
// Send any clicks to the 'Return' link to the parent page.

View File

@ -291,6 +291,7 @@ final class WP_Customize_Manager {
public function customize_preview_settings() {
$settings = array(
'values' => array(),
'channel' => esc_js( $_POST['customize_messenger_channel'] ),
);
foreach ( $this->settings as $id => $setting ) {

View File

@ -302,13 +302,12 @@ if ( typeof wp === 'undefined' )
return this.add( id, new this.defaultConstructor( api.Class.applicator, slice.call( arguments, 1 ) ) );
},
get: function() {
var result = {};
each: function( callback, context ) {
context = typeof context === 'undefined' ? this : context;
$.each( this._value, function( key, obj ) {
result[ key ] = obj.get();
callback.call( context, obj, key );
});
return result;
},
remove: function( id ) {
@ -481,19 +480,36 @@ if ( typeof wp === 'undefined' )
return this[ key ] = new api.Value( initial, options );
},
initialize: function( url, targetWindow, options ) {
/**
* Initialize Messenger.
*
* @param {object} params Parameters to configure the messenger.
* {string} .url The URL to communicate with.
* {window} .targetWindow The window instance to communicate with. Default window.parent.
* {string} .channel If provided, will send the channel with each message and only accept messages a matching channel.
* @param {object} options Extend any instance parameter or method with this object.
*/
initialize: function( params, options ) {
// Target the parent frame by default, but only if a parent frame exists.
var defaultTarget = window.parent == window ? null : window.parent;
$.extend( this, options || {} );
url = this.add( 'url', url );
this.add( 'targetWindow', targetWindow || defaultTarget );
this.add( 'origin', url() ).link( url ).setter( function( to ) {
this.add( 'channel', params.channel );
this.add( 'url', params.url );
this.add( 'targetWindow', params.targetWindow || defaultTarget );
this.add( 'origin', this.url() ).link( this.url ).setter( function( to ) {
return to.replace( /([^:]+:\/\/[^\/]+).*/, '$1' );
});
// Since we want jQuery to treat the receive function as unique
// to this instance, we give the function a new guid.
//
// This will prevent every Messenger's receive function from being
// unbound when calling $.off( 'message', this.receive );
this.receive = $.proxy( this.receive, this );
this.receive.guid = $.guid++;
$( window ).on( 'message', this.receive );
},
@ -515,7 +531,14 @@ if ( typeof wp === 'undefined' )
message = JSON.parse( event.data );
if ( message && message.id && typeof message.data !== 'undefined' )
// Check required message properties.
if ( ! message || ! message.id || typeof message.data === 'undefined' )
return;
// Check if channel names match.
if ( ( message.channel || this.channel() ) && this.channel() !== message.channel )
return;
this.trigger( message.id, message.data );
},
@ -527,8 +550,11 @@ if ( typeof wp === 'undefined' )
if ( ! this.url() || ! this.targetWindow() )
return;
message = JSON.stringify({ id: id, data: data });
this.targetWindow().postMessage( message, this.origin() );
message = { id: id, data: data };
if ( this.channel() )
message.channel = this.channel();
this.targetWindow().postMessage( JSON.stringify( message ), this.origin() );
}
});
@ -540,6 +566,15 @@ if ( typeof wp === 'undefined' )
* ===================================================================== */
api = $.extend( new api.Values(), api );
api.get = function() {
var result = {};
this.each( function( obj, key ) {
result[ key ] = obj.get();
});
return result;
};
// Expose the API to the world.
exports.customize = api;

View File

@ -77,7 +77,11 @@ if ( typeof wp === 'undefined' )
this.iframe.one( 'load', this.loaded );
// Create a postMessage connection with the iframe.
this.messenger = new api.Messenger( src, this.iframe[0].contentWindow );
this.messenger = new api.Messenger({
url: src,
channel: 'loader',
targetWindow: this.iframe[0].contentWindow
});
// Wait for the connection from the iframe before sending any postMessage events.
this.messenger.bind( 'ready', function() {

View File

@ -21,15 +21,11 @@
/**
* Requires params:
* - url - the URL of preview frame
*
* @todo: Perhaps add a window.onbeforeunload dialog in case the theme
* somehow attempts to leave the page and we don't catch it
* (which really shouldn't happen).
*/
initialize: function( url, options ) {
initialize: function( params, options ) {
var self = this;
api.Messenger.prototype.initialize.call( this, url, null, options );
api.Messenger.prototype.initialize.call( this, params, options );
this.body = $( document.body );
this.body.on( 'click.preview', 'a', function( event ) {
@ -39,8 +35,7 @@
});
// You cannot submit forms.
// @todo: Namespace customizer settings so that we can mix the
// $_POST data with the customize setting $_POST data.
// @todo: Allow form submissions by mixing $_POST data with the customize setting $_POST data.
this.body.on( 'submit.preview', 'form', function( event ) {
event.preventDefault();
});
@ -63,18 +58,40 @@
var preview, bg;
preview = new api.Preview( window.location.href );
preview = new api.Preview({
url: window.location.href,
channel: api.settings.channel
});
$.each( api.settings.values, function( id, value ) {
preview.bind( 'settings', function( values ) {
$.each( values, function( id, value ) {
if ( api.has( id ) )
api( id ).set( value );
else
api.create( id, value );
});
});
preview.trigger( 'settings', api.settings.values );
preview.bind( 'setting', function( args ) {
var value = api( args.shift() );
if ( value )
var value;
args = args.slice();
if ( value = api( args.shift() ) )
value.set.apply( value, args );
});
preview.bind( 'sync', function( events ) {
$.each( events, function( event, args ) {
preview.trigger( event, args );
});
preview.send( 'synced' );
})
preview.send( 'ready' );
/* Custom Backgrounds */
bg = $.map(['color', 'image', 'position_x', 'repeat', 'attachment'], function( prop ) {
return 'background_' + prop;