Skip to content
This repository was archived by the owner on Mar 3, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## UNRELEASED

### Adds
* Refactors the `AtRule` to handle Tailwind 4.x nesting of media queries

### Fixed

* Changes the wrapping of the `:where()` pseudoclass so that it doesn't set specificity of some elements to `0`, resulting in a broken cascade.

## 2.0.1 (2025-08-06)

### Fixed
Expand Down
295 changes: 254 additions & 41 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ const plugin = (opts = {}) => {
...options
});
const ruleProcessor = createRuleProcessor({
unitConverter,
...options
...options,
unitConverter
});
const selectorHelper = createSelectorHelper({ modifierAttr });

Expand All @@ -57,6 +57,11 @@ const plugin = (opts = {}) => {
// Flag to track if container context like sticky has been added
let hasAddedContainerContext = false;

const isSameMediaQuery = (mq1, mq2) => {
return mq1.params === mq2.params &&
mq1.source?.start?.line === mq2.source?.start?.line;
};

/**
* Adds a container context with `position: relative` and `contain: layout` if required.
*
Expand Down Expand Up @@ -171,10 +176,17 @@ const plugin = (opts = {}) => {

AtRule: {
media(atRule, helpers) {
debugUtils.logMediaQuery(atRule, 'START');

if (atRule[processed]) {
debugUtils.log('Skipping already processed media query', atRule);
return;
}

// Check if this media query is nested inside a rule
const isNested = atRule.parent?.type === 'rule';
debugUtils.log(`Media query is ${isNested ? 'NESTED' : 'TOP-LEVEL'}`, atRule);

let hasNotSelector = false;
atRule.walkRules(rule => {
if (rule.selector.includes(conditionalNotSelector)) {
Expand All @@ -183,79 +195,280 @@ const plugin = (opts = {}) => {
});

if (hasNotSelector) {
debugUtils.log('Skipping - already has not selector', atRule);
atRule[processed] = true;
return;
}

const conditions = mediaProcessor.getMediaConditions(atRule);
debugUtils.log(`Extracted conditions: ${JSON.stringify(conditions)}`, atRule);

if (conditions.length > 0) {
debugUtils.stats.mediaQueriesProcessed++;

// Create container version first
const containerConditions =
mediaProcessor.convertToContainerConditions(conditions);

debugUtils.log(`Container conditions: ${containerConditions}`, atRule);

if (containerConditions) {
debugUtils.log('Creating container query...', atRule);

const containerQuery = new helpers.AtRule({
name: 'container',
params: containerConditions,
source: atRule.source,
from: helpers.result.opts.from
});

// Clone and process rules for container query - keep selectors clean
atRule.walkRules(rule => {
const containerRule = rule.clone({
source: rule.source,
from: helpers.result.opts.from,
selector: selectorHelper.updateBodySelectors(
rule.selector,
[ containerBodySelector ]
)
});

ruleProcessor.processDeclarations(containerRule, {
isContainer: true,
from: helpers.result.opts.from
// For nested media queries
if (isNested) {
debugUtils.log('Processing nested media query declarations...', atRule);

atRule.each(node => {
if (node.type === 'decl') {
debugUtils.log(` Processing declaration: ${node.prop}: ${node.value}`, atRule);

const containerDecl = node.clone({
source: node.source,
from: helpers.result.opts.from
});

// Convert viewport units if needed
let value = containerDecl.value;
if (Object.keys(unitConverter.units)
.some(unit => value.includes(unit))) {
value = unitConverter.convertUnitsInExpression(value);
containerDecl.value = value;
debugUtils.log(` Converted value to: ${value}`, atRule);
}

containerQuery.append(containerDecl);
}
});

containerRule.raws.before = '\n ';
containerRule.raws.after = '\n ';
containerRule.walkDecls(decl => {
decl.raws.before = '\n ';
debugUtils.log(` Total declarations in container query: ${containerQuery.nodes?.length || 0}`, atRule);

const parentRule = atRule.parent;

// Find the root nesting level
let rootParent = parentRule;
let isSingleLevel = true;
while (rootParent.parent && rootParent.parent.type === 'rule') {
rootParent = rootParent.parent;
isSingleLevel = false;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏼


// For single-level nesting (Tailwind case), use simple approach
if (isSingleLevel) {
// Add container query inside the parent rule, after the media query
atRule.after(containerQuery);

const originalSelector = parentRule.selector;

let conditionalRule = null;
let alreadyHasWrapper = false;

let prevNode = parentRule.prev();
const targetSelector = selectorHelper
.addTargetToSelectors(
originalSelector,
conditionalNotSelector
);

while (prevNode) {
if (prevNode.type === 'rule' && prevNode.selector === targetSelector) {
conditionalRule = prevNode;
alreadyHasWrapper = true;
debugUtils.log('Found existing conditional wrapper, reusing it', atRule);
break;
}
prevNode = prevNode.prev();
}

if (!alreadyHasWrapper) {
conditionalRule = new helpers.Rule({
selector: targetSelector,
source: parentRule.source,
from: helpers.result.opts.from
});

parentRule.before(conditionalRule);
debugUtils.log('Created new conditional wrapper', atRule);
}

const clonedMedia = atRule.clone();
clonedMedia[processed] = true;
conditionalRule.append(clonedMedia);
atRule.remove();

debugUtils.log('Added conditional wrapper for nested media query', atRule);

} else {
// Multi-level nesting - hoist to root with full structure
const rootSelector = rootParent.selector;

// Check if wrapper exists at root level
let conditionalRule = null;
let alreadyHasWrapper = false;

let prevNode = rootParent.prev();
const targetSelector = selectorHelper
.addTargetToSelectors(
rootSelector,
conditionalNotSelector
);

while (prevNode) {
if (prevNode.type === 'rule' && prevNode.selector === targetSelector) {
conditionalRule = prevNode;
alreadyHasWrapper = true;
debugUtils.log('Found existing conditional wrapper, reusing it', atRule);
break;
}
prevNode = prevNode.prev();
}
Copy link
Copy Markdown
Contributor

@ValJed ValJed Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is for the prevNode, its the root one that gets the :where(body:not?
From what I understand if this wrapper is already here we don't add it again, shouldn't be checked at the rootSelector level directly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop searches for an existing conditional wrapper created by a previously processed media query. Since wrappers are inserted with parentRule.before(conditionalRule) (or rootParent.before()), they become previous siblings. We need to check all previous siblings, not just the immediate one, because there could be multiple wrappers or other rules between them.


if (!alreadyHasWrapper) {
// Create wrapper with full nested structure
conditionalRule = new helpers.Rule({
selector: targetSelector,
source: rootParent.source,
from: helpers.result.opts.from
});

// Clone children of root parent (before container query is added)
rootParent.each(node => {
const clonedNode = node.clone();

// Remove media queries that are NOT the current one being processed
clonedNode.walkAtRules('media', (mediaRule) => {
// Keep the current media query we're processing, remove others
if (mediaRule !== atRule && !isSameMediaQuery(mediaRule, atRule)) {
mediaRule.remove();
} else {
mediaRule[processed] = true;
}
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we check if the media has already been processed.


// If this node itself is a media query
// and not the one we're processing, skip it
if (clonedNode.type === 'atrule' && clonedNode.name === 'media') {
if (!isSameMediaQuery(clonedNode, atRule)) {
return; // Skip appending
}
clonedNode[processed] = true;
}

conditionalRule.append(clonedNode);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have doubts about this block, we mark rules as processed but shouldn't we go through each node individually to handle everything?
I don't get all this part of the code nicely so prefer to ask.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No problem! Let me walk through what's happening here for the multi-level nesting case:
Context: We have deeply nested CSS like:

.foo {
  .bar {
    .inside {
      @media (max-width: 500px) { margin: 2rem; }
    }
  }
}

Goal: Create a conditional wrapper at the root level (.foo) that contains the media query, so it only applies when the breakpoint preview is OFF.
What this code does:

We clone the entire nested structure from .foo down
During cloning, we remove any media queries that aren't the one currently being processed (to avoid duplicates)
We mark the current media query as [processed] = true so when the plugin encounters it again during traversal, it skips it
We append this cloned structure to the conditional wrapper

We mark as processed because after we clone and append the media query to the wrapper, PostCSS will walk through that new wrapper structure. Without the [processed] flag, the plugin would try to process those media queries again, creating infinite loops or duplicate container queries.
The walkAtRules('media', ...) ensures that not just the top-level media query but any nested ones are also marked as processed.
Does that clarify the logic? Happy to explain any specific part in more detail!

The updates above this code prevent duplicate media queries by only cloning the current media query being processed, not all media queries in the nested structure.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand, and thanks!
So the [processed] flag is the key to preventing infinite recursion - when you clone the nested structure and append it to the conditional wrapper, PostCSS will naturally walk through that new cloned tree during its traversal. Without marking those cloned media queries as processed, the plugin would encounter them again and try to process them a second time, which would create duplicates or even infinite loops.
And by using walkAtRules('media', ...) on the cloned structure, you're ensuring that not just the immediate media query but any deeply nested ones within the clone are also flagged, so none of them get processed again when PostCSS continues its walk.
The combination of selective cloning (only the current media query) plus the processed flag is what keeps everything clean. Makes total sense - thanks for the detailed explanation!

});

rootParent.before(conditionalRule);
debugUtils.log('Created new conditional wrapper at root level', atRule);
} else {
// Wrapper exists, add media query to matching nested location
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose this is the part where we duplicate media queries maybe.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The duplication issue was in the cloning logic above this line (inside rootParent.each), where all media queries were being cloned. The fix uses isSameMediaQuery to remove media queries that aren't the current one. The rootParent.before(conditionalRule) line just inserts the wrapper - it doesn't cause duplication. The duplication bug has been fixed and all tests are passing.

const targetInWrapper = conditionalRule.first;
if (targetInWrapper && targetInWrapper.type === 'rule') {
// Build path from parentRule to rootParent
const pathSelectors = [];
let current = parentRule;
while (current !== rootParent) {
pathSelectors.unshift(current.selector);
current = current.parent;
}

// Navigate to matching location in wrapper
let navNode = targetInWrapper;
for (const selector of pathSelectors) {
let found = false;
navNode.each(node => {
if (node.type === 'rule' && node.selector === selector) {
navNode = node;
found = true;
return false;
}
});
if (!found) {
break;
}
}

// Add cloned media to correct location
const clonedMedia = atRule.clone();
clonedMedia[processed] = true;
navNode.append(clonedMedia);
}
}

// Remove from original
atRule.remove();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove what from original, the atRule node?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that is correct. Otherwise, we would have the original and the clone, which could cause specificity conflicts.


// Now add container query to original (after cloning)
parentRule.append(containerQuery);

debugUtils.log('Added conditional wrapper for nested media query', atRule);
}

} else {
// Original logic for top-level media queries
atRule.walkRules(rule => {
const containerRule = rule.clone({
source: rule.source,
from: helpers.result.opts.from,
selector: selectorHelper.updateBodySelectors(
rule.selector,
[ containerBodySelector ]
)
});

ruleProcessor.processDeclarations(containerRule, {
isContainer: true,
from: helpers.result.opts.from
});

containerRule.raws.before = '\n ';
containerRule.raws.after = '\n ';
containerRule.walkDecls(decl => {
decl.raws.before = '\n ';
});

containerQuery.append(containerRule);
});

containerQuery.append(containerRule);
});

// Add container query
atRule.after(containerQuery);
// Add container query
atRule.after(containerQuery);
}
}

// Now handle viewport media query modifications
// We want the original media query to get the not selector
atRule.walkRules(rule => {
// Skip if already modified with not selector
if (rule.selector.includes(conditionalNotSelector)) {
return;
}
if (!isNested) {
atRule.walkRules(rule => {
// Skip if already modified with not selector
if (rule.selector.includes(conditionalNotSelector)) {
return;
}

const viewportRule = rule.clone({
source: rule.source,
from: helpers.result.opts.from
});
const viewportRule = rule.clone({
source: rule.source,
from: helpers.result.opts.from
});

viewportRule.selector = selectorHelper.addTargetToSelectors(
rule.selector,
conditionalNotSelector
);
viewportRule.selector = selectorHelper.addTargetToSelectors(
rule.selector,
conditionalNotSelector
);

rule.replaceWith(viewportRule);
});
rule.replaceWith(viewportRule);
});
}
} else {
debugUtils.log('No conditions found - skipping', atRule);
}

// Only mark the atRule as processed after all transformations
atRule[processed] = true;
debugUtils.logMediaQuery(atRule, 'END');
}
},

Expand Down
Loading
Loading