diff --git a/.github/workflows/actions/install-playwright/action.yml b/.github/workflows/actions/install-playwright/action.yml index 5213f791f87..20186d5bf51 100644 --- a/.github/workflows/actions/install-playwright/action.yml +++ b/.github/workflows/actions/install-playwright/action.yml @@ -1,11 +1,16 @@ name: 'Install Playwright Browsers' description: 'Installs Playwright browsers with caching' +inputs: + working-directory: + description: 'Directory containing the Playwright installation to use' + required: false + default: 'test/ssr' runs: using: composite steps: - name: Get Playwright version id: playwright-version - working-directory: test/ssr + working-directory: ${{ inputs.working-directory }} run: echo "version=$(npx playwright --version)" >> $GITHUB_OUTPUT shell: bash @@ -21,12 +26,12 @@ runs: - name: Install Playwright Browsers if: steps.playwright-cache.outputs.cache-hit != 'true' - working-directory: test/ssr + working-directory: ${{ inputs.working-directory }} run: npx playwright install --with-deps shell: bash - name: Install Playwright system deps (cache hit) if: steps.playwright-cache.outputs.cache-hit == 'true' && runner.os == 'Linux' - working-directory: test/ssr + working-directory: ${{ inputs.working-directory }} run: npx playwright install-deps shell: bash diff --git a/.github/workflows/test-component-starter.yml b/.github/workflows/test-component-starter.yml index 549bd9a3d7f..5f591bceac6 100644 --- a/.github/workflows/test-component-starter.yml +++ b/.github/workflows/test-component-starter.yml @@ -38,6 +38,11 @@ jobs: working-directory: ./packages/core shell: bash + - name: Pack @stencil/cli + run: pnpm pack --pack-destination ../../ + working-directory: ./packages/cli + shell: bash + - name: Initialize Component Starter run: npm init stencil component tmp-component-starter shell: bash @@ -48,12 +53,19 @@ jobs: shell: bash - name: Install Stencil from Pack - run: npm install ../stencil-core-*.tgz --legacy-peer-deps + run: npm install ../stencil-cli-*.tgz ../stencil-core-*.tgz --legacy-peer-deps working-directory: ./tmp-component-starter shell: bash - name: Install Playwright Browsers uses: ./.github/workflows/actions/install-playwright + with: + working-directory: ./tmp-component-starter + + - name: Run Stencil Migrations + run: npx stencil migrate + working-directory: ./tmp-component-starter + shell: bash - name: Build Starter Project run: npm run build diff --git a/V5_PLANNING.md b/V5_PLANNING.md index 233a47d33f3..2fc5ee8e633 100644 --- a/V5_PLANNING.md +++ b/V5_PLANNING.md @@ -74,6 +74,16 @@ See [CLI/Core Architecture](#clicore-architecture) section for details. - `dist` and `dist-hydrate-script` output targets no longer generate CJS bundles by default. Add `cjs: true` to your output target config to restore CJS output. - `dist-hydrate-script` no longer generates a `package.json` file. Use `exports` in your library's main `package.json` to expose the hydrate script. - **ES5 build output removed.** The `buildEs5` config option, `--es5` CLI flag, and all ES5-related output (esm-es5 directory, SystemJS bundles, ES5 polyfills) have been removed. Stencil now targets ES2017+ only. IE11 and Edge 18 and below are no longer supported. +- **@Component decorator `shadow`, `scoped`, and `formAssociated` properties removed.** Use the new unified `encapsulation` property instead: + - `shadow: true` → `encapsulation: { type: 'shadow' }` + - `shadow: { delegatesFocus: true }` → `encapsulation: { type: 'shadow', delegatesFocus: true }` + - `scoped: true` → `encapsulation: { type: 'scoped' }` + - Default (no encapsulation) → `encapsulation: { type: 'none' }` (optional, 'none' is default) + - **New feature:** `encapsulation: { type: 'shadow', mode: 'closed' }` enables closed shadow DOM + - **New feature:** Per-component slot patches via `encapsulation: { type: 'scoped', patches: ['children', 'clone', 'insert'] }` + - `formAssociated: true` → Use `@AttachInternals()` decorator instead (auto-sets `formAssociated: true`) + - To use `@AttachInternals` without form association: `@AttachInternals({ formAssociated: false })` + - Run `stencil migrate --dry-run` to preview automatic migration, or `stencil migrate` to apply changes ### 8. 🏷️ Release Management: Changesets **Status:** 📋 Planned diff --git a/knip.json b/knip.json index e5faa9504d1..4701647e4b2 100644 --- a/knip.json +++ b/knip.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", "ignoreWorkspaces": ["test/**"], - "ignoreBinaries": ["playwright"], + "ignoreBinaries": ["playwright", "stencil"], "rules": { "catalog": "off" }, diff --git a/packages/cli/src/_test_/task-migrate.spec.ts b/packages/cli/src/_test_/task-migrate.spec.ts new file mode 100644 index 00000000000..33ce2bb125c --- /dev/null +++ b/packages/cli/src/_test_/task-migrate.spec.ts @@ -0,0 +1,363 @@ +import { mockCompilerSystem, mockValidatedConfig } from '@stencil/core/testing'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import type * as d from '@stencil/core/compiler'; + +import { createConfigFlags } from '../config-flags'; +import { detectMigrations, taskMigrate } from '../task-migrate'; + +// Mock migration rules module +const mockRules = vi.hoisted(() => [ + { + id: 'test-rule', + name: 'Test Migration Rule', + description: 'A test migration rule', + fromVersion: '4.x', + toVersion: '5.x', + detect: vi.fn(), + transform: vi.fn(), + }, +]); + +vi.mock('../migrations', () => ({ + getRulesForVersionUpgrade: vi.fn((from: string, to: string) => { + if (from === '4' && to === '5') { + return mockRules; + } + return []; + }), +})); + +// Mock TypeScript's getParsedCommandLineOfConfigFile +vi.mock('typescript', async () => { + const actual = await vi.importActual('typescript'); + return { + ...actual, + default: { + ...actual, + getParsedCommandLineOfConfigFile: vi.fn(() => ({ + fileNames: ['/test/src/components/my-component.tsx'], + errors: [], + })), + }, + }; +}); + +const mockCoreCompiler = { + version: '5.0.0', +} as any; + +interface SetupOptions { + dryRun?: boolean; + fileContent?: string | null; + detectMatches?: Array<{ node: any; message: string; line: number; column: number }>; +} + +const setup = async (options: SetupOptions = {}) => { + const { dryRun = false, fileContent = null, detectMatches = [] } = options; + + const sys = mockCompilerSystem(); + const flags = createConfigFlags({ task: 'migrate', dryRun }); + const config: d.ValidatedConfig = mockValidatedConfig({ + configPath: '/test/stencil.config.ts', + rootDir: '/test', + sys, + }); + + // Mock sys methods + config.sys.exit = vi.fn(); + vi.spyOn(config.sys, 'readFile').mockImplementation(async (path: string) => { + if (path.endsWith('tsconfig.json')) { + return '{}'; + } + return fileContent; + }); + vi.spyOn(config.sys, 'writeFile').mockResolvedValue({} as any); + + // Mock logger methods + const infoSpy = vi.spyOn(config.logger, 'info'); + + // Configure mock rule behavior + mockRules[0].detect.mockReturnValue(detectMatches); + mockRules[0].transform.mockImplementation((_sourceFile: any, _matches: any) => { + return 'transformed content'; + }); + + return { config, flags, infoSpy, sys }; +}; + +describe('task-migrate', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('when no migrations are needed', () => { + it('should report that code is up to date', async () => { + const { config, flags, infoSpy } = await setup({ + fileContent: 'const x = 1;', + detectMatches: [], + }); + + await taskMigrate(mockCoreCompiler, config, flags); + + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('No migrations needed')); + }); + + it('should not write any files', async () => { + const { config, flags, sys } = await setup({ + fileContent: 'const x = 1;', + detectMatches: [], + }); + + await taskMigrate(mockCoreCompiler, config, flags); + + // writeFile should not be called for component files + const writeFileCalls = (sys.writeFile as any).mock.calls.filter((call: any[]) => + call[0].endsWith('.tsx'), + ); + expect(writeFileCalls).toHaveLength(0); + }); + }); + + describe('when migrations are found', () => { + const migrationMatch = { + node: {}, + message: 'Found deprecated API', + line: 10, + column: 5, + }; + + it('should show detected migrations', async () => { + const { config, flags, infoSpy } = await setup({ + fileContent: '@Component({ shadow: true }) class MyComponent {}', + detectMatches: [migrationMatch], + }); + + await taskMigrate(mockCoreCompiler, config, flags); + + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('Line 10')); + }); + + it('should apply migrations and write files', async () => { + const { config, flags, sys } = await setup({ + fileContent: '@Component({ shadow: true }) class MyComponent {}', + detectMatches: [migrationMatch], + }); + + await taskMigrate(mockCoreCompiler, config, flags); + + expect(sys.writeFile).toHaveBeenCalledWith( + '/test/src/components/my-component.tsx', + 'transformed content', + ); + }); + + it('should show success message', async () => { + const { config, flags, infoSpy } = await setup({ + fileContent: '@Component({ shadow: true }) class MyComponent {}', + detectMatches: [migrationMatch], + }); + + await taskMigrate(mockCoreCompiler, config, flags); + + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('Successfully migrated')); + }); + + it('should show migration summary', async () => { + const { config, flags, infoSpy } = await setup({ + fileContent: '@Component({ shadow: true }) class MyComponent {}', + detectMatches: [migrationMatch], + }); + + await taskMigrate(mockCoreCompiler, config, flags); + + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('Migration Summary')); + }); + }); + + describe('with --dry-run flag', () => { + const migrationMatch = { + node: {}, + message: 'Found deprecated API', + line: 10, + column: 5, + }; + + it('should not modify files', async () => { + const { config, flags, sys } = await setup({ + dryRun: true, + fileContent: '@Component({ shadow: true }) class MyComponent {}', + detectMatches: [migrationMatch], + }); + + await taskMigrate(mockCoreCompiler, config, flags); + + // writeFile should not be called for component files + const writeFileCalls = (sys.writeFile as any).mock.calls.filter((call: any[]) => + call[0].endsWith('.tsx'), + ); + expect(writeFileCalls).toHaveLength(0); + }); + + it('should show dry run message', async () => { + const { config, flags, infoSpy } = await setup({ + dryRun: true, + fileContent: '@Component({ shadow: true }) class MyComponent {}', + detectMatches: [migrationMatch], + }); + + await taskMigrate(mockCoreCompiler, config, flags); + + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('Dry run mode')); + }); + + it('should show hint to run without --dry-run', async () => { + const { config, flags, infoSpy } = await setup({ + dryRun: true, + fileContent: '@Component({ shadow: true }) class MyComponent {}', + detectMatches: [migrationMatch], + }); + + await taskMigrate(mockCoreCompiler, config, flags); + + expect(infoSpy).toHaveBeenCalledWith( + expect.stringContaining('Run without --dry-run to apply the migrations'), + ); + }); + + it('should still show what would be migrated', async () => { + const { config, flags, infoSpy } = await setup({ + dryRun: true, + fileContent: '@Component({ shadow: true }) class MyComponent {}', + detectMatches: [migrationMatch], + }); + + await taskMigrate(mockCoreCompiler, config, flags); + + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('Line 10')); + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('Found deprecated API')); + }); + }); + + describe('edge cases', () => { + it('should handle no TypeScript files found', async () => { + const ts = await import('typescript'); + // Use mockReturnValueOnce to avoid affecting other tests + vi.mocked(ts.default.getParsedCommandLineOfConfigFile).mockReturnValueOnce({ + fileNames: [], + errors: [], + options: {}, + } as any); + + const { config, flags, infoSpy } = await setup({ + fileContent: null, + }); + + await taskMigrate(mockCoreCompiler, config, flags); + + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('No TypeScript files found')); + }); + + it('should handle empty file content', async () => { + const { config, flags, sys } = await setup({ + fileContent: null, + detectMatches: [], + }); + + await taskMigrate(mockCoreCompiler, config, flags); + + // Should not crash and should not write any files + const writeFileCalls = (sys.writeFile as any).mock.calls.filter((call: any[]) => + call[0].endsWith('.tsx'), + ); + expect(writeFileCalls).toHaveLength(0); + }); + }); + + describe('detectMigrations', () => { + it('should return hasMigrations: false when no migrations found', async () => { + const { config } = await setup({ + fileContent: 'const x = 1;', + detectMatches: [], + }); + + const result = await detectMigrations(mockCoreCompiler, config); + + expect(result.hasMigrations).toBe(false); + expect(result.totalMatches).toBe(0); + expect(result.filesAffected).toBe(0); + expect(result.migrations).toHaveLength(0); + }); + + it('should return hasMigrations: true when migrations are found', async () => { + const migrationMatch = { + node: {}, + message: 'Found deprecated API', + line: 10, + column: 5, + }; + + const { config } = await setup({ + fileContent: '@Component({ shadow: true }) class MyComponent {}', + detectMatches: [migrationMatch], + }); + + const result = await detectMigrations(mockCoreCompiler, config); + + expect(result.hasMigrations).toBe(true); + expect(result.totalMatches).toBe(1); + expect(result.filesAffected).toBe(1); + expect(result.migrations).toHaveLength(1); + }); + + it('should include migration details', async () => { + const migrationMatch = { + node: {}, + message: 'Found deprecated API', + line: 10, + column: 5, + }; + + const { config } = await setup({ + fileContent: '@Component({ shadow: true }) class MyComponent {}', + detectMatches: [migrationMatch], + }); + + const result = await detectMigrations(mockCoreCompiler, config); + + expect(result.migrations[0].filePath).toBe('/test/src/components/my-component.tsx'); + expect(result.migrations[0].rule.id).toBe('test-rule'); + expect(result.migrations[0].matches).toHaveLength(1); + }); + + it('should not modify any files', async () => { + const migrationMatch = { + node: {}, + message: 'Found deprecated API', + line: 10, + column: 5, + }; + + const { config, sys } = await setup({ + fileContent: '@Component({ shadow: true }) class MyComponent {}', + detectMatches: [migrationMatch], + }); + + await detectMigrations(mockCoreCompiler, config); + + // writeFile should never be called during detection + expect(sys.writeFile).not.toHaveBeenCalled(); + }); + + it('should include rules in result', async () => { + const { config } = await setup({ + fileContent: 'const x = 1;', + detectMatches: [], + }); + + const result = await detectMigrations(mockCoreCompiler, config); + + expect(result.rules).toHaveLength(1); + expect(result.rules[0].id).toBe('test-rule'); + }); + }); +}); diff --git a/packages/cli/src/config-flags.ts b/packages/cli/src/config-flags.ts index 855ccd8fa22..3628bf52044 100644 --- a/packages/cli/src/config-flags.ts +++ b/packages/cli/src/config-flags.ts @@ -15,6 +15,7 @@ export const BOOLEAN_CLI_FLAGS = [ 'dev', 'devtools', 'docs', + 'dryRun', 'esm', 'help', 'log', diff --git a/packages/cli/src/migrations/_test_/encapsulation-api.spec.ts b/packages/cli/src/migrations/_test_/encapsulation-api.spec.ts new file mode 100644 index 00000000000..5bef58c8bbb --- /dev/null +++ b/packages/cli/src/migrations/_test_/encapsulation-api.spec.ts @@ -0,0 +1,334 @@ +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +import { encapsulationApiRule } from '../rules/encapsulation-api'; + +/** + * Helper to create a TypeScript source file from code string + */ +function createSourceFile(code: string): ts.SourceFile { + return ts.createSourceFile('test.tsx', code, ts.ScriptTarget.Latest, true); +} + +describe('encapsulation-api migration rule', () => { + describe('metadata', () => { + it('should have correct rule metadata', () => { + expect(encapsulationApiRule.id).toBe('encapsulation-api'); + expect(encapsulationApiRule.name).toBe('Encapsulation API'); + expect(encapsulationApiRule.fromVersion).toBe('4.x'); + expect(encapsulationApiRule.toVersion).toBe('5.x'); + }); + }); + + describe('detect', () => { + it('should detect shadow: true', () => { + const code = ` + import { Component } from '@stencil/core'; + @Component({ + tag: 'my-component', + shadow: true + }) + export class MyComponent {} + `; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + + expect(matches).toHaveLength(1); + expect(matches[0].message).toContain("'shadow'"); + }); + + it('should detect shadow: false', () => { + const code = ` + import { Component } from '@stencil/core'; + @Component({ + tag: 'my-component', + shadow: false + }) + export class MyComponent {} + `; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + + expect(matches).toHaveLength(1); + }); + + it('should detect shadow with options object', () => { + const code = ` + import { Component } from '@stencil/core'; + @Component({ + tag: 'my-component', + shadow: { delegatesFocus: true } + }) + export class MyComponent {} + `; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + + expect(matches).toHaveLength(1); + }); + + it('should detect scoped: true', () => { + const code = ` + import { Component } from '@stencil/core'; + @Component({ + tag: 'my-component', + scoped: true + }) + export class MyComponent {} + `; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + + expect(matches).toHaveLength(1); + expect(matches[0].message).toContain("'scoped'"); + }); + + it('should detect both shadow and scoped in same file', () => { + const code = ` + import { Component } from '@stencil/core'; + @Component({ + tag: 'cmp-a', + shadow: true + }) + export class CmpA {} + + @Component({ + tag: 'cmp-b', + scoped: true + }) + export class CmpB {} + `; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + + expect(matches).toHaveLength(2); + }); + + it('should not detect when using new encapsulation API', () => { + const code = ` + import { Component } from '@stencil/core'; + @Component({ + tag: 'my-component', + encapsulation: { type: 'shadow' } + }) + export class MyComponent {} + `; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + + expect(matches).toHaveLength(0); + }); + + it('should not detect components without shadow or scoped', () => { + const code = ` + import { Component } from '@stencil/core'; + @Component({ + tag: 'my-component' + }) + export class MyComponent {} + `; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + + expect(matches).toHaveLength(0); + }); + + it('should provide correct line numbers', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + shadow: true +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + + expect(matches).toHaveLength(1); + expect(matches[0].line).toBe(4); // shadow: true is on line 4 + }); + + it('should detect shadow: true with aliased Component import', () => { + const code = ` + import { Component as Cmp } from '@stencil/core'; + @Cmp({ + tag: 'my-component', + shadow: true + }) + export class MyComponent {} + `; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + + expect(matches).toHaveLength(1); + expect(matches[0].message).toContain("'shadow'"); + }); + + it('should detect scoped: true with aliased Component import', () => { + const code = ` + import { Component as StencilComponent, h } from '@stencil/core'; + @StencilComponent({ + tag: 'my-component', + scoped: true + }) + export class MyComponent {} + `; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + + expect(matches).toHaveLength(1); + expect(matches[0].message).toContain("'scoped'"); + }); + + it('should not detect non-Stencil decorators with same name as alias', () => { + const code = ` + import { Component as Cmp } from '@stencil/core'; + import { SomeDecorator } from 'other-library'; + @SomeDecorator({ + shadow: true + }) + export class MyComponent {} + `; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + + expect(matches).toHaveLength(0); + }); + }); + + describe('transform', () => { + it('should transform shadow: true to encapsulation: { type: "shadow" }', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + shadow: true +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + const result = encapsulationApiRule.transform(sourceFile, matches); + + expect(result).toContain("encapsulation: { type: 'shadow' }"); + expect(result).not.toContain('shadow: true'); + }); + + it('should transform shadow: false by removing it', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + shadow: false +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + const result = encapsulationApiRule.transform(sourceFile, matches); + + expect(result).not.toContain('shadow'); + expect(result).not.toContain('encapsulation'); + }); + + it('should transform shadow with delegatesFocus', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + shadow: { delegatesFocus: true } +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + const result = encapsulationApiRule.transform(sourceFile, matches); + + expect(result).toContain("encapsulation: { type: 'shadow', delegatesFocus: true }"); + expect(result).not.toContain('shadow: {'); + }); + + it('should transform shadow with slotAssignment', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + shadow: { slotAssignment: 'manual' } +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + const result = encapsulationApiRule.transform(sourceFile, matches); + + expect(result).toContain("type: 'shadow'"); + expect(result).toContain("slotAssignment: 'manual'"); + }); + + it('should transform shadow with multiple options', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + shadow: { delegatesFocus: true, slotAssignment: 'manual' } +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + const result = encapsulationApiRule.transform(sourceFile, matches); + + expect(result).toContain("type: 'shadow'"); + expect(result).toContain('delegatesFocus: true'); + expect(result).toContain("slotAssignment: 'manual'"); + }); + + it('should transform scoped: true to encapsulation: { type: "scoped" }', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + scoped: true +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + const result = encapsulationApiRule.transform(sourceFile, matches); + + expect(result).toContain("encapsulation: { type: 'scoped' }"); + expect(result).not.toContain('scoped: true'); + }); + + it('should transform scoped: false by removing it', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + scoped: false +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + const result = encapsulationApiRule.transform(sourceFile, matches); + + expect(result).not.toContain('scoped'); + expect(result).not.toContain('encapsulation'); + }); + + it('should preserve other component options', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + styleUrl: 'my-component.css', + shadow: true +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const matches = encapsulationApiRule.detect(sourceFile); + const result = encapsulationApiRule.transform(sourceFile, matches); + + expect(result).toContain("tag: 'my-component'"); + expect(result).toContain("styleUrl: 'my-component.css'"); + expect(result).toContain("encapsulation: { type: 'shadow' }"); + }); + + it('should return original text when no matches', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component' +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const result = encapsulationApiRule.transform(sourceFile, []); + + expect(result).toBe(code); + }); + }); +}); diff --git a/packages/cli/src/migrations/_test_/form-associated.spec.ts b/packages/cli/src/migrations/_test_/form-associated.spec.ts new file mode 100644 index 00000000000..258a20dfca8 --- /dev/null +++ b/packages/cli/src/migrations/_test_/form-associated.spec.ts @@ -0,0 +1,345 @@ +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +import { formAssociatedRule } from '../rules/form-associated'; + +/** + * Helper to create a TypeScript source file from code string + */ +function createSourceFile(code: string): ts.SourceFile { + return ts.createSourceFile('test.tsx', code, ts.ScriptTarget.Latest, true); +} + +describe('form-associated migration rule', () => { + describe('metadata', () => { + it('should have correct rule metadata', () => { + expect(formAssociatedRule.id).toBe('form-associated'); + expect(formAssociatedRule.name).toBe('Form Associated'); + expect(formAssociatedRule.fromVersion).toBe('4.x'); + expect(formAssociatedRule.toVersion).toBe('5.x'); + }); + }); + + describe('detect', () => { + it('should detect formAssociated: true', () => { + const code = ` + import { Component } from '@stencil/core'; + @Component({ + tag: 'my-component', + formAssociated: true + }) + export class MyComponent {} + `; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + + expect(matches).toHaveLength(1); + expect(matches[0].message).toContain('formAssociated'); + }); + + it('should detect formAssociated with shadow', () => { + const code = ` + import { Component } from '@stencil/core'; + @Component({ + tag: 'my-component', + shadow: true, + formAssociated: true + }) + export class MyComponent {} + `; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + + expect(matches).toHaveLength(1); + }); + + it('should not detect when formAssociated is not present', () => { + const code = ` + import { Component } from '@stencil/core'; + @Component({ + tag: 'my-component', + shadow: true + }) + export class MyComponent {} + `; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + + expect(matches).toHaveLength(0); + }); + + it('should detect when @AttachInternals already exists', () => { + const code = ` + import { Component, AttachInternals } from '@stencil/core'; + @Component({ + tag: 'my-component', + formAssociated: true + }) + export class MyComponent { + @AttachInternals() internals: ElementInternals; + } + `; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + + expect(matches).toHaveLength(1); + expect(matches[0].message).toContain('already has @AttachInternals'); + }); + + it('should provide correct line numbers', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + formAssociated: true +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + + expect(matches).toHaveLength(1); + expect(matches[0].line).toBe(4); // formAssociated: true is on line 4 + }); + + it('should detect multiple components with formAssociated', () => { + const code = ` + import { Component } from '@stencil/core'; + @Component({ + tag: 'cmp-a', + formAssociated: true + }) + export class CmpA {} + + @Component({ + tag: 'cmp-b', + formAssociated: true + }) + export class CmpB {} + `; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + + expect(matches).toHaveLength(2); + }); + + it('should detect formAssociated with aliased Component import', () => { + const code = ` + import { Component as Cmp, h } from '@stencil/core'; + @Cmp({ + tag: 'my-component', + formAssociated: true + }) + export class MyComponent {} + `; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + + expect(matches).toHaveLength(1); + expect(matches[0].message).toContain('formAssociated'); + }); + + it('should detect existing @AttachInternals with aliased import', () => { + const code = ` + import { Component as Cmp, AttachInternals as ElInternals } from '@stencil/core'; + @Cmp({ + tag: 'my-component', + formAssociated: true + }) + export class MyComponent { + @ElInternals() internals: ElementInternals; + } + `; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + + expect(matches).toHaveLength(1); + expect(matches[0].message).toContain('already has @AttachInternals'); + }); + + it('should not detect non-Stencil decorators with same name as alias', () => { + const code = ` + import { Component as Cmp } from '@stencil/core'; + import { SomeDecorator } from 'other-library'; + @SomeDecorator({ + formAssociated: true + }) + export class MyComponent {} + `; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + + expect(matches).toHaveLength(0); + }); + }); + + describe('transform', () => { + it('should add @AttachInternals and remove formAssociated', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + formAssociated: true +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + const result = formAssociatedRule.transform(sourceFile, matches); + + expect(result).toContain('@AttachInternals()'); + expect(result).toContain('internals: ElementInternals'); + expect(result).not.toContain('formAssociated'); + }); + + it('should add AttachInternals to imports', () => { + const code = `import { Component, h } from '@stencil/core'; +@Component({ + tag: 'my-component', + formAssociated: true +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + const result = formAssociatedRule.transform(sourceFile, matches); + + expect(result).toContain('AttachInternals'); + expect(result).toMatch( + /import\s*\{[^}]*AttachInternals[^}]*\}\s*from\s*['"]@stencil\/core['"]/, + ); + }); + + it('should not duplicate AttachInternals import if already present', () => { + const code = `import { Component, AttachInternals } from '@stencil/core'; +@Component({ + tag: 'my-component', + formAssociated: true +}) +export class MyComponent { + @AttachInternals() internals: ElementInternals; +}`; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + const result = formAssociatedRule.transform(sourceFile, matches); + + // Should only have one AttachInternals in imports + const importMatches = result.match(/AttachInternals/g); + // One in import, one in decorator usage + expect(importMatches!.length).toBe(2); + }); + + it('should only remove formAssociated when @AttachInternals already exists', () => { + const code = `import { Component, AttachInternals } from '@stencil/core'; +@Component({ + tag: 'my-component', + formAssociated: true +}) +export class MyComponent { + @AttachInternals() internals: ElementInternals; +}`; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + const result = formAssociatedRule.transform(sourceFile, matches); + + expect(result).not.toContain('formAssociated'); + // Should still have the existing @AttachInternals + expect(result).toContain('@AttachInternals()'); + }); + + it('should preserve other component options', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + styleUrl: 'my-component.css', + formAssociated: true +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + const result = formAssociatedRule.transform(sourceFile, matches); + + expect(result).toContain("tag: 'my-component'"); + expect(result).toContain("styleUrl: 'my-component.css'"); + expect(result).not.toContain('formAssociated'); + }); + + it('should handle trailing comma after formAssociated', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + formAssociated: true, +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + const result = formAssociatedRule.transform(sourceFile, matches); + + expect(result).not.toContain('formAssociated'); + // Should be valid syntax + expect(() => createSourceFile(result)).not.toThrow(); + }); + + it('should insert @AttachInternals with correct indentation', () => { + const code = `import { Component, Prop } from '@stencil/core'; +@Component({ + tag: 'my-component', + formAssociated: true +}) +export class MyComponent { + @Prop() value: string; +}`; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + const result = formAssociatedRule.transform(sourceFile, matches); + + // Should have @AttachInternals before @Prop + const attachIndex = result.indexOf('@AttachInternals'); + const propIndex = result.indexOf('@Prop'); + expect(attachIndex).toBeLessThan(propIndex); + }); + + it('should return original text when no matches', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component' +}) +export class MyComponent {}`; + const sourceFile = createSourceFile(code); + const result = formAssociatedRule.transform(sourceFile, []); + + expect(result).toBe(code); + }); + + it('should handle component with extends clause', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + formAssociated: true +}) +export class MyComponent extends BaseComponent { +}`; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + const result = formAssociatedRule.transform(sourceFile, matches); + + expect(result).toContain('@AttachInternals()'); + expect(result).toContain('extends BaseComponent'); + // Should be inside the class body, not before extends + const classBodyStart = result.indexOf('{', result.indexOf('extends BaseComponent')); + const attachIndex = result.indexOf('@AttachInternals'); + expect(attachIndex).toBeGreaterThan(classBodyStart); + }); + + it('should handle component with implements clause', () => { + const code = `import { Component } from '@stencil/core'; +@Component({ + tag: 'my-component', + formAssociated: true +}) +export class MyComponent implements SomeInterface { +}`; + const sourceFile = createSourceFile(code); + const matches = formAssociatedRule.detect(sourceFile); + const result = formAssociatedRule.transform(sourceFile, matches); + + expect(result).toContain('@AttachInternals()'); + expect(result).toContain('implements SomeInterface'); + }); + }); +}); diff --git a/packages/cli/src/migrations/_test_/index.spec.ts b/packages/cli/src/migrations/_test_/index.spec.ts new file mode 100644 index 00000000000..ba9235e5057 --- /dev/null +++ b/packages/cli/src/migrations/_test_/index.spec.ts @@ -0,0 +1,132 @@ +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +import { getRulesForVersionUpgrade, getStencilCoreImportMap, isStencilDecorator } from '../index'; + +/** + * Helper to create a TypeScript source file from code string + */ +function createSourceFile(code: string): ts.SourceFile { + return ts.createSourceFile('test.tsx', code, ts.ScriptTarget.Latest, true); +} + +describe('migrations/index', () => { + describe('getStencilCoreImportMap', () => { + it('should return empty map when no @stencil/core import', () => { + const code = ` + import { something } from 'other-library'; + `; + const sourceFile = createSourceFile(code); + const importMap = getStencilCoreImportMap(sourceFile); + + expect(importMap.size).toBe(0); + }); + + it('should map non-aliased imports to themselves', () => { + const code = ` + import { Component, h, Prop } from '@stencil/core'; + `; + const sourceFile = createSourceFile(code); + const importMap = getStencilCoreImportMap(sourceFile); + + expect(importMap.get('Component')).toBe('Component'); + expect(importMap.get('h')).toBe('h'); + expect(importMap.get('Prop')).toBe('Prop'); + }); + + it('should map aliased imports to original names', () => { + const code = ` + import { Component as Cmp, Prop as Input } from '@stencil/core'; + `; + const sourceFile = createSourceFile(code); + const importMap = getStencilCoreImportMap(sourceFile); + + expect(importMap.get('Cmp')).toBe('Component'); + expect(importMap.get('Input')).toBe('Prop'); + // Original names should not be in the map + expect(importMap.has('Component')).toBe(false); + expect(importMap.has('Prop')).toBe(false); + }); + + it('should handle mixed aliased and non-aliased imports', () => { + const code = ` + import { Component as Cmp, h, Prop as Input, State } from '@stencil/core'; + `; + const sourceFile = createSourceFile(code); + const importMap = getStencilCoreImportMap(sourceFile); + + expect(importMap.get('Cmp')).toBe('Component'); + expect(importMap.get('h')).toBe('h'); + expect(importMap.get('Input')).toBe('Prop'); + expect(importMap.get('State')).toBe('State'); + }); + }); + + describe('isStencilDecorator', () => { + it('should return true for non-aliased decorator', () => { + const importMap = new Map([['Component', 'Component']]); + expect(isStencilDecorator('Component', 'Component', importMap)).toBe(true); + }); + + it('should return true for aliased decorator', () => { + const importMap = new Map([['Cmp', 'Component']]); + expect(isStencilDecorator('Cmp', 'Component', importMap)).toBe(true); + }); + + it('should return false for non-matching decorator', () => { + const importMap = new Map([['Component', 'Component']]); + expect(isStencilDecorator('SomeOther', 'Component', importMap)).toBe(false); + }); + + it('should return false for empty import map', () => { + const importMap = new Map(); + expect(isStencilDecorator('Component', 'Component', importMap)).toBe(false); + }); + }); + + describe('getRulesForVersionUpgrade', () => { + it('should return rules for 4.x to 5.x upgrade', () => { + const rules = getRulesForVersionUpgrade('4', '5'); + + expect(rules.length).toBeGreaterThan(0); + expect(rules.every((r) => r.fromVersion.startsWith('4'))).toBe(true); + expect(rules.every((r) => r.toVersion.startsWith('5'))).toBe(true); + }); + + it('should include encapsulation-api rule for v4 to v5', () => { + const rules = getRulesForVersionUpgrade('4', '5'); + const encapsulationRule = rules.find((r) => r.id === 'encapsulation-api'); + + expect(encapsulationRule).toBeDefined(); + expect(encapsulationRule!.name).toBe('Encapsulation API'); + }); + + it('should include form-associated rule for v4 to v5', () => { + const rules = getRulesForVersionUpgrade('4', '5'); + const formAssociatedRule = rules.find((r) => r.id === 'form-associated'); + + expect(formAssociatedRule).toBeDefined(); + expect(formAssociatedRule!.name).toBe('Form Associated'); + }); + + it('should return empty array for non-existent version upgrade', () => { + const rules = getRulesForVersionUpgrade('99', '100'); + + expect(rules).toEqual([]); + }); + + it('should return empty array for downgrade', () => { + const rules = getRulesForVersionUpgrade('5', '4'); + + expect(rules).toEqual([]); + }); + + it('should handle partial version matching', () => { + // Rules have fromVersion: '4.x', toVersion: '5.x' + // Should match when we pass just '4' and '5' + const rules = getRulesForVersionUpgrade('4', '5'); + + expect(rules.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/cli/src/migrations/index.ts b/packages/cli/src/migrations/index.ts new file mode 100644 index 00000000000..88fc848d702 --- /dev/null +++ b/packages/cli/src/migrations/index.ts @@ -0,0 +1,121 @@ +import ts from 'typescript'; + +import { encapsulationApiRule } from './rules/encapsulation-api'; +import { formAssociatedRule } from './rules/form-associated'; + +/** + * Build a map of local import names to their original names from @stencil/core. + * Handles aliased imports like `import { Component as Cmp } from '@stencil/core'`. + * + * @param sourceFile The TypeScript source file to analyze + * @returns Map where keys are local names and values are original imported names + */ +export const getStencilCoreImportMap = (sourceFile: ts.SourceFile): Map => { + const importMap = new Map(); + + for (const statement of sourceFile.statements) { + if ( + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text === '@stencil/core' + ) { + const namedBindings = statement.importClause?.namedBindings; + if (namedBindings && ts.isNamedImports(namedBindings)) { + for (const element of namedBindings.elements) { + // element.name is the local name (what's used in code) + // element.propertyName is the original name (if aliased), otherwise undefined + const localName = element.name.text; + const originalName = element.propertyName?.text ?? element.name.text; + importMap.set(localName, originalName); + } + } + break; // Only process first @stencil/core import + } + } + + return importMap; +}; + +/** + * Check if a decorator identifier refers to a specific @stencil/core export. + * Handles aliased imports like `import { Component as Cmp } from '@stencil/core'`. + * + * @param decoratorName The identifier used in the decorator + * @param expectedOriginalName The original export name (e.g., 'Component') + * @param importMap The import map from getStencilCoreImportMap + * @returns True if the decorator refers to the expected export + */ +export const isStencilDecorator = ( + decoratorName: string, + expectedOriginalName: string, + importMap: Map, +): boolean => { + return importMap.get(decoratorName) === expectedOriginalName; +}; + +/** + * Represents a match found by a migration rule during detection. + */ +export interface MigrationMatch { + /** The AST node that matched */ + node: ts.Node; + /** Human-readable message describing what needs to be migrated */ + message: string; + /** Line number in the source file (1-indexed) */ + line: number; + /** Column number in the source file (1-indexed) */ + column: number; +} + +/** + * Interface for pluggable migration rules. + * Each rule can detect deprecated patterns and transform them to the new API. + */ +export interface MigrationRule { + /** Unique identifier for the rule */ + id: string; + /** Human-readable name */ + name: string; + /** Description of what this rule migrates */ + description: string; + /** Source version (e.g., '4.x') */ + fromVersion: string; + /** Target version (e.g., '5.x') */ + toVersion: string; + + /** + * Detect if this rule applies to a source file. + * @param sourceFile The TypeScript source file to check + * @returns Array of matches found, empty if rule doesn't apply + */ + detect(sourceFile: ts.SourceFile): MigrationMatch[]; + + /** + * Apply the transformation to a source file. + * @param sourceFile The TypeScript source file to transform + * @param matches The matches found during detection + * @returns The transformed source code as a string + */ + transform(sourceFile: ts.SourceFile, matches: MigrationMatch[]): string; +} + +/** + * Registry of all available migration rules. + * Rules are applied in order, so add new rules at the end. + */ +const migrationRules: MigrationRule[] = [encapsulationApiRule, formAssociatedRule]; + +/** + * Get all migration rules for a specific version upgrade. + * @param fromVersion Source version (e.g., '4') + * @param toVersion Target version (e.g., '5') + * @returns Filtered list of applicable rules + */ +export const getRulesForVersionUpgrade = ( + fromVersion: string, + toVersion: string, +): MigrationRule[] => { + return migrationRules.filter( + (rule) => rule.fromVersion.startsWith(fromVersion) && rule.toVersion.startsWith(toVersion), + ); +}; diff --git a/packages/cli/src/migrations/rules/encapsulation-api.ts b/packages/cli/src/migrations/rules/encapsulation-api.ts new file mode 100644 index 00000000000..14eda18c5ec --- /dev/null +++ b/packages/cli/src/migrations/rules/encapsulation-api.ts @@ -0,0 +1,137 @@ +import ts from 'typescript'; + +import { + getStencilCoreImportMap, + isStencilDecorator, + type MigrationMatch, + type MigrationRule, +} from '../index'; + +/** + * Migration rule for the @Component encapsulation API change. + * + * Migrates: + * - `shadow: true` → `encapsulation: { type: 'shadow' }` + * - `shadow: { delegatesFocus: true }` → `encapsulation: { type: 'shadow', delegatesFocus: true }` + * - `scoped: true` → `encapsulation: { type: 'scoped' }` + */ +export const encapsulationApiRule: MigrationRule = { + id: 'encapsulation-api', + name: 'Encapsulation API', + description: 'Migrate shadow/scoped properties to new encapsulation API', + fromVersion: '4.x', + toVersion: '5.x', + + detect(sourceFile: ts.SourceFile): MigrationMatch[] { + const matches: MigrationMatch[] = []; + const importMap = getStencilCoreImportMap(sourceFile); + + const visit = (node: ts.Node) => { + // Look for @Component decorator (handles aliased imports like `Component as Cmp`) + if (ts.isDecorator(node) && ts.isCallExpression(node.expression)) { + const decoratorName = node.expression.expression; + if ( + ts.isIdentifier(decoratorName) && + isStencilDecorator(decoratorName.text, 'Component', importMap) + ) { + const [arg] = node.expression.arguments; + if (arg && ts.isObjectLiteralExpression(arg)) { + // Check for deprecated properties + for (const prop of arg.properties) { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { + const propName = prop.name.text; + if (propName === 'shadow' || propName === 'scoped') { + const { line, character } = sourceFile.getLineAndCharacterOfPosition( + prop.getStart(), + ); + matches.push({ + node: prop, + message: `Deprecated '${propName}' property found - migrate to 'encapsulation' API`, + 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(); + } + + let text = sourceFile.getFullText(); + + // Process matches in reverse order to preserve positions + const sortedMatches = [...matches].sort((a, b) => { + const posA = (a.node as ts.Node).getStart(); + const posB = (b.node as ts.Node).getStart(); + return posB - posA; + }); + + for (const match of sortedMatches) { + const prop = match.node as ts.PropertyAssignment; + const propName = (prop.name as ts.Identifier).text; + const start = prop.getStart(); + const end = prop.getEnd(); + + let replacement: string; + + if (propName === 'shadow') { + if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) { + replacement = "encapsulation: { type: 'shadow' }"; + } else if (ts.isObjectLiteralExpression(prop.initializer)) { + // Extract options from the object + const options: string[] = []; + for (const innerProp of prop.initializer.properties) { + if (ts.isPropertyAssignment(innerProp) && ts.isIdentifier(innerProp.name)) { + const optName = innerProp.name.text; + const optValue = innerProp.initializer.getText(); + options.push(`${optName}: ${optValue}`); + } + } + if (options.length > 0) { + replacement = `encapsulation: { type: 'shadow', ${options.join(', ')} }`; + } else { + replacement = "encapsulation: { type: 'shadow' }"; + } + } else { + // shadow: false or other - just remove it + replacement = ''; + } + } else if (propName === 'scoped') { + if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) { + replacement = "encapsulation: { type: 'scoped' }"; + } else { + // scoped: false - just remove it + replacement = ''; + } + } else { + continue; + } + + // Handle trailing comma + let endPos = end; + const afterProp = text.slice(end).match(/^\s*,/); + if (afterProp && replacement === '') { + endPos = end + afterProp[0].length; + } else if (afterProp && replacement !== '') { + // Keep the comma + } else if (!afterProp && replacement !== '') { + // Check if there's a comma before this prop that we should handle + } + + text = text.slice(0, start) + replacement + text.slice(endPos); + } + + return text; + }, +}; diff --git a/packages/cli/src/migrations/rules/form-associated.ts b/packages/cli/src/migrations/rules/form-associated.ts new file mode 100644 index 00000000000..a1dee31ea4b --- /dev/null +++ b/packages/cli/src/migrations/rules/form-associated.ts @@ -0,0 +1,222 @@ +import ts from 'typescript'; + +import { + getStencilCoreImportMap, + isStencilDecorator, + type MigrationMatch, + type MigrationRule, +} from '../index'; + +interface FormAssociatedMatch extends MigrationMatch { + classBodyStart: number; + hasAttachInternals: boolean; + indent: string; + needsImport: boolean; + stencilImportEnd: number; +} + +/** + * Migration rule for formAssociated → @AttachInternals. + * + * Migrates: + * - `formAssociated: true` in @Component → Adds `@AttachInternals() internals: ElementInternals;` + */ +export const formAssociatedRule: MigrationRule = { + id: 'form-associated', + name: 'Form Associated', + description: 'Migrate formAssociated to @AttachInternals decorator', + fromVersion: '4.x', + toVersion: '5.x', + + detect(sourceFile: ts.SourceFile): MigrationMatch[] { + const matches: FormAssociatedMatch[] = []; + const importMap = getStencilCoreImportMap(sourceFile); + + // Check if AttachInternals is already imported (handles aliases) + let hasAttachInternalsImport = false; + let stencilImportEnd = 0; + + for (const statement of sourceFile.statements) { + if ( + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text === '@stencil/core' + ) { + stencilImportEnd = statement.getEnd(); + // Check if any import resolves to AttachInternals + for (const [, originalName] of importMap) { + if (originalName === 'AttachInternals') { + hasAttachInternalsImport = true; + break; + } + } + break; + } + } + + const visit = (node: ts.Node) => { + // Look for class declarations with @Component decorator (handles aliased imports) + if (ts.isClassDeclaration(node)) { + const decorators = ts.getDecorators(node); + if (!decorators) { + ts.forEachChild(node, visit); + return; + } + + // Find @Component decorator (handles aliased imports like `Component as Cmp`) + let componentDecorator: ts.Decorator | undefined; + let formAssociatedProp: ts.PropertyAssignment | undefined; + + for (const decorator of decorators) { + if ( + ts.isCallExpression(decorator.expression) && + ts.isIdentifier(decorator.expression.expression) && + isStencilDecorator(decorator.expression.expression.text, 'Component', importMap) + ) { + componentDecorator = decorator; + const [arg] = decorator.expression.arguments; + if (arg && ts.isObjectLiteralExpression(arg)) { + for (const prop of arg.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'formAssociated' + ) { + formAssociatedProp = prop; + break; + } + } + } + break; + } + } + + if (componentDecorator && formAssociatedProp) { + // Check if class already has @AttachInternals (handles aliased imports) + let hasAttachInternals = false; + for (const member of node.members) { + if (ts.isPropertyDeclaration(member) || ts.isMethodDeclaration(member)) { + const memberDecorators = ts.getDecorators(member); + if (memberDecorators) { + for (const d of memberDecorators) { + if ( + ts.isCallExpression(d.expression) && + ts.isIdentifier(d.expression.expression) && + isStencilDecorator(d.expression.expression.text, 'AttachInternals', importMap) + ) { + hasAttachInternals = true; + break; + } + } + } + } + } + + // Find class body start (opening brace) - must be AFTER class name/heritage clauses + // node.getText() includes decorators, so we can't just indexOf('{') + let searchStart = node.name ? node.name.getEnd() : node.getStart(sourceFile); + + // Skip past heritage clauses (extends/implements) + if (node.heritageClauses) { + for (const clause of node.heritageClauses) { + searchStart = Math.max(searchStart, clause.getEnd()); + } + } + + // Find the { after the class name/heritage + const textAfterName = sourceFile.getFullText().slice(searchStart); + const braceMatch = textAfterName.match(/\s*\{/); + if (!braceMatch) { + ts.forEachChild(node, visit); + return; + } + const classBodyStart = searchStart + braceMatch[0].length; + + // Determine indentation from first member or default + let indent = ' '; + if (node.members.length > 0) { + const firstMember = node.members[0]; + const memberStart = firstMember.getStart(sourceFile); + const textBefore = sourceFile.getFullText().slice(classBodyStart, memberStart); + const indentMatch = textBefore.match(/\n(\s+)/); + if (indentMatch) { + indent = indentMatch[1]; + } + } + + const { line, character } = sourceFile.getLineAndCharacterOfPosition( + formAssociatedProp.getStart(), + ); + + matches.push({ + node: formAssociatedProp, + message: hasAttachInternals + ? "Remove 'formAssociated' (already has @AttachInternals)" + : "Migrate 'formAssociated' to @AttachInternals decorator", + line: line + 1, + column: character + 1, + classBodyStart, + hasAttachInternals, + indent, + needsImport: !hasAttachInternalsImport && !hasAttachInternals, + stencilImportEnd, + }); + } + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return matches; + }, + + transform(sourceFile: ts.SourceFile, matches: MigrationMatch[]): string { + if (matches.length === 0) { + return sourceFile.getFullText(); + } + + let text = sourceFile.getFullText(); + const typedMatches = matches as FormAssociatedMatch[]; + + // Sort by position descending (process from end to start) + const sortedMatches = [...typedMatches].sort((a, b) => { + return b.classBodyStart - a.classBodyStart; + }); + + for (const match of sortedMatches) { + // First, add @AttachInternals if needed (do this first since it's later in file) + if (!match.hasAttachInternals) { + const newMember = `\n${match.indent}@AttachInternals() internals: ElementInternals;\n`; + text = text.slice(0, match.classBodyStart) + newMember + text.slice(match.classBodyStart); + } + + // Then remove formAssociated property (earlier in file, so positions still valid) + const prop = match.node as ts.PropertyAssignment; + const start = prop.getStart(); + let end = prop.getEnd(); + + // Handle trailing comma + const afterProp = text.slice(end).match(/^\s*,/); + if (afterProp) { + end = end + afterProp[0].length; + } + + text = text.slice(0, start) + text.slice(end); + } + + // Add AttachInternals to import if needed (only once, check first match) + const firstMatch = typedMatches[0]; + if (firstMatch?.needsImport && firstMatch.stencilImportEnd > 0) { + // Find the import statement and add AttachInternals to it + const importMatch = text.match(/import\s*\{([^}]*)\}\s*from\s*['"]@stencil\/core['"]/); + if (importMatch) { + const existingImports = importMatch[1]; + const newImports = existingImports.trimEnd() + ', AttachInternals'; + text = text.replace(importMatch[0], `import {${newImports}} from '@stencil/core'`); + } + } + + return text; + }, +}; diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index 636e28d967c..2ee2a495121 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -13,6 +13,7 @@ import { taskDocs } from './task-docs'; import { taskGenerate } from './task-generate'; import { taskHelp } from './task-help'; import { taskInfo } from './task-info'; +import { taskMigrate } from './task-migrate'; import { taskPrerender } from './task-prerender'; import { taskServe } from './task-serve'; import { taskTelemetry } from './task-telemetry'; @@ -164,6 +165,10 @@ export const runTask = async ( await taskHelp(resolvedFlags, strictConfig.logger, sys); break; + case 'migrate': + await taskMigrate(coreCompiler, strictConfig, resolvedFlags); + break; + case 'prerender': await taskPrerender(coreCompiler, strictConfig, resolvedFlags); break; diff --git a/packages/cli/src/task-build.ts b/packages/cli/src/task-build.ts index c92b7b6289e..9716a024e5d 100644 --- a/packages/cli/src/task-build.ts +++ b/packages/cli/src/task-build.ts @@ -1,7 +1,9 @@ +import { relative } from 'path'; import type * as d from '@stencil/core/compiler'; import { printCheckVersionResults, startCheckVersion } from './check-version'; import { startupCompilerLog } from './logs'; +import { detectMigrations, taskMigrate, type MigrationDetectionResult } from './task-migrate'; import { runPrerenderTask } from './task-prerender'; import { taskWatch } from './task-watch'; import { telemetryBuildFinishedAction } from './telemetry/telemetry'; @@ -35,7 +37,53 @@ export const taskBuild = async ( await compiler.destroy(); if (results.hasError) { - exitCode = 1; + // Check if there are migrations that might help fix the errors + const migrationResult = await detectMigrations(coreCompiler, config); + + if (migrationResult.hasMigrations) { + // Show what migrations are available and prompt user + const action = await promptForMigrationOnBuildError(config, migrationResult); + + if (action === 'run') { + // Run migrations and re-run build + await taskMigrate(coreCompiler, config, { ...flags, dryRun: false }); + config.logger.info('\nRe-running build after migrations...\n'); + + // Re-run the build + const newCompiler = await coreCompiler.createCompiler(config); + const newResults = await newCompiler.build(); + await newCompiler.destroy(); + + if (!newResults.hasError) { + // Build succeeded after migration + exitCode = 0; + if (flags.prerender) { + const prerenderDiagnostics = await runPrerenderTask( + coreCompiler, + config, + newResults.hydrateAppFilePath, + newResults.componentGraph, + undefined, + ); + config.logger.printDiagnostics(prerenderDiagnostics); + if (prerenderDiagnostics.some((d) => d.level === 'error')) { + exitCode = 1; + } + } + } else { + exitCode = 1; + } + } else if (action === 'dry-run') { + // Show what would be migrated + await taskMigrate(coreCompiler, config, { ...flags, dryRun: true }); + exitCode = 1; + } else { + // User chose to exit + exitCode = 1; + } + } else { + exitCode = 1; + } } else if (flags.prerender) { const prerenderDiagnostics = await runPrerenderTask( coreCompiler, @@ -61,3 +109,69 @@ export const taskBuild = async ( return config.sys.exit(exitCode); } }; + +type MigrationAction = 'run' | 'dry-run' | 'exit'; + +/** + * Prompt the user about available migrations when a build fails. + * Shows what migrations are available and lets them choose to run them. + * @param config the Stencil config + * @param migrationResult the result of migration detection with available migrations + * @returns the user's chosen action for handling migrations + */ +async function promptForMigrationOnBuildError( + config: d.ValidatedConfig, + migrationResult: MigrationDetectionResult, +): Promise { + const logger = config.logger; + + // Show migration availability message + logger.info(''); + logger.info(logger.bold(logger.yellow('Migrations Available'))); + logger.info('─'.repeat(40)); + logger.info( + `Found ${migrationResult.totalMatches} item(s) in ${migrationResult.filesAffected} file(s) that can be automatically migrated.`, + ); + + // Show summary of what can be migrated + for (const migration of migrationResult.migrations) { + const relPath = relative(config.rootDir, migration.filePath); + logger.info(` ${logger.cyan(relPath)}: ${migration.matches.length} item(s)`); + } + + logger.info(''); + logger.info('These migrations may help resolve the build errors above.'); + + // Import prompts dynamically + const { prompt } = await import('prompts'); + + const response = await prompt({ + name: 'action', + type: 'select', + message: 'What would you like to do?', + choices: [ + { + title: 'Run migration', + value: 'run', + description: 'Apply migrations and re-run build', + }, + { + title: 'Dry run', + value: 'dry-run', + description: 'Preview changes without modifying files', + }, + { + title: 'Exit', + value: 'exit', + description: 'Exit without making changes', + }, + ], + }); + + // Handle Ctrl+C or escape + if (response.action === undefined) { + return 'exit'; + } + + return response.action as MigrationAction; +} diff --git a/packages/cli/src/task-migrate.ts b/packages/cli/src/task-migrate.ts new file mode 100644 index 00000000000..4aadfa65ab4 --- /dev/null +++ b/packages/cli/src/task-migrate.ts @@ -0,0 +1,303 @@ +import { isAbsolute, join, relative } from 'path'; +import ts from 'typescript'; +import type * as d from '@stencil/core/compiler'; + +import { getRulesForVersionUpgrade, type MigrationMatch, type MigrationRule } from './migrations'; +import type { ConfigFlags } from './config-flags'; +import type { CoreCompiler } from './load-compiler'; + +interface MigrationResult { + filePath: string; + rule: MigrationRule; + matches: MigrationMatch[]; + transformed: boolean; +} + +/** + * Represents a detected migration that can be applied. + */ +export interface DetectedMigration { + filePath: string; + rule: MigrationRule; + matches: MigrationMatch[]; +} + +/** + * Result of migration detection. + */ +export interface MigrationDetectionResult { + /** Whether any migrations were detected */ + hasMigrations: boolean; + /** Total number of items that need migration */ + totalMatches: number; + /** Number of files affected */ + filesAffected: number; + /** The detected migrations */ + migrations: DetectedMigration[]; + /** The migration rules that were checked */ + rules: MigrationRule[]; +} + +/** + * Run the migration task to update Stencil components from v4 to v5 API. + * + * @param coreCompiler the Stencil compiler instance + * @param config the validated Stencil config + * @param flags CLI flags (includes dryRun option) + */ +export const taskMigrate = async ( + coreCompiler: CoreCompiler, + config: d.ValidatedConfig, + flags: ConfigFlags, +): Promise => { + const logger = config.logger; + const sys = config.sys; + const dryRun = flags.dryRun ?? false; + + // Get migration rules for the specified version upgrade + // Default: from previous major version to current installed version + const currentMajor = coreCompiler.version.split('.')[0]; + const fromVersion = String(Number(currentMajor) - 1); + const toVersion = currentMajor; + const rules = getRulesForVersionUpgrade(fromVersion, toVersion); + + if (rules.length === 0) { + logger.info(`No migration rules found for ${fromVersion}.x → ${toVersion}.x upgrade.`); + return; + } + + logger.info(`${logger.emoji('🔄 ')}Stencil Migration Tool (v${fromVersion} → v${toVersion})`); + logger.info(`Scanning for components that need migration...`); + + if (dryRun) { + logger.info(logger.cyan('Dry run mode - no files will be modified')); + } + + // Get TypeScript files from tsconfig (same approach as the compiler) + const tsFiles = await getTypeScriptFiles(config, sys, logger); + + if (tsFiles.length === 0) { + logger.info(`No TypeScript files found. Check your tsconfig.json configuration.`); + return; + } + + logger.info(`Found ${tsFiles.length} TypeScript files to scan`); + + const results: MigrationResult[] = []; + + // Process each file + for (const filePath of tsFiles) { + let content = await sys.readFile(filePath); + if (!content) { + continue; + } + + // Run each migration rule - re-parse after each transformation to get fresh positions + for (const rule of rules) { + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + const matches = rule.detect(sourceFile); + + if (matches.length > 0) { + const relPath = relative(config.rootDir, filePath); + logger.info(`\n${logger.cyan(relPath)}`); + logger.info(` ${logger.yellow(`[${rule.id}]`)} ${rule.name}`); + + for (const match of matches) { + logger.info(` Line ${match.line}: ${match.message}`); + } + + if (!dryRun) { + // Apply the transformation + const transformed = rule.transform(sourceFile, matches); + await sys.writeFile(filePath, transformed); + // Update content for next rule to use fresh positions + content = transformed; + results.push({ filePath, rule, matches, transformed: true }); + logger.info(` ${logger.green('✓')} Migrated`); + } else { + results.push({ filePath, rule, matches, transformed: false }); + } + } + } + } + + // Print summary + logger.info('\n' + logger.bold('Migration Summary')); + logger.info('─'.repeat(40)); + + const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0); + const filesAffected = new Set(results.map((r) => r.filePath)).size; + + if (totalMatches === 0) { + logger.info(logger.green('No migrations needed - your code is up to date!')); + } else { + logger.info(`Found ${totalMatches} item(s) to migrate in ${filesAffected} file(s)`); + + if (dryRun) { + logger.info(logger.yellow('\nRun without --dry-run to apply the migrations')); + } else { + logger.info(logger.green(`\n✓ Successfully migrated ${totalMatches} item(s)`)); + } + } + + // Group results by rule for detailed summary + const byRule = new Map(); + for (const result of results) { + const existing = byRule.get(result.rule.id) || []; + existing.push(result); + byRule.set(result.rule.id, existing); + } + + if (byRule.size > 0) { + logger.info('\nBy migration rule:'); + for (const [ruleId, ruleResults] of byRule) { + const rule = rules.find((r) => r.id === ruleId); + const count = ruleResults.reduce((sum, r) => sum + r.matches.length, 0); + logger.info(` ${rule?.name || ruleId}: ${count} item(s)`); + } + } +}; + +/** + * Detect available migrations without applying them. + * Used by the build task to check if migrations might help fix build errors. + * + * @param coreCompiler the Stencil compiler instance + * @param config the validated Stencil config + * @returns detection result with migration information + */ +export const detectMigrations = async ( + coreCompiler: CoreCompiler, + config: d.ValidatedConfig, +): Promise => { + const sys = config.sys; + const logger = config.logger; + + // Get migration rules for the specified version upgrade + const currentMajor = coreCompiler.version.split('.')[0]; + const fromVersion = String(Number(currentMajor) - 1); + const toVersion = currentMajor; + const rules = getRulesForVersionUpgrade(fromVersion, toVersion); + + if (rules.length === 0) { + return { + hasMigrations: false, + totalMatches: 0, + filesAffected: 0, + migrations: [], + rules: [], + }; + } + + // Get TypeScript files from tsconfig + const tsFiles = await getTypeScriptFiles(config, sys, logger); + + if (tsFiles.length === 0) { + return { + hasMigrations: false, + totalMatches: 0, + filesAffected: 0, + migrations: [], + rules, + }; + } + + const migrations: DetectedMigration[] = []; + + // Detect migrations in each file + for (const filePath of tsFiles) { + const content = await sys.readFile(filePath); + if (!content) { + continue; + } + + for (const rule of rules) { + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + const matches = rule.detect(sourceFile); + + if (matches.length > 0) { + migrations.push({ filePath, rule, matches }); + } + } + } + + const totalMatches = migrations.reduce((sum, m) => sum + m.matches.length, 0); + const filesAffected = new Set(migrations.map((m) => m.filePath)).size; + + return { + hasMigrations: migrations.length > 0, + totalMatches, + filesAffected, + migrations, + rules, + }; +}; + +/** + * Get TypeScript files using the project's tsconfig.json. + * Uses the same approach as the Stencil compiler. + * + * @param config the validated Stencil config + * @param sys the compiler system for file operations + * @param logger the logger for output + * @returns array of absolute paths to TypeScript files + */ +async function getTypeScriptFiles( + config: d.ValidatedConfig, + sys: d.CompilerSystem, + logger: d.Logger, +): Promise { + // Determine tsconfig path - check stencil config first, fall back to default + let tsconfigPath: string; + if (config.tsconfig) { + tsconfigPath = isAbsolute(config.tsconfig) + ? config.tsconfig + : join(config.rootDir, config.tsconfig); + } else { + tsconfigPath = join(config.rootDir, 'tsconfig.json'); + } + + logger.debug(`Using tsconfig: ${tsconfigPath}`); + + // Check if tsconfig exists + const tsconfigContent = await sys.readFile(tsconfigPath); + if (!tsconfigContent) { + logger.error(`tsconfig not found: ${tsconfigPath}`); + return []; + } + + // Parse the tsconfig using TypeScript's native parser + // Use ts.sys directly for readDirectory since it handles glob patterns correctly + const host: ts.ParseConfigFileHost = { + ...ts.sys, + readFile: (p) => { + if (p === tsconfigPath) { + return tsconfigContent; + } + return ts.sys.readFile(p); + }, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => { + logger.error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')); + }, + }; + + const results = ts.getParsedCommandLineOfConfigFile(tsconfigPath, {}, host); + + if (!results) { + logger.error(`Failed to parse tsconfig: ${tsconfigPath}`); + return []; + } + + if (results.errors && results.errors.length > 0) { + for (const err of results.errors) { + logger.warn(ts.flattenDiagnosticMessageText(err.messageText, '\n')); + } + } + + // Filter to only .ts and .tsx files (excluding .d.ts) + const files = results.fileNames.filter( + (f) => (f.endsWith('.ts') || f.endsWith('.tsx')) && !f.endsWith('.d.ts'), + ); + + return files; +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 096320a5e62..62b22a5b0ec 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -8,6 +8,7 @@ export type TaskCommand = | 'g' | 'help' | 'info' + | 'migrate' | 'prerender' | 'serve' | 'telemetry' diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index b5b8a156f59..c0738f23e4a 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -9,6 +9,6 @@ export default defineConfig({ dts: true, clean: true, deps: { - neverBundle: [/^node:/], + neverBundle: [/^node:/, 'typescript'], }, }); diff --git a/packages/core/src/app-data/index.ts b/packages/core/src/app-data/index.ts index d22be925528..7bde5ebc477 100644 --- a/packages/core/src/app-data/index.ts +++ b/packages/core/src/app-data/index.ts @@ -41,6 +41,7 @@ export const BUILD: BuildConditionals = { reflect: true, scoped: true, shadowDom: true, + shadowModeClosed: false, slot: true, cssAnnotations: true, state: true, diff --git a/packages/core/src/compiler/app-core/app-data.ts b/packages/core/src/compiler/app-core/app-data.ts index ad733928af1..42953f10cef 100644 --- a/packages/core/src/compiler/app-core/app-data.ts +++ b/packages/core/src/compiler/app-core/app-data.ts @@ -56,6 +56,7 @@ export const getBuildFeatures = (cmps: ComponentCompilerMeta[]): BuildFeatures = serializer: cmps.some((c) => c.hasSerializer), shadowDom, shadowDelegatesFocus: shadowDom && cmps.some((c) => c.shadowDelegatesFocus), + shadowModeClosed: shadowDom && cmps.some((c) => c.shadowMode === 'closed'), shadowSlotAssignmentManual: shadowDom && cmps.some((c) => c.slotAssignment === 'manual'), slot, slotRelocation, @@ -75,6 +76,11 @@ export const getBuildFeatures = (cmps: ComponentCompilerMeta[]): BuildFeatures = vdomStyle: cmps.some((c) => c.hasVdomStyle), vdomText: cmps.some((c) => c.hasVdomText), taskQueue: true, + // Per-component slot patches + patchAll: cmps.some((c) => c.hasPatchAll), + patchChildren: cmps.some((c) => c.hasPatchChildren), + patchClone: cmps.some((c) => c.hasPatchClone), + patchInsert: cmps.some((c) => c.hasPatchInsert), }; f.vdomAttribute = f.vdomAttribute || f.reflect; f.vdomPropOrAttr = f.vdomPropOrAttr || f.reflect; diff --git a/packages/core/src/compiler/build/build.ts b/packages/core/src/compiler/build/build.ts index 74378996a8a..512bfbb4918 100644 --- a/packages/core/src/compiler/build/build.ts +++ b/packages/core/src/compiler/build/build.ts @@ -6,6 +6,7 @@ import { catchError, isString, readPackageJson } from '../../utils'; import { generateOutputTargets } from '../output-targets'; import { emptyOutputTargets } from '../output-targets/empty-dir'; import { generateGlobalStyles } from '../style/global-styles'; +import { resetDeprecatedApiWarning } from '../transformers/decorators-to-static/component-decorator'; import { runTsProgram, validateTypesAfterGeneration } from '../transpile/run-program'; import { buildAbort, buildFinish } from './build-finish'; import { writeBuild } from './write-build'; @@ -20,6 +21,9 @@ export const build = async ( // reset process.cwd() for 3rd-party plugins process.chdir(config.rootDir); + // reset the deprecated API warning flag for this build + resetDeprecatedApiWarning(); + // empty the directories on the first build await emptyOutputTargets(config, compilerCtx, buildCtx); if (buildCtx.hasError) return buildAbort(buildCtx); diff --git a/packages/core/src/compiler/transformers/_test_/convert-decorators.spec.ts b/packages/core/src/compiler/transformers/_test_/convert-decorators.spec.ts index 7b409a564e3..7de0b0f731a 100644 --- a/packages/core/src/compiler/transformers/_test_/convert-decorators.spec.ts +++ b/packages/core/src/compiler/transformers/_test_/convert-decorators.spec.ts @@ -375,27 +375,27 @@ describe('convert-decorators', () => { ); }); - it('should create formAssociated static getter', async () => { + it('should create formAssociated static getter when @AttachInternals is used', async () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - formAssociated: true }) export class CmpA { + @AttachInternals() internals; } `); expect(getStaticGetter(t.outputText, 'formAssociated')).toBe(true); }); - it('should support formAssociated with shadow', async () => { + it('should support @AttachInternals with shadow', async () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - formAssociated: true, - shadow: true + encapsulation: { type: 'shadow' } }) export class CmpA { + @AttachInternals() internals; } `); @@ -403,14 +403,14 @@ describe('convert-decorators', () => { expect(getStaticGetter(t.outputText, 'formAssociated')).toBe(true); }); - it('should support formAssociated with scoped', async () => { + it('should support @AttachInternals with scoped', async () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - formAssociated: true, - scoped: true + encapsulation: { type: 'scoped' } }) export class CmpA { + @AttachInternals() internals; } `); diff --git a/packages/core/src/compiler/transformers/_test_/lazy-component.spec.ts b/packages/core/src/compiler/transformers/_test_/lazy-component.spec.ts index b15c49f016f..cc16e76fd11 100644 --- a/packages/core/src/compiler/transformers/_test_/lazy-component.spec.ts +++ b/packages/core/src/compiler/transformers/_test_/lazy-component.spec.ts @@ -85,7 +85,6 @@ describe('lazy-component', () => { const code = ` @Component({ tag: 'cmp-a', - formAssociated: true }) export class CmpA { @AttachInternals() internals: ElementInternals; diff --git a/packages/core/src/compiler/transformers/_test_/native-component.spec.ts b/packages/core/src/compiler/transformers/_test_/native-component.spec.ts index 2a19d2f5287..9a008078b53 100644 --- a/packages/core/src/compiler/transformers/_test_/native-component.spec.ts +++ b/packages/core/src/compiler/transformers/_test_/native-component.spec.ts @@ -107,7 +107,7 @@ describe('nativeComponentTransform', () => { const code = ` @Component({ tag: 'cmp-a', - shadow: true, + encapsulation: { type: 'shadow' }, }) export class CmpA { @Prop() foo: number; @@ -127,7 +127,7 @@ describe('nativeComponentTransform', () => { const code = ` @Component({ tag: 'cmp-a', - shadow: true, + encapsulation: { type: 'shadow' }, }) export class CmpA { @Prop() foo: number; @@ -168,12 +168,13 @@ describe('nativeComponentTransform', () => { }); describe('updateNativeConstructor', () => { - it('adds a getter for formAssociated', async () => { + it('adds a getter for formAssociated when @AttachInternals is used', async () => { const code = ` @Component({ - tag: 'cmp-a', formAssociated: true + tag: 'cmp-a' }) export class CmpA { + @AttachInternals() internals; } `; @@ -189,6 +190,7 @@ describe('nativeComponentTransform', () => { if (registerHost !== false) { this.__registerHost(); } + this.internals = this.attachInternals(); } static get formAssociated() { return true; @@ -197,10 +199,10 @@ describe('nativeComponentTransform', () => { ); }); - it('adds a binding for @AttachInternals', async () => { + it('adds a binding for @AttachInternals with formAssociated', async () => { const code = ` @Component({ - tag: 'cmp-a', formAssociated: true + tag: 'cmp-a' }) export class CmpA { @AttachInternals() internals; diff --git a/packages/core/src/compiler/transformers/_test_/parse-attach-internals.spec.ts b/packages/core/src/compiler/transformers/_test_/parse-attach-internals.spec.ts index f0e738acfb2..ab12c6ea528 100644 --- a/packages/core/src/compiler/transformers/_test_/parse-attach-internals.spec.ts +++ b/packages/core/src/compiler/transformers/_test_/parse-attach-internals.spec.ts @@ -3,11 +3,10 @@ import { describe, expect, it } from 'vitest'; import { transpileModule } from './transpile'; describe('parse attachInternals', function () { - it('should set attachInternalsMemberName when set', async () => { + it('should set attachInternalsMemberName and formAssociated when @AttachInternals is used', async () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - formAssociated: true }) export class CmpA { @AttachInternals() @@ -29,28 +28,13 @@ describe('parse attachInternals', function () { expect(t.cmp!.attachInternalsMemberName).toBe(null); }); - it('should set attachInternalsMemberName even if formAssociated is not defined', async () => { + it('should opt-out of formAssociated with @AttachInternals({ formAssociated: false })', async () => { const t = transpileModule(` @Component({ tag: 'cmp-a', }) export class CmpA { - @AttachInternals() - myProp; - } - `); - expect(t.cmp!.formAssociated).toBe(false); - expect(t.cmp!.attachInternalsMemberName).toBe('myProp'); - }); - - it('should set attachInternalsMemberName even if formAssociated is false', async () => { - const t = transpileModule(` - @Component({ - tag: 'cmp-a', - formAssociated: false - }) - export class CmpA { - @AttachInternals() + @AttachInternals({ formAssociated: false }) myProp; } `); @@ -104,11 +88,10 @@ describe('parse attachInternals', function () { expect(t.cmp!.attachInternalsCustomStates).toEqual([]); }); - it('should handle @AttachInternals with states and formAssociated', async () => { + it('should handle @AttachInternals with states (formAssociated is implicitly true)', async () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - formAssociated: true }) export class CmpA { @AttachInternals({ states: { checked: true } }) diff --git a/packages/core/src/compiler/transformers/_test_/parse-encapsulation.spec.ts b/packages/core/src/compiler/transformers/_test_/parse-encapsulation.spec.ts index c5e765ec61f..b49f72d8010 100644 --- a/packages/core/src/compiler/transformers/_test_/parse-encapsulation.spec.ts +++ b/packages/core/src/compiler/transformers/_test_/parse-encapsulation.spec.ts @@ -7,7 +7,7 @@ describe('parse encapsulation', () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - shadow: true + encapsulation: { type: 'shadow' } }) export class CmpA {} `); @@ -24,7 +24,8 @@ describe('parse encapsulation', () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - shadow: { + encapsulation: { + type: 'shadow', delegatesFocus: true } }) @@ -43,7 +44,8 @@ describe('parse encapsulation', () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - shadow: { + encapsulation: { + type: 'shadow', delegatesFocus: false } }) @@ -62,7 +64,7 @@ describe('parse encapsulation', () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - scoped: true + encapsulation: { type: 'scoped' } }) export class CmpA {} `); diff --git a/packages/core/src/compiler/transformers/_test_/parse-form-associated.spec.ts b/packages/core/src/compiler/transformers/_test_/parse-form-associated.spec.ts index 72e31c67c7c..b897f64b161 100644 --- a/packages/core/src/compiler/transformers/_test_/parse-form-associated.spec.ts +++ b/packages/core/src/compiler/transformers/_test_/parse-form-associated.spec.ts @@ -3,19 +3,19 @@ import { describe, expect, it } from 'vitest'; import { transpileModule } from './transpile'; describe('parse form associated', function () { - it('should set formAssociated if passed to decorator', async () => { + it('should set formAssociated if @AttachInternals is used', async () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - formAssociated: true }) export class CmpA { + @AttachInternals() internals; } `); expect(t.cmp!.formAssociated).toBe(true); }); - it('should not set formAssociated if not set', async () => { + it('should not set formAssociated if @AttachInternals is not used', async () => { const t = transpileModule(` @Component({ tag: 'cmp-a', @@ -25,4 +25,17 @@ describe('parse form associated', function () { `); expect(t.cmp!.formAssociated).toBe(false); }); + + it('should allow opting out of formAssociated with @AttachInternals({ formAssociated: false })', async () => { + const t = transpileModule(` + @Component({ + tag: 'cmp-a', + }) + export class CmpA { + @AttachInternals({ formAssociated: false }) internals; + } + `); + expect(t.cmp!.formAssociated).toBe(false); + expect(t.cmp!.attachInternalsMemberName).toBe('internals'); + }); }); diff --git a/packages/core/src/compiler/transformers/_test_/parse-slot-assignment.spec.ts b/packages/core/src/compiler/transformers/_test_/parse-slot-assignment.spec.ts index b5a92f1d017..6c56ec04381 100644 --- a/packages/core/src/compiler/transformers/_test_/parse-slot-assignment.spec.ts +++ b/packages/core/src/compiler/transformers/_test_/parse-slot-assignment.spec.ts @@ -7,7 +7,7 @@ describe('parse slotAssignment', () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - shadow: true + encapsulation: { type: 'shadow' } }) export class CmpA {} `); @@ -23,7 +23,8 @@ describe('parse slotAssignment', () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - shadow: { + encapsulation: { + type: 'shadow', slotAssignment: 'manual' } }) @@ -41,7 +42,8 @@ describe('parse slotAssignment', () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - shadow: { + encapsulation: { + type: 'shadow', slotAssignment: 'named' } }) @@ -59,7 +61,8 @@ describe('parse slotAssignment', () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - shadow: { + encapsulation: { + type: 'shadow', delegatesFocus: true, slotAssignment: 'manual' } @@ -80,7 +83,7 @@ describe('parse slotAssignment', () => { const t = transpileModule(` @Component({ tag: 'cmp-a', - scoped: true + encapsulation: { type: 'scoped' } }) export class CmpA {} `); @@ -113,7 +116,8 @@ describe('parse slotAssignment', () => { transpileModule(` @Component({ tag: 'cmp-a', - shadow: { + encapsulation: { + type: 'shadow', slotAssignment: 'invalid' } }) diff --git a/packages/core/src/compiler/transformers/component-build-conditionals.ts b/packages/core/src/compiler/transformers/component-build-conditionals.ts index 51322866f01..e5f497be471 100644 --- a/packages/core/src/compiler/transformers/component-build-conditionals.ts +++ b/packages/core/src/compiler/transformers/component-build-conditionals.ts @@ -71,4 +71,12 @@ export const setComponentBuildConditionals = (cmpMeta: d.ComponentCompilerMeta) !cmpMeta.hasLifecycle && !cmpMeta.hasListener && !cmpMeta.hasVdomRender; + + // Per-component slot patches + if (cmpMeta.patches) { + cmpMeta.hasPatchAll = !!cmpMeta.patches.all; + cmpMeta.hasPatchChildren = !!cmpMeta.patches.children; + cmpMeta.hasPatchClone = !!cmpMeta.patches.clone; + cmpMeta.hasPatchInsert = !!cmpMeta.patches.insert; + } }; diff --git a/packages/core/src/compiler/transformers/decorators-to-static/attach-internals.ts b/packages/core/src/compiler/transformers/decorators-to-static/attach-internals.ts index 759b75043f5..e45cab4cc09 100644 --- a/packages/core/src/compiler/transformers/decorators-to-static/attach-internals.ts +++ b/packages/core/src/compiler/transformers/decorators-to-static/attach-internals.ts @@ -64,9 +64,10 @@ export const attachInternalsDecoratorsToStatic = ( const { staticName: name } = tsPropDeclName(decoratedProp, typeChecker); - // Parse decorator options for custom states, extracting JSDoc comments from AST + // Parse decorator options for custom states and formAssociated setting const decorator = retrieveTsDecorators(decoratedProp)?.find(isDecoratorNamed(decoratorName)); const customStates = parseCustomStatesFromDecorator(decorator, typeChecker); + const formAssociated = parseFormAssociatedFromDecorator(decorator); newMembers.push(createStaticGetter('attachInternalsMemberName', convertValueToLiteral(name))); @@ -76,8 +77,55 @@ export const attachInternalsDecoratorsToStatic = ( createStaticGetter('attachInternalsCustomStates', convertValueToLiteral(customStates)), ); } + + // Add formAssociated static getter - defaults to true unless explicitly set to false + // This makes the component form-associated when using @AttachInternals + if (formAssociated) { + newMembers.push(createStaticGetter('formAssociated', convertValueToLiteral(true))); + } }; +/** + * Parse the formAssociated option from the decorator. + * Returns true (form-associated) unless explicitly set to false. + * + * @param decorator the decorator node to parse + * @returns whether the component should be form-associated + */ +function parseFormAssociatedFromDecorator(decorator: ts.Decorator | undefined): boolean { + if (!decorator || !ts.isCallExpression(decorator.expression)) { + // No options provided, default to true + return true; + } + + const [firstArg] = decorator.expression.arguments; + if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) { + // Empty call or non-object argument, default to true + return true; + } + + // Find the 'formAssociated' property in the options object + const formAssociatedProp = firstArg.properties.find( + (prop): prop is ts.PropertyAssignment => + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'formAssociated', + ); + + if (!formAssociatedProp) { + // Not specified, default to true + return true; + } + + // Check if explicitly set to false + if (formAssociatedProp.initializer.kind === ts.SyntaxKind.FalseKeyword) { + return false; + } + + // Any other value (true, or truthy expression) means form-associated + return true; +} + /** * Parse custom states from the decorator AST, including JSDoc comments. * diff --git a/packages/core/src/compiler/transformers/decorators-to-static/component-decorator.ts b/packages/core/src/compiler/transformers/decorators-to-static/component-decorator.ts index e5e363a2e2b..5857f709709 100644 --- a/packages/core/src/compiler/transformers/decorators-to-static/component-decorator.ts +++ b/packages/core/src/compiler/transformers/decorators-to-static/component-decorator.ts @@ -10,6 +10,29 @@ import { import { getDecoratorParameters } from './decorator-utils'; import { styleToStatic } from './style-to-static'; +// Track if we've already shown the deprecated API error this build +let hasShownDeprecatedApiError = false; + +/** + * Reset the deprecated API error flag. Call this at the start of each build. + */ +export const resetDeprecatedApiWarning = () => { + hasShownDeprecatedApiError = false; +}; + +/** + * Internal interface that includes both new and deprecated properties + * for detection and migration purposes. + */ +interface ComponentOptionsWithDeprecated extends d.ComponentOptions { + /** @deprecated Use `encapsulation: { type: 'shadow' }` instead */ + shadow?: boolean | { delegatesFocus?: boolean; slotAssignment?: 'manual' | 'named' }; + /** @deprecated Use `encapsulation: { type: 'scoped' }` instead */ + scoped?: boolean; + /** @deprecated Use `@AttachInternals()` decorator instead */ + formAssociated?: boolean; +} + /** * Perform code generation to create new class members for a Stencil component * which will drive the runtime functionality specified by various options @@ -39,7 +62,7 @@ export const componentDecoratorToStatic = ( newMembers: ts.ClassElement[], componentDecorator: ts.Decorator, ) => { - const [componentOptions] = getDecoratorParameters( + const [componentOptions] = getDecoratorParameters( componentDecorator, typeChecker, diagnostics, @@ -48,6 +71,11 @@ export const componentDecoratorToStatic = ( return; } + // Check for deprecated API usage before validation + if (!checkForDeprecatedApi(diagnostics, componentOptions, componentDecorator)) { + return; + } + if ( !validateComponent( config, @@ -63,23 +91,37 @@ export const componentDecoratorToStatic = ( newMembers.push(createStaticGetter('is', convertValueToLiteral(componentOptions.tag.trim()))); - if (componentOptions.shadow) { - newMembers.push(createStaticGetter('encapsulation', convertValueToLiteral('shadow'))); + // Process the new encapsulation property + if (componentOptions.encapsulation) { + const enc = componentOptions.encapsulation; + + if (enc.type === 'shadow') { + newMembers.push(createStaticGetter('encapsulation', convertValueToLiteral('shadow'))); + + if (enc.mode === 'closed') { + newMembers.push(createStaticGetter('shadowMode', convertValueToLiteral('closed'))); + } - if (typeof componentOptions.shadow !== 'boolean') { - if (componentOptions.shadow.delegatesFocus === true) { + if (enc.delegatesFocus === true) { newMembers.push(createStaticGetter('delegatesFocus', convertValueToLiteral(true))); } - if (componentOptions.shadow.slotAssignment === 'manual') { + + if (enc.slotAssignment === 'manual') { newMembers.push(createStaticGetter('slotAssignment', convertValueToLiteral('manual'))); } - } - } else if (componentOptions.scoped) { - newMembers.push(createStaticGetter('encapsulation', convertValueToLiteral('scoped'))); - } + } else if (enc.type === 'scoped') { + newMembers.push(createStaticGetter('encapsulation', convertValueToLiteral('scoped'))); - if (componentOptions.formAssociated === true) { - newMembers.push(createStaticGetter('formAssociated', convertValueToLiteral(true))); + if (enc.patches && enc.patches.length > 0) { + newMembers.push(createStaticGetter('patches', convertValueToLiteral(enc.patches))); + } + } else if (enc.type === 'none') { + // 'none' is the default, no need to set encapsulation getter + // but we still need to handle patches + if (enc.patches && enc.patches.length > 0) { + newMembers.push(createStaticGetter('patches', convertValueToLiteral(enc.patches))); + } + } } styleToStatic(newMembers, componentOptions); @@ -91,6 +133,57 @@ export const componentDecoratorToStatic = ( } }; +/** + * Check for usage of deprecated API properties and emit helpful error messages. + * Returns false if deprecated API is detected (halts compilation). + * Only shows detailed error once per build to avoid flooding the console. + * + * @param diagnostics an array of diagnostics for surfacing errors + * @param componentOptions the options passed to the `@Component` decorator + * @param componentDecorator the TypeScript decorator node + * @returns whether the component uses only the new API (true = valid) + */ +const checkForDeprecatedApi = ( + diagnostics: d.Diagnostic[], + componentOptions: ComponentOptionsWithDeprecated, + componentDecorator: ts.Decorator, +): boolean => { + const deprecatedProps: string[] = []; + + if (componentOptions.shadow !== undefined) { + deprecatedProps.push('shadow'); + } + + if (componentOptions.scoped !== undefined) { + deprecatedProps.push('scoped'); + } + + if (componentOptions.formAssociated !== undefined) { + deprecatedProps.push('formAssociated'); + } + + if (deprecatedProps.length > 0) { + // Only show the full error message once per build + if (!hasShownDeprecatedApiError) { + hasShownDeprecatedApiError = true; + const err = buildError(diagnostics); + err.messageText = `@Component() uses deprecated API removed in Stencil v5. + +The "shadow", "scoped", and "formAssociated" properties have been replaced: + • shadow: true → encapsulation: { type: "shadow" } + • scoped: true → encapsulation: { type: "scoped" } + • formAssociated: true → Use @AttachInternals() decorator + +Run 'npx stencil migrate --dry-run' to see all affected files. +Run 'npx stencil migrate' to automatically update your components.`; + augmentDiagnosticWithNode(err, findTagNode(deprecatedProps[0], componentDecorator)); + } + return false; + } + + return true; +}; + /** * Perform validation on a Stencil component in preparation for some * component-level code generation, checking that the class declaration node @@ -115,23 +208,39 @@ const validateComponent = ( cmpNode: ts.ClassDeclaration, componentDecorator: ts.Decorator, ) => { - if (componentOptions.shadow && componentOptions.scoped) { - const err = buildError(diagnostics); - err.messageText = `Components cannot be "scoped" and "shadow" at the same time, they are mutually exclusive configurations.`; - augmentDiagnosticWithNode(err, findTagNode('scoped', componentDecorator)); - return false; - } + // Validate encapsulation options + if (componentOptions.encapsulation) { + const enc = componentOptions.encapsulation; - // Validate slotAssignment is only used with shadow: true - if (typeof componentOptions.shadow === 'object' && componentOptions.shadow.slotAssignment) { - if ( - componentOptions.shadow.slotAssignment !== 'manual' && - componentOptions.shadow.slotAssignment !== 'named' - ) { - const err = buildError(diagnostics); - err.messageText = `The "slotAssignment" option must be either "manual" or "named".`; - augmentDiagnosticWithNode(err, findTagNode('slotAssignment', componentDecorator)); - return false; + if (enc.type === 'shadow') { + // Validate slotAssignment + if (enc.slotAssignment && enc.slotAssignment !== 'manual' && enc.slotAssignment !== 'named') { + const err = buildError(diagnostics); + err.messageText = `The "slotAssignment" option must be either "manual" or "named".`; + augmentDiagnosticWithNode(err, findTagNode('slotAssignment', componentDecorator)); + return false; + } + + // Validate mode + if (enc.mode && enc.mode !== 'open' && enc.mode !== 'closed') { + const err = buildError(diagnostics); + err.messageText = `The "mode" option must be either "open" or "closed".`; + augmentDiagnosticWithNode(err, findTagNode('mode', componentDecorator)); + return false; + } + } + + // Validate patches for non-shadow encapsulation + if ((enc.type === 'none' || enc.type === 'scoped') && enc.patches) { + const validPatches = ['all', 'children', 'clone', 'insert']; + for (const patch of enc.patches) { + if (!validPatches.includes(patch)) { + const err = buildError(diagnostics); + err.messageText = `Invalid patch "${patch}". Valid patches are: ${validPatches.join(', ')}.`; + augmentDiagnosticWithNode(err, findTagNode('patches', componentDecorator)); + return false; + } + } } } diff --git a/packages/core/src/compiler/transformers/decorators-to-static/decorators-constants.ts b/packages/core/src/compiler/transformers/decorators-to-static/decorators-constants.ts index 0c8295b4c0c..03e02fcd676 100644 --- a/packages/core/src/compiler/transformers/decorators-to-static/decorators-constants.ts +++ b/packages/core/src/compiler/transformers/decorators-to-static/decorators-constants.ts @@ -61,7 +61,9 @@ export const STATIC_GETTER_NAMES = [ 'listeners', 'methods', 'originalStyleUrls', + 'patches', 'properties', + 'shadowMode', 'slotAssignment', 'states', 'style', diff --git a/packages/core/src/compiler/transformers/static-to-meta/component.ts b/packages/core/src/compiler/transformers/static-to-meta/component.ts index abe371d2ec7..2594bf55926 100644 --- a/packages/core/src/compiler/transformers/static-to-meta/component.ts +++ b/packages/core/src/compiler/transformers/static-to-meta/component.ts @@ -27,7 +27,9 @@ import { parseClassMethods } from './class-methods'; import { parseStaticElementRef } from './element-ref'; import { parseStaticEncapsulation, + parseStaticPatches, parseStaticShadowDelegatesFocus, + parseStaticShadowMode, parseStaticSlotAssignment, } from './encapsulation'; import { parseFormAssociated } from './form-associated'; @@ -116,7 +118,9 @@ export const parseStaticComponentMeta = ( elementRef: parseStaticElementRef(staticMembers), encapsulation, shadowDelegatesFocus: !!parseStaticShadowDelegatesFocus(encapsulation, staticMembers), + shadowMode: parseStaticShadowMode(encapsulation, staticMembers), slotAssignment: parseStaticSlotAssignment(encapsulation, staticMembers), + patches: parseStaticPatches(encapsulation, staticMembers), properties, virtualProperties: parseVirtualProps(docs), states, @@ -200,6 +204,10 @@ export const parseStaticComponentMeta = ( dependencies: [], directDependents: [], directDependencies: [], + hasPatchAll: false, + hasPatchChildren: false, + hasPatchClone: false, + hasPatchInsert: false, }; const visitComponentChildNode = (node: ts.Node, ctx: d.BuildCtx) => { diff --git a/packages/core/src/compiler/transformers/static-to-meta/encapsulation.ts b/packages/core/src/compiler/transformers/static-to-meta/encapsulation.ts index 7b39f28f593..f19778013c2 100644 --- a/packages/core/src/compiler/transformers/static-to-meta/encapsulation.ts +++ b/packages/core/src/compiler/transformers/static-to-meta/encapsulation.ts @@ -64,3 +64,65 @@ export const parseStaticSlotAssignment = ( } return null; }; + +/** + * Find and return the shadow DOM mode for a component. + * + * @param encapsulation the encapsulation mode to use for a component + * @param staticMembers a collection of static getters to search + * @returns 'open' (default), 'closed' if explicitly set, or null if not shadow encapsulation + */ +export const parseStaticShadowMode = ( + encapsulation: string, + staticMembers: ts.ClassElement[], +): 'open' | 'closed' | null => { + if (encapsulation === 'shadow') { + const shadowMode: string = getStaticValue(staticMembers, 'shadowMode'); + return shadowMode === 'closed' ? 'closed' : 'open'; + } + return null; +}; + +/** + * Find and return the per-component patches for slot handling. + * + * @param encapsulation the encapsulation mode to use for a component + * @param staticMembers a collection of static getters to search + * @returns ComponentPatches object or null if no patches defined + */ +export const parseStaticPatches = ( + encapsulation: string, + staticMembers: ts.ClassElement[], +): d.ComponentPatches | null => { + // Patches only apply to non-shadow encapsulation + if (encapsulation === 'shadow') { + return null; + } + + const patches: string[] = getStaticValue(staticMembers, 'patches'); + if (!Array.isArray(patches) || patches.length === 0) { + return null; + } + + const result: d.ComponentPatches = {}; + + for (const patch of patches) { + switch (patch) { + case 'all': + result.all = true; + break; + case 'children': + result.children = true; + break; + case 'clone': + result.clone = true; + break; + case 'insert': + result.insert = true; + break; + } + } + + // Return null if no valid patches were found + return Object.keys(result).length > 0 ? result : null; +}; diff --git a/packages/core/src/compiler/transformers/update-stencil-core-import.ts b/packages/core/src/compiler/transformers/update-stencil-core-import.ts index 1708cbfea75..c4cc4a15cc0 100644 --- a/packages/core/src/compiler/transformers/update-stencil-core-import.ts +++ b/packages/core/src/compiler/transformers/update-stencil-core-import.ts @@ -117,6 +117,7 @@ const KEEP_IMPORTS = new Set([ 'writeTask', 'readTask', 'getElement', + 'getShadowRoot', 'forceUpdate', 'getRenderingRef', 'forceModeUpdate', diff --git a/packages/core/src/compiler/types/_tests_/ComponentCompilerMeta.stub.ts b/packages/core/src/compiler/types/_tests_/ComponentCompilerMeta.stub.ts index dc4e8a530cf..257d00fd18d 100644 --- a/packages/core/src/compiler/types/_tests_/ComponentCompilerMeta.stub.ts +++ b/packages/core/src/compiler/types/_tests_/ComponentCompilerMeta.stub.ts @@ -50,6 +50,10 @@ export const stubComponentCompilerMeta = ( hasMethod: false, hasMode: false, hasModernPropertyDecls: false, + hasPatchAll: false, + hasPatchChildren: false, + hasPatchClone: false, + hasPatchInsert: false, hasProp: false, hasPropBoolean: false, hasPropMutable: false, @@ -83,10 +87,12 @@ export const stubComponentCompilerMeta = ( jsFilePath: '/some/stubbed/path/my-component.js', listeners: [], methods: [], + patches: null, potentialCmpRefs: [], properties: [], serializers: [], shadowDelegatesFocus: false, + shadowMode: null, slotAssignment: null, sourceFilePath: '/some/stubbed/path/my-component.tsx', sourceMapPath: '/some/stubbed/path/my-component.js.map', diff --git a/packages/core/src/declarations/stencil-private.ts b/packages/core/src/declarations/stencil-private.ts index 49d34b5e45d..fa07c56e847 100644 --- a/packages/core/src/declarations/stencil-private.ts +++ b/packages/core/src/declarations/stencil-private.ts @@ -85,6 +85,7 @@ export interface BuildFeatures { // dom shadowDom: boolean; shadowDelegatesFocus: boolean; + shadowModeClosed: boolean; shadowSlotAssignmentManual: boolean; scoped: boolean; @@ -112,6 +113,12 @@ export interface BuildFeatures { vdomXlink: boolean; slotRelocation: boolean; + // per-component slot patches + patchAll: boolean; + patchChildren: boolean; + patchClone: boolean; + patchInsert: boolean; + // elements slot: boolean; svg: boolean; @@ -549,6 +556,10 @@ export interface ComponentCompilerFeatures { hasMethod: boolean; hasMode: boolean; hasModernPropertyDecls: boolean; + hasPatchAll: boolean; + hasPatchChildren: boolean; + hasPatchClone: boolean; + hasPatchInsert: boolean; hasProp: boolean; hasPropBoolean: boolean; hasPropNumber: boolean; @@ -648,11 +659,22 @@ export interface ComponentCompilerMeta extends ComponentCompilerFeatures { properties: ComponentCompilerProperty[]; serializers: ComponentCompilerChangeHandler[]; shadowDelegatesFocus: boolean; + /** + * Shadow DOM mode. 'open' (default) or 'closed'. + * Only applicable when encapsulation is 'shadow'. + */ + shadowMode: 'open' | 'closed' | null; /** * Slot assignment mode for shadow DOM. 'manual', enables imperative slotting * using HTMLSlotElement.assign(). Only applicable when encapsulation is 'shadow'. */ slotAssignment: 'manual' | null; + /** + * Per-component slot patches for non-shadow DOM components. + * These patches enable proper slot behavior without native Shadow DOM. + * Only applicable when encapsulation is 'none' or 'scoped'. + */ + patches: ComponentPatches | null; sourceFilePath: string; sourceMapPath: string; states: ComponentCompilerState[]; @@ -671,6 +693,21 @@ export interface ComponentCompilerMeta extends ComponentCompilerFeatures { */ export type Encapsulation = 'shadow' | 'scoped' | 'none'; +/** + * Per-component slot patches for non-shadow DOM components. + * These enable proper slot behavior when not using native Shadow DOM. + */ +export interface ComponentPatches { + /** Apply all slot patches (equivalent to experimentalSlotFixes) */ + all?: boolean; + /** Patch child node accessors (children, firstChild, lastChild, etc.) */ + children?: boolean; + /** Patch cloneNode() to handle slotted content */ + clone?: boolean; + /** Patch appendChild(), insertBefore(), etc. for slot relocation */ + insert?: boolean; +} + /** * Intermediate Representation (IR) of a static property on a Stencil component */ diff --git a/packages/core/src/declarations/stencil-public-runtime.ts b/packages/core/src/declarations/stencil-public-runtime.ts index de5e7aef3c2..67bc686a84a 100644 --- a/packages/core/src/declarations/stencil-public-runtime.ts +++ b/packages/core/src/declarations/stencil-public-runtime.ts @@ -14,16 +14,6 @@ export interface ComponentDecorator { (opts?: ComponentOptions): ClassDecorator; } export interface ComponentOptions { - /** - * When set to `true` this component will be form-associated. See - * https://stenciljs.com/docs/next/form-associated documentation on how to - * build form-associated Stencil components that integrate into forms like - * native browser elements such as `` and `