diff --git a/.changeset/style-walk-stale-runner.md b/.changeset/style-walk-stale-runner.md new file mode 100644 index 0000000..42384de --- /dev/null +++ b/.changeset/style-walk-stale-runner.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +Start mode dev no longer serves a stale module after an edit to a module a lazy route imports (#537). The dev asset resolver's style walk re-transformed invalidated SSR modules before the module runner imported them, and Vite's `fetchModule` then answered the runner's `cached: true` request with `{ cache: true }`, so the runner kept its old copy until the dev server restarted. This affected runners that rely on that handshake instead of a full reload, such as workerd under `@cloudflare/vite-plugin`. The plugin now wraps the SSR environment's `fetchModule`: it records each module's invalidation timestamp from before the runner received its code, and answers a later cached request with fresh code and `invalidate: true` when the module was invalidated since. The walk calls the unwrapped `fetchModule`, so it no longer changes what the runner sees, and no module is evaluated twice. diff --git a/src/dev-manifest.test.ts b/src/dev-manifest.test.ts new file mode 100644 index 0000000..59986ce --- /dev/null +++ b/src/dev-manifest.test.ts @@ -0,0 +1,120 @@ +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + createServer, + createServerModuleRunner, + type EnvironmentModuleNode, + type ViteDevServer, +} from 'vite'; +import type { ModuleRunner } from 'vite/module-runner'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createDevAssetResolver } from './dev-manifest.js'; + +declare global { + var __evals: Record; +} + +let root: string; +let server: ViteDevServer; +let runner: ModuleRunner; +let resolver: ReturnType; + +const track = (name: string) => + `globalThis.__evals[${JSON.stringify(name)}] = (globalThis.__evals[${JSON.stringify(name)}] ?? 0) + 1;\n`; + +async function edit(file: string, code: string) { + await writeFile(path.join(root, file), code); + // Vite's watcher path: onFileChange, then the HMR invalidation of the module and its importers. + const env = server.environments.ssr; + const changedFile = path.join(root, file); + env.moduleGraph.onFileChange(changedFile); + const seen = new Set(); + for (const mod of env.moduleGraph.getModulesByFile(changedFile)!) { + env.moduleGraph.invalidateModule(mod, seen, Date.now(), true); + } + resolver.invalidate(); +} + +beforeEach(async () => { + globalThis.__evals = {}; + root = await realpath(await mkdtemp(path.join(tmpdir(), 'solid-dev-manifest-'))); + await writeFile(path.join(root, 'page.css'), '.page { color: red }\n'); + await writeFile( + path.join(root, 'ctx.js'), + track('ctx') + 'export const ctx = { id: Math.random() };\n', + ); + await writeFile(path.join(root, 'child.js'), track('child') + "export const v = 'v0';\n"); + await writeFile( + path.join(root, 'page.js'), + track('page') + + "import './page.css';\nimport { ctx } from './ctx.js';\nexport { v } from './child.js';\nexport { ctx };\n", + ); + await writeFile( + path.join(root, 'layout.js'), + track('layout') + "export { ctx } from './ctx.js';\n", + ); + server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + server: { middlewareMode: true, ws: false, watch: null }, + plugins: [ + { + // A slow transform widens the window in which the walk and the runner request the same module. + name: 'slow-ctx', + async transform(_code, id) { + if (id.endsWith('/ctx.js')) await new Promise((r) => setTimeout(r, 50)); + }, + }, + ], + }); + resolver = createDevAssetResolver(server); + // Without HMR the runner relies on fetchModule's `cached` handshake alone, like workerd's. + runner = createServerModuleRunner(server.environments.ssr, { hmr: false }); +}); + +afterEach(async () => { + await runner.close(); + await server.close(); + await rm(root, { recursive: true, force: true }); +}); + +describe('dev asset resolver style walk (#537)', () => { + it('inlines the CSS a lazy module imports', async () => { + const assets = await resolver.resolve('page.js'); + expect(assets?.css.map((css) => typeof css === 'object' && css.id)).toEqual([ + path.join(root, 'page.css'), + ]); + }); + + it('does not leave the runner on stale code after an edit', async () => { + expect((await runner.import('/page.js')).v).toBe('v0'); + await edit('child.js', track('child') + "export const v = 'v1';\n"); + await resolver.resolve('page.js'); + expect((await runner.import('/page.js')).v).toBe('v1'); + expect(globalThis.__evals).toEqual({ ctx: 1, child: 2, page: 2 }); + }); + + it('evaluates each module once when the walk races the first render', async () => { + const [, layout, page] = await Promise.all([ + resolver.resolve('page.js'), + runner.import('/layout.js'), + runner.import('/page.js'), + ]); + expect(page.ctx).toBe(layout.ctx); + expect((await runner.import('/page.js')).ctx).toBe(layout.ctx); + expect(globalThis.__evals).toEqual({ ctx: 1, child: 1, page: 1, layout: 1 }); + }); + + it('evaluates each module once when the walk races a render after an edit', async () => { + const before = (await runner.import('/layout.js')).ctx; + await runner.import('/page.js'); + await edit('child.js', track('child') + "export const v = 'v1';\n"); + const [, page] = await Promise.all([resolver.resolve('page.js'), runner.import('/page.js')]); + expect(page.v).toBe('v1'); + expect(page.ctx).toBe(before); + expect((await runner.import('/page.js')).v).toBe('v1'); + expect(globalThis.__evals).toEqual({ ctx: 1, child: 2, page: 2, layout: 1 }); + }); +}); diff --git a/src/dev-manifest.ts b/src/dev-manifest.ts index 29dc0f2..18936a1 100644 --- a/src/dev-manifest.ts +++ b/src/dev-manifest.ts @@ -205,6 +205,54 @@ export const devStylePatch = `(function(){var P=${JSON.stringify( NULL_BYTE_PLACEHOLDER, )};var handle=function(el){var v=el.isConnected&&el.getAttribute("data-vite-dev-id");if(!v)return;if(v.indexOf(P)===0){v="\\0"+v.slice(P.length);el.setAttribute("data-vite-dev-id",v)}var all=document.querySelectorAll("style[data-vite-dev-id]");for(var i=0;i(); + +function unwrapId(id: string): string { + return id.startsWith('/@id/') ? id.slice(5).replace('__x00__', '\0') : id; +} + +function invalidationStamp(mod: EnvironmentModuleNode | undefined): number { + return mod ? Math.max(mod.lastHMRTimestamp, mod.lastInvalidationTimestamp) : 0; +} + +// Re-sends a module invalidated since the runner got it, even if the walk re-transformed it (#537). +function trackRunnerFetches(env: DevEnvironment): FetchModule { + const installed = walkFetchers.get(env); + if (installed) return installed; + const fetchModule = env.fetchModule.bind(env); + const served = new WeakMap(); + const lookup = (id: string, importer?: string) => + (importer && id[0] !== '.' && id[0] !== '/') || id.startsWith('data:') + ? undefined + : env.moduleGraph.getModuleByUrl(unwrapId(id)).catch(() => undefined); + const record = (result: Awaited>, before: number) => { + if (!('code' in result) || !result.id) return; + const mod = env.moduleGraph.getModuleById(result.id); + if (!mod) return; + // Code matching the stored result is current, whatever was invalidated mid-fetch. + const current = mod.transformResult?.code === result.code; + served.set(mod, current ? invalidationStamp(mod) : before); + }; + env.fetchModule = async (id, importer, options) => { + const mod = await lookup(id, importer); + const stamp = invalidationStamp(mod); + const result = await fetchModule(id, importer, options); + if (!('cache' in result)) { + record(result, stamp); + return result; + } + const servedStamp = mod && served.get(mod); + if (servedStamp === undefined || servedStamp === invalidationStamp(mod)) return result; + const fresh = await fetchModule(id, importer, { ...options, cached: false }); + record(fresh, stamp); + return 'code' in fresh ? { ...fresh, invalidate: true } : fresh; + }; + walkFetchers.set(env, fetchModule); + return fetchModule; +} + async function getModuleNode( env: DevEnvironment, file: string, @@ -215,7 +263,7 @@ async function getModuleNode( // context, so dep strings that are placeholder-wrapped virtual URLs // (`/@id/__x00__…`) or importer-relative specifiers land on the right // module id — a raw moduleGraph/transformRequest lookup would miss them. - const resolved = await env.fetchModule(file, importer); + const resolved = await trackRunnerFetches(env)(file, importer); if (!('id' in resolved)) return; return env.moduleGraph.getModuleById(resolved.id); } catch { @@ -396,6 +444,10 @@ export function createDevAssetResolver( const resolved = new Map(); const pending = new Map>(); const { root, base } = server.config; + // Installed before any server runner's first fetch, so every module it evaluates is tracked. + for (const env of Object.values(server.environments ?? {})) { + if (env.config.consumer === 'server') trackRunnerFetches(env); + } let generation = 0; const invalidate = () => { generation++;