Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions V5_PLANNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
2 changes: 2 additions & 0 deletions packages/cli/src/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -124,6 +125,7 @@ const migrationRules: MigrationRule[] = [
globalStyleInjectRule,
lightDomPatchesRule,
extrasToCompatRule,
suppressWarningsToCompatRule,
externalRuntimeRule,
hashFileNamesRule,
rolldownConfigRule,
Expand Down
120 changes: 120 additions & 0 deletions packages/cli/src/migrations/rules/suppress-warnings-to-compat.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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(', ')}` };
}
31 changes: 14 additions & 17 deletions packages/core/src/compiler/config/_test_/validate-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/compiler/config/validate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -266,7 +266,7 @@ describe('parse events', () => {
clickEvent: EventEmitter<void>;
}
`,
{ suppressReservedEventNameWarnings: true },
{ compat: { suppressEventNameWarnings: true } },
);

expect(t.event).toEqual({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const validatePublicName = (
memberType: string,
node: ts.Node,
): void => {
if (config.suppressReservedPublicNameWarnings) {
if (config.compat?.suppressPublicNameWarnings) {
return;
}

Expand Down
Loading
Loading