@atlassian/aui
Version:
Atlassian User Interface library
425 lines (374 loc) • 15.1 kB
text/typescript
import globalize from './globalize';
type ArbitraryFunction = () => unknown;
type SelectorMap = {
selector: string;
options?: DeprecationOptions;
};
/**
* Purely to reflect the existing state of the code, not ideal.
*/
export type DeprecationOptions = {
/**
* the version this has been deprecated since
*/
sinceVersion?: string;
/**
* the version this will be removed in
*/
removeInVersion?: string;
/**
* the name of an alternative to use
*/
alternativeName?: string;
/**
* extra information to be printed at the end of the deprecation log
*/
extraInfo?: string;
/**
* an extra object that will be printed at the end
*/
extraObject?: object | string;
/**
* a human-readable name to show in the deprecation message. If not provided, it is inferred from the function or object being deprecated.
*/
displayName?: string;
/**
* type of the deprecation to append to the start of the deprecation message. e.g. JS or CSS
*/
deprecationType?: string;
};
// eslint-disable-next-line @typescript-eslint/unbound-method -- Leaving behaviour identical for now
const has = Object.prototype.hasOwnProperty;
const deprecationCalls: (string | string[])[] = [];
function toSentenceCase(name: string): string {
if (!name) {
return '';
}
name = '' + name;
return name.charAt(0).toUpperCase() + name.substring(1);
}
function getDeprecatedLocation(printFrameOffset: number) {
const error = new Error();
let stacktraceString: string | undefined =
error.stack ??
// @ts-expect-error -- preserving the legacy, not sure what .stacktrace would exist on, guessing the type
(error.stacktrace as string | undefined);
stacktraceString = stacktraceString?.replace(/^Error\n/, '') ?? '';
const stacktrace = stacktraceString.split('\n');
return stacktrace[printFrameOffset + 2];
}
function logger(...args: unknown[]): void {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JS test DOM environments
if (typeof console !== 'undefined' && console.warn) {
Function.prototype.apply.call(console.warn, console, args);
}
}
/**
* Return a function that logs a deprecation warning to the console the first time it is called from a certain location.
* It will also print the stack frame of the calling function.
*
* @param {string} displayName the name of the thing being deprecated
* @param {DeprecationOptions} options
* @return {Function} that logs the warning and stack frame of the calling function. Takes in an optional parameter for the offset of
* the stack frame to print, the default is 0. For example, 0 will log it for the line of the calling function,
* -1 will print the location the logger was called from
*/
const getShowDeprecationMessagePublic = (
displayName: string,
options: DeprecationOptions
): ArbitraryFunction => {
return getShowDeprecationMessageInternal(displayName, options);
};
/**
* Return a function that logs a deprecation warning to the console the first time it is called from a certain location.
* It will also print the stack frame of the calling function.
*
* @param {string | symbol | number | Function} displayName the name of the thing being deprecated
* @param {DeprecationOptions} [options]
* @return {Function} that logs the warning and stack frame of the calling function. Takes in an optional parameter for the offset of
* the stack frame to print, the default is 0. For example, 0 will log it for the line of the calling function,
* -1 will print the location the logger was called from
*/
function getShowDeprecationMessageInternal(
displayName: string | ArbitraryFunction,
options?: DeprecationOptions
): ArbitraryFunction {
// This can be used internally to pas in a showmessage fn
if (typeof displayName === 'function') {
return displayName;
}
let called = false;
options = options ?? {};
return function (printFrameOffset?: number): void {
let deprecatedLocation =
getDeprecatedLocation(printFrameOffset ? printFrameOffset : 1) ?? '';
// Only log once if the stack frame doesn't exist to avoid spamming the console/test output
if (!called || !deprecationCalls.includes(deprecatedLocation)) {
deprecationCalls.push(deprecatedLocation);
called = true;
const deprecationType = options.deprecationType ?? '';
let message =
'DEPRECATED ' +
deprecationType +
'- ' +
toSentenceCase(displayName) +
' has been deprecated' +
(options.sinceVersion ? ' since ' + options.sinceVersion : '') +
' and will be removed in ' +
(options.removeInVersion ?? 'a future release') +
'.';
if (options.alternativeName) {
message += ' Use ' + options.alternativeName + ' instead. ';
}
if (options.extraInfo) {
message += ' ' + options.extraInfo;
}
if (deprecatedLocation === '') {
deprecatedLocation =
' \n ' +
'No stack trace of the deprecated usage is available in your current browser.';
} else {
deprecatedLocation = ' \n ' + String(deprecatedLocation);
}
if (options.extraObject) {
message += '\n';
logger(message, options.extraObject, deprecatedLocation);
} else {
logger(message, deprecatedLocation);
}
}
};
}
function logCssDeprecation(selectorMap: SelectorMap, newNode: Node) {
let displayName = selectorMap.options?.displayName;
displayName = displayName ? ' (' + displayName + ')' : '';
const options: DeprecationOptions = Object.assign(
{
deprecationType: 'CSS',
extraObject: newNode,
},
selectorMap.options
);
getShowDeprecationMessageInternal(
"'" + selectorMap.selector + "' pattern" + displayName,
options
)();
}
/**
* Returns a wrapped version of the function that logs a deprecation warning when the function is used.
* @param {Function} fn the fn to wrap
* @param {string} displayName the name of the fn to be displayed in the message
* @param {DeprecationOptions} options
* @return {Function} wrapping the original function
*/
function deprecateFunctionExpression(
fn: ArbitraryFunction,
displayName: string,
options: DeprecationOptions
): ArbitraryFunction {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Just in case someone outside AUI is using it
options = options ?? {};
options.deprecationType = options.deprecationType ?? 'JS';
const showDeprecationMessage = getShowDeprecationMessageInternal(
displayName || fn.name || 'this function',
options
);
return function (...args: unknown[]) {
showDeprecationMessage();
// @ts-expect-error Sorry TS, don't want to change this behaviour just yet in case of side effects
return fn.apply(this, args);
};
}
/**
* Returns a wrapped version of the constructor that logs a deprecation warning when the constructor is instantiated.
* @param {Function} constructorFn the constructor function to wrap
* @param {string} displayName the name of the fn to be displayed in the message
* @param {DeprecationOptions} options
* @return {Function} wrapping the original function
*/
function deprecateConstructor(
constructorFn: ArbitraryFunction,
displayName: string,
options: DeprecationOptions
): ArbitraryFunction {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Just in case someone outside AUI is using it
options = options ?? {};
options.deprecationType = options.deprecationType ?? 'JS';
const deprecatedConstructor = deprecateFunctionExpression(constructorFn, displayName, options);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
deprecatedConstructor.prototype = constructorFn.prototype;
Object.assign(deprecatedConstructor, constructorFn); //copy static methods across;
return deprecatedConstructor;
}
/**
* Wraps a "value" object property in a deprecation warning in browsers supporting Object.defineProperty
* @param {Object} obj the object containing the property
* @param {string} prop the name of the property to deprecate
* @param {DeprecationOptions} [options]
*/
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- This is easier to read
function deprecateValueProperty<T extends object, K extends keyof T & string>(
obj: T,
prop: K,
options?: DeprecationOptions
) {
let oldVal = obj[prop];
options = options ?? {};
options.deprecationType = options.deprecationType ?? 'JS';
const displayNameOrShowMessageFn = options.displayName ?? prop;
const showDeprecationMessage = getShowDeprecationMessageInternal(
displayNameOrShowMessageFn,
options
);
Object.defineProperty(obj, prop, {
get: function (): T[K] {
showDeprecationMessage();
return oldVal;
},
set: function (val: T[K]): T[K] {
oldVal = val;
showDeprecationMessage();
return val;
},
});
}
/**
* Wraps an object property in a deprecation warning, if possible. functions will always log warnings, but other
* types of properties will only log in browsers supporting Object.defineProperty
* @param {Object} object the object containing the property
* @param {string} propertyKey the name of the property to deprecate
* @param {DeprecationOptions} options
*/
function deprecateObjectProperty<T extends object>(
object: T,
propertyKey: string & keyof T,
options: DeprecationOptions
) {
if (typeof object[propertyKey] === 'function') {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Just in case someone outside AUI is using it
options = options ?? {};
options.deprecationType = options.deprecationType ?? 'JS';
const displayNameOrShowMessageFn = options.displayName ?? propertyKey;
// @ts-expect-error -- Maybe a TypeScript wizard can figure out something
// better than me so TS is happy. We only care it's a function.
object[propertyKey] = deprecateFunctionExpression(
object[propertyKey] as ArbitraryFunction,
displayNameOrShowMessageFn,
options
);
} else {
deprecateValueProperty(object, propertyKey, options);
}
}
type DeprecationOptionsWithAltNamePrefix = DeprecationOptions & {
/**
* a prefix for the alternative property name. Used to generate alternativeName per property.
*/
alternativeNamePrefix?: string;
};
/**
* Wraps all an objects properties in a deprecation warning, if possible. functions will always log warnings, but other
* types of properties will only log in browsers supporting Object.defineProperty
* @param {Object} obj the object to be wrapped
* @param {string} objDisplayPrefix the object's prefix to be used in logs
* @param {DeprecationOptionsWithAltNamePrefix} options
*/
function deprecateAllProperties(
obj: object,
objDisplayPrefix: string,
options: DeprecationOptionsWithAltNamePrefix
) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Just in case someone outside AUI is using it
options = options ?? {};
for (const attr in obj) {
if (has.call(obj, attr)) {
options.deprecationType = options.deprecationType ?? 'JS';
options.displayName = objDisplayPrefix + attr;
options.alternativeName =
options.alternativeNamePrefix && options.alternativeNamePrefix + attr;
deprecateObjectProperty(
obj,
// @ts-expect-error -- We're very safely checking this is actually an attribute on the object
attr,
Object.assign({}, options)
);
}
}
}
function matchesSelector(node: Node, selector: string) {
if (node instanceof Element) {
return node.matches(selector);
}
return false;
}
function handleAddingSelector(options?: DeprecationOptions) {
return function (selector: string) {
const selectorMap: SelectorMap = {
selector: selector,
options: options,
};
// Search if matches have already been added
const matches = document.querySelectorAll(selector);
for (const match of matches) {
logCssDeprecation(selectorMap, match);
}
observeFutureChange(selectorMap);
};
}
/**
* Return a function that logs a deprecation warning to the console the first time it is called from a certain location.
* It will also print the stack frame of the calling function.
*/
function deprecateCSS(selectors: string | string[], options?: DeprecationOptions): void {
if (typeof selectors === 'string') {
selectors = [selectors];
}
selectors.forEach(handleAddingSelector(options));
}
function testAndHandleDeprecation(newNode: Node) {
return function (selectorMap: SelectorMap) {
if (matchesSelector(newNode, selectorMap.selector)) {
logCssDeprecation(selectorMap, newNode);
}
};
}
const deprecatedSelectorMap: SelectorMap[] = [];
let observer: MutationObserver | undefined = undefined;
function observeFutureChange(selectorMap: SelectorMap) {
deprecatedSelectorMap.push(selectorMap);
// Lazily instantiate a mutation observer because they're expensive.
if (observer === undefined) {
observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
// TODO - should this also look at class changes, if possible?
const addedNodes = mutation.addedNodes;
for (const newNode of addedNodes) {
if (newNode.nodeType === 1) {
deprecatedSelectorMap.forEach(testAndHandleDeprecation(newNode));
}
}
});
});
const config = {
childList: true,
subtree: true,
};
observer.observe(document, config);
}
}
globalize('deprecate', {
fn: deprecateFunctionExpression,
construct: deprecateConstructor,
css: deprecateCSS,
prop: deprecateObjectProperty,
obj: deprecateAllProperties,
getMessageLogger: getShowDeprecationMessagePublic,
});
export {
deprecateFunctionExpression as fn,
deprecateConstructor as construct,
deprecateCSS as css,
deprecateObjectProperty as prop,
deprecateAllProperties as obj,
getShowDeprecationMessagePublic as getMessageLogger,
};