Skip to content

Commit f1ec2fe

Browse files
committed
feature #61 New preload option to automatically set preload Link header for rendered scripts/styles (weaverryan)
This PR was merged into the master branch. Discussion ---------- New preload option to automatically set preload Link header for rendered scripts/styles Fixes #14 This adds a new configuration option: ```yml webpack_encore: # ... preload: true ``` When enabled, it automatically adds a `preload` entry to the Link header for every JavaScript and CSS file that was rendered - for example: <img width="1225" alt="Screen Shot 2019-04-11 at 11 51 36 AM" src="https://user-images.githubusercontent.com/121003/55983583-4cf87080-5c50-11e9-9305-92998ab47d93.png"> What I need is just some validation on this approach and that the Link header looks right ;). Cheers! Commits ------- 0778950 New preload option to automatically set preload Link header for rendered scripts/styles
2 parents 81db0d5 + 0778950 commit f1ec2fe

File tree

10 files changed

+342
-35
lines changed

10 files changed

+342
-35
lines changed

composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
"symfony/framework-bundle": "^3.4 || ^4.0",
3333
"symfony/phpunit-bridge": "^3.4 || ^4.1",
3434
"symfony/twig-bundle": "^3.4 || ^4.0",
35-
"twig/twig": "^1.35 || ^2.0"
35+
"twig/twig": "^1.35 || ^2.0",
36+
"symfony/web-link": "^3.4 || ^4.0",
37+
"fig/link-util": "^1.0"
3638
},
3739
"extra": {
3840
"thanks": {

src/Asset/EntrypointLookup.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ class EntrypointLookup implements EntrypointLookupInterface, IntegrityDataProvid
2929

3030
private $cache;
3131

32+
private $cacheKey;
33+
3234
private $strictMode;
3335

3436
public function __construct(string $entrypointJsonPath, CacheItemPoolInterface $cache = null, string $cacheKey = null, bool $strictMode = true)

src/Asset/TagRenderer.php

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,21 @@
1111

1212
use Symfony\Component\Asset\Packages;
1313
use Symfony\Component\DependencyInjection\ServiceLocator;
14+
use Symfony\Contracts\Service\ResetInterface;
1415

15-
final class TagRenderer
16+
/**
17+
* @final
18+
*/
19+
class TagRenderer implements ResetInterface
1620
{
1721
private $entrypointLookupCollection;
1822

1923
private $packages;
2024

2125
private $defaultAttributes;
2226

27+
private $renderedFiles = [];
28+
2329
public function __construct(
2430
$entrypointLookupCollection,
2531
Packages $packages,
@@ -41,6 +47,8 @@ public function __construct(
4147

4248
$this->packages = $packages;
4349
$this->defaultAttributes = $defaultAttributes;
50+
51+
$this->reset();
4452
}
4553

4654
public function renderWebpackScriptTags(string $entryName, string $packageName = null, string $entrypointName = '_default'): string
@@ -61,6 +69,8 @@ public function renderWebpackScriptTags(string $entryName, string $packageName =
6169
'<script %s></script>',
6270
$this->convertArrayToAttributes($attributes)
6371
);
72+
73+
$this->renderedFiles['scripts'][] = $attributes['src'];
6474
}
6575

6676
return implode('', $scriptTags);
@@ -85,11 +95,36 @@ public function renderWebpackLinkTags(string $entryName, string $packageName = n
8595
'<link %s>',
8696
$this->convertArrayToAttributes($attributes)
8797
);
98+
99+
$this->renderedFiles['styles'][] = $attributes['href'];
88100
}
89101

90102
return implode('', $scriptTags);
91103
}
92104

105+
public function getRenderedScripts(): array
106+
{
107+
return $this->renderedFiles['scripts'];
108+
}
109+
110+
public function getRenderedStyles(): array
111+
{
112+
return $this->renderedFiles['styles'];
113+
}
114+
115+
public function getDefaultAttributes(): array
116+
{
117+
return $this->defaultAttributes;
118+
}
119+
120+
public function reset()
121+
{
122+
$this->renderedFiles = [
123+
'scripts' => [],
124+
'styles' => [],
125+
];
126+
}
127+
93128
private function getAssetPath(string $assetPath, string $packageName = null): string
94129
{
95130
if (null === $this->packages) {

src/DependencyInjection/Configuration.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ public function getConfigTreeBuilder()
3939
->values([false, 'anonymous', 'use-credentials'])
4040
->info('crossorigin value when Encore.enableIntegrityHashes() is used, can be false (default), anonymous or use-credentials')
4141
->end()
42+
->booleanNode('preload')
43+
->info('preload all rendered script and link tags automatically via the http2 Link header.')
44+
->defaultFalse()
45+
->end()
4246
->booleanNode('cache')
4347
->info('Enable caching of the entry point file(s)')
4448
->defaultFalse()

src/DependencyInjection/WebpackEncoreExtension.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
1818
use Symfony\Component\DependencyInjection\Reference;
1919
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
20+
use Symfony\Component\WebLink\EventListener\AddLinkHeaderListener;
2021
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookup;
2122
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
2223

@@ -62,6 +63,14 @@ public function load(array $configs, ContainerBuilder $container)
6263

6364
$container->getDefinition('webpack_encore.tag_renderer')
6465
->replaceArgument(2, $defaultAttributes);
66+
67+
if ($config['preload']) {
68+
if (!class_exists(AddLinkHeaderListener::class)) {
69+
throw new \LogicException('To use the "preload" option, the WebLink component must be installed. Try running "composer require symfony/web-link".');
70+
}
71+
} else {
72+
$container->removeDefinition('webpack_encore.preload_assets_event_listener');
73+
}
6574
}
6675

6776
private function entrypointFactory(ContainerBuilder $container, string $name, string $path, bool $cacheEnabled, bool $strictMode): Reference
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony WebpackEncoreBundle package.
5+
* (c) Fabien Potencier <[email protected]>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace Symfony\WebpackEncoreBundle\EventListener;
11+
12+
use Fig\Link\GenericLinkProvider;
13+
use Fig\Link\Link;
14+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15+
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
16+
use Symfony\WebpackEncoreBundle\Asset\TagRenderer;
17+
18+
/**
19+
* @author Ryan Weaver <[email protected]>
20+
*/
21+
class PreLoadAssetsEventListener implements EventSubscriberInterface
22+
{
23+
private $tagRenderer;
24+
25+
public function __construct(TagRenderer $tagRenderer)
26+
{
27+
$this->tagRenderer = $tagRenderer;
28+
}
29+
30+
public function onKernelResponse(FilterResponseEvent $event)
31+
{
32+
if (!$event->isMasterRequest()) {
33+
return;
34+
}
35+
36+
$request = $event->getRequest();
37+
38+
if (null === $linkProvider = $request->attributes->get('_links')) {
39+
$request->attributes->set('_links', new GenericLinkProvider());
40+
}
41+
42+
/** @var GenericLinkProvider $linkProvider */
43+
$linkProvider = $request->attributes->get('_links');
44+
$defaultAttributes = $this->tagRenderer->getDefaultAttributes();
45+
$crossOrigin = $defaultAttributes['crossorigin'] ?? false;
46+
47+
foreach ($this->tagRenderer->getRenderedScripts() as $href) {
48+
$link = (new Link('preload', $href))->withAttribute('as', 'script');
49+
50+
if (false !== $crossOrigin) {
51+
$link = $link->withAttribute('crossorigin', $crossOrigin);
52+
}
53+
54+
$linkProvider = $linkProvider->withLink($link);
55+
}
56+
57+
foreach ($this->tagRenderer->getRenderedStyles() as $href) {
58+
$link = (new Link('preload', $href))->withAttribute('as', 'style');
59+
60+
if (false !== $crossOrigin) {
61+
$link = $link->withAttribute('crossorigin', $crossOrigin);
62+
}
63+
64+
$linkProvider = $linkProvider->withLink($link);
65+
}
66+
67+
$request->attributes->set('_links', $linkProvider);
68+
}
69+
70+
public static function getSubscribedEvents()
71+
{
72+
return [
73+
// must run before AddLinkHeaderListener
74+
'kernel.response' => ['onKernelResponse', 50],
75+
];
76+
}
77+
}

src/Resources/config/services.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,10 @@
4848
<service id="cache.webpack_encore" parent="cache.system" public="false">
4949
<tag name="cache.pool" />
5050
</service>
51+
52+
<service id="webpack_encore.preload_assets_event_listener" class="Symfony\WebpackEncoreBundle\EventListener\PreLoadAssetsEventListener">
53+
<tag name="kernel.event_subscriber" />
54+
<argument type="service" id="webpack_encore.tag_renderer" />
55+
</service>
5156
</services>
5257
</container>

tests/Asset/TagRendererTest.php

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public function testRenderScriptTagsWithDefaultAttributes()
4040
->willReturnCallback(function ($path) {
4141
return 'http://localhost:8080'.$path;
4242
});
43-
$renderer = new TagRenderer($entrypointCollection, $packages, []);
43+
$renderer = new TagRenderer($entrypointCollection, $packages, []);
4444

4545
$output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package');
4646
$this->assertContains(
@@ -71,7 +71,7 @@ public function testRenderScriptTagsWithBadFilename()
7171
->willReturnCallback(function ($path) {
7272
return 'http://localhost:8080'.$path;
7373
});
74-
$renderer = new TagRenderer($entrypointCollection, $packages, ['crossorigin'=>'anonymous']);
74+
$renderer = new TagRenderer($entrypointCollection, $packages, ['crossorigin' => 'anonymous']);
7575

7676
$output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package');
7777
$this->assertContains(
@@ -117,7 +117,7 @@ public function testRenderScriptTagsWithinAnEntryPointCollection()
117117
->willReturnCallback(function ($path) {
118118
return 'http://localhost:8080'.$path;
119119
});
120-
$renderer = new TagRenderer($entrypointCollection, $packages, ['crossorigin'=>'anonymous']);
120+
$renderer = new TagRenderer($entrypointCollection, $packages, ['crossorigin' => 'anonymous']);
121121

122122
$output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package');
123123
$this->assertContains(
@@ -167,7 +167,7 @@ public function testRenderScriptTagsWithHashes()
167167
->willReturnCallback(function ($path) {
168168
return 'http://localhost:8080'.$path;
169169
});
170-
$renderer = new TagRenderer($entrypointCollection, $packages, ['crossorigin'=>'anonymous']);
170+
$renderer = new TagRenderer($entrypointCollection, $packages, ['crossorigin' => 'anonymous']);
171171

172172
$output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package');
173173
$this->assertContains(
@@ -179,4 +179,36 @@ public function testRenderScriptTagsWithHashes()
179179
$output
180180
);
181181
}
182+
183+
public function testGetRenderedFilesAndReset()
184+
{
185+
$entrypointLookup = $this->createMock(EntrypointLookupInterface::class);
186+
$entrypointLookup->expects($this->once())
187+
->method('getJavaScriptFiles')
188+
->willReturn(['/build/file1.js', '/build/file2.js']);
189+
$entrypointLookup->expects($this->once())
190+
->method('getCssFiles')
191+
->willReturn(['/build/file1.css']);
192+
$entrypointCollection = $this->createMock(EntrypointLookupCollection::class);
193+
$entrypointCollection->expects($this->any())
194+
->method('getEntrypointLookup')
195+
->willReturn($entrypointLookup);
196+
197+
$packages = $this->createMock(Packages::class);
198+
$packages->expects($this->any())
199+
->method('getUrl')
200+
->willReturnCallback(function ($path) {
201+
return 'http://localhost:8080'.$path;
202+
});
203+
$renderer = new TagRenderer($entrypointCollection, $packages);
204+
205+
$renderer->renderWebpackScriptTags('my_entry');
206+
$renderer->renderWebpackLinkTags('my_entry');
207+
$this->assertSame(['http://localhost:8080/build/file1.js', 'http://localhost:8080/build/file2.js'], $renderer->getRenderedScripts());
208+
$this->assertSame(['http://localhost:8080/build/file1.css'], $renderer->getRenderedStyles());
209+
210+
$renderer->reset();
211+
$this->assertEmpty($renderer->getRenderedScripts());
212+
$this->assertEmpty($renderer->getRenderedStyles());
213+
}
182214
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony WebpackEncoreBundle package.
5+
* (c) Fabien Potencier <[email protected]>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace Symfony\WebpackEncoreBundle\Tests\Asset;
11+
12+
use Fig\Link\GenericLinkProvider;
13+
use Fig\Link\Link;
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpFoundation\Response;
16+
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
17+
use Symfony\Component\HttpKernel\HttpKernelInterface;
18+
use PHPUnit\Framework\TestCase;
19+
use Symfony\WebpackEncoreBundle\Asset\TagRenderer;
20+
use Symfony\WebpackEncoreBundle\EventListener\PreLoadAssetsEventListener;
21+
22+
class PreLoadAssetsEventListenerTest extends TestCase
23+
{
24+
public function testItPreloadsAssets()
25+
{
26+
$tagRenderer = $this->createMock(TagRenderer::class);
27+
$tagRenderer->expects($this->once())->method('getDefaultAttributes')->willReturn(['crossorigin' => 'anonymous']);
28+
$tagRenderer->expects($this->once())->method('getRenderedScripts')->willReturn(['/file1.js']);
29+
$tagRenderer->expects($this->once())->method('getRenderedStyles')->willReturn(['/css/file1.css']);
30+
31+
$request = new Request();
32+
$response = new Response();
33+
$event = new FilterResponseEvent(
34+
$this->createMock(HttpKernelInterface::class),
35+
$request,
36+
HttpKernelInterface::MASTER_REQUEST,
37+
$response
38+
);
39+
$listener = new PreLoadAssetsEventListener($tagRenderer);
40+
$listener->onKernelResponse($event);
41+
$this->assertTrue($request->attributes->has('_links'));
42+
/** @var GenericLinkProvider $linkProvider */
43+
$linkProvider = $request->attributes->get('_links');
44+
$this->assertInstanceOf(GenericLinkProvider::class, $linkProvider);
45+
/** @var Link[] $links */
46+
$links = array_values($linkProvider->getLinks());
47+
$this->assertCount(2, $links);
48+
$this->assertSame('/file1.js', $links[0]->getHref());
49+
$this->assertSame(['preload'], $links[0]->getRels());
50+
$this->assertSame(['as' => 'script', 'crossorigin' => 'anonymous'], $links[0]->getAttributes());
51+
52+
$this->assertSame('/css/file1.css', $links[1]->getHref());
53+
$this->assertSame(['preload'], $links[1]->getRels());
54+
$this->assertSame(['as' => 'style', 'crossorigin' => 'anonymous'], $links[1]->getAttributes());
55+
}
56+
57+
public function testItReusesExistingLinkProvider()
58+
{
59+
$tagRenderer = $this->createMock(TagRenderer::class);
60+
$tagRenderer->expects($this->once())->method('getDefaultAttributes')->willReturn(['crossorigin' => 'anonymous']);
61+
$tagRenderer->expects($this->once())->method('getRenderedScripts')->willReturn(['/file1.js']);
62+
$tagRenderer->expects($this->once())->method('getRenderedStyles')->willReturn([]);
63+
64+
$request = new Request();
65+
$linkProvider = new GenericLinkProvider([new Link('preload', 'bar.js')]);
66+
$request->attributes->set('_links', $linkProvider);
67+
68+
$response = new Response();
69+
$event = new FilterResponseEvent(
70+
$this->createMock(HttpKernelInterface::class),
71+
$request,
72+
HttpKernelInterface::MASTER_REQUEST,
73+
$response
74+
);
75+
$listener = new PreLoadAssetsEventListener($tagRenderer);
76+
$listener->onKernelResponse($event);
77+
/** @var GenericLinkProvider $linkProvider */
78+
$linkProvider = $request->attributes->get('_links');
79+
$this->assertCount(2, $linkProvider->getLinks());
80+
}
81+
82+
public function testItDoesNothingOnSubRequest()
83+
{
84+
$tagRenderer = $this->createMock(TagRenderer::class);
85+
$tagRenderer->expects($this->never())->method('getDefaultAttributes');
86+
$tagRenderer->expects($this->never())->method('getRenderedScripts');
87+
88+
$request = new Request();
89+
$response = new Response();
90+
$event = new FilterResponseEvent(
91+
$this->createMock(HttpKernelInterface::class),
92+
$request,
93+
HttpKernelInterface::SUB_REQUEST,
94+
$response
95+
);
96+
$listener = new PreLoadAssetsEventListener($tagRenderer);
97+
$listener->onKernelResponse($event);
98+
}
99+
}

0 commit comments

Comments
 (0)