Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[9.x] Support preloading assets with Vite #44096

Merged
merged 3 commits into from
Oct 24, 2022
Merged

Conversation

timacdonald
Copy link
Member

@timacdonald timacdonald commented Sep 12, 2022

Preloading is a mechanism that informs the browser which assets are required for the current page load. You can read more about preloading on web.dev.

Using this PR on a real-world application increased the Lighthouse score from 59 to 90. These were the average results from a controlled environment. Your mileage may vary, but this PR should generally work for the 95% usecase (IMO).

Preloading is not something to opt into. With this PR it becomes the default behaviour for applications using Vite, however the middleware does need to be applied manually if you want to have the preloading headers sent with the initial Laravel HTTP response.

I'll outline the problem we are looking to solve: for SPA applications building with Vite, or any modern build tool using code splitting, the resulting assets look similar to the following:

  • app.123.js
  • Welcome.123.js
  • Login.123.js
  • Register.123.js
  • ...

Each of these top-level "pages" will often have imports:

  • Login.123.js
    • TextInput.123.js
    • InputLabel.123.js
    • PrimaryButton.123.js

"Code splitting" like this is a performance optimisation so you load only the JavaScript required for the current page, which makes the initial page load faster.

Currently in Laravel, when loading the very first page of an application, we specify the following entry point:

@vite(['resources/js/app.js'])

This results in the following waterfall of requests:

  1. /login.html...
  2. app.js...
  3. Login.js, TextInput.js, InputLabel.js, PrimaryButton.js...

This waterfall of requests is, of course, less than ideal. Working through this waterfall:

  1. The browser must first download the HTML and parse it to know what CSS and JavaScript the document has specified. Our document has specified "app.js", so the browser proceeds to fetch it.
  2. Once app.js js fetched and parsed, the JavaScript kicks in an informs the browser that it should be loading the Login.js file and it's imports.
  3. The browser begins fetching, parsing, and executing the required imports.

Possible solutions are:

  1. Bundle everything into a single app.js with "eager globing"
  2. Preloading

Before I dig into these solutions, let me post the results of my Lighthouse performance testing against a real-world application. These tests were run against "mobile".

I acknowledge that this is a single application and need further verification.

With Preloading Configuration Lighthouse score
YES Code split + vendor split 90
YES Code split 90
NO Code split + vendor split 85
NO Code split 85
YES Eager glob + vendor split 65
NO Eager glob + vendor split 63
YES Eager glob 61
NO Eager glob 59

We can see in all instances, preloading had a positive impact on the Lighthouse scores.

Lighthouse screenshots

Although all these screenshots are of the same page, I found similar results across other pages with the application.

Preloading w/ Code split + vendor split

1  Code split + vendor split w: Preloading

Preloading w/ Code split

2  Code split w: Preloading

Not Preloading w/ Code split + vendor split

3  Code split + vendor split

Not Preloading w/ Code split

4  Code split

Preloading w/ Eager glob + vendor split

5  Eager glob + vendor split w: Preloading

Preloading w/ Eager

6  Eager glob w: Preloading

Not preloading w/ Eager glob + vendor split

7  Eager glob + vendor split

Not preloading w/ Eager glob

8  Eager glob

Although eager globing does solve the waterfall issue, I believe that the Vite setup for Laravel should also support code splitting the best it can. Preloading is utilised by Vite itself when doing things "the Vite way". This is the mechanism that all code splitting usually works. Additionally, if you utilise vendor splitting with eager globing, you are back to the original waterfall problem, as the app.js has to be fetched and parsed before vendor.js is requested and parsed.

With this PR in place, we are able to, at best, remove the waterfall entirely for all configurations:

  1. /login.html, app.js, Login.js, TextInput.js, InputLabel.js, PrimaryButton.js...

and at worst, the non-SPA waterfall:

  1. /login.html
  2. app.js, Login.js, TextInput.js, InputLabel.js, PrimaryButton.js...

This PR works by adding "preload" <link tags into the HTML for any asset required by the current page and if used the middleware will send preload Link: headers to the browser.

Preload tags are generated for the JavaScript and CSS entry points and their imports.

To take full advantage of this, applications do need to specify the page / chunk they are currently trying to load in the Vite tag:

@vite(["resources/js/Pages/{$page['component']}.vue"])

Applications do not have to update the Vite directive may still find this PR beneficial, as it can send the headers with the document instructing the browser to start fetching the app.js and any additional CSS it imports.

With that in mind, preloading is beneficial to all configurations: eager glob, vendor split, code split, etc.

Note Applications that are vendor splitting will still need to add the app.js into the Vite directive:

@vite([
    'resources/js/app.js',
    "resources/js/Pages/{$page['component']}.vue",
])

Browser support

For CSS files, we have great browser support.

For JavaScript "Module preloading" unfortunately out of the main browsers Firefox and Safari do not currently support this feature. According to caniuse 73% of browsers in use globally support this feature. Their stats are provided by statscounter.

A lot of JavaScript eco-system is relying on this feature and we are all waiting for Firefox and Safari to roll the feature out.

The good news is that this feature has no negative impacts on un-supprted browsers. They simply ignore the tag and move on with their existing behaviour.

However, because CSS preloading is supported, applications will still likely get a boost in Firefox and Safari.

@timacdonald timacdonald changed the title [9.x] support (module)preloading with Vite [9.x] support (module)preloading / prefecting with Vite Sep 13, 2022
@driesvints driesvints changed the title [9.x] support (module)preloading / prefecting with Vite [9.x] Support (module)preloading / prefecting with Vite Sep 13, 2022
@timacdonald timacdonald changed the title [9.x] Support (module)preloading / prefecting with Vite [9.x] Support (module)preloading assets with Vite Sep 14, 2022
@timacdonald timacdonald changed the title [9.x] Support (module)preloading assets with Vite [9.x] Support preloading assets with Vite Sep 14, 2022
@timacdonald timacdonald marked this pull request as ready for review September 14, 2022 08:27
@taylorotwell taylorotwell marked this pull request as draft September 15, 2022 13:22
@taylorotwell
Copy link
Member

@timacdonald

How would you picture doing this in an Inertia app? Do you put this in the main layout? Having trouble picturing it.

@vite(["resources/js/Pages/{$page['component']}.vue"])

@timacdonald
Copy link
Member Author

timacdonald commented Sep 27, 2022

That is correct. I would suggest we make that the default for Inertia across the started kits.

Take a fresh Breeze application for example:

Before

Layout

@vite('resources/js/app.js')

Headers

n/a

HTML

<link rel="stylesheet" href="http://127.0.0.1:8000/build/assets/app.f31a18ec.css" />
<script type="module" src="http://127.0.0.1:8000/build/assets/app.9bf14e2c.js"></script>

Waterfall

  1. login.html
  2. app.js, app.css
  3. Login.js, PrimaryButton.js, TextInput.js, ApplicationLogo.js

After

Layout

@vite("resources/js/Pages/{$page['component']}.vue")

Headers

Link: <http://127.0.0.1:8000/build/assets/app.f31a18ec.css>; rel="preload"; as="style", 
      <http://127.0.0.1:8000/build/assets/Login.5e7e8a19.js>; rel="modulepreload", 
      <http://127.0.0.1:8000/build/assets/app.9bf14e2c.js>; rel="modulepreload", 
      <http://127.0.0.1:8000/build/assets/PrimaryButton.62d440f3.js>; rel="modulepreload", 
      <http://127.0.0.1:8000/build/assets/TextInput.d97ddb4c.js>; rel="modulepreload", 
      <http://127.0.0.1:8000/build/assets/ApplicationLogo.fc364aed.js>; rel="modulepreload"

HTML

<link rel="preload" as="style" href="http://127.0.0.1:8000/build/assets/app.f31a18ec.css" />
<link rel="modulepreload" as="script" href="http://127.0.0.1:8000/build/assets/Login.5e7e8a19.js" />
<link rel="modulepreload" as="script" href="http://127.0.0.1:8000/build/assets/app.9bf14e2c.js" />
<link rel="modulepreload" as="script" href="http://127.0.0.1:8000/build/assets/PrimaryButton.62d440f3.js" />
<link rel="modulepreload" as="script" href="http://127.0.0.1:8000/build/assets/TextInput.d97ddb4c.js" />
<link rel="modulepreload" as="script" href="http://127.0.0.1:8000/build/assets/ApplicationLogo.fc364aed.js" />
<link rel="stylesheet" href="http://127.0.0.1:8000/build/assets/app.f31a18ec.css" />
<script type="module" src="http://127.0.0.1:8000/build/assets/Login.5e7e8a19.js"></script> 

Waterfall

  1. login.html
  2. app.js, app.css, Login.js, PrimaryButton.js, TextInput.js, ApplicationLogo.js

However with the headers, the browser is able to prioritise and connect etc. in advance of parsing the DOM.

@timacdonald timacdonald force-pushed the preload branch 3 times, most recently from 9dd9cb2 to 1f5fe9c Compare September 27, 2022 03:43
@taylorotwell
Copy link
Member

In your after example. How do we actually get $page['component] to the layout? And doesn't that page component change in between page navigations? Is the entire layout re-rendered on those navigations?

@timacdonald
Copy link
Member Author

In your after example. How do we actually get $page['component] to the layout?

$page['component] is injected by Inertia for us. Don't need to do anything to make it work.

I believe it is handled here: https://github.com/inertiajs/inertia-laravel/blob/540b953ec383364264f9bd633849db16560a4461/src/Response.php#L99-L110

And doesn't that page component change in between page navigations?

It does, but future navigations are done in SPA land and the blade layout is not re-rendered.

@php
    logger()->info('rendering blade');
@endphp

You only get one log statement on initial load. Future navigations do not log.

Additionally, what I am proposing we do on initial load is what already happening for us on subsequent navigations, if the components required for the page have not already be preloaded before...

Screen.Recording.2022-10-14.at.9.27.09.am.mov

If we are keen on this in general, I would also like to propose that we optimize future navigations as well - but I feel that is fine for a different PR, which is why it isn't included here. The full story I have in my head is that we remove the waterfall from the initial load, but then in the background we offer the option to preload all (or some) of the sites assets, which will mean that even on future navigations, the only request to the server is the Inertia request.

@timacdonald
Copy link
Member Author

timacdonald commented Oct 14, 2022

Just some more info on this one: these link headers are what would power the recent “Early Hints” feature in some caches.

Cloudflare’s implementation is based on these headers.

2F5C1E1C-CBDC-40B9-A74D-D15396203835

Early hints could offer an additional performance impact on end-users.

@taylorotwell taylorotwell marked this pull request as ready for review October 22, 2022 17:30
@timacdonald
Copy link
Member Author

@driesvints
Copy link
Member

Hi there. We fixed the failing tests on 9.x which are causing this PR to fail as well. Can you please rebase your PR with 9.x and mark this PR as ready for review when all tests are passing again? Thanks

@driesvints driesvints marked this pull request as draft October 24, 2022 09:52
@taylorotwell taylorotwell marked this pull request as ready for review October 24, 2022 13:27
@taylorotwell taylorotwell merged commit ae9f78a into laravel:9.x Oct 24, 2022
@timacdonald timacdonald deleted the preload branch October 24, 2022 22:31
@lk77
Copy link

lk77 commented Jan 30, 2024

Is there any way to disable this ?

it preloads everything, even modules that 99% of users will most likely never need

@dennisprudlo
Copy link
Contributor

dennisprudlo commented Jan 31, 2024

@lk77 I also get warnings in the console that scripts were preloaded that haven't been used later on. I was able to disable it using the following line in a service provider's boot method:

Vite::usePreloadTagAttributes(fn () => false);

@timacdonald
Copy link
Member Author

If you can provide a reproduction repository that shows assets being preloaded that are not used by the current page, I'm happy to look into this for you.

I'm unable to replicate the issue myself.

@lk77
Copy link

lk77 commented Feb 13, 2024

@timacdonald In my testings, all dependencies that were chunked using manualChunk were preloaded by laravel, perhaps there is something here.

I do that because i want to replicate the way extract worked on laravel-mix, i do not want those dependencies to be present on the vendor chunk, usually because they are heavy and not needed in a lot of pages, and i don't want them to be loaded by default

i'm using a regex for that :

const extract = /pdfjs|fabric/;
[...]
build: {
        rollupOptions: {
            output: {
                manualChunks: (id) => {
                    if(id.includes('node_modules') && extract.test(id)) {
                        return id.match(extract)[0];
                    }
                }
            },
        }
    },

but it seems vite consider that those chunks are imported by the main entry point of the application :

"file": "assets/app-Gs03R-Tb.js",
"imports": [
  "_vendor-hzoY_nRi.js",
  [...]
  "_fabric-A_TtU6Vv.js",
  "_pdfjs-wmCqQ4z4.js"
],

perhaps it's not an issue with laravel but more with vite

philbates35 added a commit to philbates35/laravel-starter that referenced this pull request Feb 28, 2024
This is automatically enabled when using breeze:install to instead
React with Intertia, I don't really see any downside in enabling
it.

See: laravel/framework#44096
philbates35 added a commit to philbates35/laravel-starter that referenced this pull request Feb 29, 2024
This is automatically enabled when using breeze:install to instead
React with Intertia, I don't really see any downside in enabling
it.

See: laravel/framework#44096
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants