Skip to content

Commit a2d7a95

Browse files
committed
Automatically preload assets that are required by other preloaded assets
1 parent c00f3b3 commit a2d7a95

File tree

10 files changed

+164
-36
lines changed

10 files changed

+164
-36
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper;
13+
14+
/**
15+
* Represents a dependency that a MappedAsset has.
16+
*/
17+
class AssetDependency
18+
{
19+
public function __construct(
20+
public readonly MappedAsset $asset,
21+
/**
22+
* @var bool Whether this dependency is immediately needed.
23+
*/
24+
public readonly bool $isLazy,
25+
)
26+
{
27+
}
28+
}

src/Symfony/Component/AssetMapper/Compiler/JavaScriptImportPathCompiler.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ class JavaScriptImportPathCompiler implements AssetCompilerInterface
2121
{
2222
use AssetCompilerPathResolverTrait;
2323

24-
private const IMPORT_PATTERN = '/(?:import(?:\s+\w+)?\s*(?:from)?\s*|\bimport\()\s*[\'"](\.\/[^\'"]+|(\.\.\/)+[^\'"]+)[\'"]\s*[;\)]?/m';
24+
// https://regex101.com/r/VFdR4H/1
25+
private const IMPORT_PATTERN = '/(?:import\s+(?:(?:\*\s+as\s+\w+|[\w\s{},*]+)\s+from\s+)?|\bimport\()\s*[\'"`](\.\/[^\'"`]+|(\.\.\/)+[^\'"`]+)[\'"`]\s*[;\)]?/m';
2526

2627
public function compile(string $content, MappedAsset $asset, AssetMapper $assetMapper): string
2728
{
@@ -45,7 +46,9 @@ public function compile(string $content, MappedAsset $asset, AssetMapper $assetM
4546
return $matches[0];
4647
}
4748

48-
$asset->addDependency($dependentAsset);
49+
$isLazy = str_contains($matches[0], 'import(');
50+
51+
$asset->addDependency($dependentAsset, $isLazy);
4952

5053
if ($resolvedPath === $finalResolvedPath) {
5154
// no change to the path, so just return the original import

src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\AssetMapper\ImportMap;
1313

14+
use Symfony\Component\AssetMapper\AssetDependency;
1415
use Symfony\Component\AssetMapper\AssetMapper;
1516
use Symfony\Component\AssetMapper\MappedAsset;
1617
use Symfony\Component\HttpClient\HttpClient;
@@ -374,8 +375,12 @@ private function convertEntriesToImports(array $entries): array
374375
$this->modulesToPreload[] = $path;
375376
}
376377

377-
$dependencyImportMapEntries = array_map(function (MappedAsset $asset) {
378-
return new ImportMapEntry($asset->getPublicPathWithoutDigest(), $asset->getLogicalPath());
378+
$dependencyImportMapEntries = array_map(function (AssetDependency $dependency) {
379+
return new ImportMapEntry(
380+
$dependency->asset->getPublicPathWithoutDigest(),
381+
$dependency->asset->getLogicalPath(),
382+
preload: !$dependency->isLazy,
383+
);
379384
}, $dependencies);
380385
$imports = array_merge($imports, $this->convertEntriesToImports($dependencyImportMapEntries));
381386
}

src/Symfony/Component/AssetMapper/MappedAsset.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111

1212
namespace Symfony\Component\AssetMapper;
1313

14+
/**
15+
* Represents a single asset in the asset mapper system.
16+
*
17+
* @author Ryan Weaver <ryan@symfonycasts.com>
18+
*/
1419
class MappedAsset
1520
{
1621

@@ -20,7 +25,7 @@ class MappedAsset
2025
private string $digest;
2126
private bool $isPredigested;
2227
private ?string $mimeType;
23-
/** @var MappedAsset[] */
28+
/** @var AssetDependency[] */
2429
private array $dependencies = [];
2530

2631
public function __construct(private string $logicalPath)
@@ -90,6 +95,9 @@ public function getMimeType(): string
9095
return $this->mimeType;
9196
}
9297

98+
/**
99+
* @return AssetDependency[]
100+
*/
93101
public function getDependencies(): array
94102
{
95103
return $this->dependencies;
@@ -141,14 +149,13 @@ public function setContent(string $content): void
141149
$this->content = $content;
142150
}
143151

144-
public function addDependency(MappedAsset $asset): void
152+
public function addDependency(MappedAsset $asset, bool $isLazy = false): void
145153
{
146-
$this->dependencies[] = $asset;
154+
$this->dependencies[] = new AssetDependency($asset, $isLazy);
147155
}
148156

149157
public function getPublicPathWithoutDigest(): string
150158
{
151-
// TODO: should we pass this in instead of trying to calculate it?
152159
if ($this->isPredigested) {
153160
return $this->publicPath;
154161
}

src/Symfony/Component/AssetMapper/Tests/Compiler/CssAssetUrlCompilerTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\AssetMapper\Tests\Compiler;
1313

1414
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\AssetMapper\AssetDependency;
1516
use Symfony\Component\AssetMapper\AssetMapper;
1617
use Symfony\Component\AssetMapper\Compiler\CssAssetUrlCompiler;
1718
use Symfony\Component\AssetMapper\MappedAsset;
@@ -46,7 +47,7 @@ public function testCompile(string $sourceLogicalName, string $input, string $ex
4647
$compiler = new CssAssetUrlCompiler();
4748
$asset = new MappedAsset($sourceLogicalName);
4849
$this->assertSame($expectedOutput, $compiler->compile($input, $asset, $assetMapper));
49-
$assetDependencyLogicalPaths = array_map(fn (MappedAsset $asset) => $asset->getLogicalPath(), $asset->getDependencies());
50+
$assetDependencyLogicalPaths = array_map(fn (AssetDependency $dependency) => $dependency->asset->getLogicalPath(), $asset->getDependencies());
5051
$this->assertSame($expectedDependencies, $assetDependencyLogicalPaths);
5152
}
5253

src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php

Lines changed: 95 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\AssetMapper\Tests\Compiler;
1313

1414
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\AssetMapper\AssetDependency;
1516
use Symfony\Component\AssetMapper\AssetMapper;
1617
use Symfony\Component\AssetMapper\Compiler\JavaScriptImportPathCompiler;
1718
use Symfony\Component\AssetMapper\MappedAsset;
@@ -43,20 +44,23 @@ public function testCompile(string $sourceLogicalName, string $input, string $ex
4344

4445
$compiler = new JavaScriptImportPathCompiler();
4546
$this->assertSame($expectedOutput, $compiler->compile($input, $asset, $assetMapper));
46-
$assetDependencyLogicalPaths = array_map(fn (MappedAsset $asset) => $asset->getLogicalPath(), $asset->getDependencies());
47-
$this->assertEquals($expectedDependencies, $assetDependencyLogicalPaths);
47+
$actualDependencies = [];
48+
foreach ($asset->getDependencies() as $dependency) {
49+
$actualDependencies[$dependency->asset->getLogicalPath()] = $dependency->isLazy;
50+
}
51+
$this->assertEquals($expectedDependencies, $actualDependencies);
4852
}
4953

5054
public function provideCompileTests(): iterable
5155
{
52-
yield 'simple_double_quotes' => [
56+
yield 'dynamic_simple_double_quotes' => [
5357
'sourceLogicalName' => 'app.js',
5458
'input' => 'import("./other.js");',
5559
'expectedOutput' => 'import("./other.js");',
56-
'expectedDependencies' => ['other.js']
60+
'expectedDependencies' => ['other.js' => true]
5761
];
5862

59-
yield 'simple_multiline' => [
63+
yield 'dynamic_simple_multiline' => [
6064
'sourceLogicalName' => 'app.js',
6165
'input' => <<<EOF
6266
const fun;
@@ -68,63 +72,133 @@ public function provideCompileTests(): iterable
6872
import("./other.js");
6973
EOF
7074
,
71-
'expectedDependencies' => ['other.js']
75+
'expectedDependencies' => ['other.js' => true]
7276
];
7377

74-
yield 'simple_single_quotes' => [
78+
yield 'dynamic_simple_single_quotes' => [
7579
'sourceLogicalName' => 'app.js',
7680
'input' => 'import(\'./other.js\');',
7781
'expectedOutput' => 'import(\'./other.js\');',
78-
'expectedDependencies' => ['other.js']
82+
'expectedDependencies' => ['other.js' => true]
7983
];
8084

81-
yield 'resolves_without_extension' => [
85+
yield 'dynamic_simple_tick_quotes' => [
86+
'sourceLogicalName' => 'app.js',
87+
'input' => 'import(`./other`);',
88+
'expectedOutput' => 'import(`./other.js`);',
89+
'expectedDependencies' => ['other.js' => true]
90+
];
91+
92+
yield 'dynamic_resolves_without_extension' => [
8293
'sourceLogicalName' => 'app.js',
8394
'input' => 'import("./other");',
8495
'expectedOutput' => 'import("./other.js");',
85-
'expectedDependencies' => ['other.js'],
96+
'expectedDependencies' => ['other.js' => true],
8697
];
8798

88-
yield 'resolves_multiple' => [
99+
yield 'dynamic_resolves_multiple' => [
89100
'sourceLogicalName' => 'app.js',
90101
'input' => 'import("./other.js"); import("./subdir/foo.js");',
91102
'expectedOutput' => 'import("./other.js"); import("./subdir/foo.js");',
92-
'expectedDependencies' => ['other.js', 'subdir/foo.js'],
103+
'expectedDependencies' => ['other.js' => true, 'subdir/foo.js' => true],
93104
];
94105

95-
yield 'avoid_resolving_non_relative_imports' => [
106+
yield 'dynamic_avoid_resolving_non_relative_imports' => [
96107
'sourceLogicalName' => 'app.js',
97108
'input' => 'import("other.js");',
98109
'expectedOutput' => 'import("other.js");',
99110
'expectedDependencies' => [],
100111
];
101112

102-
yield 'resolves_dynamic_imports_later_in_file' => [
113+
yield 'dynamic_resolves_dynamic_imports_later_in_file' => [
103114
'sourceLogicalName' => 'app.js',
104115
'input' => 'console.log("Hello test!") import("./subdir/foo.js").then(() => console.log("inside promise!"));',
105116
'expectedOutput' => 'console.log("Hello test!") import("./subdir/foo.js").then(() => console.log("inside promise!"));',
106-
'expectedDependencies' => ['subdir/foo.js'],
117+
'expectedDependencies' => ['subdir/foo.js' => true],
107118
];
108119

109-
yield 'correctly_moves_to_higher_directories' => [
120+
yield 'dynamic_correctly_moves_to_higher_directories' => [
110121
'sourceLogicalName' => 'subdir/app.js',
111122
'input' => 'import("../other.js");',
112123
'expectedOutput' => 'import("../other.js");',
113-
'expectedDependencies' => ['other.js'],
124+
'expectedDependencies' => ['other.js' => true],
114125
];
115126

116-
yield 'correctly_moves_to_higher_directories_and_adds_extension' => [
127+
yield 'dynamic_correctly_moves_to_higher_directories_and_adds_extension' => [
117128
'sourceLogicalName' => 'subdir/app.js',
118129
'input' => 'import("../other");',
119130
'expectedOutput' => 'import("../other.js");',
120-
'expectedDependencies' => ['other.js'],
131+
'expectedDependencies' => ['other.js' => true],
121132
];
122133

123-
yield 'resolves_the_index_file_in_directory' => [
134+
yield 'dynamic_resolves_the_index_file_in_directory' => [
124135
'sourceLogicalName' => 'app.js',
125136
'input' => 'import("./dir_with_index");',
126137
'expectedOutput' => 'import("./dir_with_index/index.js");',
127-
'expectedDependencies' => ['dir_with_index/index.js'],
138+
'expectedDependencies' => ['dir_with_index/index.js' => true],
139+
];
140+
141+
yield 'static_named_import_double_quotes' => [
142+
'sourceLogicalName' => 'app.js',
143+
'input' => 'import { myFunction } from "./other";',
144+
'expectedOutput' => 'import { myFunction } from "./other.js";',
145+
'expectedDependencies' => ['other.js' => false],
146+
];
147+
148+
yield 'static_named_import_single_quotes' => [
149+
'sourceLogicalName' => 'app.js',
150+
'input' => 'import { myFunction } from \'./other\';',
151+
'expectedOutput' => 'import { myFunction } from \'./other.js\';',
152+
'expectedDependencies' => ['other.js' => false],
153+
];
154+
155+
yield 'static_default_import' => [
156+
'sourceLogicalName' => 'app.js',
157+
'input' => 'import myFunction from "./other";',
158+
'expectedOutput' => 'import myFunction from "./other.js";',
159+
'expectedDependencies' => ['other.js' => false],
160+
];
161+
162+
yield 'static_default_and_named_import' => [
163+
'sourceLogicalName' => 'app.js',
164+
'input' => 'import myFunction, { helperFunction } from "./other";',
165+
'expectedOutput' => 'import myFunction, { helperFunction } from "./other.js";',
166+
'expectedDependencies' => ['other.js' => false],
167+
];
168+
169+
yield 'static_import_everything' => [
170+
'sourceLogicalName' => 'app.js',
171+
'input' => 'import * as myModule from "./other";',
172+
'expectedOutput' => 'import * as myModule from "./other.js";',
173+
'expectedDependencies' => ['other.js' => false],
174+
];
175+
176+
yield 'static_import_just_for_side_effects' => [
177+
'sourceLogicalName' => 'app.js',
178+
'input' => 'import "./other";',
179+
'expectedOutput' => 'import "./other.js";',
180+
'expectedDependencies' => ['other.js' => false],
181+
];
182+
183+
yield 'mix_of_static_and_dynamic_imports' => [
184+
'sourceLogicalName' => 'app.js',
185+
'input' => 'import "./other"; import("./subdir/foo.js");',
186+
'expectedOutput' => 'import "./other.js"; import("./subdir/foo.js");',
187+
'expectedDependencies' => ['other.js' => false, 'subdir/foo.js' => true],
188+
];
189+
190+
yield 'extra_import_word_does_not_cause_issues' => [
191+
'sourceLogicalName' => 'app.js',
192+
'input' => "// about to do an import\nimport('./other');",
193+
'expectedOutput' => "// about to do an import\nimport('./other.js');",
194+
'expectedDependencies' => ['other.js' => true],
195+
];
196+
197+
yield 'import_on_one_line_then_module_name_on_next_is_ok' => [
198+
'sourceLogicalName' => 'app.js',
199+
'input' => "import \n './other';",
200+
'expectedOutput' => "import \n './other.js';",
201+
'expectedDependencies' => ['other.js' => false],
128202
];
129203
}
130204
}

src/Symfony/Component/AssetMapper/Tests/Compiler/SourceMappingUrlsCompilerTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\AssetMapper\Tests\Compiler;
1313

1414
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\AssetMapper\AssetDependency;
1516
use Symfony\Component\AssetMapper\AssetMapper;
1617
use Symfony\Component\AssetMapper\Compiler\SourceMappingUrlsCompiler;
1718
use Symfony\Component\AssetMapper\MappedAsset;
@@ -46,7 +47,7 @@ public function testCompile(string $sourceLogicalName, string $input, string $ex
4647
$compiler = new SourceMappingUrlsCompiler();
4748
$asset = new MappedAsset($sourceLogicalName);
4849
$this->assertSame($expectedOutput, $compiler->compile($input, $asset, $assetMapper));
49-
$assetDependencyLogicalPaths = array_map(fn (MappedAsset $asset) => $asset->getLogicalPath(), $asset->getDependencies());
50+
$assetDependencyLogicalPaths = array_map(fn (AssetDependency $dependency) => $dependency->asset->getLogicalPath(), $asset->getDependencies());
5051
$this->assertSame($expectedDependencies, $assetDependencyLogicalPaths);
5152
}
5253

src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapManagerTest.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
use Symfony\Component\HttpClient\MockHttpClient;
1414
use Symfony\Component\HttpClient\Response\MockResponse;
1515
use Symfony\Component\Mime\MimeTypesInterface;
16-
use Symfony\Contracts\HttpClient\HttpClientInterface;
1716

1817
class ImportMapManagerTest extends TestCase
1918
{
@@ -42,7 +41,10 @@ public function testGetModulesToPreload()
4241
);
4342
$this->assertEquals([
4443
'https://unpkg.com/@hotwired/stimulus@3.2.1/dist/stimulus.js',
45-
'/assets/app-2ed6cde5b51e6acf8a94964885ce35f4.js',
44+
'/assets/app-ea9ebe6156adc038aba53164e2be0867.js',
45+
// these are non-lazily imported from app.js
46+
'/assets/pizza/index-b3fb5ee31adaf5e1b32d28edf1ab8e7a.js',
47+
'/assets/popcorn-c0778b84ef9893592385aebc95a2896e.js',
4648
], $manager->getModulesToPreload());
4749
}
4850

@@ -55,9 +57,10 @@ public function testGetImportMapJson()
5557
$this->assertEquals(['imports' => [
5658
'@hotwired/stimulus' => 'https://unpkg.com/@hotwired/stimulus@3.2.1/dist/stimulus.js',
5759
'lodash' => '/assets/vendor/lodash-ad7bd7bf42edd09654255a82b9027810.js',
58-
'app' => '/assets/app-2ed6cde5b51e6acf8a94964885ce35f4.js',
60+
'app' => '/assets/app-ea9ebe6156adc038aba53164e2be0867.js',
5961
'/assets/pizza/index.js' => '/assets/pizza/index-b3fb5ee31adaf5e1b32d28edf1ab8e7a.js',
6062
'/assets/popcorn.js' => '/assets/popcorn-c0778b84ef9893592385aebc95a2896e.js',
63+
'/assets/imported_async.js' => '/assets/imported_async-8f0cd418bfeb0cf63826e09a4474a81c.js',
6164
'other_app' => '/assets/namespaced_assets2/app2-344d0d513d424647e7d8a394ffe5e4b5.js',
6265
]], json_decode($manager->getImportMapJson(), true));
6366
}
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
1-
import './pizza';
2-
import './popcorn';
1+
import
2+
'./pizza';
3+
import Popcorn from './popcorn';
4+
5+
import('./imported_async').then(() => {
6+
console.log('async import done');
7+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
console.log('imported_async.js');

0 commit comments

Comments
 (0)