Skip to content

Handle when project config is re-created #1300

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 7, 2025
5 changes: 5 additions & 0 deletions packages/tailwindcss-language-server/src/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,11 @@ export async function createProjectService(
let elapsed = process.hrtime.bigint() - start

console.log(`---- RELOADED IN ${(Number(elapsed) / 1e6).toFixed(2)}ms ----`)

let isTestMode = params.initializationOptions?.testMode ?? false
if (!isTestMode) return

connection.sendNotification('@/tailwindCSS/projectReloaded')
},

state,
Expand Down
42 changes: 30 additions & 12 deletions packages/tailwindcss-language-server/src/testing/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { onTestFinished, test, TestOptions } from 'vitest'
import { onTestFinished, test, TestContext, TestOptions } from 'vitest'
import * as os from 'node:os'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as proc from 'node:child_process'
import dedent from 'dedent'

export interface TestUtils {
export interface TestUtils<TestInput extends Record<string, any>> {
/** The "cwd" for this test */
root: string

/**
* The input for this test — taken from the `inputs` in the test config
*
* @see {TestConfig}
*/
input?: TestInput
}

export interface StorageSymlink {
Expand All @@ -21,29 +28,39 @@ export interface Storage {
[filePath: string]: string | Uint8Array | StorageSymlink
}

export interface TestConfig<Extras extends {}> {
export interface TestConfig<Extras extends {}, TestInput extends Record<string, any>> {
name: string
inputs?: TestInput[]

fs?: Storage
debug?: boolean
prepare?(utils: TestUtils): Promise<Extras>
handle(utils: TestUtils & Extras): void | Promise<void>
prepare?(utils: TestUtils<TestInput>): Promise<Extras>
handle(utils: TestUtils<TestInput> & Extras): void | Promise<void>

options?: TestOptions
}

export function defineTest<T>(config: TestConfig<T>) {
return test(config.name, config.options ?? {}, async ({ expect }) => {
let utils = await setup(config)
export function defineTest<T, I>(config: TestConfig<T, I>) {
async function runTest(ctx: TestContext, input?: I) {
let utils = await setup(config, input)
let extras = await config.prepare?.(utils)

await config.handle({
...utils,
...extras,
})
})
}

if (config.inputs) {
return test.for(config.inputs ?? [])(config.name, config.options ?? {}, (input, ctx) =>
runTest(ctx, input),
)
}

return test(config.name, config.options ?? {}, runTest)
}

async function setup<T>(config: TestConfig<T>): Promise<TestUtils> {
async function setup<T, I>(config: TestConfig<T, I>, input: I): Promise<TestUtils<I>> {
let randomId = Math.random().toString(36).substring(7)

let baseDir = path.resolve(process.cwd(), `../../.debug/${randomId}`)
Expand All @@ -56,7 +73,7 @@ async function setup<T>(config: TestConfig<T>): Promise<TestUtils> {
await installDependencies(baseDir, config.fs)
}

onTestFinished(async (result) => {
onTestFinished(async (ctx) => {
// Once done, move all the files to a new location
try {
await fs.rename(baseDir, doneDir)
Expand All @@ -66,7 +83,7 @@ async function setup<T>(config: TestConfig<T>): Promise<TestUtils> {
console.error('Failed to move test files to done directory')
}

if (result.state === 'fail') return
if (ctx.task.result?.state === 'fail') return

if (path.sep === '\\') return

Expand All @@ -79,6 +96,7 @@ async function setup<T>(config: TestConfig<T>): Promise<TestUtils> {

return {
root: baseDir,
input,
}
}

Expand Down
44 changes: 41 additions & 3 deletions packages/tailwindcss-language-server/src/tw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import { readCssFile } from './util/css'
import { ProjectLocator, type ProjectConfig } from './project-locator'
import type { TailwindCssSettings } from '@tailwindcss/language-service/src/util/state'
import { createResolver, Resolver } from './resolver'
import { retry } from './util/retry'
import { analyzeStylesheet } from './version-guesser.js'

const TRIGGER_CHARACTERS = [
// class attributes
Expand Down Expand Up @@ -382,6 +382,13 @@ export class TW {
for (let [, project] of this.projects) {
if (!project.state.v4) continue

if (
change.type === FileChangeType.Deleted &&
changeAffectsFile(normalizedFilename, [project.projectConfig.configPath])
) {
continue
}

if (!changeAffectsFile(normalizedFilename, project.dependencies())) continue

needsSoftRestart = true
Expand All @@ -405,6 +412,31 @@ export class TW {
needsRestart = true
break
}

//
else {
// If the main CSS file in a project is deleted and then re-created
// the server won't restart because the project is gone by now and
// there's no concept of a "config file" for us to compare with
//
// So we'll check if the stylesheet could *potentially* create
// a new project but we'll only do so if no projects were found
//
// If we did this all the time we'd potentially restart the server
// unncessarily a lot while the user is editing their stylesheets
if (this.projects.size > 0) continue

let content = await readCssFile(change.file)
if (!content) continue

let stylesheet = analyzeStylesheet(content)
if (!stylesheet.root) continue

if (!stylesheet.versions.includes('4')) continue

needsRestart = true
break
}
}

let isConfigFile = isConfigMatcher(normalizedFilename)
Expand Down Expand Up @@ -1041,11 +1073,17 @@ export class TW {
this.watched.length = 0
}

restart(): void {
async restart(): void {
let isTestMode = this.initializeParams.initializationOptions?.testMode ?? false

console.log('----------\nRESTARTING\n----------')
this.dispose()
this.initPromise = undefined
this.init()
await this.init()

if (isTestMode) {
this.connection.sendNotification('@/tailwindCSS/serverRestarted')
}
}

async softRestart(): Promise<void> {
Expand Down
169 changes: 169 additions & 0 deletions packages/tailwindcss-language-server/tests/env/restart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { expect } from 'vitest'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { css, defineTest } from '../../src/testing'
import dedent from 'dedent'
import { createClient } from '../utils/client'

defineTest({
name: 'The design system is reloaded when the CSS changes ($watcher)',
fs: {
'app.css': css`
@import 'tailwindcss';

@theme {
--color-primary: #c0ffee;
}
`,
},
prepare: async ({ root }) => ({
client: await createClient({
root,
capabilities(caps) {
caps.workspace!.didChangeWatchedFiles!.dynamicRegistration = false
},
}),
}),
handle: async ({ root, client }) => {
let doc = await client.open({
lang: 'html',
text: '<div class="text-primary">',
})

// <div class="text-primary">
// ^
let hover = await doc.hover({ line: 0, character: 13 })

expect(hover).toEqual({
contents: {
language: 'css',
value: dedent`
.text-primary {
color: var(--color-primary) /* #c0ffee */;
}
`,
},
range: {
start: { line: 0, character: 12 },
end: { line: 0, character: 24 },
},
})

let didReload = new Promise((resolve) => {
client.conn.onNotification('@/tailwindCSS/projectReloaded', resolve)
})

// Update the CSS
await fs.writeFile(
path.resolve(root, 'app.css'),
css`
@import 'tailwindcss';

@theme {
--color-primary: #bada55;
}
`,
)

await didReload

// <div class="text-primary">
// ^
let hover2 = await doc.hover({ line: 0, character: 13 })

expect(hover2).toEqual({
contents: {
language: 'css',
value: dedent`
.text-primary {
color: var(--color-primary) /* #bada55 */;
}
`,
},
range: {
start: { line: 0, character: 12 },
end: { line: 0, character: 24 },
},
})
},
})

defineTest({
options: {
retry: 3,
},
name: 'Server is "restarted" when a config file is removed',
fs: {
'app.css': css`
@import 'tailwindcss';

@theme {
--color-primary: #c0ffee;
}
`,
},
prepare: async ({ root }) => ({
client: await createClient({
root,
capabilities(caps) {
caps.workspace!.didChangeWatchedFiles!.dynamicRegistration = false
},
}),
}),
handle: async ({ root, client }) => {
let doc = await client.open({
lang: 'html',
text: '<div class="text-primary">',
})

// <div class="text-primary">
// ^
let hover = await doc.hover({ line: 0, character: 13 })

expect(hover).toEqual({
contents: {
language: 'css',
value: dedent`
.text-primary {
color: var(--color-primary) /* #c0ffee */;
}
`,
},
range: {
start: { line: 0, character: 12 },
end: { line: 0, character: 24 },
},
})

// Remove the CSS file
let didRestart = new Promise((resolve) => {
client.conn.onNotification('@/tailwindCSS/serverRestarted', resolve)
})
await fs.unlink(path.resolve(root, 'app.css'))
await didRestart

// <div class="text-primary">
// ^
let hover2 = await doc.hover({ line: 0, character: 13 })
expect(hover2).toEqual(null)

// Re-create the CSS file
let didRestartAgain = new Promise((resolve) => {
client.conn.onNotification('@/tailwindCSS/serverRestarted', resolve)
})
await fs.writeFile(
path.resolve(root, 'app.css'),
css`
@import 'tailwindcss';
`,
)
await didRestartAgain

await new Promise((resolve) => setTimeout(resolve, 500))

// <div class="text-primary">
// ^
let hover3 = await doc.hover({ line: 0, character: 13 })
expect(hover3).toEqual(null)
},
})
8 changes: 7 additions & 1 deletion packages/tailwindcss-language-server/tests/utils/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
CompletionList,
CompletionParams,
Diagnostic,
DidChangeWorkspaceFoldersNotification,
Disposable,
DocumentLink,
DocumentLinkRequest,
Expand Down Expand Up @@ -179,6 +178,11 @@ export interface ClientOptions extends ConnectOptions {
* and the Tailwind CSS version it detects
*/
features?: Feature[]

/**
* Tweak the client capabilities presented to the server
*/
capabilities?(caps: ClientCapabilities): ClientCapabilities | Promise<ClientCapabilities> | void
}

export interface Client extends ClientWorkspace {
Expand Down Expand Up @@ -394,6 +398,8 @@ export async function createClient(opts: ClientOptions): Promise<Client> {
},
}

capabilities = (await opts.capabilities?.(capabilities)) ?? capabilities

trace('Client initializing')
await conn.sendRequest(InitializeRequest.type, {
processId: process.pid,
Expand Down
1 change: 1 addition & 0 deletions packages/vscode-tailwindcss/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Only scan the file system once when needed ([#1287](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1287))
- Don't follow recursive symlinks when searching for projects ([#1270](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1270))
- Correctly re-create a project when its main config file is removed then re-created ([#1300](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1300))

# 0.14.13

Expand Down