Skip to content
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
20 changes: 20 additions & 0 deletions .changeset/fix-native-style-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"jest-styled-components": patch
---

**Bug Fixes**

- Fix native `toHaveStyleRule` crash when element has no `style` prop (#225, #110)
- Fix nested at-rules (`@media` inside `@supports` and vice versa) not being found by `toHaveStyleRule` (#245)
- Fix `getHTML()` creating an empty `ServerStyleSheet` instead of reading from the global sheet (#401)
- Fix selector matching with spaces around CSS combinators (`> ul > li > a` now matches correctly)
- Fix invalid CSS causing cryptic parse errors — now shows the offending rule with surrounding context and a caret pointing at the exact error position (#147)

**Improvements**

- Add opt-in CSS parse caching via `import 'jest-styled-components/cache'` for faster `toHaveStyleRule` in large test suites (#235)
- Normalize whitespace in value comparisons so `red !important`, `sidebar / inline-size`, and `rgb(0, 0, 0)` match their stylis-formatted equivalents (skips quoted strings)
- Clearer validation messages: "Property not found" instead of cryptic crash, human-readable options formatting, better negation wording
- Add `cache/index.d.ts` for TypeScript users importing the cache entry point
- Use `Set` for hash lookups (O(1) vs O(n) per lookup)
- Serializer no longer mutates the parsed CSS AST
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ npx jest --updateSnapshot

- **`src/utils.js`** — Shared utilities. Accesses styled-components internals via `__PRIVATE__` to read/reset the global stylesheet. Parses CSS with `@adobe/css-tools`. Key exports: `resetStyleSheet`, `getCSS`, `getHashes`, `matcherTest`, `buildReturnMessage`.
- **`src/styleSheetSerializer.js`** — Jest snapshot serializer. Walks the component tree to collect class names, extracts matching CSS rules from the stylesheet, replaces hashed class names with sequential placeholders, and prepends styles to the snapshot output. Uses a `WeakSet` cache to prevent re-processing nodes during recursive serialization.
- **`src/toHaveStyleRule.js`** — Web matcher. Extracts class names from react-test-renderer JSON, Enzyme wrappers, or DOM elements, then queries parsed CSS for matching declarations. Supports `media`, `supports`, and `modifier` options for targeting nested/at-rule styles.
- **`src/toHaveStyleRule.js`** — Web matcher. Extracts class names from react-test-renderer JSON, Enzyme wrappers, or DOM elements, then queries parsed CSS for matching declarations. Supports `media`, `supports`, `container`, `layer`, and `modifier` options for targeting nested/at-rule styles.
- **`src/native/toHaveStyleRule.js`** — React Native matcher. Works directly with the `style` prop (no CSS parsing needed), merging style arrays and converting kebab-case properties to camelCase.

### Test Configurations
Expand Down Expand Up @@ -98,7 +98,7 @@ Two regex patterns are used:

### CSS Parsing

Uses `@adobe/css-tools` as the sole production dependency to parse stylesheet output into an AST, then queries rules by matching selectors against component class names. At-rules (`@media`, `@supports`) are handled by first filtering to matching at-rule blocks, then searching nested rules.
Uses `@adobe/css-tools` as the sole production dependency to parse stylesheet output into an AST, then queries rules by matching selectors against component class names. At-rules (`@media`, `@supports`, `@container`, `@layer`) are handled by first filtering to matching at-rule blocks, then searching nested rules. `@scope` is not yet supported (blocked by `@adobe/css-tools` parse error).

## Tooling

Expand All @@ -122,7 +122,8 @@ Uses `@adobe/css-tools` as the sole production dependency to parse stylesheet ou

- **Never mass-close or mass-comment.** Every response posted to an issue must be individually researched and evidence-backed with a fresh code experiment.
- **Verify before closing.** Reproduce the reported scenario on the current version and confirm it's resolved before closing. Don't assume a fix covers an issue without testing.
- **Be kind and appreciative.** Thank reporters for filing. Be respectful even when closing as stale or wontfix.
- **Warm and positive tone.** Be honest but welcoming. Thank reporters for taking the time to file.
- **Own bugs gracefully.** If the issue is an actual bug in the codebase, respectfully apologize (short and sweet) and prepare a well-researched, thoroughly tested candidate fix PR.
- **Preserve original authorship.** When absorbing work from external PRs, credit the original author in the commit or PR description.

### PRs
Expand All @@ -132,6 +133,7 @@ Uses `@adobe/css-tools` as the sole production dependency to parse stylesheet ou

### Code

- **Never break downstream snapshots.** Changes to matcher logic or normalization must not alter snapshot output format. If our tests had a certain format, users likely have similar tests.
- **Use TDD (red/green)** when fixing bugs — write the failing test first, then fix the code.
- **Always run tests** before considering work done.
- **Research before implementing.** Go beyond official docs — review dev blogs, expert content, and source code for the deepest understanding.
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ The third argument is an options object for targeting rules within at-rules, wit

| Option | Type | Description |
|---|---|---|
| `container` | `string` | Match within a `@container` at-rule, e.g. `'(min-width: 400px)'` |
| `layer` | `string` | Match within a `@layer` at-rule, e.g. `'utilities'` |
| `media` | `string` | Match within a `@media` at-rule, e.g. `'(max-width: 640px)'` |
| `supports` | `string` | Match within a `@supports` at-rule, e.g. `'(display: grid)'` |
| `modifier` | `string \| css` | Refine the selector: pseudo-selectors, combinators, `&` references, or the `css` helper for component selectors |
Expand Down Expand Up @@ -186,6 +188,41 @@ test('supports query', () => {
})
```

### container

```js
const Card = styled.div`
container-type: inline-size;
@container (min-width: 400px) {
font-size: 1.5rem;
}
`

test('container query', () => {
const { container } = render(<Card />)
expect(container.firstChild).toHaveStyleRule('font-size', '1.5rem', {
container: '(min-width: 400px)',
})
})
```

### layer

```js
const Themed = styled.div`
@layer utilities {
color: red;
}
`

test('layer query', () => {
const { container } = render(<Themed />)
expect(container.firstChild).toHaveStyleRule('color', 'red', {
layer: 'utilities',
})
})
```

### modifier with component selectors

When a rule is nested within another styled-component, use the `css` helper to target it:
Expand Down Expand Up @@ -362,6 +399,18 @@ import { resetStyleSheet } from 'jest-styled-components'
resetStyleSheet()
```

### CSS Parse Caching

By default, `toHaveStyleRule` re-parses the stylesheet on every assertion. For test suites with many assertions, this can be slow. Import the cached entry point to parse once and reuse the result when the stylesheet hasn't changed:

```js
import 'jest-styled-components/cache'
```

That's it—the cache automatically invalidates when the stylesheet changes (new components render) and when `resetStyleSheet` runs between tests via `beforeEach`. No manual cleanup needed.

With the cached entry point, both `toHaveStyleRule` and the snapshot serializer reuse the parsed stylesheet when possible. The serializer builds a filtered copy of the AST instead of mutating it during serialization.

## Legacy: Enzyme

[Enzyme](https://github.com/enzymejs/enzyme) is no longer actively maintained. If you still use it, snapshot testing requires [enzyme-to-json](https://www.npmjs.com/package/enzyme-to-json) and `toHaveStyleRule` works with both shallow and mounted wrappers. Consider migrating to `@testing-library/react`.
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": true,
"includes": ["src/**", "native/**", "serializer/**", "vitest/**", "test/**", "!test/native/**"]
"includes": ["src/**", "native/**", "serializer/**", "vitest/**", "cache/**", "test/**", "!test/native/**"]
},
"formatter": {
"enabled": true,
Expand Down
2 changes: 2 additions & 0 deletions cache/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Disable CSS parse caching and clear the cache. */
export declare const disableCSSCache: () => void;
5 changes: 5 additions & 0 deletions cache/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const { enableCSSCache, disableCSSCache } = require('../src/utils');

enableCSSCache();

module.exports = { disableCSSCache };
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"main": "./src/index.js",
"types": "./typings/index.d.ts",
"files": [
"cache",
"native",
"serializer",
"src",
Expand Down Expand Up @@ -68,7 +69,7 @@
"styled-components": ">= 5"
},
"lint-staged": {
"*.js": [
"{src,native,serializer,vitest,cache}/**/*.js": [
"biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
]
},
Expand Down
4 changes: 3 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const toHaveStyleRule = require('./toHaveStyleRule');
const styleSheetSerializer = require('./styleSheetSerializer');
const { resetStyleSheet } = require('./utils');
const { resetStyleSheet, enableCSSCache, disableCSSCache } = require('./utils');

if (typeof beforeEach === 'function') {
beforeEach(resetStyleSheet);
Expand All @@ -14,4 +14,6 @@ if (typeof expect !== 'undefined') {
module.exports = {
styleSheetSerializer,
resetStyleSheet,
enableCSSCache,
disableCSSCache,
};
22 changes: 20 additions & 2 deletions src/native/toHaveStyleRule.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
const { matcherTest, buildReturnMessage } = require('../utils');

function toHaveStyleRule({ props: { style } }, property, expected) {
const styles = Array.isArray(style) ? style.filter((x) => x) : style;
function toHaveStyleRule(component, property, expected) {
const style = component?.props ? component.props.style : undefined;

if (!style) {
const pass = matcherTest(undefined, expected, this.isNot);
return {
pass,
message: buildReturnMessage(
this.utils,
pass,
property,
undefined,
expected
),
};
}

const styles = Array.isArray(style)
? style.flat(Infinity).filter((x) => x)
: style;
const camelCasedProperty = property.replace(/-(\w)/g, (_, match) =>
match.toUpperCase()
);
Expand Down
47 changes: 24 additions & 23 deletions src/styleSheetSerializer.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const css = require('@adobe/css-tools');
const { getCSS, getHashes } = require('./utils');
const { AT_RULE_TYPES, getCSSForMatcher, getHashes } = require('./utils');

const cache = new WeakSet();
const getNodes = (node, nodes = []) => {
Expand Down Expand Up @@ -42,11 +42,11 @@ const getClassNames = (nodes) =>

const isStyledClass = (className) => /^\.?(\w+(-|_))?sc-/.test(className);

const filterClassNames = (classNames, hashes) =>
classNames.filter((className) => hashes.includes(className));
const filterUnreferencedClassNames = (classNames, hashes) =>
const filterClassNames = (classNames, hashSet) =>
classNames.filter((className) => hashSet.has(className));
const filterUnreferencedClassNames = (classNames, hashSet) =>
classNames.filter(
(className) => isStyledClass(className) && !hashes.includes(className)
(className) => isStyledClass(className) && !hashSet.has(className)
);

const includesClassNames = (classNames, selectors) =>
Expand All @@ -70,23 +70,23 @@ const filterRules = (classNames) => (rule) =>

const getAtRules = (ast, filter) =>
ast.stylesheet.rules
.filter((rule) => rule.type === 'media' || rule.type === 'supports')
.reduce((acc, atRule) => {
atRule.rules = atRule.rules.filter(filter);

return acc.concat(atRule);
}, []);
.filter((rule) => AT_RULE_TYPES.includes(rule.type))
.map((atRule) => ({ ...atRule, rules: atRule.rules.filter(filter) }))
.filter((atRule) => atRule.rules.length);

const getFilteredRulesAndStyle = (classNames, config = {}) => {
const ast = getCSS();
const ast = getCSSForMatcher();
const filter = filterRules(classNames);
const rules = ast.stylesheet.rules.filter(filter);
const atRules = getAtRules(ast, filter);
const allRules = rules.concat(atRules);

ast.stylesheet.rules = allRules;
const filtered = {
...ast,
stylesheet: { ...ast.stylesheet, rules: allRules },
};

return { rules, style: css.stringify(ast, { indent: config.indent }) };
return { rules, style: css.stringify(filtered, { indent: config.indent }) };
};

const getClassNamesFromSelectorsByRules = (classNames, rules, hashes) => {
Expand Down Expand Up @@ -123,15 +123,16 @@ const stripUnreferencedClassNames = (result, classNames) =>
result
);

const replaceHashes = (result, hashes) =>
hashes.reduce(
(acc, className) =>
acc.replace(
new RegExp(`((class|className)="[^"]*?)${className}\\s?([^"]*")`, 'g'),
'$1$3'
),
result
);
const replaceHashes = (result, hashes) => {
let acc = result;
for (const className of hashes) {
acc = acc.replace(
new RegExp(`((class|className)="[^"]*?)${className}\\s?([^"]*")`, 'g'),
'$1$3'
);
}
return acc;
};

const serializerOptionDefaults = {
addStyles: true,
Expand Down
Loading
Loading