From 6bb43cada5e1bb6f0e33e9c62dc5232d9daea9b4 Mon Sep 17 00:00:00 2001 From: KCM Date: Thu, 23 Apr 2026 21:01:14 -0500 Subject: [PATCH 1/2] fix(diagnostics): enforce current-editor snapshots and harden stale-result handling. --- playwright/diagnostics.spec.ts | 127 +++++++++++++++-- src/app.js | 54 +++++++- src/modules/app-core/app-bindings-startup.js | 57 +++++++- .../app-core/app-composition-options.js | 4 + .../app-core/diagnostics-flow-controller.js | 131 +++++++++++++----- .../app-core/diagnostics-tab-state-helpers.js | 120 ++++++++++++++++ .../app-core/workspace-controllers-setup.js | 2 + .../workspace-tab-selection-controller.js | 3 + src/modules/diagnostics/lint-diagnostics.js | 62 ++++++++- src/modules/diagnostics/type-diagnostics.js | 68 +++++++-- 10 files changed, 560 insertions(+), 68 deletions(-) create mode 100644 src/modules/app-core/diagnostics-tab-state-helpers.js diff --git a/playwright/diagnostics.spec.ts b/playwright/diagnostics.spec.ts index d573440..379e699 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,119 @@ 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.', + ) }) 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..6783145 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 }) }) @@ -208,7 +224,13 @@ const bindAppEventsAndStart = ({ if (diagnosticsClearStyles) { diagnosticsClearStyles.addEventListener('click', () => { clearDiagnosticsScope('styles') - clearStylesLintDiagnosticsState() + const activeTab = getActiveWorkspaceTab() + if (getTabKind(activeTab) === 'styles') { + clearStylesLintDiagnosticsState() + return + } + + clearComponentLintDiagnosticsState() }) } if (diagnosticsClearAll) { @@ -224,26 +246,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 +495,7 @@ const bindAppEventsAndStart = ({ updateRenderButtonVisibility() setDiagnosticsDrawerOpen(false) setTypeDiagnosticsDetails({ headline: '' }) + syncDiagnosticsDrawerLayout() renderRuntime.setStyleCompiling(false) setCdnLoading(true) previewBackground.initializePreviewBackgroundPicker() @@ -462,6 +508,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..2a4766c 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') @@ -122,7 +171,7 @@ const createDiagnosticsFlowController = ({ getComponentSource: getJsxSource, getStylesSource: getCssSource, getStyleMode, - setComponentDiagnostics: setTypeDiagnosticsDetails, + setComponentDiagnostics: setStyleDiagnosticsDetails, setStyleDiagnostics: setStyleDiagnosticsDetails, setStatus, onIssuesDetected: ({ issueCount }) => { @@ -151,6 +200,8 @@ const createDiagnosticsFlowController = ({ } const runComponentLint = ({ userInitiated = false, source = undefined } = {}) => { + const runContext = createLintRunContext('component') + activeComponentLintAbortController?.abort() const controller = new AbortController() activeComponentLintAbortController = controller @@ -165,11 +216,21 @@ 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', + }) + + if (statusNode.textContent.startsWith('Rendered (Lint issues:')) { + setStatus('Rendered', 'neutral') + } } + return result }) .finally(() => { @@ -182,6 +243,8 @@ const createDiagnosticsFlowController = ({ } const runStylesLint = ({ userInitiated = false, source = undefined } = {}) => { + const runContext = createLintRunContext('styles') + activeStylesLintAbortController?.abort() const controller = new AbortController() activeStylesLintAbortController = controller @@ -196,11 +259,21 @@ 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', + }) + + if (statusNode.textContent.startsWith('Rendered (Lint issues:')) { + setStatus('Rendered', 'neutral') + } } + return result }) .finally(() => { @@ -217,26 +290,17 @@ 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', }) @@ -247,22 +311,13 @@ const createDiagnosticsFlowController = ({ } 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() @@ -277,14 +332,14 @@ const createDiagnosticsFlowController = ({ } 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..ec04532 --- /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.clearTypeDiagnosticsState() + 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) From dee933a14054f48af0e7d0a0e55e2b12a29882c1 Mon Sep 17 00:00:00 2001 From: KCM Date: Thu, 23 Apr 2026 21:25:26 -0500 Subject: [PATCH 2/2] refactor: address comments. --- playwright/diagnostics.spec.ts | 28 +++++++++++++++ src/modules/app-core/app-bindings-startup.js | 7 +--- .../app-core/diagnostics-flow-controller.js | 34 ++++++++++++------- .../app-core/diagnostics-tab-state-helpers.js | 2 +- 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/playwright/diagnostics.spec.ts b/playwright/diagnostics.spec.ts index 379e699..99c8000 100644 --- a/playwright/diagnostics.spec.ts +++ b/playwright/diagnostics.spec.ts @@ -597,3 +597,31 @@ test('same-tab edits with drawer open replace lint issues with stale state', asy '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/modules/app-core/app-bindings-startup.js b/src/modules/app-core/app-bindings-startup.js index 6783145..75e6e62 100644 --- a/src/modules/app-core/app-bindings-startup.js +++ b/src/modules/app-core/app-bindings-startup.js @@ -224,12 +224,7 @@ const bindAppEventsAndStart = ({ if (diagnosticsClearStyles) { diagnosticsClearStyles.addEventListener('click', () => { clearDiagnosticsScope('styles') - const activeTab = getActiveWorkspaceTab() - if (getTabKind(activeTab) === 'styles') { - clearStylesLintDiagnosticsState() - return - } - + clearStylesLintDiagnosticsState() clearComponentLintDiagnosticsState() }) } diff --git a/src/modules/app-core/diagnostics-flow-controller.js b/src/modules/app-core/diagnostics-flow-controller.js index 2a4766c..1b34445 100644 --- a/src/modules/app-core/diagnostics-flow-controller.js +++ b/src/modules/app-core/diagnostics-flow-controller.js @@ -165,6 +165,24 @@ 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, @@ -226,9 +244,7 @@ const createDiagnosticsFlowController = ({ level: 'muted', }) - if (statusNode.textContent.startsWith('Rendered (Lint issues:')) { - setStatus('Rendered', 'neutral') - } + restoreStatusAfterLintInvalidation() } return result @@ -269,9 +285,7 @@ const createDiagnosticsFlowController = ({ level: 'muted', }) - if (statusNode.textContent.startsWith('Rendered (Lint issues:')) { - setStatus('Rendered', 'neutral') - } + restoreStatusAfterLintInvalidation() } return result @@ -305,9 +319,7 @@ const createDiagnosticsFlowController = ({ level: 'muted', }) - if (statusNode.textContent.startsWith('Rendered (Lint issues:')) { - setStatus('Rendered', 'neutral') - } + restoreStatusAfterLintInvalidation() } const markStylesLintDiagnosticsStale = () => { @@ -326,9 +338,7 @@ const createDiagnosticsFlowController = ({ level: 'muted', }) - if (statusNode.textContent.startsWith('Rendered (Lint issues:')) { - setStatus('Rendered', 'neutral') - } + restoreStatusAfterLintInvalidation() } const clearComponentLintDiagnosticsState = () => { diff --git a/src/modules/app-core/diagnostics-tab-state-helpers.js b/src/modules/app-core/diagnostics-tab-state-helpers.js index ec04532..29bfe04 100644 --- a/src/modules/app-core/diagnostics-tab-state-helpers.js +++ b/src/modules/app-core/diagnostics-tab-state-helpers.js @@ -69,7 +69,7 @@ const createDiagnosticsTabStateHelpers = ({ const diagnosticsFlowController = getDiagnosticsFlowController() if (diagnosticsFlowController) { - diagnosticsFlowController.typeDiagnostics.clearTypeDiagnosticsState() + diagnosticsFlowController.typeDiagnostics.cancelTypeDiagnostics() diagnosticsFlowController.clearComponentLintDiagnosticsState() diagnosticsFlowController.clearStylesLintDiagnosticsState() }