diff --git a/.changeset/spotty-mirrors-pick.md b/.changeset/spotty-mirrors-pick.md new file mode 100644 index 000000000..58de19bb6 --- /dev/null +++ b/.changeset/spotty-mirrors-pick.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/vite-plugin-svelte': patch +--- + +fix: ensure compiled svelte css is loaded correctly when rebuilding in `build --watch` diff --git a/.gitignore b/.gitignore index c5eb7a2ad..a2f4e6129 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,5 @@ coverage packages/playground/**/* !packages/playground/README.md +# vite plugin inspect +.vite-inspect diff --git a/.prettierrc.js b/.prettierrc.js index 044917ed6..8d1916460 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -25,7 +25,8 @@ export default { '**/vite.config.js.timestamp-*.mjs', 'packages/e2e-tests/dynamic-compile-options/src/components/A.svelte', 'packages/playground/big/src/pages/**', // lots of generated files - 'packages/e2e-tests/scan-deps/src/Svelte*.svelte' // various syntax tests that require no format + 'packages/e2e-tests/scan-deps/src/Svelte*.svelte', // various syntax tests that require no format + '**/.vite-inspect/**' ], options: { rangeEnd: 0 diff --git a/eslint.config.js b/eslint.config.js index f5b840710..3064952de 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,7 +15,8 @@ export default [ 'packages/*/types/index.d.ts', 'packages/*/types/index.d.ts.map', 'packages/*/CHANGELOG.md', - 'packages/e2e-tests/**/logs/**' + 'packages/e2e-tests/**/logs/**', + '**/.vite-inspect/**' ] }, ...svelteOrgEslintConfig, // contains setup for svelte and typescript diff --git a/package.json b/package.json index c5b6c4c36..f07451283 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,11 @@ "private": true, "type": "module", "scripts": { - "test": "run-s -c test:unit \"test:build {@}\" \"test:serve {@}\" --", + "test": "run-s -c test:unit \"test:build {@}\" \"test:serve {@}\" \"test:build:watch {@}\" --", "test:unit": "vitest run", "test:serve": "vitest run -c vitest.config.e2e.ts", "test:build": "cross-env TEST_BUILD=1 vitest run -c vitest.config.e2e.ts", + "test:build:watch": "cross-env TEST_BUILD_WATCH=1 vitest run -c vitest.config.e2e.ts", "check": "run-p -c check:*", "check:audit": "pnpm audit --prod", "check:publint": "pnpm --filter \"./packages/*\" --parallel check:publint", @@ -78,7 +79,8 @@ "vite": "$vite", "@types/node@<=20.12.0": "20.19.9", "send@<0.19.0": "^0.19.1", - "@sveltejs/kit>cookie@<0.7.0": "^0.7.2" + "@sveltejs/kit>cookie@<0.7.0": "^0.7.2", + "vite-plugin-inspect": "/home/dominikg/develop/vite-plugin-inspect" }, "onlyBuiltDependencies": [ "esbuild" diff --git a/packages/e2e-tests/_test_dependencies/vite-plugins/index.js b/packages/e2e-tests/_test_dependencies/vite-plugins/index.js index 12e635d67..a6c1ea36d 100644 --- a/packages/e2e-tests/_test_dependencies/vite-plugins/index.js +++ b/packages/e2e-tests/_test_dependencies/vite-plugins/index.js @@ -1,5 +1,15 @@ import path from 'node:path'; import fs from 'node:fs'; +import MagicString from 'magic-string'; + +function replaceWithSourceMap(code, value, replacement) { + const s = new MagicString(code); + s.replaceAll(value, replacement); + return { + code: s.toString(), + map: s.generateMap({ hires: 'boundary' }) + }; +} /** * Ensure transform flow is not interrupted * @returns {import('vite').Plugin[]} @@ -11,9 +21,9 @@ export function transformValidation() { enforce: 'pre', transform(code, id) { if (id.endsWith('.svelte')) { - return code.replaceAll('__JS_TRANSFORM_1__', '__JS_TRANSFORM_2__'); + return replaceWithSourceMap(code, '__JS_TRANSFORM_1__', '__JS_TRANSFORM_2__'); } else if (id.endsWith('.css')) { - return code.replaceAll('__CSS_TRANSFORM_1__', '__CSS_TRANSFORM_2__'); + return replaceWithSourceMap(code, '__CSS_TRANSFORM_1__', '__CSS_TRANSFORM_2__'); } } }, @@ -21,9 +31,9 @@ export function transformValidation() { name: 'transform-validation:2', transform(code, id) { if (id.endsWith('.svelte')) { - return code.replaceAll('__JS_TRANSFORM_2__', '__JS_TRANSFORM_3__'); + return replaceWithSourceMap(code, '__JS_TRANSFORM_2__', '__JS_TRANSFORM_3__'); } else if (id.endsWith('.css')) { - return code.replaceAll('__CSS_TRANSFORM_2__', 'red'); + return replaceWithSourceMap(code, '__CSS_TRANSFORM_2__', 'red'); } } }, @@ -32,7 +42,7 @@ export function transformValidation() { enforce: 'post', transform(code, id) { if (id.endsWith('.svelte')) { - return code.replaceAll('__JS_TRANSFORM_3__', 'Hello world'); + return replaceWithSourceMap(code, '__JS_TRANSFORM_3__', 'Hello world'); } // can't handle css here as in build, it would be `export default {}` } diff --git a/packages/e2e-tests/_test_dependencies/vite-plugins/package.json b/packages/e2e-tests/_test_dependencies/vite-plugins/package.json index 0159e694a..4ca56d865 100644 --- a/packages/e2e-tests/_test_dependencies/vite-plugins/package.json +++ b/packages/e2e-tests/_test_dependencies/vite-plugins/package.json @@ -6,5 +6,8 @@ "main": "./index.js", "files": [ "index.js" - ] + ], + "dependencies": { + "magic-string": "^0.30.17" + } } diff --git a/packages/e2e-tests/build-watch/__tests__/build-watch.spec.ts b/packages/e2e-tests/build-watch/__tests__/build-watch.spec.ts new file mode 100644 index 000000000..c99b5b8af --- /dev/null +++ b/packages/e2e-tests/build-watch/__tests__/build-watch.spec.ts @@ -0,0 +1,157 @@ +import { + isBuildWatch, + getEl, + getText, + editFileAndWaitForBuildWatchComplete, + hmrCount, + untilMatches, + sleep, + getColor, + browserLogs, + e2eServer +} from '~utils'; + +import * as vite from 'vite'; +// @ts-ignore +const isRolldownVite = !!vite.rolldownVersion; + +describe.runIf(isBuildWatch)('build-watch', () => { + test('should render App', async () => { + expect(await getText('#app-header')).toBe('Test-App'); + }); + + test('should render static import', async () => { + expect(await getText('#static-import .label')).toBe('static-import'); + }); + + test('should render dependency import', async () => { + expect(await getText('#dependency-import .label')).toBe('dependency-import'); + }); + + test('should render dynamic import', async () => { + expect(await getEl('#dynamic-import')).toBe(null); + const dynamicImportButton = await getEl('#button-import-dynamic'); + expect(dynamicImportButton).toBeDefined(); + await dynamicImportButton.click(); + await untilMatches( + () => getText('#dynamic-import .label'), + 'dynamic-import', + 'dynamic import loaded after click' + ); + }); + + test('should not have failed requests', async () => { + browserLogs.forEach((msg) => { + expect(msg).not.toMatch('404'); + }); + }); + + test('should respect transforms', async () => { + expect(await getText('#js-transform')).toBe('Hello world'); + expect(await getColor('#css-transform')).toBe('red'); + }); + + describe('edit files', () => { + const updateHmrTest = editFileAndWaitForBuildWatchComplete.bind( + null, + 'src/components/HmrTest.svelte' + ); + const updateModuleContext = editFileAndWaitForBuildWatchComplete.bind( + null, + 'src/components/partial-hmr/ModuleContext.svelte' + ); + const updateApp = editFileAndWaitForBuildWatchComplete.bind(null, 'src/App.svelte'); + const updateStore = editFileAndWaitForBuildWatchComplete.bind(null, 'src/stores/hmr-stores.js'); + + const getWatchErrors = () => + isRolldownVite + ? e2eServer.logs.watch.err.filter( + (m) => + ![ + 'Support for rolldown-vite in vite-plugin-svelte is experimental', + 'See https://github.com/sveltejs/vite-plugin-svelte/issues/1143' + ].some((s) => m.includes(s)) + ) + : e2eServer.logs.watch.err; + + test('should have expected initial state', async () => { + // initial state, both counters 0, both labels red + expect(await getText('#hmr-test-1 .counter')).toBe('0'); + expect(await getText('#hmr-test-2 .counter')).toBe('0'); + expect(await getText('#hmr-test-1 .label')).toBe('hmr-test'); + expect(await getText('#hmr-test-2 .label')).toBe('hmr-test'); + expect(await getColor('#hmr-test-1 .label')).toBe('red'); + expect(await getColor('#hmr-test-2 .label')).toBe('red'); + }); + + test('should have working increment button', async () => { + // increment counter of one instance to have local state to verify after build updates + await (await getEl('#hmr-test-1 .increment')).click(); + await sleep(50); + + // counter1 = 1, counter2 = 0 + expect(await getText('#hmr-test-1 .counter')).toBe('1'); + expect(await getText('#hmr-test-2 .counter')).toBe('0'); + }); + + test('should apply css changes in HmrTest.svelte', async () => { + // update style, change label color from red to green + await updateHmrTest((content) => content.replace('color: red', 'color: green')); + + // color should have changed + expect(await getColor('#hmr-test-1 .label')).toBe('green'); + expect(await getColor('#hmr-test-2 .label')).toBe('green'); + expect(getWatchErrors(), 'error log of `build --watch` is not empty').toEqual([]); + }); + + test('should apply js change in HmrTest.svelte ', async () => { + // update script, change label value + await updateHmrTest((content) => + content.replace("const label = 'hmr-test'", "const label = 'hmr-test-updated'") + ); + expect(await getText('#hmr-test-1 .label')).toBe('hmr-test-updated'); + expect(await getText('#hmr-test-2 .label')).toBe('hmr-test-updated'); + expect(getWatchErrors(), 'error log of `build --watch` is not empty').toEqual([]); + }); + + test('should reset state of external store used by HmrTest.svelte when editing App.svelte', async () => { + // update App, add a new instance of HmrTest + await updateApp((content) => + content.replace( + '', + '\n' + ) + ); + // counter state is reset + expect(await getText('#hmr-test-1 .counter')).toBe('0'); + expect(await getText('#hmr-test-2 .counter')).toBe('0'); + // a third instance has been added + expect(await getText('#hmr-test-3 .counter')).toBe('0'); + expect(getWatchErrors(), 'error log of `build --watch` is not empty').toEqual([]); + }); + + test('should reset state of store when editing hmr-stores.js', async () => { + // change state + await (await getEl('#hmr-test-2 .increment')).click(); + await sleep(50); + expect(await getText('#hmr-test-2 .counter')).toBe('1'); + await updateStore((content) => `${content}\n/*trigger change*/\n`); + // counter state is reset + expect(await getText('#hmr-test-2 .counter')).toBe('0'); + expect(getWatchErrors(), 'error log of `build --watch` is not empty').toEqual([]); + }); + + test('should work when editing script context="module"', async () => { + expect(await getText('#hmr-with-context')).toContain('x=0 y=1 slot=1'); + expect(await getText('#hmr-without-context')).toContain('x=0 y=1 slot='); + expect(hmrCount('UsingNamed.svelte'), 'updates for UsingNamed.svelte').toBe(0); + expect(hmrCount('UsingDefault.svelte'), 'updates for UsingDefault.svelte').toBe(0); + await updateModuleContext((content) => content.replace('y = 1', 'y = 2')); + expect(await getText('#hmr-with-context')).toContain('x=0 y=2 slot=2'); + expect(await getText('#hmr-without-context')).toContain('x=0 y=2 slot='); + expect(hmrCount('UsingNamed.svelte'), 'updates for UsingNamed.svelte').toBe(0); + expect(hmrCount('UsingDefault.svelte'), 'updates for UsingDefault.svelte').toBe(0); + expect(getWatchErrors(), 'error log of `build --watch` is not empty').toEqual([]); + }); + }); +}); diff --git a/packages/e2e-tests/build-watch/index.html b/packages/e2e-tests/build-watch/index.html new file mode 100644 index 000000000..5203079ba --- /dev/null +++ b/packages/e2e-tests/build-watch/index.html @@ -0,0 +1,12 @@ + + + + + + + Svelte App + + + + + diff --git a/packages/e2e-tests/build-watch/package.json b/packages/e2e-tests/build-watch/package.json new file mode 100644 index 000000000..ed2615ab0 --- /dev/null +++ b/packages/e2e-tests/build-watch/package.json @@ -0,0 +1,23 @@ +{ + "name": "e2e-tests-build-watch", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "build:watch": "vite build --watch", + "preview": "vite preview" + }, + "dependencies": { + "e2e-test-dep-svelte-simple": "file:../_test_dependencies/svelte-simple" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "workspace:^", + "e2e-test-dep-vite-plugins": "file:../_test_dependencies/vite-plugins", + "node-fetch": "^3.3.2", + "svelte": "^5.36.13", + "vite": "^7.0.5", + "vite-plugin-inspect": "^11.3.2" + }, + "type": "module" +} diff --git a/packages/e2e-tests/build-watch/public/favicon.png b/packages/e2e-tests/build-watch/public/favicon.png new file mode 100644 index 000000000..7e6f5eb5a Binary files /dev/null and b/packages/e2e-tests/build-watch/public/favicon.png differ diff --git a/packages/e2e-tests/build-watch/src/App.svelte b/packages/e2e-tests/build-watch/src/App.svelte new file mode 100644 index 000000000..399824ca6 --- /dev/null +++ b/packages/e2e-tests/build-watch/src/App.svelte @@ -0,0 +1,40 @@ + + +

Test-App

+ +

{jsTransform}

+ +

Hello world

+ + +{#if !dynamicImportComponent} + +{:else} + +{/if} + + + + + + + + diff --git a/packages/e2e-tests/build-watch/src/assets/dynamic.png b/packages/e2e-tests/build-watch/src/assets/dynamic.png new file mode 100644 index 000000000..7e6f5eb5a Binary files /dev/null and b/packages/e2e-tests/build-watch/src/assets/dynamic.png differ diff --git a/packages/e2e-tests/build-watch/src/assets/static.png b/packages/e2e-tests/build-watch/src/assets/static.png new file mode 100644 index 000000000..7e6f5eb5a Binary files /dev/null and b/packages/e2e-tests/build-watch/src/assets/static.png differ diff --git a/packages/e2e-tests/build-watch/src/components/DynamicImport.svelte b/packages/e2e-tests/build-watch/src/components/DynamicImport.svelte new file mode 100644 index 000000000..af9cb3229 --- /dev/null +++ b/packages/e2e-tests/build-watch/src/components/DynamicImport.svelte @@ -0,0 +1,23 @@ + + +
+ {label} imported +
+ + + diff --git a/packages/e2e-tests/build-watch/src/components/HmrTest.svelte b/packages/e2e-tests/build-watch/src/components/HmrTest.svelte new file mode 100644 index 000000000..53222ac21 --- /dev/null +++ b/packages/e2e-tests/build-watch/src/components/HmrTest.svelte @@ -0,0 +1,21 @@ + + +
+ {label} + +
+ + + diff --git a/packages/e2e-tests/build-watch/src/components/StaticImport.svelte b/packages/e2e-tests/build-watch/src/components/StaticImport.svelte new file mode 100644 index 000000000..1644dcc64 --- /dev/null +++ b/packages/e2e-tests/build-watch/src/components/StaticImport.svelte @@ -0,0 +1,23 @@ + + +
+ {label} imported +
+ + + diff --git a/packages/e2e-tests/build-watch/src/components/partial-hmr/ModuleContext.svelte b/packages/e2e-tests/build-watch/src/components/partial-hmr/ModuleContext.svelte new file mode 100644 index 000000000..d3059687c --- /dev/null +++ b/packages/e2e-tests/build-watch/src/components/partial-hmr/ModuleContext.svelte @@ -0,0 +1,12 @@ + + + + +
+  x={x} y={y} slot=
+
diff --git a/packages/e2e-tests/build-watch/src/components/partial-hmr/PartialHmr.svelte b/packages/e2e-tests/build-watch/src/components/partial-hmr/PartialHmr.svelte new file mode 100644 index 000000000..45bbce756 --- /dev/null +++ b/packages/e2e-tests/build-watch/src/components/partial-hmr/PartialHmr.svelte @@ -0,0 +1,7 @@ + + + + diff --git a/packages/e2e-tests/build-watch/src/components/partial-hmr/UsingNamed.svelte b/packages/e2e-tests/build-watch/src/components/partial-hmr/UsingNamed.svelte new file mode 100644 index 000000000..5c7eee9ca --- /dev/null +++ b/packages/e2e-tests/build-watch/src/components/partial-hmr/UsingNamed.svelte @@ -0,0 +1,5 @@ + + +{y} diff --git a/packages/e2e-tests/build-watch/src/components/partial-hmr/UsingOnlyDefault.svelte b/packages/e2e-tests/build-watch/src/components/partial-hmr/UsingOnlyDefault.svelte new file mode 100644 index 000000000..d5474f66a --- /dev/null +++ b/packages/e2e-tests/build-watch/src/components/partial-hmr/UsingOnlyDefault.svelte @@ -0,0 +1,5 @@ + + + diff --git a/packages/e2e-tests/build-watch/src/index.js b/packages/e2e-tests/build-watch/src/index.js new file mode 100644 index 000000000..071c75dc7 --- /dev/null +++ b/packages/e2e-tests/build-watch/src/index.js @@ -0,0 +1,3 @@ +import App from './App.svelte'; +import { mount } from 'svelte'; +mount(App, { target: document.body }); diff --git a/packages/e2e-tests/build-watch/src/stores/hmr-stores.js b/packages/e2e-tests/build-watch/src/stores/hmr-stores.js new file mode 100644 index 000000000..1e247644f --- /dev/null +++ b/packages/e2e-tests/build-watch/src/stores/hmr-stores.js @@ -0,0 +1,16 @@ +import { writable } from 'svelte/store'; +let stores = {}; + +export function getStore(id, initialValue) { + return stores[id] || (stores[id] = writable(initialValue)); +} + +if (import.meta.hot) { + if (import.meta.hot.data.stores) { + stores = import.meta.hot.data.stores; + } + import.meta.hot.accept(); + import.meta.hot.dispose(() => { + import.meta.hot.data.stores = stores; + }); +} diff --git a/packages/e2e-tests/build-watch/vite.config.js b/packages/e2e-tests/build-watch/vite.config.js new file mode 100644 index 000000000..f195467f2 --- /dev/null +++ b/packages/e2e-tests/build-watch/vite.config.js @@ -0,0 +1,43 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; +import { transformValidation } from 'e2e-test-dep-vite-plugins'; +// import inspect from 'vite-plugin-inspect'; + +export default defineConfig(({ command, mode }) => { + return { + plugins: [ + transformValidation(), + svelte() + /* + inspect({ build: true, outputDir: '.vite-inspect' }), + { + name: 'vite-plugin-no-ssr-fallback-env', + enforce: 'post', + config: { + order: 'post', + handler(c) { + delete c.ssr; // workaround to avoid vite-plugin-inspect never finishing due to unused ssr build + } + } + } + */ + ], + build: { + minify: false, + target: 'esnext', + commonjsOptions: { + // pnpm only symlinks packages, and vite wont process cjs deps not in + // node_modules, so we add the cjs dep here + include: [/node_modules/, /cjs-only/] + } + }, + server: { + watch: { + // During tests we edit the files too fast and sometimes chokidar + // misses change events, so enforce polling for consistency + usePolling: true, + interval: 100 + } + } + }; +}); diff --git a/packages/e2e-tests/e2e-server.js b/packages/e2e-tests/e2e-server.js index 25bdc25e1..ead83d20b 100644 --- a/packages/e2e-tests/e2e-server.js +++ b/packages/e2e-tests/e2e-server.js @@ -46,7 +46,33 @@ async function startedOnPort(serverProcess, port, timeout) { }); } -export async function serve(root, isBuild, port) { +async function buildWatchIdle(watchProcess, timeout) { + let id; + let stdoutListener; + const timerPromise = new Promise( + (_, reject) => + (id = setTimeout(() => { + reject(`timeout for server start after ${timeout}`); + }, timeout)) + ); + const startedPromise = new Promise((resolve, reject) => { + stdoutListener = (data) => { + const str = data.toString(); + const match = str.match(/built in \d+ms\./); + if (match) { + resolve(); + } + }; + watchProcess.stdout.on('data', stdoutListener); + }); + + return Promise.race([timerPromise, startedPromise]).finally(() => { + watchProcess.stdout.off('data', stdoutListener); + clearTimeout(id); + }); +} + +export async function serve(root, testMode, port) { const logDir = path.join(root, 'logs'); const logs = { server: null, @@ -88,7 +114,7 @@ export async function serve(root, isBuild, port) { } } - if (isBuild) { + if (testMode === 'build') { let buildResult; let hasErr = false; const out = []; @@ -120,50 +146,72 @@ export async function serve(root, isBuild, port) { throw buildResult; } } + let watchProcess; + if (testMode === 'build:watch') { + watchProcess = execa('pnpm', ['build', '--watch'], { + cwd: root, + stdio: 'pipe' + }); + logs.watch = { out: [], err: [] }; + collectLogs(watchProcess, logs.watch); + await buildWatchIdle(watchProcess, 10000); + } - const serverProcess = execa('pnpm', [isBuild ? 'preview' : 'dev', '--port', port], { - cwd: root, - stdio: 'pipe' - }); - const out = [], - err = []; - logs.server = { out, err }; + const serverProcess = execa( + 'pnpm', + [testMode === 'serve' ? 'dev' : 'preview', '--port', port, '--strictPort'], + { + cwd: root, + stdio: 'pipe' + } + ); + logs.server = { out: [], err: [] }; collectLogs(serverProcess, logs.server); const closeServer = async () => { - if (serverProcess) { - if (serverProcess.pid) { - await new Promise((resolve) => { - treeKill(serverProcess.pid, (err) => { - if (err) { - console.error(`failed to treekill serverprocess ${serverProcess.pid}`, err); - } - resolve(); + for (const p of [watchProcess, serverProcess]) { + if (p) { + if (p.pid) { + await new Promise((resolve) => { + treeKill(p.pid, (err) => { + if (err) { + console.error( + `failed to treekill ${p === watchProcess ? 'watchprocess' : 'serverprocess'} ${p.pid}`, + err + ); + } + resolve(); + }); }); - }); - } else { - serverProcess.cancel(); - } - - try { - await serverProcess; - } catch (e) { - if (e.stdout) { - pushLines(e.stdout, out); - } - if (e.stderr) { - pushLines(e.stderr, err); + } else { + p.cancel(); } - if (!!process.env.DEBUG && !isWin) { - // treekill on windows uses taskkill and that ends up here always - console.debug(`e2e server process did not exit gracefully. dir: ${root}`, e); + + try { + await p; + } catch (e) { + const { out, err } = p === watchProcess ? logs.watch : logs.server; + if (e.stdout) { + pushLines(e.stdout, out); + } + if (e.stderr) { + pushLines(e.stderr, err); + } + if (!!process.env.DEBUG && !isWin) { + // treekill on windows uses taskkill and that ends up here always + console.debug(`e2e server process did not exit gracefully. dir: ${root}`, e); + } } } } + await writeLogs('server', logs.server); + if (logs.watch) { + await writeLogs('watch', logs.watch); + } }; try { - await startedOnPort(serverProcess, port, 20000); + await startedOnPort(serverProcess, port, 10000); return { port, logs, diff --git a/packages/e2e-tests/testUtils.ts b/packages/e2e-tests/testUtils.ts index 6f5ffa2ca..b749ee3d7 100644 --- a/packages/e2e-tests/testUtils.ts +++ b/packages/e2e-tests/testUtils.ts @@ -8,7 +8,7 @@ import colors from 'css-color-names'; import { ElementHandle } from 'playwright-core'; import fetch from 'node-fetch'; import { - isBuild, + testMode, isWin, isCI, page, @@ -80,7 +80,7 @@ export function readFileContent(filename: string) { } export function editFile(filename: string, replacer: (str: string) => string) { - if (isBuild) return; + if (testMode === 'build') return; filename = path.resolve(testDir, filename); const content = fs.readFileSync(filename, 'utf-8'); const modified = replacer(content); @@ -113,7 +113,7 @@ export async function untilMatches( matches: string, msg: string ) { - if (isBuild) return; + if (testMode === 'build') return; const maxTries = process.env.CI ? 100 : 20; for (let tries = 0; tries < maxTries; tries++) { @@ -204,6 +204,29 @@ export async function editFileAndWaitForHmrComplete(file, replacer, fileUpdateTo } } +export async function editFileAndWaitForBuildWatchComplete(file, replacer) { + const newContent = editFile(file, replacer); + + try { + await waitForBuildWatchAndPageReload(hmrUpdateTimeout); + } catch (e) { + const maxTries = isCI && isWin ? 3 : 1; + let lastErr; + for (let i = 1; i <= maxTries; i++) { + try { + console.log(`retry #${i} of build:watch update for ${file}`); + editFile(file, () => newContent + '\n'.repeat(i)); + await waitForBuildWatchAndPageReload(hmrUpdateTimeout); + return; + } catch (e) { + lastErr = e; + } + } + await saveScreenshot(`failed_update_${file}`); + throw lastErr; + } +} + export function hmrCount(file) { return browserLogs.filter((line) => line.includes('hot updated') && line.includes(file)).length; } @@ -231,11 +254,35 @@ export async function saveScreenshot(name: string) { export async function editViteConfig(replacer: (str: string) => string) { editFile('vite.config.js', replacer); - if (!isBuild) { + if (testMode === 'serve') { await waitForServerRestartAndPageReload(); } } +export async function waitForBuildWatchAndPageReload(timeout = 10000) { + const logs = e2eServer.logs.watch.out; + const startIdx = logs.length; + let timeleft = timeout; + const pollInterval = 50; + let completed = false; + while (timeleft > 0) { + await sleep(pollInterval); + if (logs.some((text, i) => i > startIdx && text.match(/\d+ modules transformed\./))) { + completed = true; + break; + } + timeleft -= pollInterval; + } + if (!completed) { + console.log('logs', logs.slice(startIdx)); + throw new Error(`watch rebuild did not finish after ${timeout}ms`); + } + + // wait for page to completely load + await sleep(100); + await page.reload(); +} + export async function waitForServerRestartAndPageReload(timeout = 10000) { const logs = e2eServer.logs.server.out; const startIdx = logs.length; diff --git a/packages/e2e-tests/vite-ssr-esm/package.json b/packages/e2e-tests/vite-ssr-esm/package.json index e5c695066..bab87e7a8 100644 --- a/packages/e2e-tests/vite-ssr-esm/package.json +++ b/packages/e2e-tests/vite-ssr-esm/package.json @@ -5,9 +5,7 @@ "type": "module", "scripts": { "dev": "node server", - "build": "run-s build:client build:server", - "build:client": "vite build --ssrManifest .vite/ssr-manifest.json --outDir dist/client", - "build:server": "vite build --ssr src/entry-server.js --outDir dist/server", + "build": "vite build --app", "preview": "cross-env NODE_ENV=production node server", "debug": "node --inspect-brk server" }, diff --git a/packages/e2e-tests/vite-ssr-esm/server.js b/packages/e2e-tests/vite-ssr-esm/server.js index babf04a9a..9b63bf11a 100644 --- a/packages/e2e-tests/vite-ssr-esm/server.js +++ b/packages/e2e-tests/vite-ssr-esm/server.js @@ -17,12 +17,6 @@ if (portArgPos > 0) { async function createServer(root = process.cwd(), isProd = process.env.NODE_ENV === 'production') { const resolve = (p) => path.resolve(root, p); - const indexProd = isProd ? fs.readFileSync(resolve('dist/client/index.html'), 'utf-8') : ''; - - const manifest = isProd - ? JSON.parse(fs.readFileSync(resolve('dist/client/.vite/ssr-manifest.json'), 'utf-8')) - : {}; - const app = polka(); /** @@ -69,10 +63,10 @@ async function createServer(root = process.cwd(), isProd = process.env.NODE_ENV template = await vite.transformIndexHtml(url, template); render = (await vite.ssrLoadModule('/src/entry-server.js')).render; } else { - template = indexProd; + template = fs.readFileSync(resolve('dist/client/index.html'), 'utf-8'); render = (await import(pathToFileURL(resolve('dist/server/entry-server.js')).href)).render; } - const rendered = await render(req.originalUrl, manifest); + const rendered = await render(req.originalUrl); const appHtml = rendered.html; const headElements = rendered.head || ''; // TODO what do we do with rendered.css here. find out if emitCss was used and vite took care of it diff --git a/packages/e2e-tests/vite-ssr-esm/src/entry-server.js b/packages/e2e-tests/vite-ssr-esm/src/entry-server.js index cce2cdf54..c91531def 100644 --- a/packages/e2e-tests/vite-ssr-esm/src/entry-server.js +++ b/packages/e2e-tests/vite-ssr-esm/src/entry-server.js @@ -5,6 +5,6 @@ import { render as ssr } from 'svelte/server'; console.log(esm()); console.log(decamelize('helloWorld')); -export async function render(url, manifest) { +export async function render(url) { return ssr(App, { props: { name: 'world' } }); } diff --git a/packages/e2e-tests/vite-ssr-esm/vite.config.js b/packages/e2e-tests/vite-ssr-esm/vite.config.js index 63c8aadaa..38c339fdd 100644 --- a/packages/e2e-tests/vite-ssr-esm/vite.config.js +++ b/packages/e2e-tests/vite-ssr-esm/vite.config.js @@ -1,9 +1,32 @@ import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; +import process from 'node:process'; +const isWatch = !!process.env.TEST_BUILD_WATCH; export default defineConfig(({ command, mode }) => { return { plugins: [svelte()], + environments: { + client: { + build: { + rollupOptions: { + output: { + dir: 'dist/client' + } + } + } + }, + ssr: { + build: { + rollupOptions: { + input: 'src/entry-server.js', + output: { + dir: 'dist/server' + } + } + } + } + }, build: { target: 'esnext', minify: false, @@ -12,7 +35,8 @@ export default defineConfig(({ command, mode }) => { output: { format: 'esm' } - } + }, + watch: isWatch ? {} : undefined }, server: { watch: { diff --git a/packages/e2e-tests/vitestSetup.ts b/packages/e2e-tests/vitestSetup.ts index a8b2adff6..d3084249d 100644 --- a/packages/e2e-tests/vitestSetup.ts +++ b/packages/e2e-tests/vitestSetup.ts @@ -6,10 +6,12 @@ import { beforeAll, type File } from 'vitest'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; -export const isBuild = !!process.env.TEST_BUILD; export const isWin = process.platform === 'win32'; export const isCI = !!process.env.CI; +export const isBuildWatch = !!process.env.TEST_BUILD_WATCH; +export const isBuild = isBuildWatch || !!process.env.TEST_BUILD; +export const testMode = isBuildWatch ? 'build:watch' : process.env.TEST_BUILD ? 'build' : 'serve'; /** * Path to the current test file */ @@ -38,7 +40,11 @@ export function setViteUrl(url: string) { export interface E2EServer { port: number; - logs: { server?: { out: string[]; err: string[] }; build?: { out: string[]; err: string[] } }; + logs: { + server?: { out: string[]; err: string[] }; + build?: { out: string[]; err: string[] }; + watch?: { out: string[]; err: string[] }; + }; close: () => Promise; } @@ -60,9 +66,9 @@ const onConsole = (msg) => { * * @param testRoot * @param testName - * @param isBuild + * @param testMode */ -const getUniqueTestPort = async (testRoot, testName, isBuild) => { +const getUniqueTestPort = async (testRoot, testName, testMode) => { const testDirs = await fs.readdir(testRoot, { withFileTypes: true }); const idx = testDirs .filter((f) => f.isDirectory()) @@ -71,7 +77,8 @@ const getUniqueTestPort = async (testRoot, testName, isBuild) => { if (idx < 0) { throw new Error(`failed to find ${testName} in ${testRoot}`); } - return (isBuild ? 5500 : 3500) + idx; + const basePort = testMode === 'build:watch' ? 7500 : testMode === 'build' ? 5500 : 3500; + return basePort + idx; }; const DIR = path.join(os.tmpdir(), 'vitest_playwright_global_setup'); @@ -105,7 +112,7 @@ beforeAll( const srcDir = path.resolve(e2eTestsRoot, testName); - tempDir = path.resolve(e2eTestsRoot, '../../temp', isBuild ? 'build' : 'serve', testName); + tempDir = path.resolve(e2eTestsRoot, '../../temp', testMode.replaceAll(':', '_'), testName); const directoriesToIgnore = [ 'node_modules', '__tests__', @@ -163,11 +170,11 @@ beforeAll( const hasCustomServer = fs.existsSync(customServerScript); const serverScript = hasCustomServer ? customServerScript : defaultServerScript; const { serve } = await import(serverScript); - const port = await getUniqueTestPort(e2eTestsRoot, testName, isBuild); - server = await serve(tempDir, isBuild, port); + const port = await getUniqueTestPort(e2eTestsRoot, testName, testMode); + server = await serve(tempDir, testMode, port); e2eServer = server; const url = (viteTestUrl = `http://localhost:${port}`); - await (isBuild ? page.goto(url) : goToUrlAndWaitForViteWSConnect(page, url)); + await (testMode !== 'serve' ? page.goto(url) : goToUrlAndWaitForViteWSConnect(page, url)); } } catch (e) { console.error(`beforeAll failed for ${testName}.`, e); @@ -236,7 +243,7 @@ async function goToUrlAndWaitForViteWSConnect(page: Page, url: string) { } export async function waitForViteConnect(page: Page, timeoutMS = 10000) { - if (isBuild) { + if (testMode !== 'serve') { return Promise.resolve(); // no vite websocket on build } let timerId; diff --git a/packages/vite-plugin-svelte/src/plugins/load-compiled-css.js b/packages/vite-plugin-svelte/src/plugins/load-compiled-css.js index dc319e155..b2616b198 100644 --- a/packages/vite-plugin-svelte/src/plugins/load-compiled-css.js +++ b/packages/vite-plugin-svelte/src/plugins/load-compiled-css.js @@ -8,9 +8,16 @@ const filter = { id: SVELTE_VIRTUAL_STYLE_ID_REGEX }; * @returns {import('vite').Plugin} */ export function loadCompiledCss(api) { + let isBuildWatch = false; + /** @type{Map} */ + const buildWatchCssCache = new Map(); return { name: 'vite-plugin-svelte:load-compiled-css', + configResolved(c) { + isBuildWatch = !!c.build?.watch; + }, + resolveId: { filter, // same filter in load to ensure minimal work handler(id) { @@ -26,7 +33,17 @@ export function loadCompiledCss(api) { if (!svelteRequest) { return; } - const cachedCss = this.getModuleInfo(svelteRequest.filename)?.meta.svelte?.css; + let cachedCss = this.getModuleInfo(svelteRequest.filename)?.meta.svelte?.css; + // in build --watch getModuleInfo only returns changed module data. + // To ensure virtual css is loaded unchanged, we cache it here separately + if (isBuildWatch) { + if (cachedCss) { + buildWatchCssCache.set(svelteRequest.filename, cachedCss); + } else { + cachedCss = buildWatchCssCache.get(svelteRequest.filename); + } + } + if (cachedCss) { const { hasGlobal, ...css } = cachedCss; if (hasGlobal === false) { @@ -37,6 +54,8 @@ export function loadCompiledCss(api) { } css.moduleType = 'css'; return css; + } else { + log.warn(`failed to load virtual css module ${id}`, undefined, 'load'); } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e65e5d866..95cf72c37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,7 @@ overrides: '@types/node@<=20.12.0': 20.19.9 send@<0.19.0: ^0.19.1 '@sveltejs/kit>cookie@<0.7.0': ^0.7.2 + vite-plugin-inspect: /home/dominikg/develop/vite-plugin-inspect importers: @@ -199,7 +200,11 @@ importers: packages/e2e-tests/_test_dependencies/types-only: {} - packages/e2e-tests/_test_dependencies/vite-plugins: {} + packages/e2e-tests/_test_dependencies/vite-plugins: + dependencies: + magic-string: + specifier: ^0.30.17 + version: 0.30.17 packages/e2e-tests/autoprefixer-browerslist: dependencies: @@ -229,6 +234,31 @@ importers: specifier: ^7.0.5 version: 7.0.5(@types/node@22.16.5)(sass@1.89.2)(stylus@0.64.0)(yaml@2.8.0) + packages/e2e-tests/build-watch: + dependencies: + e2e-test-dep-svelte-simple: + specifier: file:../_test_dependencies/svelte-simple + version: file:packages/e2e-tests/_test_dependencies/svelte-simple + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: workspace:^ + version: link:../../vite-plugin-svelte + e2e-test-dep-vite-plugins: + specifier: file:../_test_dependencies/vite-plugins + version: file:packages/e2e-tests/_test_dependencies/vite-plugins + node-fetch: + specifier: ^3.3.2 + version: 3.3.2 + svelte: + specifier: ^5.36.13 + version: 5.36.13 + vite: + specifier: ^7.0.5 + version: 7.0.5(@types/node@22.16.5)(sass@1.89.2)(stylus@0.64.0)(yaml@2.8.0) + vite-plugin-inspect: + specifier: /home/dominikg/develop/vite-plugin-inspect + version: link:../../../../../vite-plugin-inspect + packages/e2e-tests/configfile-custom: dependencies: e2e-test-dep-svelte-simple: @@ -464,7 +494,7 @@ importers: version: 5.36.13 svelte-check: specifier: ^4.3.0 - version: 4.3.0(picomatch@4.0.2)(svelte@5.36.13)(typescript@5.8.3) + version: 4.3.0(picomatch@4.0.3)(svelte@5.36.13)(typescript@5.8.3) svelte-i18n: specifier: ^4.0.1 version: 4.0.1(svelte@5.36.13) @@ -2805,6 +2835,10 @@ packages: resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} engines: {node: '>=12'} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -4701,7 +4735,9 @@ snapshots: dependencies: e2e-test-dep-cjs-only: file:packages/e2e-tests/_test_dependencies/cjs-only - e2e-test-dep-vite-plugins@file:packages/e2e-tests/_test_dependencies/vite-plugins: {} + e2e-test-dep-vite-plugins@file:packages/e2e-tests/_test_dependencies/vite-plugins: + dependencies: + magic-string: 0.30.17 eastasianwidth@0.2.0: {} @@ -5024,6 +5060,10 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fdir@6.4.6(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -5620,6 +5660,9 @@ snapshots: picomatch@4.0.2: {} + picomatch@4.0.3: + optional: true + pidtree@0.6.0: {} pify@4.0.1: {} @@ -5902,6 +5945,18 @@ snapshots: transitivePeerDependencies: - picomatch + svelte-check@4.3.0(picomatch@4.0.3)(svelte@5.36.13)(typescript@5.8.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + chokidar: 4.0.3 + fdir: 6.4.6(picomatch@4.0.3) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.36.13 + typescript: 5.8.3 + transitivePeerDependencies: + - picomatch + svelte-eslint-parser@1.3.0(svelte@5.36.13): dependencies: eslint-scope: 8.4.0 diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts index bf43f97d5..3d323a836 100644 --- a/vitest.config.e2e.ts +++ b/vitest.config.e2e.ts @@ -21,6 +21,17 @@ const exclude = [ '**/cypress/**', '**/.{idea,git,cache,output,temp}/**' ]; +const include = ['./packages/e2e-tests/**/*.spec.[tj]s']; + +const isBuildWatch = !!process.env.TEST_BUILD_WATCH; +const buildWatchPatterns = ['./packages/e2e-tests/build-watch/**/*.spec.[tj]s']; + +if (isBuildWatch) { + include.length = 0; + include.push(...buildWatchPatterns); +} else { + exclude.push(...buildWatchPatterns); +} export default defineConfig({ resolve: { @@ -29,7 +40,7 @@ export default defineConfig({ } }, test: { - include: ['./packages/e2e-tests/**/*.spec.[tj]s'], + include, exclude, setupFiles: ['./packages/e2e-tests/vitestSetup.ts'], globalSetup: ['./packages/e2e-tests/vitestGlobalSetup.ts'],