Skip to content

fix(playground): wait until playground is in view before loading stored framework/mode #3185

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Oct 10, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 70 additions & 44 deletions src/components/global/Playground/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ export default function Playground({
*/
const [resetCount, setResetCount] = useState(0);

/**
* Keeps track of whether any amount of this playground is
* currently on the screen.
*/
const [isInView, setIsInView] = useState(false);

const setAndSaveMode = (mode: Mode) => {
setIonicMode(mode);

Expand All @@ -261,7 +267,7 @@ export default function Playground({

/**
* Tell other playgrounds on the page that the mode has
* updated, so they can sync up.
* updated, so they can sync up if they're in view.
*/
window.dispatchEvent(
new CustomEvent(MODE_UPDATED_EVENT, {
Expand Down Expand Up @@ -289,7 +295,7 @@ export default function Playground({

/**
* Tell other playgrounds on the page that the framework
* has updated, so they can sync up.
* has updated, so they can sync up if they're in view.
*/
window.dispatchEvent(
new CustomEvent(USAGE_TARGET_UPDATED_EVENT, {
Expand Down Expand Up @@ -401,72 +407,92 @@ export default function Playground({
});

/**
* By default, we do not render the iframe content
* as it could cause delays on page load. Instead
* we wait for even 1 pixel of the playground to
* scroll into view (intersect with the viewport)
* before loading the iframes.
* By default, we do not render the iframe content as it could
* cause delays on page load. We also do not immediately switch
* the framework/mode when it gets changed through another
* playground on the page, as switching them for every playground
* at once can cause memory-related crashes on some devices.
*
* Instead, we wait for even 1 pixel of the playground to scroll
* into view (intersect with the viewport) before loading the
* iframes or auto-switching the framework/mode.
*/
useEffect(() => {
const io = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
const ev = entries[0];
if (!ev.isIntersecting || renderIframes) return;
setIsInView(ev.isIntersecting);
if (!ev.isIntersecting) return;

setRenderIframes(true);
/**
* Load the stored mode and/or usage target, if present
* from previously being toggled.
*/
if (isBrowser) {
const storedMode = localStorage.getItem(MODE_STORAGE_KEY);
if (storedMode) setIonicMode(storedMode);
const storedUsageTarget = localStorage.getItem(USAGE_TARGET_STORAGE_KEY);
if (storedUsageTarget) setUsageTarget(storedUsageTarget);
}

/**
* Once the playground is loaded, it is never "unloaded"
* so we can safely disconnect the observer.
* If the iframes weren't already loaded, load them now.
*/
io.disconnect();
if (!renderIframes) {
setRenderIframes(true);
}
},
{ threshold: 0 }
);

io.observe(hostRef.current!);
});

const handleModeUpdated = (e: CustomEvent) => {
const mode = e.detail;
if (Object.values(Mode).includes(mode)) {
setIonicMode(mode); // don't use setAndSave to avoid infinite loop
}
};

const handleUsageTargetUpdated = (e: CustomEvent) => {
const usageTarget = e.detail;
if (Object.values(UsageTarget).includes(usageTarget)) {
setUsageTarget(usageTarget); // don't use setAndSave to avoid infinite loop
}
};

/**
* When this playground is in view, listen for any other playgrounds
* on the page to switch their framework or mode, so this one can
* sync up to the same setting. This is needed because if the
* playground is already in view, the IntersectionObserver doesn't
* fire until the playground is scrolled off and back on the screen.
*
* Sometimes, the app isn't fully hydrated on the first render,
* causing isBrowser to be set to false even if running the app
* in a browser (vs. SSR). isBrowser is then updated on the next
* render cycle.
* render cycle. This means we need to re-run this hook when
* isBrowser changes to handle playgrounds that were in view
* from the start of the page load.
*
* This useEffect contains code that can only run in the browser,
* and also needs to run on that first go-around. Note that
* isBrowser will never be set from true back to false, so the
* code within the if(isBrowser) check will only run once.
* We also re-run when isInView changes because otherwise, a stale
* state value would be captured. Since we need to listen for these
* events only when the playground is in view, we check the state
* before adding the listeners at all, rather than within the
* callbacks.
Comment on lines +479 to +483
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative would be to manually calculate whether the playground is on screen within the event callbacks, rather than using a state variable at all. I chose the state route because we already have an IntersectionObserver doing the heavy lifting with calculation, but I can switch this over if preferred.

*/
useEffect(() => {
if (isBrowser) {
/**
* Load the stored mode and/or usage target, if present
* from previously being toggled.
*/
const storedMode = localStorage.getItem(MODE_STORAGE_KEY);
if (storedMode) setIonicMode(storedMode);
const storedUsageTarget = localStorage.getItem(USAGE_TARGET_STORAGE_KEY);
if (storedUsageTarget) setUsageTarget(storedUsageTarget);

/**
* Listen for any playground on the page to have its mode or framework
* updated so this playground can switch to the same setting.
*/
window.addEventListener(MODE_UPDATED_EVENT, (e: CustomEvent) => {
const mode = e.detail;
if (Object.values(Mode).includes(mode)) {
setIonicMode(mode); // don't use setAndSave to avoid infinite loop
}
});
window.addEventListener(USAGE_TARGET_UPDATED_EVENT, (e: CustomEvent) => {
const usageTarget = e.detail;
if (Object.values(UsageTarget).includes(usageTarget)) {
setUsageTarget(usageTarget); // don't use setAndSave to avoid infinite loop
}
});
if (isBrowser && isInView) {
window.addEventListener(MODE_UPDATED_EVENT, handleModeUpdated);
window.addEventListener(USAGE_TARGET_UPDATED_EVENT, handleUsageTargetUpdated);
}
}, [isBrowser]);

return () => {
window.removeEventListener(MODE_UPDATED_EVENT, handleModeUpdated);
window.removeEventListener(USAGE_TARGET_UPDATED_EVENT, handleUsageTargetUpdated);
};
}, [isBrowser, isInView]);

const isIOS = ionicMode === Mode.iOS;
const isMD = ionicMode === Mode.MD;
Expand Down