From 46f881be3be35b58c6502eff127fba1d767f74c6 Mon Sep 17 00:00:00 2001 From: Krystan HuffMenne Date: Wed, 6 May 2020 14:33:38 -0700 Subject: [PATCH 1/2] Make integration testing AST plugins possible --- .../@glimmer/integration-tests/lib/compile.ts | 5 ++-- .../lib/modes/aot/delegate.ts | 26 +++++++++++++++--- .../lib/modes/jit/delegate.ts | 20 ++++++++++++-- .../integration-tests/lib/modes/jit/render.ts | 6 +++-- .../lib/modes/rehydration/delegate.ts | 27 +++++++++++++++++-- .../integration-tests/lib/render-delegate.ts | 2 ++ .../integration-tests/lib/render-test.ts | 15 +++++++---- .../@glimmer/integration-tests/package.json | 1 + 8 files changed, 86 insertions(+), 16 deletions(-) diff --git a/packages/@glimmer/integration-tests/lib/compile.ts b/packages/@glimmer/integration-tests/lib/compile.ts index 9e9dcceca8..d99b05dde9 100644 --- a/packages/@glimmer/integration-tests/lib/compile.ts +++ b/packages/@glimmer/integration-tests/lib/compile.ts @@ -21,9 +21,10 @@ export const DEFAULT_TEST_META: AnnotatedModuleLocator = Object.freeze({ // out of the test environment. export function preprocess( template: string, - meta?: AnnotatedModuleLocator + meta?: AnnotatedModuleLocator, + options?: PrecompileOptions ): Template { - let wrapper = JSON.parse(rawPrecompile(template)); + let wrapper = JSON.parse(rawPrecompile(template, options)); let factory = templateFactory(wrapper); return factory.create(meta || DEFAULT_TEST_META); } diff --git a/packages/@glimmer/integration-tests/lib/modes/aot/delegate.ts b/packages/@glimmer/integration-tests/lib/modes/aot/delegate.ts index d632f761e4..fa8a448531 100644 --- a/packages/@glimmer/integration-tests/lib/modes/aot/delegate.ts +++ b/packages/@glimmer/integration-tests/lib/modes/aot/delegate.ts @@ -1,3 +1,4 @@ +import { PrecompileOptions } from '@glimmer/compiler'; import { BundleCompiler, DebugConstants, @@ -31,6 +32,7 @@ import { renderSync, AotRuntime, } from '@glimmer/runtime'; +import { ASTPluginBuilder } from '@glimmer/syntax'; import { assert, assign, expect, Option } from '@glimmer/util'; import { SimpleElement, @@ -99,6 +101,7 @@ export class AotRenderDelegate implements RenderDelegate { static readonly isEager = true; static style = 'aot'; + private plugins: ASTPluginBuilder[] = []; protected registry = new AotCompilerRegistry(); protected compileTimeModules = new Modules(); protected symbolTables = new ModuleLocatorMap(); @@ -134,6 +137,10 @@ export class AotRenderDelegate implements RenderDelegate { return this.doc.createDocumentFragment(); } + registerPlugin(plugin: ASTPluginBuilder): void { + this.plugins.push(plugin); + } + registerComponent( type: ComponentKind, testType: ComponentKind, @@ -182,7 +189,10 @@ export class AotRenderDelegate implements RenderDelegate { } registerPartial(name: string, source: string) { - let definition = new PartialDefinitionImpl(name, preprocess(source)); + let definition = new PartialDefinitionImpl( + name, + preprocess(source, undefined, this.precompileOptions) + ); this.registry.register(name, 'partial', { default: definition }); } @@ -192,6 +202,14 @@ export class AotRenderDelegate implements RenderDelegate { this.registry.register(name, 'modifier', { default: { manager, state } }); } + private get precompileOptions(): PrecompileOptions { + return { + plugins: { + ast: this.plugins, + }, + }; + } + private addRegisteredComponents(bundleCompiler: BundleCompiler): void { let { registry, compileTimeModules } = this; Object.keys(registry.components).forEach(key => { @@ -256,7 +274,7 @@ export class AotRenderDelegate implements RenderDelegate { } private getBundleCompiler(): BundleCompiler { - let { compiler, constants } = getBundleCompiler(this.registry); + let { compiler, constants } = getBundleCompiler(this.registry, this.plugins); this.constants = constants; return compiler; @@ -320,13 +338,15 @@ export class AotRenderDelegate implements RenderDelegate { } function getBundleCompiler( - registry: AotCompilerRegistry + registry: AotCompilerRegistry, + plugins?: ASTPluginBuilder[] ): { compiler: BundleCompiler; constants: DebugConstants } { let delegate: AotCompilerDelegate = new AotCompilerDelegate(registry); let constants = new DebugConstants(); let compiler = new BundleCompiler(delegate, { macros: new TestMacros(), constants, + plugins, }); return { constants, compiler }; } diff --git a/packages/@glimmer/integration-tests/lib/modes/jit/delegate.ts b/packages/@glimmer/integration-tests/lib/modes/jit/delegate.ts index 92e18f0c22..eda4a14221 100644 --- a/packages/@glimmer/integration-tests/lib/modes/jit/delegate.ts +++ b/packages/@glimmer/integration-tests/lib/modes/jit/delegate.ts @@ -1,3 +1,4 @@ +import { PrecompileOptions } from '@glimmer/compiler'; import { JitRuntimeContext, SyntaxCompilationContext, @@ -36,6 +37,7 @@ import { registerTemplate, componentHelper, } from './register'; +import { ASTPluginBuilder } from '@glimmer/syntax'; import { TestMacros } from '../../compile/macros'; import JitCompileTimeLookup from './compilation-context'; import TestJitRuntimeResolver from './resolver'; @@ -74,6 +76,7 @@ export class JitRenderDelegate implements RenderDelegate { static readonly isEager = false; static style = 'jit'; + private plugins: ASTPluginBuilder[] = []; private resolver: TestJitRuntimeResolver = new TestJitRuntimeResolver(); private registry: TestJitRegistry = this.resolver.registry; private context: JitTestDelegateContext; @@ -119,6 +122,10 @@ export class JitRenderDelegate implements RenderDelegate { return componentHelper(this.resolver, this.registry, name); } + registerPlugin(plugin: ASTPluginBuilder): void { + this.plugins.push(plugin); + } + registerComponent( type: K, _testType: L, @@ -200,7 +207,7 @@ export class JitRenderDelegate implements RenderDelegate { } compileTemplate(template: string): HandleResult { - let compiled = preprocess(template); + let compiled = preprocess(template, undefined, this.precompileOptions); return unwrapTemplate(compiled) .asLayout() @@ -214,9 +221,18 @@ export class JitRenderDelegate implements RenderDelegate { template, this.context, this.getSelf(context), - this.getElementBuilder(this.context.runtime.env, cursor) + this.getElementBuilder(this.context.runtime.env, cursor), + this.precompileOptions ); } + + private get precompileOptions(): PrecompileOptions { + return { + plugins: { + ast: this.plugins, + }, + }; + } } function isBrowserTestDocument(doc: SimpleDocument): doc is SimpleDocument & Document { diff --git a/packages/@glimmer/integration-tests/lib/modes/jit/render.ts b/packages/@glimmer/integration-tests/lib/modes/jit/render.ts index 7526d50125..edcaae9923 100644 --- a/packages/@glimmer/integration-tests/lib/modes/jit/render.ts +++ b/packages/@glimmer/integration-tests/lib/modes/jit/render.ts @@ -1,4 +1,5 @@ import { JitTestDelegateContext } from './delegate'; +import { PrecompileOptions } from '@glimmer/compiler'; import { VersionedPathReference } from '@glimmer/reference'; import { ElementBuilder, RenderResult } from '@glimmer/interfaces'; import { preprocess } from '../../compile'; @@ -9,9 +10,10 @@ export function renderTemplate( src: string, { runtime, syntax }: JitTestDelegateContext, self: VersionedPathReference, - builder: ElementBuilder + builder: ElementBuilder, + options?: PrecompileOptions ): RenderResult { - let template = preprocess(src); + let template = preprocess(src, undefined, options); let handle = unwrapTemplate(template) .asLayout() .compile(syntax); diff --git a/packages/@glimmer/integration-tests/lib/modes/rehydration/delegate.ts b/packages/@glimmer/integration-tests/lib/modes/rehydration/delegate.ts index 20b1deb67d..05832dbff6 100644 --- a/packages/@glimmer/integration-tests/lib/modes/rehydration/delegate.ts +++ b/packages/@glimmer/integration-tests/lib/modes/rehydration/delegate.ts @@ -1,3 +1,4 @@ +import { PrecompileOptions } from '@glimmer/compiler'; import { Cursor, Dict, @@ -8,6 +9,7 @@ import { Helper, } from '@glimmer/interfaces'; import { serializeBuilder } from '@glimmer/node'; +import { ASTPluginBuilder } from '@glimmer/syntax'; import createHTMLDocument from '@simple-dom/document'; import { SimpleDocument, @@ -44,6 +46,8 @@ export class RehydrationDelegate implements RenderDelegate { static readonly isEager = false; static readonly style = 'rehydration'; + private plugins: ASTPluginBuilder[] = []; + public clientEnv: JitTestDelegateContext; public serverEnv: JitTestDelegateContext; @@ -113,7 +117,8 @@ export class RehydrationDelegate implements RenderDelegate { template, this.serverEnv, this.getSelf(context), - this.getElementBuilder(this.serverEnv.runtime.env, cursor) + this.getElementBuilder(this.serverEnv.runtime.env, cursor), + this.precompileOptions ); takeSnapshot(); @@ -139,7 +144,13 @@ export class RehydrationDelegate implements RenderDelegate { // Client-side rehydration let cursor = { element, nextSibling: null }; let builder = this.getElementBuilder(env, cursor) as DebugRehydrationBuilder; - let result = renderTemplate(template, this.clientEnv, this.getSelf(context), builder); + let result = renderTemplate( + template, + this.clientEnv, + this.getSelf(context), + builder, + this.precompileOptions + ); this.rehydrationStats = { clearedNodes: builder['clearedNodes'], @@ -161,6 +172,10 @@ export class RehydrationDelegate implements RenderDelegate { return this.renderClientSide(template, context, element); } + registerPlugin(plugin: ASTPluginBuilder): void { + this.plugins.push(plugin); + } + registerComponent(type: ComponentKind, _testType: string, name: string, layout: string): void { registerComponent(this.clientRegistry, type, name, layout); registerComponent(this.serverRegistry, type, name, layout); @@ -185,6 +200,14 @@ export class RehydrationDelegate implements RenderDelegate { registerModifier(this.clientRegistry, name, ModifierClass); registerModifier(this.serverRegistry, name, ModifierClass); } + + private get precompileOptions(): PrecompileOptions { + return { + plugins: { + ast: this.plugins, + }, + }; + } } export function qunitFixture(): SimpleElement { diff --git a/packages/@glimmer/integration-tests/lib/render-delegate.ts b/packages/@glimmer/integration-tests/lib/render-delegate.ts index a6b96bad7c..c11e71317b 100644 --- a/packages/@glimmer/integration-tests/lib/render-delegate.ts +++ b/packages/@glimmer/integration-tests/lib/render-delegate.ts @@ -6,6 +6,7 @@ import { SimpleDocumentFragment, SimpleDocument, } from '@simple-dom/interface'; +import { ASTPluginBuilder } from '@glimmer/syntax'; import { ComponentKind, ComponentTypes } from './components'; import { UserHelper } from './helpers'; import { @@ -39,6 +40,7 @@ export default interface RenderDelegate { layout: string, Class?: ComponentTypes[K] ): void; + registerPlugin(plugin: ASTPluginBuilder): void; registerHelper(name: string, helper: UserHelper): void; registerInternalHelper(name: string, helper: Helper): void; registerPartial(name: string, content: string): void; diff --git a/packages/@glimmer/integration-tests/lib/render-test.ts b/packages/@glimmer/integration-tests/lib/render-test.ts index 2a5ebb1360..d08bffd4a2 100644 --- a/packages/@glimmer/integration-tests/lib/render-test.ts +++ b/packages/@glimmer/integration-tests/lib/render-test.ts @@ -1,4 +1,5 @@ import { Dict, Maybe, Option, RenderResult, Helper } from '@glimmer/interfaces'; +import { ASTPluginBuilder } from '@glimmer/syntax'; import { bump, isConst } from '@glimmer/validator'; import { clearElement, dict, expect, assign } from '@glimmer/util'; import { SimpleElement, SimpleNode } from '@simple-dom/interface'; @@ -54,19 +55,23 @@ export class RenderTest implements IRenderTest { this.element = delegate.getInitialElement(); } - registerHelper(name: string, helper: UserHelper) { + registerPlugin(plugin: ASTPluginBuilder): void { + this.delegate.registerPlugin(plugin); + } + + registerHelper(name: string, helper: UserHelper): void { this.delegate.registerHelper(name, helper); } - registerInternalHelper(name: string, helper: Helper) { + registerInternalHelper(name: string, helper: Helper): void { this.delegate.registerInternalHelper(name, helper); } - registerModifier(name: string, ModifierClass: TestModifierConstructor) { + registerModifier(name: string, ModifierClass: TestModifierConstructor): void { this.delegate.registerModifier(name, ModifierClass); } - registerPartial(name: string, content: string) { + registerPartial(name: string, content: string): void { this.delegate.registerPartial(name, content); } @@ -75,7 +80,7 @@ export class RenderTest implements IRenderTest { name: string, layout: string, Class?: ComponentTypes[K] - ) { + ): void { this.delegate.registerComponent(type, this.testType, name, layout, Class); } diff --git a/packages/@glimmer/integration-tests/package.json b/packages/@glimmer/integration-tests/package.json index 32747b3c6d..991a4cafa5 100644 --- a/packages/@glimmer/integration-tests/package.json +++ b/packages/@glimmer/integration-tests/package.json @@ -6,6 +6,7 @@ "dependencies": { "@glimmer/reference": "^0.52.0", "@glimmer/runtime": "^0.52.0", + "@glimmer/syntax": "^0.52.0", "@glimmer/validator": "^0.52.0", "@glimmer/compiler": "^0.52.0", "@glimmer/util": "^0.52.0", From 1b6a07441f3d903d2192df88db74b8361f512032 Mon Sep 17 00:00:00 2001 From: Krystan HuffMenne Date: Wed, 6 May 2020 14:34:26 -0700 Subject: [PATCH 2/2] [BUGFIX] Process in-element in compiler not parser This fixes the issue described in https://github.com/DockYard/ember-maybe-in-element/pull/25#issuecomment-622506443 tl;dr If an AST transform introduces the element, it doesn't go through the parser, so it's missing the required `guid` and `insertBefore` normalization. This commit moves the processing to a later stage to avoid this issue. --- .../compiler/lib/template-compiler.ts | 70 +++++++++++++++---- .../lib/suites/in-element.ts | 37 ++++++++++ .../opcode-compiler/lib/syntax/builtins.ts | 2 + .../lib/parser/handlebars-node-visitors.ts | 37 ---------- 4 files changed, 96 insertions(+), 50 deletions(-) diff --git a/packages/@glimmer/compiler/lib/template-compiler.ts b/packages/@glimmer/compiler/lib/template-compiler.ts index a7eccc8c30..5c86a787f4 100644 --- a/packages/@glimmer/compiler/lib/template-compiler.ts +++ b/packages/@glimmer/compiler/lib/template-compiler.ts @@ -18,8 +18,6 @@ function isTrustedValue(value: any) { return value.escaped !== undefined && !value.escaped; } -export const THIS = 0; - export default class TemplateCompiler implements Processor { static compile(ast: AST.Template, source: string, options?: CompileOptions): Template { let templateVisitor = new TemplateVisitor(); @@ -49,6 +47,12 @@ export default class TemplateCompiler implements Processor { private locations: Option[] = []; private includeMeta = true; + private cursorCount = 0; + + cursor() { + return `%cursor:${this.cursorCount++}%`; + } + process( actions: Action[] ): { opcodes: readonly Ops[]; locations: readonly Option[] } { @@ -62,6 +66,7 @@ export default class TemplateCompiler implements Processor { } startProgram([program]: [AST.Template]) { + this.cursorCount = 0; this.opcode(['startProgram', program], program); } @@ -242,7 +247,12 @@ export default class TemplateCompiler implements Processor { } block([action /*, index, count*/]: [AST.BlockStatement]) { - this.prepareHelper(action, 'block'); + if (isInElement(action)) { + this.prepareHelper(action, 'in-element'); + } else { + this.prepareHelper(action, 'block'); + } + let templateId = this.templateIds.pop()!; let inverseId = action.inverse === null ? null : this.templateIds.pop()!; this.expression(action.path, ExpressionContext.BlockHead, action); @@ -403,12 +413,12 @@ export default class TemplateCompiler implements Processor { this.opcode(['helper'], call); } - prepareHelper(expr: AST.Call, context: string) { + prepareHelper(expr: AST.Call, context: 'helper' | 'modifier' | 'block' | 'in-element') { assertIsSimplePath(expr.path, expr.loc, context); let { params, hash } = expr; - this.prepareHash(hash); + this.prepareHash(hash, context); this.prepareParams(params); } @@ -428,23 +438,51 @@ export default class TemplateCompiler implements Processor { this.opcode(['prepareArray', params.length], null); } - prepareHash(hash: AST.Hash) { + prepareHash(hash: AST.Hash, context: 'helper' | 'modifier' | 'block' | 'in-element') { let pairs = hash.pairs; + let length = pairs.length; - if (!pairs.length) { - this.opcode(['literal', null], null); - return; - } + let isInElement = context === 'in-element'; + let hasInsertBefore = false; - for (let i = pairs.length - 1; i >= 0; i--) { + for (let i = length - 1; i >= 0; i--) { let { key, value } = pairs[i]; + if (isInElement) { + if (key === 'guid') { + throw new SyntaxError( + `Cannot pass \`guid\` to \`{{#in-element}}\` on line ${value.loc.start.line}.`, + value.loc + ); + } + + if (key === 'insertBefore') { + hasInsertBefore = true; + } + } + assert(this[value.type], `Unimplemented ${value.type} on TemplateCompiler`); this[value.type](value as any); - this.opcode(['literal', key], null); + this.opcode(['literal', key]); } - this.opcode(['prepareObject', pairs.length], null); + if (isInElement) { + if (!hasInsertBefore) { + this.opcode(['literal', undefined]); + this.opcode(['literal', 'insertBefore']); + length++; + } + + this.opcode(['literal', this.cursor()]); + this.opcode(['literal', 'guid']); + length++; + } + + if (length === 0) { + this.opcode(['literal', null]); + } else { + this.opcode(['prepareObject', length]); + } } prepareAttributeValue(value: AST.AttrNode['value']): value is AST.TextNode { @@ -575,6 +613,12 @@ function isPath(node: AST.Node | AST.PathExpression): node is AST.PathExpression return node.type === 'PathExpression'; } +function isInElement( + node: AST.BlockStatement +): node is AST.BlockStatement & { path: AST.PathExpression } { + return isPath(node.path) && node.path.original === 'in-element'; +} + function destructureDynamicComponent(element: AST.ElementNode): Option { let open = element.tag.charAt(0); diff --git a/packages/@glimmer/integration-tests/lib/suites/in-element.ts b/packages/@glimmer/integration-tests/lib/suites/in-element.ts index f47f6da248..0daeb63640 100644 --- a/packages/@glimmer/integration-tests/lib/suites/in-element.ts +++ b/packages/@glimmer/integration-tests/lib/suites/in-element.ts @@ -1,3 +1,5 @@ +import { AST } from '@glimmer/syntax'; +import { assign } from '@glimmer/util'; import { RenderTest } from '../render-test'; import { test } from '../test-decorator'; import { equalsElement } from '../dom/assertions'; @@ -8,6 +10,41 @@ import { EmberishCurlyComponent } from '../components/emberish-curly'; export class InElementSuite extends RenderTest { static suiteName = '#in-element'; + @test + 'It works with AST transforms'() { + this.registerPlugin(env => ({ + name: 'maybe-in-element', + visitor: { + BlockStatement(node: AST.BlockStatement) { + let b = env.syntax.builders; + let { path, ...rest } = node; + if (path.type !== 'SubExpression' && path.original === 'maybe-in-element') { + return assign({ path: b.path('in-element', path.loc) }, rest); + } else { + return node; + } + }, + }, + })); + + let externalElement = this.delegate.createElement('div'); + this.render('{{#maybe-in-element externalElement}}[{{foo}}]{{/maybe-in-element}}', { + externalElement, + foo: 'Yippie!', + }); + + equalsElement(externalElement, 'div', {}, '[Yippie!]'); + this.assertStableRerender(); + + this.rerender({ foo: 'Double Yups!' }); + equalsElement(externalElement, 'div', {}, '[Double Yups!]'); + this.assertStableNodes(); + + this.rerender({ foo: 'Yippie!' }); + equalsElement(externalElement, 'div', {}, '[Yippie!]'); + this.assertStableNodes(); + } + @test 'Renders curlies into external element'() { let externalElement = this.delegate.createElement('div'); diff --git a/packages/@glimmer/opcode-compiler/lib/syntax/builtins.ts b/packages/@glimmer/opcode-compiler/lib/syntax/builtins.ts index 8a84c71ed0..30ccdaa648 100644 --- a/packages/@glimmer/opcode-compiler/lib/syntax/builtins.ts +++ b/packages/@glimmer/opcode-compiler/lib/syntax/builtins.ts @@ -167,6 +167,8 @@ export function populateBuiltins( return ReplayableIf({ args() { + assert(hash !== null, '[BUG] `{{#in-element}}` should have non-empty hash'); + let [keys, values] = hash!; let actions: StatementCompileActions = []; diff --git a/packages/@glimmer/syntax/lib/parser/handlebars-node-visitors.ts b/packages/@glimmer/syntax/lib/parser/handlebars-node-visitors.ts index b4f974366e..595d36b078 100644 --- a/packages/@glimmer/syntax/lib/parser/handlebars-node-visitors.ts +++ b/packages/@glimmer/syntax/lib/parser/handlebars-node-visitors.ts @@ -13,12 +13,6 @@ export abstract class HandlebarsNodeVisitors extends Parser { abstract beginAttributeValue(quoted: boolean): void; abstract finishAttributeValue(): void; - cursorCount = 0; - - cursor() { - return `%cursor:${this.cursorCount++}%`; - } - private get isTopLevel() { return this.elementStack.length === 0; } @@ -28,8 +22,6 @@ export abstract class HandlebarsNodeVisitors extends Parser { Program(program: HBS.Program): AST.Template | AST.Block; Program(program: HBS.Program): AST.Block | AST.Template { let body: AST.Statement[] = []; - this.cursorCount = 0; - let node; if (this.isTopLevel) { @@ -86,10 +78,6 @@ export abstract class HandlebarsNodeVisitors extends Parser { let program = this.Program(block.program); let inverse = block.inverse ? this.Program(block.inverse) : null; - if (path.original === 'in-element') { - hash = addInElementHash(this.cursor(), hash, block.loc); - } - let node = b.block( path, params, @@ -444,31 +432,6 @@ function addElementModifier(element: Tag<'StartTag'>, mustache: AST.MustacheStat element.modifiers.push(modifier); } -function addInElementHash(cursor: string, hash: AST.Hash, loc: AST.SourceLocation) { - let hasInsertBefore = false; - hash.pairs.forEach(pair => { - if (pair.key === 'guid') { - throw new SyntaxError('Cannot pass `guid` from user space', loc); - } - - if (pair.key === 'insertBefore') { - hasInsertBefore = true; - } - }); - - let guid = b.literal('StringLiteral', cursor); - let guidPair = b.pair('guid', guid); - hash.pairs.unshift(guidPair); - - if (!hasInsertBefore) { - let undefinedLiteral = b.literal('UndefinedLiteral', undefined); - let beforeSibling = b.pair('insertBefore', undefinedLiteral); - hash.pairs.push(beforeSibling); - } - - return hash; -} - function appendDynamicAttributeValuePart(attribute: Attribute, part: AST.MustacheStatement) { attribute.isDynamic = true; attribute.parts.push(part);