Skip to content

Commit 6ae244c

Browse files
fix(vite-plugin-angular): route template/style imports through virtual module ids (#2287)
1 parent 741ce33 commit 6ae244c

File tree

4 files changed

+339
-120
lines changed

4 files changed

+339
-120
lines changed

packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts

Lines changed: 135 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import * as path from 'node:path';
2+
import * as realFs from 'node:fs';
3+
import { tmpdir } from 'node:os';
24
import { describe, it, expect, vi } from 'vitest';
35
import { normalizePath, preprocessCSS } from 'vite';
46

@@ -91,7 +93,7 @@ describe('JIT resolveId', () => {
9193
expect(result).not.toContain('?inline');
9294
});
9395

94-
it('should resolve template files with ?analog-raw suffix', () => {
96+
it('should resolve template files to virtual raw ids', () => {
9597
const plugins = angular({ jit: true });
9698
const mainPlugin = plugins.find(
9799
(p) => p.name === '@analogjs/vite-plugin-angular',
@@ -106,9 +108,9 @@ describe('JIT resolveId', () => {
106108
'/project/src/app/my-component.ts',
107109
);
108110

109-
expect(result).toBeDefined();
110-
expect(result).toContain('?analog-raw');
111-
expect(result).not.toContain('??analog-raw');
111+
expect(result).toContain('virtual:@analogjs/vite-plugin-angular:raw:');
112+
expect(result).not.toContain('?analog-raw');
113+
expect(result).not.toContain('.html');
112114
});
113115

114116
it('should resolve bare virtual style ids to rollup virtual modules', () => {
@@ -125,7 +127,20 @@ describe('JIT resolveId', () => {
125127
expect(resolveId(virtualId)).toBe(`\0${virtualId}`);
126128
});
127129

128-
it('should intercept .html?raw imports and remap to ?analog-raw', () => {
130+
it('should resolve bare virtual raw ids to rollup virtual modules', () => {
131+
const plugins = angular({ jit: true });
132+
const mainPlugin = plugins.find(
133+
(p) => p.name === '@analogjs/vite-plugin-angular',
134+
);
135+
expect(mainPlugin).toBeDefined();
136+
137+
const resolveId = (mainPlugin as any).resolveId;
138+
const virtualId = 'virtual:@analogjs/vite-plugin-angular:raw:test-raw-id';
139+
140+
expect(resolveId(virtualId)).toBe(`\0${virtualId}`);
141+
});
142+
143+
it('should intercept .html?raw imports and remap to virtual raw ids', () => {
129144
const plugins = angular({ jit: true });
130145
const mainPlugin = plugins.find(
131146
(p) => p.name === '@analogjs/vite-plugin-angular',
@@ -138,14 +153,16 @@ describe('JIT resolveId', () => {
138153
'./my-component.html?raw',
139154
'/project/src/app/my-component.ts',
140155
);
141-
expect(result).toContain('/project/src/app/my-component.html?analog-raw');
156+
expect(result).toContain('virtual:@analogjs/vite-plugin-angular:raw:');
157+
expect(result).not.toContain('.html');
142158

143159
// Absolute path
144160
const result2 = resolveId(
145161
'/project/src/app/my-component.html?raw',
146162
'/project/src/app/other.ts',
147163
);
148-
expect(result2).toBe('/project/src/app/my-component.html?analog-raw');
164+
expect(result2).toContain('virtual:@analogjs/vite-plugin-angular:raw:');
165+
expect(result2).not.toContain('.html');
149166
});
150167

151168
it('should intercept .html?raw imports even without jit mode', () => {
@@ -160,34 +177,50 @@ describe('JIT resolveId', () => {
160177
'./my-component.html?raw',
161178
'/project/src/app/my-component.ts',
162179
);
163-
expect(result).toContain('?analog-raw');
180+
expect(result).toContain('virtual:@analogjs/vite-plugin-angular:raw:');
181+
});
182+
183+
it('should emit virtual raw ids that do not look like asset or html resources', () => {
184+
const assetRE = /\.(svg|png|jpe?g|gif|webp|html)($|\?)/;
185+
const plugins = angular({ jit: true });
186+
const mainPlugin = plugins.find(
187+
(p) => p.name === '@analogjs/vite-plugin-angular',
188+
);
189+
190+
const resolveId = (mainPlugin as any).resolveId;
191+
const virtualId = resolveId(
192+
'angular:jit:template:file;./my-component.svg',
193+
'/project/src/app/my-component.ts',
194+
);
195+
196+
expect(assetRE.test(virtualId)).toBe(false);
164197
});
165198

166-
it('should intercept style ?inline imports and remap to ?analog-inline', () => {
199+
it('should intercept style ?inline imports and remap to virtual style ids', () => {
167200
const plugins = angular({ jit: true });
168201
const mainPlugin = plugins.find(
169202
(p) => p.name === '@analogjs/vite-plugin-angular',
170203
);
171204

172205
const resolveId = (mainPlugin as any).resolveId;
173206
const importer = '/project/src/app/my-component.ts';
174-
const expectedScssPath = `${normalizePath(
175-
path.resolve(path.dirname(importer), './my-component.scss'),
176-
)}?analog-inline`;
177-
const expectedCssPath = `${normalizePath(
178-
'/project/src/app/my-component.css',
179-
)}?analog-inline`;
180207

181208
// Relative .scss?inline
182209
const result = resolveId('./my-component.scss?inline', importer);
183-
expect(result).toBe(expectedScssPath);
210+
expect(result).toContain(
211+
'virtual:@analogjs/vite-plugin-angular:inline-style:',
212+
);
213+
expect(result).not.toContain('.scss');
184214

185215
// Absolute .css?inline
186216
const result2 = resolveId(
187217
'/project/src/app/my-component.css?inline',
188218
'/project/src/app/other.ts',
189219
);
190-
expect(result2).toBe(expectedCssPath);
220+
expect(result2).toContain(
221+
'virtual:@analogjs/vite-plugin-angular:inline-style:',
222+
);
223+
expect(result2).not.toContain('.css');
191224
});
192225

193226
it('should intercept style ?inline imports even without jit mode', () => {
@@ -202,7 +235,9 @@ describe('JIT resolveId', () => {
202235
'./my-component.scss?inline',
203236
'/project/src/app/my-component.ts',
204237
);
205-
expect(result).toContain('?analog-inline');
238+
expect(result).toContain(
239+
'virtual:@analogjs/vite-plugin-angular:inline-style:',
240+
);
206241
});
207242

208243
it('should not match Vite inline security regex /[?&]inline\\b/', () => {
@@ -245,8 +280,7 @@ describe('load ?inline style imports', () => {
245280
// module-runner. Direct ?inline imports therefore bypass the resolveId
246281
// rewrites in tests, and the load hook must still accept the original
247282
// query directly. (See issue #2263.)
248-
const realFs = require('node:fs');
249-
const tmpDir = require('node:os').tmpdir();
283+
const tmpDir = tmpdir();
250284

251285
function getLoadHook() {
252286
const plugins = angular();
@@ -302,25 +336,95 @@ describe('load ?inline style imports', () => {
302336
}
303337
});
304338

305-
it('still handles the rewritten ?analog-inline query', async () => {
306-
const cssPath = path.join(tmpDir, `analog-rewrite-${Date.now()}.css`);
307-
realFs.writeFileSync(cssPath, '.bar { color: blue; }', 'utf-8');
339+
it('ignores non-style ?inline imports', async () => {
340+
const load = getLoadHook();
341+
const result = await load('/project/src/data.json?inline');
342+
expect(result).toBeUndefined();
343+
});
344+
});
345+
346+
describe('load virtual raw template imports', () => {
347+
// Templates (.html, .svg, …) are routed through a virtual module id so
348+
// Vite's built-in asset/CSS plugins never see a file extension and can't
349+
// re-tag the id with ?import (which would otherwise return a data URI for
350+
// .svg) or ?inline. This covers both the main dev path and the Vitest
351+
// fetchModule path, since resolveId is bypassed for the module-runner.
352+
const tmpDir = tmpdir();
353+
354+
function getMainPlugin() {
355+
const plugins = angular({ jit: true });
356+
return plugins.find((p) => p.name === '@analogjs/vite-plugin-angular');
357+
}
358+
359+
function loadHook() {
360+
return (getMainPlugin() as any).load.bind({ addWatchFile: vi.fn() });
361+
}
362+
363+
it('loads an .svg templateUrl via its virtual raw id', async () => {
364+
const svgPath = normalizePath(
365+
path.join(tmpDir, `analog-raw-${Date.now()}.svg`),
366+
);
367+
realFs.writeFileSync(
368+
svgPath,
369+
'<svg xmlns="http://www.w3.org/2000/svg"><g></g></svg>',
370+
'utf-8',
371+
);
372+
373+
try {
374+
const mainPlugin = getMainPlugin();
375+
const resolveId = (mainPlugin as any).resolveId;
376+
const virtualId = resolveId(
377+
`angular:jit:template:file;./${path.basename(svgPath)}`,
378+
path.join(tmpDir, 'host.component.ts'),
379+
);
380+
381+
expect(virtualId).toContain('virtual:@analogjs/vite-plugin-angular:raw:');
382+
expect(virtualId).not.toContain('.svg');
383+
384+
const addWatchFile = vi.fn();
385+
const load = (mainPlugin as any).load.bind({ addWatchFile });
386+
const result = await load(`\0${virtualId}`);
387+
388+
expect(result).toBeDefined();
389+
expect(result).toContain('export default');
390+
expect(result).toContain('<svg');
391+
expect(result).toContain('</svg>');
392+
expect(addWatchFile).toHaveBeenCalledWith(svgPath);
393+
} finally {
394+
realFs.unlinkSync(svgPath);
395+
}
396+
});
397+
398+
it('handles virtual raw ids without the rollup \\0 prefix (Vitest path)', async () => {
399+
// Vitest's fetchModule path calls moduleGraph.ensureEntryFromUrl before
400+
// transformRequest, so resolveId is a no-op for the module-runner and
401+
// the id reaches load as a bare virtual id.
402+
const htmlPath = normalizePath(
403+
path.join(tmpDir, `analog-raw-${Date.now()}.html`),
404+
);
405+
realFs.writeFileSync(htmlPath, '<h1>hello</h1>', 'utf-8');
308406

309407
try {
310-
const load = getLoadHook();
311-
const result = await load(`${cssPath}?analog-inline`);
408+
const mainPlugin = getMainPlugin();
409+
const resolveId = (mainPlugin as any).resolveId;
410+
const virtualId = resolveId(
411+
`angular:jit:template:file;./${path.basename(htmlPath)}`,
412+
path.join(tmpDir, 'host.component.ts'),
413+
);
414+
const load = (mainPlugin as any).load.bind({ addWatchFile: vi.fn() });
415+
416+
const result = await load(virtualId);
312417
expect(result).toBeDefined();
313418
expect(result).toContain('export default');
314-
expect(result).toContain('color: blue');
419+
expect(result).toContain('<h1>hello</h1>');
315420
} finally {
316-
realFs.unlinkSync(cssPath);
421+
realFs.unlinkSync(htmlPath);
317422
}
318423
});
319424

320-
it('ignores non-style ?inline imports', async () => {
321-
const load = getLoadHook();
322-
const result = await load('/project/src/data.json?inline');
323-
expect(result).toBeUndefined();
425+
it('ignores unrelated ids', async () => {
426+
const load = loadHook();
427+
expect(await load('/project/src/data.json?raw')).toBeUndefined();
324428
});
325429
});
326430

0 commit comments

Comments
 (0)