The Zendesk Web Widget Appears To Have A Small Hide / Show Race Condition
If all you want to do is display the Zendesk web widget on your site, it's pretty easy. You just load the bootstrapping script, which loads the widget script asynchronously, which renders the Zendesk widget on the bottom-right of your screen. But, if you want to programmatically show and hide the widget, based on the state of the application, things get a bit more complicated. Mostly, because I believe there is a small race condition in the show and hide algorithm which limits how fast you can toggle the widget.
To be clear, I am not exactly sure what is going on here. I know that, under certain conditions, I don't get the results that I would like from the Zendesk web widget. But, I am not sure if has to do with how I'm consuming the API; or, if it has to do with the timing of the Script load; or if it has to do with how commands get queued pre-load? It's unclear. But, I can consistently demonstrate that the Zendesk widget will not - under some circumstances - render the way I tell it to.
For this demonstration, I am using Angular 2.4.9; but, the same Zendesk web widget behavior is exhibited in Angular 1.2; and, almost certainly has nothing to do with the fact that I am using Angular. It just so happens that most of my JavaScript demos are written in Angular, so it's easy for me to do this.
Before we look at the code, it's important to understand how the Zendesk web widget presents itself. First, you load a bootstrapping script which exposes the zEmbed (or zE) object. This object is actually just a lightweight Function that keeps an internal queue of callbacks that you can register before the web widget script has loaded. Once the web widget script has loaded, the zEmbed object is augmented (or possibly overridden) with the actual API and the enqueued callbacks are invoked. As such, much of the zEmbed consumption follows this pattern:
zEmbed(
function runWhenLoaded() {
console.log( "zEmbed has finally loaded!" );
zEmbed.identify({ /* things. */ });
}
);
This queues up a callback using the lightweight zEmbed Function; then, once the full zEmbed widget has loaded, this callback consumes the finalized API. This patten is helpful because we have no idea how long it will take for the full Zendesk web widget script to load asynchronously.
Now, to demonstrate the race condition, I've created a simple Angular 2 demo that uses the zEmbed queuing function to hide the Zendesk web widget the moment the full zEmbed script is loaded. Then, after hiding the widget, I subscribe to a "Zendesk state" stream, which will hide or show the web widget based on the state of the application:
// Import the core angular services.
import { Component } from "@angular/core";
import { Observable } from "rxjs/Observable";
// Import these for their side-effects.
import "rxjs/add/observable/of";
// The zEmbed function is a global object, so we have to Declare the interface so that
// TypeScript doesn't complain. The zEmbed object acts as both a pre-load queue as well
// as the API. As such, it must be invocable and expose the API.
declare var zEmbed: {
// zEmbed can queue functions to be invoked when the asynchronous script has loaded.
( callback: () => void ) : void;
// ... and, once the asynchronous zEmbed script is loaded, the zEmbed object will
// expose the widget API.
activate(): void;
hide(): void;
identify(): void;
setHelpCenterSuggestions(): void;
show(): void;
}
@Component({
selector: "my-app",
styleUrls: [ "./app.component.css" ],
templateUrl: "./app.component.htm"
})
export class AppComponent {
// I initialize the app component.
constructor() {
this.startWatchingZendeskStatus();
}
// ---
// PRIVATE METHODS.
// ---
// I return an Observable stream of values that should be used to drive the
// visibility of the Zendesk widget.
private getZendeskStatusStream() {
return( Observable.of( true, false ) );
}
// I update the visibility of the Zendesk widget based on the application state.
private startWatchingZendeskStatus() {
// CAUTION: Since the Zendesk widget may not have loaded yet, we need to use the
// zEmbed() queue method to defer watching the Zendesk status until the widget
// has loaded.
zEmbed(
() => {
console.log( "Zendesk widget has loaded -- start watching status." );
// First, let's start by turning OFF the Zendesk widget until we have
// a reason to turn the widget back on.
zEmbed.hide();
// ... then, let's subscribe to changes in the Zendesk widget status to
// adjust the widget rendering as needed.
this.getZendeskStatusStream().subscribe(
( value: boolean ) => {
console.log( `Zendesk status value changed to [${ value }].` );
value
? zEmbed.show()
: zEmbed.hide()
;
}
);
}
);
}
}
As you can see, the Zendesk state stream is just an RxJS Observable that returns a few Boolean values. When the value is "true", I show the widget; and, when the value is "false", I hide the widget. And, since the last Boolean in the stream is "false", I would expect the Zendesk widget to end up hidden. However, when we run this demo, we get the following page output:
As you can see by the console logging, the last value emitted by the RxJS Observable stream was "false". This means that we would have turned around and called zEmbed.hide() to hide the web widget. However, we can clearly see that the web widget is still visible.
While I am not sure why this happens, I suspect that it might have something to do with the animation that is used to render the widget. Obviously, you can't see this in the screenshot; but, when the web widget is toggled, it uses about a half-second fade-in/out animation. Perhaps if the hide/show methods are invoked during the animation, they are ignored? Or, just not applied properly?
To follow-up this line of thinking, I refactored my demo to put a delay between the RxJS Observable event and the invocation of the zEmbed API. And, what's more, I am debouncing the calls to the zEmbed API so that only the last request within the delay-period will actually get communicated to the zEmbed widget:
// Import the core angular services.
import { Component } from "@angular/core";
import { Observable } from "rxjs/Observable";
// Import these for their side-effects.
import "rxjs/add/observable/of";
// The zEmbed function is a global object, so we have to Declare the interface so that
// TypeScript doesn't complain. The zEmbed object acts as both a pre-load queue as well
// as the API. As such, it must be invocable and expose the API.
declare var zEmbed: {
// zEmbed can queue functions to be invoked when the asynchronous script has loaded.
( callback: () => void ) : void;
// ... and, once the asynchronous zEmbed script is loaded, the zEmbed object will
// expose the widget API.
activate(): void;
hide(): void;
identify(): void;
setHelpCenterSuggestions(): void;
show(): void;
}
@Component({
selector: "my-app",
styleUrls: [ "./app.component.css" ],
templateUrl: "./app.component.htm"
})
export class AppComponent {
// I initialize the app component.
constructor() {
this.startWatchingZendeskStatus();
}
// ---
// PRIVATE METHODS.
// ---
// I return an Observable stream of values that should be used to drive the
// visibility of the Zendesk widget.
private getZendeskStatusStream() {
return( Observable.of( true, false, true ) );
}
// I update the visibility of the Zendesk widget based on the application state.
private startWatchingZendeskStatus() {
// CAUTION: Since the Zendesk widget may not have loaded yet, we need to use the
// zEmbed() queue method to defer watching the Zendesk status until the widget
// has loaded.
zEmbed(
() => {
console.log( "Zendesk widget has loaded -- start watching status." );
var widgetTimer: number = null;
var widgetDelay: number = 100;
// First, let's start by turning OFF the Zendesk widget until we have
// a reason to turn the widget back on.
zEmbed.hide();
// ... then, let's subscribe to changes in the Zendesk widget status to
// adjust the widget rendering as needed.
this.getZendeskStatusStream().subscribe(
( value: boolean ) => {
console.log( `Zendesk status value changed to [${ value }].` );
clearTimeout( widgetTimer );
widgetTimer = setTimeout(
() => {
console.log( `Applying status change [${ value }].` );
value
? zEmbed.show()
: zEmbed.hide()
;
},
widgetDelay
);
}
);
}
);
}
}
As you can see, I am maintaining an internal timer (using setTimeout()), which is debouncing the calls to the zEmbed API using a 100 millisecond window. I have also slightly changed the RxJS Observable stream to end with a "true" so that we render the web widget. However, when we run this code, we get the following page output:
As you can see, despite the fact that our RxJS stream ends with "true" and we call the zEmbed.show() method, the Zendesk web widget remains hidden.
Because I suspect that the race condition has something to do with the animation, it's possible that we're on the right track but the 100 millisecond delay isn't quite long enough. So, let's try to change from 100ms to 500ms:
var widgetThrottle: number = 500;
This time, when we run the demo with the 500 millisecond delay, we get the following page output:
As you can see, this time - with the 500ms delay rather than the 100ms delay - the Zendesk web widget rendering finally matches our calls to the zEmbed API. And, again, I don't really know why the delay is necessary - I don't know where the race condition is. But, the fact that this is fixed by a difference in delay-duration makes me think that it has something to do with the animation.
I know that this is a rather niche topic (probably within an already niche topic); but, this had me scratching my head for a while. Hopefully, this will help others that are using the Zendesk web widget API.
Want to use code from this post? Check out the license.
Reader Comments
@All,
Here's a quick follow-up, encapsulating the race condition and the dual-nature of the zEmbed Function / Object inside of a Promise-based service:
www.bennadel.com/blog/3249-wrapping-the-zendesk-web-widget-in-a-promise-based-zendesk-service-in-angular-2-4-9.htm
... this allows the calling context to become greatly simplified. And, allows the zEmbed object to evolve somewhat independently of the rest of the application since it's now behind an API that we control.