From b0887c0d1b67f5c83863bd5e1ceaee0bdc076c5d Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Mon, 19 Oct 2026 10:06:05 +0800 Subject: [PATCH] fix(dev): keep a shared stylesheet in its first position In start mode dev, a stylesheet imported by both the entry graph and a lazy component is rendered twice in the shell. When both copies were server-rendered, the dev style patch removed the earlier one, so the stylesheet moved after the lazy component's styles. The patch now removes the later copy in that case, and skips tags that were already removed. Fixes #425 Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/dev-shared-css-order.md | 5 ++ examples/start-ssr/package.json | 2 +- examples/start-ssr/test/style-patch.mjs | 115 ++++++++++++++++++++++++ src/dev-manifest.ts | 6 +- 4 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 .changeset/dev-shared-css-order.md create mode 100644 examples/start-ssr/test/style-patch.mjs 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