Skip to content

Commit

Permalink
feature #61 New preload option to automatically set preload Link head…
Browse files Browse the repository at this point in the history
…er 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
  • Loading branch information
weaverryan committed May 26, 2019
2 parents 81db0d5 + 0778950 commit f1ec2fe
Show file tree
Hide file tree
Showing 10 changed files with 342 additions and 35 deletions.
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
"symfony/framework-bundle": "^3.4 || ^4.0",
"symfony/phpunit-bridge": "^3.4 || ^4.1",
"symfony/twig-bundle": "^3.4 || ^4.0",
"twig/twig": "^1.35 || ^2.0"
"twig/twig": "^1.35 || ^2.0",
"symfony/web-link": "^3.4 || ^4.0",
"fig/link-util": "^1.0"
},
"extra": {
"thanks": {
Expand Down
2 changes: 2 additions & 0 deletions src/Asset/EntrypointLookup.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ class EntrypointLookup implements EntrypointLookupInterface, IntegrityDataProvid

private $cache;

private $cacheKey;

private $strictMode;

public function __construct(string $entrypointJsonPath, CacheItemPoolInterface $cache = null, string $cacheKey = null, bool $strictMode = true)
Expand Down
37 changes: 36 additions & 1 deletion src/Asset/TagRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,21 @@

use Symfony\Component\Asset\Packages;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Contracts\Service\ResetInterface;

final class TagRenderer
/**
* @final
*/
class TagRenderer implements ResetInterface
{
private $entrypointLookupCollection;

private $packages;

private $defaultAttributes;

private $renderedFiles = [];

public function __construct(
$entrypointLookupCollection,
Packages $packages,
Expand All @@ -41,6 +47,8 @@ public function __construct(

$this->packages = $packages;
$this->defaultAttributes = $defaultAttributes;

$this->reset();
}

public function renderWebpackScriptTags(string $entryName, string $packageName = null, string $entrypointName = '_default'): string
Expand All @@ -61,6 +69,8 @@ public function renderWebpackScriptTags(string $entryName, string $packageName =
'<script %s></script>',
$this->convertArrayToAttributes($attributes)
);

$this->renderedFiles['scripts'][] = $attributes['src'];
}

return implode('', $scriptTags);
Expand All @@ -85,11 +95,36 @@ public function renderWebpackLinkTags(string $entryName, string $packageName = n
'<link %s>',
$this->convertArrayToAttributes($attributes)
);

$this->renderedFiles['styles'][] = $attributes['href'];
}

return implode('', $scriptTags);
}

public function getRenderedScripts(): array
{
return $this->renderedFiles['scripts'];
}

public function getRenderedStyles(): array
{
return $this->renderedFiles['styles'];
}

public function getDefaultAttributes(): array
{
return $this->defaultAttributes;
}

public function reset()
{
$this->renderedFiles = [
'scripts' => [],
'styles' => [],
];
}

private function getAssetPath(string $assetPath, string $packageName = null): string
{
if (null === $this->packages) {
Expand Down
4 changes: 4 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ public function getConfigTreeBuilder()
->values([false, 'anonymous', 'use-credentials'])
->info('crossorigin value when Encore.enableIntegrityHashes() is used, can be false (default), anonymous or use-credentials')
->end()
->booleanNode('preload')
->info('preload all rendered script and link tags automatically via the http2 Link header.')
->defaultFalse()
->end()
->booleanNode('cache')
->info('Enable caching of the entry point file(s)')
->defaultFalse()
Expand Down
9 changes: 9 additions & 0 deletions src/DependencyInjection/WebpackEncoreExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\WebLink\EventListener\AddLinkHeaderListener;
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookup;
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;

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

$container->getDefinition('webpack_encore.tag_renderer')
->replaceArgument(2, $defaultAttributes);

if ($config['preload']) {
if (!class_exists(AddLinkHeaderListener::class)) {
throw new \LogicException('To use the "preload" option, the WebLink component must be installed. Try running "composer require symfony/web-link".');
}
} else {
$container->removeDefinition('webpack_encore.preload_assets_event_listener');
}
}

private function entrypointFactory(ContainerBuilder $container, string $name, string $path, bool $cacheEnabled, bool $strictMode): Reference
Expand Down
77 changes: 77 additions & 0 deletions src/EventListener/PreLoadAssetsEventListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

/*
* This file is part of the Symfony WebpackEncoreBundle package.
* (c) Fabien Potencier <fabien@symfony.com>
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\WebpackEncoreBundle\EventListener;

use Fig\Link\GenericLinkProvider;
use Fig\Link\Link;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\WebpackEncoreBundle\Asset\TagRenderer;

/**
* @author Ryan Weaver <ryan@symfonycasts.com>
*/
class PreLoadAssetsEventListener implements EventSubscriberInterface
{
private $tagRenderer;

public function __construct(TagRenderer $tagRenderer)
{
$this->tagRenderer = $tagRenderer;
}

public function onKernelResponse(FilterResponseEvent $event)
{
if (!$event->isMasterRequest()) {
return;
}

$request = $event->getRequest();

if (null === $linkProvider = $request->attributes->get('_links')) {
$request->attributes->set('_links', new GenericLinkProvider());
}

/** @var GenericLinkProvider $linkProvider */
$linkProvider = $request->attributes->get('_links');
$defaultAttributes = $this->tagRenderer->getDefaultAttributes();
$crossOrigin = $defaultAttributes['crossorigin'] ?? false;

foreach ($this->tagRenderer->getRenderedScripts() as $href) {
$link = (new Link('preload', $href))->withAttribute('as', 'script');

if (false !== $crossOrigin) {
$link = $link->withAttribute('crossorigin', $crossOrigin);
}

$linkProvider = $linkProvider->withLink($link);
}

foreach ($this->tagRenderer->getRenderedStyles() as $href) {
$link = (new Link('preload', $href))->withAttribute('as', 'style');

if (false !== $crossOrigin) {
$link = $link->withAttribute('crossorigin', $crossOrigin);
}

$linkProvider = $linkProvider->withLink($link);
}

$request->attributes->set('_links', $linkProvider);
}

public static function getSubscribedEvents()
{
return [
// must run before AddLinkHeaderListener
'kernel.response' => ['onKernelResponse', 50],
];
}
}
5 changes: 5 additions & 0 deletions src/Resources/config/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,10 @@
<service id="cache.webpack_encore" parent="cache.system" public="false">
<tag name="cache.pool" />
</service>

<service id="webpack_encore.preload_assets_event_listener" class="Symfony\WebpackEncoreBundle\EventListener\PreLoadAssetsEventListener">
<tag name="kernel.event_subscriber" />
<argument type="service" id="webpack_encore.tag_renderer" />
</service>
</services>
</container>
40 changes: 36 additions & 4 deletions tests/Asset/TagRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public function testRenderScriptTagsWithDefaultAttributes()
->willReturnCallback(function ($path) {
return 'http://localhost:8080'.$path;
});
$renderer = new TagRenderer($entrypointCollection, $packages, []);
$renderer = new TagRenderer($entrypointCollection, $packages, []);

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

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

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

$output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package');
$this->assertContains(
Expand All @@ -179,4 +179,36 @@ public function testRenderScriptTagsWithHashes()
$output
);
}

public function testGetRenderedFilesAndReset()
{
$entrypointLookup = $this->createMock(EntrypointLookupInterface::class);
$entrypointLookup->expects($this->once())
->method('getJavaScriptFiles')
->willReturn(['/build/file1.js', '/build/file2.js']);
$entrypointLookup->expects($this->once())
->method('getCssFiles')
->willReturn(['/build/file1.css']);
$entrypointCollection = $this->createMock(EntrypointLookupCollection::class);
$entrypointCollection->expects($this->any())
->method('getEntrypointLookup')
->willReturn($entrypointLookup);

$packages = $this->createMock(Packages::class);
$packages->expects($this->any())
->method('getUrl')
->willReturnCallback(function ($path) {
return 'http://localhost:8080'.$path;
});
$renderer = new TagRenderer($entrypointCollection, $packages);

$renderer->renderWebpackScriptTags('my_entry');
$renderer->renderWebpackLinkTags('my_entry');
$this->assertSame(['http://localhost:8080/build/file1.js', 'http://localhost:8080/build/file2.js'], $renderer->getRenderedScripts());
$this->assertSame(['http://localhost:8080/build/file1.css'], $renderer->getRenderedStyles());

$renderer->reset();
$this->assertEmpty($renderer->getRenderedScripts());
$this->assertEmpty($renderer->getRenderedStyles());
}
}
99 changes: 99 additions & 0 deletions tests/EventListener/PreLoadAssetsEventListenerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

/*
* This file is part of the Symfony WebpackEncoreBundle package.
* (c) Fabien Potencier <fabien@symfony.com>
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\WebpackEncoreBundle\Tests\Asset;

use Fig\Link\GenericLinkProvider;
use Fig\Link\Link;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use PHPUnit\Framework\TestCase;
use Symfony\WebpackEncoreBundle\Asset\TagRenderer;
use Symfony\WebpackEncoreBundle\EventListener\PreLoadAssetsEventListener;

class PreLoadAssetsEventListenerTest extends TestCase
{
public function testItPreloadsAssets()
{
$tagRenderer = $this->createMock(TagRenderer::class);
$tagRenderer->expects($this->once())->method('getDefaultAttributes')->willReturn(['crossorigin' => 'anonymous']);
$tagRenderer->expects($this->once())->method('getRenderedScripts')->willReturn(['/file1.js']);
$tagRenderer->expects($this->once())->method('getRenderedStyles')->willReturn(['/css/file1.css']);

$request = new Request();
$response = new Response();
$event = new FilterResponseEvent(
$this->createMock(HttpKernelInterface::class),
$request,
HttpKernelInterface::MASTER_REQUEST,
$response
);
$listener = new PreLoadAssetsEventListener($tagRenderer);
$listener->onKernelResponse($event);
$this->assertTrue($request->attributes->has('_links'));
/** @var GenericLinkProvider $linkProvider */
$linkProvider = $request->attributes->get('_links');
$this->assertInstanceOf(GenericLinkProvider::class, $linkProvider);
/** @var Link[] $links */
$links = array_values($linkProvider->getLinks());
$this->assertCount(2, $links);
$this->assertSame('/file1.js', $links[0]->getHref());
$this->assertSame(['preload'], $links[0]->getRels());
$this->assertSame(['as' => 'script', 'crossorigin' => 'anonymous'], $links[0]->getAttributes());

$this->assertSame('/css/file1.css', $links[1]->getHref());
$this->assertSame(['preload'], $links[1]->getRels());
$this->assertSame(['as' => 'style', 'crossorigin' => 'anonymous'], $links[1]->getAttributes());
}

public function testItReusesExistingLinkProvider()
{
$tagRenderer = $this->createMock(TagRenderer::class);
$tagRenderer->expects($this->once())->method('getDefaultAttributes')->willReturn(['crossorigin' => 'anonymous']);
$tagRenderer->expects($this->once())->method('getRenderedScripts')->willReturn(['/file1.js']);
$tagRenderer->expects($this->once())->method('getRenderedStyles')->willReturn([]);

$request = new Request();
$linkProvider = new GenericLinkProvider([new Link('preload', 'bar.js')]);
$request->attributes->set('_links', $linkProvider);

$response = new Response();
$event = new FilterResponseEvent(
$this->createMock(HttpKernelInterface::class),
$request,
HttpKernelInterface::MASTER_REQUEST,
$response
);
$listener = new PreLoadAssetsEventListener($tagRenderer);
$listener->onKernelResponse($event);
/** @var GenericLinkProvider $linkProvider */
$linkProvider = $request->attributes->get('_links');
$this->assertCount(2, $linkProvider->getLinks());
}

public function testItDoesNothingOnSubRequest()
{
$tagRenderer = $this->createMock(TagRenderer::class);
$tagRenderer->expects($this->never())->method('getDefaultAttributes');
$tagRenderer->expects($this->never())->method('getRenderedScripts');

$request = new Request();
$response = new Response();
$event = new FilterResponseEvent(
$this->createMock(HttpKernelInterface::class),
$request,
HttpKernelInterface::SUB_REQUEST,
$response
);
$listener = new PreLoadAssetsEventListener($tagRenderer);
$listener->onKernelResponse($event);
}
}

0 comments on commit f1ec2fe

Please sign in to comment.