diff --git a/playwright/diagnostics.spec.ts b/playwright/diagnostics.spec.ts
index d573440..99c8000 100644
--- a/playwright/diagnostics.spec.ts
+++ b/playwright/diagnostics.spec.ts
@@ -62,7 +62,7 @@ test('clear component diagnostics removes type errors and restores rendered stat
await expect(page.getByText(/Rendered \(Type errors: [1-9]\d*\)/)).toBeVisible()
await ensureDiagnosticsDrawerOpen(page)
- await page.getByRole('button', { name: 'Reset component' }).click()
+ await page.getByRole('button', { name: 'Reset types' }).click()
await expect(page.getByText('No diagnostics yet.')).toHaveCount(2)
await expect(diagnosticsToggle).toHaveText('Diagnostics')
@@ -419,9 +419,9 @@ test('clear component diagnostics resets rendered lint-issue status pill', async
)
await ensureDiagnosticsDrawerOpen(page)
- await page.getByRole('button', { name: 'Reset component' }).click()
+ await page.getByRole('button', { name: 'Reset lint' }).click()
- await expect(page.getByText('No diagnostics yet.')).toHaveCount(2)
+ await expect(page.locator('#diagnostics-styles')).toContainText('No diagnostics yet.')
await expect(diagnosticsToggle).toHaveText('Diagnostics')
await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--neutral/)
await expect(page.getByText('Rendered', { exact: true })).toHaveClass(/status--neutral/)
@@ -458,7 +458,7 @@ test('component lint ignores only unused App binding', async ({ page }) => {
expect(diagnosticsText).toContain('This function render is unused')
})
-test('component lint with unresolved issues enters pending diagnostics state while typing', async ({
+test('component lint with unresolved issues becomes stale and waits for manual rerun', async ({
page,
}) => {
await waitForInitialRender(page)
@@ -481,10 +481,147 @@ test('component lint with unresolved issues enters pending diagnostics state whi
),
)
- await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--pending/)
- await expect(diagnosticsToggle).toHaveAttribute('aria-busy', 'true')
-
- await expect(page.getByText(/Rendered \(Lint issues: [1-9]\d*\)/)).toBeVisible()
- await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/)
+ await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--neutral/)
await expect(diagnosticsToggle).toHaveAttribute('aria-busy', 'false')
+
+ await ensureDiagnosticsDrawerOpen(page)
+ await expect(page.locator('#diagnostics-styles')).toContainText(
+ 'Source changed. Click Lint to run diagnostics.',
+ )
+
+ await expect(page.getByText('Rendered', { exact: true })).toBeVisible()
+})
+
+test('styles active tab shows lint-only diagnostics drawer actions', async ({ page }) => {
+ await waitForInitialRender(page)
+
+ await ensurePanelToolsVisible(page, 'styles')
+ await ensureDiagnosticsDrawerOpen(page)
+
+ await expect(page.locator('[data-diagnostics-scope="component"]')).toBeHidden()
+ await expect(page.locator('#diagnostics-clear-styles')).toHaveText('Reset lint')
+ await expect(page.locator('#diagnostics-clear-styles')).toBeVisible()
+ await expect(page.locator('#diagnostics-clear-component')).toBeHidden()
+ await expect(page.locator('#diagnostics-clear-all')).toBeHidden()
+})
+
+test('component lint completion is ignored after switching to another component tab', async ({
+ page,
+}) => {
+ await waitForInitialRender(page)
+
+ await ensurePanelToolsVisible(page, 'component')
+ await addWorkspaceTab(page)
+
+ const heavyLintSource = [
+ ...Array.from({ length: 120 }, (_, index) => `const unused${index} = ${index}`),
+ 'const App = () => ',
+ ].join('\n')
+
+ await setWorkspaceTabSource(page, {
+ fileName: 'module.tsx',
+ kind: 'component',
+ source: heavyLintSource,
+ })
+
+ const lintTrigger = page.getByRole('button', { name: 'Lint' }).first()
+ await lintTrigger.click()
+
+ await setComponentEditorSource(
+ page,
+ 'const App = () => ',
+ )
+
+ await expect(page.locator('#diagnostics-styles')).toContainText(
+ 'Source changed. Click Lint to run diagnostics.',
+ )
+ await expect(page.locator('#diagnostics-styles')).not.toContainText(
+ 'Biome reported issues.',
+ )
+ await expect(page.getByRole('button', { name: /^Diagnostics/ })).toHaveClass(
+ /diagnostics-toggle--neutral/,
+ )
+})
+
+test('switching tabs clears diagnostics while drawer is open', async ({ page }) => {
+ await waitForInitialRender(page)
+
+ await setComponentEditorSource(page, 'const App = () => ')
+ await runComponentLint(page)
+
+ await ensureDiagnosticsDrawerOpen(page)
+ await expect(page.locator('#diagnostics-styles')).toContainText(
+ 'Biome reported issues.',
+ )
+
+ await page.getByRole('button', { name: 'Open tab app.css' }).click()
+
+ await expect(page.locator('#diagnostics-styles')).toContainText('No diagnostics yet.')
+ await expect(page.getByRole('button', { name: /^Diagnostics/ })).toHaveClass(
+ /diagnostics-toggle--neutral/,
+ )
+})
+
+test('same-tab edits with drawer open replace lint issues with stale state', async ({
+ page,
+}) => {
+ await waitForInitialRender(page)
+
+ await setComponentEditorSource(
+ page,
+ ['const count: string = 1', 'const App = () => '].join('\n'),
+ )
+
+ await runTypecheck(page)
+ await runComponentLint(page)
+ await ensureDiagnosticsDrawerOpen(page)
+
+ await expect(page.locator('#diagnostics-component')).toContainText('TypeScript found')
+ await expect(page.locator('#diagnostics-styles')).toContainText(
+ 'Biome reported issues.',
+ )
+
+ await setComponentEditorSource(
+ page,
+ [
+ 'const count: string = "ok"',
+ 'const App = () => ',
+ ].join('\n'),
+ )
+
+ await expect(page.locator('#diagnostics-component')).not.toContainText('TS2322')
+ await expect(page.locator('#diagnostics-styles')).toContainText(
+ 'Source changed. Click Lint to run diagnostics.',
+ )
+ await expect(page.locator('#diagnostics-styles')).not.toContainText(
+ 'Biome reported issues.',
+ )
+})
+
+test('reset lint on styles tab clears in-flight component lint state', async ({
+ page,
+}) => {
+ await waitForInitialRender(page)
+
+ const heavyLintSource = [
+ ...Array.from({ length: 120 }, (_, index) => `const unused${index} = ${index}`),
+ 'const App = () => ',
+ ].join('\n')
+
+ await setComponentEditorSource(page, heavyLintSource)
+
+ await ensureDiagnosticsDrawerOpen(page)
+ await page.getByRole('button', { name: 'Lint' }).first().click()
+
+ await page.getByRole('button', { name: 'Open tab app.css' }).click()
+ await page.locator('#diagnostics-clear-styles').click()
+
+ await expect(page.getByRole('button', { name: /^Diagnostics/ })).toHaveAttribute(
+ 'aria-busy',
+ 'false',
+ )
+ await expect(page.locator('#diagnostics-styles')).toContainText('No diagnostics yet.')
+ await expect(page.locator('#diagnostics-styles')).not.toContainText(
+ 'Biome reported issues.',
+ )
})
diff --git a/src/app.js b/src/app.js
index 0cc6bc9..7602057 100644
--- a/src/app.js
+++ b/src/app.js
@@ -28,6 +28,7 @@ import {
createWorkspaceContextSnapshotGetter,
toStyleModeForTabLanguage,
} from './modules/app-core/workspace-local-helpers.js'
+import { createDiagnosticsTabStateHelpers } from './modules/app-core/diagnostics-tab-state-helpers.js'
import { createWorkspaceEditorHelpers } from './modules/app-core/workspace-editor-helpers.js'
import { createEditedIndicatorVisibilityController } from './modules/app-core/edited-indicator-visibility-controller.js'
import { createPublishTrailingNewlineNormalizer } from './modules/app-core/publish-trailing-newline-normalizer.js'
@@ -187,6 +188,14 @@ const diagnosticsClearStyles = document.getElementById('diagnostics-clear-styles
const diagnosticsClearAll = document.getElementById('diagnostics-clear-all')
const diagnosticsComponent = document.getElementById('diagnostics-component')
const diagnosticsStyles = document.getElementById('diagnostics-styles')
+const diagnosticsComponentSection = document.querySelector(
+ '[data-diagnostics-scope="component"]',
+)
+const diagnosticsStylesSection = document.querySelector(
+ '[data-diagnostics-scope="styles"]',
+)
+const diagnosticsComponentHeading = diagnosticsComponentSection?.querySelector('h3')
+const diagnosticsStylesHeading = diagnosticsStylesSection?.querySelector('h3')
const appToast = document.getElementById('app-toast')
const previewBgColorInput = document.getElementById('preview-bg-color')
const clearConfirmDialog = document.getElementById('clear-confirm-dialog')
@@ -224,6 +233,7 @@ cssEditor.value = defaultCss
let previewHost = document.getElementById('preview-host')
let jsxCodeEditor = null
let cssCodeEditor = null
+let diagnosticsFlowController = null
let getJsxSource = () => jsxEditor.value
let getCssSource = () => cssEditor.value
let renderRuntime = null
@@ -647,11 +657,40 @@ const workspaceSyncController = createWorkspaceSyncController({
const getLoadedComponentWorkspaceTab = () =>
workspaceTabsState.getTab(loadedComponentTabId) ?? getWorkspaceTabByKind('component')
+const getLoadedStylesWorkspaceTab = () =>
+ workspaceTabsState.getTab(loadedStylesTabId) ?? getWorkspaceTabByKind('styles')
+
const getTypecheckSourcePath = () => {
const loadedComponentTab = getLoadedComponentWorkspaceTab()
return toNonEmptyWorkspaceText(loadedComponentTab?.path) || defaultComponentTabPath
}
+const {
+ clearDiagnosticsOnTabSwitch,
+ getComponentLintTarget,
+ getStylesLintTarget,
+ syncDiagnosticsDrawerLayout,
+} = createDiagnosticsTabStateHelpers({
+ getActiveWorkspaceTab,
+ getLoadedComponentWorkspaceTab,
+ getLoadedStylesWorkspaceTab,
+ getTabKind,
+ toNonEmptyWorkspaceText,
+ diagnosticsComponentSection,
+ diagnosticsStylesSection,
+ diagnosticsComponentHeading,
+ diagnosticsStylesHeading,
+ diagnosticsClearComponent,
+ diagnosticsClearStyles,
+ diagnosticsClearAll,
+ clearAllDiagnostics,
+ setTypeDiagnosticsPending,
+ setLintDiagnosticsPending,
+ statusNode,
+ setStatus,
+ getDiagnosticsFlowController: () => diagnosticsFlowController,
+})
+
const createWorkspaceTabId = prefix => createWorkspaceTabIdFactory(prefix)
const makeUniqueTabPath = ({ basePath, suffix = '' }) =>
@@ -734,6 +773,13 @@ const {
},
persistRenderMode: mode => persistRenderMode(mode),
getActiveWorkspaceTab,
+ onActiveWorkspaceTabChange: (_tab, { changed } = {}) => {
+ syncDiagnosticsDrawerLayout()
+
+ if (changed) {
+ clearDiagnosticsOnTabSwitch()
+ }
+ },
loadWorkspaceTabIntoEditor,
updateRenderModeEditability: () => updateRenderModeEditability(),
getHasCompletedInitialWorkspaceBootstrap: () => hasCompletedInitialWorkspaceBootstrap,
@@ -1223,6 +1269,8 @@ const runtimeCoreOptions = createRuntimeCoreOptions({
getJsxSource: () => getJsxSource(),
getCssSource: () => getCssSource(),
getTypecheckSourcePath,
+ getComponentLintTarget,
+ getStylesLintTarget,
buildWorkspaceTabsSnapshot,
renderMode,
styleMode,
@@ -1267,7 +1315,7 @@ const runtimeCoreOptions = createRuntimeCoreOptions({
})
const runtimeCore = createRuntimeCoreSetup(runtimeCoreOptions)
-const diagnosticsFlowController = runtimeCore.diagnosticsFlowController
+diagnosticsFlowController = runtimeCore.diagnosticsFlowController
renderRuntime = runtimeCore.renderRuntime
const setCdnLoading = runtimeCore.setCdnLoading
const typeDiagnostics = diagnosticsFlowController.typeDiagnostics
@@ -1360,6 +1408,8 @@ bindAppEventsAndStart({
renderPreview,
setJsxSource,
setCssSource,
+ persistActiveTabEditorContent,
+ getWorkspaceTabsSnapshot: () => workspaceTabsState.getTabs(),
queueWorkspaceSave,
maybeRender,
maybeRenderFromComponentEditorChange,
@@ -1389,6 +1439,7 @@ bindAppEventsAndStart({
updateRenderModeEditability,
loadPreferredWorkspaceContext,
getActiveWorkspaceTab,
+ getTabKind,
setActiveWorkspaceTab,
workspaceTabsState,
loadedStylesTabIdRef: {
@@ -1397,6 +1448,7 @@ bindAppEventsAndStart({
},
},
getWorkspaceTabByKind,
+ syncDiagnosticsDrawerLayout,
workspaceSaveController,
workspaceStorage,
bindWorkspaceMetadataPersistence,
diff --git a/src/modules/app-core/app-bindings-startup.js b/src/modules/app-core/app-bindings-startup.js
index 0bb38d4..75e6e62 100644
--- a/src/modules/app-core/app-bindings-startup.js
+++ b/src/modules/app-core/app-bindings-startup.js
@@ -50,6 +50,8 @@ const bindAppEventsAndStart = ({
renderPreview,
setJsxSource,
setCssSource,
+ persistActiveTabEditorContent,
+ getWorkspaceTabsSnapshot,
queueWorkspaceSave,
maybeRender,
maybeRenderFromComponentEditorChange,
@@ -79,6 +81,7 @@ const bindAppEventsAndStart = ({
updateRenderModeEditability,
loadPreferredWorkspaceContext,
getActiveWorkspaceTab,
+ getTabKind,
setActiveWorkspaceTab,
workspaceTabsState,
loadedStylesTabIdRef,
@@ -86,6 +89,7 @@ const bindAppEventsAndStart = ({
workspaceSaveController,
workspaceStorage,
bindWorkspaceMetadataPersistence,
+ syncDiagnosticsDrawerLayout,
setHasCompletedInitialWorkspaceBootstrap,
} = workspaceUi
const { appThemeButtons, applyTheme, getInitialTheme, getInitialRenderMode } = themeUi
@@ -172,6 +176,18 @@ const bindAppEventsAndStart = ({
}
}
+ const syncAndCaptureDiagnosticsSnapshot = () => {
+ persistActiveTabEditorContent()
+
+ const activeTab = getActiveWorkspaceTab()
+ const tabsSnapshot = getWorkspaceTabsSnapshot()
+
+ return {
+ activeTab,
+ tabsSnapshot,
+ }
+ }
+
renderMode.addEventListener('change', () => {
applyRenderMode({ mode: renderMode.value })
})
@@ -209,6 +225,7 @@ const bindAppEventsAndStart = ({
diagnosticsClearStyles.addEventListener('click', () => {
clearDiagnosticsScope('styles')
clearStylesLintDiagnosticsState()
+ clearComponentLintDiagnosticsState()
})
}
if (diagnosticsClearAll) {
@@ -224,26 +241,49 @@ const bindAppEventsAndStart = ({
}
if (typecheckButton) {
typecheckButton.addEventListener('click', () => {
+ const { activeTab, tabsSnapshot } = syncAndCaptureDiagnosticsSnapshot()
+ const source =
+ getTabKind(activeTab) === 'component' && typeof activeTab?.content === 'string'
+ ? activeTab.content
+ : getJsxSource()
+ const sourcePath =
+ getTabKind(activeTab) === 'component' && typeof activeTab?.path === 'string'
+ ? activeTab.path
+ : getTypecheckSourcePath()
+
typeDiagnostics.triggerTypeDiagnostics({
userInitiated: true,
- source: getJsxSource(),
- sourcePath: getTypecheckSourcePath(),
+ source,
+ sourcePath,
+ workspaceTabs: tabsSnapshot,
})
})
}
if (lintComponentButton) {
lintComponentButton.addEventListener('click', () => {
+ const { activeTab } = syncAndCaptureDiagnosticsSnapshot()
+ const source =
+ getTabKind(activeTab) === 'component' && typeof activeTab?.content === 'string'
+ ? activeTab.content
+ : getJsxSource()
+
void runComponentLint({
userInitiated: true,
- source: getJsxSource(),
+ source,
})
})
}
if (lintStylesButton) {
lintStylesButton.addEventListener('click', () => {
+ const { activeTab } = syncAndCaptureDiagnosticsSnapshot()
+ const source =
+ getTabKind(activeTab) === 'styles' && typeof activeTab?.content === 'string'
+ ? activeTab.content
+ : getCssSource()
+
void runStylesLint({
userInitiated: true,
- source: getCssSource(),
+ source,
})
})
}
@@ -450,6 +490,7 @@ const bindAppEventsAndStart = ({
updateRenderButtonVisibility()
setDiagnosticsDrawerOpen(false)
setTypeDiagnosticsDetails({ headline: '' })
+ syncDiagnosticsDrawerLayout()
renderRuntime.setStyleCompiling(false)
setCdnLoading(true)
previewBackground.initializePreviewBackgroundPicker()
@@ -462,6 +503,7 @@ const bindAppEventsAndStart = ({
const activeTab = getActiveWorkspaceTab()
if (activeTab) {
setActiveWorkspaceTab(activeTab.id)
+ syncDiagnosticsDrawerLayout()
}
const stylesTab =
diff --git a/src/modules/app-core/app-composition-options.js b/src/modules/app-core/app-composition-options.js
index 4ffa2e4..2118dc5 100644
--- a/src/modules/app-core/app-composition-options.js
+++ b/src/modules/app-core/app-composition-options.js
@@ -10,6 +10,8 @@ const createRuntimeCoreOptions = ({
getJsxSource,
getCssSource,
getTypecheckSourcePath,
+ getComponentLintTarget,
+ getStylesLintTarget,
buildWorkspaceTabsSnapshot,
renderMode,
styleMode,
@@ -64,6 +66,8 @@ const createRuntimeCoreOptions = ({
getJsxSource,
getCssSource,
getTypecheckSourcePath,
+ getComponentLintTarget,
+ getStylesLintTarget,
getWorkspaceTabs: () => buildWorkspaceTabsSnapshot(),
getRenderMode: () => renderMode.value,
getStyleMode: () => styleMode.value,
diff --git a/src/modules/app-core/diagnostics-flow-controller.js b/src/modules/app-core/diagnostics-flow-controller.js
index d92cab5..1b34445 100644
--- a/src/modules/app-core/diagnostics-flow-controller.js
+++ b/src/modules/app-core/diagnostics-flow-controller.js
@@ -8,6 +8,8 @@ const createDiagnosticsFlowController = ({
getJsxSource,
getCssSource,
getTypecheckSourcePath,
+ getComponentLintTarget = () => null,
+ getStylesLintTarget = () => null,
getWorkspaceTabs,
getRenderMode,
getStyleMode,
@@ -33,12 +35,59 @@ const createDiagnosticsFlowController = ({
}) => {
let activeComponentLintAbortController = null
let activeStylesLintAbortController = null
- let lastComponentLintIssueCount = 0
- let lastStylesLintIssueCount = 0
let scheduledComponentLintRecheck = null
let scheduledStylesLintRecheck = null
let componentLintPending = false
let stylesLintPending = false
+ let componentLintSourceVersion = 0
+ let stylesLintSourceVersion = 0
+
+ const normalizeLintTargetIdentity = target => {
+ const tabId = typeof target?.tabId === 'string' ? target.tabId.trim() : ''
+ const path = typeof target?.path === 'string' ? target.path.trim() : ''
+ const language =
+ typeof target?.language === 'string' ? target.language.trim().toLowerCase() : ''
+
+ return {
+ tabId,
+ path,
+ language,
+ key: `${tabId}|${path}|${language}`,
+ }
+ }
+
+ const getCurrentLintTargetIdentity = scope => {
+ const resolveTarget =
+ scope === 'styles' ? getStylesLintTarget : getComponentLintTarget
+ return normalizeLintTargetIdentity(resolveTarget())
+ }
+
+ const createLintRunContext = scope => {
+ const target = getCurrentLintTargetIdentity(scope)
+ const sourceVersion =
+ scope === 'styles' ? stylesLintSourceVersion : componentLintSourceVersion
+
+ return {
+ scope,
+ sourceVersion,
+ targetKey: target.key,
+ }
+ }
+
+ const isLintRunContextCurrent = (scope, runContext) => {
+ if (!runContext || runContext.scope !== scope) {
+ return false
+ }
+
+ const currentSourceVersion =
+ scope === 'styles' ? stylesLintSourceVersion : componentLintSourceVersion
+ if (runContext.sourceVersion !== currentSourceVersion) {
+ return false
+ }
+
+ const currentTarget = getCurrentLintTargetIdentity(scope)
+ return runContext.targetKey === currentTarget.key
+ }
const setTypecheckButtonLoading = isLoading => {
const runtimeTypecheckButton = document.getElementById('typecheck-button')
@@ -116,13 +165,31 @@ const createDiagnosticsFlowController = ({
}
}
+ const restoreStatusAfterLintInvalidation = () => {
+ if (typeDiagnostics.getLastTypeErrorCount() > 0) {
+ setStatus(
+ `Rendered (Type errors: ${typeDiagnostics.getLastTypeErrorCount()})`,
+ 'error',
+ )
+ return
+ }
+
+ if (
+ statusNode.textContent.startsWith('Rendered (Lint issues:') ||
+ statusNode.textContent.startsWith('Linting component with Biome...') ||
+ statusNode.textContent.startsWith('Linting styles with Biome...')
+ ) {
+ setStatus('Rendered', 'neutral')
+ }
+ }
+
const lintDiagnostics = createLintDiagnosticsController({
cdnImports,
importFromCdnWithFallback,
getComponentSource: getJsxSource,
getStylesSource: getCssSource,
getStyleMode,
- setComponentDiagnostics: setTypeDiagnosticsDetails,
+ setComponentDiagnostics: setStyleDiagnosticsDetails,
setStyleDiagnostics: setStyleDiagnosticsDetails,
setStatus,
onIssuesDetected: ({ issueCount }) => {
@@ -151,6 +218,8 @@ const createDiagnosticsFlowController = ({
}
const runComponentLint = ({ userInitiated = false, source = undefined } = {}) => {
+ const runContext = createLintRunContext('component')
+
activeComponentLintAbortController?.abort()
const controller = new AbortController()
activeComponentLintAbortController = controller
@@ -165,11 +234,19 @@ const createDiagnosticsFlowController = ({
signal: controller.signal,
userInitiated,
source,
+ runContext,
+ isRunContextCurrent: context => isLintRunContextCurrent('component', context),
})
.then(result => {
- if (result) {
- lastComponentLintIssueCount = result.issueCount
+ if (!result && !isLintRunContextCurrent('component', runContext)) {
+ setStyleDiagnosticsDetails({
+ headline: 'Source changed. Click Lint to run diagnostics.',
+ level: 'muted',
+ })
+
+ restoreStatusAfterLintInvalidation()
}
+
return result
})
.finally(() => {
@@ -182,6 +259,8 @@ const createDiagnosticsFlowController = ({
}
const runStylesLint = ({ userInitiated = false, source = undefined } = {}) => {
+ const runContext = createLintRunContext('styles')
+
activeStylesLintAbortController?.abort()
const controller = new AbortController()
activeStylesLintAbortController = controller
@@ -196,11 +275,19 @@ const createDiagnosticsFlowController = ({
signal: controller.signal,
userInitiated,
source,
+ runContext,
+ isRunContextCurrent: context => isLintRunContextCurrent('styles', context),
})
.then(result => {
- if (result) {
- lastStylesLintIssueCount = result.issueCount
+ if (!result && !isLintRunContextCurrent('styles', runContext)) {
+ setStyleDiagnosticsDetails({
+ headline: 'Source changed. Click Lint to run diagnostics.',
+ level: 'muted',
+ })
+
+ restoreStatusAfterLintInvalidation()
}
+
return result
})
.finally(() => {
@@ -217,52 +304,32 @@ const createDiagnosticsFlowController = ({
}
const markComponentLintDiagnosticsStale = () => {
+ componentLintSourceVersion += 1
clearComponentLintRecheckTimer()
- if (lastComponentLintIssueCount > 0) {
- componentLintPending = true
- syncLintPendingState()
- setTypeDiagnosticsDetails({
- headline: 'Source changed. Re-checking lint issues…',
- level: 'muted',
- })
-
- scheduledComponentLintRecheck = setTimeout(() => {
- scheduledComponentLintRecheck = null
- void runComponentLint()
- }, 450)
- return
- }
+ activeComponentLintAbortController?.abort()
+ activeComponentLintAbortController = null
+ lintDiagnostics.cancelComponent()
+ setLintButtonLoading({ button: lintComponentButton, isLoading: false })
componentLintPending = false
syncLintPendingState()
- setTypeDiagnosticsDetails({
+ setStyleDiagnosticsDetails({
headline: 'Source changed. Click Lint to run diagnostics.',
level: 'muted',
})
- if (statusNode.textContent.startsWith('Rendered (Lint issues:')) {
- setStatus('Rendered', 'neutral')
- }
+ restoreStatusAfterLintInvalidation()
}
const markStylesLintDiagnosticsStale = () => {
+ stylesLintSourceVersion += 1
clearStylesLintRecheckTimer()
- if (lastStylesLintIssueCount > 0) {
- stylesLintPending = true
- syncLintPendingState()
- setStyleDiagnosticsDetails({
- headline: 'Source changed. Re-checking lint issues…',
- level: 'muted',
- })
-
- scheduledStylesLintRecheck = setTimeout(() => {
- scheduledStylesLintRecheck = null
- void runStylesLint()
- }, 450)
- return
- }
+ activeStylesLintAbortController?.abort()
+ activeStylesLintAbortController = null
+ lintDiagnostics.cancelStyles()
+ setLintButtonLoading({ button: lintStylesButton, isLoading: false })
stylesLintPending = false
syncLintPendingState()
@@ -271,20 +338,18 @@ const createDiagnosticsFlowController = ({
level: 'muted',
})
- if (statusNode.textContent.startsWith('Rendered (Lint issues:')) {
- setStatus('Rendered', 'neutral')
- }
+ restoreStatusAfterLintInvalidation()
}
const clearComponentLintDiagnosticsState = () => {
- lastComponentLintIssueCount = 0
+ componentLintSourceVersion += 1
componentLintPending = false
clearComponentLintRecheckTimer()
syncLintPendingState()
}
const clearStylesLintDiagnosticsState = () => {
- lastStylesLintIssueCount = 0
+ stylesLintSourceVersion += 1
stylesLintPending = false
clearStylesLintRecheckTimer()
syncLintPendingState()
diff --git a/src/modules/app-core/diagnostics-tab-state-helpers.js b/src/modules/app-core/diagnostics-tab-state-helpers.js
new file mode 100644
index 0000000..29bfe04
--- /dev/null
+++ b/src/modules/app-core/diagnostics-tab-state-helpers.js
@@ -0,0 +1,120 @@
+const createDiagnosticsTabStateHelpers = ({
+ getActiveWorkspaceTab,
+ getLoadedComponentWorkspaceTab,
+ getLoadedStylesWorkspaceTab,
+ getTabKind,
+ toNonEmptyWorkspaceText,
+ diagnosticsComponentSection,
+ diagnosticsStylesSection,
+ diagnosticsComponentHeading,
+ diagnosticsStylesHeading,
+ diagnosticsClearComponent,
+ diagnosticsClearStyles,
+ diagnosticsClearAll,
+ clearAllDiagnostics,
+ setTypeDiagnosticsPending,
+ setLintDiagnosticsPending,
+ statusNode,
+ setStatus,
+ getDiagnosticsFlowController,
+}) => {
+ const syncDiagnosticsDrawerLayout = () => {
+ const activeTab = getActiveWorkspaceTab()
+ const isStylesTab = getTabKind(activeTab) === 'styles'
+
+ if (diagnosticsComponentSection instanceof HTMLElement) {
+ diagnosticsComponentSection.hidden = isStylesTab
+ }
+
+ if (diagnosticsStylesSection instanceof HTMLElement) {
+ diagnosticsStylesSection.hidden = false
+ }
+
+ if (diagnosticsComponentHeading instanceof HTMLElement) {
+ diagnosticsComponentHeading.textContent = 'Typecheck'
+ }
+
+ if (diagnosticsStylesHeading instanceof HTMLElement) {
+ diagnosticsStylesHeading.textContent = 'Lint'
+ }
+
+ if (diagnosticsClearComponent instanceof HTMLButtonElement) {
+ diagnosticsClearComponent.hidden = isStylesTab
+ diagnosticsClearComponent.textContent = 'Reset types'
+ }
+
+ if (diagnosticsClearStyles instanceof HTMLButtonElement) {
+ diagnosticsClearStyles.hidden = false
+ diagnosticsClearStyles.textContent = 'Reset lint'
+ }
+
+ if (diagnosticsClearAll instanceof HTMLButtonElement) {
+ diagnosticsClearAll.hidden = isStylesTab
+ diagnosticsClearAll.textContent = 'Reset all'
+ }
+ }
+
+ const clearDiagnosticsOnTabSwitch = () => {
+ clearAllDiagnostics()
+ setTypeDiagnosticsPending(false)
+ setLintDiagnosticsPending(false)
+
+ if (statusNode.textContent.startsWith('Rendered (Type errors:')) {
+ setStatus('Rendered', 'neutral')
+ }
+
+ if (statusNode.textContent.startsWith('Rendered (Lint issues:')) {
+ setStatus('Rendered', 'neutral')
+ }
+
+ const diagnosticsFlowController = getDiagnosticsFlowController()
+ if (diagnosticsFlowController) {
+ diagnosticsFlowController.typeDiagnostics.cancelTypeDiagnostics()
+ diagnosticsFlowController.clearComponentLintDiagnosticsState()
+ diagnosticsFlowController.clearStylesLintDiagnosticsState()
+ }
+ }
+
+ const getComponentLintTarget = () => {
+ const activeTab = getActiveWorkspaceTab()
+ const tab =
+ activeTab && getTabKind(activeTab) === 'component'
+ ? activeTab
+ : getLoadedComponentWorkspaceTab()
+ if (!tab) {
+ return null
+ }
+
+ return {
+ tabId: toNonEmptyWorkspaceText(tab.id),
+ path: toNonEmptyWorkspaceText(tab.path),
+ language: toNonEmptyWorkspaceText(tab.language),
+ }
+ }
+
+ const getStylesLintTarget = () => {
+ const activeTab = getActiveWorkspaceTab()
+ const tab =
+ activeTab && getTabKind(activeTab) === 'styles'
+ ? activeTab
+ : getLoadedStylesWorkspaceTab()
+ if (!tab) {
+ return null
+ }
+
+ return {
+ tabId: toNonEmptyWorkspaceText(tab.id),
+ path: toNonEmptyWorkspaceText(tab.path),
+ language: toNonEmptyWorkspaceText(tab.language),
+ }
+ }
+
+ return {
+ clearDiagnosticsOnTabSwitch,
+ getComponentLintTarget,
+ getStylesLintTarget,
+ syncDiagnosticsDrawerLayout,
+ }
+}
+
+export { createDiagnosticsTabStateHelpers }
diff --git a/src/modules/app-core/workspace-controllers-setup.js b/src/modules/app-core/workspace-controllers-setup.js
index 707869e..68cd0e7 100644
--- a/src/modules/app-core/workspace-controllers-setup.js
+++ b/src/modules/app-core/workspace-controllers-setup.js
@@ -32,6 +32,7 @@ const createWorkspaceControllersSetup = ({
persistRenderMode,
onWorkspaceRecordApplied,
getActiveWorkspaceTab,
+ onActiveWorkspaceTabChange,
loadWorkspaceTabIntoEditor,
updateRenderModeEditability,
getHasCompletedInitialWorkspaceBootstrap,
@@ -108,6 +109,7 @@ const createWorkspaceControllersSetup = ({
persistActiveTabEditorContent,
getActiveWorkspaceTab,
flushWorkspaceSave,
+ onActiveWorkspaceTabChange,
})
const setActiveWorkspaceTab = tabId =>
diff --git a/src/modules/app-core/workspace-tab-selection-controller.js b/src/modules/app-core/workspace-tab-selection-controller.js
index a63c5d3..7410dc3 100644
--- a/src/modules/app-core/workspace-tab-selection-controller.js
+++ b/src/modules/app-core/workspace-tab-selection-controller.js
@@ -7,6 +7,7 @@ const createWorkspaceTabSelectionController = ({
persistActiveTabEditorContent,
getActiveWorkspaceTab,
flushWorkspaceSave,
+ onActiveWorkspaceTabChange = () => {},
}) => {
const setActiveWorkspaceTab = tabId => {
const normalizedTabId = toNonEmptyWorkspaceText(tabId)
@@ -24,6 +25,7 @@ const createWorkspaceTabSelectionController = ({
loadWorkspaceTabIntoEditor(targetTab)
renderWorkspaceTabs()
updateRenderModeEditability()
+ onActiveWorkspaceTabChange(targetTab, { changed: false })
return
}
@@ -33,6 +35,7 @@ const createWorkspaceTabSelectionController = ({
const activeTab = getActiveWorkspaceTab()
if (activeTab) {
loadWorkspaceTabIntoEditor(activeTab)
+ onActiveWorkspaceTabChange(activeTab, { changed: true })
}
renderWorkspaceTabs()
diff --git a/src/modules/diagnostics/lint-diagnostics.js b/src/modules/diagnostics/lint-diagnostics.js
index 043b3d9..68f9781 100644
--- a/src/modules/diagnostics/lint-diagnostics.js
+++ b/src/modules/diagnostics/lint-diagnostics.js
@@ -300,10 +300,24 @@ export const createLintDiagnosticsController = ({
}
}
+ const shouldCommitRun = ({ runId, currentRunId, runContext, isRunContextCurrent }) => {
+ if (runId !== currentRunId) {
+ return false
+ }
+
+ if (typeof isRunContextCurrent === 'function') {
+ return isRunContextCurrent(runContext)
+ }
+
+ return true
+ }
+
const lintComponent = async ({
signal,
userInitiated = false,
source = undefined,
+ runContext = null,
+ isRunContextCurrent = () => true,
} = {}) => {
componentLintRunId += 1
const runId = componentLintRunId
@@ -322,7 +336,14 @@ export const createLintDiagnosticsController = ({
signal,
})
- if (runId !== componentLintRunId) {
+ if (
+ !shouldCommitRun({
+ runId,
+ currentRunId: componentLintRunId,
+ runContext,
+ isRunContextCurrent,
+ })
+ ) {
return null
}
@@ -352,7 +373,14 @@ export const createLintDiagnosticsController = ({
issueCount: summary.lines.length,
}
} catch (error) {
- if (runId !== componentLintRunId) {
+ if (
+ !shouldCommitRun({
+ runId,
+ currentRunId: componentLintRunId,
+ runContext,
+ isRunContextCurrent,
+ })
+ ) {
return null
}
@@ -377,6 +405,8 @@ export const createLintDiagnosticsController = ({
signal,
userInitiated = false,
source = undefined,
+ runContext = null,
+ isRunContextCurrent = () => true,
} = {}) => {
stylesLintRunId += 1
const runId = stylesLintRunId
@@ -407,7 +437,14 @@ export const createLintDiagnosticsController = ({
signal,
})
- if (runId !== stylesLintRunId) {
+ if (
+ !shouldCommitRun({
+ runId,
+ currentRunId: stylesLintRunId,
+ runContext,
+ isRunContextCurrent,
+ })
+ ) {
return null
}
@@ -437,7 +474,14 @@ export const createLintDiagnosticsController = ({
issueCount: summary.lines.length,
}
} catch (error) {
- if (runId !== stylesLintRunId) {
+ if (
+ !shouldCommitRun({
+ runId,
+ currentRunId: stylesLintRunId,
+ runContext,
+ isRunContextCurrent,
+ })
+ ) {
return null
}
@@ -463,10 +507,20 @@ export const createLintDiagnosticsController = ({
stylesLintRunId += 1
}
+ const cancelComponent = () => {
+ componentLintRunId += 1
+ }
+
+ const cancelStyles = () => {
+ stylesLintRunId += 1
+ }
+
const dispose = () => {}
return {
cancelAll,
+ cancelComponent,
+ cancelStyles,
lintComponent,
lintStyles,
dispose,
diff --git a/src/modules/diagnostics/type-diagnostics.js b/src/modules/diagnostics/type-diagnostics.js
index bb20a05..f73a67e 100644
--- a/src/modules/diagnostics/type-diagnostics.js
+++ b/src/modules/diagnostics/type-diagnostics.js
@@ -284,8 +284,12 @@ export const createTypeDiagnosticsController = ({
return /\.(css|less|sass|scss)$/.test(path)
}
- const toWorkspaceComponentTabs = () => {
- const tabs = typeof getWorkspaceTabs === 'function' ? getWorkspaceTabs() : []
+ const toWorkspaceComponentTabs = tabsInput => {
+ const tabs = Array.isArray(tabsInput)
+ ? tabsInput
+ : typeof getWorkspaceTabs === 'function'
+ ? getWorkspaceTabs()
+ : []
if (!Array.isArray(tabs)) {
return []
}
@@ -387,6 +391,8 @@ export const createTypeDiagnosticsController = ({
let lastTypeErrorCount = 0
let hasUnresolvedTypeErrors = false
let scheduledTypeRecheck = null
+ let sourceVersion = 0
+ let scheduledTypeRecheckSourceVersion = null
const clearTypeRecheckTimer = () => {
if (!scheduledTypeRecheck) {
@@ -395,6 +401,7 @@ export const createTypeDiagnosticsController = ({
clearTimeout(scheduledTypeRecheck)
scheduledTypeRecheck = null
+ scheduledTypeRecheckSourceVersion = null
}
const flattenTypeDiagnosticMessage = (compiler, messageText) => {
@@ -820,9 +827,9 @@ export const createTypeDiagnosticsController = ({
const collectTypeDiagnostics = async (
compiler,
- { sourceText, sourcePathOverride = '' },
+ { sourceText, sourcePathOverride = '', workspaceTabsOverride = undefined },
) => {
- const workspaceComponentTabs = toWorkspaceComponentTabs()
+ const workspaceComponentTabs = toWorkspaceComponentTabs(workspaceTabsOverride)
const resolvedEntryTab = resolveWorkspaceEntryForTypecheck(workspaceComponentTabs)
const normalizedSourcePathOverride =
typeof sourcePathOverride === 'string'
@@ -1037,6 +1044,8 @@ export const createTypeDiagnosticsController = ({
userInitiated = false,
sourceOverride = undefined,
sourcePathOverride = undefined,
+ workspaceTabsOverride = undefined,
+ sourceVersionOverride = sourceVersion,
} = {},
) => {
incrementTypeDiagnosticsRuns()
@@ -1064,7 +1073,12 @@ export const createTypeDiagnosticsController = ({
const diagnostics = await collectTypeDiagnostics(compiler, {
sourceText: sourceForRun,
sourcePathOverride: sourcePathForRun,
+ workspaceTabsOverride,
})
+ if (runId !== typeCheckRunId || sourceVersionOverride !== sourceVersion) {
+ return
+ }
+
const errorCategory = compiler.DiagnosticCategory?.Error
const errors = diagnostics.filter(
diagnostic => diagnostic.category === errorCategory,
@@ -1100,7 +1114,7 @@ export const createTypeDiagnosticsController = ({
setRenderedStatus()
}
} catch (error) {
- if (runId !== typeCheckRunId) {
+ if (runId !== typeCheckRunId || sourceVersionOverride !== sourceVersion) {
return
}
@@ -1118,7 +1132,7 @@ export const createTypeDiagnosticsController = ({
setStatus('Rendered', 'neutral')
}
} finally {
- if (runId === typeCheckRunId) {
+ if (runId === typeCheckRunId && sourceVersionOverride === sourceVersion) {
setTypeDiagnosticsPending(false)
}
decrementTypeDiagnosticsRuns()
@@ -1130,36 +1144,67 @@ export const createTypeDiagnosticsController = ({
userInitiated = false,
source = undefined,
sourcePath = undefined,
+ workspaceTabs = undefined,
+ sourceVersionForRun = sourceVersion,
} = {}) => {
+ const sourceForRun = typeof source === 'string' ? source : getJsxSource()
+ const sourcePathForRun =
+ typeof sourcePath === 'string' && sourcePath.length > 0
+ ? sourcePath
+ : getTypecheckSourcePath()
+ const workspaceTabsForRun = Array.isArray(workspaceTabs)
+ ? workspaceTabs
+ : typeof getWorkspaceTabs === 'function'
+ ? getWorkspaceTabs()
+ : []
+
+ clearTypeRecheckTimer()
typeCheckRunId += 1
void runTypeDiagnostics(typeCheckRunId, {
userInitiated,
- sourceOverride: source,
- sourcePathOverride: sourcePath,
+ sourceOverride: sourceForRun,
+ sourcePathOverride: sourcePathForRun,
+ workspaceTabsOverride: workspaceTabsForRun,
+ sourceVersionOverride: sourceVersionForRun,
})
}
- const scheduleTypeRecheck = () => {
+ const scheduleTypeRecheck = sourceVersionForRun => {
clearTypeRecheckTimer()
if (!hasUnresolvedTypeErrors) {
return
}
+ scheduledTypeRecheckSourceVersion = sourceVersionForRun
+
scheduledTypeRecheck = setTimeout(() => {
+ if (scheduledTypeRecheckSourceVersion !== sourceVersion) {
+ scheduledTypeRecheck = null
+ scheduledTypeRecheckSourceVersion = null
+ return
+ }
+
scheduledTypeRecheck = null
- triggerTypeDiagnostics()
+ scheduledTypeRecheckSourceVersion = null
+ triggerTypeDiagnostics({
+ sourceVersionForRun,
+ })
}, 450)
}
const markTypeDiagnosticsStale = () => {
+ sourceVersion += 1
+ typeCheckRunId += 1
+ clearTypeRecheckTimer()
+
if (hasUnresolvedTypeErrors) {
setTypeDiagnosticsPending(true)
setTypeDiagnosticsDetails({
headline: 'Source changed. Re-checking type errors…',
level: 'muted',
})
- scheduleTypeRecheck()
+ scheduleTypeRecheck(sourceVersion)
return
}
@@ -1183,6 +1228,7 @@ export const createTypeDiagnosticsController = ({
}
const cancelTypeDiagnostics = () => {
+ sourceVersion += 1
typeCheckRunId += 1
clearTypeDiagnosticsState()
setTypecheckButtonLoading(false)