mirror of
https://github.com/nextcloud/server.git
synced 2026-02-18 18:28:50 -05:00
fix(template): add import map for JS module entry points
Currently apps are broken if they have exports in the JS entry point,
because they then will import from the entry point but because they do
not know about the Nextcloud cache buster they will import without cache
buster.
This results in two problem:
1. The module might be outdated (old cached)
2. The module is duplicated, so the module will be loaded twice and will
have two different - out of sync - states. This also means it will
re-run sideeffects of the entry point.
To fix this we generate an import map which basically maps the plain
entry point script to the script with cache buster added.
(Some background: Bundler will try to minimize chunks (reduce page
loading time) so they can inline modules into entry points and thus
extend the entry point exports and then this issue would be caused).
For example:
```js
// entry.mjs
console.error('called')
async function onClick() {
await import('./chunk.mjs')
}
export const name = 'foo'
// chunk.mjs
import { name } from './entry.mjs'
console.error(name)
```
When calling `onClick` without this fix the output will be:
> called
> called
> foo
With this fix:
> called
> foo
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
0e99c60da1
commit
f0e020f97d
2 changed files with 55 additions and 10 deletions
|
|
@ -54,23 +54,22 @@ function emit_css_loading_tags($obj): void {
|
|||
* @param string $src the source URL, ignored when empty
|
||||
* @param string $script_content the inline script content, ignored when empty
|
||||
* @param string $content_type the type of the source (e.g. 'module')
|
||||
*
|
||||
* @since 27.0.0 added the $content_type parameter
|
||||
*/
|
||||
function emit_script_tag(string $src, string $script_content = '', string $content_type = ''): void {
|
||||
$nonceManager = Server::get(ContentSecurityPolicyNonceManager::class);
|
||||
|
||||
$defer_str = ' defer';
|
||||
$defer_str = $content_type === '' ? ' defer' : ''; // "defer" only works with classic scripts
|
||||
$type = $content_type !== '' ? ' type="' . $content_type . '"' : '';
|
||||
|
||||
$s = '<script nonce="' . $nonceManager->getNonce() . '"';
|
||||
$s = '<script nonce="' . $nonceManager->getNonce() . '"' . $type;
|
||||
if (!empty($src)) {
|
||||
// emit script tag for deferred loading from $src
|
||||
$s .= $defer_str . ' src="' . $src . '"' . $type . '>';
|
||||
} elseif ($script_content !== '') {
|
||||
$s .= $defer_str . ' src="' . $src . '">';
|
||||
} else {
|
||||
// emit script tag for inline script from $script_content without defer (see MDN)
|
||||
$s .= ">\n" . $script_content . "\n";
|
||||
} else {
|
||||
// no $src nor $src_content, really useless empty tag
|
||||
$s .= '>';
|
||||
}
|
||||
$s .= '</script>';
|
||||
print_unescaped($s . "\n");
|
||||
|
|
@ -81,6 +80,8 @@ function emit_script_tag(string $src, string $script_content = '', string $conte
|
|||
* @param array $obj all the script information from template
|
||||
*/
|
||||
function emit_script_loading_tags($obj): void {
|
||||
emit_import_map($obj);
|
||||
|
||||
foreach ($obj['jsfiles'] as $jsfile) {
|
||||
$fileName = explode('?', $jsfile, 2)[0];
|
||||
$type = str_ends_with($fileName, '.mjs') ? 'module' : '';
|
||||
|
|
@ -91,6 +92,29 @@ function emit_script_loading_tags($obj): void {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the import map for the current JS modules.
|
||||
* The import map is needed to ensure that an import of an entry point does not duplicate the state,
|
||||
* but reuses the already loaded module. This is needed because Nextcloud will append a cache buster
|
||||
* to the entry point URLs but the scripts does not know about that (both must match).
|
||||
*
|
||||
* @param $obj all the script information from template
|
||||
*/
|
||||
function emit_import_map(array $obj): void {
|
||||
$modules = [];
|
||||
foreach ($obj['jsfiles'] as $jsfile) {
|
||||
$fileName = explode('?', $jsfile, 2)[0];
|
||||
if (str_ends_with($fileName, '.mjs') && $jsfile !== $fileName) {
|
||||
// its a module and we have a cache buster available
|
||||
$modules[$fileName] = $jsfile;
|
||||
}
|
||||
}
|
||||
if (!empty($modules)) {
|
||||
$json = json_encode(['imports' => $modules], JSON_UNESCAPED_SLASHES | JSON_FORCE_OBJECT);
|
||||
emit_script_tag('', $json, 'importmap');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints an unsanitized string - usage of this function may result into XSS.
|
||||
* Consider using p() instead.
|
||||
|
|
|
|||
|
|
@ -54,13 +54,34 @@ class TemplateFunctionsTest extends \Test\TestCase {
|
|||
}
|
||||
|
||||
public function testEmitScriptTagWithModuleSource(): void {
|
||||
$this->expectOutputRegex('/<script nonce=".*" defer src="some.mjs" type="module"><\/script>/');
|
||||
$this->expectOutputRegex('/<script nonce=".*" type="module" src="some.mjs"><\/script>/');
|
||||
emit_script_tag('some.mjs', '', 'module');
|
||||
}
|
||||
|
||||
public function testEmitImportMap(): void {
|
||||
$this->expectOutputRegex('/^<script[^>]+type="importmap">\n{"imports":{"\/some\/path\/file\.mjs":"\/some\/path\/file\.mjs\?v=123"}}\n<\/script>$/m');
|
||||
emit_import_map(['jsfiles' => ['/some/path/file.mjs?v=123']]);
|
||||
}
|
||||
|
||||
// only create import map for modules with versioning
|
||||
public function testEmitImportMapMixedScripts(): void {
|
||||
$this->expectOutputRegex('/^<script[^>]+type="importmap">\n{"imports":{"\/some\/path\/module\.mjs":"\/some\/path\/module\.mjs\?v=123"}}\n<\/script>$/m');
|
||||
emit_import_map(['jsfiles' => ['/some/path/module.mjs?v=123', '/some/path/classic.js?v=123']]);
|
||||
}
|
||||
|
||||
public function testEmitImportMapNoOutputWithoutVersion(): void {
|
||||
$this->expectOutputString('');
|
||||
emit_import_map(['jsfiles' => ['some.mjs']]);
|
||||
}
|
||||
|
||||
public function testEmitImportMapNoOutputWithClassicScript(): void {
|
||||
$this->expectOutputString('');
|
||||
emit_import_map(['jsfiles' => ['some.js?v=123']]);
|
||||
}
|
||||
|
||||
public function testEmitScriptLoadingTags(): void {
|
||||
// Test mjs js and inline content
|
||||
$pattern = '/src="some\.mjs"[^>]+type="module"[^>]*>.+\n'; // some.mjs with type = module
|
||||
$pattern = '/type="module"[^>]+src="some\.mjs"[^>]*>.+\n'; // some.mjs with type = module
|
||||
$pattern .= '<script[^>]+src="other\.js"[^>]*>.+\n'; // other.js as plain javascript
|
||||
$pattern .= '<script[^>]*>\n?.*inline.*\n?<\/script>'; // inline content
|
||||
$pattern .= '/'; // no flags
|
||||
|
|
@ -74,7 +95,7 @@ class TemplateFunctionsTest extends \Test\TestCase {
|
|||
|
||||
public function testEmitScriptLoadingTagsWithVersion(): void {
|
||||
// Test mjs js and inline content
|
||||
$pattern = '/src="some\.mjs\?v=ab123cd"[^>]+type="module"[^>]*>.+\n'; // some.mjs with type = module
|
||||
$pattern = '/type="module"[^>]+src="some\.mjs\?v=ab123cd"[^>]*>.+\n'; // some.mjs with type = module
|
||||
$pattern .= '<script[^>]+src="other\.js\?v=12abc34"[^>]*>.+\n'; // other.js as plain javascript
|
||||
$pattern .= '/'; // no flags
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue