whatwg / html

HTML Standard
https://html.spec.whatwg.org/multipage/
Other
8.17k stars 2.69k forks source link

Early hints and `modulepreload` #7854

Open noamr opened 2 years ago

noamr commented 2 years ago

Currently in the spec, early hints work only for preload (and soon for preconnect). In Chromium source code (as I understand it) and in WPT, modulepreload is also supported, but it's treated like a preload in the sense that it doesn't start loading module dependencies. It still uses the special fetching defaults/parameters of modulepreload like having script as the default for as.

I see how it's problematic to start creating actual script objects before the document is initialized, but it also changes the semantics of modulepreload when it's an early hint, which might be confusing - e.g. if the developer doesn't add an actual equivalent <link rel=modulepreload> to the document, the dependencies will only be fetched when the module is imported in practice.

I suggest the following strategy in the spec (and implementation):

bashi commented 2 years ago

+1 for the suggestion. I'd like to hear @hiroshige-g's opinion.

Note that Chromium's current implementation is somewhat different from the ideal. As the design document explains it makes a request upon a reception of an Early Hints preload and put the response in the HTTP cache (no network request happens when there is a fresh response in the cache already). When the document makes a request for the same resource the response hopefully comes from the HTTP cache. It doesn't recognize the semantics of modulepreload now.

We don't think the current approach is a long-term solution and we would like to have a proper implementation in the future -- doing so requires a lot of engineering work and we are trying to figure out a reasonable approach.

cc: @yutakahirano

hiroshige-g commented 2 years ago

I suggest the following strategy in the spec (and implementation):

  • Keep the existing Chromium behavior when the early hint headers are processed
  • Once the document is created, also preload the script graph immediately as if the document had a regular modulepreload header.

Just to clarify, does this mean the following?

Note that preload the script graph anyway doesn't fetch the dependencies (at least in Chromium). Step 3 is marked as "optionally perform".

noamr commented 2 years ago

I suggest the following strategy in the spec (and implementation):

  • Keep the existing Chromium behavior when the early hint headers are processed
  • Once the document is created, also preload the script graph immediately as if the document had a regular modulepreload header.

Just to clarify, does this mean the following?

  • When the early hint modulepreload headers are processed, preload the scripts as if they were preload.

Right, with the "special" aspects of modulepreload such as treating empty as as script.

Exactly, passing in the response we already have and modifying that algorithm to optionally accept a response argument.

Note that preload the script graph anyway doesn't fetch the dependencies (at least in Chromium). Step 3 is marked as "optionally perform".

Right, but at the very least the module is added to the module map, and this "optionally perform" would become viable for early hints.

hiroshige-g commented 2 years ago

Thanks for clarification!

After looking further at the Chromium Early Hints source code, "preload the scripts as if they were preload" is actually a tweaked version for module scripts (and thus there are no exactly corresponding case in <link rel=preload>).

cc @domenic @irori.

noamr commented 2 years ago

Thanks for clarification!

After looking further at the Chromium Early Hints source code, "preload the scripts as if they were preload" is actually a tweaked version for module scripts (and thus there are no exactly corresponding case in <link rel=preload>).

cc @domenic @irori.

Yes, that's what I meant by "special" aspects of modulepreload.

noamr commented 2 years ago

Anyway, I can work on a PR along those lines, where early hints performs the modulepreload steps all the way until the response, and continues to fetch the script graph with that response once the document is initialized. Perhaps a PR would be easier to discuss.

noamr commented 2 years ago

Created a PR to try to express the proposal.

Krinkle commented 7 months ago

In a project I'm working on, I made the choice to adopt ES6+ syntax and ESM native modules, with an "importmap" to faccilitate versioning/cache busting. Upon transitioning the preload strategy, I believe I am now stuck with no options available that aren't actively harmful in some way. The reason the project has a preload strategy, is that some endpoints involve non-trivial server-side logic where Page Load Time benefits from concurrently requesting static assets (flush headers before the HTTP body. HTTP body is cannot feasibly be flushed in parts).

Assumptions

Let me back up a bit, and share what my assumptions are at this point. Correct me if any of this is wrong!

Observations

It seeems thus that today, when transitioning from having 3 classic scripts to 3 ESM files in a project, there is currently no option available that provides the same level of correct/complete preloading as was already available to classic scripts.

What I tried:

  1. preload with rel=preload;as=script;crossorigin.
    • Chrome: fine to DIY (one works, flat list upfront also works if developer wants to invest in that)
    • Firefox: actively harmful, makes an unused preload fetch then also makes a duplicate for the real one.
    • Safari: actively harmful, makes an unused preload fetch then also makes a duplicate for the real one.
  2. preload either one or all scripts with rel=modulepreload. Given

    Link: </static/bar.js?dev>;rel=modulepreload,</static/foo.js?dev>;rel=modulepreload,</static/main.js?dev>;rel=modulepreload
    <script type="importmap">
        {
            "imports": {
                "/static/foo.js": "/static/foo.js?dev",
                "/static/bar.js": "/static/bar.js?dev",
                "/static/main.js": "/static/main.js?dev"
            }
        }
        </script>
    <link rel="modulepreload" href="/static/foo.js?dev">
    <link rel="modulepreload" href="/static/bar.js?dev">
    <script type="module" src="/static/main.js?dev"></script>
    • Firefox 123: correctly preloads and re-use 3 files, but also eagerly downloads bare import references, causing early-duplicate requests for dependencies. Not completely harmful since the correct cache did (also) end up warmed early and used to satisfy the later demand.
    • Safari 17.4: broken beyond my understanding.
    • Makes requests for all 3 modulepreload URLs from the Link header. It appears to make these with the corret CORS setting (Origin header) and doesn't recurse into dependencies, which is fine.
    • Makes additional (duplicate) requests for the 2 modulepreload URLs in the HTML link element. Fails reuse.
    • Makes additional (duplicate) request for the main URL in the HTML script element. Fails reuse.
    • Makes additional (duplicate) requests for the imported dependencies. Fails reuse.
    Screenshot

    Safari is making 9 requests, 3x for each of the 3 files. All with the correct URL (no bare ones, WebKit Inspector hides querystring) and CORS setting. It even fetches main?v1 three times despite only being requested twice (Link header and script tag). The middle one corresponds to a line an unrelated element after the importmap script. That seems to trigger Safari into making a request somehow. Notice it is doing the same for stylesheets as well. It is requesting style.css three times. Once for the Link header. Once for no apparent reason after reading an importmap relating to JS files. And a third time for the actual stylesheet link tag. And CSS isn't even at issue in terms of potential preload mismatch. I'm going to assume I've hit an edge case and that under some definition of normal circumstances, simple CSS preloads aren't broken in this way in Safari.

    • Chrome 123: correctly preloads and re-use 3 files. Like Firefox, and unlike what @noamr observed in 2022, Chrome is now also eagerly resolving dependencies from the modulepreload, and doing so without the importmap. Although it seems to be doing that last step very late (e.g. near DOMContentLoaded). Which means the waterfall looks rather funny. Well-after the main?dev preload has been consumed by the DOM, and the definitive importmap is known, utilized, and satisfied; Chrome then starts to revisit the old modulepreload response it got earlier and decides to make two additional duplicate requests for the 2 unversioned imports. Screenshot

So... in conclusion, it appears there are no options available to progressively enhance performance by preloading some (or all) ESM files as module scripts. Each of the options I found is actively harmful in at least one major browser. I'm leaving this here in case I've missed something. I'd love to know of a way that I can at least declare one of the JS resources for preloading, in a way that works in 1 browser and is safely ignored in any browsers that don't support it yet.

smaug---- commented 7 months ago

@zqianem

zqianem commented 5 months ago

@Krinkle, apologies for the delay; my understanding of the modulepreload side of the problem is this:

Early Hints modulepreload and import maps can't work together because conceptually, Early Hints prepends a <link rel="modulepreload"> to the document's head^1, which since it comes before the <script type="importmap">, should disable the import map according to spec^2.

However, Chromium seems to have a bug^3 where Early Hint modulepreloads don't disable the import map, (but having an actual <link rel="modulepreload"> element in the HTML will); this may have led to the belief that the above combination was feasible when it really wasn't.

Here's a minimal repro of the current situation: https://github.com/zqianem/modulepreload-krinkle-repro

For both Firefox and Chromium, the recursion from the Early Hints only starts after index.html is parsed, even when index.html doesn't have any content.