diff --git a/.ai/rules/code-conformance.md b/.ai/rules/code-conformance.md index 4aae1a2e88e..11a7b74c76f 100644 --- a/.ai/rules/code-conformance.md +++ b/.ai/rules/code-conformance.md @@ -50,6 +50,7 @@ Reference: [Linting tools](../../CONTRIBUTOR-DOCS/02_style-guide/03_linting-tool - Methods follow visibility and naming conventions - JSDoc is present and well-formed on public API members - No patterns listed as anti-patterns or discouraged in the guide +- Dev-warning validation (enum values, required/conditionally required properties, mutually exclusive combinations, required slots, allowed children) uses the shared helpers in `@spectrum-web-components/core/utils` (`validateEnum`, `warnIf`, `validateRequiredSlot`, `validateAllowedChildren`), not hand-rolled `includes()` + `window.__swc.warn()` checks. See [Debug and validation](../../CONTRIBUTOR-DOCS/02_style-guide/02_typescript/17_debug-validation.md#reusable-validation-helpers). ## CSS diff --git a/.changeset/dev-warning-validation-helpers.md b/.changeset/dev-warning-validation-helpers.md new file mode 100644 index 00000000000..920aaf0f72a --- /dev/null +++ b/.changeset/dev-warning-validation-helpers.md @@ -0,0 +1,7 @@ +--- +'@spectrum-web-components/core': minor +--- + +**feat(dev-validation):** Added reusable dev-mode validation helpers (`validateEnum`, `warnIf`, `validateRequiredSlot`, `validateAllowedChildren`) in `@spectrum-web-components/core/utils`, and fixed `window.__swc.warn`'s dedup key so two distinct warnings on the same component no longer suppress each other. + +Component authors should use these helpers instead of hand-rolled `includes()` + `window.__swc.warn()` checks for union/enum values, required and conditionally required properties, mutually exclusive/no-effect combinations, required slots, and allowed slotted children. See the "Reusable validation helpers" and "Slot validation" sections of the [Debug and validation style guide](https://github.com/adobe/spectrum-web-components/blob/main/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/17_debug-validation.md). diff --git a/2nd-gen/packages/core/element/spectrum-element.ts b/2nd-gen/packages/core/element/spectrum-element.ts index 38af380890b..2cbb12ef488 100644 --- a/2nd-gen/packages/core/element/spectrum-element.ts +++ b/2nd-gen/packages/core/element/spectrum-element.ts @@ -26,7 +26,133 @@ export class SpectrumElement extends LitElement { static CORE_VERSION = coreVersion; } -if (process.env.NODE_ENV === 'development') { +/** + * Builds the deduplication key for a dev-mode warning. The `message` is part of + * the key so two distinct problems that share a `type`/`level` (the common + * case) do not suppress each other; only a verbatim repeat of the same warning + * is deduplicated. Exported so the dedup key can be unit tested without relying + * on the `NODE_ENV`-gated `window.__swc` setup below. + */ +export function warningId( + localName: string, + type: WarningType, + level: WarningLevel, + message: string +): BrandedSWCWarningID { + return `${localName}:${type}:${level}:${message}` as BrandedSWCWarningID; +} + +/** + * A group of dev-mode warnings that share a `warningId`, collected within a + * single microtask so they can be emitted as one console line with a count. + */ +type BatchedWarning = { + message: string; + url: string; + localName: string; + type: WarningType; + level: WarningLevel; + listedIssues: string; + elements: HTMLElement[]; + count: number; + + /** + * Elements already counted in this batch, keyed by identity. A repeated + * `warn()` for an element already here does not bump `count` again. + */ + countedElements?: Set; + + /** + * Whether the dev-mode warning (the only warning with no element) has been + * counted, so repeats of it collapse to one. + */ + countedDevModeWarning?: boolean; +}; + +/** + * Builds the `console.warn` arguments for a grouped dev warning: the message + * and a count line, then the element ref(s) to inspect. By default one + * representative element is shown; `verbose` shows every collected element + * (already capped at collection time). Exported so the grouped output can be + * unit tested without the `NODE_ENV`-gated `window.__swc` setup below. + * + * @internal + */ +export function buildGroupedWarningArgs( + batch: BatchedWarning, + verbose: boolean +): unknown[] { + const { + message, + url, + localName, + type, + level, + listedIssues, + elements, + count, + } = batch; + const intro = level === 'deprecation' ? 'DEPRECATION NOTICE: ' : ''; + const shown = verbose ? elements : elements.slice(0, 1); + let countLine = ''; + if (count === 1) { + countLine = shown.length ? '\nAffected element:' : ''; + } else { + countLine = `\n${count} <${localName}> elements affected:`; + } + const args: unknown[] = [intro + message + '\n' + listedIssues + countLine]; + for (const el of shown) { + args.push(el); + } + args.push((shown.length ? '\n\n' : '\n') + url + '\n', { + data: { localName, type, level, count }, + }); + return args; +} + +/** + * Records one warning occurrence into its batch, counting by element identity: + * `count` becomes the number of distinct elements with this warning, not the + * number of `warn()` calls. A host that warns once per child (same message, + * same element) still counts as one affected element. `count` is uncapped; + * `maxDisplayed` only limits how many element refs are kept for display. + * + * @param batch - Grouped-warning batch to record into. + * @param element - Element the warning is about, or `undefined` for the + * dev-mode warning, which is not about a specific element. + * @param maxDisplayed - Cap on element refs kept for display; does not affect + * `count`. + * @internal + */ +export function recordAffectedElement( + batch: BatchedWarning, + element: HTMLElement | undefined, + maxDisplayed: number +): void { + if (!element) { + // The dev-mode warning is the only warning with no element; count it once + // however often it fires. + if (batch.countedDevModeWarning) { + return; + } + batch.countedDevModeWarning = true; + batch.count += 1; + return; + } + const countedElements = (batch.countedElements ??= new Set()); + if (countedElements.has(element)) { + return; // this element is already counted for the batch + } + countedElements.add(element); + batch.count += 1; + if (batch.elements.length < maxDisplayed) { + batch.elements.push(element); + } +} + +// Enabled in every environment except production (the ecosystem convention); +// a production build strips this block via dead-code elimination. +if (process.env.NODE_ENV !== 'production') { const ignoreWarningTypes = { default: false, accessibility: false, @@ -39,6 +165,26 @@ if (process.env.NODE_ENV === 'development') { high: false, deprecation: false, }; + + // Grouped warnings: collect same-`warningId` warnings fired within one + // microtask and emit a single console line with the total count. By default + // one representative element is shown for inspection; `verbose` lists all + // collected elements (capped at WARNING_ELEMENT_CAP). + const WARNING_ELEMENT_CAP = 10; + const batchedWarnings = new Map(); + let flushScheduled = false; + + const flushWarnings = (): void => { + flushScheduled = false; + batchedWarnings.forEach((batch, id) => { + console.warn( + ...buildGroupedWarningArgs(batch, Boolean(window.__swc.verbose)) + ); + window.__swc.issuedWarnings.add(id); + }); + batchedWarnings.clear(); + }; + window.__swc = { ...window.__swc, DEBUG: true, @@ -61,10 +207,8 @@ if (process.env.NODE_ENV === 'development') { { type = 'api', level = 'default', issues } = {} ): void => { const { localName = 'base' } = element || {}; - const id = `${localName}:${type}:${level}` as BrandedSWCWarningID; - if (!window.__swc.verbose && window.__swc.issuedWarnings.has(id)) { - return; - } + const id = warningId(localName, type, level, message); + // Ignore filters short-circuit before any batching. if (window.__swc.ignoreWarningLocalNames[localName]) { return; } @@ -74,30 +218,37 @@ if (process.env.NODE_ENV === 'development') { if (window.__swc.ignoreWarningLevels[level]) { return; } - window.__swc.issuedWarnings.add(id); - let listedIssues = ''; - if (issues && issues.length) { - issues.unshift(''); - listedIssues = issues.join('\n - ') + '\n'; - } - const intro = level === 'deprecation' ? 'DEPRECATION NOTICE: ' : ''; - const inspectElement = element - ? '\nInspect this issue in the follow element:' - : ''; - const displayURL = (element ? '\n\n' : '\n') + url + '\n'; - const messages: unknown[] = []; - messages.push(intro + message + '\n' + listedIssues + inspectElement); - if (element) { - messages.push(element); + // Session dedup: each id emits one grouped line per session. `verbose` + // bypasses it so every microtask flush re-emits. + if (!window.__swc.verbose && window.__swc.issuedWarnings.has(id)) { + return; } - messages.push(displayURL, { - data: { + // Collect into the per-id batch; `flushWarnings` builds the grouped + // message on the next microtask. + let batch = batchedWarnings.get(id); + if (!batch) { + let listedIssues = ''; + if (issues && issues.length) { + issues.unshift(''); + listedIssues = issues.join('\n - ') + '\n'; + } + batch = { + message, + url, localName, type, level, - }, - }); - console.warn(...messages); + listedIssues, + elements: [], + count: 0, + }; + batchedWarnings.set(id, batch); + } + recordAffectedElement(batch, element, WARNING_ELEMENT_CAP); + if (!flushScheduled) { + flushScheduled = true; + queueMicrotask(flushWarnings); + } }, }; diff --git a/2nd-gen/packages/core/element/test/spectrum-element.test.ts b/2nd-gen/packages/core/element/test/spectrum-element.test.ts new file mode 100644 index 00000000000..5bdd246c5fd --- /dev/null +++ b/2nd-gen/packages/core/element/test/spectrum-element.test.ts @@ -0,0 +1,185 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { html } from 'lit'; +import { expect } from '@storybook/test'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import { + buildGroupedWarningArgs, + recordAffectedElement, + warningId, +} from '../spectrum-element.js'; + +export default { + title: 'Utils/Dev validation/Dedup tests', + tags: ['!autodocs', 'dev'], + render: () => html` +
+ `, +} as Meta; + +// `warningId` builds the dedup key `window.__swc.warn` uses to suppress repeat +// warnings. Testing it directly guards the dedup-key fix (adding `message` to +// the key) without depending on the `NODE_ENV`-gated `window.__swc` setup, +// which the test harness (`NODE_ENV === 'test'`) never runs. +export const WarningIdTest: Story = { + play: async ({ step }) => { + await step('distinct messages produce distinct dedup keys', () => { + const first = warningId('swc-badge', 'api', 'default', 'first problem'); + const second = warningId('swc-badge', 'api', 'default', 'second problem'); + // Same localName/type/level, different message: the keys must differ so + // the second warning is not suppressed. This is the behavior the + // dedup-key fix restored. + expect(first).not.toBe(second); + }); + + await step('an identical warning produces the same dedup key', () => { + const first = warningId('swc-badge', 'api', 'default', 'same problem'); + const second = warningId('swc-badge', 'api', 'default', 'same problem'); + // A verbatim repeat collapses to one key, so it is deduplicated. + expect(first).toBe(second); + }); + + await step('the key is localName:type:level:message, in that order', () => { + expect(String(warningId('swc-badge', 'api', 'high', 'bad variant'))).toBe( + 'swc-badge:api:high:bad variant' + ); + }); + }, +}; + +// Builds a grouped-warning batch (the shape `window.__swc.warn` accumulates +// within a microtask). +const makeBatch = ( + count: number, + numElements: number +): Parameters[0] => ({ + message: + ' expects "variant" to be one of: neutral. Received "banana".', + url: 'https://spectrum-web-components.adobe.com/?path=/docs/components-badge--docs', + localName: 'swc-badge', + type: 'api', + level: 'default', + listedIssues: '', + elements: Array.from({ length: numElements }, () => + document.createElement('div') + ), + count, +}); + +const elementArgs = (args: unknown[]): HTMLElement[] => + args.filter((arg): arg is HTMLElement => arg instanceof HTMLElement); + +const countFromArgs = (args: unknown[]): number => + (args[args.length - 1] as { data: { count: number } }).data.count; + +// `buildGroupedWarningArgs` produces the exact arguments `window.__swc.warn` +// passes to `console.warn` for a grouped warning. Testing it directly captures +// the grouped output (count line + element refs) +export const GroupedWarningTest: Story = { + play: async ({ step }) => { + await step('single element: "affected element", exactly one ref', () => { + const args = buildGroupedWarningArgs(makeBatch(1, 1), false); + expect(String(args[0])).toContain('Affected element:'); + expect(elementArgs(args).length).toBe(1); + expect(countFromArgs(args)).toBe(1); + }); + + await step( + 'multiple, default: count line names the total, one representative ref', + () => { + const args = buildGroupedWarningArgs(makeBatch(12, 10), false); + expect(String(args[0])).toContain('12 elements affected:'); + // The default path surfaces exactly one live element ref, whatever the + // count, so a table of N broken rows can't pin N DOM nodes. + expect(elementArgs(args).length).toBe(1); + expect(countFromArgs(args)).toBe(12); + } + ); + + await step( + 'multiple, verbose: same count line, every collected ref', + () => { + const args = buildGroupedWarningArgs(makeBatch(12, 10), true); + expect(String(args[0])).toContain('12 elements affected:'); + // Verbose surfaces all collected refs; collection caps them at 10. + expect(elementArgs(args).length).toBe(10); + expect(countFromArgs(args)).toBe(12); + } + ); + }, +}; + +// `recordAffectedElement` accumulates one `warn()` occurrence into its batch. +// It deduplicates by element identity so `count` reflects distinct affected +// elements, not the number of `warn()` calls. +export const WarningCountDedupTest: Story = { + play: async ({ step }) => { + const WARNING_ELEMENT_CAP = 10; + + await step( + 'one host warned repeatedly (e.g. several same-tag bad children) counts once', + () => { + const batch = makeBatch(0, 0); + const host = document.createElement('div'); + // Simulate validateAllowedChildren emitting for three same-tag children + // on the same host: identical message, same element, same microtask. + recordAffectedElement(batch, host, WARNING_ELEMENT_CAP); + recordAffectedElement(batch, host, WARNING_ELEMENT_CAP); + recordAffectedElement(batch, host, WARNING_ELEMENT_CAP); + expect(batch.count).toBe(1); + expect(batch.elements.length).toBe(1); + } + ); + + await step('distinct elements each count once', () => { + const batch = makeBatch(0, 0); + recordAffectedElement( + batch, + document.createElement('div'), + WARNING_ELEMENT_CAP + ); + recordAffectedElement( + batch, + document.createElement('div'), + WARNING_ELEMENT_CAP + ); + expect(batch.count).toBe(2); + expect(batch.elements.length).toBe(2); + }); + + await step( + 'distinct elements past the cap still count, display list stays capped', + () => { + const batch = makeBatch(0, 0); + for (let i = 0; i < 15; i += 1) { + recordAffectedElement( + batch, + document.createElement('div'), + WARNING_ELEMENT_CAP + ); + } + expect(batch.count).toBe(15); + expect(batch.elements.length).toBe(WARNING_ELEMENT_CAP); + } + ); + + await step('the dev-mode warning counts once even if repeated', () => { + const batch = makeBatch(0, 0); + recordAffectedElement(batch, undefined, WARNING_ELEMENT_CAP); + recordAffectedElement(batch, undefined, WARNING_ELEMENT_CAP); + expect(batch.count).toBe(1); + expect(batch.elements.length).toBe(0); + }); + }, +}; diff --git a/2nd-gen/packages/core/global.d.ts b/2nd-gen/packages/core/global.d.ts index 60e16761795..fbea529c0d7 100644 --- a/2nd-gen/packages/core/global.d.ts +++ b/2nd-gen/packages/core/global.d.ts @@ -18,7 +18,8 @@ type SWCWarningOptions = { level?: WarningLevel; issues?: string[]; }; -type BrandedSWCWarningID = `${ElementLocalName}:${WarningType}:${WarningLevel}`; +type BrandedSWCWarningID = + `${ElementLocalName}:${WarningType}:${WarningLevel}:${string}`; /** * ARIA element-reflection properties (IDRef-free associations). Not yet in the diff --git a/2nd-gen/packages/core/utils/dev-validation.ts b/2nd-gen/packages/core/utils/dev-validation.ts new file mode 100644 index 00000000000..08619762cf9 --- /dev/null +++ b/2nd-gen/packages/core/utils/dev-validation.ts @@ -0,0 +1,211 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * The single place "warn in development" lives: the `DEBUG` gate plus the + * `window.__swc.warn` call. Every public helper below computes its own + * predicate and message, then defers the actual emit to this primitive, so + * there is one source of truth for how a warning is gated and dispatched. + * + * Note: this deliberately does **not** include the + * `process.env.NODE_ENV === 'production'` guard. That guard must stay in each + * public helper so a bundler can dead-code-eliminate the message construction + * inside that function for production builds; centralizing it here would leave + * each caller's message-building in the production bundle. + * + * @param element - The component instance the warning is attributed to. + * @param message - The warning message. + * @param url - Documentation URL for the component. + * @param options - Passed through to `window.__swc.warn`. + */ +function emitWarning( + element: HTMLElement, + message: string, + url: string, + options?: SWCWarningOptions +): void { + if (!window.__swc?.DEBUG) { + return; + } + window.__swc.warn(element, message, url, options); +} + +/** + * Whether dev-mode validation is active: `false` in production builds, + * otherwise mirrors `window.__swc.DEBUG`. + * + * Use this to guard the *call site* of a warning whose condition or message is + * expensive to compute (for example a DOM traversal), so that work is skipped + * entirely when validation is off: + * + * ```ts + * if (isDebug()) { + * this.warnAboutExpensiveThing(); // only traverses the DOM when validation runs + * } + * ``` + * + * For cheap conditions, call `warnIf`/`validateEnum` directly; they already + * gate internally. This helper exists only to avoid paying call-site argument + * cost in the expensive cases. It checks `process.env.NODE_ENV` first so a + * bundler can dead-code-eliminate the guarded block in production, matching the + * other helpers in this file. + */ +export function isDebug(): boolean { + if (process.env.NODE_ENV === 'production') { + return false; + } + return Boolean(window.__swc?.DEBUG); +} + +/** + * Warns when `value` is not one of `valid`. Covers union-type/enum property + * validation (e.g. `variant`, `size`). + * + * @param element - The component instance the warning is attributed to. + * @param check - The enum check to perform. + * @param check.prop - The property name, for the warning message. + * @param check.value - The value received. + * @param check.valid - The allowed values. + * @param check.url - Documentation URL for the component. + * @param check.options - Passed through to `window.__swc.warn`. + */ +export function validateEnum( + element: HTMLElement, + { + prop, + value, + valid, + url, + options, + }: { + prop: string; + value: string; + valid: readonly T[]; + url: string; + options?: SWCWarningOptions; + } +): void { + if (process.env.NODE_ENV === 'production') { + return; + } + if ((valid as readonly string[]).includes(value)) { + return; + } + emitWarning( + element, + `<${element.localName}> expects "${prop}" to be one of: ${valid.join(', ')}. Received "${value}".`, + url, + { issues: [`${prop}="${value}"`], ...options } + ); +} + +/** + * Warns when `condition` is true. The general-purpose validation primitive: + * covers required properties, conditionally required properties, mutually + * exclusive/no-effect property combinations, and any component-specific + * quirk that doesn't fit the other helpers here. + * + * @param element - The component instance the warning is attributed to. + * @param condition - Warn when this is true. + * @param message - The warning message. + * @param url - Documentation URL for the component. + * @param options - Passed through to `window.__swc.warn`. + */ +export function warnIf( + element: HTMLElement, + condition: boolean, + message: string, + url: string, + options?: SWCWarningOptions +): void { + if (process.env.NODE_ENV === 'production') { + return; + } + if (!condition) { + return; + } + emitWarning(element, message, url, options); +} + +/** + * Warns when a slot has no assigned nodes. Covers required-slot validation. + * + * @param element - The component instance the warning is attributed to. + * @param slot - The slot element to check (`null`/`undefined` counts as empty). + * @param slotName - The slot's `name` attribute (or `"default"`), for the message. + * @param url - Documentation URL for the component. + * @param options - Passed through to `window.__swc.warn`. + */ +export function validateRequiredSlot( + element: HTMLElement, + slot: HTMLSlotElement | null | undefined, + slotName: string, + url: string, + options?: SWCWarningOptions +): void { + if (process.env.NODE_ENV === 'production') { + return; + } + const isEmpty = !slot || slot.assignedNodes({ flatten: true }).length === 0; + if (!isEmpty) { + return; + } + emitWarning( + element, + `<${element.localName}> requires content in the "${slotName}" slot.`, + url, + options + ); +} + +/** + * Warns for each assigned element in `slot` whose tag name is not in + * `allowedTagNames`. Covers allowed-children slot validation (e.g. a heading + * slot that only accepts `

`-`

`). + * + * @param element - The component instance the warning is attributed to. + * @param slot - The slot element to check. + * @param allowedTagNames - Allowed tag names (case-insensitive, e.g. `['h2', 'h3']`). + * @param slotName - The slot's `name` attribute (or `"default"`), for the message. + * @param url - Documentation URL for the component. + * @param options - Passed through to `window.__swc.warn`. + */ +export function validateAllowedChildren( + element: HTMLElement, + slot: HTMLSlotElement | null | undefined, + allowedTagNames: readonly string[], + slotName: string, + url: string, + options?: SWCWarningOptions +): void { + if (process.env.NODE_ENV === 'production') { + return; + } + if (!slot) { + return; + } + const allowed = allowedTagNames.map((tag) => tag.toUpperCase()); + const allowedList = allowedTagNames.map((tag) => `<${tag}>`).join(', '); + for (const el of slot.assignedElements()) { + if (!allowed.includes(el.tagName)) { + emitWarning( + element, + `<${element.localName}> "${slotName}" slot received a <${el.tagName.toLowerCase()}> element. Only ${allowedList} elements are allowed.`, + url, + { + issues: [`${slotName} slot: <${el.tagName.toLowerCase()}>`], + ...options, + } + ); + } + } +} diff --git a/2nd-gen/packages/core/utils/index.ts b/2nd-gen/packages/core/utils/index.ts index 4f049058ede..1a3b4c5602e 100644 --- a/2nd-gen/packages/core/utils/index.ts +++ b/2nd-gen/packages/core/utils/index.ts @@ -13,6 +13,13 @@ export { physicalSide } from './actual-placement.js'; export { capitalize } from './capitalize.js'; export { deepContains } from './deep-contains.js'; +export { + isDebug, + validateAllowedChildren, + validateEnum, + validateRequiredSlot, + warnIf, +} from './dev-validation.js'; export { isTopDismissible, registerDismissible, diff --git a/2nd-gen/packages/core/utils/test/dev-validation.test.ts b/2nd-gen/packages/core/utils/test/dev-validation.test.ts new file mode 100644 index 00000000000..acf61df85d8 --- /dev/null +++ b/2nd-gen/packages/core/utils/test/dev-validation.test.ts @@ -0,0 +1,343 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { html } from 'lit'; +import { expect } from '@storybook/test'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import { + isDebug, + validateAllowedChildren, + validateEnum, + validateRequiredSlot, + warnIf, +} from '../index.js'; + +const URL = + 'https://opensource.adobe.com/spectrum-web-components/components/test/'; + +// Enables DEBUG mode and captures window.__swc.warn calls for the duration of `fn`. +async function withWarningSpy( + fn: (warnCalls: unknown[][]) => void | Promise, + { debug = true }: { debug?: boolean } = {} +): Promise { + const originalDebug = window.__swc?.DEBUG; + const originalWarn = window.__swc?.warn; + const warnCalls: unknown[][] = []; + window.__swc = { + ...window.__swc, + DEBUG: debug, + warn: (...args: unknown[]) => { + warnCalls.push(args); + }, + } as Window['__swc']; + try { + await fn(warnCalls); + } finally { + window.__swc = { + ...window.__swc, + DEBUG: originalDebug, + warn: originalWarn, + } as Window['__swc']; + } +} + +export default { + title: 'Utils/Dev validation/Tests', + tags: ['!autodocs', 'dev'], + render: () => html` +
+ `, +} as Meta; + +export const DevValidationTest: Story = { + play: async ({ step }) => { + await step('validateEnum warns for an invalid value', () => + withWarningSpy((warnCalls) => { + const el = document.createElement('div'); + validateEnum(el, { + prop: 'variant', + value: 'bogus', + valid: ['positive', 'negative'], + url: URL, + }); + expect(warnCalls.length).toBeGreaterThan(0); + expect(String(warnCalls[0]?.[1] || '')).toContain('variant'); + }) + ); + + await step('validateEnum does not warn for a valid value', () => + withWarningSpy((warnCalls) => { + const el = document.createElement('div'); + validateEnum(el, { + prop: 'variant', + value: 'positive', + valid: ['positive', 'negative'], + url: URL, + }); + expect(warnCalls.length).toBe(0); + }) + ); + + await step('warnIf warns only when the condition is true', () => + withWarningSpy((warnCalls) => { + const el = document.createElement('div'); + warnIf(el, false, 'should not fire', URL); + expect(warnCalls.length).toBe(0); + warnIf(el, true, 'required property missing', URL); + expect(warnCalls.length).toBe(1); + expect(String(warnCalls[0]?.[1] || '')).toContain('required property'); + }) + ); + + await step('validateRequiredSlot warns when the slot is empty', () => + withWarningSpy((warnCalls) => { + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + const slot = document.createElement('slot'); + shadow.append(slot); + validateRequiredSlot(host, slot, 'label', URL); + expect(warnCalls.length).toBeGreaterThan(0); + expect(String(warnCalls[0]?.[1] || '')).toContain('label'); + }) + ); + + await step( + 'validateRequiredSlot does not warn when content is assigned', + () => + withWarningSpy((warnCalls) => { + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + const slot = document.createElement('slot'); + slot.name = 'label'; + shadow.append(slot); + const content = document.createElement('span'); + content.slot = 'label'; + host.append(content); + validateRequiredSlot(host, slot, 'label', URL); + expect(warnCalls.length).toBe(0); + }) + ); + + await step( + 'validateAllowedChildren warns for a disallowed child element', + () => + withWarningSpy((warnCalls) => { + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + const slot = document.createElement('slot'); + slot.name = 'heading'; + shadow.append(slot); + const p = document.createElement('p'); + p.slot = 'heading'; + host.append(p); + validateAllowedChildren( + host, + slot, + ['h2', 'h3', 'h4', 'h5', 'h6'], + 'heading', + URL + ); + expect(warnCalls.length).toBeGreaterThan(0); + expect(String(warnCalls[0]?.[1] || '')).toContain('

'); + }) + ); + + await step( + 'validateAllowedChildren does not warn for an allowed child', + () => + withWarningSpy((warnCalls) => { + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + const slot = document.createElement('slot'); + slot.name = 'heading'; + shadow.append(slot); + const h2 = document.createElement('h2'); + h2.slot = 'heading'; + host.append(h2); + validateAllowedChildren( + host, + slot, + ['h2', 'h3', 'h4', 'h5', 'h6'], + 'heading', + URL + ); + expect(warnCalls.length).toBe(0); + }) + ); + + // Guards the DEBUG gate that `emitWarning` centralizes: with DEBUG off, + // no helper may warn even for clearly invalid input. Without this, an + // inverted/dropped gate would still pass every test above (they force + // DEBUG on). + await step('no helper warns when DEBUG is off', () => + withWarningSpy( + (warnCalls) => { + const el = document.createElement('div'); + validateEnum(el, { + prop: 'variant', + value: 'bogus', + valid: ['positive'], + url: URL, + }); + warnIf(el, true, 'should be gated by DEBUG', URL); + + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + const slot = document.createElement('slot'); + shadow.append(slot); + validateRequiredSlot(host, slot, 'label', URL); + + const headingHost = document.createElement('div'); + const headingShadow = headingHost.attachShadow({ mode: 'open' }); + const headingSlot = document.createElement('slot'); + headingSlot.name = 'heading'; + headingShadow.append(headingSlot); + const p = document.createElement('p'); + p.slot = 'heading'; + headingHost.append(p); + validateAllowedChildren( + headingHost, + headingSlot, + ['h2'], + 'heading', + URL + ); + + expect(warnCalls.length).toBe(0); + }, + { debug: false } + ) + ); + + // The helpers rely on `window.__swc?.DEBUG` optional chaining, so a + // production/no-debug environment where `__swc` was never created must not + // throw a ReferenceError. + await step('helpers do not throw when window.__swc is undefined', () => { + const original = window.__swc; + // @ts-expect-error - simulate an environment where __swc was never created + window.__swc = undefined; + try { + const el = document.createElement('div'); + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + const slot = document.createElement('slot'); + slot.name = 'heading'; + shadow.append(slot); + const p = document.createElement('p'); + p.slot = 'heading'; + host.append(p); + expect(() => { + validateEnum(el, { + prop: 'variant', + value: 'bogus', + valid: ['positive'], + url: URL, + }); + warnIf(el, true, 'no throw', URL); + validateRequiredSlot(host, null, 'label', URL); + validateAllowedChildren(host, slot, ['h2'], 'heading', URL); + }).not.toThrow(); + } finally { + window.__swc = original; + } + }); + + // `isDebug()` is the call-site guard for expensive checks. It mirrors the + // same gate `emitWarning` uses, so it must track `window.__swc.DEBUG` and + // stay safe when `__swc` was never created. + await step('isDebug is true when DEBUG is on', () => + withWarningSpy(() => { + expect(isDebug()).toBe(true); + }) + ); + + await step('isDebug is false when DEBUG is off', () => + withWarningSpy( + () => { + expect(isDebug()).toBe(false); + }, + { debug: false } + ) + ); + + await step( + 'isDebug is false (no throw) when window.__swc is undefined', + () => { + const original = window.__swc; + // @ts-expect-error - simulate an environment where __swc was never created + window.__swc = undefined; + try { + expect(() => isDebug()).not.toThrow(); + expect(isDebug()).toBe(false); + } finally { + window.__swc = original; + } + } + ); + + await step('validateAllowedChildren warns once per disallowed child', () => + withWarningSpy((warnCalls) => { + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + const slot = document.createElement('slot'); + slot.name = 'heading'; + shadow.append(slot); + const p = document.createElement('p'); + p.slot = 'heading'; + host.append(p); + const span = document.createElement('span'); + span.slot = 'heading'; + host.append(span); + validateAllowedChildren( + host, + slot, + ['h2', 'h3', 'h4', 'h5', 'h6'], + 'heading', + URL + ); + expect(warnCalls.length).toBe(2); + }) + ); + + await step('validateRequiredSlot warns when the slot is null', () => + withWarningSpy((warnCalls) => { + const host = document.createElement('div'); + validateRequiredSlot(host, null, 'label', URL); + expect(warnCalls.length).toBeGreaterThan(0); + expect(String(warnCalls[0]?.[1] || '')).toContain('label'); + }) + ); + + await step('validateEnum forwards issues and merges caller options', () => + withWarningSpy((warnCalls) => { + const el = document.createElement('div'); + validateEnum(el, { + prop: 'variant', + value: 'bogus', + valid: ['positive', 'negative'], + url: URL, + options: { type: 'accessibility', level: 'high' }, + }); + expect(warnCalls.length).toBe(1); + const options = warnCalls[0]?.[3] as { + issues?: string[]; + type?: string; + level?: string; + }; + expect(options?.issues).toContain('variant="bogus"'); + expect(options?.type).toBe('accessibility'); + expect(options?.level).toBe('high'); + }) + ); + }, +}; diff --git a/2nd-gen/packages/swc/.storybook/guides/dev-mode/dev-mode-warnings.mdx b/2nd-gen/packages/swc/.storybook/guides/dev-mode/dev-mode-warnings.mdx new file mode 100644 index 00000000000..a2740f00c8a --- /dev/null +++ b/2nd-gen/packages/swc/.storybook/guides/dev-mode/dev-mode-warnings.mdx @@ -0,0 +1,68 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Dev mode warnings + +Spectrum Web Components Gen2 components run extra validation checks in development builds of your application. When a component receives an invalid value, is missing a required property, has a mutually exclusive combination of properties set, or is missing required slot content, you'll see a warning printed to the browser console describing the problem and linking to the component's documentation. + +These warnings exist to catch configuration mistakes early, in your own development environment, rather than surfacing as a subtle visual or accessibility bug later. + +## What triggers a warning + +- **Invalid enum/union values**: e.g. setting `variant` to a value that isn't one of the component's documented options. +- **Missing required properties**: e.g. omitting an accessible label where one is required. +- **Missing conditionally required properties**: e.g. omitting an accessible label when no label content is slotted either. +- **Mutually exclusive or no-effect property combinations**: e.g. setting two properties together where one has no effect given the other's value. +- **Missing required slot content**: a slot that must have something assigned to it is empty. +- **Disallowed slotted children**: a slot received an element type it doesn't support (e.g. a `

` in a slot that only accepts headings). + +## Only in development + +These checks run behind a `window.__swc.DEBUG` flag, enabled whenever `process.env.NODE_ENV` is not `'production'` (so `'development'`, `'staging'`, `'test'`, or unset all enable it, and `DEBUG` can be toggled in any of them), plus an internal guard in each validation helper that skips its own logic when `process.env.NODE_ENV === 'production'`. Both are literal checks this package ships as-is (it does not pre-strip them), so whether your production build actually removes them depends on **your own build configuration** replacing `process.env.NODE_ENV` with `'production'` and minifying. + +Even in a well-configured production build, this removes the internal validation logic (the actual checks, message building, and console output), not the call sites themselves: every component still invokes a cheap property check that immediately no-ops, on every update. + +**Recommended: confirm your production build actually strips what it can.** Most setups handle it automatically, but "automatically" depends on your bundler processing `node_modules`, which not every default config does: + +- **Webpack / Next.js**: `mode: 'production'` sets `process.env.NODE_ENV` via `DefinePlugin` and minifies with Terser, both of which apply to `node_modules` by default. No extra configuration needed in most setups. +- **Vite (app builds)**: `vite build` (default mode `production`) replaces `process.env.NODE_ENV` and removes the resulting dead branch during minification, including inside dependencies. No extra configuration needed in most setups. +- **esbuild directly**: pass `--define:process.env.NODE_ENV='"production"'`. esbuild removes statically-false branches as part of normal bundling, so this alone is enough even without `--minify`. +- **Rollup**: use [`@rollup/plugin-replace`](https://github.com/rollup/plugins/tree/master/packages/replace) to replace `process.env.NODE_ENV`, paired with a minifier plugin (e.g. `@rollup/plugin-terser`) so the resulting dead branch is actually removed, not just replaced. +- **No bundler at all** (a bare `