diff --git a/packages/@ember/-internals/glimmer/lib/syntax/in-element.ts b/packages/@ember/-internals/glimmer/lib/syntax/in-element.ts new file mode 100644 index 00000000000..d0d25fad539 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/syntax/in-element.ts @@ -0,0 +1,38 @@ +/** + @module ember + */ + +/** + The `in-element` helper renders its block content outside of the regular flow, + into a DOM element given by its `destinationElement` positional argument. + + Common use cases - often referred to as "portals" or "wormholes" - are rendering + dropdowns, modals or tooltips close to the root of the page to bypass CSS overflow + rules, or to render content to parts of the page that are outside of the control + of the Ember app itself (e.g. embedded into a static or server rendered HTML page). + + ```handlebars + {{#in-element this.destinationElement}} +
Some content
+ {{/in-element}} + ``` + + ### Arguments + + `{{in-element}}` requires a single positional argument: + + - `destinationElement` -- the DOM element to render into. It must exist at the time + of rendering. + + It also supports an optional named argument: + + - `insertBefore` -- by default the DOM element's content is replaced when used as + `destinationElement`. Passing `null` changes the behaviour to appended at the end + of any existing content. Any other value than `null` is currently not supported. + + ``` + + @method in-element + @for Ember.Templates.helpers + @public + */ diff --git a/packages/@ember/-internals/glimmer/tests/integration/syntax/in-element-test.js b/packages/@ember/-internals/glimmer/tests/integration/syntax/deprecated-in-element-test.js similarity index 86% rename from packages/@ember/-internals/glimmer/tests/integration/syntax/in-element-test.js rename to packages/@ember/-internals/glimmer/tests/integration/syntax/deprecated-in-element-test.js index ee2f67be7c3..3e1b7019d4a 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/syntax/in-element-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/syntax/deprecated-in-element-test.js @@ -1,12 +1,14 @@ import { moduleFor, RenderingTestCase, strip, equalTokens, runTask } from 'internal-test-helpers'; - import { Component } from '@ember/-internals/glimmer'; import { set } from '@ember/-internals/metal'; +import { EMBER_GLIMMER_IN_ELEMENT } from '@ember/canary-features'; + +const deprecationMessage = /The use of the private `{{-in-element}}` is deprecated, please refactor to the public `{{in-element}}`/; moduleFor( '{{-in-element}}', class extends RenderingTestCase { - ['@test using {{#in-element whatever}} asserts']() { + ['@feature(!EMBER_GLIMMER_IN_ELEMENT) using {{#in-element whatever}} asserts']() { // the in-element keyword is not yet public API this test should be removed // once https://github.com/emberjs/rfcs/pull/287 lands and is enabled @@ -17,6 +19,10 @@ moduleFor( } ['@test allows rendering into an external element']() { + if (EMBER_GLIMMER_IN_ELEMENT) { + expectDeprecation(deprecationMessage); + } + let someElement = document.createElement('div'); this.render( @@ -47,7 +53,11 @@ moduleFor( equalTokens(someElement, 'Whoop!'); } - ['@test it appends to the extenal element by default']() { + ['@test it appends to the external element by default']() { + if (EMBER_GLIMMER_IN_ELEMENT) { + expectDeprecation(deprecationMessage); + } + let someElement = document.createElement('div'); someElement.appendChild(document.createTextNode('foo ')); @@ -80,6 +90,10 @@ moduleFor( } ['@test allows appending to the external element with insertBefore=null']() { + if (EMBER_GLIMMER_IN_ELEMENT) { + expectDeprecation(deprecationMessage); + } + let someElement = document.createElement('div'); someElement.appendChild(document.createTextNode('foo ')); @@ -112,6 +126,10 @@ moduleFor( } ['@test allows clearing the external element with insertBefore=undefined']() { + if (EMBER_GLIMMER_IN_ELEMENT) { + expectDeprecation(deprecationMessage); + } + let someElement = document.createElement('div'); someElement.appendChild(document.createTextNode('foo ')); @@ -144,6 +162,10 @@ moduleFor( } ['@test does not allow insertBefore=non-null-value']() { + if (EMBER_GLIMMER_IN_ELEMENT) { + expectDeprecation(deprecationMessage); + } + let someElement = document.createElement('div'); expectAssertion(() => { @@ -162,6 +184,10 @@ moduleFor( } ['@test components are cleaned up properly'](assert) { + if (EMBER_GLIMMER_IN_ELEMENT) { + expectDeprecation(deprecationMessage); + } + let hooks = []; let someElement = document.createElement('div'); @@ -229,7 +255,11 @@ moduleFor( assert.deepEqual(hooks, ['didInsertElement', 'willDestroyElement']); } - ['@test appending to the root element should not cause double clearing are cleaned up properly']() { + ['@test appending to the root element should not cause double clearing']() { + if (EMBER_GLIMMER_IN_ELEMENT) { + expectDeprecation(deprecationMessage); + } + this.render( strip` Before diff --git a/packages/@ember/-internals/glimmer/tests/integration/syntax/public-in-element-test.js b/packages/@ember/-internals/glimmer/tests/integration/syntax/public-in-element-test.js new file mode 100644 index 00000000000..9d653c287e7 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/syntax/public-in-element-test.js @@ -0,0 +1,216 @@ +import { moduleFor, RenderingTestCase, strip, equalTokens, runTask } from 'internal-test-helpers'; + +import { Component } from '@ember/-internals/glimmer'; +import { set } from '@ember/-internals/metal'; + +moduleFor( + '{{in-element}}', + class extends RenderingTestCase { + ['@feature(EMBER_GLIMMER_IN_ELEMENT) allows rendering into an external element']() { + let someElement = document.createElement('div'); + + this.render( + strip` + {{#in-element someElement}} + {{text}} + {{/in-element}} + `, + { + someElement, + text: 'Whoop!', + } + ); + + equalTokens(this.element, ''); + equalTokens(someElement, 'Whoop!'); + + this.assertStableRerender(); + + runTask(() => set(this.context, 'text', 'Huzzah!!')); + + equalTokens(this.element, ''); + equalTokens(someElement, 'Huzzah!!'); + + runTask(() => set(this.context, 'text', 'Whoop!')); + + equalTokens(this.element, ''); + equalTokens(someElement, 'Whoop!'); + } + + ["@feature(EMBER_GLIMMER_IN_ELEMENT) it replaces the external element's content by default"]() { + let someElement = document.createElement('div'); + someElement.appendChild(document.createTextNode('foo ')); + + this.render( + strip` + {{#in-element someElement insertBefore=undefined}} + {{text}} + {{/in-element}} + `, + { + someElement, + text: 'bar', + } + ); + + equalTokens(this.element, ''); + equalTokens(someElement, 'bar'); + + this.assertStableRerender(); + + runTask(() => set(this.context, 'text', 'bar!!')); + + equalTokens(this.element, ''); + equalTokens(someElement, 'bar!!'); + + runTask(() => set(this.context, 'text', 'bar')); + + equalTokens(this.element, ''); + equalTokens(someElement, 'bar'); + } + + ['@feature(EMBER_GLIMMER_IN_ELEMENT) allows appending to the external element with insertBefore=null']() { + let someElement = document.createElement('div'); + someElement.appendChild(document.createTextNode('foo ')); + + this.render( + strip` + {{#in-element someElement insertBefore=null}} + {{text}} + {{/in-element}} + `, + { + someElement, + text: 'bar', + } + ); + + equalTokens(this.element, ''); + equalTokens(someElement, 'foo bar'); + + this.assertStableRerender(); + + runTask(() => set(this.context, 'text', 'bar!!')); + + equalTokens(this.element, ''); + equalTokens(someElement, 'foo bar!!'); + + runTask(() => set(this.context, 'text', 'bar')); + + equalTokens(this.element, ''); + equalTokens(someElement, 'foo bar'); + } + + ['@feature(EMBER_GLIMMER_IN_ELEMENT) does not allow insertBefore=non-null-value']() { + let someElement = document.createElement('div'); + + expectAssertion(() => { + this.render( + strip` + {{#in-element someElement insertBefore=".foo"}} + {{text}} + {{/in-element}} + `, + { + someElement, + text: 'Whoop!', + } + ); + }, /Can only pass null to insertBefore in in-element, received:/); + } + + ['@feature(EMBER_GLIMMER_IN_ELEMENT) components are cleaned up properly'](assert) { + let hooks = []; + + let someElement = document.createElement('div'); + + this.registerComponent('modal-display', { + ComponentClass: Component.extend({ + didInsertElement() { + hooks.push('didInsertElement'); + }, + + willDestroyElement() { + hooks.push('willDestroyElement'); + }, + }), + + template: `{{text}}`, + }); + + this.render( + strip` + {{#if showModal}} + {{#in-element someElement}} + {{modal-display text=text}} + {{/in-element}} + {{/if}} + `, + { + someElement, + text: 'Whoop!', + showModal: false, + } + ); + + equalTokens(this.element, ''); + equalTokens(someElement, ''); + + this.assertStableRerender(); + + runTask(() => set(this.context, 'showModal', true)); + + equalTokens(this.element, ''); + this.assertComponentElement(someElement.firstChild, { + content: 'Whoop!', + }); + + runTask(() => set(this.context, 'text', 'Huzzah!')); + + equalTokens(this.element, ''); + this.assertComponentElement(someElement.firstChild, { + content: 'Huzzah!', + }); + + runTask(() => set(this.context, 'text', 'Whoop!')); + + equalTokens(this.element, ''); + this.assertComponentElement(someElement.firstChild, { + content: 'Whoop!', + }); + + runTask(() => set(this.context, 'showModal', false)); + + equalTokens(this.element, ''); + equalTokens(someElement, ''); + + assert.deepEqual(hooks, ['didInsertElement', 'willDestroyElement']); + } + + ['@feature(EMBER_GLIMMER_IN_ELEMENT) appending to the root element should not cause double clearing']() { + this.render( + strip` + Before + {{#in-element this.rootElement insertBefore=null}} + {{this.text}} + {{/in-element}} + After + `, + { + rootElement: this.element, + text: 'Whoop!', + } + ); + + equalTokens(this.element, 'BeforeWhoop!After'); + + this.assertStableRerender(); + + runTask(() => set(this.context, 'text', 'Huzzah!')); + + equalTokens(this.element, 'BeforeHuzzah!After'); + + // teardown happens in afterEach and should not cause double-clearing error + } + } +); diff --git a/packages/@ember/canary-features/index.ts b/packages/@ember/canary-features/index.ts index 322e1f96286..0ee835b37d9 100644 --- a/packages/@ember/canary-features/index.ts +++ b/packages/@ember/canary-features/index.ts @@ -20,6 +20,7 @@ export const DEFAULT_FEATURES = { EMBER_CUSTOM_COMPONENT_ARG_PROXY: true, EMBER_GLIMMER_SET_COMPONENT_TEMPLATE: true, EMBER_ROUTING_MODEL_ARG: true, + EMBER_GLIMMER_IN_ELEMENT: null, }; /** @@ -79,3 +80,4 @@ export const EMBER_GLIMMER_SET_COMPONENT_TEMPLATE = featureValue( FEATURES.EMBER_GLIMMER_SET_COMPONENT_TEMPLATE ); export const EMBER_ROUTING_MODEL_ARG = featureValue(FEATURES.EMBER_ROUTING_MODEL_ARG); +export const EMBER_GLIMMER_IN_ELEMENT = featureValue(FEATURES.EMBER_GLIMMER_IN_ELEMENT); diff --git a/packages/ember-template-compiler/lib/plugins/transform-in-element.ts b/packages/ember-template-compiler/lib/plugins/transform-in-element.ts index e90ada77221..8ccf5d30e37 100644 --- a/packages/ember-template-compiler/lib/plugins/transform-in-element.ts +++ b/packages/ember-template-compiler/lib/plugins/transform-in-element.ts @@ -1,5 +1,6 @@ import { StaticTemplateMeta } from '@ember/-internals/views'; -import { assert } from '@ember/debug'; +import { EMBER_GLIMMER_IN_ELEMENT } from '@ember/canary-features'; +import { assert, deprecate } from '@ember/debug'; import { AST, ASTPlugin, ASTPluginEnvironment } from '@glimmer/syntax'; import calculateLocationDisplay from '../system/calculate-location-display'; import { isPath } from './utils'; @@ -9,17 +10,10 @@ import { isPath } from './utils'; */ /** - glimmer-vm has made the `in-element` API public from its perspective (in - https://github.com/glimmerjs/glimmer-vm/pull/619) so in glimmer-vm the - correct keyword to use is `in-element`, however Ember is still working through - its form of `in-element` (see https://github.com/emberjs/rfcs/pull/287). + A Glimmer2 AST transformation that handles the public `{{in-element}}` as per RFC287, and deprecates but still + continues support for the private `{{-in-element}}`. - There are enough usages of the pre-existing private API (`{{-in-element`) in - the wild that we need to transform `{{-in-element` into `{{in-element` during - template transpilation, but since RFC#287 is not landed and enabled by default we _also_ need - to prevent folks from starting to use `{{in-element` "for realz". - - Tranforms: + Transforms: ```handlebars {{#-in-element someElement}} @@ -35,16 +29,18 @@ import { isPath } from './utils'; {{/in-element}} ``` - And issues a build time assertion for: + And issues a deprecation message. + + Issues a build time assertion for: ```handlebars - {{#in-element someElement}} + {{#in-element someElement insertBefore="some-none-null-value"}} {{modal-display text=text}} {{/in-element}} ``` @private - @class TransformHasBlockSyntax + @class TransformInElement */ export default function transformInElement(env: ASTPluginEnvironment): ASTPlugin { let { moduleName } = env.meta as StaticTemplateMeta; @@ -59,8 +55,33 @@ export default function transformInElement(env: ASTPluginEnvironment): ASTPlugin if (!isPath(node.path)) return; if (node.path.original === 'in-element') { - assert(assertMessage(moduleName, node)); + if (EMBER_GLIMMER_IN_ELEMENT) { + node.hash.pairs.forEach(pair => { + if (pair.key === 'insertBefore') { + assert( + `Can only pass null to insertBefore in in-element, received: ${JSON.stringify( + pair.value + )}`, + pair.value.type === 'NullLiteral' || pair.value.type === 'UndefinedLiteral' + ); + } + }); + } else { + assert(assertMessage(moduleName, node)); + } } else if (node.path.original === '-in-element') { + if (EMBER_GLIMMER_IN_ELEMENT) { + let sourceInformation = calculateLocationDisplay(moduleName, node.loc); + deprecate( + `The use of the private \`{{-in-element}}\` is deprecated, please refactor to the public \`{{in-element}}\`. ${sourceInformation}`, + false, + { + id: 'glimmer.private-in-element', + until: '4.0.0', + } + ); + } + node.path.original = 'in-element'; node.path.parts = ['in-element'];