diff --git a/playwright/layout-panels.spec.ts b/playwright/layout-panels.spec.ts index 51048a2..5a6cd82 100644 --- a/playwright/layout-panels.spec.ts +++ b/playwright/layout-panels.spec.ts @@ -69,24 +69,55 @@ test('changing preview background keeps applied preview styles', async ({ page } await expect(previewFrameRoot).toHaveCount(1) const hasComponentStylesBefore = await previewFrameRoot.evaluate(() => { - const styleElement = document.getElementById('knighted-preview-styles') - if (!(styleElement instanceof HTMLStyleElement)) { + const baseStyleElement = document.getElementById('knighted-preview-base-styles') + const userStyleElement = document.getElementById('knighted-preview-user-styles') + if ( + !(baseStyleElement instanceof HTMLStyleElement) || + !(userStyleElement instanceof HTMLStyleElement) + ) { return false } - return styleElement.textContent?.includes('.counter-button') ?? false + const baseContainsReset = + baseStyleElement.textContent?.includes('box-sizing: inherit;') + const userContainsComponentStyles = + userStyleElement.textContent?.includes('.counter-button') + const styleElements = Array.from(document.head.querySelectorAll('style')) + const baseIndex = styleElements.indexOf(baseStyleElement) + const userIndex = styleElements.indexOf(userStyleElement) + + return Boolean( + baseContainsReset && + userContainsComponentStyles && + baseIndex >= 0 && + userIndex >= 0 && + baseIndex < userIndex, + ) }) expect(hasComponentStylesBefore).toBe(true) await page.getByLabel('Background').fill('#b1aaaa') const hasComponentStylesAfter = await previewFrameRoot.evaluate(() => { - const styleElement = document.getElementById('knighted-preview-styles') - if (!(styleElement instanceof HTMLStyleElement)) { + const baseStyleElements = document.querySelectorAll('#knighted-preview-base-styles') + const userStyleElements = document.querySelectorAll('#knighted-preview-user-styles') + if (baseStyleElements.length !== 1 || userStyleElements.length !== 1) { return false } - return styleElement.textContent?.includes('.counter-button') ?? false + const baseStyleElement = baseStyleElements[0] + const userStyleElement = userStyleElements[0] + if ( + !(baseStyleElement instanceof HTMLStyleElement) || + !(userStyleElement instanceof HTMLStyleElement) + ) { + return false + } + + return ( + (baseStyleElement.textContent?.includes('box-sizing: inherit;') ?? false) && + (userStyleElement.textContent?.includes('.counter-button') ?? false) + ) }) expect(hasComponentStylesAfter).toBe(true) diff --git a/playwright/rendering-modes/core.spec.ts b/playwright/rendering-modes/core.spec.ts index 8724a65..717e085 100644 --- a/playwright/rendering-modes/core.spec.ts +++ b/playwright/rendering-modes/core.spec.ts @@ -134,6 +134,19 @@ const readLatestWorkspaceSnapshot = async (page: import('@playwright/test').Page }) } +const readPreviewUserStyleText = async (page: import('@playwright/test').Page) => { + return getPreviewFrame(page) + .locator('html') + .evaluate(() => { + const userStyleElement = document.getElementById('knighted-preview-user-styles') + if (!(userStyleElement instanceof HTMLStyleElement)) { + return '' + } + + return userStyleElement.textContent ?? '' + }) +} + test.beforeEach(async ({ page }) => { await resetWorkbenchStorage(page) }) @@ -283,11 +296,7 @@ test('preview styles require explicit import from entry graph', async ({ page }) await expect .poll(async () => { - const styleContent = await getPreviewFrame(page) - .locator('style') - .first() - .textContent() - return styleContent ?? '' + return readPreviewUserStyleText(page) }) .toContain('rgb(1, 2, 3)') @@ -302,15 +311,298 @@ test('preview styles require explicit import from entry graph', async ({ page }) await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') await expect .poll(async () => { - const styleContent = await getPreviewFrame(page) - .locator('style') - .first() - .textContent() - return styleContent ?? '' + return readPreviewUserStyleText(page) }) .not.toContain('rgb(1, 2, 3)') }) +test('top-level @import in user css is applied in preview iframe', async ({ page }) => { + await waitForInitialRender(page) + + const importedCss = encodeURIComponent('.counter-button { color: rgb(11, 22, 33); }') + + await setWorkspaceTabSource(page, { + fileName: 'app.css', + kind: 'styles', + source: [ + `@import url("data:text/css,${importedCss}");`, + '.counter-button { font-weight: 700; }', + ].join('\n'), + }) + + await setComponentEditorSource( + page, + [ + "import '../styles/app.css'", + '', + 'const App = () => ', + '', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + + await expect + .poll(async () => { + return getPreviewFrame(page) + .getByRole('button', { name: 'Imported style' }) + .evaluate(element => getComputedStyle(element).color) + }) + .toBe('rgb(11, 22, 33)') +}) + +test('non-first style tab keeps top-level @import valid', async ({ page }) => { + await waitForInitialRender(page) + + const importedCss = encodeURIComponent( + '.imported-tab-button { color: rgb(21, 31, 41); }', + ) + + await addWorkspaceTab(page, { type: 'style' }) + + await setWorkspaceTabSource(page, { + fileName: 'app.css', + kind: 'styles', + source: ['.first-style-sentinel { color: rgb(1, 2, 3); }'].join('\n'), + }) + + await setWorkspaceTabSource(page, { + fileName: 'module.css', + kind: 'styles', + source: [ + `@import url("data:text/css,${importedCss}");`, + '.imported-tab-button { font-weight: 700; }', + ].join('\n'), + }) + + await setComponentEditorSource( + page, + [ + "import '../styles/app.css'", + "import '../styles/module.css'", + '', + 'const App = () => ', + '', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + + await expect + .poll(async () => { + return getPreviewFrame(page) + .locator('html') + .evaluate(() => { + const userStyleElements = document.querySelectorAll( + 'style[id^="knighted-preview-user-styles"]', + ) + return userStyleElements.length + }) + }) + .toBe(2) + + await expect + .poll(async () => { + return getPreviewFrame(page) + .getByRole('button', { name: 'Imported from second tab' }) + .evaluate(element => getComputedStyle(element).color) + }) + .toBe('rgb(21, 31, 41)') +}) + +test('preview iframe keeps one base and one user style node across rerenders', async ({ + page, +}) => { + await waitForInitialRender(page) + + await setWorkspaceTabSource(page, { + fileName: 'app.css', + kind: 'styles', + source: ['.counter-button { color: rgb(40, 50, 60); }'].join('\n'), + }) + + await page.getByLabel('Background').fill('#123456') + + await setWorkspaceTabSource(page, { + fileName: 'app.css', + kind: 'styles', + source: ['.counter-button { color: rgb(70, 80, 90); }'].join('\n'), + }) + + await expect + .poll(async () => { + return getPreviewFrame(page) + .locator('html') + .evaluate(() => { + const baseStyleElements = document.querySelectorAll( + '#knighted-preview-base-styles', + ) + const userStyleElements = document.querySelectorAll( + '#knighted-preview-user-styles', + ) + + const baseStyleElement = document.getElementById('knighted-preview-base-styles') + const userStyleElement = document.getElementById('knighted-preview-user-styles') + + if ( + !(baseStyleElement instanceof HTMLStyleElement) || + !(userStyleElement instanceof HTMLStyleElement) + ) { + return { + baseCount: baseStyleElements.length, + userCount: userStyleElements.length, + ordered: false, + userText: '', + } + } + + const styleElements = Array.from(document.head.querySelectorAll('style')) + const baseIndex = styleElements.indexOf(baseStyleElement) + const userIndex = styleElements.indexOf(userStyleElement) + + return { + baseCount: baseStyleElements.length, + userCount: userStyleElements.length, + ordered: baseIndex >= 0 && userIndex >= 0 && baseIndex < userIndex, + userText: userStyleElement.textContent ?? '', + } + }) + }) + .toMatchObject({ + baseCount: 1, + userCount: 1, + ordered: true, + }) + + await expect + .poll(async () => { + return readPreviewUserStyleText(page) + }) + .toContain('rgb(70, 80, 90)') +}) + +test('config patch keeps preview style order stable around app head styles', async ({ + page, +}) => { + await waitForInitialRender(page) + + await setWorkspaceTabSource(page, { + fileName: 'app.css', + kind: 'styles', + source: ['.counter-button { color: rgb(80, 90, 100); }'].join('\n'), + }) + + await setComponentEditorSource( + page, + [ + "import '../styles/app.css'", + '', + "const injected = document.getElementById('app-head-style')", + 'if (!(injected instanceof HTMLStyleElement)) {', + " const style = document.createElement('style')", + " style.id = 'app-head-style'", + " style.textContent = '.counter-button { border-radius: 0px; }'", + ' document.head.append(style)', + '}', + '', + 'const App = () => ', + '', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + + await expect + .poll(async () => { + return getPreviewFrame(page) + .locator('html') + .evaluate(() => { + const baseStyleElement = document.getElementById('knighted-preview-base-styles') + const userStyleElement = document.getElementById('knighted-preview-user-styles') + const appHeadStyleElement = document.getElementById('app-head-style') + const styleElements = Array.from(document.head.querySelectorAll('style')) + + if ( + !(baseStyleElement instanceof HTMLStyleElement) || + !(userStyleElement instanceof HTMLStyleElement) || + !(appHeadStyleElement instanceof HTMLStyleElement) + ) { + return null + } + + return { + baseIndex: styleElements.indexOf(baseStyleElement), + userIndex: styleElements.indexOf(userStyleElement), + appIndex: styleElements.indexOf(appHeadStyleElement), + } + }) + }) + .not.toBeNull() + + const resolvedOrderBeforePatch = await getPreviewFrame(page) + .locator('html') + .evaluate(() => { + const baseStyleElement = document.getElementById('knighted-preview-base-styles') + const userStyleElement = document.getElementById('knighted-preview-user-styles') + const appHeadStyleElement = document.getElementById('app-head-style') + const styleElements = Array.from(document.head.querySelectorAll('style')) + + if ( + !(baseStyleElement instanceof HTMLStyleElement) || + !(userStyleElement instanceof HTMLStyleElement) || + !(appHeadStyleElement instanceof HTMLStyleElement) + ) { + return null + } + + return { + baseIndex: styleElements.indexOf(baseStyleElement), + userIndex: styleElements.indexOf(userStyleElement), + appIndex: styleElements.indexOf(appHeadStyleElement), + } + }) + + if (!resolvedOrderBeforePatch) { + throw new Error('Expected app-injected head style to exist before config patch.') + } + + expect(resolvedOrderBeforePatch.baseIndex).toBeLessThan( + resolvedOrderBeforePatch.userIndex, + ) + expect(resolvedOrderBeforePatch.userIndex).toBeLessThan( + resolvedOrderBeforePatch.appIndex, + ) + + await page.getByLabel('Background').fill('#456789') + + await expect + .poll(async () => { + return getPreviewFrame(page) + .locator('html') + .evaluate(() => { + const baseStyleElement = document.getElementById('knighted-preview-base-styles') + const userStyleElement = document.getElementById('knighted-preview-user-styles') + const appHeadStyleElement = document.getElementById('app-head-style') + const styleElements = Array.from(document.head.querySelectorAll('style')) + + if ( + !(baseStyleElement instanceof HTMLStyleElement) || + !(userStyleElement instanceof HTMLStyleElement) || + !(appHeadStyleElement instanceof HTMLStyleElement) + ) { + return null + } + + return { + baseIndex: styleElements.indexOf(baseStyleElement), + userIndex: styleElements.indexOf(userStyleElement), + appIndex: styleElements.indexOf(appHeadStyleElement), + } + }) + }) + .toEqual(resolvedOrderBeforePatch) +}) + test('nested module imports can bring styles into preview graph', async ({ page }) => { await waitForInitialRender(page) @@ -347,11 +639,7 @@ test('nested module imports can bring styles into preview graph', async ({ page await expect(getPreviewFrame(page).getByRole('button')).toContainText('Nested style') await expect .poll(async () => { - const styleContent = await getPreviewFrame(page) - .locator('style') - .first() - .textContent() - return styleContent ?? '' + return readPreviewUserStyleText(page) }) .toContain('rgb(9, 8, 7)') }) diff --git a/src/modules/preview-runtime/iframe-preview-executor.js b/src/modules/preview-runtime/iframe-preview-executor.js index abd3d38..48d8138 100644 --- a/src/modules/preview-runtime/iframe-preview-executor.js +++ b/src/modules/preview-runtime/iframe-preview-executor.js @@ -58,6 +58,7 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { renderedNodes: [], visualConfig: { cssText: '', + userStyleSheets: [], hostPadding: '', backgroundColor: '', }, @@ -114,24 +115,100 @@ const createIframeShellDocument = ({ channelId, parentOrigin, importMap }) => { ].join('\\n') } - const __knightedApplyVisualConfig = ({ cssText = '', hostPadding = '', backgroundColor = '' }) => { + const __knightedApplyVisualConfig = ({ + cssText = '', + userStyleSheets = [], + hostPadding = '', + backgroundColor = '', + }) => { + const normalizedUserStyleSheets = Array.isArray(userStyleSheets) + ? userStyleSheets + .filter(styleText => typeof styleText === 'string') + .map(styleText => String(styleText)) + : [] + const fallbackUserStyleText = typeof cssText === 'string' ? cssText : '' + const desiredUserStyleSheets = + normalizedUserStyleSheets.length > 0 + ? normalizedUserStyleSheets + : [fallbackUserStyleText] + __knightedState.visualConfig = { - cssText: typeof cssText === 'string' ? cssText : '', + cssText: fallbackUserStyleText, + userStyleSheets: desiredUserStyleSheets, hostPadding: typeof hostPadding === 'string' ? hostPadding : '', backgroundColor: typeof backgroundColor === 'string' ? backgroundColor : '', } - let styleElement = document.getElementById('knighted-preview-styles') - if (!(styleElement instanceof HTMLStyleElement)) { - styleElement = document.createElement('style') - styleElement.id = 'knighted-preview-styles' - document.head.append(styleElement) + let baseStyleElement = document.getElementById('knighted-preview-base-styles') + if (!(baseStyleElement instanceof HTMLStyleElement)) { + baseStyleElement = document.createElement('style') + baseStyleElement.id = 'knighted-preview-base-styles' + document.head.append(baseStyleElement) + } + + const desiredUserStyleElementIds = __knightedState.visualConfig.userStyleSheets.map( + (_styleText, index) => + index === 0 + ? 'knighted-preview-user-styles' + : 'knighted-preview-user-styles-' + index, + ) + + const desiredUserStyleElementIdSet = new Set(desiredUserStyleElementIds) + const existingUserStyleElements = Array.from( + document.head.querySelectorAll('style[id^="knighted-preview-user-styles"]'), + ) + for (const existingUserStyleElement of existingUserStyleElements) { + if (!desiredUserStyleElementIdSet.has(existingUserStyleElement.id)) { + existingUserStyleElement.remove() + } } - styleElement.textContent = - __knightedToBaseStyles(__knightedState.visualConfig.hostPadding) + - '\\n' + - String(__knightedState.visualConfig.cssText) + const userStyleElements = [] + let previousPreviewStyleElement = baseStyleElement + + for (const styleElementId of desiredUserStyleElementIds) { + let userStyleElement = document.getElementById(styleElementId) + if (!(userStyleElement instanceof HTMLStyleElement)) { + userStyleElement = document.createElement('style') + userStyleElement.id = styleElementId + + if ( + previousPreviewStyleElement instanceof HTMLStyleElement && + previousPreviewStyleElement.parentNode === document.head + ) { + document.head.insertBefore( + userStyleElement, + previousPreviewStyleElement.nextSibling, + ) + } else { + document.head.append(userStyleElement) + } + } + + userStyleElements.push(userStyleElement) + previousPreviewStyleElement = userStyleElement + } + + const firstUserStyleElement = userStyleElements[0] + const isBaseAfterUser = + firstUserStyleElement instanceof HTMLStyleElement && + (baseStyleElement.compareDocumentPosition(firstUserStyleElement) & + Node.DOCUMENT_POSITION_PRECEDING) !== + 0 + if (isBaseAfterUser && firstUserStyleElement instanceof HTMLStyleElement) { + document.head.insertBefore(baseStyleElement, firstUserStyleElement) + } + + baseStyleElement.textContent = __knightedToBaseStyles( + __knightedState.visualConfig.hostPadding, + ) + + for (let index = 0; index < userStyleElements.length; index += 1) { + const userStyleElement = userStyleElements[index] + userStyleElement.textContent = String( + __knightedState.visualConfig.userStyleSheets[index] ?? '', + ) + } if (__knightedState.visualConfig.hostPadding.trim().length > 0) { document.documentElement.style.setProperty( @@ -559,6 +636,7 @@ export const createWorkspaceIframePreviewBridge = ({ entryExportName, importMap, cssText, + userStyleSheets = [], hostPadding = '', backgroundColor = '', runtimeSpecifiers, @@ -594,13 +672,18 @@ export const createWorkspaceIframePreviewBridge = ({ timer, } + const stylePayload = + Array.isArray(userStyleSheets) && userStyleSheets.length > 0 + ? { userStyleSheets } + : { cssText } + const payload = { mode, entrySpecifier, entryDisplaySpecifier, entryExportName, runtimeSpecifiers, - cssText, + ...stylePayload, hostPadding, backgroundColor, importMap, diff --git a/src/modules/preview/render-runtime.js b/src/modules/preview/render-runtime.js index 1e8356d..0672bae 100644 --- a/src/modules/preview/render-runtime.js +++ b/src/modules/preview/render-runtime.js @@ -411,7 +411,7 @@ export const createRenderRuntimeController = ({ if (!entryTab) { clearStyleDiagnostics() - return { css: '', styleModuleExportsByTabId: {} } + return { css: '', userStyleSheets: [], styleModuleExportsByTabId: {} } } const runtimeSpecifiers = getWorkspaceRuntimeSpecifiers() @@ -427,7 +427,7 @@ export const createRenderRuntimeController = ({ if (!virtualModulePlan) { clearStyleDiagnostics() - return { css: '', styleModuleExportsByTabId: {} } + return { css: '', userStyleSheets: [], styleModuleExportsByTabId: {} } } const workspaceTabById = new Map( @@ -481,7 +481,7 @@ export const createRenderRuntimeController = ({ if (styleInputs.length === 0) { clearStyleDiagnostics() - const output = { css: '', styleModuleExportsByTabId: {} } + const output = { css: '', userStyleSheets: [], styleModuleExportsByTabId: {} } compiledStylesCache = { key: cacheKey, value: output, @@ -560,6 +560,7 @@ export const createRenderRuntimeController = ({ const styleModuleExportsByTabId = {} const compiledCssParts = [] + const userStyleSheets = [] for (let index = 0; index < styleInputs.length; index += 1) { const input = styleInputs[index] @@ -567,6 +568,7 @@ export const createRenderRuntimeController = ({ if (part && typeof part.css === 'string') { compiledCssParts.push(part.css) + userStyleSheets.push(part.css) } if (input?.dialect !== 'module' || !part?.moduleExports) { @@ -594,6 +596,7 @@ export const createRenderRuntimeController = ({ const output = { css: compiledCssParts.join('\n\n'), + userStyleSheets, styleModuleExportsByTabId, } if (styleWarningLines.length > 0) { @@ -708,6 +711,7 @@ export const createRenderRuntimeController = ({ const renderWorkspaceInIframe = async ({ mode, cssText, + userStyleSheets = [], styleModuleExportsByTabId = {}, }) => { const workspaceTabs = getWorkspaceTabsForPreview() @@ -784,6 +788,7 @@ export const createRenderRuntimeController = ({ entryExportName: virtualModulePlan.entryExportName, importMap: virtualModulePlan.importMap, cssText, + userStyleSheets, hostPadding, backgroundColor: getPreviewBackgroundColor(), runtimeSpecifiers, @@ -816,6 +821,7 @@ export const createRenderRuntimeController = ({ await renderWorkspaceInIframe({ mode: 'dom', cssText: compiledStyles.css, + userStyleSheets: compiledStyles.userStyleSheets, styleModuleExportsByTabId: compiledStyles.styleModuleExportsByTabId, }) } @@ -834,6 +840,7 @@ export const createRenderRuntimeController = ({ await renderWorkspaceInIframe({ mode: 'react', cssText: compiledStyles.css, + userStyleSheets: compiledStyles.userStyleSheets, styleModuleExportsByTabId: compiledStyles.styleModuleExportsByTabId, }) }