Skip to content

Commit

Permalink
fix(initMode): improve the reliability of the "user-visible" mode (#343)
Browse files Browse the repository at this point in the history
  • Loading branch information
danilowoz committed Feb 2, 2022
1 parent bd71ba5 commit 177f0ab
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 55 deletions.
29 changes: 22 additions & 7 deletions sandpack-react/src/common/LoadingOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,38 @@
import { useClasser } from "@code-hike/classer";
import * as React from "react";

import { useLoadingOverlayState } from "../hooks/useLoadingOverlayState";
import {
useLoadingOverlayState,
FADE_ANIMATION_DURATION,
} from "../hooks/useLoadingOverlayState";

import { OpenInCodeSandboxButton } from "./OpenInCodeSandboxButton";

export interface LoadingOverlayProps {
clientId?: string;

/**
* It enforces keeping the loading state visible,
* which is helpful for external loading states.
*/
loading?: boolean;
}

/**
* @category Components
*/
export const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ clientId }) => {
const loadingOverlayState = useLoadingOverlayState(clientId);
export const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
clientId,
loading,
}) => {
const loadingOverlayState = useLoadingOverlayState(clientId, loading);
const c = useClasser("sp");

if (loadingOverlayState === "hidden") {
if (loadingOverlayState === "HIDDEN") {
return null;
}

if (loadingOverlayState === "timeout") {
if (loadingOverlayState === "TIMEOUT") {
return (
<div className={c("overlay", "error")}>
<div className={c("error-message")}>
Expand All @@ -47,12 +59,15 @@ export const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ clientId }) => {
);
}

const stillLoading =
loadingOverlayState === "LOADING" || loadingOverlayState === "PRE_FADING";

return (
<div
className={c("overlay", "loading")}
style={{
opacity: loadingOverlayState === "visible" ? 1 : 0,
transition: "opacity 0.5s ease-out",
opacity: stillLoading ? 1 : 0,
transition: `opacity ${FADE_ANIMATION_DURATION}ms ease-out`,
}}
>
<div className="sp-cube-wrapper" title="Open in CodeSandbox">
Expand Down
53 changes: 42 additions & 11 deletions sandpack-react/src/contexts/sandpackContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export interface SandpackProviderProps {
* a certain control of when to initialize them.
*/
initMode?: SandpackInitMode;
initModeObserverOptions?: IntersectionObserverInit;

// bundler options
bundlerURL?: string;
Expand Down Expand Up @@ -113,6 +114,7 @@ class SandpackProvider extends React.PureComponent<
unsubscribe?: UnsubscribeFunction;
debounceHook?: number;
timeoutHook: NodeJS.Timer | null = null;
initializeSandpackIframeHook: NodeJS.Timer | null = null;

constructor(props: SandpackProviderProps) {
super(props);
Expand Down Expand Up @@ -254,9 +256,8 @@ class SandpackProvider extends React.PureComponent<
return;
}

const observerOptions = {
rootMargin: "600px 0px",
threshold: 0.4,
const observerOptions = this.props.initModeObserverOptions ?? {
rootMargin: `1000px 0px`,
};

if (this.intersectionObserver && this.lazyAnchorRef.current) {
Expand All @@ -266,9 +267,9 @@ class SandpackProvider extends React.PureComponent<
if (this.lazyAnchorRef.current && this.state.initMode === "lazy") {
// If any component registerd a lazy anchor ref component, use that for the intersection observer
this.intersectionObserver = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting) {
if (entries.some((entry) => entry.isIntersecting)) {
// Delay a cycle so all hooks register the refs for the sub-components (open in csb, loading, error overlay)
setTimeout(() => {
this.initializeSandpackIframeHook = setTimeout(() => {
this.runSandpack();
}, 50);

Expand All @@ -284,20 +285,28 @@ class SandpackProvider extends React.PureComponent<
this.state.initMode === "user-visible"
) {
this.intersectionObserver = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting) {
if (entries.some((entry) => entry.isIntersecting)) {
// Delay a cycle so all hooks register the refs for the sub-components (open in csb, loading, error overlay)
setTimeout(() => {
this.initializeSandpackIframeHook = setTimeout(() => {
this.runSandpack();
}, 50);
} else {
if (this.initializeSandpackIframeHook) {
clearTimeout(this.initializeSandpackIframeHook);
}

Object.keys(this.clients).map(this.unregisterBundler);
this.unregisterAllClients();
}
}, observerOptions);

this.intersectionObserver.observe(this.lazyAnchorRef.current);
} else {
// else run the sandpack on mount, with a slight delay to allow all subcomponents to mount/register components
setTimeout(() => this.runSandpack(), 50);
this.initializeSandpackIframeHook = setTimeout(
() => this.runSandpack(),
50
);
}
}

Expand Down Expand Up @@ -375,6 +384,10 @@ class SandpackProvider extends React.PureComponent<
clearTimeout(this.debounceHook);
}

if (this.initializeSandpackIframeHook) {
clearTimeout(this.initializeSandpackIframeHook);
}

if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
Expand Down Expand Up @@ -406,9 +419,11 @@ class SandpackProvider extends React.PureComponent<
}
);

// Subscribe inside the context with the first client that gets instantiated.
// This subscription is for global states like error and timeout, so no need for a per client listen
// Also, set the timeout timer only when the first client is instantiated
/**
* Subscribe inside the context with the first client that gets instantiated.
* This subscription is for global states like error and timeout, so no need for a per client listen
* Also, set the timeout timer only when the first client is instantiated
*/
if (typeof this.unsubscribe !== "function") {
this.unsubscribe = client.listen(this.handleMessage);

Expand Down Expand Up @@ -485,9 +500,25 @@ class SandpackProvider extends React.PureComponent<
delete this.preregisteredIframes[clientId];
}

if (this.timeoutHook) {
clearTimeout(this.timeoutHook);
}

this.setState({ sandpackStatus: "idle" });
};

/**
* @hidden
*/
unregisterAllClients = (): void => {
Object.keys(this.clients).map(this.unregisterBundler);

if (typeof this.unsubscribe === "function") {
this.unsubscribe();
this.unsubscribe = undefined;
}
};

/**
* @hidden
*/
Expand Down
68 changes: 43 additions & 25 deletions sandpack-react/src/hooks/useLoadingOverlayState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,76 @@ import * as React from "react";

import { useSandpack } from "./useSandpack";

export type LoadingOverlayState = "visible" | "fading" | "hidden" | "timeout";
export type LoadingOverlayState =
| "LOADING"
| "PRE_FADING"
| "FADING"
| "HIDDEN"
| "TIMEOUT";

const FADE_DELAY = 1000; // 1 second delay one initial load, only relevant if the loading overlay is visible.
const FADE_ANIMATION_DURATION = 500; // 500 ms fade animation
export const FADE_ANIMATION_DURATION = 200;

/**
* @category Hooks
*/
export const useLoadingOverlayState = (
clientId?: string
clientId?: string,
externalLoading?: boolean
): LoadingOverlayState => {
const { sandpack, listen } = useSandpack();
const [loadingOverlayState, setLoadingOverlayState] =
React.useState<LoadingOverlayState>("visible");
const [state, setState] = React.useState<LoadingOverlayState>("LOADING");

/**
* Sandpack listener
*/
React.useEffect(() => {
sandpack.loadingScreenRegisteredRef.current = true;
let innerHook: NodeJS.Timer;
let outerHook: NodeJS.Timer;

const unsub = listen((message) => {
const unsubscribe = listen((message) => {
if (message.type === "start" && message.firstLoad === true) {
setLoadingOverlayState("visible");
setState("LOADING");
}

if (message.type === "done") {
outerHook = setTimeout(() => {
setLoadingOverlayState(
(prev) => (prev === "visible" ? "fading" : "hidden") // Only set 'fading' if the current state is 'visible'
);
innerHook = setTimeout(
() => setLoadingOverlayState("hidden"),
FADE_ANIMATION_DURATION
);
}, FADE_DELAY);
setState((prev) => {
return prev === "LOADING" ? "PRE_FADING" : "HIDDEN";
});
}
}, clientId);

return (): void => {
clearTimeout(outerHook);
clearTimeout(innerHook);
unsub();
unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clientId, sandpack.status === "idle"]);

/**
* Fading transient state
*/
React.useEffect(() => {
let fadeTimeout: NodeJS.Timer;

if (state === "PRE_FADING" && !externalLoading) {
setState("FADING");
} else if (state === "FADING") {
fadeTimeout = setTimeout(
() => setState("HIDDEN"),
FADE_ANIMATION_DURATION
);
}

return (): void => {
clearTimeout(fadeTimeout);
};
}, [state, externalLoading]);

if (sandpack.status === "timeout") {
return "timeout";
return "TIMEOUT";
}

if (sandpack.status !== "running") {
return "hidden";
return "HIDDEN";
}

return loadingOverlayState;
return state;
};
29 changes: 19 additions & 10 deletions sandpack-react/src/presets/Playground.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,36 @@
import { useEffect, useState } from "react";

import {
Sandpack,
SandpackPreview,
SandpackProvider,
SandpackLayout,
} from "../";
import { LoadingOverlay, SandpackStack } from "../common";

export default {
title: "Playground",
};

export const Main = (): JSX.Element => {
const [loading, setLoading] = useState(true);

useEffect(() => {
setTimeout(() => setLoading(false), 5000);
}, []);

return (
<>
{new Array(30).fill(" ").map(() => {
return (
<SandpackProvider initMode="user-visible">
<SandpackLayout>
<SandpackPreview />
</SandpackLayout>
</SandpackProvider>
);
return <Sandpack options={{ initMode: "lazy" }} />;
})}
<SandpackProvider initMode="user-visible">
<SandpackLayout>
<SandpackStack>
<div className="sp-preview-container">
<LoadingOverlay loading={loading} />
</div>
<SandpackPreview />
</SandpackStack>
</SandpackLayout>
</SandpackProvider>
</>
);
};
4 changes: 2 additions & 2 deletions sandpack-react/src/presets/Sandpack.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,9 @@ export const RunnableComponent = (): React.ReactElement => (
export const InitModeUserVisible: React.FC = () => {
return (
<>
{new Array(30).fill(" ").map(() => {
{new Array(30).fill(" ").map((_, index) => {
return (
<div style={{ marginBottom: 200 }}>
<div key={index} style={{ marginBottom: 200 }}>
<Sandpack options={{ initMode: "user-visible" }} />
</div>
);
Expand Down
2 changes: 2 additions & 0 deletions sandpack-react/src/presets/Sandpack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface SandpackProps {
* a certain control of when to initialize them.
*/
initMode?: SandpackInitMode;
initModeObserverOptions?: IntersectionObserverInit;

bundlerURL?: string;
startRoute?: string;
Expand Down Expand Up @@ -113,6 +114,7 @@ export const Sandpack: React.FC<SandpackProps> = (props) => {
skipEval: props.options?.skipEval,
fileResolver: props.options?.fileResolver,
initMode: props.options?.initMode,
initModeObserverOptions: props.options?.initModeObserverOptions,
externalResources: props.options?.externalResources,
};

Expand Down

0 comments on commit 177f0ab

Please sign in to comment.