diff --git a/packages/glimmer-runtime/index.ts b/packages/glimmer-runtime/index.ts index d32d688528..a5f80899fa 100644 --- a/packages/glimmer-runtime/index.ts +++ b/packages/glimmer-runtime/index.ts @@ -105,6 +105,10 @@ export { default as WithDynamicVarsSyntax } from './lib/syntax/builtins/with-dynamic-vars'; +export { + default as InElementSyntax +} from './lib/syntax/builtins/in-element'; + export { PublicVM as VM, UpdatingVM, RenderResult } from './lib/vm'; export { SafeString, isSafeString } from './lib/upsert'; diff --git a/packages/glimmer-runtime/lib/builder.ts b/packages/glimmer-runtime/lib/builder.ts index 972fcc31c7..6a918ed0f6 100644 --- a/packages/glimmer-runtime/lib/builder.ts +++ b/packages/glimmer-runtime/lib/builder.ts @@ -27,11 +27,7 @@ export interface LastNode { } class First { - private node: Node; - - constructor(node) { - this.node = node; - } + constructor(private node: Node) { } firstNode(): Node { return this.node; @@ -39,11 +35,7 @@ class First { } class Last { - private node: Node; - - constructor(node) { - this.node = node; - } + constructor(private node: Node) { } lastNode(): Node { return this.node; @@ -127,7 +119,7 @@ export class ElementStack implements Cursor { return this.blockStack.current; } - private popElement() { + popElement() { let { elementStack, nextSiblingStack } = this; let topElement = elementStack.pop(); @@ -151,12 +143,15 @@ export class ElementStack implements Cursor { return tracker; } - private pushBlockTracker(tracker: Tracker) { + private pushBlockTracker(tracker: Tracker, isRemote = false) { let current = this.blockStack.current; if (current !== null) { current.newDestroyable(tracker); - current.newBounds(tracker); + + if (!isRemote) { + current.newBounds(tracker); + } } this.blockStack.push(tracker); @@ -193,16 +188,35 @@ export class ElementStack implements Cursor { flushElement() { let parent = this.element; - let element = this.element = this.constructing; + let element = this.constructing; this.dom.insertBefore(parent, element, this.nextSibling); this.constructing = null; this.operations = null; - this.nextSibling = null; + + this.pushElement(element); + this.blockStack.current.openElement(element); + } + + pushRemoteElement(element: Simple.Element) { + this.pushElement(element); + + let tracker = new RemoteBlockTracker(element); + this.pushBlockTracker(tracker, true); + } + + popRemoteElement() { + this.popBlock(); + this.popElement(); + } + + private pushElement(element: Simple.Element) { + this.element = element; this.elementStack.push(element); + + this.nextSibling = null; this.nextSiblingStack.push(null); - this.blockStack.current.openElement(element); } newDestroyable(d: Destroyable) { @@ -331,6 +345,14 @@ export class SimpleBlockTracker implements Tracker { } } +class RemoteBlockTracker extends SimpleBlockTracker { + destroy() { + super.destroy(); + + clear(this); + } +} + export interface UpdatableTracker extends Tracker { reset(env: Environment); } diff --git a/packages/glimmer-runtime/lib/compiled/opcodes/builder.ts b/packages/glimmer-runtime/lib/compiled/opcodes/builder.ts index 17f1eb31d4..9a6a255c4c 100644 --- a/packages/glimmer-runtime/lib/compiled/opcodes/builder.ts +++ b/packages/glimmer-runtime/lib/compiled/opcodes/builder.ts @@ -223,6 +223,18 @@ export abstract class BasicOpcodeBuilder extends StatementCompilationBufferProxy // vm + pushRemoteElement() { + this.append(new dom.PushRemoteElementOpcode()); + } + + popRemoteElement() { + this.append(new dom.PopRemoteElementOpcode()); + } + + popElement() { + this.append(new dom.PopElementOpcode()); + } + label(name: string) { this.append(this.labelFor(name)); } diff --git a/packages/glimmer-runtime/lib/compiled/opcodes/dom.ts b/packages/glimmer-runtime/lib/compiled/opcodes/dom.ts index cd0570de66..2dc603b005 100644 --- a/packages/glimmer-runtime/lib/compiled/opcodes/dom.ts +++ b/packages/glimmer-runtime/lib/compiled/opcodes/dom.ts @@ -21,6 +21,7 @@ import { ValueReference } from '../../compiled/expressions/value'; import { CompiledArgs, EvaluatedArgs } from '../../compiled/expressions/args'; import { AttributeManager } from '../../dom/attribute-managers'; import { ElementOperations } from '../../builder'; +import { Assert } from './vm'; export class TextOpcode extends Opcode { public type = "text"; @@ -62,6 +63,38 @@ export class OpenPrimitiveElementOpcode extends Opcode { } } +export class PushRemoteElementOpcode extends Opcode { + public type = "push-remote-element"; + + evaluate(vm: VM) { + let reference = vm.frame.getOperand(); + let cache = isConstReference(reference) ? undefined : new ReferenceCache(reference); + let element = cache ? cache.peek() : reference.value(); + + vm.stack().pushRemoteElement(element); + + if (cache) { + vm.updateWith(new Assert(cache)); + } + } + + toJSON(): OpcodeJSON { + return { + guid: this._guid, + type: this.type, + args: ['$OPERAND'] + }; + } +} + +export class PopRemoteElementOpcode extends Opcode { + public type = "pop-remote-element"; + + evaluate(vm: VM) { + vm.stack().popRemoteElement(); + } +} + export class OpenComponentElementOpcode extends Opcode { public type = "open-component-element"; @@ -345,6 +378,20 @@ export class CloseElementOpcode extends Opcode { } } +export class PopElementOpcode extends Opcode { + public type = "pop-element"; + + evaluate(vm: VM) { + vm.stack().popElement(); + } +} + +export interface StaticAttrOptions { + namespace: string; + name: string; + value: string; +} + export class StaticAttrOpcode extends Opcode { public type = "static-attr"; diff --git a/packages/glimmer-runtime/lib/syntax/builtins/in-element.ts b/packages/glimmer-runtime/lib/syntax/builtins/in-element.ts new file mode 100644 index 0000000000..2a365f3c52 --- /dev/null +++ b/packages/glimmer-runtime/lib/syntax/builtins/in-element.ts @@ -0,0 +1,34 @@ +import { + Statement as StatementSyntax +} from '../../syntax'; + +import OpcodeBuilderDSL from '../../compiled/opcodes/builder'; +import * as Syntax from '../core'; +import Environment from '../../environment'; + +export default class InElementSyntax extends StatementSyntax { + type = "in-element-statement"; + + public args: Syntax.Args; + public templates: Syntax.Templates; + public isStatic = false; + + constructor({ args, templates }: { args: Syntax.Args, templates: Syntax.Templates }) { + super(); + this.args = args; + this.templates = templates; + } + + compile(dsl: OpcodeBuilderDSL, env: Environment) { + let { args, templates } = this; + + dsl.block({ templates, args }, (dsl, BEGIN, END) => { + dsl.putArgs(args); + dsl.test('simple'); + dsl.jumpUnless(END); + dsl.pushRemoteElement(); + dsl.evaluate('default'); + dsl.popRemoteElement(); + }); + } +} diff --git a/packages/glimmer-runtime/tests/ember-component-test.ts b/packages/glimmer-runtime/tests/ember-component-test.ts index 7b5382dc5a..3db25d2514 100644 --- a/packages/glimmer-runtime/tests/ember-component-test.ts +++ b/packages/glimmer-runtime/tests/ember-component-test.ts @@ -27,7 +27,7 @@ import { equalTokens, stripTight } from "glimmer-test-helpers"; import { CLASS_META, UpdatableReference, setProperty as set } from 'glimmer-object-reference'; -class EmberishRootView extends EmberObject { +export class EmberishRootView extends EmberObject { private parent: Element; protected _result: RenderResult; protected template: Template<{}>; @@ -73,7 +73,7 @@ function module(name: string) { module("Components - generic - props"); -function appendViewFor(template: string, context: Object = {}) { +export function appendViewFor(template: string, context: Object = {}) { class MyRootView extends EmberishRootView { protected env = env; protected template = env.compile(template); @@ -90,7 +90,7 @@ function appendViewFor(template: string, context: Object = {}) { return view; } -function assertAppended(content: string) { +export function assertAppended(content: string) { equalTokens((document.querySelector('#qunit-fixture')), content); } @@ -147,12 +147,12 @@ function assertEmberishElement(...args) { equalsElement(view.element, tagName, fullAttrs, contents); } -function assertElementIsEmberishElement(element: Element, tagName: string, attrs: Object, contents: string); -function assertElementIsEmberishElement(element: Element, tagName: string, attrs: Object); -function assertElementIsEmberishElement(element: Element, tagName: string, contents: string); -function assertElementIsEmberishElement(element: Element, tagName: string); +export function assertElementIsEmberishElement(element: Element, tagName: string, attrs: Object, contents: string); +export function assertElementIsEmberishElement(element: Element, tagName: string, attrs: Object); +export function assertElementIsEmberishElement(element: Element, tagName: string, contents: string); +export function assertElementIsEmberishElement(element: Element, tagName: string); -function assertElementIsEmberishElement(element: Element, ...args) { +export function assertElementIsEmberishElement(element: Element, ...args) { let tagName, attrs, contents; if (args.length === 2) { if (typeof args[1] === 'string') [tagName, attrs, contents] = [args[0], {}, args[1]]; diff --git a/packages/glimmer-runtime/tests/in-element-test.ts b/packages/glimmer-runtime/tests/in-element-test.ts new file mode 100644 index 0000000000..e03513ce93 --- /dev/null +++ b/packages/glimmer-runtime/tests/in-element-test.ts @@ -0,0 +1,401 @@ +import { + TestEnvironment, + stripTight, + equalsElement, + EmberishCurlyComponent + } from "glimmer-test-helpers"; + +import { + assertAppended, + assertElementIsEmberishElement, + EmberishRootView +} from './ember-component-test'; + +import { CLASS_META, setProperty as set } from 'glimmer-object-reference'; + +let view, env; + +function rerender() { + view.rerender(); +} + +function appendViewFor(template: string, context: Object = {}) { + class MyRootView extends EmberishRootView { + protected env = env; + protected template = env.compile(template); + } + + view = new MyRootView(context); + MyRootView[CLASS_META].seal(); + + env.begin(); + view.appendTo('#qunit-fixture'); + env.commit(); + + return view; +} + +QUnit.module('Targeting a remote element', { + setup() { + env = new TestEnvironment(); + } +}); + +QUnit.test('basic', function(assert) { + let externalElement = document.createElement('div'); + + appendViewFor( + stripTight`{{#-in-element externalElement}}[{{foo}}]{{/-in-element}}`, + { externalElement, foo: 'Yippie!' } + ); + + equalsElement(externalElement, 'div', {}, stripTight`[Yippie!]`); + + set(view, 'foo', 'Double Yips!'); + rerender(); + + equalsElement(externalElement, 'div', {}, stripTight`[Double Yips!]`); + + set(view, 'foo', 'Yippie!'); + rerender(); + + equalsElement(externalElement, 'div', {}, stripTight`[Yippie!]`); +}); + +QUnit.test('changing to falsey', function(assert) { + let first = document.createElement('div'); + let second = document.createElement('div'); + + appendViewFor( + stripTight` + |{{foo}}| + {{#-in-element first}}[{{foo}}]{{/-in-element}} + {{#-in-element second}}[{{foo}}]{{/-in-element}} + `, + { first, second: null, foo: 'Yippie!' } + ); + + equalsElement(first, 'div', {}, `[Yippie!]`); + equalsElement(second, 'div', {}, ``); + assertAppended('|Yippie!|'); + + set(view, 'foo', 'Double Yips!'); + rerender(); + + equalsElement(first, 'div', {}, `[Double Yips!]`); + equalsElement(second, 'div', {}, ``); + assertAppended('|Double Yips!|'); + + set(view, 'first', null); + rerender(); + + equalsElement(first, 'div', {}, ``); + equalsElement(second, 'div', {}, ``); + assertAppended('|Double Yips!|'); + + set(view, 'second', second); + rerender(); + + equalsElement(first, 'div', {}, ``); + equalsElement(second, 'div', {}, `[Double Yips!]`); + assertAppended('|Double Yips!|'); + + set(view, 'foo', 'Yippie!'); + rerender(); + + equalsElement(first, 'div', {}, ``); + equalsElement(second, 'div', {}, `[Yippie!]`); + assertAppended('|Yippie!|'); + + set(view, 'first', first); + set(view, 'second', null); + rerender(); + + equalsElement(first, 'div', {}, `[Yippie!]`); + equalsElement(second, 'div', {}, ``); + assertAppended('|Yippie!|'); +}); + +QUnit.test('with pre-existing content', function(assert) { + let externalElement = document.createElement('div'); + let initialContent = externalElement.innerHTML = '

Hello there!

'; + + appendViewFor( + stripTight`{{#-in-element externalElement}}[{{foo}}]{{/-in-element}}`, + { externalElement, foo: 'Yippie!' } + ); + + assertAppended(''); + equalsElement(externalElement, 'div', {}, `${initialContent}[Yippie!]`); + + set(view, 'foo', 'Double Yips!'); + rerender(); + + assertAppended(''); + equalsElement(externalElement, 'div', {}, `${initialContent}[Double Yips!]`); + + set(view, 'foo', 'Yippie!'); + rerender(); + + assertAppended(''); + equalsElement(externalElement, 'div', {}, `${initialContent}[Yippie!]`); + + set(view, 'externalElement', null); + rerender(); + + assertAppended(''); + equalsElement(externalElement, 'div', {}, `${initialContent}`); + + set(view, 'externalElement', externalElement); + rerender(); + + assertAppended(''); + equalsElement(externalElement, 'div', {}, `${initialContent}[Yippie!]`); +}); + +QUnit.test('updating remote element', function(assert) { + let first = document.createElement('div'); + let second = document.createElement('div'); + + appendViewFor( + stripTight`{{#-in-element targetElement}}[{{foo}}]{{/-in-element}}`, + { + targetElement: first, + foo: 'Yippie!' + } + ); + + equalsElement(first, 'div', {}, `[Yippie!]`); + equalsElement(second, 'div', {}, ``); + + set(view, 'foo', 'Double Yips!'); + rerender(); + + equalsElement(first, 'div', {}, `[Double Yips!]`); + equalsElement(second, 'div', {}, ``); + + set(view, 'foo', 'Yippie!'); + rerender(); + + equalsElement(first, 'div', {}, `[Yippie!]`); + equalsElement(second, 'div', {}, ``); + + set(view, 'targetElement', second); + rerender(); + + equalsElement(first, 'div', {}, ``); + equalsElement(second, 'div', {}, `[Yippie!]`); + + set(view, 'foo', 'Double Yips!'); + rerender(); + + equalsElement(first, 'div', {}, ``); + equalsElement(second, 'div', {}, `[Double Yips!]`); + + set(view, 'foo', 'Yippie!'); + rerender(); + + equalsElement(first, 'div', {}, ``); + equalsElement(second, 'div', {}, `[Yippie!]`); +}); + +QUnit.test('inside an `{{if}}', function(assert) { + let first = document.createElement('div'); + let second = document.createElement('div'); + + appendViewFor( + stripTight` + {{#if showFirst}} + {{#-in-element first}}[{{foo}}]{{/-in-element}} + {{/if}} + {{#if showSecond}} + {{#-in-element second}}[{{foo}}]{{/-in-element}} + {{/if}} + `, + { + first, + second, + showFirst: true, + showSecond: false, + foo: 'Yippie!' + } + ); + + equalsElement(first, 'div', {}, stripTight`[Yippie!]`); + equalsElement(second, 'div', {}, stripTight``); + + set(view, 'showFirst', false); + rerender(); + + equalsElement(first, 'div', {}, stripTight``); + equalsElement(second, 'div', {}, stripTight``); + + set(view, 'showSecond', true); + rerender(); + + equalsElement(first, 'div', {}, stripTight``); + equalsElement(second, 'div', {}, stripTight`[Yippie!]`); + + set(view, 'foo', 'Double Yips!'); + rerender(); + + equalsElement(first, 'div', {}, stripTight``); + equalsElement(second, 'div', {}, stripTight`[Double Yips!]`); + + set(view, 'showSecond', false); + rerender(); + + equalsElement(first, 'div', {}, stripTight``); + equalsElement(second, 'div', {}, stripTight``); + + set(view, 'showFirst', true); + rerender(); + + equalsElement(first, 'div', {}, stripTight`[Double Yips!]`); + equalsElement(second, 'div', {}, stripTight``); + + set(view, 'foo', 'Yippie!'); + rerender(); + + equalsElement(first, 'div', {}, stripTight`[Yippie!]`); + equalsElement(second, 'div', {}, stripTight``); +}); + +QUnit.test('multiple', function(assert) { + let firstElement = document.createElement('div'); + let secondElement = document.createElement('div'); + + appendViewFor( + stripTight` + {{#-in-element firstElement}} + [{{foo}}] + {{/-in-element}} + {{#-in-element secondElement}} + [{{bar}}] + {{/-in-element}} + `, + { + firstElement, + secondElement, + foo: 'Hello!', + bar: 'World!' + } + ); + + equalsElement(firstElement, 'div', {}, stripTight`[Hello!]`); + equalsElement(secondElement, 'div', {}, stripTight`[World!]`); + + set(view, 'foo', 'GoodBye!'); + rerender(); + + equalsElement(firstElement, 'div', {}, stripTight`[GoodBye!]`); + equalsElement(secondElement, 'div', {}, stripTight`[World!]`); + + set(view, 'bar', 'Folks!'); + rerender(); + + equalsElement(firstElement, 'div', {}, stripTight`[GoodBye!]`); + equalsElement(secondElement, 'div', {}, stripTight`[Folks!]`); + + set(view, 'bar', 'World!'); + rerender(); + + equalsElement(firstElement, 'div', {}, stripTight`[GoodBye!]`); + equalsElement(secondElement, 'div', {}, stripTight`[World!]`); + + set(view, 'foo', 'Hello!'); + rerender(); + + equalsElement(firstElement, 'div', {}, stripTight`[Hello!]`); + equalsElement(secondElement, 'div', {}, stripTight`[World!]`); +}); + +QUnit.test('nesting', function(assert) { + let firstElement = document.createElement('div'); + let secondElement = document.createElement('div'); + + appendViewFor( + stripTight` + {{#-in-element firstElement}} + [{{foo}}] + {{#-in-element secondElement}} + [{{bar}}] + {{/-in-element}} + {{/-in-element}} + `, + { + firstElement, + secondElement, + foo: 'Hello!', + bar: 'World!' + } + ); + + equalsElement(firstElement, 'div', {}, stripTight`[Hello!]`); + equalsElement(secondElement, 'div', {}, stripTight`[World!]`); + + set(view, 'foo', 'GoodBye!'); + rerender(); + + equalsElement(firstElement, 'div', {}, stripTight`[GoodBye!]`); + equalsElement(secondElement, 'div', {}, stripTight`[World!]`); + + set(view, 'bar', 'Folks!'); + rerender(); + + equalsElement(firstElement, 'div', {}, stripTight`[GoodBye!]`); + equalsElement(secondElement, 'div', {}, stripTight`[Folks!]`); + + set(view, 'bar', 'World!'); + rerender(); + + equalsElement(firstElement, 'div', {}, stripTight`[GoodBye!]`); + equalsElement(secondElement, 'div', {}, stripTight`[World!]`); + + set(view, 'foo', 'Hello!'); + rerender(); + + equalsElement(firstElement, 'div', {}, stripTight`[Hello!]`); + equalsElement(secondElement, 'div', {}, stripTight`[World!]`); +}); + +QUnit.test('components are destroyed', function(assert) { + let destroyed = 0; + let DestroyMeComponent = EmberishCurlyComponent.extend({ + destroy() { + this._super(); + destroyed++; + } + }); + + env.registerEmberishCurlyComponent('destroy-me', DestroyMeComponent as any, 'destroy me!'); + + let externalElement = document.createElement('div'); + + appendViewFor( + stripTight` + {{#if showExternal}} + {{#-in-element externalElement}}[{{destroy-me}}]{{/-in-element}} + {{/if}} + `, + { + externalElement, + showExternal: false, + } + ); + + equalsElement(externalElement, 'div', {}, stripTight``); + assert.equal(destroyed, 0, 'component was destroyed'); + + set(view, 'showExternal', true); + rerender(); + + assertElementIsEmberishElement(externalElement.firstElementChild, 'div', { }, 'destroy me!'); + assert.equal(destroyed, 0, 'component was destroyed'); + + set(view, 'showExternal', false); + rerender(); + + equalsElement(externalElement, 'div', {}, stripTight``); + assert.equal(destroyed, 1, 'component was destroyed'); +}); diff --git a/packages/glimmer-test-helpers/lib/environment.ts b/packages/glimmer-test-helpers/lib/environment.ts index 69a93c6fac..9569713205 100644 --- a/packages/glimmer-test-helpers/lib/environment.ts +++ b/packages/glimmer-test-helpers/lib/environment.ts @@ -42,6 +42,7 @@ import { ArgsSyntax, OptimizedAppend, WithDynamicVarsSyntax, + InElementSyntax, // References ValueReference, @@ -788,6 +789,8 @@ export class TestEnvironment extends Environment { return new RenderInverseIdentitySyntax({ args, templates }); case '-with-dynamic-vars': return new WithDynamicVarsSyntax({ args, templates }); + case '-in-element': + return new InElementSyntax({ args, templates }); } }