diff --git a/.changeset/dev-manifest-css-invalidation.md b/.changeset/dev-manifest-css-invalidation.md new file mode 100644 index 00000000..4e954256 --- /dev/null +++ b/.changeset/dev-manifest-css-invalidation.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +Invalidate the dev manifest's CSS cache in isolated SSR runners after file changes. Subsequent server responses include the updated styles before hydration. Responses started before invalidation cannot restore old cache entries or remove newer pending requests. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8b2d52bd..19ba6503 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -31,5 +31,9 @@ jobs: - name: Build project run: pnpm run build + - name: Test isolated dev manifest cache + working-directory: examples/css-matrix + run: node test/bridge.mjs && node test/cache.mjs + - name: Run tests run: pnpm run test diff --git a/examples/css-matrix/package.json b/examples/css-matrix/package.json index 93577fd6..abd598c0 100644 --- a/examples/css-matrix/package.json +++ b/examples/css-matrix/package.json @@ -8,7 +8,7 @@ "build:client": "vite build --outDir dist/client", "build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server", "serve": "NODE_ENV=production node server.js", - "test": "node test/run.mjs && node test/bridge.mjs" + "test": "node test/run.mjs && node test/bridge.mjs && node test/cache.mjs" }, "devDependencies": { "vite": "^8.2.1", diff --git a/examples/css-matrix/test/cache.mjs b/examples/css-matrix/test/cache.mjs new file mode 100644 index 00000000..c614ae36 --- /dev/null +++ b/examples/css-matrix/test/cache.mjs @@ -0,0 +1,262 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { test } from 'node:test'; +import { runInNewContext } from 'node:vm'; +import solid from '@solidjs/vite-plugin'; +import { + createServer, + createServerHotChannel, + createServerModuleRunner, + DevEnvironment, +} from 'vite'; + +const EVENT = 'solid:dev-manifest-invalidate'; +const KEY = 'page.ts'; + +async function within(promise) { + let timer; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Manifest invalidation did not arrive')), 5000); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +async function fixture(context, plugins = []) { + const root = await realpath(await mkdtemp(path.join(tmpdir(), 'solid-bridge-cache-'))); + await writeFile(path.join(root, KEY), "import './page.css'"); + const css = path.join(root, 'page.css'); + await writeFile(css, '.probe { width: 18px; }'); + let fetchAssets = fetch; + let runner; + let server; + context.after(async () => { + await runner?.close(); + await server?.close(); + await rm(root, { recursive: true, force: true }); + }); + server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + appType: 'custom', + optimizeDeps: { noDiscovery: true, include: [] }, + server: { host: '127.0.0.1', port: 0 }, + environments: { + ssr: { + dev: { + createEnvironment(name, config) { + const transport = createServerHotChannel(); + const send = transport.send.bind(transport); + transport.send = (payload) => { + // Nitro consumes full-reload itself; the manifest can retain its evaluation. + if (payload.type !== 'full-reload') send(payload); + }; + return new DevEnvironment(name, config, { hot: true, transport }); + }, + }, + }, + }, + plugins: [ + solid({ ssr: true }), + { + name: 'bridge-cache:probe', + resolveId(id) { + if (id === 'virtual:bridge-cache-probe') return '\0' + id; + }, + load(id) { + if (id !== '\0virtual:bridge-cache-probe') return; + return `let complete; + import.meta.hot.on(${JSON.stringify(EVENT)}, () => { + complete?.(); + complete = undefined; + }); + export const next = () => new Promise(resolve => { complete = resolve; });`; + }, + }, + ...plugins, + ], + }); + await server.listen(); + runner = createServerModuleRunner(server.environments.ssr, { + sourcemapInterceptor: false, + hmr: { logger: false }, + evaluator: { + async runInlinedModule(moduleContext, code) { + await runInNewContext(`(async () => { ${code}\n })()`, { + ...moduleContext, + fetch: (...args) => fetchAssets(...args), + URL, + console, + }); + }, + runExternalModule: (file) => import(file), + }, + }); + const manifest = (await runner.import('virtual:solid-manifest')).default; + const probe = await runner.import('virtual:bridge-cache-probe'); + return { + manifest, + css, + nextInvalidation: () => within(probe.next()), + async invalidate() { + const received = within(probe.next()); + server.environments.ssr.hot.send({ type: 'custom', event: EVENT }); + await received; + }, + setFetch: (value) => { + fetchAssets = value; + }, + async reimport() { + const module = server.environments.ssr.moduleGraph.getModuleById('\0virtual:solid-manifest'); + server.environments.ssr.moduleGraph.invalidateModule(module); + return (await runner.import('virtual:solid-manifest')).default; + }, + }; +} + +function response(value) { + return Response.json({ js: ['/' + KEY], css: [{ content: value }] }); +} + +test( + 'CSS edits invalidate the same isolated manifest instance', + { timeout: 15000 }, + async (context) => { + const { manifest, css, nextInvalidation } = await fixture(context); + const pending = manifest.resolve(KEY); + assert.equal(manifest.resolve(KEY), pending); + const before = await pending; + assert.match(JSON.stringify(before.css), /18px/); + assert.equal(manifest.resolve(KEY), before); + + const invalidated = nextInvalidation(); + await writeFile(css, '.probe { width: 36px; }'); + await invalidated; + const after = await manifest.resolve(KEY); + assert.match(JSON.stringify(after.css), /36px/); + assert.doesNotMatch(JSON.stringify(after.css), /18px/); + assert.equal(manifest.resolve(KEY), after); + assert.equal(manifest.resolveSync(KEY), after); + }, +); + +test( + 'an asynchronous watchChange cannot leave stale CSS cached', + { timeout: 15000 }, + async (context) => { + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + const processed = Promise.withResolvers(); + context.after(() => release.resolve()); + const { manifest, css, nextInvalidation } = await fixture(context, [ + { + name: 'bridge-cache:slow-watch-change', + async watchChange(file) { + if (!file.endsWith('/page.css')) return; + started.resolve(); + await release.promise; + }, + hotUpdate({ file }) { + if (file.endsWith('/page.css') && this.environment.name === 'ssr') processed.resolve(); + }, + }, + ]); + await manifest.resolve(KEY); + const invalidated = nextInvalidation(); + await writeFile(css, '.probe { width: 36px; }'); + await within(started.promise); + const during = await manifest.resolve(KEY); + assert.match(JSON.stringify(during.css), /18px/); + release.resolve(); + await within(processed.promise); + await invalidated; + const after = await manifest.resolve(KEY); + assert.match(JSON.stringify(after.css), /36px/); + assert.doesNotMatch(JSON.stringify(after.css), /18px/); + assert.equal(manifest.resolve(KEY), after); + }, +); + +for (const order of ['old-first', 'new-first', 'old-rejects']) { + test(`in-flight invalidation: ${order}`, { timeout: 15000 }, async (context) => { + const fixtureState = await fixture(context); + const { manifest } = fixtureState; + const oldFetch = Promise.withResolvers(); + const newFetch = Promise.withResolvers(); + let calls = 0; + fixtureState.setFetch(() => (++calls === 1 ? oldFetch.promise : newFetch.promise)); + const old = manifest.resolve(KEY); + const oldResult = old.then( + (value) => ({ value }), + (error) => ({ error }), + ); + assert.equal(manifest.resolve(KEY), old); + + await fixtureState.invalidate(); + const current = manifest.resolve(KEY); + assert.notEqual(current, old); + assert.equal(calls, 2); + + if (order === 'new-first') { + newFetch.resolve(response('36px')); + const fresh = await current; + oldFetch.resolve(response('18px')); + await oldResult; + assert.equal(manifest.resolve(KEY), fresh); + } else { + oldFetch.resolve(order === 'old-rejects' ? new Response('invalid JSON') : response('18px')); + const previous = await oldResult; + assert.equal(Boolean(previous.error), order === 'old-rejects'); + assert.equal(manifest.resolve(KEY), current); + assert.equal(manifest.resolveSync(KEY).css.length, 0); + newFetch.resolve(response('36px')); + await current; + } + assert.equal(calls, 2); + assert.match(JSON.stringify(manifest.resolve(KEY).css), /36px/); + assert.doesNotMatch(JSON.stringify(manifest.resolve(KEY).css), /18px/); + }); +} + +test( + 'a bridge failure remains retryable after invalidation', + { timeout: 15000 }, + async (context) => { + const state = await fixture(context); + state.setFetch(() => Promise.resolve(Response.json(null))); + assert.equal(await state.manifest.resolve(KEY), null); + state.setFetch(() => Promise.resolve(response('18px'))); + const before = await state.manifest.resolve(KEY); + assert.equal(state.manifest.resolve(KEY), before); + await state.invalidate(); + state.setFetch(() => Promise.resolve(response('36px'))); + const after = await state.manifest.resolve(KEY); + assert.match(JSON.stringify(after.css), /36px/); + assert.equal(state.manifest.resolve(KEY), after); + }, +); + +test( + 're-evaluation leaves only the current manifest subscribed', + { timeout: 15000 }, + async (context) => { + const state = await fixture(context); + const before = await state.manifest.resolve(KEY); + const next = await state.reimport(); + assert.notEqual(next, state.manifest); + await next.resolve(KEY); + await state.invalidate(); + assert.equal(state.manifest.resolve(KEY), before); + assert.equal(next.resolveSync(KEY).css.length, 0); + const after = await next.resolve(KEY); + assert.equal(next.resolve(KEY), after); + }, +); diff --git a/src/dev-manifest.ts b/src/dev-manifest.ts index 80c6ea76..1b2ec576 100644 --- a/src/dev-manifest.ts +++ b/src/dev-manifest.ts @@ -48,6 +48,8 @@ export type DevAssetResolver = { resolveSync: (key: string) => ResolvedAssets; }; +type InvalidatableDevAssetResolver = DevAssetResolver & { invalidate: () => void }; + // The resolver is created plugin-side (it closes over the dev server) but is // called from the SSR module runner, which only shares `globalThis` with the // plugin when it runs in-process (the default). The primary channel is a @@ -74,6 +76,7 @@ export function registerDevAssetResolver(root: string, resolver: DevAssetResolve * registry and never touch HTTP. */ export const DEV_MANIFEST_ENDPOINT = '/@solidjs/vite-plugin/dev-manifest'; +export const DEV_MANIFEST_INVALIDATE_EVENT = 'solid:dev-manifest-invalidate'; export function installDevManifestBridge(server: ViteDevServer): void { // configureServer middlewares run ahead of Vite's internals, so `req.url` @@ -376,7 +379,7 @@ export function devModuleUrl(root: string, base: string, key: string): string { export function createDevAssetResolver( server: ViteDevServer, filter: DevStyleFilter = defaultStyleFilter, -): DevAssetResolver { +): InvalidatableDevAssetResolver { // Server-side lazy() re-requests a module's assets on every retry of a // suspended render pass (retries re-create the component). The build // manifest answers those repeats synchronously and the pass converges; an @@ -392,11 +395,12 @@ export function createDevAssetResolver( const pending = new Map>(); const { root, base } = server.config; let generation = 0; - server.watcher.on('all', () => { + const invalidate = () => { generation++; resolved.clear(); pending.clear(); - }); + }; + server.watcher.on('all', invalidate); const resolve = function resolveDevAssets( key: string, @@ -431,6 +435,7 @@ export function createDevAssetResolver( return walk; }; return { + invalidate, resolve, resolveSync: (key: string) => resolved.get(normalizeModuleKey(key)) ?? { js: [devModuleUrl(root, base, key)], css: [] }, diff --git a/src/index.ts b/src/index.ts index ced30641..c24ad4e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { installDevManifestBridge, devManifestBridgeUrl, DEV_MANIFEST_REGISTRY_KEY, + DEV_MANIFEST_INVALIDATE_EVENT, } from './dev-manifest.js'; import { boundaryModules } from './boundary-modules.js'; import { solidDiagnostics } from './diagnostics/index.js'; @@ -129,33 +130,42 @@ function moduleUrl(key) { const jsOnly = key => ({ js: [moduleUrl(key)], css: [] }); const bridgeUrl = ${JSON.stringify(bridgeUrl)}; function createBridgeResolver() { - // Convergence cache, mirroring the in-process resolver: server-side lazy() - // re-requests assets on every retry of a suspended render pass, and only a - // synchronous answer lets the pass converge (a fresh promise per call - // suspends every retry anew — nested routes then loop until the render - // stack overflows). Cached entries can go stale after a CSS edit (no - // watcher reaches this side of the bridge); the HMR client replaces SSR'd - // dev styles on load, so staleness self-heals at hydration. Only successful - // answers are cached: a null (bridge failure) must stay retryable, or one - // transient miss would strip the module's client assets — silently — for - // the rest of the dev session. In-flight dedupe still gives retries of the - // same pass a stable promise, so convergence holds either way. + // Lazy SSR retries need synchronous cache hits to converge. Failed bridge + // requests stay retryable; concurrent lookups share one pending promise. const cache = new Map(); const inFlight = new Map(); + let generation = 0; + const invalidate = () => { + generation++; + cache.clear(); + inFlight.clear(); + }; + if (import.meta.hot) { + // Hosts such as Nitro consume full-reload before it reaches this runner. + import.meta.hot.on(${JSON.stringify(DEV_MANIFEST_INVALIDATE_EVENT)}, invalidate); + import.meta.hot.dispose(() => { + import.meta.hot.off(${JSON.stringify(DEV_MANIFEST_INVALIDATE_EVENT)}, invalidate); + invalidate(); + }); + } + return { resolve(key) { const cached = cache.get(key); if (cached) return cached; let request = inFlight.get(key); if (!request) { + const startedAt = generation; request = fetchAssets(key).then( (assets) => { - if (assets) cache.set(key, assets); - inFlight.delete(key); + if (generation === startedAt) { + if (assets) cache.set(key, assets); + inFlight.delete(key); + } return assets; }, (error) => { - inFlight.delete(key); + if (generation === startedAt) inFlight.delete(key); throw error; }, ); @@ -1194,6 +1204,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // The live dev server, kept so the dev manifest module can bake the bridge // endpoint URL in when its code is generated (see devManifestBridgeUrl). let devServer: ViteDevServer | null = null; + let devAssetResolver: ReturnType | undefined; let projectRoot = process.cwd(); let isTestMode = false; let serverTestPosture = false; @@ -1870,10 +1881,8 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // that don't share globals with this process, through the HTTP bridge // endpoint the middleware serves. if (options.ssr || options.start) { - registerDevAssetResolver( - server.config.root, - createDevAssetResolver(server, filterDevStyles), - ); + devAssetResolver = createDevAssetResolver(server, filterDevStyles); + registerDevAssetResolver(server.config.root, devAssetResolver); installDevManifestBridge(server); } if (!needHmr) return; @@ -1921,6 +1930,11 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { }, async hotUpdate({ file, modules, read }) { + if (devAssetResolver && this.environment.config.consumer === 'server') { + // Vite awaits watchChange and invalidates transforms before hotUpdate. + devAssetResolver.invalidate(); + this.environment.hot.send({ type: 'custom', event: DEV_MANIFEST_INVALIDATE_EVENT }); + } if (isTsrxModule(file) && this.environment.name === 'client') { updateTsrxCss(tsrxCss, file, await compileTsrxCss(await read(), file)); const cssModule = this.environment.moduleGraph.getModuleById(resolvedTsrxCssModuleId(file));