diff --git a/electron/previewClient/morphAssets.ts b/electron/previewClient/morphAssets.ts index c62ef1be..44beccf2 100644 --- a/electron/previewClient/morphAssets.ts +++ b/electron/previewClient/morphAssets.ts @@ -180,28 +180,44 @@ export function loadStyles(page: Document): void { // to the page reaches the browser as a reload, which this patch replaces, and // nothing else would ever bring the page's new CSS: the canvas kept the // stylesheet the page loaded with, whatever the style panel wrote. The -// rendering just fetched holds every dev stylesheet as the server compiled it -// for this edit, so a live one whose text differs takes the new text. One -// whose text is the same is left alone: a rewritten stylesheet restarts every -// animation it defines, on every keystroke of a text edit. -export function refreshDevStyles(page: Document): void { - const fresh = new Map(); - const served = page.querySelectorAll('style[data-vite-dev-id]'); - for (let i = 0; i < served.length; i++) { - const id = served[i]?.getAttribute('data-vite-dev-id'); - if (id !== null && id !== undefined) { - fresh.set(id, served[i]?.textContent ?? ''); - } - } +// rendering just fetched holds that stylesheet as the server compiled it for +// this edit, so a live one takes the text the server has just changed. One the +// server renders as it did before is left alone, for two reasons. A rewritten +// stylesheet restarts every animation it defines, on every keystroke of a text +// edit. And the server's text is not always the newest: a stylesheet that +// reaches the page through a CSS \`@import\` is rendered from the server's +// first compile for as long as it runs, while Vite hot-swaps the live one on +// every write. Comparing against the live text put that first compile back on +// each patch, and the canvas dropped every style edit made to such a file. +export function refreshDevStyles(previous: Document, next: Document): void { + const before = devStyleTexts(previous); + const fresh = devStyleTexts(next); const live = document.querySelectorAll('style[data-vite-dev-id]'); for (let i = 0; i < live.length; i++) { const style = live[i]; - const text = fresh.get(style?.getAttribute('data-vite-dev-id') ?? ''); + const id = style?.getAttribute('data-vite-dev-id') ?? ''; + const text = fresh.get(id); if (style === undefined || text === undefined) { continue; // Not in this rendering: Vite's own, or a component that left. } + if (before.get(id) === text) { + continue; // The server did not change it: the live text is Vite's to keep. + } if (style.textContent !== text) { style.textContent = text; } } } + +// Each dev stylesheet of a server rendering, by the id Vite gave it. +function devStyleTexts(page: Document): Map { + const texts = new Map(); + const served = page.querySelectorAll('style[data-vite-dev-id]'); + for (let i = 0; i < served.length; i++) { + const id = served[i]?.getAttribute('data-vite-dev-id'); + if (id !== null && id !== undefined) { + texts.set(id, served[i]?.textContent ?? ''); + } + } + return texts; +} diff --git a/electron/previewClient/morphClient.ts b/electron/previewClient/morphClient.ts index d523bd09..cc07d525 100644 --- a/electron/previewClient/morphClient.ts +++ b/electron/previewClient/morphClient.ts @@ -148,11 +148,12 @@ async function update(): Promise { patchAttrs(liveRoot, previousRoot, nextRoot); patchChildren(document.head, previousDocument.head, next.clean.head); patchChildren(document.body, previousDocument.body, next.clean.body); + const renderedBefore = previousDocument; previousDocument = next.clean; // After the patch, so a component that has just appeared is styled by the // time anything measures it — and running by the time anything clicks it. loadStyles(next.clean); - refreshDevStyles(next.clean); + refreshDevStyles(renderedBefore, next.clean); runScripts(added); const liveBody = document.body; const serverBody = next.withAnchors.body; diff --git a/test/electron/previewClient/morph.test.js b/test/electron/previewClient/morph.test.js index 9cf49321..2d806edc 100644 --- a/test/electron/previewClient/morph.test.js +++ b/test/electron/previewClient/morph.test.js @@ -554,10 +554,17 @@ const LIVE_TABS = (labels, active) => // A page's ` + - `` + - '', - 'text/html', - ); + // Vite has hot-swapped this one since the server first compiled it. + const sheet = devStyle(SHEET, '.pad { padding: 3rem; }'); + head.append(page, card, own, sheet); + const rendering = (pageCss) => + new dom.window.DOMParser().parseFromString( + '' + + `` + + `` + + `` + + '', + 'text/html', + ); + const before = rendering('.box { color: red; }'); + const served = rendering('.box { color: blue; }'); const writes = []; const observer = new dom.window.MutationObserver((records) => writes.push(...records)); observer.observe(head, { childList: true, subtree: true, characterData: true }); - refreshDevStyles(served); + refreshDevStyles(before, served); writes.push(...observer.takeRecords()); observer.disconnect(); check( @@ -600,6 +614,27 @@ const LIVE_TABS = (labels, active) => 'a stylesheet the rendering does not carry is left to Vite', own.textContent === '.vite { margin: 0; }', ); + check( + 'a stylesheet the server renders stale keeps the text Vite gave it', + sheet.textContent === '.pad { padding: 3rem; }', + sheet.textContent, + ); + // A stylesheet new to the server's rendering has no earlier text to agree with. + const arrived = devStyle(CARD, '.card { gap: 0; }'); + head.append(arrived); + const without = new dom.window.DOMParser().parseFromString( + '', + 'text/html', + ); + card.remove(); + refreshDevStyles(without, served); + check( + 'a stylesheet the previous rendering lacked takes the server text', + arrived.textContent === '.card { gap: 1rem; }', + arrived.textContent, + ); + arrived.remove(); + head.append(card); check( 'exactly one stylesheet was written', new Set( @@ -612,6 +647,7 @@ const LIVE_TABS = (labels, active) => page.remove(); card.remove(); own.remove(); + sheet.remove(); } if (failures.length) {