Skip to content

Commit

Permalink
Sprinkles: Improve error experience (#77)
Browse files Browse the repository at this point in the history
Co-authored-by: Mark Dalgleish <mark.john.dalgleish@gmail.com>
  • Loading branch information
mattcompiles and markdalgleish committed May 4, 2021
1 parent 3360bdf commit 63c01ad
Show file tree
Hide file tree
Showing 3 changed files with 288 additions and 24 deletions.
13 changes: 13 additions & 0 deletions .changeset/plenty-pianos-travel.md
@@ -0,0 +1,13 @@
---
'@vanilla-extract/sprinkles': patch
---

Improve runtime errors

Sprinkles will now validate your `atoms` calls at runtime for a better developer experience. The validation code should be stripped from production bundles via a `process.env.NODE_ENV` check.

Example Error

```bash
SprinklesError: "paddingTop" has no value "xlarge". Possible values are "small", "medium", "large"
```
172 changes: 148 additions & 24 deletions packages/sprinkles/src/createAtomsFn.ts
Expand Up @@ -126,40 +126,164 @@ export function createAtomsFn<Args extends ReadonlyArray<AtomicStyles>>(
for (const prop in finalProps) {
const propValue = finalProps[prop];
const atomicProperty = atomicStyles[prop];
try {
if (atomicProperty.mappings) {
// Skip shorthands
continue;
}

if (atomicProperty.mappings) {
// Skip shorthands
continue;
}
if (typeof propValue === 'string' || typeof propValue === 'number') {
if (process.env.NODE_ENV !== 'production') {
if (!atomicProperty.values[propValue].defaultClass) {
throw new Error();
}
}
classNames.push(atomicProperty.values[propValue].defaultClass);
} else if (Array.isArray(propValue)) {
for (const responsiveIndex in propValue) {
const responsiveValue = propValue[responsiveIndex];

if (responsiveValue != null) {
const conditionName =
atomicProperty.responsiveArray[responsiveIndex];

if (process.env.NODE_ENV !== 'production') {
if (
!atomicProperty.values[responsiveValue].conditions[
conditionName
]
) {
throw new Error();
}
}

classNames.push(
atomicProperty.values[responsiveValue].conditions[
conditionName
],
);
}
}
} else {
for (const conditionName in propValue) {
// Conditional style
const value = propValue[conditionName];

if (typeof propValue === 'string' || typeof propValue === 'number') {
classNames.push(atomicProperty.values[propValue].defaultClass);
} else if (Array.isArray(propValue)) {
for (const responsiveIndex in propValue) {
const responsiveValue = propValue[responsiveIndex];

if (
typeof responsiveValue === 'string' ||
typeof responsiveValue === 'number'
) {
const conditionName =
atomicProperty.responsiveArray[responsiveIndex];
if (process.env.NODE_ENV !== 'production') {
if (!atomicProperty.values[value].conditions[conditionName]) {
throw new Error();
}
}
classNames.push(
atomicProperty.values[responsiveValue].conditions[conditionName],
atomicProperty.values[value].conditions[conditionName],
);
}
}
} else {
for (const conditionName in propValue) {
// Conditional style
const value = propValue[conditionName];
} catch (e) {
if (process.env.NODE_ENV !== 'production') {
class SprinklesError extends Error {
constructor(message: string) {
super(message);
this.name = 'SprinklesError';
}
}

if (typeof value === 'string' || typeof value === 'number') {
classNames.push(
atomicProperty.values[value].conditions[conditionName],
const format = (v: string | number) =>
typeof v === 'string' ? `"${v}"` : v;

const invalidPropValue = (
prop: string,
value: string | number,
possibleValues: Array<string | number>,
) => {
throw new SprinklesError(
`"${prop}" has no value ${format(
value,
)}. Possible values are ${Object.keys(possibleValues)
.map(format)
.join(', ')}`,
);
};

if (!atomicProperty) {
throw new SprinklesError(`"${prop}" is not a valid atom property`);
}

if (typeof propValue === 'string' || typeof propValue === 'number') {
if (!(propValue in atomicProperty.values)) {
invalidPropValue(prop, propValue, atomicProperty.values);
}
if (!atomicProperty.values[propValue].defaultClass) {
throw new SprinklesError(
`"${prop}" has no default condition. You must specify which conditions to target explicitly. Possible options are ${Object.keys(
atomicProperty.values[propValue].conditions,
)
.map(format)
.join(', ')}`,
);
}
}

if (typeof propValue === 'object') {
if (
!(
'conditions' in
atomicProperty.values[Object.keys(atomicProperty.values)[0]]
)
) {
throw new SprinklesError(
`"${prop}" is not a conditional property`,
);
}

if (Array.isArray(propValue)) {
if (!('responsiveArray' in atomicProperty)) {
throw new SprinklesError(
`"${prop}" does not support responsive arrays`,
);
}

const breakpointCount = atomicProperty.responsiveArray.length;
if (breakpointCount < propValue.length) {
throw new SprinklesError(
`"${prop}" only supports up to ${breakpointCount} breakpoints. You passed ${propValue.length}`,
);
}

for (const responsiveValue of propValue) {
if (!atomicProperty.values[responsiveValue]) {
invalidPropValue(
prop,
responsiveValue,
atomicProperty.values,
);
}
}
} else {
for (const conditionName in propValue) {
const value = propValue[conditionName];

if (!atomicProperty.values[value]) {
invalidPropValue(prop, value, atomicProperty.values);
}

if (!atomicProperty.values[value].conditions[conditionName]) {
throw new SprinklesError(
`"${prop}" has no condition named ${format(
conditionName,
)}. Possible values are ${Object.keys(
atomicProperty.values[value].conditions,
)
.map(format)
.join(', ')}`,
);
}
}
}
}
}

throw e;
}
}

Expand Down
127 changes: 127 additions & 0 deletions tests/sprinkles/sprinkles.test.ts
Expand Up @@ -5,6 +5,8 @@ import {
atomicWithPaddingShorthandStyles,
atomicWithShorthandStyles,
conditionalAtomicStyles,
conditionalStylesWithoutDefaultCondition,
conditionalStylesWithoutResponsiveArray,
} from './index.css';

describe('sprinkles', () => {
Expand Down Expand Up @@ -241,6 +243,131 @@ describe('sprinkles', () => {
});
});

describe('errors', () => {
it('should handle invalid properties', () => {
const atoms = createAtomsFn(conditionalAtomicStyles);

expect(() =>
atoms({
// @ts-expect-error
paddingLefty: 'small',
}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"paddingLefty\\" is not a valid atom property"`,
);
});

it('should handle invalid property values', () => {
const atoms = createAtomsFn(conditionalAtomicStyles);

expect(() =>
atoms({
// @ts-expect-error
paddingLeft: 'xsmall',
}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"paddingLeft\\" is not a valid atom property"`,
);
});

it('should handle conditional objects to unconditional values', () => {
const atoms = createAtomsFn(atomicStyles);

expect(() =>
atoms({
// @ts-expect-error
color: {
mobile: 'red',
},
}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"color\\" is not a conditional property"`,
);
});

it('should handle missing responsive arrays definitions', () => {
const atoms = createAtomsFn(conditionalStylesWithoutResponsiveArray);

expect(() =>
atoms({
// @ts-expect-error
marginTop: ['small'],
}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"marginTop\\" does not support responsive arrays"`,
);
});

it('should handle invalid responsive arrays values', () => {
const atoms = createAtomsFn(conditionalAtomicStyles);

expect(() =>
atoms({
// @ts-expect-error
paddingTop: ['xsmall'],
}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"paddingTop\\" has no value \\"xsmall\\". Possible values are \\"small\\", \\"medium\\", \\"large\\""`,
);
});

it('should handle responsive arrays with too many values', () => {
const atoms = createAtomsFn(conditionalAtomicStyles);

expect(() =>
atoms({
// @ts-expect-error
paddingTop: ['small', 'medium', 'large', 'small'],
}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"paddingTop\\" only supports up to 3 breakpoints. You passed 4"`,
);
});

it('should handle invalid conditional property values', () => {
const atoms = createAtomsFn(conditionalAtomicStyles);

expect(() =>
atoms({
// @ts-expect-error
paddingTop: {
mobile: 'xlarge',
},
}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"paddingTop\\" has no value \\"xlarge\\". Possible values are \\"small\\", \\"medium\\", \\"large\\""`,
);
});

it('should handle properties with no default condition', () => {
const atoms = createAtomsFn(conditionalStylesWithoutDefaultCondition);

expect(() =>
atoms({
// @ts-expect-error
transform: 'shrink',
}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"transform\\" has no default condition. You must specify which conditions to target explicitly. Possible options are \\"active\\""`,
);
});

it('should handle invalid conditions', () => {
const atoms = createAtomsFn(conditionalAtomicStyles);

expect(() =>
atoms({
paddingTop: {
// @ts-expect-error
ultraWide: 'large',
},
}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"paddingTop\\" has no condition named \\"ultraWide\\". Possible values are \\"mobile\\", \\"tablet\\", \\"desktop\\""`,
);
});
});

it('should create atomic styles', () => {
expect(atomicWithShorthandStyles).toMatchInlineSnapshot(`
Object {
Expand Down

0 comments on commit 63c01ad

Please sign in to comment.