From 955b0bef22340ba47a2219f9e376f1eff65f8ad6 Mon Sep 17 00:00:00 2001 From: John Jenkins Date: Thu, 16 Jul 2026 22:44:10 +0100 Subject: [PATCH] chore(config): v5 move `suppressReserved*` names into `compat` --- V5_PLANNING.md | 6 + .../suppress-warnings-to-compat.spec.ts | 103 +++++++++++++++ packages/cli/src/migrations/index.ts | 2 + .../rules/suppress-warnings-to-compat.ts | 120 ++++++++++++++++++ .../config/_test_/validate-config.spec.ts | 31 ++--- .../src/compiler/config/validate-config.ts | 7 +- .../transformers/_test_/parse-events.spec.ts | 4 +- .../decorators-to-static/event-decorator.ts | 2 +- .../transformers/reserved-public-members.ts | 2 +- .../declarations/stencil-public-compiler.ts | 32 ++--- packages/core/src/testing/mocks.ts | 8 +- 11 files changed, 273 insertions(+), 44 deletions(-) create mode 100644 packages/cli/src/migrations/_test_/suppress-warnings-to-compat.spec.ts create mode 100644 packages/cli/src/migrations/rules/suppress-warnings-to-compat.ts diff --git a/V5_PLANNING.md b/V5_PLANNING.md index 0e8b1602230..d71605e600a 100644 --- a/V5_PLANNING.md +++ b/V5_PLANNING.md @@ -117,6 +117,7 @@ Modernize Stencil after 10 years: shed tech debt, embrace modern tooling, simpli - **`standalone` output target: `externalRuntime` now defaults to `false`**. The runtime is bundled as a shared local chunk rather than kept as an external `@stencil/core/runtime/client` import. Set `externalRuntime: true` if you need multiple Stencil component libraries on the same page to share a single runtime instance (e.g., for `setNonce`/`setTagTransformer` to propagate across libraries). - **`esmLoaderPath` config option renamed to `loaderPath`** in `loader-bundle` output target. - **`hashFileNames` and `hashedFileNameLength` moved from top-level config to `loader-bundle` and `www` output targets.** Only these two targets serve bundles directly in the browser. Run `stencil migrate` to remove them from the top-level config, then add to your output targets if non-default values are needed. +- **`suppressReservedPublicNameWarnings` / `suppressReservedEventNameWarnings` moved into `compat` and renamed** to `compat.suppressPublicNameWarnings` / `compat.suppressEventNameWarnings`. Run `stencil migrate` to update your config automatically. --- @@ -563,6 +564,11 @@ pnpm run dev # Watch mode --- +## Known Test Coverage Gaps +- `validatePublicName` (`compiler/transformers/reserved-public-members.ts`) has no dedicated unit tests - only its event-name counterpart (`compat.suppressEventNameWarnings`, tested in `parse-events.spec.ts`) is covered. + +--- + ## Build Commands ```bash diff --git a/packages/cli/src/migrations/_test_/suppress-warnings-to-compat.spec.ts b/packages/cli/src/migrations/_test_/suppress-warnings-to-compat.spec.ts new file mode 100644 index 00000000000..9029d71d17d --- /dev/null +++ b/packages/cli/src/migrations/_test_/suppress-warnings-to-compat.spec.ts @@ -0,0 +1,103 @@ +import ts from 'typescript'; +import { describe, it, expect } from 'vitest'; + +import { suppressWarningsToCompatRule } from '../rules/suppress-warnings-to-compat'; + +const parse = (source: string) => + ts.createSourceFile('test.ts', source, ts.ScriptTarget.Latest, true); + +describe('suppress-warnings-to-compat migration', () => { + describe('detect', () => { + it('detects both flags', () => { + const source = `export const config: Config = { + suppressReservedPublicNameWarnings: true, + suppressReservedEventNameWarnings: false, +};`; + const matches = suppressWarningsToCompatRule.detect(parse(source)); + expect(matches).toHaveLength(2); + expect(matches[0].message).toContain( + "'suppressReservedPublicNameWarnings' has moved to 'compat.suppressPublicNameWarnings'", + ); + expect(matches[1].message).toContain( + "'suppressReservedEventNameWarnings' has moved to 'compat.suppressEventNameWarnings'", + ); + }); + + it('returns no matches when neither flag is present', () => { + const source = `export const config: Config = { + namespace: 'MyApp', +};`; + expect(suppressWarningsToCompatRule.detect(parse(source))).toHaveLength(0); + }); + }); + + describe('transform', () => { + it('moves a single flag into a new compat object', () => { + const source = `export const config: Config = { + namespace: 'MyApp', + suppressReservedPublicNameWarnings: true, +};`; + const sourceFile = parse(source); + const matches = suppressWarningsToCompatRule.detect(sourceFile); + const result = suppressWarningsToCompatRule.transform(sourceFile, matches); + + expect(result).not.toContain('suppressReservedPublicNameWarnings'); + expect(result).toContain("namespace: 'MyApp'"); + expect(result).toContain('compat: { suppressPublicNameWarnings: true }'); + }); + + it('moves both flags into a new compat object', () => { + const source = `export const config: Config = { + suppressReservedPublicNameWarnings: true, + suppressReservedEventNameWarnings: false, +};`; + const sourceFile = parse(source); + const matches = suppressWarningsToCompatRule.detect(sourceFile); + const result = suppressWarningsToCompatRule.transform(sourceFile, matches); + + expect(result).not.toContain('suppressReservedPublicNameWarnings'); + expect(result).not.toContain('suppressReservedEventNameWarnings'); + expect(result).toContain( + 'compat: { suppressPublicNameWarnings: true, suppressEventNameWarnings: false }', + ); + expect(ts.createSourceFile('out.ts', result, ts.ScriptTarget.Latest, true)).toBeTruthy(); + }); + + it('merges into an existing empty compat object', () => { + const source = `export const config: Config = { + compat: {}, + suppressReservedPublicNameWarnings: true, +};`; + const sourceFile = parse(source); + const matches = suppressWarningsToCompatRule.detect(sourceFile); + const result = suppressWarningsToCompatRule.transform(sourceFile, matches); + + expect(result).not.toContain('suppressReservedPublicNameWarnings'); + expect(result).toContain('compat: { suppressPublicNameWarnings: true }'); + }); + + it('merges into an existing non-empty compat object', () => { + const source = `export const config: Config = { + compat: { lightDomPatches: false }, + suppressReservedEventNameWarnings: true, +};`; + const sourceFile = parse(source); + const matches = suppressWarningsToCompatRule.detect(sourceFile); + const result = suppressWarningsToCompatRule.transform(sourceFile, matches); + + expect(result).not.toContain('suppressReservedEventNameWarnings'); + expect(result).toContain('lightDomPatches: false'); + expect(result).toContain('suppressEventNameWarnings: true'); + expect(result).toMatch( + /compat:\s*{\s*lightDomPatches: false, suppressEventNameWarnings: true\s*}/, + ); + }); + + it('returns unchanged source when no matches', () => { + const source = `export const config: Config = { namespace: 'App' };`; + const sourceFile = parse(source); + const result = suppressWarningsToCompatRule.transform(sourceFile, []); + expect(result).toBe(source); + }); + }); +}); diff --git a/packages/cli/src/migrations/index.ts b/packages/cli/src/migrations/index.ts index 474469f7573..4b211094652 100644 --- a/packages/cli/src/migrations/index.ts +++ b/packages/cli/src/migrations/index.ts @@ -12,6 +12,7 @@ import { lightDomPatchesRule } from './rules/light-dom-patches'; import { outputTargetRenamesRule } from './rules/output-target-renames'; import { rolldownConfigRule } from './rules/rolldown-config'; import { serviceWorkerDefaultRule } from './rules/service-worker-default'; +import { suppressWarningsToCompatRule } from './rules/suppress-warnings-to-compat'; /** * Build a map of local import names to their original names from @stencil/core. @@ -124,6 +125,7 @@ const migrationRules: MigrationRule[] = [ globalStyleInjectRule, lightDomPatchesRule, extrasToCompatRule, + suppressWarningsToCompatRule, externalRuntimeRule, hashFileNamesRule, rolldownConfigRule, diff --git a/packages/cli/src/migrations/rules/suppress-warnings-to-compat.ts b/packages/cli/src/migrations/rules/suppress-warnings-to-compat.ts new file mode 100644 index 00000000000..cafec800b96 --- /dev/null +++ b/packages/cli/src/migrations/rules/suppress-warnings-to-compat.ts @@ -0,0 +1,120 @@ +import ts from 'typescript'; + +import type { MigrationMatch, MigrationRule } from '../index'; + +/** + * Migration rule: move `suppressReservedPublicNameWarnings` / `suppressReservedEventNameWarnings` + * off the top level of the config and into `compat`, dropping `Reserved` from their names. + * + * In v5: + * suppressReservedPublicNameWarnings → compat.suppressPublicNameWarnings + * suppressReservedEventNameWarnings → compat.suppressEventNameWarnings + */ +const KEY_RENAME: Record = { + suppressReservedPublicNameWarnings: 'suppressPublicNameWarnings', + suppressReservedEventNameWarnings: 'suppressEventNameWarnings', +}; + +export const suppressWarningsToCompatRule: MigrationRule = { + id: 'suppress-warnings-to-compat', + name: 'Suppress Warnings → Compat', + description: + "Move 'suppressReservedPublicNameWarnings' / 'suppressReservedEventNameWarnings' into 'compat'", + + fromVersion: '4.x', + toVersion: '5.x', + + detect(sourceFile: ts.SourceFile): MigrationMatch[] { + const matches: MigrationMatch[] = []; + + const visit = (node: ts.Node) => { + if ( + ts.isPropertyAssignment(node) && + ts.isIdentifier(node.name) && + node.name.text in KEY_RENAME + ) { + const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart()); + matches.push({ + node, + message: `'${node.name.text}' has moved to 'compat.${KEY_RENAME[node.name.text]}'`, + line: line + 1, + column: character + 1, + }); + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return matches; + }, + + transform(sourceFile: ts.SourceFile, matches: MigrationMatch[]): string { + if (matches.length === 0) return sourceFile.getFullText(); + + const text = sourceFile.getFullText(); + const edits: { start: number; end: number; replacement: string }[] = []; + const newProps: string[] = []; + + // Remove each old top-level property (including its comma), and record its renamed replacement. + for (const match of matches) { + const node = match.node as ts.PropertyAssignment; + const key = (node.name as ts.Identifier).text; + newProps.push(`${KEY_RENAME[key]}: ${node.initializer.getText(sourceFile)}`); + + let start = node.getStart(); + let end = node.getEnd(); + const afterNode = text.slice(end); + const trailingComma = afterNode.match(/^(\s*,)/); + if (trailingComma) { + end += trailingComma[1].length; + } else { + const beforeNode = text.slice(0, start); + const leadingComma = beforeNode.match(/,\s*$/); + if (leadingComma) { + start -= leadingComma[0].length; + } + } + edits.push({ start, end, replacement: '' }); + } + + // All matched properties are assumed to be siblings on the same config object literal. + const parentObject = (matches[0].node as ts.PropertyAssignment) + .parent as ts.ObjectLiteralExpression; + + const existingCompat = parentObject.properties.find( + (p): p is ts.PropertyAssignment => + ts.isPropertyAssignment(p) && + ts.isIdentifier(p.name) && + p.name.text === 'compat' && + ts.isObjectLiteralExpression(p.initializer), + ); + + if (existingCompat) { + edits.push( + insertIntoObjectLiteral(existingCompat.initializer as ts.ObjectLiteralExpression, newProps), + ); + } else { + edits.push(insertIntoObjectLiteral(parentObject, [`compat: { ${newProps.join(', ')} }`])); + } + + // Apply right-to-left so earlier offsets stay valid. + let result = text; + for (const edit of edits.sort((a, b) => b.start - a.start)) { + result = result.slice(0, edit.start) + edit.replacement + result.slice(edit.end); + } + + return result; + }, +}; + +function insertIntoObjectLiteral( + obj: ts.ObjectLiteralExpression, + newProps: string[], +): { start: number; end: number; replacement: string } { + if (obj.properties.length === 0) { + const pos = obj.getStart() + 1; + return { start: pos, end: pos, replacement: ` ${newProps.join(', ')} ` }; + } + const last = obj.properties[obj.properties.length - 1]; + return { start: last.getEnd(), end: last.getEnd(), replacement: `, ${newProps.join(', ')}` }; +} diff --git a/packages/core/src/compiler/config/_test_/validate-config.spec.ts b/packages/core/src/compiler/config/_test_/validate-config.spec.ts index 0e7af0ebbcd..f1ba089ea66 100644 --- a/packages/core/src/compiler/config/_test_/validate-config.spec.ts +++ b/packages/core/src/compiler/config/_test_/validate-config.spec.ts @@ -96,32 +96,29 @@ describe('validation', () => { }); }); - describe('suppressReservedPublicNameWarnings', () => { - it.each([true, false])( - 'sets suppressReservedPublicNameWarnings to %p when provided', - (bool) => { - userConfig.suppressReservedPublicNameWarnings = bool; - const { config } = validateConfig(userConfig, bootstrapConfig); - expect(config.suppressReservedPublicNameWarnings).toBe(bool); - }, - ); + describe('compat.suppressPublicNameWarnings', () => { + it.each([true, false])('sets compat.suppressPublicNameWarnings to %p when provided', (bool) => { + userConfig.compat = { suppressPublicNameWarnings: bool }; + const { config } = validateConfig(userConfig, bootstrapConfig); + expect(config.compat.suppressPublicNameWarnings).toBe(bool); + }); - it('defaults suppressReservedPublicNameWarnings to false', () => { + it('defaults compat.suppressPublicNameWarnings to false', () => { const { config } = validateConfig(userConfig, bootstrapConfig); - expect(config.suppressReservedPublicNameWarnings).toBe(false); + expect(config.compat.suppressPublicNameWarnings).toBe(false); }); }); - describe('suppressReservedEventNameWarnings', () => { - it.each([true, false])('sets suppressReservedEventNameWarnings to %p when provided', (bool) => { - userConfig.suppressReservedEventNameWarnings = bool; + describe('compat.suppressEventNameWarnings', () => { + it.each([true, false])('sets compat.suppressEventNameWarnings to %p when provided', (bool) => { + userConfig.compat = { suppressEventNameWarnings: bool }; const { config } = validateConfig(userConfig, bootstrapConfig); - expect(config.suppressReservedEventNameWarnings).toBe(bool); + expect(config.compat.suppressEventNameWarnings).toBe(bool); }); - it('defaults suppressReservedEventNameWarnings to false', () => { + it('defaults compat.suppressEventNameWarnings to false', () => { const { config } = validateConfig(userConfig, bootstrapConfig); - expect(config.suppressReservedEventNameWarnings).toBe(false); + expect(config.compat.suppressEventNameWarnings).toBe(false); }); }); diff --git a/packages/core/src/compiler/config/validate-config.ts b/packages/core/src/compiler/config/validate-config.ts index 40a72c3345d..a15c574acd5 100644 --- a/packages/core/src/compiler/config/validate-config.ts +++ b/packages/core/src/compiler/config/validate-config.ts @@ -187,6 +187,11 @@ export const validateConfig = ( validatedConfig.compat.lightDomPatches = true; } + validatedConfig.compat.suppressPublicNameWarnings = + !!validatedConfig.compat.suppressPublicNameWarnings; + validatedConfig.compat.suppressEventNameWarnings = + !!validatedConfig.compat.suppressEventNameWarnings; + // Set boolean config values with defaults // CLI is responsible for merging flags into config before validation setBooleanConfig(validatedConfig, 'watch', false); @@ -196,8 +201,6 @@ export const validateConfig = ( setBooleanConfig(validatedConfig, 'autoprefixCss', false); setBooleanConfig(validatedConfig, 'validateTypes', !validatedConfig._isTesting); setBooleanConfig(validatedConfig, 'allowInlineScripts', true); - setBooleanConfig(validatedConfig, 'suppressReservedPublicNameWarnings', false); - setBooleanConfig(validatedConfig, 'suppressReservedEventNameWarnings', false); if (!isString(validatedConfig.taskQueue)) { validatedConfig.taskQueue = 'async'; diff --git a/packages/core/src/compiler/transformers/_test_/parse-events.spec.ts b/packages/core/src/compiler/transformers/_test_/parse-events.spec.ts index 9451938faf0..819b2f19117 100644 --- a/packages/core/src/compiler/transformers/_test_/parse-events.spec.ts +++ b/packages/core/src/compiler/transformers/_test_/parse-events.spec.ts @@ -242,7 +242,7 @@ describe('parse events', () => { }); }); - describe('suppressReservedEventNameWarnings', () => { + describe('compat.suppressEventNameWarnings', () => { it('should warn when using native DOM event name and flag is unset (default)', () => { expect(() => { transpileModule( @@ -266,7 +266,7 @@ describe('parse events', () => { clickEvent: EventEmitter; } `, - { suppressReservedEventNameWarnings: true }, + { compat: { suppressEventNameWarnings: true } }, ); expect(t.event).toEqual({ diff --git a/packages/core/src/compiler/transformers/decorators-to-static/event-decorator.ts b/packages/core/src/compiler/transformers/decorators-to-static/event-decorator.ts index 2ce16fac571..7b53252e18d 100644 --- a/packages/core/src/compiler/transformers/decorators-to-static/event-decorator.ts +++ b/packages/core/src/compiler/transformers/decorators-to-static/event-decorator.ts @@ -180,7 +180,7 @@ const validateEventName = ( return; } - if (!config.suppressReservedEventNameWarnings && DOM_EVENT_NAMES.has(eventName.toLowerCase())) { + if (!config.compat?.suppressEventNameWarnings && DOM_EVENT_NAMES.has(eventName.toLowerCase())) { const diagnostic = buildWarn(diagnostics); diagnostic.messageText = `The event name conflicts with the "${eventName}" native DOM event name.`; augmentDiagnosticWithNode(diagnostic, node); diff --git a/packages/core/src/compiler/transformers/reserved-public-members.ts b/packages/core/src/compiler/transformers/reserved-public-members.ts index a4196a4c7df..44648db4985 100644 --- a/packages/core/src/compiler/transformers/reserved-public-members.ts +++ b/packages/core/src/compiler/transformers/reserved-public-members.ts @@ -23,7 +23,7 @@ export const validatePublicName = ( memberType: string, node: ts.Node, ): void => { - if (config.suppressReservedPublicNameWarnings) { + if (config.compat?.suppressPublicNameWarnings) { return; } diff --git a/packages/core/src/declarations/stencil-public-compiler.ts b/packages/core/src/declarations/stencil-public-compiler.ts index 56d12669f46..af06c610316 100644 --- a/packages/core/src/declarations/stencil-public-compiler.ts +++ b/packages/core/src/declarations/stencil-public-compiler.ts @@ -147,16 +147,6 @@ export interface StencilConfig { * This behavior defaults to `true`, but may be opted-out of by setting this flag to `false`. */ transformAliasedImportPaths?: boolean; - /** - * When `true`, Stencil will suppress diagnostics which warn about public members using reserved names - * (for example, decorating a method named `focus` with `@Method()`). Defaults to `false`. - */ - suppressReservedPublicNameWarnings?: boolean; - - /** - * When `true`, Stencil will suppress diagnostics which warn about event names conflicting with native DOM event names. Defaults to `false`. - */ - suppressReservedEventNameWarnings?: boolean; /** * Passes custom configuration down to the "@rolldown/plugin-node-resolve" that Stencil uses under the hood. @@ -188,7 +178,8 @@ export interface StencilConfig { logger?: Logger; /** - * Compatibility and workaround flags for framework integration and bundler edge cases. + * Compatibility and workaround flags for framework/bundler edge cases + * and rarely-needed diagnostic suppressions. */ compat?: ConfigCompat; @@ -338,8 +329,6 @@ export interface StencilConfig { * @default [] */ collections?: string[]; - - stencilCoreResolvedId?: string; } /** @@ -369,8 +358,10 @@ export type LightDomPatches = { }; /** - * Compatibility and workaround flags for framework integration and bundler edge cases. - * These are opt-in runtime behaviors that aren't needed by every project. + * Compatibility and workaround flags, primarily for shielding non-shadow DOM components + * from consuming frameworks that mutate internals they don't know about, plus other + * framework/bundler integration edge cases and rarely-needed diagnostic suppressions. + * These are opt-in behaviors that aren't needed by every project. */ export interface ConfigCompat { /** @@ -405,6 +396,17 @@ export interface ConfigCompat { * See {@link LightDomPatches} for granular control. Defaults to `true`. */ lightDomPatches?: boolean | LightDomPatches; + + /** + * When `true`, Stencil will suppress diagnostics which warn about public members using reserved names + * (for example, decorating a method named `focus` with `@Method()`). Defaults to `false`. + */ + suppressPublicNameWarnings?: boolean; + + /** + * When `true`, Stencil will suppress diagnostics which warn about event names conflicting with native DOM event names. Defaults to `false`. + */ + suppressEventNameWarnings?: boolean; } export interface Config extends StencilConfig { diff --git a/packages/core/src/testing/mocks.ts b/packages/core/src/testing/mocks.ts index f2d742bbc68..b40a3d977ec 100644 --- a/packages/core/src/testing/mocks.ts +++ b/packages/core/src/testing/mocks.ts @@ -134,7 +134,7 @@ export function mockValidatedConfig(overrides: Partial = {}): cacheDir: '.stencil', devMode: true, devServer: {}, - compat: {}, + compat: { suppressPublicNameWarnings: false, suppressEventNameWarnings: false }, fsNamespace: 'testing', hydratedFlag: null, logLevel: 'info', @@ -148,8 +148,6 @@ export function mockValidatedConfig(overrides: Partial = {}): sourceMap: true, srcDir: '/src', srcIndexHtml: 'src/index.html', - suppressReservedPublicNameWarnings: false, - suppressReservedEventNameWarnings: false, sys: createTestingSystem(), transformAliasedImportPaths: true, rolldownConfig: {}, @@ -179,7 +177,7 @@ export function mockConfig(overrides: Partial = {}): d.Unva bundles: null, devMode: true, enableCache: false, - compat: {}, + compat: { suppressPublicNameWarnings: false, suppressEventNameWarnings: false }, globalScript: null, logger: new TestingLogger(), maxConcurrentWorkers: 0, @@ -194,8 +192,6 @@ export function mockConfig(overrides: Partial = {}): d.Unva }, rootDir, sourceMap: true, - suppressReservedPublicNameWarnings: false, - suppressReservedEventNameWarnings: false, sys, validateTypes: false, ...overrides,