Skip to content

Commit 589d0e8

Browse files
Detect conflicting imports for potential utility files
Co-authored-by: Robin Malfait <[email protected]>
1 parent 42d79e0 commit 589d0e8

File tree

4 files changed

+216
-2
lines changed

4 files changed

+216
-2
lines changed

integrations/upgrade/index.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,3 +808,68 @@ test(
808808
`)
809809
},
810810
)
811+
812+
test(
813+
'migrate utility files imported by multiple roots',
814+
{
815+
fs: {
816+
'package.json': json`
817+
{
818+
"dependencies": {
819+
"tailwindcss": "workspace:^",
820+
"@tailwindcss/cli": "workspace:^",
821+
"@tailwindcss/upgrade": "workspace:^"
822+
}
823+
}
824+
`,
825+
'tailwind.config.js': js`module.exports = {}`,
826+
'src/index.html': html`
827+
<div class="hover:thing"></div>
828+
`,
829+
'src/root.1.css': css`
830+
@import 'tailwindcss/utilities';
831+
@import './a.1.css' layer(utilities);
832+
`,
833+
'src/root.2.css': css`
834+
@import 'tailwindcss/utilities';
835+
@import './a.1.css' layer(components);
836+
`,
837+
'src/root.3.css': css`
838+
@import 'tailwindcss/utilities';
839+
@import './a.1.css';
840+
`,
841+
'src/a.1.css': css`
842+
.foo-from-a {
843+
color: red;
844+
}
845+
`,
846+
},
847+
},
848+
async ({ fs, exec }) => {
849+
let output = await exec('npx @tailwindcss/upgrade --force')
850+
851+
expect(output).toMatch(
852+
/You have one or more stylesheets that are imported into a utility layer and non-utility layer./,
853+
)
854+
855+
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
856+
"
857+
--- ./src/a.1.css ---
858+
.foo-from-a {
859+
color: red;
860+
}
861+
862+
--- ./src/root.1.css ---
863+
@import 'tailwindcss/utilities' layer(utilities);
864+
@import './a.1.css' layer(utilities);
865+
866+
--- ./src/root.2.css ---
867+
@import 'tailwindcss/utilities' layer(utilities);
868+
@import './a.1.css' layer(components);
869+
870+
--- ./src/root.3.css ---
871+
@import 'tailwindcss/utilities' layer(utilities);
872+
@import './a.1.css' layer(utilities);"
873+
`)
874+
},
875+
)

integrations/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export function test(
114114
if (execOptions.ignoreStdErr !== true) console.error(stderr)
115115
reject(error)
116116
} else {
117-
resolve(stdout.toString())
117+
resolve(stdout.toString() + '\n\n' + stderr.toString())
118118
}
119119
},
120120
)

packages/@tailwindcss-upgrade/src/migrate.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { migrateAtLayerUtilities } from './codemods/migrate-at-layer-utilities'
99
import { migrateMediaScreen } from './codemods/migrate-media-screen'
1010
import { migrateMissingLayers } from './codemods/migrate-missing-layers'
1111
import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives'
12-
import { Stylesheet, type StylesheetId } from './stylesheet'
12+
import { Stylesheet, type StylesheetConnection, type StylesheetId } from './stylesheet'
1313
import { resolveCssId } from './utils/resolve'
1414
import { walk, WalkAction } from './utils/walk'
1515

@@ -43,6 +43,8 @@ export async function migrate(stylesheet: Stylesheet, options: MigrateOptions) {
4343
throw new Error('Cannot migrate a stylesheet without a file path')
4444
}
4545

46+
if (!stylesheet.canMigrate) return
47+
4648
await migrateContents(stylesheet, options)
4749
}
4850

@@ -123,6 +125,57 @@ export async function analyze(stylesheets: Stylesheet[]) {
123125

124126
await processor.process(sheet.root, { from: sheet.file })
125127
}
128+
129+
let commonPath = process.cwd()
130+
131+
function pathToString(path: StylesheetConnection[]) {
132+
let parts: string[] = []
133+
134+
for (let connection of path) {
135+
if (!connection.item.file) continue
136+
137+
let filePath = connection.item.file.replace(commonPath, '')
138+
let layers = connection.meta.layers.join(', ')
139+
140+
if (layers.length > 0) {
141+
parts.push(`${filePath} (layers: ${layers})`)
142+
} else {
143+
parts.push(filePath)
144+
}
145+
}
146+
147+
return parts.join(' <- ')
148+
}
149+
150+
let lines: string[] = []
151+
152+
for (let sheet of stylesheets) {
153+
if (!sheet.file) continue
154+
155+
let { convertablePaths, nonConvertablePaths } = sheet.analyzeImportPaths()
156+
let isAmbiguous = convertablePaths.length > 0 && nonConvertablePaths.length > 0
157+
158+
if (!isAmbiguous) continue
159+
160+
sheet.canMigrate = false
161+
162+
let filePath = sheet.file.replace(commonPath, '')
163+
164+
for (let path of convertablePaths) {
165+
lines.push(`- ${filePath} <- ${pathToString(path)}`)
166+
}
167+
168+
for (let path of nonConvertablePaths) {
169+
lines.push(`- ${filePath} <- ${pathToString(path)}`)
170+
}
171+
}
172+
173+
if (lines.length === 0) return
174+
175+
let error = `You have one or more stylesheets that are imported into a utility layer and non-utility layer.\n`
176+
error += `We cannot convert stylesheets under these conditions. Please look at the following stylesheets:\n`
177+
178+
throw new Error(error + lines.join('\n'))
126179
}
127180

128181
export async function split(stylesheets: Stylesheet[]) {

packages/@tailwindcss-upgrade/src/stylesheet.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export class Stylesheet {
4242
*/
4343
children = new Set<StylesheetConnection>()
4444

45+
/**
46+
* Whether or not this stylesheet can be migrated
47+
*/
48+
canMigrate = true
49+
4550
/**
4651
* Whether or not this stylesheet can be migrated
4752
*/
@@ -123,6 +128,97 @@ export class Stylesheet {
123128
return layers
124129
}
125130

131+
/**
132+
* Iterate all paths from a stylesheet through its ancestors to all roots
133+
*
134+
* For example, given the following structure:
135+
*
136+
* ```
137+
* c.css
138+
* -> a.1.css @import "…"
139+
* -> a.css
140+
* -> root.1.css (utility: no)
141+
* -> root.2.css (utility: no)
142+
* -> b.css
143+
* -> root.1.css (utility: no)
144+
* -> root.2.css (utility: no)
145+
*
146+
* -> a.2.css @import "…" layer(foo)
147+
* -> a.css
148+
* -> root.1.css (utility: no)
149+
* -> root.2.css (utility: no)
150+
* -> b.css
151+
* -> root.1.css (utility: no)
152+
* -> root.2.css (utility: no)
153+
*
154+
* -> b.1.css @import "…" layer(components / utilities)
155+
* -> a.css
156+
* -> root.1.css (utility: yes)
157+
* -> root.2.css (utility: yes)
158+
* -> b.css
159+
* -> root.1.css (utility: yes)
160+
* -> root.2.css (utility: yes)
161+
* ```
162+
*
163+
* We can see there are a total of 12 import paths with various layers.
164+
* We need to be able to iterate every one of these paths and inspect
165+
* the layers used in each path..
166+
*/
167+
*pathsToRoot(): Iterable<StylesheetConnection[]> {
168+
for (let { item, path } of walkDepth(this, (sheet) => sheet.parents)) {
169+
// Skip over intermediate stylesheets since all paths from a leaf to a
170+
// root will encompass all possible intermediate stylesheet paths.
171+
if (item.parents.size > 0) {
172+
continue
173+
}
174+
175+
yield path
176+
}
177+
}
178+
179+
/**
180+
* Analyze a stylesheets import paths to see if some can be considered
181+
* for conversion to utility rules and others can't.
182+
*
183+
* If a stylesheet is imported directly or indirectly and some imports are in
184+
* a utility layer and some are not that means that we can't safely convert
185+
* the rules in the stylesheet to `@utility`. Doing so would mean that we
186+
* would need to replicate the stylesheet and change one to have `@utility`
187+
* rules and leave the other as is.
188+
*
189+
* We can see, given the same structure from the `pathsToRoot` example, that
190+
* `css.css` is imported into different layers:
191+
* - `a.1.css` has no layers and should not be converted
192+
* - `a.2.css` has a layer `foo` and should not be converted
193+
* - `b.1.css` has a layer `utilities` (or `components`) which should be
194+
*
195+
* Since this means that `c.css` must both not be converted and converted
196+
* we can't do this without replicating the stylesheet, any ancestors, and
197+
* adjusting imports which is a non-trivial task.
198+
*/
199+
analyzeImportPaths() {
200+
let convertablePaths: StylesheetConnection[][] = []
201+
let nonConvertablePaths: StylesheetConnection[][] = []
202+
203+
for (let path of this.pathsToRoot()) {
204+
let isConvertable = false
205+
206+
for (let { meta } of path) {
207+
for (let layer of meta.layers) {
208+
isConvertable ||= layer === 'utilities' || layer === 'components'
209+
}
210+
}
211+
212+
if (isConvertable) {
213+
convertablePaths.push(path)
214+
} else {
215+
nonConvertablePaths.push(path)
216+
}
217+
}
218+
219+
return { convertablePaths, nonConvertablePaths }
220+
}
221+
126222
[util.inspect.custom]() {
127223
return {
128224
...this,

0 commit comments

Comments
 (0)