Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/style-walk-stale-runner.md
Original file line number Diff line number Diff line change
@@ -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.
120 changes: 120 additions & 0 deletions src/dev-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
}

let root: string;
let server: ViteDevServer;
let runner: ModuleRunner;
let resolver: ReturnType<typeof createDevAssetResolver>;

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<EnvironmentModuleNode>();
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 });
});
});
54 changes: 53 additions & 1 deletion src/dev-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<all.length;i++){var o=all[i];if(o!==el&&o.getAttribute("data-vite-dev-id")===v){var oa=o.hasAttribute("data-asset"),ea=el.hasAttribute("data-asset");var ssr=oa&&ea?(o.compareDocumentPosition(el)&4?el:o):oa?o:ea?el:null;if(ssr)ssr.remove();break}}};var scan=function(n){if(n.nodeType!==1)return;if(n.tagName==="STYLE")handle(n);else if(n.querySelectorAll)n.querySelectorAll("style[data-vite-dev-id]").forEach(handle)};var onMuts=function(muts){for(var i=0;i<muts.length;i++)muts[i].addedNodes.forEach(scan)};var headPhase=function(){scan(document.documentElement);new MutationObserver(onMuts).observe(document.head,{childList:true})};scan(document.documentElement);if(document.readyState==="loading"){var mo=new MutationObserver(onMuts);mo.observe(document.documentElement,{childList:true,subtree:true});document.addEventListener("DOMContentLoaded",function(){mo.disconnect();headPhase()})}else headPhase()})();`;

type FetchModule = DevEnvironment['fetchModule'];

const walkFetchers = new WeakMap<DevEnvironment, FetchModule>();

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<EnvironmentModuleNode, number>();
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<ReturnType<FetchModule>>, 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,
Expand All @@ -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 {
Expand Down Expand Up @@ -396,6 +444,10 @@ export function createDevAssetResolver(
const resolved = new Map<string, ResolvedAssets>();
const pending = new Map<string, Promise<ResolvedAssets | null>>();
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++;
Expand Down
Loading