Skip to content

Commit 3d71ac9

Browse files
wilrcursoragent
andcommitted
feat: modulepreload Vite import chunks
Emit link rel=modulepreload for JS files listed in a production entry's manifest imports so the browser can fetch Registry and vendor chunks in parallel with the entry instead of after it parses. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent cf64c06 commit 3d71ac9

4 files changed

Lines changed: 178 additions & 0 deletions

File tree

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,35 @@ automatically included in the page requirements, while JavaScript files will be
164164
loaded as modules. This is useful for including print stylesheets, page-specific
165165
styles, or additional JavaScript modules.
166166

167+
#### Modulepreload for imported chunks
168+
169+
Vite's production HTML is typically a tiny inline module:
170+
171+
```html
172+
<script type="module">import '/_resources/app/client/dist/index-….js'</script>
173+
```
174+
175+
Any file listed in that entry's `imports` in `manifest.json` (for example a
176+
`Registry` chunk, or a shared vendor chunk) is only discovered after `index.js`
177+
downloads and parses — an extra round trip on the critical path.
178+
179+
`ViteProvider` emits `<link rel="modulepreload">` for those JS imports (and for
180+
imports of any additional JS entries from `getAdditionalRequirements()`). CSS
181+
imports are unchanged: they still go through `Requirements::css()`. No project
182+
code is required beyond using `<% include Vite %>`.
183+
184+
If you render `Includes/ViteRequirements` yourself, pass `ModulePreloads` as well
185+
as `JSModules`:
186+
187+
```php
188+
return $this->renderWith('Includes/ViteRequirements', [
189+
'JSModules' => $jsModules,
190+
'ModulePreloads' => $this->getViteModulePreloads($manifest, [
191+
$this->getDefaultJsAsset(),
192+
]),
193+
]);
194+
```
195+
167196
### React components (SSR)
168197
169198
Tumu can optionally server-render React "islands" into Silverstripe templates,

src/Traits/ViteProvider.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ public function getIncludeViteBuiltRequirements(): string
241241
$resourcesPath = '/_resources/';
242242

243243
$jsModules = ArrayList::create();
244+
$jsEntryKeys = [$this->defaultJsAsset];
244245

245246
$jsModules->push(ArrayData::create([
246247
'Asset' => Controller::join_links($resourcesPath, $this->distPath, $manifest[$this->defaultJsAsset]['file'])
@@ -278,6 +279,7 @@ public function getIncludeViteBuiltRequirements(): string
278279
Requirements::css($this->distPath . $manifest[$asset]['file'], $media, $opts);
279280
}
280281
} elseif (isset($manifest[$asset])) {
282+
$jsEntryKeys[] = $asset;
281283
$jsModules->push(ArrayData::create([
282284
'Asset' => Controller::join_links(
283285
$resourcesPath,
@@ -306,10 +308,55 @@ public function getIncludeViteBuiltRequirements(): string
306308

307309
return $this->renderWith('Includes/ViteRequirements', [
308310
'JSModules' => $jsModules,
311+
'ModulePreloads' => $this->getViteModulePreloads($manifest, $jsEntryKeys),
309312
]);
310313
}
311314

312315

316+
/**
317+
* Build modulepreload hrefs for JS chunks listed in a Vite manifest
318+
* entry's `imports`. The browser otherwise discovers those chunks only
319+
* after the entry module downloads and parses.
320+
*
321+
* CSS imports are skipped — they are already included via {@link importCssAssets()}.
322+
*
323+
* @param array<string, mixed> $manifest
324+
* @param array<int, string> $entryKeys
325+
* @return ArrayList<ArrayData>
326+
*/
327+
public function getViteModulePreloads(array $manifest, array $entryKeys): ArrayList
328+
{
329+
$preloads = ArrayList::create();
330+
$seen = [];
331+
$resourcesPath = '/_resources/';
332+
333+
foreach ($entryKeys as $entryKey) {
334+
if (!isset($manifest[$entryKey]['imports']) || !is_array($manifest[$entryKey]['imports'])) {
335+
continue;
336+
}
337+
338+
foreach ($manifest[$entryKey]['imports'] as $importKey) {
339+
if (!isset($manifest[$importKey]['file']) || !is_string($manifest[$importKey]['file'])) {
340+
continue;
341+
}
342+
343+
$file = $manifest[$importKey]['file'];
344+
345+
if (!str_ends_with($file, '.js') || isset($seen[$file])) {
346+
continue;
347+
}
348+
349+
$seen[$file] = true;
350+
$preloads->push(ArrayData::create([
351+
'Asset' => Controller::join_links($resourcesPath, $this->distPath, $file),
352+
]));
353+
}
354+
}
355+
356+
return $preloads;
357+
}
358+
359+
313360
/**
314361
* @param array<string, array<string, string>> $manifest
315362
* @param string|null $media
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
<% if $ModulePreloads %><% loop $ModulePreloads %>
2+
<link rel="modulepreload" href="{$Asset}"<% if $Up.Nonce %> nonce="{$Up.Nonce}"<% end_if %>>
3+
<% end_loop %><% end_if %>
14
<script type="module" <% if $Nonce %>nonce="{$Nonce}"<% end_if %>><% loop $JSModules %>
25
import '{$Asset}';<% end_loop %>
36
</script>

tests/Traits/ViteProviderTest.php

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,4 +371,103 @@ public function testGetIncludeViteBuiltRequirementsWithAdditionalRequirementsInc
371371
}
372372
}
373373
}
374+
375+
public function testGetViteModulePreloadsIncludesJsImportsAndSkipsCss(): void
376+
{
377+
$this->testClass->setDistPath('app/client/dist/');
378+
379+
$manifest = [
380+
'app/client/src/index.ts' => [
381+
'file' => 'index-abc.js',
382+
'imports' => [
383+
'_Registry-xyz.js',
384+
'_Registry-css.js',
385+
'_Loader-def.js',
386+
'_Registry-xyz.js',
387+
],
388+
],
389+
'_Registry-xyz.js' => ['file' => 'Registry-xyz.js'],
390+
'_Registry-css.js' => ['file' => 'Registry-xyz.css'],
391+
'_Loader-def.js' => ['file' => 'Loader-def.js'],
392+
];
393+
394+
$preloads = $this->testClass->getViteModulePreloads($manifest, [
395+
'app/client/src/index.ts',
396+
]);
397+
398+
$this->assertEquals(2, $preloads->count());
399+
$this->assertEquals(
400+
'/_resources/app/client/dist/Registry-xyz.js',
401+
$preloads->first()->Asset
402+
);
403+
$this->assertEquals(
404+
'/_resources/app/client/dist/Loader-def.js',
405+
$preloads->last()->Asset
406+
);
407+
}
408+
409+
public function testGetViteModulePreloadsFromAdditionalJsEntries(): void
410+
{
411+
$manifest = [
412+
'app/client/src/index.ts' => [
413+
'file' => 'index-abc.js',
414+
],
415+
'app/client/src/additional.jsx' => [
416+
'file' => 'additional-ghi.js',
417+
'imports' => ['_shared-jkl.js'],
418+
],
419+
'_shared-jkl.js' => ['file' => 'shared-jkl.js'],
420+
];
421+
422+
$preloads = $this->testClass->getViteModulePreloads($manifest, [
423+
'app/client/src/index.ts',
424+
'app/client/src/additional.jsx',
425+
]);
426+
427+
$this->assertEquals(1, $preloads->count());
428+
$this->assertEquals(
429+
'/_resources/app/client/dist/shared-jkl.js',
430+
$preloads->first()->Asset
431+
);
432+
}
433+
434+
public function testGetIncludeViteBuiltRequirementsEmitsModulePreloadLinks(): void
435+
{
436+
$manifestPath = Director::baseFolder() . '/app/client/dist/manifest.json';
437+
$dir = dirname($manifestPath);
438+
439+
if (!is_dir($dir)) {
440+
mkdir($dir, 0755, true);
441+
}
442+
443+
$manifestData = [
444+
'app/client/src/index.css' => ['file' => 'index.css'],
445+
'app/client/src/index.ts' => [
446+
'file' => 'index.ts',
447+
'imports' => ['_Registry-xyz.js'],
448+
],
449+
'_Registry-xyz.js' => ['file' => 'Registry-xyz.js'],
450+
];
451+
452+
file_put_contents($manifestPath, json_encode($manifestData));
453+
454+
$mockCache = $this->createMock(CacheInterface::class);
455+
$mockCache->method('has')->willReturn(false);
456+
$mockCache->method('set')->willReturn(true);
457+
$mockCache->method('get')->willReturn($manifestData);
458+
459+
Injector::inst()->registerService($mockCache, CacheInterface::class . '.ViteRequirementsManifest');
460+
461+
try {
462+
$result = $this->testClass->getIncludeViteBuiltRequirements();
463+
464+
$this->assertStringContainsString('rel="modulepreload"', $result);
465+
$this->assertStringContainsString('Registry-xyz.js', $result);
466+
$this->assertStringContainsString('script type="module"', $result);
467+
} finally {
468+
if (file_exists($manifestPath)) {
469+
unlink($manifestPath);
470+
}
471+
}
472+
}
374473
}

0 commit comments

Comments
 (0)