From 2ffb5033e8eacc3eb8c38f6d8e3be4f91d1b1f22 Mon Sep 17 00:00:00 2001 From: Ryan Waskiewicz Date: Mon, 25 Oct 2021 13:23:37 -0400 Subject: [PATCH] fix(compiler): add deletegatesFocus to custom elements targets (#3117) this commit allows `delegatesFocus` to be properly applied to components generated using the following output targets: - dist-custom-elements - dist-custom-elements-bundle the generation of the `attachShadow` call is moved from a standalone function to being attached to the prototype of the custom element when we proxy it. the reason for this is that we need the component metadata to to determine whether or not each individual component should have delegateFocus enabled or not. this led to the removal of the original standalone attachShadow function. I do not consider this to be a breaking change, as we don't publicly state our runtime APIs are available for general consumption. this change also led to the transition from using ts.create*() calls to ts.factory.create*() calls for nativeAttachShadowStatement, which is the general direction I'd like to take such calls, since the former is now deprecated STENCIL-90: "dist-custom-elements-bundle" does not set delegatesFocus when attaching shadow --- .../component-native/native-constructor.ts | 27 +++++-- .../test/native-constructor.spec.ts | 70 +++++++++++++++++++ src/hydrate/platform/index.ts | 1 - src/runtime/bootstrap-custom-element.ts | 23 +++--- src/runtime/index.ts | 2 +- test/karma/package.json | 2 +- test/karma/test-app/components.d.ts | 26 +++++++ .../custom-elements-delegates-focus.tsx | 18 +++++ .../custom-elements-no-delegates-focus.tsx | 16 +++++ .../index.esm.js | 3 + .../index.html | 6 ++ .../karma.spec.ts | 31 ++++++++ .../shared-delegates-focus.css | 15 ++++ .../webpack.config.js | 19 +++++ 14 files changed, 243 insertions(+), 16 deletions(-) create mode 100644 src/compiler/transformers/test/native-constructor.spec.ts create mode 100644 test/karma/test-app/custom-elements-delegates-focus/custom-elements-delegates-focus.tsx create mode 100644 test/karma/test-app/custom-elements-delegates-focus/custom-elements-no-delegates-focus.tsx create mode 100644 test/karma/test-app/custom-elements-delegates-focus/index.esm.js create mode 100644 test/karma/test-app/custom-elements-delegates-focus/index.html create mode 100644 test/karma/test-app/custom-elements-delegates-focus/karma.spec.ts create mode 100644 test/karma/test-app/custom-elements-delegates-focus/shared-delegates-focus.css create mode 100644 test/karma/test-app/custom-elements-delegates-focus/webpack.config.js diff --git a/src/compiler/transformers/component-native/native-constructor.ts b/src/compiler/transformers/component-native/native-constructor.ts index ad92311adb3..713c3ff4622 100644 --- a/src/compiler/transformers/component-native/native-constructor.ts +++ b/src/compiler/transformers/component-native/native-constructor.ts @@ -1,7 +1,7 @@ import type * as d from '../../../declarations'; import { addCreateEvents } from '../create-event'; import { addLegacyProps } from '../legacy-props'; -import { ATTACH_SHADOW, RUNTIME_APIS, addCoreRuntimeApi } from '../core-runtime-apis'; +import { RUNTIME_APIS, addCoreRuntimeApi } from '../core-runtime-apis'; import ts from 'typescript'; export const updateNativeConstructor = ( @@ -57,7 +57,13 @@ export const updateNativeConstructor = ( } }; -const nativeInit = (moduleFile: d.Module, cmp: d.ComponentCompilerMeta) => { +/** + * Generates a series of expression statements used to help initialize a Stencil component + * @param moduleFile the Stencil module that will be instantiated + * @param cmp the component's metadata + * @returns the generated expression statements + */ +const nativeInit = (moduleFile: d.Module, cmp: d.ComponentCompilerMeta): ReadonlyArray => { const initStatements = [nativeRegisterHostStatement()]; if (cmp.encapsulation === 'shadow') { initStatements.push(nativeAttachShadowStatement(moduleFile)); @@ -71,10 +77,21 @@ const nativeRegisterHostStatement = () => { ); }; -const nativeAttachShadowStatement = (moduleFile: d.Module) => { +/** + * Generates an expression statement for attaching a shadow DOM tree to an element. + * @param moduleFile the Stencil module that will use the generated expression statement + * @returns the generated expression statement + */ +const nativeAttachShadowStatement = (moduleFile: d.Module): ts.ExpressionStatement => { addCoreRuntimeApi(moduleFile, RUNTIME_APIS.attachShadow); - - return ts.createStatement(ts.createCall(ts.createIdentifier(ATTACH_SHADOW), undefined, [ts.createThis()])); + // Create an expression statement, `this.__attachShadow();` + return ts.factory.createExpressionStatement( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(ts.factory.createThis(), ts.factory.createIdentifier('__attachShadow')), + undefined, + undefined + ) + ); }; const createNativeConstructorSuper = () => { diff --git a/src/compiler/transformers/test/native-constructor.spec.ts b/src/compiler/transformers/test/native-constructor.spec.ts new file mode 100644 index 00000000000..79ec0f19122 --- /dev/null +++ b/src/compiler/transformers/test/native-constructor.spec.ts @@ -0,0 +1,70 @@ +import { mockCompilerCtx } from '@stencil/core/testing'; +import * as d from '@stencil/core/declarations'; +import { transpileModule } from './transpile'; +import { nativeComponentTransform } from '../component-native/tranform-to-native-component'; + +describe('nativeComponentTransform', () => { + let compilerCtx: d.CompilerCtx; + let transformOpts: d.TransformOptions; + + beforeEach(() => { + compilerCtx = mockCompilerCtx(); + transformOpts = { + coreImportPath: '@stencil/core', + componentExport: 'customelement', + componentMetadata: null, + currentDirectory: '/', + proxy: null, + style: 'static', + styleImportData: undefined, + }; + }); + + describe('updateNativeComponentClass', () => { + it("adds __attachShadow() calls when a component doesn't have a constructor", () => { + const code = ` + @Component({ + tag: 'cmp-a', + shadow: true, + }) + export class CmpA { + @Prop() foo: number; + } + `; + + const transformer = nativeComponentTransform(compilerCtx, transformOpts); + + const transpiledModule = transpileModule(code, null, compilerCtx, null, [], [transformer]); + + expect(transpiledModule.outputText).toContain( + `import { attachShadow as __stencil_attachShadow, defineCustomElement as __stencil_defineCustomElement } from "@stencil/core";` + ); + expect(transpiledModule.outputText).toContain(`this.__attachShadow()`); + }); + + it('adds __attachShadow() calls when a component has a constructor', () => { + const code = ` + @Component({ + tag: 'cmp-a', + shadow: true, + }) + export class CmpA { + @Prop() foo: number; + + constructor() { + super(); + } + } + `; + + const transformer = nativeComponentTransform(compilerCtx, transformOpts); + + const transpiledModule = transpileModule(code, null, compilerCtx, null, [], [transformer]); + + expect(transpiledModule.outputText).toContain( + `import { attachShadow as __stencil_attachShadow, defineCustomElement as __stencil_defineCustomElement } from "@stencil/core";` + ); + expect(transpiledModule.outputText).toContain(`this.__attachShadow()`); + }); + }); +}); diff --git a/src/hydrate/platform/index.ts b/src/hydrate/platform/index.ts index a050106efd8..68467d5949c 100644 --- a/src/hydrate/platform/index.ts +++ b/src/hydrate/platform/index.ts @@ -161,7 +161,6 @@ export { hydrateApp } from './hydrate-app'; export { addHostEventListeners, - attachShadow, defineCustomElement, forceModeUpdate, proxyCustomElement, diff --git a/src/runtime/bootstrap-custom-element.ts b/src/runtime/bootstrap-custom-element.ts index fa1d0bf14a1..c2f73be0c5b 100644 --- a/src/runtime/bootstrap-custom-element.ts +++ b/src/runtime/bootstrap-custom-element.ts @@ -52,8 +52,23 @@ export const proxyCustomElement = (Cstr: any, compactMeta: d.ComponentRuntimeMet originalDisconnectedCallback.call(this); } }, + __attachShadow() { + if (supportsShadow) { + if (BUILD.shadowDelegatesFocus) { + this.attachShadow({ + mode: 'open', + delegatesFocus: !!(cmpMeta.$flags$ & CMP_FLAGS.shadowDelegatesFocus), + }); + } else { + this.attachShadow({ mode: 'open' }); + } + } else { + (this as any).shadowRoot = this; + } + }, }); Cstr.is = cmpMeta.$tagName$; + return proxyComponent(Cstr, cmpMeta, PROXY_FLAGS.isElementConstructor | PROXY_FLAGS.proxyState); }; @@ -80,11 +95,3 @@ export const forceModeUpdate = (elm: d.RenderNode) => { } } }; - -export const attachShadow = (el: HTMLElement) => { - if (supportsShadow) { - el.attachShadow({ mode: 'open' }); - } else { - (el as any).shadowRoot = el; - } -}; diff --git a/src/runtime/index.ts b/src/runtime/index.ts index c32c7360e16..70e07a340d9 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -1,5 +1,5 @@ export { addHostEventListeners } from './host-listener'; -export { attachShadow, defineCustomElement, forceModeUpdate, proxyCustomElement } from './bootstrap-custom-element'; +export { defineCustomElement, forceModeUpdate, proxyCustomElement } from './bootstrap-custom-element'; export { bootstrapLazy } from './bootstrap-lazy'; export { connectedCallback } from './connected-callback'; export { createEvent } from './event-emitter'; diff --git a/test/karma/package.json b/test/karma/package.json index 4c753cbc12e..aa23338903f 100644 --- a/test/karma/package.json +++ b/test/karma/package.json @@ -19,7 +19,7 @@ "karma.prod": "npm run build.sibling && npm run build.invisible-prehydration && npm run build.app && npm run karma.webpack && npm run build.prerender && npm run karma", "karma.ie": "karma start karma.config.js --browsers=IE --single-run=false", "karma.edge": "karma start karma.config.js --browsers=Edge --single-run=false", - "karma.webpack": "webpack-cli --config test-app/esm-webpack/webpack.config.js && webpack-cli --config test-app/custom-elements-output-webpack/webpack.config.js && webpack-cli --config test-app/custom-elements-output-tag-class-different/webpack.config.js", + "karma.webpack": "webpack-cli --config test-app/esm-webpack/webpack.config.js && webpack-cli --config test-app/custom-elements-output-webpack/webpack.config.js && webpack-cli --config test-app/custom-elements-output-tag-class-different/webpack.config.js && webpack-cli --config test-app/custom-elements-delegates-focus/webpack.config.js", "start": "node ../../bin/stencil build --dev --watch --serve --es5" }, "devDependencies": { diff --git a/test/karma/test-app/components.d.ts b/test/karma/test-app/components.d.ts index 8e9bce65042..1b24df6ffcf 100644 --- a/test/karma/test-app/components.d.ts +++ b/test/karma/test-app/components.d.ts @@ -76,6 +76,10 @@ export namespace Components { } interface CustomElementRootDifferentNameThanClass { } + interface CustomElementsDelegatesFocus { + } + interface CustomElementsNoDelegatesFocus { + } interface CustomEventRoot { } interface DelegatesFocus { @@ -458,6 +462,18 @@ declare global { prototype: HTMLCustomElementRootDifferentNameThanClassElement; new (): HTMLCustomElementRootDifferentNameThanClassElement; }; + interface HTMLCustomElementsDelegatesFocusElement extends Components.CustomElementsDelegatesFocus, HTMLStencilElement { + } + var HTMLCustomElementsDelegatesFocusElement: { + prototype: HTMLCustomElementsDelegatesFocusElement; + new (): HTMLCustomElementsDelegatesFocusElement; + }; + interface HTMLCustomElementsNoDelegatesFocusElement extends Components.CustomElementsNoDelegatesFocus, HTMLStencilElement { + } + var HTMLCustomElementsNoDelegatesFocusElement: { + prototype: HTMLCustomElementsNoDelegatesFocusElement; + new (): HTMLCustomElementsNoDelegatesFocusElement; + }; interface HTMLCustomEventRootElement extends Components.CustomEventRoot, HTMLStencilElement { } var HTMLCustomEventRootElement: { @@ -1077,6 +1093,8 @@ declare global { "custom-element-nested-child": HTMLCustomElementNestedChildElement; "custom-element-root": HTMLCustomElementRootElement; "custom-element-root-different-name-than-class": HTMLCustomElementRootDifferentNameThanClassElement; + "custom-elements-delegates-focus": HTMLCustomElementsDelegatesFocusElement; + "custom-elements-no-delegates-focus": HTMLCustomElementsNoDelegatesFocusElement; "custom-event-root": HTMLCustomEventRootElement; "delegates-focus": HTMLDelegatesFocusElement; "dom-reattach": HTMLDomReattachElement; @@ -1246,6 +1264,10 @@ declare namespace LocalJSX { } interface CustomElementRootDifferentNameThanClass { } + interface CustomElementsDelegatesFocus { + } + interface CustomElementsNoDelegatesFocus { + } interface CustomEventRoot { } interface DelegatesFocus { @@ -1514,6 +1536,8 @@ declare namespace LocalJSX { "custom-element-nested-child": CustomElementNestedChild; "custom-element-root": CustomElementRoot; "custom-element-root-different-name-than-class": CustomElementRootDifferentNameThanClass; + "custom-elements-delegates-focus": CustomElementsDelegatesFocus; + "custom-elements-no-delegates-focus": CustomElementsNoDelegatesFocus; "custom-event-root": CustomEventRoot; "delegates-focus": DelegatesFocus; "dom-reattach": DomReattach; @@ -1643,6 +1667,8 @@ declare module "@stencil/core" { "custom-element-nested-child": LocalJSX.CustomElementNestedChild & JSXBase.HTMLAttributes; "custom-element-root": LocalJSX.CustomElementRoot & JSXBase.HTMLAttributes; "custom-element-root-different-name-than-class": LocalJSX.CustomElementRootDifferentNameThanClass & JSXBase.HTMLAttributes; + "custom-elements-delegates-focus": LocalJSX.CustomElementsDelegatesFocus & JSXBase.HTMLAttributes; + "custom-elements-no-delegates-focus": LocalJSX.CustomElementsNoDelegatesFocus & JSXBase.HTMLAttributes; "custom-event-root": LocalJSX.CustomEventRoot & JSXBase.HTMLAttributes; "delegates-focus": LocalJSX.DelegatesFocus & JSXBase.HTMLAttributes; "dom-reattach": LocalJSX.DomReattach & JSXBase.HTMLAttributes; diff --git a/test/karma/test-app/custom-elements-delegates-focus/custom-elements-delegates-focus.tsx b/test/karma/test-app/custom-elements-delegates-focus/custom-elements-delegates-focus.tsx new file mode 100644 index 00000000000..6d131645b08 --- /dev/null +++ b/test/karma/test-app/custom-elements-delegates-focus/custom-elements-delegates-focus.tsx @@ -0,0 +1,18 @@ +import { Component, Host, h } from '@stencil/core'; + +@Component({ + tag: 'custom-elements-delegates-focus', + styleUrl: 'shared-delegates-focus.css', + shadow: { + delegatesFocus: true, + }, +}) +export class CustomElementsDelegatesFocus { + render() { + return ( + + + + ); + } +} diff --git a/test/karma/test-app/custom-elements-delegates-focus/custom-elements-no-delegates-focus.tsx b/test/karma/test-app/custom-elements-delegates-focus/custom-elements-no-delegates-focus.tsx new file mode 100644 index 00000000000..14c7dbb09a7 --- /dev/null +++ b/test/karma/test-app/custom-elements-delegates-focus/custom-elements-no-delegates-focus.tsx @@ -0,0 +1,16 @@ +import { Component, Host, h } from '@stencil/core'; + +@Component({ + tag: 'custom-elements-no-delegates-focus', + styleUrl: 'shared-delegates-focus.css', + shadow: true, +}) +export class CustomElementsNoDelegatesFocus { + render() { + return ( + + + + ); + } +} diff --git a/test/karma/test-app/custom-elements-delegates-focus/index.esm.js b/test/karma/test-app/custom-elements-delegates-focus/index.esm.js new file mode 100644 index 00000000000..58319d06cf2 --- /dev/null +++ b/test/karma/test-app/custom-elements-delegates-focus/index.esm.js @@ -0,0 +1,3 @@ +import { defineCustomElement } from '../../test-components/custom-elements-delegates-focus'; + +defineCustomElement(); diff --git a/test/karma/test-app/custom-elements-delegates-focus/index.html b/test/karma/test-app/custom-elements-delegates-focus/index.html new file mode 100644 index 00000000000..a4af38474ea --- /dev/null +++ b/test/karma/test-app/custom-elements-delegates-focus/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/karma/test-app/custom-elements-delegates-focus/karma.spec.ts b/test/karma/test-app/custom-elements-delegates-focus/karma.spec.ts new file mode 100644 index 00000000000..060d65bef84 --- /dev/null +++ b/test/karma/test-app/custom-elements-delegates-focus/karma.spec.ts @@ -0,0 +1,31 @@ +import { setupDomTests } from '../util'; + +describe('custom-elements-delegates-focus', () => { + const { setupDom, tearDownDom } = setupDomTests(document); + let app: HTMLElement; + + beforeEach(async () => { + app = await setupDom('/custom-elements-delegates-focus/index.html'); + }); + afterEach(tearDownDom); + + it('sets delegatesFocus correctly', async () => { + expect(customElements.get('custom-elements-delegates-focus')).toBeDefined(); + + const elm: Element = app.querySelector('custom-elements-delegates-focus'); + + expect(elm.shadowRoot).toBeDefined(); + // as of TypeScript 4.3, `delegatesFocus` does not exist on the `shadowRoot` object + expect((elm.shadowRoot as any).delegatesFocus).toBe(true); + }); + + it('does not set delegatesFocus when shadow is set to "true"', async () => { + expect(customElements.get('custom-elements-no-delegates-focus')).toBeDefined(); + + const elm: Element = app.querySelector('custom-elements-no-delegates-focus'); + + expect(elm.shadowRoot).toBeDefined(); + // as of TypeScript 4.3, `delegatesFocus` does not exist on the `shadowRoot` object + expect((elm.shadowRoot as any).delegatesFocus).toBe(false); + }); +}); diff --git a/test/karma/test-app/custom-elements-delegates-focus/shared-delegates-focus.css b/test/karma/test-app/custom-elements-delegates-focus/shared-delegates-focus.css new file mode 100644 index 00000000000..378f4956840 --- /dev/null +++ b/test/karma/test-app/custom-elements-delegates-focus/shared-delegates-focus.css @@ -0,0 +1,15 @@ +:host { + display: block; + border: 5px solid red; + padding: 10px; + margin: 10px; +} + +:host(:focus) { + border: 5px solid green; +} + +input { + display: block; + width: 100%; +} diff --git a/test/karma/test-app/custom-elements-delegates-focus/webpack.config.js b/test/karma/test-app/custom-elements-delegates-focus/webpack.config.js new file mode 100644 index 00000000000..3bd1b040f3a --- /dev/null +++ b/test/karma/test-app/custom-elements-delegates-focus/webpack.config.js @@ -0,0 +1,19 @@ +const path = require('path'); + +module.exports = { + entry: path.resolve(__dirname, 'index.esm.js'), + output: { + path: path.resolve(__dirname, '..', '..', 'www', 'custom-elements-delegates-focus'), + publicPath: '/custom-elements-delegates-focus/', + }, + mode: 'production', + optimization: { + minimize: false, + }, + resolve: { + alias: { + '@stencil/core/internal/client': '../../../internal/client', + '@stencil/core/internal/app-data': '../app-data', + }, + }, +};