vemund
vemund

Reputation: 1807

React suspense/lazy delay?

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);
});

Upvotes: 66

Views: 49368

Answers (12)

robertwerner_sf
robertwerner_sf

Reputation: 1303

My issue seems to be very similar to this. In App.jsx we have this code construct:

<React.Suspense fallback={<Loading />}>

</React.Suspense>

What is happening is that when a user changes pages, there's a noticeable blank screen and then the loading indicator appears a little after. With slow 3G connections, it's quite noticeable. So much so, that we will likely have to give up Code Splitting if this can't be resolved.

My question is simple: How can we force our Loading Indicator component to be instantly shown on top of the blank screen without any delay?

Upvotes: 0

Sankha Rathnayake
Sankha Rathnayake

Reputation: 843

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

Upvotes: 1

TAMIDI company
TAMIDI company

Reputation: 1

You can write a new function that await both the component and a delay. This await Promise.all must wait for both promises to resolve, so the loading time takes at least delayMs thus no more flickering.

export const importDelay = (importFn: () => Promise<any>, delayMs = 500) =>
    async () => {
        const [ result ] = await Promise.all([
            importFn(),
            new Promise((resolve) => setTimeout(resolve, delayMs))
        ]);
        
        return result as { default: ComponentType<any> };
    };

And use it like this:

const Component = React.lazy(importDelay(import("./component")));

Upvotes: 0

Navneet Dabral
Navneet Dabral

Reputation: 21

let shouldNotDelay = false;

export const DelayLoading = () => {
  if (shouldNotDelay) {
    return null;
  }
  throw new Promise((resolve) => {
    setTimeout(() => {
      shouldNotDelay = true;
      resolve(1);
    }, 2000);
  });
};

Here is full implementation : https://codesandbox.io/s/suspense-delay-7i5b34?file=/src/index.js

Upvotes: 0

liz
liz

Reputation: 163

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);
});

Upvotes: 3

rt_
rt_

Reputation: 1195

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)

Upvotes: 3

Night Zen
Night Zen

Reputation: 11

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;

Upvotes: 1

ford04
ford04

Reputation: 74490

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) → if true, Fallback can start its fade out animation
  • enableComponent (callback) → invoke it to unmount Fallback, when animation is done.
  • DeferredComponent → extended lazy Component loaded by dynamic import

2.) Listen to the animationend DOM event, so we know when animation has ended.

Upvotes: 9

Divyanshu Rawat
Divyanshu Rawat

Reputation: 4721

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 😆

const LazyRoute = lazy(() => {
  return new Promise(resolve => () =>
    import(
      '../../abc'
    ).then(x => x e => null as never),
  );
});

Upvotes: 0

Akrom
Akrom

Reputation: 331

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>

Upvotes: 17

Estus Flask
Estus Flask

Reputation: 222309

lazy 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);
});

Upvotes: 99

loopmode
loopmode

Reputation: 616

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.

Upvotes: 2

Related Questions