Philipp Spiess 156afc6d67
Improve compatibility with Safari 15 (#17435)
This PR improves the compatibility with Tailwind CSS v4 with unsupported
browsers with the goal to greatly improve compatibility with Safari 15.

To make this work, this PR makes the following changes to all code

- Change `oklab(…)` default theme values to use a percentage in the
first place (so instead of `--color-red-500: oklch(0.637 0.237 25.331);`
we now define it as `--color-red-500: oklch(63.7% 0.237 25.331);` since
this syntax has much broader support on Safari).
- Polyfill `@property` with a `@supports` query targeting older versions
of Safari and Firefox *
- Create fallbacks for the `color-mix(…)` function that use _inlined
color values from your theme_ so that they can be computed a compile
time by `lightningcss`. These fallbacks will convert to srgb to increase
compatibility.
- Create fallbacks for the _relative color_ feature used in the new
shadow utilities and using `color-mix(…)` in case _relative color_ is
applied on `currentcolor` (due to limited browser support)
- Create fallbacks for gradient interpolation methods (e.g. to support
`bg-linear-to-r/oklab`)
- Polyfill `@media` queries range syntax.

## A simplified example

Given this example CSS input:

```css
@import 'tailwindcss';
@source inline('from-cyan-500/50 bg-linear-45');
```

Here's the updated output CSS including the newly added polyfills and
updated `oklab` values:

```css
.bg-linear-45 {
  --tw-gradient-position: 45deg;
  background-image: linear-gradient(var(--tw-gradient-stops));
}

@supports (background-image: linear-gradient(in lab, red, red)) {
  .bg-linear-45 {
    --tw-gradient-position: 45deg in oklab;
  }
}

.from-cyan-500\\/50 {
  --tw-gradient-from: oklab(71.5% -.11682 -.08247 / .5);
  --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
}

@supports (color: color-mix(in lab, red, red)) {
  .from-cyan-500\\/50 {
    --tw-gradient-from: color-mix(in oklab, var(--color-cyan-500) 50%, transparent);
  }
}

:root, :host {
  --color-cyan-500: oklch(71.5% .143 215.221);
}

@supports (((-webkit-hyphens: none)) and (not (margin-trim: 1lh))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) {
  @layer base {
    *, :before, :after, ::backdrop {
      --tw-gradient-position: initial;
      --tw-gradient-from: #0000;
      --tw-gradient-via: #0000;
      --tw-gradient-to: #0000;
      --tw-gradient-stops: initial;
      --tw-gradient-via-stops: initial;
      --tw-gradient-from-position: 0%;
      --tw-gradient-via-position: 50%;
      --tw-gradient-to-position: 100%;
    }
  }
}

@property --tw-gradient-position {
  syntax: "*";
  inherits: false
}

@property --tw-gradient-from {
  syntax: "<color>";
  inherits: false;
  initial-value: #0000;
}

@property --tw-gradient-via {
  syntax: "<color>";
  inherits: false;
  initial-value: #0000;
}

@property --tw-gradient-to {
  syntax: "<color>";
  inherits: false;
  initial-value: #0000;
}

@property --tw-gradient-stops {
  syntax: "*";
  inherits: false
}

@property --tw-gradient-via-stops {
  syntax: "*";
  inherits: false
}

@property --tw-gradient-from-position {
  syntax: "<length-percentage>";
  inherits: false;
  initial-value: 0%;
}

@property --tw-gradient-via-position {
  syntax: "<length-percentage>";
  inherits: false;
  initial-value: 50%;
}

@property --tw-gradient-to-position {
  syntax: "<length-percentage>";
  inherits: false;
  initial-value: 100%;
}
```

## \* A note on `@property` polyfills and CSS modules

On Next.js, CSS module files are required to be _pure_, meaning that all
selectors must either be scoped to a class or an ID. Fortunatnyl for us,
this does not apply to `@property` rules which we've been using before
to initialize CSS variables.

However, since we're now bringing back the `@property` polyfills, that
would cause unexpected rules to be exported from the CSS file as this:

```css
@reference "tailwindcss";

.skew {
  @apply skew-7;
}
```

Would turn to the following file:

```css
.skew {
  /* … */
}
@supports (/*…*/) {
  @layer base {
    *, :before, :after, ::backdrop {
      --tw-gradient-position: initial;
    }
  }
}
@property /* … */ 
```

Notice that this adds a `*` selector which is not considered pure.

Unfortunately there is no way for us to silence this warning or work
around it, as the dependency causing this errors
([`postcss-modules-local-by-default`](https://github.com/css-modules/postcss-modules-local-by-default))
is bundled into Next.js. To work around crashes, these polyfills will
not apply to CSS modules processed by the PostCSS extension for now.

## Testing on tailwindcss.com

To see the changes in effect, take a look at this screencast that
compares tailwindcss.com on iOS 15.5 with a version that has the patches
of this PR applied:

https://github.com/user-attachments/assets/1279d6f5-3c63-4f30-839c-198a789f4292

## Test plan

- Tested on tailwindcss.com via a preview build:
https://tailwindcss-com-git-legacy-browsers-tailwindlabs.vercel.app/
- Updated tests
- Ensure we also test on Chrome 111, Safari 16.4, Firefox 128 to
make sure we have no regressions. Also tested on Safari 16.4, 15.5, 18.0
2025-04-01 13:33:22 +02:00

282 lines
7.5 KiB
TypeScript

import EnhancedResolve from 'enhanced-resolve'
import { createJiti, type Jiti } from 'jiti'
import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import path, { dirname } from 'node:path'
import { pathToFileURL } from 'node:url'
import {
__unstable__loadDesignSystem as ___unstable__loadDesignSystem,
compile as _compile,
compileAst as _compileAst,
Features,
Polyfills,
} from 'tailwindcss'
import type { AstNode } from '../../tailwindcss/src/ast'
import { getModuleDependencies } from './get-module-dependencies'
import { rewriteUrls } from './urls'
export { Features, Polyfills }
export type Resolver = (id: string, base: string) => Promise<string | false | undefined>
export interface CompileOptions {
base: string
onDependency: (path: string) => void
shouldRewriteUrls?: boolean
polyfills?: Polyfills
customCssResolver?: Resolver
customJsResolver?: Resolver
}
function createCompileOptions({
base,
polyfills,
onDependency,
shouldRewriteUrls,
customCssResolver,
customJsResolver,
}: CompileOptions) {
return {
base,
polyfills,
async loadModule(id: string, base: string) {
return loadModule(id, base, onDependency, customJsResolver)
},
async loadStylesheet(id: string, sheetBase: string) {
let sheet = await loadStylesheet(id, sheetBase, onDependency, customCssResolver)
if (shouldRewriteUrls) {
sheet.content = await rewriteUrls({
css: sheet.content,
root: base,
base: sheet.base,
})
}
return sheet
},
}
}
async function ensureSourceDetectionRootExists(
compiler: { root: Awaited<ReturnType<typeof compile>>['root'] },
base: string,
) {
// Verify if the `source(…)` path exists (until the glob pattern starts)
if (compiler.root && compiler.root !== 'none') {
let globSymbols = /[*{]/
let basePath = []
for (let segment of compiler.root.pattern.split('/')) {
if (globSymbols.test(segment)) {
break
}
basePath.push(segment)
}
let exists = await fsPromises
.stat(path.resolve(base, basePath.join('/')))
.then((stat) => stat.isDirectory())
.catch(() => false)
if (!exists) {
throw new Error(`The \`source(${compiler.root.pattern})\` does not exist`)
}
}
}
export async function compileAst(ast: AstNode[], options: CompileOptions) {
let compiler = await _compileAst(ast, createCompileOptions(options))
await ensureSourceDetectionRootExists(compiler, options.base)
return compiler
}
export async function compile(css: string, options: CompileOptions) {
let compiler = await _compile(css, createCompileOptions(options))
await ensureSourceDetectionRootExists(compiler, options.base)
return compiler
}
export async function __unstable__loadDesignSystem(css: string, { base }: { base: string }) {
return ___unstable__loadDesignSystem(css, {
base,
async loadModule(id, base) {
return loadModule(id, base, () => {})
},
async loadStylesheet(id, base) {
return loadStylesheet(id, base, () => {})
},
})
}
export async function loadModule(
id: string,
base: string,
onDependency: (path: string) => void,
customJsResolver?: Resolver,
) {
if (id[0] !== '.') {
let resolvedPath = await resolveJsId(id, base, customJsResolver)
if (!resolvedPath) {
throw new Error(`Could not resolve '${id}' from '${base}'`)
}
let module = await importModule(pathToFileURL(resolvedPath).href)
return {
base: dirname(resolvedPath),
module: module.default ?? module,
}
}
let resolvedPath = await resolveJsId(id, base, customJsResolver)
if (!resolvedPath) {
throw new Error(`Could not resolve '${id}' from '${base}'`)
}
let [module, moduleDependencies] = await Promise.all([
importModule(pathToFileURL(resolvedPath).href + '?id=' + Date.now()),
getModuleDependencies(resolvedPath),
])
for (let file of moduleDependencies) {
onDependency(file)
}
return {
base: dirname(resolvedPath),
module: module.default ?? module,
}
}
async function loadStylesheet(
id: string,
base: string,
onDependency: (path: string) => void,
cssResolver?: Resolver,
) {
let resolvedPath = await resolveCssId(id, base, cssResolver)
if (!resolvedPath) throw new Error(`Could not resolve '${id}' from '${base}'`)
onDependency(resolvedPath)
if (typeof globalThis.__tw_readFile === 'function') {
let file = await globalThis.__tw_readFile(resolvedPath, 'utf-8')
if (file) {
return {
base: path.dirname(resolvedPath),
content: file,
}
}
}
let file = await fsPromises.readFile(resolvedPath, 'utf-8')
return {
base: path.dirname(resolvedPath),
content: file,
}
}
// Attempts to import the module using the native `import()` function. If this
// fails, it sets up `jiti` and attempts to import this way so that `.ts` files
// can be resolved properly.
let jiti: null | Jiti = null
async function importModule(path: string): Promise<any> {
if (typeof globalThis.__tw_load === 'function') {
let module = await globalThis.__tw_load(path)
if (module) {
return module
}
}
try {
return await import(path)
} catch (error) {
jiti ??= createJiti(import.meta.url, { moduleCache: false, fsCache: false })
return await jiti.import(path)
}
}
const modules = ['node_modules', ...(process.env.NODE_PATH ? [process.env.NODE_PATH] : [])]
const cssResolver = EnhancedResolve.ResolverFactory.createResolver({
fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000),
useSyncFileSystemCalls: true,
extensions: ['.css'],
mainFields: ['style'],
conditionNames: ['style'],
modules,
})
async function resolveCssId(
id: string,
base: string,
customCssResolver?: Resolver,
): Promise<string | false | undefined> {
if (typeof globalThis.__tw_resolve === 'function') {
let resolved = globalThis.__tw_resolve(id, base)
if (resolved) {
return Promise.resolve(resolved)
}
}
if (customCssResolver) {
let customResolution = await customCssResolver(id, base)
if (customResolution) {
return customResolution
}
}
return runResolver(cssResolver, id, base)
}
const esmResolver = EnhancedResolve.ResolverFactory.createResolver({
fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000),
useSyncFileSystemCalls: true,
extensions: ['.js', '.json', '.node', '.ts'],
conditionNames: ['node', 'import'],
modules,
})
const cjsResolver = EnhancedResolve.ResolverFactory.createResolver({
fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000),
useSyncFileSystemCalls: true,
extensions: ['.js', '.json', '.node', '.ts'],
conditionNames: ['node', 'require'],
modules,
})
async function resolveJsId(
id: string,
base: string,
customJsResolver?: Resolver,
): Promise<string | false | undefined> {
if (typeof globalThis.__tw_resolve === 'function') {
let resolved = globalThis.__tw_resolve(id, base)
if (resolved) {
return Promise.resolve(resolved)
}
}
if (customJsResolver) {
let customResolution = await customJsResolver(id, base)
if (customResolution) {
return customResolution
}
}
return runResolver(esmResolver, id, base).catch(() => runResolver(cjsResolver, id, base))
}
function runResolver(
resolver: EnhancedResolve.Resolver,
id: string,
base: string,
): Promise<string | false | undefined> {
return new Promise((resolve, reject) =>
resolver.resolve({}, base, id, {}, (err, result) => {
if (err) return reject(err)
resolve(result)
}),
)
}