Skip to content

Commit bdba2fb

Browse files
authored
Merge pull request #7981 from QwikDev/v2-trim-vite-script-tag
fix: trim script added by vite in dev mode
2 parents d368215 + ce0541d commit bdba2fb

File tree

3 files changed

+201
-1
lines changed

3 files changed

+201
-1
lines changed

.changeset/fuzzy-worlds-kneel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/router': patch
3+
---
4+
5+
fix: trim script added by vite in dev mode
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { wrapResponseForHtmlTransform } from './html-transform-wrapper';
3+
import type { IncomingMessage } from 'node:http';
4+
import type { ViteDevServer } from 'vite';
5+
import { createServer } from 'vite';
6+
import { EventEmitter } from 'node:events';
7+
8+
class MockServerResponse extends EventEmitter {
9+
output = '';
10+
headers: Record<string, string | number | string[]> = {};
11+
statusCode = 200;
12+
13+
private _origWrite = vi.fn((chunk: any, cb?: () => void) => {
14+
this.output += chunk.toString();
15+
cb?.();
16+
return true;
17+
});
18+
19+
private _origEnd = vi.fn((chunk?: any, cb?: () => void) => {
20+
if (chunk && typeof chunk !== 'function') {
21+
this.output += chunk.toString();
22+
}
23+
this.emit('finish');
24+
cb?.();
25+
return this as any;
26+
});
27+
28+
write = this._origWrite;
29+
end = this._origEnd;
30+
31+
setHeader = vi.fn((name: string, value: string | number | string[]) => {
32+
this.headers[name.toLowerCase()] = value;
33+
});
34+
35+
writeHead = vi.fn((statusCode: number, headers?: Record<string, string | number | string[]>) => {
36+
this.statusCode = statusCode;
37+
if (headers) {
38+
for (const [key, value] of Object.entries(headers)) {
39+
this.headers[key.toLowerCase()] = value;
40+
}
41+
}
42+
return this as any;
43+
});
44+
45+
// Mocks for spying on the original methods if they were patched
46+
get origWrite() {
47+
return this._origWrite;
48+
}
49+
get origEnd() {
50+
return this._origEnd;
51+
}
52+
}
53+
54+
describe('wrapResponseForHtmlTransform', () => {
55+
let req: IncomingMessage;
56+
let res: MockServerResponse;
57+
let server: ViteDevServer;
58+
59+
beforeEach(() => {
60+
req = { url: '/' } as IncomingMessage;
61+
res = new MockServerResponse();
62+
63+
server = {
64+
transformIndexHtml: vi.fn(async (url, html) => {
65+
return html
66+
.replace('<head>', '<head><!-- head pre content -->')
67+
.replace('</head>', '<!-- head post content --></head>')
68+
.replace('<body>', '<body><!-- body pre content -->')
69+
.replace('</body>', '<!-- body post content --></body>');
70+
}),
71+
} as any;
72+
});
73+
74+
it('should transform HTML response in a single chunk', async () => {
75+
wrapResponseForHtmlTransform(req, res as any, server);
76+
77+
res.setHeader('Content-Type', 'text/html');
78+
res.write('<html><head></head><body><h1>Hello</h1>');
79+
res.end('</body></html>');
80+
81+
await new Promise((resolve) => res.on('finish', resolve));
82+
83+
expect(server.transformIndexHtml).toHaveBeenCalledOnce();
84+
expect(res.output).toBe(
85+
'<html><head><!-- head pre content --><!-- head post content --></head><body><!-- body pre content --><h1>Hello</h1><!-- body post content --></body></html>'
86+
);
87+
});
88+
89+
it('should not transform non-HTML response', async () => {
90+
wrapResponseForHtmlTransform(req, res as any, server);
91+
92+
res.setHeader('Content-Type', 'application/json');
93+
const json = JSON.stringify({ message: 'hello' });
94+
res.end(json);
95+
96+
await new Promise((resolve) => res.on('finish', resolve));
97+
98+
expect(server.transformIndexHtml).not.toHaveBeenCalled();
99+
expect(res.output).toBe(json);
100+
});
101+
102+
it('should handle streamed HTML response', async () => {
103+
wrapResponseForHtmlTransform(req, res as any, server);
104+
105+
res.setHeader('Content-Type', 'text/html');
106+
res.write('<html><head>');
107+
res.write('</head><body>');
108+
res.write('<h1>Hello</h1>');
109+
res.write('</body>');
110+
res.end('</html>');
111+
112+
await new Promise((resolve) => res.on('finish', resolve));
113+
114+
expect(server.transformIndexHtml).toHaveBeenCalledOnce();
115+
expect(res.output).toBe(
116+
'<html><head><!-- head pre content --><!-- head post content --></head><body><!-- body pre content --><h1>Hello</h1><!-- body post content --></body></html>'
117+
);
118+
});
119+
120+
it('should use content-type from writeHead', async () => {
121+
wrapResponseForHtmlTransform(req, res as any, server);
122+
123+
res.writeHead(200, { 'Content-Type': 'text/html' });
124+
res.write('<html><head></head><body>');
125+
res.end('</body></html>');
126+
127+
await new Promise((resolve) => res.on('finish', resolve));
128+
129+
expect(server.transformIndexHtml).toHaveBeenCalledOnce();
130+
});
131+
132+
it('should fallback to passthrough on transform error', async () => {
133+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
134+
server.transformIndexHtml = vi.fn().mockRejectedValue(new Error('Transform failed'));
135+
wrapResponseForHtmlTransform(req, res as any, server);
136+
137+
const originalHtml = '<html><head></head><body><h1>Hello</h1></body></html>';
138+
res.setHeader('Content-Type', 'text/html');
139+
res.end(originalHtml);
140+
141+
await new Promise((resolve) => res.on('finish', resolve));
142+
143+
expect(res.output).toBe(originalHtml);
144+
expect(consoleErrorSpy).toHaveBeenCalledWith('Error transforming HTML:', expect.any(Error));
145+
consoleErrorSpy.mockRestore();
146+
});
147+
148+
it('should handle head and body tags split across chunks', async () => {
149+
wrapResponseForHtmlTransform(req, res as any, server);
150+
res.setHeader('Content-Type', 'text/html');
151+
152+
res.write('<html><he');
153+
res.write('ad></h');
154+
res.write('ead><bo');
155+
res.write('dy><h1>Hello</h1></bo');
156+
res.write('dy></html>');
157+
res.end();
158+
159+
await new Promise((resolve) => res.on('finish', resolve));
160+
161+
expect(server.transformIndexHtml).toHaveBeenCalledOnce();
162+
expect(res.output).toBe(
163+
'<html><head><!-- head pre content --><!-- head post content --></head><body><!-- body pre content --><h1>Hello</h1><!-- body post content --></body></html>'
164+
);
165+
});
166+
167+
it('should inject vite client script using native vite transform without new line', async () => {
168+
const viteServer = await createServer({
169+
root: process.cwd(),
170+
server: { middlewareMode: true },
171+
appType: 'custom',
172+
});
173+
try {
174+
// note: we are using the real vite server, not the mocked one from beforeEach
175+
wrapResponseForHtmlTransform(req, res as any, viteServer);
176+
177+
res.setHeader('Content-Type', 'text/html');
178+
res.write('<html><head><title>Test</title></head><body></body></html>');
179+
res.end();
180+
181+
await new Promise((resolve) => res.on('finish', resolve));
182+
183+
expect(res.output).toContain('<script type="module" src="/@vite/client"></script><title>');
184+
} finally {
185+
await viteServer.close();
186+
}
187+
});
188+
});

packages/qwik-router/src/buildtime/vite/html-transform-wrapper.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ class HtmlTransformPatcher {
5959
statusMessage?: string | OutgoingHttpHeaders | OutgoingHttpHeader[],
6060
headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]
6161
) => {
62+
if (typeof statusMessage === 'object' && statusMessage !== null) {
63+
headers = statusMessage;
64+
statusMessage = undefined;
65+
}
6266
if (headers && typeof headers === 'object') {
6367
for (const [key, value] of Object.entries(headers)) {
6468
if (key.toLowerCase() === 'content-type') {
@@ -164,7 +168,10 @@ class HtmlTransformPatcher {
164168
if (fakeHeadIndex === -1 || fakeHeadCloseIndex === -1) {
165169
throw new Error('Transformed HTML does not contain [FAKE_HEAD]...</head>');
166170
}
167-
const headPreContent = transformedHtml.slice('<html><head>'.length, fakeHeadIndex);
171+
const headPreContent = transformedHtml
172+
.slice('<html><head>'.length, fakeHeadIndex)
173+
// remove new line after <script type="module" src="/@vite/client"></script>
174+
.trim();
168175
const headPostContent = transformedHtml.slice(
169176
fakeHeadIndex + '[FAKE_HEAD]'.length,
170177
fakeHeadCloseIndex

0 commit comments

Comments
 (0)