Skip to content

Commit 94ec13f

Browse files
committed
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 <[email protected]>
1 parent 75edec9 commit 94ec13f

File tree

2 files changed

+55
-10
lines changed

2 files changed

+55
-10
lines changed

lib/private/Template/functions.php

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,23 +54,22 @@ function emit_css_loading_tags($obj): void {
5454
* @param string $src the source URL, ignored when empty
5555
* @param string $script_content the inline script content, ignored when empty
5656
* @param string $content_type the type of the source (e.g. 'module')
57+
*
58+
* @since 27.0.0 added the $content_type parameter
5759
*/
5860
function emit_script_tag(string $src, string $script_content = '', string $content_type = ''): void {
5961
$nonceManager = Server::get(ContentSecurityPolicyNonceManager::class);
6062

61-
$defer_str = ' defer';
63+
$defer_str = $content_type === '' ? ' defer' : ''; // "defer" only works with classic scripts
6264
$type = $content_type !== '' ? ' type="' . $content_type . '"' : '';
6365

64-
$s = '<script nonce="' . $nonceManager->getNonce() . '"';
66+
$s = '<script nonce="' . $nonceManager->getNonce() . '"' . $type;
6567
if (!empty($src)) {
6668
// emit script tag for deferred loading from $src
67-
$s .= $defer_str . ' src="' . $src . '"' . $type . '>';
68-
} elseif ($script_content !== '') {
69+
$s .= $defer_str . ' src="' . $src . '">';
70+
} else {
6971
// emit script tag for inline script from $script_content without defer (see MDN)
7072
$s .= ">\n" . $script_content . "\n";
71-
} else {
72-
// no $src nor $src_content, really useless empty tag
73-
$s .= '>';
7473
}
7574
$s .= '</script>';
7675
print_unescaped($s . "\n");
@@ -81,6 +80,8 @@ function emit_script_tag(string $src, string $script_content = '', string $conte
8180
* @param array $obj all the script information from template
8281
*/
8382
function emit_script_loading_tags($obj): void {
83+
emit_import_map($obj);
84+
8485
foreach ($obj['jsfiles'] as $jsfile) {
8586
$fileName = explode('?', $jsfile, 2)[0];
8687
$type = str_ends_with($fileName, '.mjs') ? 'module' : '';
@@ -91,6 +92,29 @@ function emit_script_loading_tags($obj): void {
9192
}
9293
}
9394

95+
/**
96+
* Print the import map for the current JS modules.
97+
* The import map is needed to ensure that an import of an entry point does not duplicate the state,
98+
* but reuses the already loaded module. This is needed because Nextcloud will append a cache buster
99+
* to the entry point URLs but the scripts does not know about that (both must match).
100+
*
101+
* @param $obj all the script information from template
102+
*/
103+
function emit_import_map(array $obj): void {
104+
$modules = [];
105+
foreach ($obj['jsfiles'] as $jsfile) {
106+
$fileName = explode('?', $jsfile, 2)[0];
107+
if (str_ends_with($fileName, '.mjs') && $jsfile !== $fileName) {
108+
// its a module and we have a cache buster available
109+
$modules[$fileName] = $jsfile;
110+
}
111+
}
112+
if (!empty($modules)) {
113+
$json = json_encode(['imports' => $modules], JSON_UNESCAPED_SLASHES | JSON_FORCE_OBJECT);
114+
emit_script_tag('', $json, 'importmap');
115+
}
116+
}
117+
94118
/**
95119
* Prints an unsanitized string - usage of this function may result into XSS.
96120
* Consider using p() instead.

tests/lib/TemplateFunctionsTest.php

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,34 @@ public function testEmitScriptTagWithSource(): void {
5454
}
5555

5656
public function testEmitScriptTagWithModuleSource(): void {
57-
$this->expectOutputRegex('/<script nonce=".*" defer src="some.mjs" type="module"><\/script>/');
57+
$this->expectOutputRegex('/<script nonce=".*" type="module" src="some.mjs"><\/script>/');
5858
emit_script_tag('some.mjs', '', 'module');
5959
}
6060

61+
public function testEmitImportMap(): void {
62+
$this->expectOutputRegex('/^<script[^>]+type="importmap">\n{"imports":{"\/some\/path\/file\.mjs":"\/some\/path\/file\.mjs\?v=123"}}\n<\/script>$/m');
63+
emit_import_map(['jsfiles' => ['/some/path/file.mjs?v=123']]);
64+
}
65+
66+
// only create import map for modules with versioning
67+
public function testEmitImportMapMixedScripts(): void {
68+
$this->expectOutputRegex('/^<script[^>]+type="importmap">\n{"imports":{"\/some\/path\/module\.mjs":"\/some\/path\/module\.mjs\?v=123"}}\n<\/script>$/m');
69+
emit_import_map(['jsfiles' => ['/some/path/module.mjs?v=123', '/some/path/classic.js?v=123']]);
70+
}
71+
72+
public function testEmitImportMapNoOutputWithoutVersion(): void {
73+
$this->expectOutputString('');
74+
emit_import_map(['jsfiles' => ['some.mjs']]);
75+
}
76+
77+
public function testEmitImportMapNoOutputWithClassicScript(): void {
78+
$this->expectOutputString('');
79+
emit_import_map(['jsfiles' => ['some.js?v=123']]);
80+
}
81+
6182
public function testEmitScriptLoadingTags(): void {
6283
// Test mjs js and inline content
63-
$pattern = '/src="some\.mjs"[^>]+type="module"[^>]*>.+\n'; // some.mjs with type = module
84+
$pattern = '/type="module"[^>]+src="some\.mjs"[^>]*>.+\n'; // some.mjs with type = module
6485
$pattern .= '<script[^>]+src="other\.js"[^>]*>.+\n'; // other.js as plain javascript
6586
$pattern .= '<script[^>]*>\n?.*inline.*\n?<\/script>'; // inline content
6687
$pattern .= '/'; // no flags
@@ -74,7 +95,7 @@ public function testEmitScriptLoadingTags(): void {
7495

7596
public function testEmitScriptLoadingTagsWithVersion(): void {
7697
// Test mjs js and inline content
77-
$pattern = '/src="some\.mjs\?v=ab123cd"[^>]+type="module"[^>]*>.+\n'; // some.mjs with type = module
98+
$pattern = '/type="module"[^>]+src="some\.mjs\?v=ab123cd"[^>]*>.+\n'; // some.mjs with type = module
7899
$pattern .= '<script[^>]+src="other\.js\?v=12abc34"[^>]*>.+\n'; // other.js as plain javascript
79100
$pattern .= '/'; // no flags
80101

0 commit comments

Comments
 (0)