diff --git a/examples/amp-timeago.amp.html b/examples/amp-timeago.amp.html index aea9e6d2cda7..67080268507e 100644 --- a/examples/amp-timeago.amp.html +++ b/examples/amp-timeago.amp.html @@ -35,7 +35,7 @@

Basic example

Scroll up and down for updated times

- Saturday 11 April 2018 00.37 + Friday 27 August 2021 00.37

amp-bind example

Saturday 11 April 2018 00.37 diff --git a/extensions/amp-iframe/1.0/component.js b/extensions/amp-iframe/1.0/component.js index 14356ef405d2..7c4d851c24fa 100644 --- a/extensions/amp-iframe/1.0/component.js +++ b/extensions/amp-iframe/1.0/component.js @@ -2,8 +2,9 @@ import * as Preact from '#preact'; import {useCallback, useEffect, useMemo, useRef} from '#preact'; import {MessageType} from '#core/3p-frame-messaging'; import {toWin} from '#core/window'; -import {ContainWrapper} from '#preact/component'; +import {ContainWrapper, useIntersectionObserver} from '#preact/component'; import {setStyle} from '#core/dom/style'; +import {refs} from '#preact/utils'; const NOOP = () => {}; @@ -39,15 +40,16 @@ export function Iframe({ }; const attemptResize = useCallback(() => { - const iframe = iframeRef.current; - let height = Number(dataRef.current.height); - let width = Number(dataRef.current.width); + let height = Number(dataRef.current?.height); + let width = Number(dataRef.current?.width); + dataRef.current = null; if (!height && !width) { console./*OK*/ error( 'Ignoring resize request because width and height value is invalid' ); return; } + const iframe = iframeRef.current; // TODO(dmanek): Calculate width and height of the container to include padding. if (!height) { height = iframe./*OK*/ offsetHeight; @@ -75,7 +77,14 @@ export function Iframe({ return; } dataRef.current = event.data; - attemptResize(); + // We only allow resizing when the iframe is outside the viewport, + // to guarantee CLS compliance. This may have the side effect of the iframe + // not resizing on `embed-size` postMessage while it's within the viewport + // where an author wants to resize the iframe. In that + // case remove this check & call `attemptResize` directly. + if (isIntersectingRef.current === false) { + attemptResize(); + } }, [attemptResize] ); @@ -89,22 +98,28 @@ export function Iframe({ if (!win) { return; } - const io = new win.IntersectionObserver((entries) => { - const last = entries[entries.length - 1]; - isIntersectingRef.current = last.isIntersecting; - if (last.isIntersecting || !dataRef.current || !win) { - return; - } - attemptResize(); - }); - io.observe(iframe); + win.addEventListener('message', handlePostMessage); return () => { - io.unobserve(iframe); win.removeEventListener('message', handlePostMessage); }; - }, [attemptResize, handlePostMessage]); + }, [handlePostMessage]); + + const ioCallback = useCallback( + ({isIntersecting}) => { + if (isIntersecting === isIntersectingRef.current) { + return; + } + isIntersectingRef.current = isIntersecting; + if (!isIntersecting && dataRef.current) { + attemptResize(); + } + }, + [attemptResize] + ); + + const measureRef = useIntersectionObserver(ioCallback); const contentProps = useMemo( () => ({ @@ -134,7 +149,7 @@ export function Iframe({ { export const WithIntersectingIframe = () => { return ( - +
+
+ +

The above iframe will not resize and should remain 100x100px

+
); }; WithIntersectingIframe.storyName = 'Resizable iframe in viewport'; -export const WithResizableIframe = ({textAbove, textBelow}) => { +export const WithResizableIframe = () => { return (
-

{textAbove}

+
-

The above iframe should resize to 300x300px when visible

-

{textBelow}

+

The above iframe should be 300x300px when visible

); }; WithResizableIframe.storyName = 'Resizable iframe outside viewport'; -WithResizableIframe.args = { - textAbove: sampleText.repeat(20), - textBelow: sampleText.repeat(5), -}; diff --git a/extensions/amp-timeago/1.0/component.js b/extensions/amp-timeago/1.0/component.js index 05741f6d95c6..ef120acd8c66 100644 --- a/extensions/amp-timeago/1.0/component.js +++ b/extensions/amp-timeago/1.0/component.js @@ -1,10 +1,11 @@ +import {devAssertElement} from '#core/assert'; import {getDate} from '#core/types/date'; import {toWin} from '#core/window'; import * as Preact from '#preact'; -import {useEffect, useRef, useState} from '#preact'; -import {Wrapper} from '#preact/component'; -import {useResourcesNotify} from '#preact/utils'; +import {useCallback, useRef, useState} from '#preact'; +import {Wrapper, useIntersectionObserver} from '#preact/component'; +import {refs, useResourcesNotify} from '#preact/utils'; import {format, getLocale} from './locales'; @@ -39,28 +40,26 @@ export function BentoTimeago({ const date = getDate(datetime); - useEffect(() => { - const node = ref.current; - const win = node && toWin(node.ownerDocument.defaultView); - if (!win) { - return undefined; - } - const observer = new win.IntersectionObserver((entries) => { + const ioCallback = useCallback( + ({isIntersecting}) => { + if (!isIntersecting) { + return; + } + const node = devAssertElement(ref.current); let {lang} = node.ownerDocument.documentElement; + const win = toWin(node.ownerDocument?.defaultView); if (lang === 'unknown') { lang = win.navigator?.language || DEFAULT_LOCALE; } const locale = getLocale(localeProp || lang); - const last = entries[entries.length - 1]; - if (last.isIntersecting) { - setTimestamp( - getFuzzyTimestampValue(new Date(date), locale, cutoff, placeholder) - ); - } - }); - observer.observe(node); - return () => observer.disconnect(); - }, [date, localeProp, cutoff, placeholder]); + setTimestamp( + getFuzzyTimestampValue(new Date(date), locale, cutoff, placeholder) + ); + }, + [cutoff, date, localeProp, placeholder] + ); + + const inObRef = useIntersectionObserver(ioCallback); useResourcesNotify(); @@ -68,7 +67,7 @@ export function BentoTimeago({ {timestamp} diff --git a/extensions/amp-timeago/1.0/storybook/Basic.js b/extensions/amp-timeago/1.0/storybook/Basic.js index afe0c3339be7..30d9965b274e 100644 --- a/extensions/amp-timeago/1.0/storybook/Basic.js +++ b/extensions/amp-timeago/1.0/storybook/Basic.js @@ -40,3 +40,137 @@ export const _default = () => { /> ); }; + +export const WithIntersectionObserver = () => { + const dateTime = date('Date/time', new Date()); + const cutoff = number('Cutoff (seconds)', 0); + const placeholder = text('Cutoff placeholder', 'Time passed!'); + const userLocale = navigator.language || 'en-US'; + const allLocales = [userLocale].concat( + LOCALES.filter((locale) => locale != userLocale) + ); + const locale = select('Locale', allLocales, userLocale); + return ( +
+

+ Bacon ipsum dolor amet shankle salami tenderloin shoulder ball tip + chislic chicken pork turkey jowl boudin sausage pancetta prosciutto. + Beef ribs ball tip meatloaf tongue. Pig shank fatback kielbasa + frankfurter ham, tongue sirloin capicola jerky pork loin sausage swine + pork. Filet mignon pork belly pork landjaeger, buffalo ham fatback + picanha alcatra leberkas. Shoulder shankle kielbasa biltong tail jowl + bresaola pig turducken brisket. Hamburger shankle chicken ham hock + boudin, rump ribeye. Spare ribs corned beef meatloaf cupim, biltong + pancetta tenderloin tongue hamburger shankle. Pork belly meatball + leberkas pork t-bone turkey porchetta filet mignon. Picanha turkey + tongue kevin prosciutto ground round porchetta strip steak. Venison + pancetta fatback shoulder chuck. Chislic filet mignon doner ribeye, + swine short loin bresaola burgdoggen buffalo rump landjaeger chicken + leberkas cow ground round. Beef ribs drumstick frankfurter ham chislic + kielbasa shoulder venison shank. Bresaola pork chicken shoulder sirloin. + Pork chop jerky tenderloin beef ribs ground round ribeye landjaeger + shank tri-tip swine. Swine sirloin meatloaf chicken pastrami jerky + shank. Porchetta swine jowl, alcatra andouille bresaola tri-tip brisket + picanha. Spare ribs hamburger bresaola ham hock. Beef kevin kielbasa + turducken tail fatback, pancetta salami cow venison meatball cupim jowl + beef ribs. Tongue doner pork chop, tri-tip shank biltong short loin + kielbasa chicken pancetta meatloaf salami capicola. Ground round chicken + cupim spare ribs. Ham boudin andouille tenderloin ground round + frankfurter leberkas tail bresaola picanha beef ribs venison short ribs + hamburger ribeye. Drumstick bresaola ham pork chop pancetta tenderloin + buffalo. Turkey beef ribs rump tri-tip beef doner jerky turducken + kielbasa meatloaf venison picanha corned beef. Spare ribs bacon beef, + sausage venison doner beef ribs picanha biltong porchetta prosciutto + bresaola. Alcatra tri-tip beef ribs pastrami. Sirloin beef chicken + ribeye jerky. Turkey shankle pig, alcatra chicken chislic cow + prosciutto. Sausage shoulder burgdoggen leberkas. Brisket andouille cow, + beef shankle filet mignon rump fatback doner ribeye t-bone. Cow pig + andouille ham. Cupim alcatra strip steak sirloin. Corned beef turkey ham + hock strip steak jowl, alcatra bacon. Bacon ribeye turducken, flank + meatball porchetta venison beef. Pork belly ham strip steak cow + hamburger shoulder turkey sirloin chicken picanha chislic porchetta pork + t-bone ball tip. Landjaeger pancetta t-bone jowl chuck ball tip filet + mignon biltong burgdoggen buffalo short loin hamburger tongue ham. Short + ribs frankfurter doner, spare ribs kevin alcatra tri-tip beef ribs + prosciutto ribeye chicken beef. Filet mignon short loin sirloin tongue, + flank buffalo alcatra pork cow andouille beef shank boudin kevin. + Chislic tail venison doner t-bone pastrami, pork loin burgdoggen + porchetta swine short ribs landjaeger kevin. Sausage bresaola tongue, + swine short ribs strip steak rump pancetta drumstick. Pork chop capicola + beef picanha buffalo, chislic hamburger. Ham sirloin sausage tenderloin + tri-tip rump shankle ribeye leberkas pig boudin. Buffalo bresaola + shoulder sausage turducken, rump turkey short ribs beef ribs biltong + alcatra short loin picanha capicola. Ribeye prosciutto meatloaf rump + jowl pork belly porchetta alcatra chislic chicken ground round bacon. + Capicola sausage shank, picanha strip steak pancetta drumstick + prosciutto doner shankle buffalo corned beef jerky meatball pork chop. + Pancetta corned beef pork chop boudin meatball shankle bresaola fatback + kevin buffalo drumstick ball tip. Alcatra jerky tongue, swine pig + burgdoggen buffalo tail. Chicken swine turducken pig, pastrami shankle + sirloin alcatra ball tip t-bone short ribs jowl. Picanha shankle spare + ribs tongue, hamburger shoulder t-bone ham hock doner. Picanha turkey + tongue kevin prosciutto ground round porchetta strip steak. Venison + pancetta fatback shoulder chuck. Chislic filet mignon doner ribeye, + swine short loin bresaola burgdoggen buffalo rump landjaeger chicken + leberkas cow ground round. Beef ribs drumstick frankfurter ham chislic + kielbasa shoulder venison shank. Bresaola pork chicken shoulder sirloin. + Pork chop jerky tenderloin beef ribs ground round ribeye landjaeger + shank tri-tip swine. Swine sirloin meatloaf chicken pastrami jerky + shank. Porchetta swine jowl, alcatra andouille bresaola tri-tip brisket + picanha. Spare ribs hamburger bresaola ham hock. Beef kevin kielbasa + turducken tail fatback, pancetta salami cow venison meatball cupim jowl + beef ribs. Tongue doner pork chop, tri-tip shank biltong short loin + kielbasa chicken pancetta meatloaf salami capicola. Ground round chicken + cupim spare ribs. Ham boudin andouille tenderloin ground round + frankfurter leberkas tail bresaola picanha beef ribs venison short ribs + hamburger ribeye. Drumstick bresaola ham pork chop pancetta tenderloin + buffalo. Turkey beef ribs rump tri-tip beef doner jerky turducken + kielbasa meatloaf venison picanha corned beef. Spare ribs bacon beef, + sausage venison doner beef ribs picanha biltong porchetta prosciutto + bresaola. Alcatra tri-tip beef ribs pastrami. Sirloin beef chicken + ribeye jerky. Turkey shankle pig, alcatra chicken chislic cow + prosciutto. Sausage shoulder burgdoggen leberkas. Brisket andouille cow, + beef shankle filet mignon rump fatback doner ribeye t-bone. Cow pig + andouille ham. Cupim alcatra strip steak sirloin. Corned beef turkey ham + hock strip steak jowl, alcatra bacon. Bacon ribeye turducken, flank + meatball porchetta venison beef. Pork belly ham strip steak cow + hamburger shoulder turkey sirloin chicken picanha chislic porchetta pork + t-bone ball tip. Landjaeger pancetta t-bone jowl chuck ball tip filet + mignon biltong burgdoggen buffalo short loin hamburger tongue ham. Short + ribs frankfurter doner, spare ribs kevin alcatra tri-tip beef ribs + prosciutto ribeye chicken beef. Filet mignon short loin sirloin tongue, + flank buffalo alcatra pork cow andouille beef shank boudin kevin. + Chislic tail venison doner t-bone pastrami, pork loin burgdoggen + porchetta swine short ribs landjaeger kevin. Sausage bresaola tongue, + swine short ribs strip steak rump pancetta drumstick. Pork chop capicola + beef picanha buffalo, chislic hamburger. Ham sirloin sausage tenderloin + tri-tip rump shankle ribeye leberkas pig boudin. Buffalo bresaola + shoulder sausage turducken, rump turkey short ribs beef ribs biltong + alcatra short loin picanha capicola. Ribeye prosciutto meatloaf rump + jowl pork belly porchetta alcatra chislic chicken ground round bacon. + Capicola sausage shank, picanha strip steak pancetta drumstick + prosciutto doner shankle buffalo corned beef jerky meatball pork chop. + Pancetta corned beef pork chop boudin meatball shankle bresaola fatback + kevin buffalo drumstick ball tip. Alcatra jerky tongue, swine pig + burgdoggen buffalo tail. Chicken swine turducken pig, pastrami shankle + sirloin alcatra ball tip t-bone short ribs jowl. Picanha shankle spare + ribs tongue, hamburger shoulder t-bone ham hock doner. Ribeye strip + steak tail, leberkas spare ribs venison sausage. Pancetta sirloin + venison porchetta burgdoggen. Burgdoggen turducken pork tongue meatloaf. + Ham salami rump jerky boudin. Pork alcatra t-bone cupim, pancetta + leberkas meatloaf shoulder drumstick. Pork capicola pancetta, meatball + beef ribs andouille filet mignon shoulder shankle. Does your lorem ipsum + text long for something a little meatier? Give our generator a try… it’s + tasty! +

+ +
+ ); +}; + +WithIntersectionObserver.storyName = 'InOb'; diff --git a/src/core/dom/layout/viewport-observer.js b/src/core/dom/layout/viewport-observer.js index f01216c7a8ca..42b6fd5961c8 100644 --- a/src/core/dom/layout/viewport-observer.js +++ b/src/core/dom/layout/viewport-observer.js @@ -1,6 +1,4 @@ -import {devAssert} from '#core/assert'; import {isIframed} from '#core/dom'; -import * as mode from '#core/mode'; import {toWin} from '#core/window'; /** @@ -31,25 +29,34 @@ export function createViewportObserver(ioCallback, win, opts = {}) { /** @type {!WeakMap} */ const viewportObservers = new WeakMap(); -/** @type {!WeakMap} */ +/** @type {!WeakMap>} */ const viewportCallbacks = new WeakMap(); /** * Lazily creates an IntersectionObserver per Window to track when elements * enter and exit the viewport. Fires viewportCallback when this happens. * + * TODO(dmanek): This is a wrapper around `observeIntersections` to maintain + * backwards compatibility and can be deleted once all instances have been + * migrated. + * * @param {!Element} element * @param {function(boolean)} viewportCallback */ export function observeWithSharedInOb(element, viewportCallback) { - // There should never be two unique observers of the same element. - if (mode.isLocalDev()) { - devAssert( - !viewportCallbacks.has(element) || - viewportCallbacks.get(element) === viewportCallback - ); - } + observeIntersections(element, ({isIntersecting}) => + viewportCallback(isIntersecting) + ); +} +/** + * Lazily creates an IntersectionObserver per Window to track when elements + * enter and exit the viewport. Fires viewportCallback when this happens. + * + * @param {!Element} element + * @param {function(IntersectionObserverEntry)} viewportCallback + */ +export function observeIntersections(element, viewportCallback) { const win = toWin(element.ownerDocument.defaultView); let viewportObserver = viewportObservers.get(win); if (!viewportObserver) { @@ -58,7 +65,13 @@ export function observeWithSharedInOb(element, viewportCallback) { (viewportObserver = createViewportObserver(ioCallback, win)) ); } - viewportCallbacks.set(element, viewportCallback); + let callbacks = viewportCallbacks.get(element); + if (!callbacks) { + callbacks = []; + viewportCallbacks.set(element, callbacks); + } + + callbacks.push(viewportCallback); viewportObserver.observe(element); } @@ -70,6 +83,8 @@ export function unobserveWithSharedInOb(element) { const win = toWin(element.ownerDocument.defaultView); const viewportObserver = viewportObservers.get(win); viewportObserver?.unobserve(element); + // TODO(dmanek): This is a potential bug. We only want to remove + // a single callback as opposed to all. viewportCallbacks.delete(element); } @@ -80,8 +95,21 @@ export function unobserveWithSharedInOb(element) { * @param {!Array} entries */ function ioCallback(entries) { - for (let i = 0; i < entries.length; i++) { - const {isIntersecting, target} = entries[i]; - viewportCallbacks.get(target)?.(isIntersecting); + const seen = new Set(); + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + const {target} = entry; + if (seen.has(target)) { + continue; + } + seen.add(target); + const callbacks = viewportCallbacks.get(target); + if (!callbacks) { + continue; + } + for (let k = 0; k < callbacks.length; k++) { + const callback = callbacks[k]; + callback(entry); + } } } diff --git a/src/preact/component/index.js b/src/preact/component/index.js index a99a8115cba6..48afd2808696 100644 --- a/src/preact/component/index.js +++ b/src/preact/component/index.js @@ -3,3 +3,4 @@ export {Wrapper} from './wrapper'; export {useRenderer} from './renderer'; export {useValueRef} from './value-ref'; export {useDOMHandle} from './dom-handle'; +export {useIntersectionObserver} from './intersection-observer-resize'; diff --git a/src/preact/component/intersection-observer-resize.js b/src/preact/component/intersection-observer-resize.js new file mode 100644 index 000000000000..5bbab153089e --- /dev/null +++ b/src/preact/component/intersection-observer-resize.js @@ -0,0 +1,32 @@ +import { + observeIntersections, + unobserveWithSharedInOb, +} from '#core/dom/layout/viewport-observer'; + +import {useCallback, useRef} from '#preact'; + +/** + * Uses a shared IntersectionObserver per window instance to observe the given `ref`. + * + * @param {function(IntersectionObserverEntry)} callback + * @return {function(Element)} + */ +export function useIntersectionObserver(callback) { + const nodeRef = useRef(null); + const refCb = useCallback( + (node) => { + const prevNode = nodeRef.current; + nodeRef.current = node; + if (prevNode) { + unobserveWithSharedInOb(prevNode); + } + if (!node) { + return; + } + observeIntersections(node, callback); + }, + [callback] + ); + + return refCb; +} diff --git a/src/preact/storybook/Hooks.js b/src/preact/storybook/Hooks.js new file mode 100644 index 000000000000..dc900361d763 --- /dev/null +++ b/src/preact/storybook/Hooks.js @@ -0,0 +1,66 @@ +import * as Preact from '#preact'; +import {useCallback, useState} from '#preact'; +import {useIntersectionObserver} from '#preact/component'; +import {refs} from '#preact/utils'; + +export default { + title: '0/Hooks', +}; + +function Component({prop}) { + const [text, setText] = useState('initial render'); + const ioCallback = useCallback( + ({isIntersecting}) => { + setText(`is intersecting for ${prop}: ${isIntersecting}`); + }, + [prop] + ); + + const anotherIoCallback = useCallback( + ({isIntersecting}) => { + // TODO(dmanek): Look into using Storybook actions instead of console.log below + // eslint-disable-next-line local/no-forbidden-terms + console.log(`is intersecting for ${prop}: ${isIntersecting}`); + }, + [prop] + ); + + const ref = useIntersectionObserver(ioCallback); + const anotherRef = useIntersectionObserver(anotherIoCallback); + + return
{text}
; +} + +export const useIO = () => { + return ( + <> +
+ +
+ +
+ + + ); +}; diff --git a/src/preact/utils.js b/src/preact/utils.js index 11361500d8ae..31e448464ad3 100644 --- a/src/preact/utils.js +++ b/src/preact/utils.js @@ -14,3 +14,17 @@ export function useResourcesNotify() { } }); } + +/** + * Combines multiple refs to pass into `ref` prop. + * @param {...any} refs + * @return {function(!Element)} + */ +export function refs(...refs) { + return (element) => { + for (let i = 0; i < refs.length; i++) { + const ref = refs[i]; + typeof ref == 'function' ? ref(element) : (ref.current = element); + } + }; +} diff --git a/test/unit/core/dom/layout/test-viewport-observer.js b/test/unit/core/dom/layout/test-viewport-observer.js index 0a865b01d823..ed3af7dd2dfd 100644 --- a/test/unit/core/dom/layout/test-viewport-observer.js +++ b/test/unit/core/dom/layout/test-viewport-observer.js @@ -1,5 +1,6 @@ import { createViewportObserver, + observeIntersections, observeWithSharedInOb, unobserveWithSharedInOb, } from '#core/dom/layout/viewport-observer'; @@ -99,8 +100,8 @@ describes.sandboxed('DOM - layout - Viewport Observer', {}, (env) => { toggleViewport(el2, true); toggleViewport(el1, true); - expect(el1Events).eql([false, true]); - expect(el2Events).eql([true]); + expect(el1Events).to.eql([false, true]); + expect(el2Events).to.eql([true]); }); it('once unobserved, the callback is no longer fired', () => { @@ -113,19 +114,7 @@ describes.sandboxed('DOM - layout - Viewport Observer', {}, (env) => { toggleViewport(el1, true); toggleViewport(el1, false); - expect(el1Events).eql([false]); - }); - - it('Observing twice with the same callback is fine, but unique ones throw', () => { - const noop = () => {}; - observeWithSharedInOb(el1, noop); - observeWithSharedInOb(el1, noop); - - allowConsoleError(() => { - expect(() => observeWithSharedInOb(el1, () => {})).throws( - 'Assertion failed' - ); - }); + expect(el1Events).to.eql([false]); }); it('A quick observe and unobserve pair should not cause an error or fire the callback', () => { @@ -136,5 +125,25 @@ describes.sandboxed('DOM - layout - Viewport Observer', {}, (env) => { expect(spy).not.called; }); + + it('can have multiple obsevers for the same element', () => { + let elInObEntries = []; + + observeIntersections(el1, (entry) => elInObEntries.push(entry)); + observeIntersections(el1, (entry) => elInObEntries.push(entry)); + toggleViewport(el1, true); + + expect(elInObEntries).to.have.lengthOf(2); + expect(elInObEntries[0].target).to.eql(el1); + expect(elInObEntries[0].isIntersecting).to.be.true; + expect(elInObEntries[1].target).to.eql(el1); + expect(elInObEntries[1].isIntersecting).to.be.true; + + elInObEntries = []; + toggleViewport(el1, false); + expect(elInObEntries).to.have.lengthOf(2); + expect(elInObEntries[0].isIntersecting).to.be.false; + expect(elInObEntries[1].isIntersecting).to.be.false; + }); }); });