Skip to content

Commit

Permalink
Shadow DOM polyfill for elements that need it (#32820)
Browse files Browse the repository at this point in the history
* Shadow DOM polyfill for elements that need it

* minors

* tests

* review fixes

* renamed uses to requiresShadowDom

* fix ad tests

* fix esm build

* switch to prepend the polyfill script instead of import

* cleanup

* fix esm build

* add Event polyfill and ensure the right version of HTMLElement is patched

* review fixes

* typos

* update resize oberver to cooperate better with the SD polyfill
  • Loading branch information
Dima Voytenko committed Feb 26, 2021
1 parent fe2d7dd commit 02e2b83
Show file tree
Hide file tree
Showing 24 changed files with 649 additions and 99 deletions.
19 changes: 9 additions & 10 deletions build-system/compile/bundles.config.extensions.json
Expand Up @@ -134,8 +134,7 @@
"name": "amp-date-picker",
"version": "0.1",
"latestVersion": "0.1",
"options": {"hasCss": true},
"postPrepend": ["third_party/react-dates/bundle.js"]
"options": {"hasCss": true}
},
{
"name": "amp-delight-player",
Expand Down Expand Up @@ -239,8 +238,7 @@
{
"name": "amp-inputmask",
"version": "0.1",
"latestVersion": "0.1",
"postPrepend": ["third_party/inputmask/bundle.js"]
"latestVersion": "0.1"
},
{
"name": "amp-instagram",
Expand Down Expand Up @@ -382,6 +380,12 @@
"latestVersion": "0.1",
"options": {"hasCss": true}
},
{
"name": "amp-shadow-dom-polyfill",
"version": "0.1",
"latestVersion": "0.1",
"options": {"noWrapper": true}
},
{
"name": "amp-sidebar",
"version": ["0.1", "0.2", "1.0"],
Expand Down Expand Up @@ -571,12 +575,7 @@
"name": "amp-viz-vega",
"version": "0.1",
"latestVersion": "0.1",
"options": {"hasCss": true},
"postPrepend": [
"third_party/d3/d3.js",
"third_party/d3-geo-projection/d3-geo-projection.js",
"third_party/vega/vega.js"
]
"options": {"hasCss": true}
},
{"name": "amp-vk", "version": "0.1", "latestVersion": "0.1"},
{
Expand Down
3 changes: 3 additions & 0 deletions build-system/tasks/helpers.js
Expand Up @@ -62,6 +62,9 @@ const EXTENSION_BUNDLE_MAP = {
],
'amp-inputmask.js': ['third_party/inputmask/bundle.js'],
'amp-date-picker.js': ['third_party/react-dates/bundle.js'],
'amp-shadow-dom-polyfill.js': [
'node_modules/@webcomponents/webcomponentsjs/bundles/webcomponents-sd.install.js',
],
};

/**
Expand Down
72 changes: 72 additions & 0 deletions build-system/tasks/update-packages.js
Expand Up @@ -124,6 +124,77 @@ function patchResizeObserver() {
writeIfUpdated(patchedName, file);
}

/**
* Patches Shadow DOM polyfill by wrapping its body into `install`
* function.
* This gives us an option to control when and how the polyfill is installed.
* The polyfill can only be installed on the root context.
*/
function patchShadowDom() {
// Copies webcomponents-sd into a new file that has an export.
const patchedName =
'node_modules/@webcomponents/webcomponentsjs/bundles/webcomponents-sd.install.js';

let file = '(function() {';
// HTMLElement is replaced, but the original needs to be used for the polyfill
// since it manipulates "own" properties. See `src/polyfills/custom-element.js`.
file += 'var HTMLElementOrig = window.HTMLElementOrig || window.HTMLElement;';
file += 'window.HTMLElementOrig = HTMLElementOrig;';
file += `
(function() {
var origContains = document.contains;
if (origContains) {
Object.defineProperty(document, '__shady_native_contains', {value: origContains});
}
Object.defineProperty(document, 'contains', {
configurable: true,
value: function(node) {
if (node === this) {
return true;
}
if (this.documentElement) {
return this.documentElement.contains(node);
}
return false;
}
});
})();
`;

function transformScript(file) {
// Use the HTMLElement from above.
file = file.replace(/\bHTMLElement\b/g, 'HTMLElementOrig');
return file;
}

// Relevant DOM polyfills
file += transformScript(
fs
.readFileSync(
'node_modules/@webcomponents/webcomponentsjs/bundles/webcomponents-pf_dom.js'
)
.toString()
);
file += transformScript(
fs
.readFileSync(
'node_modules/@webcomponents/webcomponentsjs/bundles/webcomponents-sd.js'
)
.toString()
);
file += '})();';

// ESM binaries fail on this expression.
file = file.replace(
'"undefined"!=typeof window&&window===this?this:"undefined"!=typeof global&&null!=global?global:this',
'window'
);
// Disable any integration with CE.
file = file.replace(/window\.customElements/g, 'window.__customElements');

writeIfUpdated(patchedName, file);
}

/**
* Deletes the map file for rrule, which breaks closure compiler.
* TODO(rsimha): Remove this workaround after a fix is merged for
Expand Down Expand Up @@ -185,6 +256,7 @@ async function updatePackages() {
patchWebAnimations();
patchIntersectionObserver();
patchResizeObserver();
patchShadowDom();
removeRruleSourcemap();
}

Expand Down
14 changes: 8 additions & 6 deletions extensions/amp-a4a/0.1/test/test-friendly-frame-renderer.js
Expand Up @@ -84,15 +84,17 @@ describes.realWin('FriendlyFrameRenderer', realWinConfig, (env) => {
});

it('should set the correct srcdoc on the iframe', () => {
const srcdoc =
'<base href="http://www.google.com">' +
'<meta http-equiv=Content-Security-Policy content="script-src ' +
"'none';object-src 'none';child-src 'none'\">" +
'<p>Hello, World!</p>';
return renderPromise.then(() => {
const iframe = containerElement.querySelector('iframe');
expect(iframe).to.be.ok;
expect(iframe.getAttribute('srcdoc')).to.equal(srcdoc);
const srcdoc = iframe.getAttribute('srcdoc');
expect(srcdoc).to.contain('<base href="http://www.google.com">');
expect(srcdoc).to.contain(
'<meta http-equiv=Content-Security-Policy content="script-src '
);
expect(srcdoc).to.contain(
";object-src 'none';child-src 'none'\"><p>Hello, World!</p>"
);
});
});

Expand Down
14 changes: 8 additions & 6 deletions extensions/amp-a4a/0.1/test/test-friendly-frame-util.js
Expand Up @@ -80,14 +80,16 @@ describes.realWin('FriendlyFrameUtil', realWinConfig, (env) => {
});

it('should set the correct srcdoc on the iframe', () => {
const srcdoc =
'<base href="http://www.google.com">' +
'<meta http-equiv=Content-Security-Policy content="script-src ' +
"'none';object-src 'none';child-src 'none'\">" +
'<p>Hello, World!</p>';
return renderPromise.then((iframe) => {
expect(iframe).to.be.ok;
expect(iframe.getAttribute('srcdoc')).to.equal(srcdoc);
const srcdoc = iframe.getAttribute('srcdoc');
expect(srcdoc).to.contain('<base href="http://www.google.com">');
expect(srcdoc).to.contain(
'<meta http-equiv=Content-Security-Policy content="script-src '
);
expect(srcdoc).to.contain(
";object-src 'none';child-src 'none'\"><p>Hello, World!</p>"
);
});
});

Expand Down
21 changes: 21 additions & 0 deletions extensions/amp-shadow-dom-polyfill/0.1/amp-shadow-dom-polyfill.js
@@ -0,0 +1,21 @@
/**
* Copyright 2021 The AMP HTML Authors. 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.
*/

/**
* @fileoverview This is just a placeholder for the polyfill that will be
* injected here from the `node_modules/@webcomponents/webcomponentsjs/bundles/webcomponents-sd.install.js`
* script.
*/
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Expand Up @@ -18,6 +18,7 @@
"@ampproject/toolbox-cache-url": "2.7.2",
"@ampproject/viewer-messaging": "1.1.2",
"@ampproject/worker-dom": "0.27.4",
"@webcomponents/webcomponentsjs": "2.5.0",
"dompurify": "2.2.6",
"intersection-observer": "0.12.0",
"jss": "10.3.0",
Expand Down
12 changes: 12 additions & 0 deletions src/base-element.js
Expand Up @@ -198,6 +198,18 @@ export class BaseElement {
return null;
}

/**
* Subclasses can override this method to indicate that instances need to
* use Shadow DOM. The Runtime will ensure that the Shadow DOM polyfill is
* installed before upgrading and building this class.
*
* @return {boolean}
* @nocollapse
*/
static requiresShadowDom() {
return false;
}

/** @param {!AmpElement} element */
constructor(element) {
/** @public @const {!Element} */
Expand Down
11 changes: 10 additions & 1 deletion src/friendly-iframe-embed.js
Expand Up @@ -28,6 +28,7 @@ import {
setParentWindow,
} from './service';
import {escapeHtml} from './dom';
import {getMode} from './mode';
import {install as installAbortController} from './polyfills/abort-controller';
import {installAmpdocServicesForEmbed} from './service/core-services';
import {install as installCustomElements} from './polyfills/custom-elements';
Expand All @@ -48,6 +49,7 @@ import {
setStyles,
} from './style';
import {toWin} from './types';
import {urls} from './config';
import {whenContentIniLoad} from './ini-load';

/**
Expand Down Expand Up @@ -298,10 +300,17 @@ function mergeHtml(spec) {
});
}

const cdnBase = getMode().localDev ? 'http://localhost:8000/dist' : urls.cdn;
const cspScriptSrc = [
`${cdnBase}/lts/`,
`${cdnBase}/rtv/`,
`${cdnBase}/sw/`,
].join(' ');

// Load CSP
result.push(
'<meta http-equiv=Content-Security-Policy ' +
"content=\"script-src 'none';object-src 'none';child-src 'none'\">"
`content="script-src ${cspScriptSrc};object-src 'none';child-src 'none'">`
);

// Postambule.
Expand Down
2 changes: 2 additions & 0 deletions src/polyfills/custom-elements.js
Expand Up @@ -797,6 +797,7 @@ function polyfill(win) {
subClass(HTMLElement, HTMLElementPolyfill);

// Expose the polyfilled HTMLElement constructor for everyone to extend from.
win.HTMLElementOrig = win.HTMLElement;
win.HTMLElement = HTMLElementPolyfill;

// When we transpile `super` in Custom Element subclasses, we change it to
Expand Down Expand Up @@ -838,6 +839,7 @@ function wrapHTMLElement(win) {
subClass(HTMLElement, HTMLElementWrapper);

// Expose the wrapped HTMLElement constructor for everyone to extend from.
win.HTMLElementOrig = win.HTMLElement;
win.HTMLElement = HTMLElementWrapper;
}

Expand Down
9 changes: 9 additions & 0 deletions src/preact/base-element.js
Expand Up @@ -142,6 +142,15 @@ const childIdGenerator = sequentialIdGenerator();
* @template API_TYPE
*/
export class PreactBaseElement extends AMP.BaseElement {
/**
* @return {boolean}
* @nocollapse
*/
static requiresShadowDom() {
// eslint-disable-next-line local/no-static-this
return usesShadowDom(this);
}

/** @param {!AmpElement} element */
constructor(element) {
super(element);
Expand Down
37 changes: 35 additions & 2 deletions src/service/custom-element-registry.js
Expand Up @@ -15,6 +15,7 @@
*/

import {ElementStub} from '../element-stub';
import {Services} from '../services';
import {createCustomElementClass, stubbedElements} from '../custom-element';
import {extensionScriptsInNode} from '../element-service';
import {reportError} from '../error';
Expand All @@ -38,6 +39,21 @@ function getExtendedElements(win) {
* @param {typeof ../base-element.BaseElement} toClass
*/
export function upgradeOrRegisterElement(win, name, toClass) {
const waitPromise = waitReadyForUpgrade(win, toClass);
if (waitPromise) {
waitPromise.then(() => upgradeOrRegisterElementReady(win, name, toClass));
} else {
upgradeOrRegisterElementReady(win, name, toClass);
}
}

/**
* Registers an element. Upgrades it if has previously been stubbed.
* @param {!Window} win
* @param {string} name
* @param {typeof ../base-element.BaseElement} toClass
*/
function upgradeOrRegisterElementReady(win, name, toClass) {
const knownElements = getExtendedElements(win);
if (!knownElements[name]) {
registerElement(win, name, toClass);
Expand Down Expand Up @@ -69,7 +85,7 @@ export function upgradeOrRegisterElement(win, name, toClass) {
element.tagName.toLowerCase() == name &&
element.ownerDocument.defaultView == win
) {
tryUpgradeElement_(element, toClass);
tryUpgradeElement(element, toClass);
// Remove element from array.
stubbedElements.splice(i--, 1);
}
Expand All @@ -82,14 +98,31 @@ export function upgradeOrRegisterElement(win, name, toClass) {
* @param {typeof ../base-element.BaseElement} toClass
* @private
*/
function tryUpgradeElement_(element, toClass) {
function tryUpgradeElement(element, toClass) {
try {
element.upgrade(toClass);
} catch (e) {
reportError(e, element);
}
}

/**
* Ensures that the element is ready for upgrade. Either returns immediately
* with `undefined` indicating that no waiting is necessary, or returns a
* promise that will resolve when the upgrade can proceed.
*
* @param {!Window} win
* @param {typeof ../base-element.BaseElement} elementClass
* @return {!Promise|undefind}
*/
function waitReadyForUpgrade(win, elementClass) {
// Make sure the polyfill is installed for Shadow DOM if element needs it.
if (elementClass.requiresShadowDom() && !win.Element.prototype.attachShadow) {
const extensions = Services.extensionsFor(win);
return extensions.importUnwrapped(win, 'amp-shadow-dom-polyfill');
}
}

/**
* Stub extended elements missing an implementation. It can be called multiple
* times and on partial document in order to start stubbing as early as
Expand Down

0 comments on commit 02e2b83

Please sign in to comment.