Skip to content
Merged
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/dev-shared-css-order.md
Original file line number Diff line number Diff line change
@@ -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 `<head>`, 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.
2 changes: 1 addition & 1 deletion examples/start-ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
115 changes: 115 additions & 0 deletions examples/start-ssr/test/style-patch.mjs
Original file line number Diff line number Diff line change
@@ -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('<!doctype html><html><head></head><body></body></html>', {
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);
}
6 changes: 4 additions & 2 deletions src/dev-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<all.length;i++){var o=all[i];if(o!==el&&o.getAttribute("data-vite-dev-id")===v){var ssr=o.hasAttribute("data-asset")?o:el.hasAttribute("data-asset")?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()})();`;
)};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()})();`;

async function getModuleNode(
env: DevEnvironment,
Expand Down
Loading