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,
})
}