tc39 / proposal-dynamic-import

import() proposal for JavaScript

Home Page:https://tc39.github.io/proposal-dynamic-import/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Always Async: Flash of Loading Content

ryanflorence opened this issue · comments

On the web, the only use-case I can think of is code splitting. I've been doing code-splitting for a few years now manually, with AMD, and for the last couple years with webpack.

While intuitively a promise seems like the most obvious async primitive here I'd argue a callback would be better for product developers and website users, why? Flash of Loading Content.

The first time somebody visits a code-split page, the module is not yet loaded so the product developer will add some loading indicator.

The second time the user visits a code-split page, the browser already has that code cached, but promises are asynchronous always, so the user gets a split second flash of the loading screen. It feels awful. Dancing around this problem with the declarative UI patterns emerging is difficult.

I've been using the bundle loader for webpack for a couple years now. It is asynchronous the first time, synchronous every time after. It makes for a great user and developer experience.

I messed around w/ import() and code-splitting by trying to keep my own module cache with some sort of syntax like this

loadBundle('./thing', () => import('./thing'), (mod) => { })

Really redundant to have to specify the path in two places every time you want to code split. Also, building a module cache isn't a trivial thing for a lot of people.

I'd really like to see something that doesn't cause a Flash of Loading Content. I'd continue to use custom module loading with webpack and skip the platform. Which is a shame since it seems this use case is the primary motivator for import()!

@ryanflorence Regardless of if something is in cache or not unless it's loaded in the current module registry would it not still need to be async? If not it seems like a callback is equally pointless. If the module already exists in the current contexts module registry then perhaps the return value or import() is either the module namespace object OR a promise. That would enable the following pattern:

async function foo() {
   let mod = import();

  if (mod instanceof Promise) {
    mod = await mod;
  }
}

My understanding is that an async function runs sync until hitting the first await. My example isn't likely the cleanest way to handle this but it seems like a reasonable premise. Thoughts?

Just for fun I wanted to write the same idea using do expressions:

async function foo() {
  const mod = do {
    let mod = import(...);
    mod instanceof Promise ? await mod : mod;
  };
  // do stuff with mod, maybe call a callback.
}

@matthewrobb Great point, I meant to cover that.

In React, you'd typically put the module in the component's state (and in other UI frameworks somewhere similar).

Then, in the lifecycles of a component, the second time around the loaded module ends up in state immediately before rendering, avoiding FOLC.

class Bundle extends Component {
  state = {
    mod: null
  }

  componentWillMount() {
    this.load(this.props)
  }

  componentWillReceiveProps(nextProps) {
    if (nextProps.load !== this.props.load) {
      this.load(nextProps)
    }
  }

  load(props) {
    this.setState({
      mod: null
    })
    props.load((mod) => {
      // if this can fire synchronously, then we'll have the new module
      // immediately on mount and on changes
      this.setState({
        mod: mod.default ? mod.default : mod
      })
    })
  }

  render() {
    return this.props.children(this.state.mod)
  }
}

export default Bundle

So yeah, I now recognize I'm already dealing with a module cache to some degree, it's just a lot easier in the component 🤔.

The second time the user visits a code-split page, the browser already has that code cached, but promises are asynchronous always, so the user gets a split second flash of the loading screen.

This is not true. Promises that fulfill immediately fulfill before any painting is done. (Microtasks run before any tasks, and WAY before the every-16ms animation frame task.)

It sounds like your environment is having other problems, but promise-induced asynchronicity is not the cause of it.

@threepointone you've run into this also, do you think this is just the webpack implementation causing problems?

Thanks @domenic that's reassuring :)

This is not true. Promises that fulfill immediately fulfill before any painting is done. (Microtasks run before any tasks, and WAY before the every-16ms animation frame task.

Very cool I did not know this. @domenic To make sure I am clear, does the following all happen in a perceptibly sync fashion?:

async function foo() {
const { a } = Promise.resolve({ a: 1 });
return a;
}
foo().then(a => console.log(a));
console.log(2);

Would we see 1 and then 2 in the console? Or is it still async but more akin to process.nextTick or setImmediate style async.

To make sure I am clear, does the following all happen in a perceptibly sync fashion?:

No, you'd see 2 then 1. But you'd see 1 before any other tasks run (such as postMessaging to yourself), and WAY before the 16 ms-boundary next painting task.

If it happened in a script tag would document.write be available in a sync fashion like the normal first pass of the dom parsing?

Just an idea: you could combine Promise.race and an ASAP timer so that, like Domenic said, the resolved promise will always win against the timer delayed one.

// show the spinner
Promise.race([
  new Promise(r => setTimeout(r, 10, 'use the module')),
  new Promise(r => setTimeout(r, 0, 'show spinner'))
]).then(console.log);

// use the module
Promise.race([
  Promise.resolve('use the module'),
  new Promise(r => setTimeout(r, 0, 'show the spinner'))
]).then(console.log);

// use the module
Promise.race([
  new Promise(r => setTimeout(r, 0, 'show the spinner')),
  Promise.resolve('use the module')
]).then(console.log);

@domenic In the following is there anything different if this came down in the page markup?:

<script>
  async function foo() {
     const { a } = Promise.resolve({ a: 1 });
     return a;
  }
  foo().then(a => console.log(a));
  console.log(2);
</script>

<script>
  console.log(3)
</script>

Would you get 2, 1, 3 or 2, 3, 1?

Maybe this is a problem for react to solve, but since it doesn't resolve in the same tick, React can't get the module into state in time for the next render. It seems likely the same problem would exist in other UI frameworks.

If it doesn't resolve in the same tick, then you get the flash.

I'm sure folks will say this is a react problem, but I'm not sure. All I know is async first, sync works really well.

If it doesn't resolve in the same tick, then you get the flash.

That's where you lost me. Maybe React has some logic where if something doesn't come in synchronously, it delays work for 16 ms. But that is indeed a React problem then: it should be using all of those 16 ms before the next painting to do useful work.

Whoops, didn't mean to reopen.

Has there been any discussion of asking for the module from an internal cache?

const mod = import.fromCache('./Something')
if (mod) { /* ... */ }

I'd be pretty excited about that.

@ryanflorence , the UI initialization flow and resolving the imports are not directly relevant to each other.
I disagree on the @domenic reasoning that resolving should happen before dom rendered. That is up to app or browser or import implementation and out of scope for standard.

As for " Flash of Loading Content" pages, UI usability requires some coding ( outside of import standard and irrelevant to use promise or not ). The code should be smart enough to prevent "loding" UX if the JS loading is fast enough even if DOM already loaded. Give it 0.1 seconds before the loading UX appear, this would be sufficient for even async code loaded in many cases. Same for hiding, do the smooth transition from loading to rendered page.