admin管理员组文章数量:1135195
I am trying to use the new React Lazy and Suspense to create a fallback loading component. This works great, but the fallback is showing only a few ms. Is there a way to add an additional delay or minimum time, so I can show animations from this component before the next component is rendered?
Lazy import now
const Home = lazy(() => import("./home"));
const Products = lazy(() => import("./home/products"));
Waiting component:
function WaitingComponent(Component) {
return props => (
<Suspense fallback={<Loading />}>
<Component {...props} />
</Suspense>
);
}
Can I do something like this?
const Home = lazy(() => {
setTimeout(import("./home"), 300);
});
I am trying to use the new React Lazy and Suspense to create a fallback loading component. This works great, but the fallback is showing only a few ms. Is there a way to add an additional delay or minimum time, so I can show animations from this component before the next component is rendered?
Lazy import now
const Home = lazy(() => import("./home"));
const Products = lazy(() => import("./home/products"));
Waiting component:
function WaitingComponent(Component) {
return props => (
<Suspense fallback={<Loading />}>
<Component {...props} />
</Suspense>
);
}
Can I do something like this?
const Home = lazy(() => {
setTimeout(import("./home"), 300);
});
Share
Improve this question
asked Jan 12, 2019 at 11:07
vemundvemund
1,8074 gold badges31 silver badges44 bronze badges
4
|
12 Answers
Reset to default 99lazy
function is supposed to return a promise of { default: ... }
object which is returned by import()
of a module with default export. setTimeout
doesn't return a promise and cannot be used like that. While arbitrary promise can:
const Home = lazy(() => {
return new Promise(resolve => {
setTimeout(() => resolve(import("./home")), 300);
});
});
If an objective is to provide minimum delay, this isn't a good choice because this will result in additional delay.
A minimum delay would be:
const Home = lazy(() => {
return Promise.all([
import("./home"),
new Promise(resolve => setTimeout(resolve, 300))
])
.then(([moduleExports]) => moduleExports);
});
As mentioned by loopmode, component fallback should have a timeout.
import React, { useState, useEffect } from 'react'
const DelayedFallback = () => {
const [show, setShow] = useState(false)
useEffect(() => {
let timeout = setTimeout(() => setShow(true), 300)
return () => {
clearTimeout(timeout)
}
}, [])
return (
<>
{show && <h3>Loading ...</h3>}
</>
)
}
export default DelayedFallback
Then just import that component and use it as fallback.
<Suspense fallback={<DelayedFallback />}>
<LazyComponent />
</Suspense>
Fallback component animations with Suspense
and lazy
@Akrom Sprinter has a good solution in case of fast load times, as it hides the fallback spinner and avoids overall delay. Here is an extension for more complex animations requested by OP:
1. Simple variant: fade-in + delayed display
const App = () => {
const [isEnabled, setEnabled] = React.useState(false);
return (
<div>
<button onClick={() => setEnabled(b => !b)}>Toggle Component</button>
<React.Suspense fallback={<Fallback />}>
{isEnabled && <Home />}
</React.Suspense>
</div>
);
};
const Fallback = () => {
const containerRef = React.useRef();
return (
<p ref={containerRef} className="fallback-fadein">
<i className="fa fa-spinner spin" style={{ fontSize: "64px" }} />
</p>
);
};
/*
Technical helpers
*/
const Home = React.lazy(() => fakeDelay(2000)(import_("./routes/Home")));
// import_ is just a stub for the stack snippet; use dynamic import in real code.
function import_(path) {
return Promise.resolve({ default: () => <p>Hello Home!</p> });
}
// add some async delay for illustration purposes
function fakeDelay(ms) {
return promise =>
promise.then(
data =>
new Promise(resolve => {
setTimeout(() => resolve(data), ms);
})
);
}
ReactDOM.render(<App />, document.getElementById("root"));
/* Delay showing spinner first, then gradually let it fade in. */
.fallback-fadein {
visibility: hidden;
animation: fadein 1.5s;
animation-fill-mode: forwards;
animation-delay: 0.5s; /* no spinner flickering for fast load times */
}
@keyframes fadein {
from {
visibility: visible;
opacity: 0;
}
to {
visibility: visible;
opacity: 1;
}
}
.spin {
animation: spin 2s infinite linear;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(359deg);
}
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
/>
<div id="root"></div>
You just add some @keyframes
animations to Fallback
component, and delay its display either by setTimeout
and a state flag, or by pure CSS (animation-fill-mode
and -delay
used here).
2. Complex variant: fade-in and out + delayed display
This is possible, but needs a wrapper. We don't have a direct API for Suspense
to wait for a fade out animation, before the Fallback
component is unmounted.
Let's create a custom useSuspenseAnimation
Hook, that delays the promise given to React.lazy
long enough, so that our ending animation is fully visible:
// inside useSuspenseAnimation
const DeferredHomeComp = React.lazy(() => Promise.all([
import("./routes/Home"),
deferred.promise // resolve this promise, when Fallback animation is complete
]).then(([imp]) => imp)
)
const App = () => {
const { DeferredComponent, ...fallbackProps } = useSuspenseAnimation(
"./routes/Home"
);
const [isEnabled, setEnabled] = React.useState(false);
return (
<div>
<button onClick={() => setEnabled(b => !b)}>Toggle Component</button>
<React.Suspense fallback={<Fallback {...fallbackProps} />}>
{isEnabled && <DeferredComponent />}
</React.Suspense>
</div>
);
};
const Fallback = ({ hasImportFinished, enableComponent }) => {
const ref = React.useRef();
React.useEffect(() => {
const current = ref.current;
current.addEventListener("animationend", handleAnimationEnd);
return () => {
current.removeEventListener("animationend", handleAnimationEnd);
};
function handleAnimationEnd(ev) {
if (ev.animationName === "fadeout") {
enableComponent();
}
}
}, [enableComponent]);
const classes = hasImportFinished ? "fallback-fadeout" : "fallback-fadein";
return (
<p ref={ref} className={classes}>
<i className="fa fa-spinner spin" style={{ fontSize: "64px" }} />
</p>
);
};
/*
Possible State transitions: LAZY -> IMPORT_FINISHED -> ENABLED
- LAZY: React suspense hasn't been triggered yet.
- IMPORT_FINISHED: dynamic import has completed, now we can trigger animations.
- ENABLED: Deferred component will now be displayed
*/
function useSuspenseAnimation(path) {
const [state, setState] = React.useState(init);
const enableComponent = React.useCallback(() => {
if (state.status === "IMPORT_FINISHED") {
setState(prev => ({ ...prev, status: "ENABLED" }));
state.deferred.resolve();
}
}, [state]);
return {
hasImportFinished: state.status === "IMPORT_FINISHED",
DeferredComponent: state.DeferredComponent,
enableComponent
};
function init() {
const deferred = deferPromise();
// component object reference is kept stable, since it's stored in state.
const DeferredComponent = React.lazy(() =>
Promise.all([
// again some fake delay for illustration
fakeDelay(2000)(import_(path)).then(imp => {
// triggers re-render, so containing component can react
setState(prev => ({ ...prev, status: "IMPORT_FINISHED" }));
return imp;
}),
deferred.promise
]).then(([imp]) => imp)
);
return {
status: "LAZY",
DeferredComponent,
deferred
};
}
}
/*
technical helpers
*/
// import_ is just a stub for the stack snippet; use dynamic import in real code.
function import_(path) {
return Promise.resolve({ default: () => <p>Hello Home!</p> });
}
// add some async delay for illustration purposes
function fakeDelay(ms) {
return promise =>
promise.then(
data =>
new Promise(resolve => {
setTimeout(() => resolve(data), ms);
})
);
}
function deferPromise() {
let resolve;
const promise = new Promise(_resolve => {
resolve = _resolve;
});
return { resolve, promise };
}
ReactDOM.render(<App />, document.getElementById("root"));
/* Delay showing spinner first, then gradually let it fade in. */
.fallback-fadein {
visibility: hidden;
animation: fadein 1.5s;
animation-fill-mode: forwards;
animation-delay: 0.5s; /* no spinner flickering for fast load times */
}
@keyframes fadein {
from {
visibility: visible;
opacity: 0;
}
to {
visibility: visible;
opacity: 1;
}
}
.fallback-fadeout {
animation: fadeout 1s;
animation-fill-mode: forwards;
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.spin {
animation: spin 2s infinite linear;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(359deg);
}
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
/>
<div id="root"></div>
Key points for complex variant
1.) useSuspenseAnimation
Hook returns three values:
hasImportFinished
(boolean
) → iftrue
,Fallback
can start its fade out animationenableComponent
(callback) → invoke it to unmountFallback
, when animation is done.DeferredComponent
→ extended lazy Component loaded by dynamicimport
2.) Listen to the animationend
DOM event, so we know when animation has ended.
If anyone is looking for a typescript, abstracted solution:
import { ComponentType, lazy } from 'react';
export const lazyMinLoadTime = <T extends ComponentType<any>>(factory: () => Promise<{ default: T }>, minLoadTimeMs = 2000) =>
lazy(() =>
Promise.all([factory(), new Promise((resolve) => setTimeout(resolve, minLoadTimeMs))]).then(([moduleExports]) => moduleExports)
);
Usage:
const ImportedComponent = lazyMinLoadTime(() => import('./component'), 2000)
props to @Estus Flask for a super helpful answer. I was using just the setTimeout functionality before, but couldn't get tests to work at all. Calling setTimeout
in the tests was causing nested calls, jest.useFakeTimers()
and jest.runAllTimers()
didn't seem to do anything, and I was stuck getting the loader back. Since searching for the right way to test this took so long, I figured it would be helpful share how I was able to test it. With an implementation per the given solution:
import React, { ReactElement, Suspense } from 'react';
import { Outlet, Route, Routes } from 'react-router-dom';
import Loader from 'app/common/components/Loader';
const Navigation = React.lazy(() => {
return Promise.all([
import("./Navigation"),
new Promise(resolve => setTimeout(resolve, 300))
])
.then(([moduleExports]) => moduleExports);
});
const Home = React.lazy(() => {
return Promise.all([
import("./Home"),
new Promise(resolve => setTimeout(resolve, 300))
])
.then(([moduleExports]) => moduleExports);
});
interface PagesProps {
toggleTheme: () => void;
}
const Pages = (props: PagesProps): ReactElement => (
<Suspense fallback={<Loader />}>
<Routes>
<Route path="/" element={
<>
<Navigation toggleTheme={props.toggleTheme}/>
<Outlet />
</>
}>
<Route index element={<Home />} />
</Route>
</Routes>
</Suspense>
);
export default Pages;
I was able to successfully test it with the following. Note that if you don't include jest.useFakeTimers()
and jest.runAllTimers()
you'll see flaky tests. There's a little excess detail in my tests because I'm also testing pushing the history (in other tests), but hopefully this helps anyone else!
/**
* @jest-environment jsdom
*/
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import Pages from './';
describe('Pages component', () => {
beforeEach(() => {
jest.useFakeTimers();
})
const history = createMemoryHistory();
it('displays loader when lazy', async () => {
render(
<Router location={history.location} navigator={history} navigationType={history.action}>
<Pages toggleTheme={function (): void { return null; } } />
</Router>,
);
const lazyElement = await screen.findByText(/please wait/i);
expect(lazyElement).toBeInTheDocument();
});
it('displays "Welcome!" on Home page lazily', async () => {
render(
<Router location={history.location} navigator={history} navigationType={history.action}>
<Pages toggleTheme={function (): void { return null; } } />
</Router>,
);
const fallbackLoader = await screen.findByText(/please wait/i);
expect(fallbackLoader).toBeInTheDocument();
jest.runAllTimers();
const lazyElement = await screen.findByText('Welcome!');
expect(lazyElement).toBeInTheDocument();
});
afterEach(cleanup);
});
You should create a fallback component that itself has a timeout and a visible state. Initially you set visible false. When fallback component gets mounted, it should setTimeout to turn visible state flag on. Either make sure your component is still mounted, or clear the timeout when the component gets unmounted. Finally, if visible state is false, render null in your fallback component (or e.g. just blocking/semi-transparent overlay but no spinner/animation)
Then use such component, e.g. <Loading overlay/>, as fallback.
To avoid flashing a loader if the loading is very fast, you could use a p-min-delay function, that delays a promise a minimum amount of time. Useful when you have a promise that may settle immediately or may take some time, and you want to ensure it doesn't settle too fast.
For example:
import { Suspense, lazy } from 'react';
import { PageLoadingIndicator } from 'components';
import pMinDelay from 'p-min-delay';
const HomePage = lazy(() => pMinDelay(import('./pages/Home'), 500));
function App() {
return (
<Suspense fallback={<PageLoadingIndicator />}>
<HomePage />
</Suspense>
);
}
export default App;
If you are experiencing a screen flickering issue (Fallback screen gets visible and gets disappeared in split of second due to the fast loading of the next component) you can solve it by adding a startTransition.
Eg:- If you have lazy loaded a modal component, and if you are making it appear by calling an state action named 'setOpenModal', you can call it inside a startTransition function like below to avoid the flickering issue.
import { startTransition } from 'react';
function ComponentA() {
const [openModal, setOpenModal] = useState(false);
const modalHandler = () => {
startTransition(() => {
setOpenModal(!openModal)
});
}
}
You can find more details about this using below react documentation.
https://react.dev/reference/react/Suspense#preventing-already-revealed-content-from-hiding
I faced similar problem moreover I was using TypeScript along with React. So, I had to respect typescript compiler as well & I went ahead with an approach having an infinite delay along with no complain from typescript as well. Promise that never resolved
本文标签: javascriptReact suspenselazy delayStack Overflow
版权声明:本文标题:javascript - React suspenselazy delay? - Stack Overflow 内容由网友自发贡献,该文观点仅代表作者本人, 转载请联系作者并注明出处:http://www.betaflare.com/web/1736920751a1956452.html, 本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
fallback
. – Estus Flask Commented Jan 12, 2019 at 11:30