diff --git a/eslint.config.mjs b/eslint.config.mjs index ee54c8c7ce..de9f3011a1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -115,6 +115,13 @@ export default tsEslint.config( 'line', [' Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.', ' SPDX-License-Identifier: Apache-2.0'], ], + 'no-restricted-syntax': [ + 'error', + { + selector: 'BinaryExpression[operator="in"][left.type="Literal"]', + message: 'Prefer a type guard function with `keyof` instead of raw `in` checks. See AWSUI-59006.', + }, + ], 'no-warning-comments': 'warn', 'simple-import-sort/imports': [ 'error', diff --git a/src/app-layout/runtime-drawer/index.tsx b/src/app-layout/runtime-drawer/index.tsx index 9638f378ee..009d8f2826 100644 --- a/src/app-layout/runtime-drawer/index.tsx +++ b/src/app-layout/runtime-drawer/index.tsx @@ -87,10 +87,12 @@ function mapRuntimeHeaderActionsToHeaderActions( return runtimeHeaderActions.map(runtimeHeaderAction => { return { ...runtimeHeaderAction, + // eslint-disable-next-line no-restricted-syntax -- Runtime plugin API: property not in TS type ...('iconSvg' in runtimeHeaderAction && runtimeHeaderAction.iconSvg && { iconSvg: convertRuntimeTriggerToReactNode(runtimeHeaderAction.iconSvg), }), + // eslint-disable-next-line no-restricted-syntax -- Runtime plugin API: property not in TS type ...('pressedIconSvg' in runtimeHeaderAction && runtimeHeaderAction.pressedIconSvg && { iconSvg: convertRuntimeTriggerToReactNode(runtimeHeaderAction.pressedIconSvg), diff --git a/src/app-layout/visual-refresh-toolbar/state/use-app-layout.tsx b/src/app-layout/visual-refresh-toolbar/state/use-app-layout.tsx index d58868eb1e..aafb2b36c1 100644 --- a/src/app-layout/visual-refresh-toolbar/state/use-app-layout.tsx +++ b/src/app-layout/visual-refresh-toolbar/state/use-app-layout.tsx @@ -254,6 +254,7 @@ export const useAppLayout = ( return; } + // eslint-disable-next-line no-restricted-syntax -- postMessage validation: runtime data shape check if (!('payload' in message && 'id' in message.payload)) { metrics.sendOpsMetricObject('awsui-widget-drawer-incorrect-payload', { type: message.type, diff --git a/src/autosuggest/autosuggest-option.tsx b/src/autosuggest/autosuggest-option.tsx index 2bf0506788..ba7682acb0 100644 --- a/src/autosuggest/autosuggest-option.tsx +++ b/src/autosuggest/autosuggest-option.tsx @@ -108,9 +108,9 @@ const AutosuggestOption = ( ref: React.Ref ) => { const baseProps = getBaseProps(rest); - const useEntered = 'type' in option && option.type === 'use-entered'; - const isParent = 'type' in option && option.type === 'parent'; - const isChild = 'type' in option && option.type === 'child'; + const useEntered = option.type === 'use-entered'; + const isParent = option.type === 'parent'; + const isChild = option.type === 'child'; const { throughIndex, inGroupIndex, groupIndex } = getTestOptionIndexes(option) || {}; let optionContent; diff --git a/src/autosuggest/options-controller.ts b/src/autosuggest/options-controller.ts index abb94f3dba..73cbc1a94b 100644 --- a/src/autosuggest/options-controller.ts +++ b/src/autosuggest/options-controller.ts @@ -179,5 +179,6 @@ function createItems(options: Options) { } function isGroup(optionOrGroup: AutosuggestProps.Option): optionOrGroup is AutosuggestProps.OptionGroup { - return 'options' in optionOrGroup; + const key: keyof AutosuggestProps.OptionGroup = 'options'; + return key in optionOrGroup; } diff --git a/src/autosuggest/utils/utils.ts b/src/autosuggest/utils/utils.ts index 5c4bbff50b..0cc6082f49 100644 --- a/src/autosuggest/utils/utils.ts +++ b/src/autosuggest/utils/utils.ts @@ -5,7 +5,7 @@ import { AutosuggestItem } from '../interfaces'; type SearchableFields = 'value' | 'label' | 'description' | 'labelTag'; type SearchableTagFields = 'tags' | 'filteringTags'; -const isGroup = (option: AutosuggestItem) => 'type' in option && option.type === 'parent'; +const isGroup = (option: AutosuggestItem) => option.type === 'parent'; const popLastGroup = (options: AutosuggestItem[]) => { if (options.length) { diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index e616784397..b392d6282d 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -22,7 +22,7 @@ import { GeneratedAnalyticsMetadataButtonDropdownCollapse, GeneratedAnalyticsMetadataButtonDropdownExpand, } from './analytics-metadata/interfaces.js'; -import { ButtonDropdownProps, InternalButtonDropdownProps } from './interfaces'; +import { ButtonDropdownProps, InternalButtonDropdownProps, InternalItem } from './interfaces'; import ItemsList from './items-list'; import { useButtonDropdown } from './utils/use-button-dropdown'; import { isLinkItem } from './utils/utils.js'; @@ -180,18 +180,20 @@ const InternalButtonDropdown = React.forwardRef( const triggerId = useUniqueId('awsui-button-dropdown__trigger'); const triggerHasBadge = () => { + const groupKey: keyof ButtonDropdownProps.ItemGroup = 'items'; const flatItems = items.flatMap(item => { - if ('items' in item) { + if (groupKey in item) { return item.items; } return item; }); + const badgeKey: keyof InternalItem = 'badge'; return ( variant === 'icon' && !!flatItems?.find(item => { - if ('badge' in item) { - return item.badge; + if (badgeKey in item) { + return (item as InternalItem).badge; } }) ); diff --git a/src/button-group/item-element.tsx b/src/button-group/item-element.tsx index 8cc623e1c1..325dd7e538 100644 --- a/src/button-group/item-element.tsx +++ b/src/button-group/item-element.tsx @@ -103,7 +103,8 @@ const ItemElement = forwardRef( }; const onClickHandler = (event: CustomEvent) => { - const hasPopoverFeedback = 'popoverFeedback' in item && item.popoverFeedback; + const feedbackKey: keyof ButtonGroupProps.IconButton = 'popoverFeedback'; + const hasPopoverFeedback = feedbackKey in item && item.popoverFeedback; if (hasPopoverFeedback) { setTooltip({ item: item.id, feedback: true }); diff --git a/src/flashbar/collapsible-flashbar.tsx b/src/flashbar/collapsible-flashbar.tsx index 60d10a51e6..644ac35f1b 100644 --- a/src/flashbar/collapsible-flashbar.tsx +++ b/src/flashbar/collapsible-flashbar.tsx @@ -35,6 +35,8 @@ import { getFlashTypeCount, getItemColor, getVisibleCollapsedItems, + isRefObject, + isStackableItem, StackableItem, } from './utils'; @@ -214,11 +216,11 @@ export default function CollapsibleFlashbar({ items, style, ...restProps }: Inte // we need to use different, more custom and more controlled animations. const hasEntered = (item: StackableItem | FlashbarProps.MessageDefinition) => enteringItems.some(_item => _item.id && _item.id === item.id); - const hasLeft = (item: StackableItem | FlashbarProps.MessageDefinition) => !('expandedIndex' in item); + const hasLeft = (item: StackableItem | FlashbarProps.MessageDefinition) => !isStackableItem(item); const hasEnteredOrLeft = (item: StackableItem | FlashbarProps.MessageDefinition) => hasEntered(item) || hasLeft(item); const showInnerContent = (item: StackableItem | FlashbarProps.MessageDefinition) => - isFlashbarStackExpanded || hasLeft(item) || ('expandedIndex' in item && item.expandedIndex === 0); + isFlashbarStackExpanded || hasLeft(item) || (isStackableItem(item) && item.expandedIndex === 0); const shouldUseStandardAnimation = (item: StackableItem, index: number) => index === 0 && hasEnteredOrLeft(item); @@ -301,11 +303,7 @@ export default function CollapsibleFlashbar({ items, style, ...restProps }: Inte if (shouldUseStandardAnimation(item, index) && transitionRootElement) { if (typeof transitionRootElement === 'function') { transitionRootElement(el); - } else if ( - transitionRootElement && - typeof transitionRootElement === 'object' && - 'current' in transitionRootElement - ) { + } else if (isRefObject(transitionRootElement)) { (transitionRootElement as React.MutableRefObject).current = el; } } diff --git a/src/flashbar/common.tsx b/src/flashbar/common.tsx index adf4eab5b6..caf270bcf0 100644 --- a/src/flashbar/common.tsx +++ b/src/flashbar/common.tsx @@ -81,6 +81,7 @@ export function useFlashbar({ onItemsRemoved?: (items: FlashbarProps.MessageDefinition[]) => void; onItemsChanged?: (options?: { allItemsHaveId?: boolean; isReducedMotion?: boolean }) => void; }) { + // eslint-disable-next-line no-restricted-syntax -- Optional property existence check const allItemsHaveId = useMemo(() => items.every(item => 'id' in item), [items]); const baseProps = getBaseProps(restProps); const ref = useRef(null); diff --git a/src/flashbar/non-collapsible-flashbar.tsx b/src/flashbar/non-collapsible-flashbar.tsx index 96cb0015b0..6c33ddb876 100644 --- a/src/flashbar/non-collapsible-flashbar.tsx +++ b/src/flashbar/non-collapsible-flashbar.tsx @@ -13,6 +13,7 @@ import { useFlashbar, useFlashbarVisibility } from './common'; import { TIMEOUT_FOR_ENTERING_ANIMATION } from './constant'; import { Flash } from './flash'; import { FlashbarProps, InternalFlashbarProps } from './interfaces'; +import { isRefObject } from './utils'; import styles from './styles.css.js'; @@ -121,11 +122,7 @@ export default function NonCollapsibleFlashbar({ items, i18nStrings, style, ...r // If there's a transition root element ref, update it too if (transitionRootElement && typeof transitionRootElement === 'function') { transitionRootElement(el); - } else if ( - transitionRootElement && - typeof transitionRootElement === 'object' && - 'current' in transitionRootElement - ) { + } else if (transitionRootElement && isRefObject(transitionRootElement)) { (transitionRootElement as React.MutableRefObject).current = el; } }} diff --git a/src/flashbar/utils.ts b/src/flashbar/utils.ts index efbc35aa22..8fbf33029c 100644 --- a/src/flashbar/utils.ts +++ b/src/flashbar/utils.ts @@ -1,5 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + import { IconProps } from '../icon/interfaces'; import { FlashbarProps } from './interfaces'; @@ -13,6 +15,16 @@ export interface StackableItem extends FlashbarProps.MessageDefinition { collapsedIndex?: number; } +export function isStackableItem(item: StackableItem | FlashbarProps.MessageDefinition): item is StackableItem { + const key: keyof StackableItem = 'expandedIndex'; + return key in item; +} + +export function isRefObject(ref: React.Ref): ref is React.RefObject { + const key: keyof React.RefObject = 'current'; + return ref !== null && typeof ref === 'object' && key in ref; +} + const typesToColors: Record = { error: 'red', info: 'blue', diff --git a/src/internal/components/focus-lock/utils.ts b/src/internal/components/focus-lock/utils.ts index 014176e897..e12710f223 100644 --- a/src/internal/components/focus-lock/utils.ts +++ b/src/internal/components/focus-lock/utils.ts @@ -27,6 +27,7 @@ const tabbables = [ /** Whether the element or any of its ancestors are not hidden. */ function isVisible(element: HTMLElement): boolean { + // eslint-disable-next-line no-restricted-syntax -- Browser API feature detection if (!('checkVisibility' in element)) { // checkVisibility isn't defined in JSDOM. It's safer to assume everything is visible. return true; diff --git a/src/internal/components/option/utils/filter-options.ts b/src/internal/components/option/utils/filter-options.ts index ff4d492218..8e7b701169 100644 --- a/src/internal/components/option/utils/filter-options.ts +++ b/src/internal/components/option/utils/filter-options.ts @@ -73,5 +73,7 @@ export const isInteractive = (option?: DropdownOption) => !!option && !option.di export const isGroupInteractive = (option?: DropdownOption) => !!option && !option.disabled; -export const isGroup = (option?: OptionDefinition | OptionGroup): option is OptionGroup => - !!option && 'options' in option && !!option.options; +export const isGroup = (option?: OptionDefinition | OptionGroup): option is OptionGroup => { + const key: keyof OptionGroup = 'options'; + return !!option && key in option && !!option.options; +}; diff --git a/src/internal/utils/dom.ts b/src/internal/utils/dom.ts index b0d300d348..ebfcba4a44 100644 --- a/src/internal/utils/dom.ts +++ b/src/internal/utils/dom.ts @@ -64,10 +64,13 @@ export function isNode(target: unknown): target is Node { target instanceof Node || (target !== null && typeof target === 'object' && + // eslint-disable-next-line no-restricted-syntax -- Cross-window duck typing: instanceof fails across iframes 'nodeType' in target && typeof target.nodeType === 'number' && + // eslint-disable-next-line no-restricted-syntax -- Cross-window duck typing: instanceof fails across iframes 'nodeName' in target && typeof target.nodeName === 'string' && + // eslint-disable-next-line no-restricted-syntax -- Cross-window duck typing: instanceof fails across iframes 'parentNode' in target && typeof target.parentNode === 'object') ); @@ -78,6 +81,7 @@ export function isHTMLElement(target: unknown): target is HTMLElement { target instanceof HTMLElement || (isNode(target) && target.nodeType === Node.ELEMENT_NODE && + // eslint-disable-next-line no-restricted-syntax -- Cross-window HTMLElement detection 'style' in target && typeof target.style === 'object' && typeof target.ownerDocument === 'object' && @@ -90,6 +94,7 @@ export function isSVGElement(target: unknown): target is SVGElement { target instanceof SVGElement || (isNode(target) && target.nodeType === Node.ELEMENT_NODE && + // eslint-disable-next-line no-restricted-syntax -- Cross-window SVGElement detection 'ownerSVGElement' in target && typeof target.ownerSVGElement === 'object') ); diff --git a/src/link/internal.tsx b/src/link/internal.tsx index ccf9f38804..dce62bf611 100644 --- a/src/link/internal.tsx +++ b/src/link/internal.tsx @@ -129,6 +129,7 @@ const InternalLink = React.forwardRef( const fireClickEvent = (event: React.MouseEvent | React.KeyboardEvent) => { const { altKey, ctrlKey, metaKey, shiftKey } = event; + // eslint-disable-next-line no-restricted-syntax -- MouseEvent vs KeyboardEvent discrimination const button = 'button' in event ? event.button : 0; // make onClick non-cancelable to prevent it from being used to block full page reload // for navigation use `onFollow` event instead diff --git a/src/mixed-line-bar-chart/utils.ts b/src/mixed-line-bar-chart/utils.ts index 0baa16f70c..3f7216c0c5 100644 --- a/src/mixed-line-bar-chart/utils.ts +++ b/src/mixed-line-bar-chart/utils.ts @@ -100,13 +100,15 @@ export const getKeyValue = (key: ChartDataTypes) => (key instanceof Date ? key.g export function isYThreshold( series: MixedLineBarChartProps.ChartSeries ): series is MixedLineBarChartProps.YThresholdSeries { - return series.type === 'threshold' && 'y' in series; + const key: keyof MixedLineBarChartProps.YThresholdSeries = 'y'; + return series.type === 'threshold' && key in series; } export function isXThreshold( series: MixedLineBarChartProps.ChartSeries ): series is MixedLineBarChartProps.XThresholdSeries { - return series.type === 'threshold' && 'x' in series; + const key: keyof MixedLineBarChartProps.XThresholdSeries = 'x'; + return series.type === 'threshold' && key in series; } export function isDataSeries( diff --git a/src/navigable-group/internal.tsx b/src/navigable-group/internal.tsx index cf40956263..4fa43b28fe 100644 --- a/src/navigable-group/internal.tsx +++ b/src/navigable-group/internal.tsx @@ -133,6 +133,7 @@ const InternalNavigableGroup = forwardRef( } function isElementDisabled(element: HTMLElement) { + // eslint-disable-next-line no-restricted-syntax -- DOM element capability check if ('disabled' in element) { return element.disabled; } diff --git a/src/property-filter/controller.ts b/src/property-filter/controller.ts index 5deb1b480e..9d54ac4af1 100644 --- a/src/property-filter/controller.ts +++ b/src/property-filter/controller.ts @@ -21,6 +21,7 @@ import { TokenGroup, } from './interfaces'; import { + isInternalToken, matchFilteringProperty, matchOperator, matchOperatorPrefix, @@ -46,7 +47,7 @@ export const getQueryActions = ({ }) => { const setQuery = (query: InternalQuery) => { function transformToken(token: InternalToken | InternalTokenGroup): Token | TokenGroup { - if ('operator' in token) { + if (isInternalToken(token)) { return matchTokenValue(token, filteringOptions); } return { ...token, tokens: token.tokens.map(transformToken) }; diff --git a/src/property-filter/filter-options.ts b/src/property-filter/filter-options.ts index 4fefef4390..46d4422c5e 100644 --- a/src/property-filter/filter-options.ts +++ b/src/property-filter/filter-options.ts @@ -27,7 +27,8 @@ export function filterOptions( } function isGroup(optionOrGroup: AutosuggestProps.Option): optionOrGroup is AutosuggestProps.OptionGroup { - return 'options' in optionOrGroup; + const key: keyof AutosuggestProps.OptionGroup = 'options'; + return key in optionOrGroup; } function matchSingleOption(option: OptionDefinition, searchText: string): boolean { diff --git a/src/property-filter/internal.tsx b/src/property-filter/internal.tsx index 86cf0ca3d3..2e6ea1d496 100644 --- a/src/property-filter/internal.tsx +++ b/src/property-filter/internal.tsx @@ -161,7 +161,11 @@ const PropertyFilterInternal = React.forwardRef( tokenOrGroup: Token | TokenGroup, standaloneIndex?: number ): InternalToken | InternalTokenGroup { - return 'operation' in tokenOrGroup + const isTokenGroup = (t: Token | TokenGroup): t is TokenGroup => { + const key: keyof TokenGroup = 'operation'; + return key in t; + }; + return isTokenGroup(tokenOrGroup) ? { operation: tokenOrGroup.operation, tokens: tokenOrGroup.tokens.map(token => transformToken(token)), @@ -316,6 +320,7 @@ const PropertyFilterInternal = React.forwardRef( return; } + // eslint-disable-next-line no-restricted-syntax -- Runtime property not in TS type if (!('keepOpenOnSelect' in option)) { createToken(value); return; diff --git a/src/property-filter/token.tsx b/src/property-filter/token.tsx index 995d867e89..dc4363b9b6 100644 --- a/src/property-filter/token.tsx +++ b/src/property-filter/token.tsx @@ -23,7 +23,7 @@ import { LoadItemsDetail, } from './interfaces'; import { TokenEditor } from './token-editor'; -import { tokenGroupToTokens } from './utils'; +import { isInternalToken, isInternalTokenGroup, tokenGroupToTokens } from './utils'; import analyticsSelectors from './analytics-metadata/styles.css.js'; import styles from './styles.css.js'; @@ -71,20 +71,24 @@ export const TokenButton = ({ }: TokenProps) => { const tokenRef = useRef(null); - const hasGroups = query.tokens.some(tokenOrGroup => 'operation' in tokenOrGroup); + const hasGroups = query.tokens.some(isInternalTokenGroup); const first = tokenIndex === 0; const tokenOrGroup = query.tokens[tokenIndex]; const tokens = tokenGroupToTokens([tokenOrGroup]).map(t => ({ ...t, standaloneIndex: undefined })); const operation = query.operation; - const groupOperation = 'operation' in tokenOrGroup ? tokenOrGroup.operation : operation === 'and' ? 'or' : 'and'; + const groupOperation = isInternalTokenGroup(tokenOrGroup) + ? tokenOrGroup.operation + : operation === 'and' + ? 'or' + : 'and'; const [tempTokens, setTempTokens] = useState(tokens); const capturedTokenIndices = tempTokens.map(token => token.standaloneIndex).filter(index => index !== undefined); const tokensToCapture: InternalToken[] = []; for (let index = 0; index < query.tokens.length; index++) { const token = query.tokens[index]; - if ('operator' in token && token !== tokenOrGroup && !capturedTokenIndices.includes(index)) { + if (isInternalToken(token) && token !== tokenOrGroup && !capturedTokenIndices.includes(index)) { tokensToCapture.push(token); } } diff --git a/src/property-filter/utils.ts b/src/property-filter/utils.ts index fea663011b..e018855c91 100644 --- a/src/property-filter/utils.ts +++ b/src/property-filter/utils.ts @@ -6,9 +6,22 @@ import { InternalFilteringOption, InternalFilteringProperty, InternalToken, + InternalTokenGroup, Token, } from './interfaces'; +export function isInternalToken(tokenOrGroup: InternalToken | InternalTokenGroup): tokenOrGroup is InternalToken { + const key: keyof InternalToken = 'operator'; + return key in tokenOrGroup; +} + +export function isInternalTokenGroup( + tokenOrGroup: InternalToken | InternalTokenGroup +): tokenOrGroup is InternalTokenGroup { + const key: keyof InternalTokenGroup = 'operation'; + return key in tokenOrGroup; +} + // Finds the longest property the filtering text starts from. export function matchFilteringProperty( filteringProperties: readonly InternalFilteringProperty[], @@ -137,17 +150,22 @@ interface AbstractTokenGroup { tokens: readonly (T | AbstractTokenGroup)[]; } +function isAbstractToken(tokenOrGroup: T | AbstractTokenGroup): tokenOrGroup is T { + const key: keyof AbstractToken = 'operator'; + return key in tokenOrGroup; +} + /** * Transforms query token groups to tokens (only taking 1 level of nesting). */ export function tokenGroupToTokens(tokenGroups: readonly (T | AbstractTokenGroup)[]): T[] { const tokens: T[] = []; for (const tokenOrGroup of tokenGroups) { - if ('operator' in tokenOrGroup) { + if (isAbstractToken(tokenOrGroup)) { tokens.push(tokenOrGroup); } else { for (const nestedTokenOrGroup of tokenOrGroup.tokens) { - if ('operator' in nestedTokenOrGroup) { + if (isAbstractToken(nestedTokenOrGroup)) { tokens.push(nestedTokenOrGroup); } else { // Ignore deeply nested tokens diff --git a/src/select/utils/check-option-value-field.ts b/src/select/utils/check-option-value-field.ts index 2622358713..6134e101b0 100644 --- a/src/select/utils/check-option-value-field.ts +++ b/src/select/utils/check-option-value-field.ts @@ -14,6 +14,8 @@ export function checkOptionValueField>( return; } + // The `in` checks below are intentional: `element` is `any` (loosely-typed external input), + // so compile-time `keyof` validation is not possible. These checks validate runtime shape. const valuePropertyMissing = !propertyValue.every(element => { return 'options' in element || 'value' in element; }); diff --git a/src/side-navigation/util.tsx b/src/side-navigation/util.tsx index 8f74d5543a..e6750833d0 100644 --- a/src/side-navigation/util.tsx +++ b/src/side-navigation/util.tsx @@ -50,14 +50,16 @@ export function checkDuplicateHrefs(items: ReadonlyArray { editor.env.editor.setValue(value, -1); diff --git a/src/top-navigation/parts/utility.tsx b/src/top-navigation/parts/utility.tsx index bf705f9bfd..6fc0df1003 100644 --- a/src/top-navigation/parts/utility.tsx +++ b/src/top-navigation/parts/utility.tsx @@ -4,6 +4,7 @@ import React from 'react'; import clsx from 'clsx'; import { InternalButton } from '../../button/internal'; +import { ButtonDropdownProps } from '../../button-dropdown/interfaces'; import { isLinkItem } from '../../button-dropdown/utils/utils'; import InternalIcon from '../../icon/internal'; import MenuDropdown, { MenuDropdownProps } from '../../internal/components/menu-dropdown'; @@ -133,7 +134,8 @@ function checkSafeUrlRecursively(itemOrGroup: MenuDropdownProps['items']) { checkSafeUrl('TopNavigation', item.href); } - if ('items' in item) { + const itemsKey: keyof ButtonDropdownProps.ItemGroup = 'items'; + if (itemsKey in item) { checkSafeUrlRecursively(item.items); } }