diff --git a/.changeset/dev-shared-css-order.md b/.changeset/dev-shared-css-order.md new file mode 100644 index 0000000..c3f2e65 --- /dev/null +++ b/.changeset/dev-shared-css-order.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +Start mode dev: a stylesheet shared by the entry graph and a lazy component now keeps its first position in ``, as in production. The dev shell renders it twice. The dev style patch used to drop the earlier copy, so the stylesheet moved after the lazy component's own styles and equal-specificity overrides in the lazy component lost. When both copies are server-rendered, the patch now drops the later one. diff --git a/examples/start-ssr/package.json b/examples/start-ssr/package.json index fb275b1..2594a52 100644 --- a/examples/start-ssr/package.json +++ b/examples/start-ssr/package.json @@ -6,7 +6,7 @@ "dev": "vite", "build": "vite build", "serve": "NODE_ENV=production node server.js", - "test": "node test/run.mjs && node test/http-bridge.mjs && node test/components-warning.mjs && node test/webworker-warning.mjs && node test/dedupe.mjs && node test/ssr-inline.mjs" + "test": "node test/run.mjs && node test/http-bridge.mjs && node test/components-warning.mjs && node test/webworker-warning.mjs && node test/dedupe.mjs && node test/style-patch.mjs && node test/ssr-inline.mjs" }, "devDependencies": { "jsdom": "^26.1.0", diff --git a/examples/start-ssr/test/style-patch.mjs b/examples/start-ssr/test/style-patch.mjs new file mode 100644 index 0000000..f6f9ff8 --- /dev/null +++ b/examples/start-ssr/test/style-patch.mjs @@ -0,0 +1,115 @@ +// Unit test for the dev style patch (`devStylePatch`), run in jsdom: +// - two SSR'd twins (both `data-asset`, e.g. a stylesheet shared by the +// entry graph and a lazy component): the later one is dropped, so the +// sheet keeps its first position, as in production (#425), +// - an SSR'd tag and a Vite-owned twin: the SSR'd one is dropped, whatever +// the order, so HMR keeps updating the tag that stays. +// +// No dev server, no browser. Requires the plugin built (pnpm build at the +// repo root). Usage: node test/style-patch.mjs + +import { JSDOM } from 'jsdom'; +import { devStylePatch } from '@solidjs/vite-plugin'; + +const results = []; +function record(name, ok, detail = '') { + results.push({ name, ok, detail }); + console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail && !ok ? ` — ${detail}` : ''}`); +} + +async function setup() { + const dom = new JSDOM('', { + runScripts: 'outside-only', + }); + const { window } = dom; + if (window.document.readyState === 'loading') { + await new Promise((r) => window.document.addEventListener('DOMContentLoaded', r)); + } + window.eval(devStylePatch); + return window; +} + +function style(window, id, { ssr }) { + const el = window.document.createElement('style'); + if (ssr) el.setAttribute('data-asset', id); + el.setAttribute('data-vite-dev-id', id); + el.textContent = `/* ${id} */`; + return el; +} + +// Let the patch's MutationObserver callbacks run. +const flush = () => new Promise((r) => setTimeout(r, 0)); + +function order(window) { + return [...window.document.querySelectorAll('style[data-vite-dev-id]')].map( + (el) => + el.getAttribute('data-vite-dev-id') + (el.hasAttribute('data-asset') ? ':ssr' : ':vite'), + ); +} + +// ---- two SSR'd twins arriving one after another (streamed shell) ---------- +{ + const window = await setup(); + const head = window.document.head; + head.append(style(window, '/src/button.css', { ssr: true })); + await flush(); + head.append(style(window, '/src/page.css', { ssr: true })); + await flush(); + head.append(style(window, '/src/button.css', { ssr: true })); + await flush(); + const got = order(window).join(', '); + record( + 'streamed SSR twins: the later one is dropped', + got === '/src/button.css:ssr, /src/page.css:ssr', + got, + ); +} + +// ---- two SSR'd twins arriving in the same mutation batch ------------------- +{ + const window = await setup(); + window.document.head.append( + style(window, '/src/button.css', { ssr: true }), + style(window, '/src/page.css', { ssr: true }), + style(window, '/src/button.css', { ssr: true }), + ); + await flush(); + const got = order(window).join(', '); + record( + 'batched SSR twins: the later one is dropped, the first one stays', + got === '/src/button.css:ssr, /src/page.css:ssr', + got, + ); +} + +// ---- SSR'd tag, then a Vite-owned twin: the SSR'd one is dropped ---------- +{ + const window = await setup(); + const head = window.document.head; + head.append(style(window, '/src/a.css', { ssr: true })); + await flush(); + head.append(style(window, '/src/a.css', { ssr: false })); + await flush(); + const got = order(window).join(', '); + record('SSR then Vite twin: the Vite-owned tag stays', got === '/src/a.css:vite', got); +} + +// ---- Vite-owned tag, then an SSR'd twin: the SSR'd one is dropped --------- +{ + const window = await setup(); + const head = window.document.head; + head.append(style(window, '/src/a.css', { ssr: false })); + await flush(); + head.append(style(window, '/src/a.css', { ssr: true })); + await flush(); + const got = order(window).join(', '); + record('Vite then SSR twin: the Vite-owned tag stays', got === '/src/a.css:vite', got); +} + +const failed = results.filter((r) => !r.ok); +console.log(`\n${results.length - failed.length}/${results.length} assertions passed`); +if (failed.length) { + console.log('\nFailures:'); + for (const f of failed) console.log(` ${f.name} — ${f.detail}`); + process.exit(1); +} diff --git a/src/dev-manifest.ts b/src/dev-manifest.ts index 80c6ea7..1786809 100644 --- a/src/dev-manifest.ts +++ b/src/dev-manifest.ts @@ -183,7 +183,9 @@ function wrapId(id: string): string { * seeded is missed by the scan, so the CSS module injects its own copy * client-side. Whenever two style tags share a `data-vite-dev-id`, the * SSR'd one (marked `data-asset`) is removed in favor of the Vite-owned - * one, which is the tag HMR updates. + * one, which is the tag HMR updates. When both are SSR'd (a stylesheet + * shared by the entry graph and a lazy component), the later one is + * removed so the sheet keeps its first position, as in production. * * Observation is two-phase to stay cheap: a document-wide subtree observer * only for the streaming window (SSR tags can only arrive while the parser @@ -198,7 +200,7 @@ function wrapId(id: string): string { */ export const devStylePatch = `(function(){var P=${JSON.stringify( NULL_BYTE_PLACEHOLDER, -)};var handle=function(el){var v=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