Skip to content
This repository has been archived by the owner on Jan 13, 2025. It is now read-only.

Commit

Permalink
feat(demos): Add theme switcher to theme demo page (#1975)
Browse files Browse the repository at this point in the history
This PR also adds hot reloading of stylesheets that have a `data-hot` attribute.

Currently, only the theme demo page has a `<link rel="stylesheet" href="..." data-hot>` element, but I plan to create follow-up PRs to add hot swapping to the remaining demo pages. Hot swapping was broken when we switched to CSS files in #1916.

When Webpack starts recompiling our Sass, the theme demo page displays a linear-progress bar in the toolbar until compilation finishes, at which point it automatically swaps in the new stylesheet.

I tested this PR in:

- Chrome 63 (desktop and mobile)
- Firefox 57 (desktop)
- Safari 11 (desktop and mobile)
- IE 11
- Edge 14
  • Loading branch information
acdvorak authored Jan 19, 2018
1 parent f5e0464 commit 4f89819
Show file tree
Hide file tree
Showing 13 changed files with 1,120 additions and 206 deletions.
28 changes: 28 additions & 0 deletions demos/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2017 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as interactivity from './interactivity.js';

demoReady((root) => {
interactivity.init(root);
});

// Export useful libs to aid debugging/experimentation in the browser's dev tools console.
import * as dom from './dom.js';
import * as pony from './ponyfill.js';
import * as util from './util.js';
export {dom, pony, util};
42 changes: 42 additions & 0 deletions demos/dom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2017 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* @param {string} query
* @param {!Document|!Element=} root
* @return {!Array<!Element>}
*/
export function getAll(query, root = document) {
return [].slice.call(root.querySelectorAll(query));
}

/**
* @param {!Window|!Document|!Element} root
* @return {!Document|undefined}
*/
export function getDocument(root) {
return root.ownerDocument || root.document || (root.documentElement ? root : undefined);
}

/**
* @param {!Window|!Document|!Element} root
* @return {!Window|undefined}
*/
export function getWindow(root) {
const doc = getDocument(root);
return doc.defaultView || doc.parentWindow || (root.document ? root : undefined);
}
301 changes: 301 additions & 0 deletions demos/interactivity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
/**
* @license
* Copyright 2017 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as dom from './dom.js';
import * as util from './util.js';

const classes = {
TOOLBAR_PROGRESS_BAR_ACTIVE: 'demo-toolbar-progress-bar--active',
};

const attrs = {
HOT_SWAP: 'data-hot',
IS_LOADING: 'data-is-loading',
};

const ids = {
TOOLBAR_PROGRESS_BAR: 'demo-toolbar-progress-bar',
};

/** @abstract */
export class InteractivityProvider {
constructor(root) {
/** @protected {!Document|!Element} */
this.root_ = root;

/** @protected {!Document} */
this.document_ = dom.getDocument(this.root_);

/** @protected {!Window} */
this.window_ = dom.getWindow(this.root_);
}

lazyInit() {}

/**
* @param {string} selector
* @param {!Document|!Element=} root
* @return {!Array<!Element>}
* @protected
*/
querySelectorAll_(selector, root = this.root_) {
return dom.getAll(selector, root);
}
}

export class ToolbarProvider extends InteractivityProvider {
/** @param {!Document|!Element} root */
static attachTo(root) {
const instance = new ToolbarProvider(root);
instance.lazyInit();
return instance;
}

/** @override */
lazyInit() {
/** @type {?Element} */
this.progressBarEl_ = this.root_.getElementById(ids.TOOLBAR_PROGRESS_BAR);
}

/** @param {boolean} isLoading */
setIsLoading(isLoading) {
if (!this.progressBarEl_) {
return;
}

if (isLoading) {
this.progressBarEl_.classList.add(classes.TOOLBAR_PROGRESS_BAR_ACTIVE);
} else {
this.progressBarEl_.classList.remove(classes.TOOLBAR_PROGRESS_BAR_ACTIVE);
}
}
}

export class HotSwapper extends InteractivityProvider {
/**
* @param {!Document|!Element} root
* @param {!ToolbarProvider} toolbarProvider
*/
static attachTo(root, toolbarProvider) {
const instance = new HotSwapper(root);
instance.lazyInit(toolbarProvider);
return instance;
}

/** @private {number} */
static get hotUpdateWaitPeriodMs_() {
return 250;
}

/**
* @param {!ToolbarProvider} toolbarProvider
* @override
*/
lazyInit(toolbarProvider) {
/** @type {!ToolbarProvider} */
this.toolbarProvider_ = toolbarProvider;

/** @type {!Array<string>} */
this.pendingRequests_ = [];

this.registerHotUpdateHandler_();
}

/** @private */
registerHotUpdateHandler_() {
const hotSwapAllStylesheets = util.debounce(() => {
this.hotSwapAllStylesheets_();
}, HotSwapper.hotUpdateWaitPeriodMs_);

this.window_.addEventListener('message', (evt) => {
if (this.isWebpackRecompileStart_(evt)) {
this.toolbarProvider_.setIsLoading(true);
} else if (this.isWebpackRecompileEnd_(evt)) {
hotSwapAllStylesheets();
}
});
}

/**
* @param {!Event} evt
* @return {boolean}
* @private
*/
isWebpackRecompileStart_(evt) {
return Boolean(evt.data) && evt.data.type === 'webpackInvalid';
}

/**
* @param {!Event} evt
* @return {boolean}
* @private
*/
isWebpackRecompileEnd_(evt) {
return typeof evt.data === 'string' && evt.data.indexOf('webpackHotUpdate') === 0;
}

/** @private */
hotSwapAllStylesheets_() {
this.querySelectorAll_(`link[${attrs.HOT_SWAP}]:not([${attrs.IS_LOADING}])`).forEach((link) => {
this.hotSwapStylesheet(link);
});
}

/**
* @param {!Element} oldLink
* @param {string=} newUri
*/
hotSwapStylesheet(oldLink, newUri) {
const oldUri = oldLink.getAttribute('href');

// Reload existing stylesheet
if (!newUri) {
newUri = oldUri;
}

// Force IE 11 and Edge to bypass the cache and request a fresh copy of the CSS.
newUri = this.bustCache_(newUri);

this.swapItLikeItsHot_(oldLink, oldUri, newUri);
}

/**
* @param {!Element} oldLink
* @param {string} oldUri
* @param {string} newUri
* @private
*/
swapItLikeItsHot_(oldLink, oldUri, newUri) {
this.logHotSwap_('swapping', oldUri, newUri, '...');

// Ensure that oldLink has a unique ID so we can remove all stale stylesheets from the DOM after newLink loads.
// This is a more robust approach than holding a reference to oldLink and removing it directly, because a user might
// quickly switch themes several times before the first stylesheet finishes loading (especially over a slow network)
// and each new stylesheet would try to remove the first one, leaving multiple conflicting stylesheets in the DOM.
if (!oldLink.id) {
oldLink.id = `stylesheet-${Math.floor(Math.random() * Date.now())}`;
}

const newLink = /** @type {!Element} */ (oldLink.cloneNode(false));
newLink.setAttribute('href', newUri);
newLink.setAttribute(attrs.IS_LOADING, 'true');

// IE 11 and Edge fire the `load` event twice for `<link>` elements.
newLink.addEventListener('load', util.debounce(() => {
this.handleStylesheetLoad_(newLink, newUri, oldUri);
}, 50));

oldLink.parentNode.insertBefore(newLink, oldLink);

this.pendingRequests_.push(newUri);
this.toolbarProvider_.setIsLoading(true);
}

/**
* @param {!Element} newLink
* @param {string} newUri
* @param {string} oldUri
* @private
*/
handleStylesheetLoad_(newLink, newUri, oldUri) {
this.pendingRequests_.splice(this.pendingRequests_.indexOf(newUri), 1);
if (this.pendingRequests_.length === 0) {
this.toolbarProvider_.setIsLoading(false);
}

setTimeout(() => {
this.purgeOldStylesheets_(newLink);

// Remove the 'loading' attribute *after* purging old stylesheets to avoid purging this one.
newLink.removeAttribute(attrs.IS_LOADING);

this.logHotSwap_('swapped', oldUri, newUri, '!');
});
}

/**
* @param {!Element} newLink
* @private
*/
purgeOldStylesheets_(newLink) {
let oldLinks;

const getOldLinks = () => this.querySelectorAll_(`link[id="${newLink.id}"]:not([${attrs.IS_LOADING}])`);

while ((oldLinks = getOldLinks()).length > 0) {
oldLinks.forEach((oldLink) => {
// Link has already been detached from the DOM. I'm not sure what causes this to happen; I've only seen it in
// IE 11 and/or Edge so far, and only occasionally.
if (!oldLink.parentNode) {
return;
}
oldLink.parentNode.removeChild(oldLink);
});
}
}

/**
* Adds a timestamp to the given URI to force IE 11 and Edge to bypass the cache and request a fresh copy of the CSS.
* @param oldUri
* @return {string}
* @private
*/
bustCache_(oldUri) {
const newUri = oldUri
// Remove previous timestamp param (if present)
.replace(/[?&]timestamp=\d+(&|$)/, '')
// Remove trailing '?' or '&' char (if present)
.replace(/[?&]$/, '');
const separator = newUri.indexOf('?') === -1 ? '?' : '&';
return `${newUri}${separator}timestamp=${Date.now()}`;
}

/**
* @param {string} verb
* @param {string} oldUri
* @param {string} newUri
* @param {string} trailingPunctuation
* @private
*/
logHotSwap_(verb, oldUri, newUri, trailingPunctuation) {
const swapMessage = `"${oldUri}"${newUri ? ` with "${newUri}"` : ''}`;
console.log(`Hot ${verb} stylesheet ${swapMessage}${trailingPunctuation}`);
}

/**
* @param {!Document|!Element} root
* @return {!HotSwapper}
*/
static getInstance(root) {
// Yeah, I know, this is gross.
if (!root.demoHotSwapperRootMap_) {
/** @type {?Map<{key:*, value:*}>} @private */
root.demoHotSwapperRootMap_ = new Map();
}
let instance = root.demoHotSwapperRootMap_.get(root);
if (!instance) {
instance = HotSwapper.attachTo(root, ToolbarProvider.attachTo(root));
root.demoHotSwapperRootMap_.set(root, instance);
}
return instance;
}
}

/** @param {!Document|!Element} root */
export function init(root) {
HotSwapper.getInstance(root);
}
Loading

0 comments on commit 4f89819

Please sign in to comment.